Skip to content

chore: add an Apple test app for iOS, Mac Catalyst, macOS, tvOS and watchOS - #545

Open
abelonogov-ld wants to merge 14 commits into
andrey/event-durability-tier3-persistencefrom
andrey/apple-test-app
Open

abelonogov-ld wants to merge 14 commits into
andrey/event-durability-tier3-persistencefrom
andrey/apple-test-app

Conversation

@abelonogov-ld

@abelonogov-ld abelonogov-ld commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions

Related issues

Stacked on #538 (event persistence, tier 3). Counterpart of the Android SDK's test-app.

Describe the solution you've provided

TestApp/ is an Xcode project inside the SDK repo that links the SDK as a local Swift package, so it always runs the code in the checkout. It has one target and shared scheme per platform:

Scheme Destinations
TestApp_iOS iOS 16+, Mac Catalyst
TestApp_macOS native macOS 13+
TestApp_tvOS tvOS 16+
TestApp_watchOS watch-only watchOS 9+

All four targets share TestApp/Sources. The app:

  • evaluates flags of each type, tracks, identifies, flushes, and calls flushAndWait;
  • switches event persistence (disabled / deferred / immediate, restarting the client) and offline mode;
  • reproduces event loss: eval + track, then SIGKILL after a flush, SIGKILL at once, or fatalError at once;
  • stresses the store: evaluations over capacity, and enough large events to fill three batches;
  • shows the event directory (open log, batches, event counts, sizes) refreshed every second, without opening or locking its files;
  • counts exposures through two DedupingHooks;
  • on a Mac (native or Catalyst), launches more instances of itself (open -n), so several processes share one event directory. TestApp/scripts/launch-instances.sh does the same from a terminal.

The mobile key comes from TestApp/Config/Secrets.xcconfig, which is git-ignored (see Secrets.xcconfig.example), or from a -LDMobileKey launch argument. LAUNCHDARKLY_ENVIRONMENT = staging points the app at ld-stg.

CI: a new build-test-app job builds every scheme and destination on macos-15 with Xcode 16.4.

Screenshots

Production environment, immediate persistence, just after start. The Mac app (native and Catalyst) shows the same form, plus a New instance button in the Event storage header.

iPad (the whole form) iPhone
iPad iPhone
Apple TV Apple Watch
Apple TV Apple Watch

Describe alternatives you've considered

  • Keeping the durability screens in the observability repo's test app. That app tests observability, and the flag SDK reaches it only through a released dependency.
  • A separate project per platform. One project with a synchronized source folder keeps the platforms from drifting apart.

Additional context

  • The project uses Xcode 16's synchronized folders (object version 77), so it needs Xcode 16 or later.
  • Verified locally: all five destinations build with no warnings (Xcode 27.1); the app runs on iPhone and Apple Watch simulators; two native and two Catalyst instances launched side by side. Not yet verified: the Xcode 16.4 build, which CI will run, and an end-to-end persistence run across instances.
  • The observability repo's durability screens (branch andrey/durability-test-app, never merged) can retire once this lands.

Note

Overview
Apple multi-platform test harness

The repo now includes an Xcode 16 TestApp project that consumes the local Swift package and shares one SwiftUI codebase across iOS/Mac Catalyst/macOS/tvOS/watchOS targets. The UI drives the SDK through client restart toggles, offline controls, flag evaluation, multi-instance event storage inspection, exposure dedupe hooks, stress/loss scenarios, benchmark tooling, and oversized POST probes. Secrets are managed via optional Secrets.xcconfig, ignored by Git, with docs/scripts (launch-instances.sh) covering local workflows.

CI + tooling updates

A new composite build-test-app action primes simulator services then xcodebuilds every scheme without signing. ci.yml gains a macOS 15 job pinned to Xcode 16.4 that runs this action so the harness is built continuously. .gitignore now excludes the real secrets file alongside the documentation additions already shown in README.

Reviewed by Cursor Bugbot for commit c14e919. Bugbot is set up for automated code reviews on this repo. Configure here.

