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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ Carthage/Build
# Node
node_modules/

# Playwright
tests/playwright-report/
tests/test-results/

# Coverage
coverage-html/

Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,17 @@ sim-install:
xcrun simctl install "$$BOOTED" "$$PRODUCTS/$(SCHEME)UITests-Runner.app"; \
echo "Installed on simulator $$BOOTED"

# Build, run mocha tests with code coverage
# Build, run Playwright tests with code coverage
test-coverage:
@rm -rf $(BUILD_DIR)/coverage.xcresult
@BOOTED=$$(xcrun simctl list devices booted -j | jq -r '[.devices[][] | select(.state=="Booted")] | first | .udid'); \
scripts/test-coverage.sh $(PROJECT) $(SCHEME) "$$BOOTED" $(BUILD_DIR)

# Generate HTML coverage report (run after test-coverage)
coverage-html:
@PROFDATA=$$(find $(BUILD_DIR)/local/Build/ProfileData -name "Coverage.profdata" 2>/dev/null | head -1); \
@PROFDATA="$(BUILD_DIR)/local/coverage/Coverage.profdata"; \
BINARY="$(BUILD_DIR)/local/Build/Products/Debug-iphonesimulator/$(SCHEME)UITests-Runner.app/PlugIns/$(SCHEME)UITests.xctest/$(SCHEME)UITests"; \
if [ -z "$$PROFDATA" ] || [ ! -f "$$BINARY" ]; then echo "error: Run 'make test-coverage' first"; exit 1; fi; \
if [ ! -f "$$PROFDATA" ] || [ ! -f "$$BINARY" ]; then echo "error: Run 'make test-coverage' first"; exit 1; fi; \
rm -rf coverage-html; \
xcrun llvm-cov show "$$BINARY" -instr-profile "$$PROFDATA" -format=html -output-dir=coverage-html \
-ignore-filename-regex='build/local/SourcePackages|DerivedSources'; \
Expand Down
81 changes: 36 additions & 45 deletions scripts/test-coverage.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#!/bin/sh
# Run mocha tests against a coverage-instrumented XCUITest server.
# Build an instrumented XCUITest runner, install it on the booted simulator, and
# run the Playwright tests against it. Playwright's globalSetup launches the runner
# (on port 12004); this script collects Swift code coverage from the run.
# Usage: scripts/test-coverage.sh <project> <scheme> <simulator-udid> <build-dir>

set -e
Expand All @@ -8,64 +10,53 @@ PROJECT="$1"
SCHEME="$2"
BOOTED="$3"
BUILD_DIR="$4"
RESULT_BUNDLE="${BUILD_DIR}/coverage.xcresult"
RUNNER_BUNDLE_ID="com.mobilenext.devicekit-iosUITests.xctrunner"
PRODUCTS="${BUILD_DIR}/local/Build/Products/Debug-iphonesimulator"
COVERAGE_DIR="${BUILD_DIR}/local/coverage"

# Clean previous result bundle
rm -rf "$RESULT_BUNDLE"

# Start xcodebuild test with coverage in the background
echo "Starting XCUITest server with coverage enabled..."
xcodebuild test \
# Build the runner with coverage instrumentation
echo "Building instrumented XCUITest runner..."
xcodebuild build-for-testing \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-destination "id=$BOOTED" \
-derivedDataPath "$BUILD_DIR/local" \
-enableCodeCoverage YES \
-resultBundlePath "$RESULT_BUNDLE" \
> /dev/null 2>&1 &
XCODEBUILD_PID=$!
> /dev/null

# Wait for the server to be ready
echo "Waiting for server on localhost:12004..."
RETRIES=0
MAX_RETRIES=120
while [ $RETRIES -lt $MAX_RETRIES ]; do
if ! kill -0 $XCODEBUILD_PID 2>/dev/null; then
echo "error: xcodebuild process ($XCODEBUILD_PID) exited before server became ready"
exit 1
fi
if curl -s http://localhost:12004/health > /dev/null 2>&1; then
echo "Server is ready"
break
fi
RETRIES=$((RETRIES + 1))
sleep 1
done
# Install host app + runner on the booted simulator
echo "Installing apps on simulator $BOOTED..."
xcrun simctl install "$BOOTED" "$PRODUCTS/${SCHEME}.app"
xcrun simctl install "$BOOTED" "$PRODUCTS/${SCHEME}UITests-Runner.app"

if [ $RETRIES -eq $MAX_RETRIES ]; then
echo "error: Server did not start within ${MAX_RETRIES}s"
kill $XCODEBUILD_PID 2>/dev/null || true
exit 1
fi
# Prepare coverage output dir. iOS simulators run on the host filesystem, so the
# instrumented runner writes .profraw straight to this host path via LLVM_PROFILE_FILE
# (forwarded into the simulator process by simctl as SIMCTL_CHILD_LLVM_PROFILE_FILE).
rm -rf "$COVERAGE_DIR"
mkdir -p "$COVERAGE_DIR"
COVERAGE_DIR_ABS="$(cd "$COVERAGE_DIR" && pwd)"

# Run mocha tests
echo "Running mocha tests..."
cd tests && npm test
TEST_EXIT=$?
cd ..
# Run Playwright tests. globalSetup terminates any stale runner and launches a fresh
# one on port 12004; DEVICEKIT_SIMULATOR_UDID pins it to this simulator.
echo "Running Playwright tests..."
TEST_EXIT=0
( cd tests && \
SIMCTL_CHILD_LLVM_PROFILE_FILE="${COVERAGE_DIR_ABS}/%p.profraw" \
DEVICEKIT_SIMULATOR_UDID="$BOOTED" \
npm test ) || TEST_EXIT=$?

