diff --git a/.github/workflows/native-ui-engine.yml b/.github/workflows/native-ui-engine.yml index 0f9ee33..73cd62a 100644 --- a/.github/workflows/native-ui-engine.yml +++ b/.github/workflows/native-ui-engine.yml @@ -14,6 +14,7 @@ jobs: - uses: actions/setup-node@v5 with: node-version: 24 + - run: node --test scripts/retry-dependency-download.test.mjs - run: bash scripts/verify-engine-spm.sh - name: Verify npm-distributed CocoaPods library run: | @@ -22,6 +23,11 @@ jobs: bash scripts/verify-engine-pods.sh "$RUNNER_TEMP/engine-pods-deps/node_modules/@capacitor/ios" - name: Install XcodeGen run: brew list xcodegen >/dev/null 2>&1 || brew install xcodegen + - name: Resolve Swift dependencies before testing + run: | + xcodegen generate --spec ios/Tests/project.yml + bash scripts/retry-dependency-download.sh xcodebuild -resolvePackageDependencies -scheme RdlaboCapacitorNativeUi-Package -clonedSourcePackagesDirPath "$RUNNER_TEMP/native-ui-spm" + bash scripts/retry-dependency-download.sh xcodebuild -resolvePackageDependencies -project ios/Tests/NativeUIHostedTests.xcodeproj -scheme NativeUIHostedTests -clonedSourcePackagesDirPath "$RUNNER_TEMP/native-ui-spm" - name: Test on an available iPhone simulator run: | DEVICE=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; print(next(d["udid"] for devices in json.load(sys.stdin)["devices"].values() for d in devices if d["name"].startswith("iPhone")))') @@ -29,10 +35,9 @@ jobs: POPOVER_CLOSE=NativeUIPluginTests/NativeUISnapshotTests/testNativePopoverClosureNotifiesWebBeforeReopening POPOVER_REPLACE=NativeUIPluginTests/NativeUISnapshotTests/testPopoverReopensAfterPresentationIdentityKeepsReplacedSource COVERED_ORIENTATION=NativeUIPluginTests/NativeUIPluginLifecycleTests/testOrientationPreservesCoveredControlsUntilRestore - xcodebuild -scheme RdlaboCapacitorNativeUi-Package -destination "platform=iOS Simulator,id=$DEVICE" CODE_SIGNING_ALLOWED=NO -skip-testing:"$POPOVER_CLOSE" -skip-testing:"$POPOVER_REPLACE" -skip-testing:"$COVERED_ORIENTATION" test + xcodebuild -scheme RdlaboCapacitorNativeUi-Package -destination "platform=iOS Simulator,id=$DEVICE" CODE_SIGNING_ALLOWED=NO -clonedSourcePackagesDirPath "$RUNNER_TEMP/native-ui-spm" -skip-testing:"$POPOVER_CLOSE" -skip-testing:"$POPOVER_REPLACE" -skip-testing:"$COVERED_ORIENTATION" test cd ios/Tests - xcodegen generate - xcodebuild -project NativeUIHostedTests.xcodeproj -scheme NativeUIHostedTests -destination "platform=iOS Simulator,id=$DEVICE" CODE_SIGNING_ALLOWED=NO -only-testing:"$POPOVER_CLOSE" -only-testing:"$POPOVER_REPLACE" -only-testing:"$COVERED_ORIENTATION" test + xcodebuild -project NativeUIHostedTests.xcodeproj -scheme NativeUIHostedTests -destination "platform=iOS Simulator,id=$DEVICE" CODE_SIGNING_ALLOWED=NO -clonedSourcePackagesDirPath "$RUNNER_TEMP/native-ui-spm" -only-testing:"$POPOVER_CLOSE" -only-testing:"$POPOVER_REPLACE" -only-testing:"$COVERED_ORIENTATION" test android: runs-on: ubuntu-latest steps: @@ -46,6 +51,8 @@ jobs: node-version: 24 cache: npm - run: npm ci + - name: Download Gradle distribution before testing + run: bash scripts/retry-dependency-download.sh ./android/gradlew --version - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules diff --git a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index 175e229..906ecfc 100644 --- a/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -512,22 +512,54 @@ final class NativeUISnapshotTests: XCTestCase { XCTAssertEqual(completed, [0, 1, 2, 3]) } - @MainActor func testMissingPopoverDisappearanceCancelsPresentationAndSettlesAllWaiters() async { - let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in }) + @MainActor func testMissingPopoverDisappearanceCancelsPresentationAndSettlesAllWaiters() throws { + var scheduled: [(TimeInterval, DispatchWorkItem)] = [] + let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, + scheduleDismissalTimeout: { scheduled.append(($0, $1)) }, event: { _ in }) host.anchoredVisible = true - host.dismissAnchored = {} // No onDisappear callback arrives. - var cancelled = false - host.cancelAnchored = { cancelled = true } - let closed = expectation(description: "Both stop requests finish after cancellation") - closed.expectedFulfillmentCount = 2 + var dismissals = 0 + host.dismissAnchored = { dismissals += 1 } // No onDisappear callback arrives. + var cancellations = 0 + host.cancelAnchored = { cancellations += 1 } + var completions = 0 for _ in 0..<2 { host.dismissPopover(animated: false) { - XCTAssertTrue(cancelled) - closed.fulfill() + XCTAssertEqual(cancellations, 1) + completions += 1 } } - await fulfillment(of: [closed], timeout: NativeUIOverlayHost.completionTimeout + 2) + XCTAssertEqual(dismissals, 1) + XCTAssertEqual(completions, 0) + XCTAssertEqual(cancellations, 0) + XCTAssertEqual(scheduled.count, 1) + let (delay, timeout) = try XCTUnwrap(scheduled.first) + XCTAssertEqual(delay, 4) // Must precede the five-second JS bridge deadline. + timeout.perform() // Advance the deadline without depending on simulator load. + XCTAssertEqual(cancellations, 1) + XCTAssertEqual(completions, 2) XCTAssertFalse(host.anchoredVisible) + XCTAssertTrue(timeout.isCancelled) + host.presentationDidDisappear() // A late SwiftUI callback must not settle twice. + XCTAssertEqual(completions, 2) + } + + @MainActor func testPopoverDisappearanceCancelsFallbackDeadline() throws { + var timeout: DispatchWorkItem? + let host = NativeUIOverlayHost(configuration: WKWebViewConfiguration(), options: nil, + scheduleDismissalTimeout: { _, work in timeout = work }, event: { _ in }) + host.anchoredVisible = true + host.dismissAnchored = {} + var cancellations = 0 + host.cancelAnchored = { cancellations += 1 } + var completions = 0 + host.dismissPopover(animated: false) { completions += 1 } + let work = try XCTUnwrap(timeout) + XCTAssertFalse(work.isCancelled) + host.presentationDidDisappear() + XCTAssertTrue(work.isCancelled) + work.perform() + XCTAssertEqual(cancellations, 0) + XCTAssertEqual(completions, 1) } @MainActor func testDetachingRailSettlesPopoverPresentationAndDismissal() throws { diff --git a/packages/native-ui-core/ios/Sources/NativeUICore/Overlays/NativeUIOverlayHost.swift b/packages/native-ui-core/ios/Sources/NativeUICore/Overlays/NativeUIOverlayHost.swift index e2d7a2d..25ae6f9 100644 --- a/packages/native-ui-core/ios/Sources/NativeUICore/Overlays/NativeUIOverlayHost.swift +++ b/packages/native-ui-core/ios/Sources/NativeUICore/Overlays/NativeUIOverlayHost.swift @@ -14,6 +14,7 @@ final class NativeUIOverlayHost: UIViewController { var disappeared = false private var dismissalCompletions: [() -> Void]? private var dismissalTimeout: DispatchWorkItem? + private let scheduleDismissalTimeout: (TimeInterval, DispatchWorkItem) -> Void func presentationDidDisappear() { // SwiftUI may disappear before (or without) updating isPresented. @@ -51,7 +52,7 @@ final class NativeUIOverlayHost: UIViewController { self.completeDismissal() } dismissalTimeout = timeout - DispatchQueue.main.asyncAfter(deadline: .now() + Self.completionTimeout, execute: timeout) + scheduleDismissalTimeout(Self.completionTimeout, timeout) if let dismissAnchored { if anchoredVisible { dismissAnchored() } else { dismissAnchored(); finish() } @@ -60,7 +61,11 @@ final class NativeUIOverlayHost: UIViewController { } else { finish() } } - init(configuration: WKWebViewConfiguration, options: [String: Any]?, event: @escaping (String) -> Void) { + init(configuration: WKWebViewConfiguration, options: [String: Any]?, + scheduleDismissalTimeout: @escaping (TimeInterval, DispatchWorkItem) -> Void = { delay, work in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + }, event: @escaping (String) -> Void) { + self.scheduleDismissalTimeout = scheduleDismissalTimeout webView = WKWebView(frame: .zero, configuration: configuration) self.event = event super.init(nibName: nil, bundle: nil) diff --git a/scripts/retry-dependency-download.sh b/scripts/retry-dependency-download.sh new file mode 100644 index 0000000..1f04028 --- /dev/null +++ b/scripts/retry-dependency-download.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Only dependency preparation belongs here; never wrap builds or tests. +set -euo pipefail +log=$(mktemp) +trap 'rm -f "$log"' EXIT +for attempt in 1 2 3; do + if "$@" >"$log" 2>&1; then + cat "$log" + exit 0 + else + status=$? + fi + cat "$log" >&2 + # Fail immediately for resolution, compilation, checksum or assertion errors. + if ! grep -Eq 'badResponseStatusCode\((429|502|503|504)\)|HTTP response code: (429|502|503|504)|SocketTimeoutException|[Cc]onnection timed out|[Rr]ead timed out|Could not resolve host' "$log"; then + exit "$status" + fi + if [ "$attempt" = 3 ]; then exit "$status"; fi + echo "Transient dependency download failure; retry $attempt/2 in 10 seconds" >&2 + sleep 10 +done diff --git a/scripts/retry-dependency-download.test.mjs b/scripts/retry-dependency-download.test.mjs new file mode 100644 index 0000000..0a730b1 --- /dev/null +++ b/scripts/retry-dependency-download.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; + +for (const [name, message, failures, expectedCalls, expectedStatus] of [ + ['success', '', 0, 1, 0], + ['transient download recovers', 'badResponseStatusCode(504)', 1, 2, 0], + ['transient download is bounded', 'HTTP response code: 504', 9, 3, 7], + ['read timeout recovers', 'java.net.SocketTimeoutException: Read timed out', 1, 2, 0], + ['assertion failure is not retried', 'Assertion failed', 9, 1, 7], + ['invalid package is not retried', 'Could not resolve package dependencies: invalid version', 9, 1, 7], + ['checksum failure is not retried', 'checksum mismatch', 9, 1, 7], +]) { + test(name, () => { + const dir = mkdtempSync(join(tmpdir(), 'dependency-retry-')); + try { + const count = join(dir, 'count'); + const fixture = join(dir, 'fixture.cjs'); + writeFileSync(count, '0'); + writeFileSync(join(dir, 'sleep'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + writeFileSync(fixture, ` + const fs = require('node:fs'); + const count = Number(fs.readFileSync(process.env.COUNT, 'utf8')) + 1; + fs.writeFileSync(process.env.COUNT, String(count)); + if (count <= Number(process.env.FAILURES)) { + console.error(process.env.MESSAGE); + process.exit(7); + } + `); + const result = spawnSync('bash', [resolve('scripts/retry-dependency-download.sh'), process.execPath, fixture], { + encoding: 'utf8', + env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, COUNT: count, FAILURES: String(failures), MESSAGE: message }, + }); + assert.equal(result.status, expectedStatus, result.stderr); + assert.equal(Number(readFileSync(count, 'utf8')), expectedCalls); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}