From c862cdb5602c8b99cda05e3183d255f55b053997 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 13:01:20 +0000 Subject: [PATCH 1/5] test(ios): replace fixed Task.yield() draining with condition waits (#61) `drainPendingTasks()` was 20 `Task.yield()` calls plus a 5ms sleep. `Task.yield()` is a scheduling hint, not a wait, so the ~60 sites that used it to "let the client catch up" raced two ways in a simulator: a simulated event (`simulateAck`, ...) could arrive before the `AppductClient` actor had reached `transport.connect`, in which case it is dropped as stray, and an assertion could read the synchronous `state`/`sessionId` snapshot before the actor had applied the event. TestSupport now offers `waitUntil(_:timeout:)`, which polls an observable condition (sync or async closure) with a short sleep and fails the test with a readable message -- never hangs -- on timeout, plus `allowQueuedWorkToRun()`, a deliberate bounded pause used only where a test asserts that *nothing* happens. Every drain site is replaced by a wait on the condition the test actually depends on: `transport.connectCallCount` reaching a value (a handshake is in flight, so a simulated ack will be picked up), a frame having reached `sentMessages`, the actor's `state`/`sessionId` reaching the expected value, or a collected listener event having arrived. `drainPendingTasks` is gone. Two negative assertions (the ignored late tool result, and a re-delivered deep link for the session already held) keep a bounded wait, now explicit and commented. No production code changed. Running the suite in a simulator in CI (and a test spec in AppductCore.podspec) remains a follow-up. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NxtF2u7HBiZLmduthmmfvn --- .../AppductCoreTests/AppductAPITests.swift | 56 +++++-- .../AppductCoreTests/AppductClientTests.swift | 154 ++++++++++++------ .../Tests/AppductCoreTests/TestSupport.swift | 59 +++++-- 3 files changed, 194 insertions(+), 75 deletions(-) diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift index 87c1c863..969e7f2d 100644 --- a/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift @@ -42,7 +42,9 @@ final class AppductAPITests: XCTestCase { let (facade, transport) = makeFacade() let connectTaskInput = connectInput() let connectTask = Task { try await facade.client.connect(connectTaskInput) } - await drainPendingTasks() + // A `session_ack` is only picked up once a handshake is actually in flight; the handshake + // calls `transport.connect` right after arming itself, so this counter is that signal. + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value return (facade, transport) @@ -109,7 +111,9 @@ final class AppductAPITests: XCTestCase { } transport.simulateIncoming(toolCallText(id: "call-1", name: "echo", args: ["value": "hi"])) - await drainPendingTasks() + try await waitUntil("the tool result reached the wire") { + transport.sentMessages.contains { $0.contains("tool_result") } + } let response = try XCTUnwrap(transport.sentMessages.first { $0.contains("tool_result") }) XCTAssertTrue(response.contains("\"value\":\"hi!\"")) @@ -123,7 +127,9 @@ final class AppductAPITests: XCTestCase { } transport.simulateIncoming(toolCallText(id: "call-2", name: "with_context")) - await drainPendingTasks() + try await waitUntil("the tool result reached the wire") { + transport.sentMessages.contains { $0.contains("tool_result") } + } let response = try XCTUnwrap(transport.sentMessages.first { $0.contains("tool_result") }) XCTAssertTrue(response.contains("with_context")) @@ -135,7 +141,9 @@ final class AppductAPITests: XCTestCase { try facade.register(name: "bad_result", description: "x") { _ in Date() } transport.simulateIncoming(toolCallText(id: "call-3", name: "bad_result")) - await drainPendingTasks() + try await waitUntil("the tool error reached the wire") { + transport.sentMessages.contains { $0.contains("tool_error") } + } let response = try XCTUnwrap(transport.sentMessages.first { $0.contains("tool_error") }) XCTAssertTrue(response.contains("tool_serialization_error")) @@ -152,9 +160,9 @@ final class AppductAPITests: XCTestCase { let (facade, transport) = makeFacade() XCTAssertTrue(facade.handle(bootstrapUrl(sessionId: "session-9"))) - await drainPendingTasks() + try await waitUntil("the deep link reached the transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-9") - await drainPendingTasks() + try await waitUntil("the facade snapshot turned active") { facade.state == .active } XCTAssertEqual(facade.state, .active) XCTAssertEqual(facade.sessionId, "session-9") @@ -166,10 +174,14 @@ final class AppductAPITests: XCTestCase { let (facade, transport) = try await activeFacade() let registration = try facade.register(name: "removable", description: "x") { _ in nil } - await drainPendingTasks() + try await waitUntil("the upsert delta was sent") { + transport.sentMessages.contains { $0.contains("tool_registry_delta") && $0.contains("upsert") } + } registration.remove() - await drainPendingTasks() + try await waitUntil("the remove delta was sent") { + transport.sentMessages.contains { $0.contains("tool_registry_delta") && $0.contains("remove") } + } let delta = transport.sentMessages.first { $0.contains("tool_registry_delta") && $0.contains("remove") } XCTAssertNotNil(delta) @@ -183,14 +195,23 @@ final class AppductAPITests: XCTestCase { let events = EventCollector() let subscription = facade.addListener { events.append($0) } - await drainPendingTasks() + // `addListener` registers with the actor asynchronously, and it registers the error channel + // last -- so once that one is in place, all three are. + try await waitUntil("the facade's listeners were registered on the client") { + await facade.client.errorListeners.count >= 1 + } let connectTaskInput = connectInput() let connectTask = Task { try await facade.client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value - await drainPendingTasks() + try await waitUntil("the listener saw the active state change") { + events.all.contains { + if case .stateChange(let event) = $0 { return event.state == .active } + return false + } + } let states: [AppductClientState] = events.all.compactMap { if case .stateChange(let event) = $0 { return event.state } @@ -207,14 +228,21 @@ final class AppductAPITests: XCTestCase { let events = EventCollector() _ = facade.addListener { events.append($0) } - await drainPendingTasks() + try await waitUntil("the facade's listeners were registered on the client") { + await facade.client.errorListeners.count >= 1 + } let connectTaskInput = connectInput() let connectTask = Task { try await facade.client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", alias: "iphone-1") try await connectTask.value - await drainPendingTasks() + try await waitUntil("the listener saw the session change") { + events.all.contains { + if case .sessionChange(let event) = $0 { return event.sessionId == "session-1" } + return false + } + } let sessionIds: [String?] = events.all.compactMap { if case .sessionChange(let event) = $0 { return event.sessionId } diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductClientTests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductClientTests.swift index 329d3c1d..e2a7ee34 100644 --- a/packages/native/ios/Tests/AppductCoreTests/AppductClientTests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/AppductClientTests.swift @@ -45,7 +45,7 @@ final class AppductClientTests: XCTestCase { let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", alias: "iphone-1") try await connectTask.value @@ -66,10 +66,12 @@ final class AppductClientTests: XCTestCase { let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value - await drainPendingTasks() + try await waitUntil("the registry snapshot reached the wire") { + transport.sentMessages.contains { $0.contains("tool_registry_snapshot") } + } let snapshotMessage = transport.sentMessages.first { $0.contains("tool_registry_snapshot") } let snapshot = try XCTUnwrap(snapshotMessage) @@ -94,7 +96,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -110,13 +112,15 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let firstConnectInput = connectInput(sessionId: "session-1") let firstConnect = Task { try await client.connect(firstConnectInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await firstConnect.value let secondConnectInput = connectInput(sessionId: "session-2") let secondConnect = Task { try await client.connect(secondConnectInput, supersede: true) } - await drainPendingTasks() + try await waitUntil("the superseding connect started its own transport handshake") { + transport.connectCallCount >= 2 + } transport.simulateAck(sessionId: "session-2") try await secondConnect.value @@ -131,7 +135,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -157,12 +161,14 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", graceS: 120) try await connectTask.value transport.simulateClose(code: 1_006, reason: nil) - await drainPendingTasks() + try await waitUntil("the client moved to reconnecting after the socket closed") { + await client.state == .reconnecting + } let state = await client.state XCTAssertEqual(state, .reconnecting) @@ -174,7 +180,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", resumeToken: "resume-1", graceS: 120) try await connectTask.value @@ -182,15 +188,21 @@ final class AppductClientTests: XCTestCase { _ = await client.onSessionChange { event in sessionChanges.append(event) } transport.simulateClose(code: 1_006, reason: nil) - await drainPendingTasks() + try await waitUntil("the client moved to reconnecting after the socket closed") { + await client.state == .reconnecting + } let stateAfterClose = await client.state XCTAssertEqual(stateAfterClose, .reconnecting) // Fire the scheduled reconnect timer; the resume attempt re-simulates an ack. timers.advance(byMs: AppductBackoff.capMs) - await drainPendingTasks() + try await waitUntil("the resume attempt started a second transport handshake") { + transport.connectCallCount >= 2 + } transport.simulateAck(sessionId: "session-1", resumeToken: "resume-2", graceS: 120) - await drainPendingTasks() + try await waitUntil("the client went active again after the resume ack") { + await client.state == .active + } let stateAfterResume = await client.state XCTAssertEqual(stateAfterResume, .active) @@ -204,7 +216,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", graceS: 10) try await connectTask.value @@ -212,12 +224,16 @@ final class AppductClientTests: XCTestCase { _ = await client.onSessionChange { event in sessionChanges.append(event) } transport.simulateClose(code: 1_006, reason: nil) - await drainPendingTasks() + try await waitUntil("the client moved to reconnecting after the socket closed") { + await client.state == .reconnecting + } let stateAfterClose = await client.state XCTAssertEqual(stateAfterClose, .reconnecting) timers.advance(byMs: 10_000) - await drainPendingTasks() + try await waitUntil("the grace window expired and closed the session") { + await client.state == .closed + } let stateAfterGraceExpiry = await client.state XCTAssertEqual(stateAfterGraceExpiry, .closed) @@ -232,7 +248,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", graceS: 120) try await connectTask.value @@ -240,7 +256,9 @@ final class AppductClientTests: XCTestCase { _ = await client.onSessionChange { event in sessionChanges.append(event) } transport.simulateClose(code: 1_008, reason: "unknown_session") - await drainPendingTasks() + try await waitUntil("the terminal close finalized the session") { + await client.state == .closed + } let state = await client.state XCTAssertEqual(state, .closed) @@ -254,7 +272,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", graceS: 120) try await connectTask.value @@ -262,7 +280,9 @@ final class AppductClientTests: XCTestCase { _ = await client.onSessionChange { event in sessionChanges.append(event) } transport.simulateClose(code: 1_000, reason: nil) - await drainPendingTasks() + try await waitUntil("the revoked close finalized the session") { + await client.state == .closed + } let state = await client.state XCTAssertEqual(state, .closed) @@ -276,12 +296,14 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value try client.registerTool(ToolDescriptor(name: "new_tool", description: "x"), handler: { _, _ in .null }) - await drainPendingTasks() + try await waitUntil("the upsert delta reached the wire") { + transport.sentMessages.contains { $0.contains("tool_registry_delta") && $0.contains("upsert") } + } let delta = transport.sentMessages.first { $0.contains("tool_registry_delta") && $0.contains("upsert") } XCTAssertNotNil(delta) @@ -292,12 +314,14 @@ final class AppductClientTests: XCTestCase { try client.registerTool(ToolDescriptor(name: "tool_a", description: "x"), handler: { _, _ in .null }) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value client.unregisterTool("tool_a") - await drainPendingTasks() + try await waitUntil("the remove delta reached the wire") { + transport.sentMessages.contains { $0.contains("tool_registry_delta") && $0.contains("remove") } + } let delta = transport.sentMessages.first { $0.contains("tool_registry_delta") && $0.contains("remove") } XCTAssertNotNil(delta) @@ -329,7 +353,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value return (client, transport) @@ -340,7 +364,9 @@ final class AppductClientTests: XCTestCase { _ = client transport.simulateIncoming(toolCallText(id: "call-1", name: "missing_tool")) - await drainPendingTasks() + try await waitUntil("a tool error reached the wire") { + transport.sentMessages.contains { $0.contains("tool_error") } + } let response = transport.sentMessages.first { $0.contains("tool_error") } let response2 = try XCTUnwrap(response) @@ -354,7 +380,9 @@ final class AppductClientTests: XCTestCase { } transport.simulateIncoming(toolCallText(id: "call-1", name: "echo", args: ["value": "hi"])) - await drainPendingTasks() + try await waitUntil("the tool result reached the wire") { + transport.sentMessages.contains { $0.contains("tool_result") } + } let response = try XCTUnwrap(transport.sentMessages.first { $0.contains("tool_result") }) XCTAssertTrue(response.contains("\"value\":\"hi\"")) @@ -366,7 +394,9 @@ final class AppductClientTests: XCTestCase { try client.registerTool(ToolDescriptor(name: "boom", description: "x")) { _, _ in throw Boom() } transport.simulateIncoming(toolCallText(id: "call-1", name: "boom")) - await drainPendingTasks() + try await waitUntil("a tool error reached the wire") { + transport.sentMessages.contains { $0.contains("tool_error") } + } let response = try XCTUnwrap(transport.sentMessages.first { $0.contains("tool_error") }) XCTAssertTrue(response.contains("tool_execution_error")) @@ -379,7 +409,9 @@ final class AppductClientTests: XCTestCase { } transport.simulateIncoming(toolCallText(id: "call-1", name: "bad_input")) - await drainPendingTasks() + try await waitUntil("a tool error reached the wire") { + transport.sentMessages.contains { $0.contains("tool_error") } + } let response = try XCTUnwrap(transport.sentMessages.first { $0.contains("tool_error") }) XCTAssertTrue(response.contains("tool_input_validation_error")) @@ -401,7 +433,9 @@ final class AppductClientTests: XCTestCase { await fulfillment(of: [handlerStarted], timeout: 2) transport.simulateIncoming(toolCancelText(id: "call-1")) - await drainPendingTasks(iterations: 40) + try await waitUntil("the cancelled tool answered with an error frame") { + transport.sentMessages.contains { $0.contains("tool_error") } + } let response = try XCTUnwrap(transport.sentMessages.first { $0.contains("tool_error") }) XCTAssertTrue(response.contains("tool_cancelled")) @@ -412,7 +446,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -434,11 +468,16 @@ final class AppductClientTests: XCTestCase { await fulfillment(of: [handlerStarted], timeout: 2) timers.advance(byMs: 1_000) - await drainPendingTasks() + try await waitUntil("the timeout answered the call") { + transport.sentMessages.contains { $0.contains("tool_timeout") } + } gate.open() await fulfillment(of: [handlerFinished], timeout: 2) - await drainPendingTasks() + // Negative assertion below ("the late result is dropped"): there is no frame to wait for, so + // this is a deliberate bounded pause giving the late resolution every chance to (wrongly) + // reach the wire before the assertions run. + await allowQueuedWorkToRun() let toolErrorMessages = transport.sentMessages.filter { $0.contains("tool_error") } XCTAssertEqual(toolErrorMessages.count, 1) @@ -454,7 +493,9 @@ final class AppductClientTests: XCTestCase { } transport.simulateIncoming(toolCallText(id: "call-1", name: "nan_tool")) - await drainPendingTasks() + try await waitUntil("a tool error reached the wire") { + transport.sentMessages.contains { $0.contains("tool_error") } + } let response = try XCTUnwrap(transport.sentMessages.first { $0.contains("tool_error") }) XCTAssertTrue(response.contains("tool_serialization_error")) @@ -471,7 +512,9 @@ final class AppductClientTests: XCTestCase { transport.simulateIncoming(toolCallText(id: "call-1", name: "progressive")) await fulfillment(of: [progressSent], timeout: 2) - await drainPendingTasks() + try await waitUntil("the progress frame reached the wire") { + transport.sentMessages.contains { $0.contains("tool_call_progress") } + } let progressMessage = transport.sentMessages.first { $0.contains("tool_call_progress") } XCTAssertNotNil(progressMessage) @@ -482,7 +525,9 @@ final class AppductClientTests: XCTestCase { func testPostEventSendsEventFrameWhileActive() async throws { let (client, transport) = try await activeClient() try await client.postEvent("greeting", payload: .string("hi")) - await drainPendingTasks() + try await waitUntil("the event frame reached the wire") { + transport.sentMessages.contains { $0.contains("\"type\":\"event\"") } + } let event = try XCTUnwrap(transport.sentMessages.first { $0.contains("\"type\":\"event\"") }) XCTAssertTrue(event.contains("greeting")) @@ -528,21 +573,23 @@ final class AppductClientTests: XCTestCase { func testHandleUrlReturnsTrueAndConnectsForValidLink() async throws { let (client, transport) = makeClient() XCTAssertTrue(client.handleUrl(bootstrapUrl(sessionId: "session-9"))) - await drainPendingTasks() + try await waitUntil("the deep link reached the transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-9") - await drainPendingTasks() + try await waitUntil("the client went active after the ack") { await client.state == .active } let state = await client.state XCTAssertEqual(state, .active) } - func testHandleUrlEmitsBootstrapErrorForMalformedPayload() async { + func testHandleUrlEmitsBootstrapErrorForMalformedPayload() async throws { let (client, _) = makeClient() let errors = EventCollector() _ = await client.onError { errors.append($0) } XCTAssertTrue(client.handleUrl("myapp://open?appduct=not-valid-base64url!!")) - await drainPendingTasks() + try await waitUntil("the bootstrap parse failure was reported to the error listener") { + errors.first != nil + } XCTAssertEqual(errors.first?.phase, "bootstrap") } @@ -551,14 +598,22 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput(sessionId: "session-1") let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value XCTAssertTrue(client.handleUrl(bootstrapUrl(sessionId: "session-2"))) - await drainPendingTasks() + try await waitUntil("the superseding link started a second transport handshake") { + transport.connectCallCount >= 2 + } transport.simulateAck(sessionId: "session-2") - await drainPendingTasks() + // `sessionId` alone would be satisfied by `connectingSessionId` the moment the superseding + // connect starts, so this waits for the ack to have actually been applied. + try await waitUntil("the client holds the superseding session") { + let state = await client.state + let sessionId = await client.sessionId + return state == .active && sessionId == "session-2" + } let sessionId = await client.sessionId XCTAssertEqual(sessionId, "session-2") @@ -568,13 +623,16 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput(sessionId: "session-1") let connectTask = Task { try await client.connect(connectTaskInput) } - await drainPendingTasks() + try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value let countBefore = transport.connectCallCount XCTAssertTrue(client.handleUrl(bootstrapUrl(sessionId: "session-1"))) - await drainPendingTasks() + // Negative assertion: a re-delivered link for the session already held must *not* reconnect, + // so there is no condition to wait for -- a deliberate bounded pause gives the deep-link task + // every chance to (wrongly) reach `transport.connect` before the count is re-read. + await allowQueuedWorkToRun() XCTAssertEqual(transport.connectCallCount, countBefore) } @@ -587,7 +645,7 @@ final class AppductClientTests: XCTestCase { XCTAssertFalse(restored) } - func testRestoreSessionStartsResumeFromAValidLease() async { + func testRestoreSessionStartsResumeFromAValidLease() async throws { let ownerGeneration = AppductProcessResumeLeaseStore.shared.newOwnerGeneration() AppductProcessResumeLeaseStore.shared.replace( ownerGeneration: ownerGeneration, @@ -606,9 +664,9 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let restored = await client.restoreSession() XCTAssertTrue(restored) - await drainPendingTasks() + try await waitUntil("the resume attempt started a transport handshake") { transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-restored", resumeToken: "resume-token-2") - await drainPendingTasks() + try await waitUntil("the client went active after the resume ack") { await client.state == .active } let state = await client.state XCTAssertEqual(state, .active) diff --git a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift index 8ea8633e..c1771731 100644 --- a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift +++ b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift @@ -5,6 +5,7 @@ // front of. import Foundation +import XCTest @testable import AppductCore /// Scripted fake standing in for `AppductConnectionManager` in `AppductClient` tests, so the @@ -160,8 +161,9 @@ final class FakeClientTimers: AppductClientTimers, @unchecked Sendable { } /// Advances the virtual clock by `deltaMs` and fires every timer now due. Handlers themselves - /// typically just kick off a `Task` to hop back onto the actor -- call `Probe.drain()` - /// afterwards to let that queued work actually run. + /// typically just kick off a `Task` to hop back onto the actor, so a test must `waitUntil` the + /// observable effect of that queued work (a new `connectCallCount`, a state change, a frame on + /// the wire) rather than assert immediately after this returns. func advance(byMs deltaMs: Double) { lock.lock() currentTimeMs += deltaMs @@ -177,17 +179,48 @@ final class FakeClientTimers: AppductClientTimers, @unchecked Sendable { } } -/// Test-only helper: yields repeatedly so `Task { await ... }` work queued by a fake timer firing -/// (or by any other fire-and-forget hop onto the `AppductClient` actor) has a chance to run -/// before the test asserts on the result. -func drainPendingTasks(iterations: Int = 20) async { - for _ in 0.. Bool +) async throws { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if await condition() { return } + try? await Task.sleep(nanoseconds: pollIntervalMs * 1_000_000) + } while Date() < deadline + if await condition() { return } + + let message = "Timed out after \(timeout)s waiting for: \(what)" + XCTFail(message, file: file, line: line) + throw WaitTimedOutError(description: message) +} + +/// Bounded wait used *only* for negative assertions ("and then nothing else happens"), where +/// there is by definition no condition to poll for: gives whatever work is queued on the client +/// actor a real chance to run, so the follow-up assertion is meaningful rather than merely early. +func allowQueuedWorkToRun(ms: UInt64 = 150) async { + try? await Task.sleep(nanoseconds: ms * 1_000_000) } /// A one-shot async gate a test can hold open until it is ready for a handler to proceed -- From 53e171de5419b9c96f2a8bc656331f9c2a952193 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 08:12:31 +0200 Subject: [PATCH 2/5] test(ios): wait for transport wiring before simulating a session_ack AppductClient.init installs its transport callbacks from a queued Task that is not ordered against a connect the test starts right after, so transport.connect can be reached before emitMessageRaw exists and the simulated ack is dropped, leaving connectTask.value hanging. Guard the fake's callbacks with its lock, expose isWired, include it in every handshake wait, and fail loudly when a simulated frame has nowhere to go. --- .../AppductCoreTests/AppductAPITests.swift | 11 ++-- .../AppductCoreTests/AppductClientTests.swift | 42 +++++++-------- .../Tests/AppductCoreTests/TestSupport.swift | 53 ++++++++++++++++--- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift index 969e7f2d..8134b44a 100644 --- a/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift @@ -43,8 +43,9 @@ final class AppductAPITests: XCTestCase { let connectTaskInput = connectInput() let connectTask = Task { try await facade.client.connect(connectTaskInput) } // A `session_ack` is only picked up once a handshake is actually in flight; the handshake - // calls `transport.connect` right after arming itself, so this counter is that signal. - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + // calls `transport.connect` right after arming itself, so this counter is that signal. The + // client must also have wired its transport callbacks, or the ack has nowhere to go. + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value return (facade, transport) @@ -160,7 +161,7 @@ final class AppductAPITests: XCTestCase { let (facade, transport) = makeFacade() XCTAssertTrue(facade.handle(bootstrapUrl(sessionId: "session-9"))) - try await waitUntil("the deep link reached the transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the deep link reached the transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-9") try await waitUntil("the facade snapshot turned active") { facade.state == .active } @@ -203,7 +204,7 @@ final class AppductAPITests: XCTestCase { let connectTaskInput = connectInput() let connectTask = Task { try await facade.client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value try await waitUntil("the listener saw the active state change") { @@ -234,7 +235,7 @@ final class AppductAPITests: XCTestCase { let connectTaskInput = connectInput() let connectTask = Task { try await facade.client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", alias: "iphone-1") try await connectTask.value try await waitUntil("the listener saw the session change") { diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductClientTests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductClientTests.swift index e2a7ee34..38230236 100644 --- a/packages/native/ios/Tests/AppductCoreTests/AppductClientTests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/AppductClientTests.swift @@ -45,7 +45,7 @@ final class AppductClientTests: XCTestCase { let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", alias: "iphone-1") try await connectTask.value @@ -66,7 +66,7 @@ final class AppductClientTests: XCTestCase { let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value try await waitUntil("the registry snapshot reached the wire") { @@ -96,7 +96,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -112,14 +112,14 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let firstConnectInput = connectInput(sessionId: "session-1") let firstConnect = Task { try await client.connect(firstConnectInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await firstConnect.value let secondConnectInput = connectInput(sessionId: "session-2") let secondConnect = Task { try await client.connect(secondConnectInput, supersede: true) } try await waitUntil("the superseding connect started its own transport handshake") { - transport.connectCallCount >= 2 + transport.isWired && transport.connectCallCount >= 2 } transport.simulateAck(sessionId: "session-2") try await secondConnect.value @@ -135,7 +135,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -161,7 +161,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", graceS: 120) try await connectTask.value @@ -180,7 +180,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", resumeToken: "resume-1", graceS: 120) try await connectTask.value @@ -197,7 +197,7 @@ final class AppductClientTests: XCTestCase { // Fire the scheduled reconnect timer; the resume attempt re-simulates an ack. timers.advance(byMs: AppductBackoff.capMs) try await waitUntil("the resume attempt started a second transport handshake") { - transport.connectCallCount >= 2 + transport.isWired && transport.connectCallCount >= 2 } transport.simulateAck(sessionId: "session-1", resumeToken: "resume-2", graceS: 120) try await waitUntil("the client went active again after the resume ack") { @@ -216,7 +216,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", graceS: 10) try await connectTask.value @@ -248,7 +248,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", graceS: 120) try await connectTask.value @@ -272,7 +272,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1", graceS: 120) try await connectTask.value @@ -296,7 +296,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -314,7 +314,7 @@ final class AppductClientTests: XCTestCase { try client.registerTool(ToolDescriptor(name: "tool_a", description: "x"), handler: { _, _ in .null }) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -353,7 +353,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value return (client, transport) @@ -446,7 +446,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let connectTaskInput = connectInput() let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -573,7 +573,7 @@ final class AppductClientTests: XCTestCase { func testHandleUrlReturnsTrueAndConnectsForValidLink() async throws { let (client, transport) = makeClient() XCTAssertTrue(client.handleUrl(bootstrapUrl(sessionId: "session-9"))) - try await waitUntil("the deep link reached the transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the deep link reached the transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-9") try await waitUntil("the client went active after the ack") { await client.state == .active } @@ -598,13 +598,13 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput(sessionId: "session-1") let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value XCTAssertTrue(client.handleUrl(bootstrapUrl(sessionId: "session-2"))) try await waitUntil("the superseding link started a second transport handshake") { - transport.connectCallCount >= 2 + transport.isWired && transport.connectCallCount >= 2 } transport.simulateAck(sessionId: "session-2") // `sessionId` alone would be satisfied by `connectingSessionId` the moment the superseding @@ -623,7 +623,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient() let connectTaskInput = connectInput(sessionId: "session-1") let connectTask = Task { try await client.connect(connectTaskInput) } - try await waitUntil("the client started its transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-1") try await connectTask.value @@ -664,7 +664,7 @@ final class AppductClientTests: XCTestCase { let (client, transport) = makeClient(timers: timers) let restored = await client.restoreSession() XCTAssertTrue(restored) - try await waitUntil("the resume attempt started a transport handshake") { transport.connectCallCount >= 1 } + try await waitUntil("the resume attempt started a transport handshake") { transport.isWired && transport.connectCallCount >= 1 } transport.simulateAck(sessionId: "session-restored", resumeToken: "resume-token-2") try await waitUntil("the client went active after the resume ack") { await client.state == .active } diff --git a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift index c1771731..f2ccaa02 100644 --- a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift +++ b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift @@ -11,10 +11,41 @@ import XCTest /// Scripted fake standing in for `AppductConnectionManager` in `AppductClient` tests, so the /// reconnect/registry/tool-invocation state machine is testable without a real TLS/WebSocket stack. final class FakeTransportSession: AppductTransportSession, @unchecked Sendable { - var emitStateChange: (@Sendable (String) -> Void)? - var emitMessageRaw: (@Sendable (String) -> Void)? - var emitError: (@Sendable (AppductErrorDetails) -> Void)? - var emitClose: (@Sendable (NSDictionary) -> Void)? + // The client assigns these from its own actor (in a `Task` its initializer queues) while tests + // read them from the test's thread, so they sit behind the same lock as the counters below. + private var _emitStateChange: (@Sendable (String) -> Void)? + private var _emitMessageRaw: (@Sendable (String) -> Void)? + private var _emitError: (@Sendable (AppductErrorDetails) -> Void)? + private var _emitClose: (@Sendable (NSDictionary) -> Void)? + + var emitStateChange: (@Sendable (String) -> Void)? { + get { withLock { _emitStateChange } } + set { withLock { _emitStateChange = newValue } } + } + + var emitMessageRaw: (@Sendable (String) -> Void)? { + get { withLock { _emitMessageRaw } } + set { withLock { _emitMessageRaw = newValue } } + } + + var emitError: (@Sendable (AppductErrorDetails) -> Void)? { + get { withLock { _emitError } } + set { withLock { _emitError = newValue } } + } + + var emitClose: (@Sendable (NSDictionary) -> Void)? { + get { withLock { _emitClose } } + set { withLock { _emitClose = newValue } } + } + + /// Whether the client has installed its transport callbacks yet. `AppductClient.init` defers + /// that wiring to a `Task`, which is not ordered against a `connect` the test starts right + /// after construction: `transport.connect` can be reached before the callbacks exist, and a + /// `simulateAck` sent then goes nowhere. Handshake waits therefore check this *and* + /// `connectCallCount`. + var isWired: Bool { + withLock { _emitMessageRaw != nil && _emitClose != nil } + } private let lock = NSLock() private var _stateSnapshot = "idle" @@ -86,8 +117,12 @@ final class FakeTransportSession: AppductTransportSession, @unchecked Sendable { // MARK: Test-side simulation helpers - func simulateIncoming(_ text: String) { - emitMessageRaw?(text) + func simulateIncoming(_ text: String, file: StaticString = #filePath, line: UInt = #line) { + guard let emitMessageRaw else { + XCTFail("simulated a frame before the client wired its transport callbacks; it was dropped", file: file, line: line) + return + } + emitMessageRaw(text) } func simulateAck( @@ -116,7 +151,11 @@ final class FakeTransportSession: AppductTransportSession, @unchecked Sendable { let dict = NSMutableDictionary() if let code { dict["code"] = code } if let reason { dict["reason"] = reason } - emitClose?(dict) + guard let emitClose else { + XCTFail("simulated a close before the client wired its transport callbacks; it was dropped") + return + } + emitClose(dict) } } From c0ab66d8f51d16ca51d06c0606d32773eb0ec866 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 08:12:44 +0200 Subject: [PATCH 3/5] test(ios): make Gate latch an open() that precedes wait() The timeout test's handler fulfils handlerStarted and only then reaches gate.wait(); an open() issued in that window was lost, leaving the handler parked and handlerFinished to time out. --- .../ios/Tests/AppductCoreTests/TestSupport.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift index f2ccaa02..ca9740c3 100644 --- a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift +++ b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift @@ -266,13 +266,22 @@ func allowQueuedWorkToRun(ms: UInt64 = 150) async { /// `withCheckedContinuation` (the non-throwing variant) deliberately ignores `Task` cancellation, so /// a handler `await`ing one keeps waiting even after its enclosing call has been cancelled/timed /// out, letting a test simulate "the handler resolves late, after the timeout already answered". +/// +/// Latching: `open()` before `wait()` is remembered, so a handler that signals "started" and only +/// then reaches `wait()` cannot miss an `open()` the test issued in between. final class Gate: @unchecked Sendable { private let lock = NSLock() private var continuation: CheckedContinuation? + private var isOpen = false func wait() async { await withCheckedContinuation { continuation in lock.lock() + if isOpen { + lock.unlock() + continuation.resume() + return + } self.continuation = continuation lock.unlock() } @@ -280,6 +289,7 @@ final class Gate: @unchecked Sendable { func open() { lock.lock() + isOpen = true let continuation = self.continuation self.continuation = nil lock.unlock() From 60483d72912c5b022acaf4411aa2f4434f4d26bf Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 08:13:23 +0200 Subject: [PATCH 4/5] test(ios): replace the Task.yield drain left in AppductConnectionManagerTests testCloseFromIdleEmitsExactlyOneCloseEventAndReportsClosed read its counter after a single Task.yield() and failed on iteration 29 of 50 in an iOS simulator. Wait for the first close event, then pause before the exactly-one assertion. The stale-close lease test's 10 ms sleep becomes the shared bounded pause for negative assertions. --- .../AppductConnectionManagerTests.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductConnectionManagerTests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductConnectionManagerTests.swift index 712968ad..308db9d0 100644 --- a/packages/native/ios/Tests/AppductCoreTests/AppductConnectionManagerTests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/AppductConnectionManagerTests.swift @@ -379,7 +379,9 @@ final class AppductConnectionManagerTests: XCTestCase { didCloseWith: .normalClosure, reason: nil ) - try? await Task.sleep(nanoseconds: 10_000_000) + // Negative assertion: nothing to wait for, so a bounded pause gives any queued (wrong) lease + // clear every chance to land first. + await allowQueuedWorkToRun() XCTAssertEqual(AppductProcessResumeLeaseStore.shared.get()?.resumeToken, "resume-token-new") XCTAssertNil(AppductProcessResumeLeaseStore.shared.get()?.disconnectedAtMs) @@ -445,7 +447,7 @@ final class AppductConnectionManagerTests: XCTestCase { XCTAssertEqual(manager.currentStateSnapshot(), "closed") } - func testCloseFromIdleEmitsExactlyOneCloseEventAndReportsClosed() async { + func testCloseFromIdleEmitsExactlyOneCloseEventAndReportsClosed() async throws { let manager = AppductConnectionManager() let closeEvents = ClosedEventCounter() manager.emitClose = { _ in @@ -454,8 +456,10 @@ final class AppductConnectionManagerTests: XCTestCase { await manager.close() - // Give the fire-and-forget increment a turn to run. - await Task.yield() + // The increment is fire-and-forget: wait for the first one, then give a (wrong) second one a + // bounded chance to land before asserting "exactly one". + try await waitUntil("the close event reached the listener") { await closeEvents.count >= 1 } + await allowQueuedWorkToRun() let count = await closeEvents.count XCTAssertEqual(count, 1) XCTAssertEqual(manager.currentStateSnapshot(), "closed") From f12263e131b8d36b66d06dea83b717aec5b81e53 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 08:18:50 +0200 Subject: [PATCH 5/5] ci(ios): run the AppductCore suite in an iOS simulator with repeated iterations Issue #61 asks for CI to run the suite on an iOS simulator so a new race fails on the PR that introduces it. Add a step to the ios job that picks any available iPhone simulator and runs the tests 20 times, stopping at the first failure. --- .github/workflows/test.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1c5a1bac..c7696e99 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -325,6 +325,19 @@ jobs: -derivedDataPath "$RUNNER_TEMP/appductcore-ios" \ SWIFT_TREAT_WARNINGS_AS_ERRORS=YES \ build + - name: Test AppductCore in an iOS Simulator (repeated) + # `swift test` above runs on macOS, where the suite's timing is forgiving. The iOS + # simulator is where scheduling races in these tests actually surfaced (issue #61), and it + # is what `pod spec lint`/`pod trunk push` run a test spec on, so the suite runs there + # too, repeated, to make a newly introduced race fail on the PR that adds it. Any + # available iPhone simulator on the runner image will do; the name is not pinned because + # the preinstalled set changes with each image update. + run: | + udid=$(xcrun simctl list devices available -j | jq -r '[.devices | to_entries[] | select(.key | test("SimRuntime\\.iOS")) | .value[] | select(.name | startswith("iPhone"))] | last | .udid') + test -n "$udid" && test "$udid" != null + xcodebuild test -scheme AppductCore -destination "platform=iOS Simulator,id=$udid" \ + -derivedDataPath "$RUNNER_TEMP/appductcore-ios-sim" \ + -test-iterations 20 -run-tests-until-failure - name: Prebuild iOS working-directory: playground env: