Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 43 additions & 14 deletions packages/native/ios/Tests/AppductCoreTests/AppductAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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!\""))
Expand All @@ -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"))
Expand All @@ -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"))
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -183,14 +196,23 @@ final class AppductAPITests: XCTestCase {

let events = EventCollector<AppductEvent>()
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 }
Expand All @@ -207,14 +229,21 @@ final class AppductAPITests: XCTestCase {

let events = EventCollector<AppductEvent>()
_ = 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 }
Expand Down
Loading
Loading