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
110 changes: 110 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,115 @@ jobs:
src/main/services/sync/syncHostService.test.ts
src/main/services/sync/syncService.test.ts

# ── iOS build + unit tests ────────────────────────────────────────────
# The iOS app is otherwise the only ADE surface with zero CI coverage, and
# three consecutive merges (#1117, #1120, #1121) each landed a Swift compile
# break that stayed invisible until someone built locally. This job keeps the
# ADE scheme compiling and ADETests green on every change that can affect
# them.
#
# It always RUNS (ci-pass treats "skipped" as failure, deliberately), but on
# pull requests that do not touch apps/ios/** or this workflow it exits
# success immediately, so non-iOS PRs pay only runner spin-up. Pushes to main
# and workflow_dispatch always execute the full build + test.
test-ios:
runs-on: macos-26
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
# Depth 2 keeps the PR merge commit's parents available, so the
# gating step below can diff the merge against its base parent.
fetch-depth: 2
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# xcodebuild executes PR-controlled build phases; it never needs
# authenticated git, so don't leave the token in .git/config.
persist-credentials: false

- name: Decide whether iOS is affected
id: gate
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" != "pull_request" ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# checkout@v4 checks out the PR merge commit; diffing it against its
# base parent yields exactly the changes this PR introduces.
changed=$(git diff --name-only HEAD^1 HEAD || true)
if printf '%s\n' "$changed" | grep -qE '^(apps/ios/|\.github/workflows/ci\.yml)'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "No apps/ios changes in this PR; skipping the iOS build."
fi

- name: Select Xcode 26
if: steps.gate.outputs.run == 'true'
run: |
set -euo pipefail
latest=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)
if [[ -n "$latest" ]]; then
sudo xcode-select -s "$latest/Contents/Developer"
fi
xcodebuild -version

