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
13 changes: 10 additions & 3 deletions .github/workflows/native-ui-engine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -22,17 +23,21 @@ 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")))')
# SwiftUI presentations require an application event loop on iOS 26.
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:
Expand All @@ -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
Expand Down
52 changes: 42 additions & 10 deletions ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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() }
Expand All @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions scripts/retry-dependency-download.sh
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions scripts/retry-dependency-download.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
}
Loading