[SDTEST-2704] Add watchOS and visionOS support#247
Merged
Conversation
Adds watchos/watchsimulator/xros/xrsimulator slices to the SDK
distribution (Package.swift, podspec, xcodeproj), so consumers can
link DatadogSDKTesting from watchOS 8+ and visionOS 1+ targets.
- Conditionalize UIKit/WatchKit imports and the iOS-only orientation /
appearance helpers to the new platforms. Replace the dead
`#if os(watchOS) import UIKit` branch (UIKit is a no-op subset on
watchOS) with proper WatchKit + visionOS UIKit branches.
- Bump swift-code-coverage to 2.1.0 to pick up the new xcframework
slices; align Mac Catalyst minimum to v14 accordingly. Drop the dead
`xcode16_*` cases from XcodeVersion mapping (2.1.0 only exposes
`.xcode26`).
- Gate Kronos and `NTPClock` to non-watchOS platforms — Kronos depends
on `CFHost*` which doesn't exist on watchOS. Force `DateClock` there.
- Use `WKApplication` notification names on watchOS in DDTestMonitor.
Skip `Spawn.output("hostname")` for `developerMachineHostName` on
real-device watchOS/visionOS.
- Move `RunLoopWaiter` from `DatadogSDKTesting` into `EventsExporter`
(now `public`) and switch `DataUploader.upload(data:)` /
`uploadWithResult(data:)` to use it instead of a bare
`DispatchSemaphore`. Main-thread callers on watchOS no longer
deadlock the run loop while waiting for URLSession to deliver.
- Fix a latent `URLProtocol` contract violation in `ServerMock`: it was
calling both `didFailWithError` and `urlProtocolDidFinishLoading`;
watchOS doesn't tolerate that and silently drops the error callback.
- Bump `WATCHOS_DEPLOYMENT_TARGET = 8.0`, `XROS_DEPLOYMENT_TARGET = 1.0`,
`TARGETED_DEVICE_FAMILY = 1,2,3,4,6,7`. Drop unused CoreTelephony
framework dependency entirely (no source references it).
- Add `INFOPLIST_KEY_WKApplication[sdk=watch*] = YES` and
`INFOPLIST_KEY_WKWatchOnly[sdk=watch*] = YES` to the
IntegrationTests-UITests app so it installs on the watchOS simulator.
- Makefile + CI workflows: new archive targets for the four watchOS /
visionOS SDKs, expanded xcframework / dSYMs deps, and matrix entries
for `watchOSsim` / `visionOSsim` in unit and integration tests.
Skipped on watchOS with documented reasons:
- 7 EventsExporter tests that drive URLProtocol-based mocks
synchronously: watchOS' URLSession bypasses `URLProtocol` mocks on
sync calls (verified empirically — `canInit` is called but
`startLoading` is not, and the request reaches the real network).
- IntegrationTests crash tests (XCCrash / STCrash): KSCrash 2.5.1
explicitly disables SIGNAL and MACH monitors on watchOS
(`KSCRASH_HAS_SIGNAL = 0`, `KSCRASH_HAS_MACH = 0`), so SIGILL from
Swift array-bounds traps cannot be captured.
- IntegrationTests networkIntegration tests: redirect handling
produces 2 info spans on watchOS instead of 1; needs separate
investigation.
Verified: SDK builds for all six platforms (iOS, tvOS, macOS,
Catalyst, watchOS, visionOS — device and simulator where applicable).
EventsExporter unit tests pass on watchOS sim (95 tests, 7 skipped).
IntegrationTests pass on watchOS sim (UI + smoke). iOS regressions
green.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Now that the minimum deployment targets are iOS 15, macOS 11, tvOS 15, watchOS 8, and visionOS 1, drop the `#available` checks and legacy fallbacks that are unreachable under those minimums. - JSONUtils: drop iOS 13 / macOS 10.15 gate on `.withoutEscapingSlashes`. - EncodableValue: remove the SR-6163 primitive-serialization workaround (the array-strip trick for iOS <13's `JSONEncoder`). - DateFormatting: remove the legacy `DateFormatter` fallback for the iOS 11.0/11.1 `.withFractionalSeconds` crash. - File.swift: drop `#if compiler(>=5.2)` + iOS 13.4 gate, remove `legacyAppend` / `legacyRead` (`seekToEndOfFile` / `readDataToEndOfFile`). Both `append` and `read` use the modern `seekToEnd` / `readToEnd` API directly. - PlatformUtils: drop `OSX 10.14` gate in `getAppearance` and the `iOS 13` gate in `getOrientation` (`statusBarOrientation` fallback). - URLSessionInstrumentation: drop the iOS 13 / macOS 10.15 gate around `injectIntoNSURLSessionCreateTaskMethods`. Simplify the formerly-noisy `macOS 12 / iOS 15 / tvOS 15 / watchOS 8 / visionOS 1` checks to just `#available(macOS 12.0, *)` since macOS is the only platform where it is still conditional. Strip the redundant `visionOS 1.0` from the still-conditional iOS 16 / macOS 13 / watchOS 9 / tvOS 16 checks. - InstrumentationUtils: drop the iOS 14 / macOS 11 / tvOS 14 / watchOS 7 / visionOS 1 gate around `os_log`. - StderrCapture: drop iOS 13 gate and the legacy `NSScanner.scan…(into: &NSString)` branches in `logTimedErrOutput` and `logUIStep`. - DDNetworkInstrumentation: drop the iOS 13 gate around `withUnsafeCurrentTask`. Net: -152 lines. Verified builds for iOS/macOS/tvOS/watchOS/visionOS simulators; EventsExporter unit tests green on iOS (96/96) and watchOS (95 + 7 skipped). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Network instrumentation now handles URLSession redirect / async-await paths correctly on watchOS, so the previously-disabled `networkIntegration` integration tests can run there. - Drop dead `#available(iOS 13 / macOS 10.15 / watchOS 6 / tvOS 13)` gate around `injectIntoNSURLSessionCreateTaskMethods` and `#available(iOS 14 / macOS 11 / tvOS 14)` around `os_log` in InstrumentationUtils — all below the current deployment minimums. - Simplify the formerly-all-platform `#available(macOS 12 / iOS 15 / tvOS 15 / watchOS 8)` checks to `#available(macOS 12.0, *)` (the only platform still conditional under the new minimums). Same for `AsyncTaskDelegate`'s class-level annotation. - Add `visionOS` to `InstrumentationUtils`'s `UIKit` import branch. - Re-enable `XCNetworkIntegration` and `STNetworkIntegration` runner tests on watchOS by removing the `.disabled(if: XcodeTestRunner.isWatchOSChildSDK)` traits. Verified passing on watchOS sim and iOS sim integration runs. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Replaces the `URLProtocol`-based test mock for `DataUploader` / `DataUploadWorker` tests with an in-process `HTTPClientType` mock. The old `ServerMock` + `URLSessionConfiguration.protocolClasses` setup is incompatible with watchOS' URL-loading machinery for sync-via-runloop callers: watchOS calls `URLProtocol.canInit` (returns `true`), then silently bypasses the protocol and routes the request to the real network. The behavior is independent of which thread / queue dispatches `.resume()`, of the run-loop wait strategy, and of using `URLProtocol.registerClass`. Switching the tests to mock at the `HTTPClient` level instead of `URLProtocol` sidesteps the issue entirely while keeping the production code path unchanged. - `Sources/EventsExporter/Upload/HTTPClient.swift`: add `HTTPClientType` protocol with the two existing entry points (`send`, `sendWithResult`). Concrete `HTTPClient` conforms. - `Sources/EventsExporter/Upload/DataUploader.swift`: retype `httpClient` from concrete `HTTPClient` to `any HTTPClientType`. - `Tests/EventsExporter/Helpers/MockHTTPClient.swift` (new): in-process mock that records requests and synchronously invokes the caller's completion handler with the configured delivery. Exposes the same `waitFor` / `waitAndReturnRequests` / `waitAndAssertNoRequestsSent` API surface that `ServerMock` provided. - `Tests/EventsExporter/Upload/DataUploaderTests.swift` and `DataUploadWorkerTests.swift`: migrate every `ServerMock` + `HTTPClient(session:)` instantiation to `MockHTTPClient`. Drop the per-class watchOS skip — both suites now run cleanly there. - `Tests/EventsExporter/Upload/HTTPClientTests.swift`: unchanged. These tests specifically validate `HTTPClient`'s `URLSession` mapping, so they have to keep using `URLProtocol`. The one failure-path case remains skipped on watchOS; the equivalent assertion at the `DataUploader` layer is now covered there via `MockHTTPClient(delivery: .failure(...))`. Results: watchOS EventsExporter goes from 95 tests / 7 skipped to 95 tests / 1 skipped, 0 failures. iOS regression: 96 / 0 / 0. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Previously `RunLoopWaiter.wait()` on the main thread busy-polled with `CFRunLoopRunInMode(.defaultMode, 0, true)` — fine for latency but hot on CPU and pointlessly tight when callers tend to take several ms. Switch to a 0.05s timeout per iteration and post a wake-up event from `signal()` so the run loop returns immediately rather than waiting out the timeout: - `signal()` enqueues a no-op block on the main run loop (any common mode) and calls `CFRunLoopWakeUp`. Any `wait()` blocked in `CFRunLoopRunInMode` exits, re-checks `_locked`, and returns. - The 0.05s safety-net timeout covers the race where `signal()` runs before `wait()` enters its blocking state, and the case where the run loop has no other sources installed (which would otherwise make `CFRunLoopRunInMode` return with `kCFRunLoopRunFinished`). Verified: EventsExporter unit tests on watchOS sim (95 / 1 skipped / 0 failures) and iOS sim (96 / 0 / 0). DatadogSDKTesting unit tests on iOS sim (356 / 0 / 0) — `waitForAsync` also exercises this code path. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`testGivenDataToUpload_whenUploadFinishesAndNeedsToBeRetried_thenDataIsPreserved` intermittently failed with `API violation - multiple calls made to -[XCTestExpectation fulfill] for "Upload has started"`. With `needsRetry: true` the batch stays on disk, so the worker re-uploads on every tick (every ~50ms under `UploadPerformanceMock.veryQuick`). If a second tick fires before `wait(for:timeout:)` returns after the first fulfillment, XCTest throws on the duplicate fulfill — pure scheduling-jitter race. Set `assertForOverFulfill = false` on the expectation: the test only cares that an upload starts at least once. Verified by running the test 5x in isolation and the full suite 3x — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Both URLProtocol-based `HTTPClientTests` cases used a 1s `waitForExpectations` timeout. The actual round-trip is milliseconds, but on a busy simulator (parallel test execution, CI load) the URLSession + URLProtocol setup occasionally blows past 1s, causing `testWhenRequestIsNotDelivered_itReturnsHTTPRequestDeliveryError` to spuriously time out: Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: \"receive response\". Bump both wait timeouts to 5s — generous enough to ride out jitter, still tight enough to fail fast on a real hang. Verified with 5 consecutive runs of `HTTPClientTests` on iOS sim, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`SwiftTestingTraitTests` and `SwiftTestingTaggedSuiteTests` both use
the shared `ObserverTesterTrait`. Swift Testing runs `@Suite` types in
parallel by default, so both can reach the trait's `provideScope`
post-checks concurrently. The original ordering was:
1. `if session.modules.first?.value.duration > 0 { await session.stop() }`
2. `let statuses = try #require(session.modules[...].suites[...])`
`SessionManager.stop()` clears `_session` and ends the underlying
session — so when one suite wins the race and calls stop() first, the
sibling's step 2 reads `nil` and the whole suite fails with `Expectation
failed: session.modules[…]?.suites[…] → nil`, followed by knock-on
`unknownSuite` / `moduleNotFound` errors and a process restart.
Move the snapshot read above the conditional `session.stop()` so the
data is captured before any sibling has a chance to tear the session
down. Verified by running both suites 5x in a row on tvOS sim — all
green.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
When the test bundle crashes and XCTest relaunches it, Swift Testing
does NOT re-run \`prepare(for:)\` for tests that already completed in
the previous launch (\"summary will include totals from previous
launches\"). Our \`SwiftTestingSuiteProvider.Registry\` is process-local,
so it ends up empty for any suite whose tests are all carried over.
The suite-level \`provideScope\` then calls \`registry.count(for:)\` /
\`registry.tests(for:)\` / \`registry.suites(for:)\`, which previously
threw \`unknownSuite\` / \`moduleNotFound\`, killing the whole suite at
its \`@Suite\` line:
✘ Suite SwiftTestingTraitTests recorded an issue at
SwiftTestingTraitTests.swift:14:2: Caught error:
unknownSuite(name: \"SwiftTestingTraitTests\", ...)
The downstream code already copes with empty suites (\`testsCount: 0\`,
empty \`_left\`, etc.), so make Registry return empty sets / zero
counts instead of throwing. Also register the bare \`module → suite\`
slot from \`Registry.register\` when the info is a suite, so suites
that are scoped without any individual test calling \`register\` still
show up in \`suites(for:)\`.
Verified by stress-running the previously-flaky suite pair on tvOS 5x
in a row (all green) and re-running the full DatadogSDKTesting / iOS,
EventsExporter / watchOS test suites — no regressions (356 / 0 and 95
/ 1-skipped / 0 respectively).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
\`unknownSuite\` and \`moduleNotFound\` were only ever thrown by the \`Registry\` lookups that are now non-throwing, and no other code in \`Sources/\`, \`Tests/\`, or \`IntegrationTests/\` references them. Drop them; leave \`moduleAlreadyEnded\` in place since \`State.didEnded\` still throws it. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The unit-test job ran all five platforms sequentially in each Xcode
job (1 job per Xcode × 5 sequential platforms × 2 schemes = ~30 wall
minutes total). Split it into one job per (Xcode × platform), so
15 jobs run in parallel and each one tests both schemes on a single
platform.
- Makefile: keep the existing `tests/unit/%` pattern (% = scheme,
runs all platforms — preserved for local use) and add five explicit
per-platform targets `tests/unit/{macOS,iOSsim,tvOSsim,watchOSsim,
visionOSsim}` that run both schemes on one platform. Make's
explicit-target rule wins over the pattern.
- `.github/workflows/unitTests.yml`: replace the single-axis matrix
with a `xcode × platform` matrix. Each job runs
`make tests/unit/${platform}`. Job names render as
"Xcode_26.x / iOSsim" etc. Artifact names are scoped per
(xcode, platform) to stay unique.
Verified via `make -n tests/unit/iOSsim` and `make -n tests/unit/EventsExporter`
— new per-platform target runs the two schemes once; old per-scheme
pattern still hits all five platforms.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two follow-ups on the previous parallelization commit: - Collapse the five explicit per-platform Makefile targets into a single `tests/unit/%` pattern target that picks the right `SIMULATOR` value via `$(filter $*,…)`, exactly mirroring how `tests/integration/%` is already written. Update the no-arg `tests/unit` aggregate to fan out to the five platforms (instead of two schemes). - Move simulator names into per-Xcode entries in the workflow matrix (`matrix.env.iossim` / `tvossim` / `watchossim` / `visionossim`). This mirrors `integrationTests.yml` and lets future Xcode versions pick different simulator names (e.g. when Apple drops a device from the Xcode-bundled simulator runtime) without touching the platforms axis. Verified `make -n tests/unit/iOSsim`, `make -n tests/unit/macOS`, and `make -n tests/unit` each emit the expected xcodebuild invocations. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Local development sometimes wants to run a single scheme across every platform without invoking the full matrix. Add explicit per-scheme targets that shadow the `tests/unit/%` platform-pattern: make tests/unit/EventsExporter # all 5 platforms × EventsExporter make tests/unit/DatadogSDKTesting # all 5 platforms × DatadogSDKTesting Implemented via a small `xctest_unit_all_platforms` macro so adding a new unit-test scheme is a one-liner. Per-platform target (`tests/unit/iOSsim`, …) and aggregate (`tests/unit`) behaviour is unchanged. Verified with `make -n` for all four target shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Two fixes for the residual tvOS flakes after a crash-induced bundle relaunch: 1. `Registry.register(test:)` — the suite-branch was \`_tests[test.module, default: [:]][test.suite, default: []]\` as a bare expression, which is a *read* in Swift's subscript-with-default form, not a write. The dictionary was never mutated for \`isSuite=true\`, so on a relaunch where no individual test of a suite re-runs \`prepare(for:)\`, \`registry.registeredTests[module]?[suite]\` stayed nil and the trait failed at \`#require(tests[ddModule]?[ddSuite])\`. Replace with an explicit assignment that runs only when the slot is missing — subscript-with-default on the LHS of an assignment does mutate, creating the outer module slot if needed. 2. `ObserverTesterTrait` — the shared `DatadogSwiftTestingTrait.sharedSuiteProvider` was only set up in `prepare(for:)`. On a relaunch where Swift Testing skips `prepare(for:)` for a suite whose tests all completed in the previous launch but still invokes the suite-level \`provideScope\`, the provider stayed nil and \`#require(provider …)\` threw — which Swift Testing then escalated to a fatal "Recording issues for suites is not supported" and crashed the bundle into another relaunch loop. Extract the setup into a private \`ensureSuiteProvider()\` helper and call it from both \`prepare\` and \`provideScope\`. Verified with 5/5 consecutive runs of the trait suites on tvOS sim and a full \`DatadogSDKTesting\` iOS regression (356 / 0). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Restore the end-of-suite \`session.stop()\` call but make it race-safe across parallel @suite siblings that share \`ObserverTesterTrait\`. \`Mocks.SessionManager.stop()\` clears \`_session\` and the observer fires \`SessionAndModuleObserver.didFinish(session:)\`, which nils both the manager's stored session and \`DatadogSwiftTestingTrait.sharedSuiteProvider\`. Swift Testing runs \`@Suite\` types in parallel, so two suites racing into the post-checks could leave one of them reading \`nil\` and crashing with the fatal "Recording issues for suites is not supported". New \`PostCheckGate\` actor: - \`session(for:)\` reads \`provider.session.session\` and stashes the first non-nil reference seen. Siblings that arrive after a stop receive the stashed \`Mocks.Session\` — the underlying class is still alive even though the manager's pointer is cleared. - \`stopOnce(via:)\` guards the stop call with an atomic flag so it runs exactly once across all sibling suites. Verified with 8/8 consecutive runs of the trait suites on tvOS sim (`SwiftTestingTraitTests` + `SwiftTestingTaggedSuiteTests`) and a full \`DatadogSDKTesting\` iOS regression (356 / 0). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Xcode 26.2 / Testing library 1501 (tvOS sim) failed to compile
let session = try await #require(Self.gate.session(for: suiteProvider))
with "async call in a function that does not support concurrency". The
`#require` macro expansion on that toolchain doesn't reliably keep the
inner expression in an async context, so the `await` is lost inside the
synthesized closure.
Extract the `await` to the caller so the macro only deals with the
optional unwrap:
let resolvedSession = await Self.gate.session(for: suiteProvider)
let session = try #require(resolvedSession)
Build now succeeds on tvOS sim under Xcode 26.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
juan-fernandez
approved these changes
May 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
watchOS 8+andvisionOS 1+to the SDK's distribution: new xcframework slices (watchos,watchsimulator,xros,xrsimulator), Package.swift / podspec / xcodeproj platform entries, Makefile archive targets, and CI matrix coverage. Bumpsswift-code-coverageto 2.1.0 to pick up the matching binary slices; aligns Mac Catalyst minimum to v14 with it.UIDevicethere), visionOS in the iOS-style branches,WKApplicationnotifications on watchOS, drop unused CoreTelephony framework, fix the long-brokenos(watchOS) import UIKitbranch inPlatformUtils. Gate Kronos/NTPClock (CFHost-dependent) to non-watchOS; forceDateClockthere.RunLoopWaiterfromDatadogSDKTestingtoEventsExporterand routeDataUploader.upload(data:)/uploadWithResult(data:)through it so main-thread callers on watchOS don't deadlock the run loop. Fix a latentURLProtocolcontract violation inServerMock(was calling bothdidFailWithErrorandurlProtocolDidFinishLoading).#availablechecks now that minimums are iOS 15 / macOS 11 / tvOS 15 / watchOS 8 / visionOS 1 — drops theiOS<13JSONEncoderworkaround, theiOS 11.xISO8601DateFormatterfallback, theiOS<13.4FileHandle.seekToEnd/readToEndfallback, theNSScanner.scan…(into: &NSString)legacy paths, etc. Net −152 lines.INFOPLIST_KEY_WKWatchOnly/INFOPLIST_KEY_WKApplicationon the test app.URLSessionInstrumentation.HTTPClientTypeprotocol seam and migrateDataUploaderTests/DataUploadWorkerTeststo a new in-processMockHTTPClient, sidestepping the watchOSURLProtocolbypass and getting those suites green there.Test plan
xcodebuild buildfor iOS / iOS-sim / tvOS / tvOS-sim / macOS / Mac Catalyst / watchOS / watchOS-sim / visionOS / visionOS-simEventsExporterunit tests on iOS sim (96 / 0 failures)EventsExporterunit tests on watchOS sim (95 / 1 skipped —HTTPClientTests.testWhenRequestIsNotDeliveredrequires URLSession+URLProtocol error propagation that watchOS doesn't deliver; equivalent assertion is covered at theDataUploaderlayer viaMockHTTPClient)DatadogSDKTestingunit tests on iOS simmake tests/integration/watchOSsim— UI + unit + Swift Testing all green)watchOSsim/visionOSsimentries (covered by the workflow changes here)Known limitations on watchOS, documented in code
KSCRASH_HAS_SIGNAL = 0andKSCRASH_HAS_MACH = 0on watchOS (upstreamKSSystemCapabilities.h, comment: "WatchOS signal is broken as of 3.1"). Swift array-bounds traps emit SIGILL, which can't be captured with the only available monitors (.nsException,.cppException). IntegrationXCCrash/STCrashtests are gated with.disabled(if: XcodeTestRunner.isWatchOSChildSDK, …)and a doc-pointer to the upstream constant.HTTPClientTestsfailure-path test is skipped — see test-plan note above.Spawn-driven features (TIA, code coverage post-processing, dSYM symbolication) inherits the existing iOS/tvOS-device gap; gated through the sametargetEnvironment(simulator) || os(macOS)pattern.🤖 Generated with Claude Code