TestApp/ is an Xcode project linking the SDK as a local Swift package, with a
target per platform: iOS (also built for Mac Catalyst), native macOS, tvOS
and watchOS. It evaluates flags, tracks and identifies, switches event
persistence and offline mode, kills the process with events unsent, and
shows the event directory as it changes. On a Mac it launches more instances
of itself, to exercise persistence shared between processes.

CI builds every target.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abelonogov-ld
abelonogov-ld requested a review from a team as a code owner October 23, 2026 14:40

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread TestApp/Sources/ContentView.swift Resolved
Comment thread TestApp/Sources/ClientController.swift Resolved
abelonogov-ld and others added 6 commits October 23, 2026 07:53
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… test app

The button sits under the status text and runs the Android test app's benchmark,
section for section, on stores of its own in a temporary directory, then shows the
tables in a sheet with Copy. It is in Debug builds only, since it reaches the SDK's
internals through @testable import.

Co-authored-by: Cursor <cursoragent@cursor.com>
Posts a 20 MB payload of valid custom events straight to the events
service on a session of its own, with the headers the SDK's sender uses,
and shows the status, headers and body that come back. Neither the event
store, the batch size limit nor the retry state is involved, so the
answer to a body that size is seen directly. Next to Benchmark, and like
it built only for Debug.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

}
}
store.commit()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark hits store byte cap

Medium Severity

recordingCostPerCall constructs EventStore with capacity: .max but leaves the default byteCapacity of 20 MiB. Staged and committed events still count against that limit until they are delivered, so after about 16 MiB stage starts returning false. measure then keeps the fastest of five rounds, which are the rounds that mostly drop. The evaluation, full event and evaluation + track (commit point) rows therefore report the cost of a rejected write rather than staging or commit.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fed655e. Configure here.

let start = DispatchTime.now().uptimeNanoseconds
body()
samples[index] = Double(DispatchTime.now().uptimeNanoseconds - start)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Watch skips outlier scaling

Low Severity

Watch scaling in measure divides iterations by ten, but measureDistribution always runs the full 100 / 1,000 / 10,000 calls. That is the commit-point outliers section, which times every commit on the caller’s thread. The class comment and README say a watch run uses a tenth of the iterations; on watchOS this section still does the full 10,000 disk commits and can run far longer than the rest of the harness.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fed655e. Configure here.

Comment thread TestApp/Sources/ContentView.swift Resolved
abelonogov-ld and others added 4 commits November 4, 2026 15:49
Each environment's DarklyService built its own URLSession, and each session
keeps its own connection pool, so seven environments posting events and
diagnostics to the same host opened at least seven TCP+TLS connections. The
session configuration does not depend on the environment, so every service
now uses one shared session and their requests reuse connections. Streams are
unaffected: they use LDSwiftEventSource's own sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	LaunchDarkly/LaunchDarklyTests/Networking/DarklyServiceSpec.swift

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 15eae25. Configure here.

client?.setOnline(!offline) { [weak self] in
Task { @MainActor in self?.refreshConnectionStatus() }
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Offline toggle ignored during restart

Medium Severity

Toggling Offline during start or restart updates isOffline in the UI, but client is nil so setOnline never runs, and makeConfig has already captured the previous value. The replacement client comes up in the opposite online state from the toggle. The persistence picker is disabled while isRestarting is true; this toggle is not. First start waits up to 10 seconds for flags, so the mismatch is easy to hit and can send events during an intended offline persistence check.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 15eae25. Configure here.

func restart(reason: String = "Restarted") {
guard !configuration.isMissingMobileKey, !isRestarting
else { return }
isRestarting = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restart never shows Restarting status

Low Severity

refreshConnectionStatus can set connectionStatus to Restarting when isRestarting is true, but nothing calls it when a restart begins. The status line keeps the previous client's mode (or Not started on first launch) for the whole wait, so the Restarting branch is never used.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 15eae25. Configure here.

This branch has not been deployed

No deployments
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.

2 participants