- name: Cache Swift packages
if: steps.gate.outputs.run == 'true'
uses: actions/cache@v4
with:
path: ~/spm-packages
key: spm-v1-${{ hashFiles('apps/ios/ADE.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-v1-

- name: Build for testing
if: steps.gate.outputs.run == 'true'
run: |
set -euo pipefail
cd apps/ios
xcodebuild \
-project ADE.xcodeproj \
-scheme ADE \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/ios-dd" \
-clonedSourcePackagesDirPath ~/spm-packages \
-skipMacroValidation \
-skipPackagePluginValidation \
-quiet \
build-for-testing

- name: Run ADETests
if: steps.gate.outputs.run == 'true'
run: |
set -euo pipefail
cd apps/ios
dest_id=$(xcrun simctl list devices available --json \
| jq -r '[.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.isAvailable and (.name | startswith("iPhone")))][0].udid')
if [[ -z "$dest_id" || "$dest_id" == "null" ]]; then
echo "::error::No available iPhone simulator on this runner image"
xcrun simctl list devices
exit 1
fi
xcodebuild \
-project ADE.xcodeproj \
-scheme ADE \
-destination "platform=iOS Simulator,id=$dest_id" \
-derivedDataPath "$RUNNER_TEMP/ios-dd" \
-clonedSourcePackagesDirPath ~/spm-packages \
-skipMacroValidation \
-skipPackagePluginValidation \
-parallel-testing-enabled NO \
-quiet \
test-without-building

- name: Upload test results on failure
if: failure() && steps.gate.outputs.run == 'true'
uses: actions/upload-artifact@v4
with:
name: ios-test-results
path: ${{ runner.temp }}/ios-dd/Logs/Test/*.xcresult
if-no-files-found: ignore
retention-days: 7

validate-docs:
needs: install
runs-on: ubuntu-latest
Expand Down Expand Up @@ -625,6 +734,7 @@ jobs:
- build
- build-runtime-binaries
- windows-foundation
- test-ios
- validate-docs
runs-on: ubuntu-latest
steps:
Expand Down
8 changes: 7 additions & 1 deletion apps/ios/ADE/Services/SyncConnectionRace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ enum SyncConnectionRaceTiming {
struct SyncConnectionRaceBudget: Equatable, Sendable {
var overallNanoseconds: UInt64
var relayReadyAfterAcceptedNanoseconds: UInt64
/// Deadline for `accepted`. Carried on the budget rather than read straight
/// off `SyncConnectionRaceTiming` so a caller that is not racing a real
/// socket — a test feeding pre-buffered frames — can widen the window without
/// changing what either production budget waits.
var relayAcceptedNegotiationNanoseconds: UInt64 = SyncConnectionRaceTiming
.relayAcceptedNegotiationNanoseconds

static let standard = SyncConnectionRaceBudget(
overallNanoseconds: SyncConnectionRaceTiming.overallBudgetNanoseconds,
Expand Down Expand Up @@ -218,7 +224,7 @@ struct SyncRelayReadyNegotiation: Equatable {
var phaseBudgetNanoseconds: UInt64 {
acceptedV2
? budget.relayReadyAfterAcceptedNanoseconds
: SyncConnectionRaceTiming.relayAcceptedNegotiationNanoseconds
: budget.relayAcceptedNegotiationNanoseconds
}

func negotiationWindowExpired() -> SyncRelayReadyNegotiationDecision {
Expand Down
18 changes: 14 additions & 4 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15806,9 +15806,10 @@ final class SyncService: ObservableObject {
/// extends the budget instead of the socket being abandoned.
@discardableResult
private func awaitRelayCandidateReady(
mailbox: SyncConnectionRaceTextMailbox
mailbox: SyncConnectionRaceTextMailbox,
budget: SyncConnectionRaceBudget? = nil
) async throws -> SyncRelayReadyNegotiation {
var negotiation = SyncRelayReadyNegotiation(budget: connectAttemptBudget)
var negotiation = SyncRelayReadyNegotiation(budget: budget ?? connectAttemptBudget)
var deadlineUptime = ProcessInfo.processInfo.systemUptime
+ TimeInterval(negotiation.phaseBudgetNanoseconds) / 1_000_000_000

Expand Down Expand Up @@ -17335,15 +17336,24 @@ final class SyncService: ObservableObject {
}

func awaitRelayCandidateReadyForTesting(
frames: [[String: Any]]
frames: [[String: Any]],
acceptedWindowNanoseconds: UInt64? = nil
) async throws -> SyncRelayReadyNegotiation {
let mailbox = SyncConnectionRaceTextMailbox()
for frame in frames {
let data = try JSONSerialization.data(withJSONObject: frame, options: [.sortedKeys])
guard let text = String(data: data, encoding: .utf8) else { continue }
await mailbox.deliver(text)
}
return try await awaitRelayCandidateReady(mailbox: mailbox)
// Every frame is already buffered, so the pre-`accepted` deadline is timing
// nothing real here — it only races the test host's scheduler, and a loaded
// machine can burn the 350ms production window between reading two frames
// that were delivered instantly. Widen that one window by default so the
// runtime's ordering rules are what the test measures; a timeout-path test
// passes a tiny window instead so it does not sit out the wide one.
var budget = connectAttemptBudget
budget.relayAcceptedNegotiationNanoseconds = acceptedWindowNanoseconds ?? 30_000_000_000
return try await awaitRelayCandidateReady(mailbox: mailbox, budget: budget)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func completeCapturedRefreshRequestsForTesting() {
Expand Down
17 changes: 12 additions & 5 deletions apps/ios/ADETests/ADETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6432,8 +6432,8 @@ final class ADETests: XCTestCase {
('pr-one', '2026-04-22T00:30:00.000Z'),
('pr-two', '2026-04-22T00:40:00.000Z');
insert into pr_groups(id, project_id, group_type, name, target_branch, created_at) values
('group-one', 'project-1', 'queue', 'Project one queue', 'main', '2026-04-22T00:30:00.000Z'),
('group-two', 'project-2', 'queue', 'Project two queue', 'main', '2026-04-22T00:40:00.000Z');
('group-one', 'project-1', 'integration', 'Project one integration', 'main', '2026-04-22T00:30:00.000Z'),
('group-two', 'project-2', 'integration', 'Project two integration', 'main', '2026-04-22T00:40:00.000Z');
insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values
('member-one', 'group-one', 'pr-one', 'lane-one', 0, 'source'),
('member-two', 'group-two', 'pr-two', 'lane-two', 0, 'source');
Expand Down Expand Up @@ -11560,7 +11560,11 @@ final class ADETests: XCTestCase {
),
]

XCTAssertEqual(filterPullRequestListItems(items, query: "review", state: .all).map(\.id), ["pr-1"])
// Search is a substring match over title/branches/lane/repo, so "review"
// matches pr-1 ("Improve review timeline") and pr-2 ("Draft review
// workflow") while excluding pr-3 — narrowing to one row is the job of the
// state filter, asserted below.
XCTAssertEqual(filterPullRequestListItems(items, query: "review", state: .all).map(\.id), ["pr-1", "pr-2"])
XCTAssertEqual(filterPullRequestListItems(items, query: "", state: .draft).map(\.id), ["pr-2"])
XCTAssertEqual(filterPullRequestListItems(items, query: "cleanup", state: .merged).map(\.id), ["pr-3"])
XCTAssertEqual(filterPullRequestListItems(items, query: "", state: .open).map(\.id), ["pr-1"])
Expand Down Expand Up @@ -13814,7 +13818,7 @@ final class ADETests: XCTestCase {

try database.executeSqlForTesting("""
insert into pr_groups(id, project_id, group_type, name, target_branch, created_at)
values ('group-1', 'project-1', 'queue', 'Queue rollout', 'main', '2026-03-17T00:15:00.000Z');
values ('group-1', 'project-1', 'integration', 'Integration rollout', 'main', '2026-03-17T00:15:00.000Z');
""")
try database.executeSqlForTesting("""
insert into pr_group_members(id, group_id, pr_id, lane_id, position, role)
Expand Down Expand Up @@ -25052,12 +25056,15 @@ final class RosterDeltaTests: XCTestCase {
}
""".utf8)
let cleanExit = try JSONDecoder().decode(RemoteRosterChat.self, from: cleanExitData)
XCTAssertEqual(cleanExit.exitCode, 0)
// Process completion is not a lifecycle declaration: settle is declared-only
// (`settledAt`), so a clean exit with no declaration rests at `.ended`.
XCTAssertEqual(
workCanonicalSessionState(
session: cleanExit.asTerminalSessionSummary(laneName: "Feature"),
summary: nil
).phase,
.settled
.ended
)

let legacyData = Data("""
Expand Down
7 changes: 6 additions & 1 deletion apps/ios/ADETests/SyncRecoveryPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1142,7 +1142,12 @@ final class SyncRecoveryPolicyTests: XCTestCase {
}

do {
try await service.awaitRelayCandidateReadyForTesting(frames: [])
// A short real window: this test IS the timeout path, so it must not sit
// out the wide scheduling-safe window the ordering tests use.
try await service.awaitRelayCandidateReadyForTesting(
frames: [],
acceptedWindowNanoseconds: 50_000_000
)
XCTFail("A ready-v2 timeout must require a fresh legacy socket.")
} catch let error as SyncRelayReadyNegotiationError {
XCTAssertEqual(error, .retryLegacySocket)
Expand Down
Loading