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: diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift index 87c1c863..8134b44a 100644 --- a/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift @@ -42,7 +42,10 @@ 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. 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) @@ -109,7 +112,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 +128,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 +142,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 +161,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.isWired && 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 +175,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 +196,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.isWired && 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 +229,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.isWired && 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..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) } - await drainPendingTasks() + 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,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.isWired && 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.isWired && 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.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) } - await drainPendingTasks() + try await waitUntil("the superseding connect started its own transport handshake") { + transport.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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.isWired && 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/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") diff --git a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift index 8ea8633e..ca9740c3 100644 --- a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift +++ b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift @@ -5,15 +5,47 @@ // front of. import Foundation +import XCTest @testable import AppductCore /// 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" @@ -85,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( @@ -115,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) } } @@ -160,8 +200,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,30 +218,70 @@ 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 -- /// `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() } @@ -208,6 +289,7 @@ final class Gate: @unchecked Sendable { func open() { lock.lock() + isOpen = true let continuation = self.continuation self.continuation = nil lock.unlock()