# Shutdown the server gracefully and let xcodebuild collect coverage
# Terminate the runner so the instrumented binary flushes its .profraw on exit
echo "Stopping XCUITest server..."
curl -s -X POST http://localhost:12004/shutdown > /dev/null 2>&1 || true
echo "Waiting for xcodebuild to finish and collect coverage..."
wait $XCODEBUILD_PID 2>/dev/null || true
xcrun simctl terminate "$BOOTED" "$RUNNER_BUNDLE_ID" > /dev/null 2>&1 || true
sleep 2 # give the profile writer a moment to flush

# Generate coverage report from profdata
PROFDATA=$(find "$BUILD_DIR/local/Build/ProfileData" -name "Coverage.profdata" 2>/dev/null | head -1)
BINARY="$BUILD_DIR/local/Build/Products/Debug-iphonesimulator/${SCHEME}UITests-Runner.app/PlugIns/${SCHEME}UITests.xctest/${SCHEME}UITests"
# Merge profraw -> profdata and report
PROFDATA="${COVERAGE_DIR_ABS}/Coverage.profdata"
BINARY="$PRODUCTS/${SCHEME}UITests-Runner.app/PlugIns/${SCHEME}UITests.xctest/${SCHEME}UITests"

if [ -n "$PROFDATA" ] && [ -f "$BINARY" ]; then
if ls "${COVERAGE_DIR_ABS}"/*.profraw > /dev/null 2>&1 && [ -f "$BINARY" ]; then
xcrun llvm-profdata merge -sparse "${COVERAGE_DIR_ABS}"/*.profraw -o "$PROFDATA"
echo ""
echo "=== Coverage Report ==="
xcrun llvm-cov report "$BINARY" -instr-profile "$PROFDATA" -ignore-filename-regex='build/local/SourcePackages|DerivedSources'
Expand Down
97 changes: 97 additions & 0 deletions tests/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { execFileSync } from "node:child_process";

// A single booted simulator device entry from `simctl list devices -j`.
type SimDevice = { udid: string; state: string };

// Bundle id of the XCUITest runner (PRODUCT_BUNDLE_IDENTIFIER
// `com.mobilenext.devicekit-iosUITests` + the `.xctrunner` suffix Xcode adds).
// Launching it on a booted simulator starts the JSON-RPC server (see README).
const RUNNER_BUNDLE_ID = "com.mobilenext.devicekit-iosUITests.xctrunner";

// Must match the port in playwright.config.ts (baseURL http://localhost:12004).
const PORT = 12004;

// How long to wait for /health after launching the runner.
const HEALTH_TIMEOUT_MS = 120_000;
const HEALTH_POLL_INTERVAL_MS = 1_000;

function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

function findBootedSimulatorUdid(): string {
const override = process.env.DEVICEKIT_SIMULATOR_UDID;
if (override) {
return override;
}

const json = execFileSync("xcrun", ["simctl", "list", "devices", "booted", "-j"], {
encoding: "utf8",
});
const devices = JSON.parse(json).devices as Record<string, SimDevice[]>;
const booted = Object.values(devices)
.flat()
.find((device) => device.state === "Booted");

if (!booted) {
throw new Error(
"No booted simulator found. Boot one (e.g. `xcrun simctl boot <udid>`) or set DEVICEKIT_SIMULATOR_UDID.",
);
}
return booted.udid;
}

function killRunnerOnSimulator(udid: string): void {
try {
execFileSync("xcrun", ["simctl", "terminate", udid, RUNNER_BUNDLE_ID], { stdio: "ignore" });
} catch {
// Runner wasn't running — nothing to kill.
}
}

function launchRunnerOnSimulator(udid: string): void {
execFileSync("xcrun", ["simctl", "launch", udid, RUNNER_BUNDLE_ID], {
stdio: "ignore",
// simctl forwards SIMCTL_CHILD_* vars to the launched app (minus the prefix),
// so the server binds the port we expect. Other SIMCTL_CHILD_* vars already in
// the environment (e.g. LLVM_PROFILE_FILE for coverage) are passed through too.
env: {
...process.env,
SIMCTL_CHILD_DEVICEKIT_LISTEN_PORT: String(PORT),
},
});
}

function waitForServerHealthy(): void {
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const body = execFileSync("curl", ["-s", `http://localhost:${PORT}/health`], {
encoding: "utf8",
});
if (body.trim() === "OK") {
return;
}
} catch {
// Server not accepting connections yet.
}
sleepSync(HEALTH_POLL_INTERVAL_MS);
}
throw new Error(
`XCUITest server did not become healthy on port ${PORT} within ${HEALTH_TIMEOUT_MS / 1000}s`,
);
}

function globalSetup(): void {
const udid = findBootedSimulatorUdid();
console.log(`[global-setup] using simulator ${udid}`);

killRunnerOnSimulator(udid);
console.log(`[global-setup] launching ${RUNNER_BUNDLE_ID} on port ${PORT}`);
launchRunnerOnSimulator(udid);

waitForServerHealthy();
console.log(`[global-setup] server healthy on http://localhost:${PORT}`);
}

export default globalSetup;
Loading
Loading