Skip to content

test(ios): replace fixed Task.yield() draining with condition waits - #71

Open
V3RON wants to merge 1 commit into
mainfrom
claude/vibrant-archimedes-9wkprk
Open

V3RON wants to merge 1 commit into
mainfrom
claude/vibrant-archimedes-9wkprk

Conversation

@V3RON

@V3RON V3RON commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Closes #61.

Why

drainPendingTasks() in TestSupport.swift was 20 Task.yield() calls plus a 5 ms sleep, used at 60 sites to "let the client catch up" before asserting. Task.yield() is a scheduling hint, not a wait, so in an iOS simulator a simulated event could arrive before the AppductClient actor had reached transport.connect (and be dropped as stray), or an assertion could read the state/sessionId snapshot before the actor applied the event. That is why AppductCore.podspec carries no test spec.

What changed

Tests only. Sources/ is untouched.

  • TestSupport.swift: drainPendingTasks is removed. waitUntil(_:timeout:) polls an observable condition (sync or async closure) every 2 ms and, after 5 s, records an XCTFail with the condition's description and throws, so a broken expectation fails readably instead of hanging. allowQueuedWorkToRun() is a deliberate bounded pause for negative assertions only.
  • AppductAPITests.swift (14 sites) and AppductClientTests.swift (46 sites): every drain is replaced by a wait on the condition the test actually depends on:
    • before every simulateAck: transport.connectCallCount >= n. performHandshake arms its pending attempt and then calls transport.connect, so a bumped counter proves the ack will be picked up.
    • after an event: await client.state == …, await client.sessionId == …, or facade.state == ….
    • wire effects: transport.sentMessages.contains { … } for the expected frame.
    • listener effects: the EventCollector holding the expected event.
  • Two negative assertions keep a bounded wait, commented inline: the ignored late tool result after a timeout, and a re-delivered deep link for the session already held.
  • The two facade listener tests wait on await facade.client.errorListeners.count >= 1 (internal actor state via @testable import). Appduct.addListener registers the error channel last, so that implies all three channels are in place. A public registration handle would be the alternative if touching internals is unwelcome.
  • testHandleUrlEmitsBootstrapErrorForMalformedPayload and testRestoreSessionStartsResumeFromAValidLease gained throws; no other signatures changed.

Verification

No Swift toolchain is available in the environment this was written in, so swift test has not been run on this branch. The change was reviewed line by line: every test using try await waitUntil is declared throws, brace balance matches the previous versions, and compound conditions use separate awaits rather than await inside an && autoclosure. The macOS swift test job in test.yaml is the gate; please treat a red run there as this PR's to fix.

Follow-up (not in this PR)

Running the suite in an iOS simulator in CI, ideally with -test-iterations, and restoring a test spec in AppductCore.podspec once it is reliable there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NxtF2u7HBiZLmduthmmfvn


Generated by Claude Code

)

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxtF2u7HBiZLmduthmmfvn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AppductCore unit tests are racy in a simulator (fixed Task.yield() draining)

2 participants