From 3687ee4f04657395ca2d050e993c970223ed5ca9 Mon Sep 17 00:00:00 2001 From: athexweb3 Date: Mon, 29 Jun 2026 13:52:14 +0600 Subject: [PATCH] feat(interpose): wire the in-sim mechanism lane Turn the run-mechanism stubs into real orchestration that builds, boots a simulator, starts the helper to arm it, launches the guest example, and asserts the routed BLE event; adds the peripheral lane, the make mechanism-* targets, and a manual macOS CI job. The examples emit events to stderr so the run observes them. Fixes the slice suffix to .dylib so arming locates and injects it. --- .github/workflows/ci-hardware.yml | 13 +- Makefile | 12 +- examples/native/Peripheral/App.swift | 1 + examples/native/Sources/App.swift | 2 + examples/native/Watch/App.swift | 1 + packages/interpose/CMakeLists.txt | 1 + .../interpose/tests/run-mechanism-central.sh | 245 ++++++++++++++++-- .../tests/run-mechanism-peripheral-ios.sh | 211 +++++++++++++++ tests/README.md | 38 ++- 9 files changed, 490 insertions(+), 34 deletions(-) create mode 100755 packages/interpose/tests/run-mechanism-peripheral-ios.sh diff --git a/.github/workflows/ci-hardware.yml b/.github/workflows/ci-hardware.yml index beaeffe..dbf7a18 100644 --- a/.github/workflows/ci-hardware.yml +++ b/.github/workflows/ci-hardware.yml @@ -3,11 +3,20 @@ name: ci-hardware +# Manual only. The mechanism lane drives the Mac's real radio, so it needs a self-hosted macOS +# runner with Bluetooth granted to the helper and a BLE peer in range. GitHub-hosted runners have +# neither, so this never runs on push. on: workflow_dispatch: +permissions: + contents: read + jobs: ble-hardware: - runs-on: ubuntu-latest + # Requires a self-hosted macOS runner with Bluetooth and a BLE peer. + runs-on: [self-hosted, macOS] steps: - - run: echo "Hardware BLE mechanism tests are not implemented yet." + - uses: actions/checkout@v4 + - name: iOS central mechanism lane + run: make mechanism-ios diff --git a/Makefile b/Makefile index a633f52..5deab94 100644 --- a/Makefile +++ b/Makefile @@ -58,14 +58,14 @@ fence: ## Run static fence checks docs: ## Generate C API docs and fail on warnings doxygen Doxyfile -mechanism-ios: ## Placeholder target (not implemented) - @echo "mechanism-ios is not implemented" +mechanism-ios: ## Run the iOS central in-simulator lane (operator, needs Bluetooth and a BLE peer) + bash packages/interpose/tests/run-mechanism-central.sh ios -mechanism-watchos: ## Placeholder target (not implemented) - @echo "mechanism-watchos is not implemented" +mechanism-watchos: ## Run the watchOS central in-simulator lane (operator, needs Bluetooth and a BLE peer) + bash packages/interpose/tests/run-mechanism-central.sh watchos -mechanism-peripheral-ios: ## Placeholder target (not implemented) - @echo "mechanism-peripheral-ios is not implemented" +mechanism-peripheral-ios: ## Run the iOS peripheral in-simulator lane (operator, needs Bluetooth) + bash packages/interpose/tests/run-mechanism-peripheral-ios.sh lint: ## biome, swiftlint, and clang-tidy pnpm exec biome check . diff --git a/examples/native/Peripheral/App.swift b/examples/native/Peripheral/App.swift index ede6eae..c25ce41 100644 --- a/examples/native/Peripheral/App.swift +++ b/examples/native/Peripheral/App.swift @@ -114,6 +114,7 @@ final class PeripheralServer: NSObject, @preconcurrency CBPeripheralManagerDeleg private func append(_ text: String) { log.insert(LogLine(text: text), at: 0) + FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8)) } // MARK: CBPeripheralManagerDelegate diff --git a/examples/native/Sources/App.swift b/examples/native/Sources/App.swift index 688dee6..6ff5df1 100644 --- a/examples/native/Sources/App.swift +++ b/examples/native/Sources/App.swift @@ -113,6 +113,7 @@ final class CentralScanner: NSObject, @preconcurrency CBCentralManagerDelegate, private func append(_ text: String) { log.insert(LogLine(text: text), at: 0) + FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8)) } // MARK: CBCentralManagerDelegate @@ -137,6 +138,7 @@ final class CentralScanner: NSObject, @preconcurrency CBCentralManagerDelegate, found.append( Discovery( id: peripheral.identifier, name: name, rssi: RSSI.intValue, peripheral: peripheral)) + append("Found \(name) (\(RSSI) dBm)") } } diff --git a/examples/native/Watch/App.swift b/examples/native/Watch/App.swift index 1a8ee23..ea9878c 100644 --- a/examples/native/Watch/App.swift +++ b/examples/native/Watch/App.swift @@ -77,6 +77,7 @@ final class CentralConsole: NSObject, @preconcurrency CBCentralManagerDelegate, private func append(_ text: String) { log.insert(ConsoleLine(text: text), at: 0) + FileHandle.standardError.write(Data("[simble-example] \(text)\n".utf8)) } // MARK: CBCentralManagerDelegate diff --git a/packages/interpose/CMakeLists.txt b/packages/interpose/CMakeLists.txt index 629ef60..9acb5b7 100644 --- a/packages/interpose/CMakeLists.txt +++ b/packages/interpose/CMakeLists.txt @@ -33,6 +33,7 @@ if(SIMBLE_SIM_SLICE) PROPERTIES PREFIX "" OUTPUT_NAME "${SIMBLE_INTERPOSE_OUTPUT}" + SUFFIX ".dylib" ) target_link_libraries(simble_interpose PRIVATE interpose_core ${SIMBLE_FRAMEWORKS}) else() diff --git a/packages/interpose/tests/run-mechanism-central.sh b/packages/interpose/tests/run-mechanism-central.sh index ffe05c3..c75dd4d 100755 --- a/packages/interpose/tests/run-mechanism-central.sh +++ b/packages/interpose/tests/run-mechanism-central.sh @@ -3,29 +3,226 @@ # SPDX-FileCopyrightText: 2026 Nirapod Labs # # The in-simulator central lane: the interposer slice, injected into a guest app booted in the -# iOS Simulator, routes the guest's CoreBluetooth central calls to the helper, which drives the -# Mac's real radio and streams events back as the guest's delegate callbacks. This needs a booted -# simulator, the running helper, and a real BLE peripheral in range, so it runs on a self-hosted -# machine with Bluetooth, not in CI. It is not a CI gate. +# iOS or watchOS Simulator, routes the guest's CoreBluetooth central calls to the helper, which +# drives the Mac's real radio and streams events back as the guest's delegate callbacks. This needs +# a booted simulator, the running helper with Bluetooth authorized, and a real BLE peripheral in +# range, so it runs on a self-hosted machine with Bluetooth, not in CI. It is not a CI gate. # -# How it runs, once the pieces exist: -# 1. Build the iOS interposer slice: make configure && cmake --build build-sim -# The slice is build-sim/bin/simble-interpose.so, named simble-interpose. -# 2. Build and start the helper, which prints {"ready":true,"port":} on stdout and exposes -# the per-session capability token in lowercase hex through its session transport. -# 3. Boot a simulator and install a guest app that uses CoreBluetooth as a central. -# 4. Launch the guest with the slice injected and the helper pointed at, through the debug -# scheme that sets, behind the fence (debug-only, allowlisted): the dyld insert list pointing -# at the slice, SIMBLE_PORT at the helper port, and SIMBLE_TOKEN at the session token hex. -# 5. Drive the guest to scan, connect, discover, read, write, and subscribe; assert it sees the -# real peripheral's advertisements and characteristic values through its own delegate. +# Usage: run-mechanism-central.sh [ios|watchos] +# ios (default) builds the iphonesimulator slice and the SimBLEExample central. +# watchos builds the watchsimulator slice and the SimBLEWatchExample central. # -# The radio-free half of this lane runs as host ctests (make test): the swizzle install and -# uninstall (hook_smoke), the shadow registry mint and fail-closed (shadow_registry), the -# passthrough invariant (passthrough), and the transport round-trip against an in-test loopback -# (client_roundtrip). -set -uo pipefail - -echo "run-mechanism-central is the self-hosted in-simulator lane; it is not wired as a CI gate." -echo "See the header of this script for how it runs against a booted simulator and the helper." -exit 0 +# Env: +# SIMBLE_DEVICE a simulator name or udid to use; else a booted one of the platform, else a +# default the script boots and shuts down on exit. +# SIMBLE_TIMEOUT per-wait timeout in seconds (default 30). +# +# Steps: +# 1. Preconditions: Xcode, xcrun simctl, xcodegen. +# 2. Build the interposer slice, the helper, and simblectl. +# 3. Pick or boot a simulator of the platform. +# 4. Build and install the central example into it. +# 5. Start the helper; it arms the booted sim and writes its discovery record. Bail if Bluetooth +# is not authorized for the helper. +# 6. Confirm the bridge over simblectl status. +# 7. Launch the guest, capture its console, and assert a discovery line within the timeout. + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$REPO" + +PLATFORM="${1:-ios}" +TIMEOUT="${SIMBLE_TIMEOUT:-30}" + +case "$PLATFORM" in + ios) + BUILD_DIR="build-sim" + SCHEME="SimBLEExample" + BUNDLE_ID="dev.simble.SimBLEExample" + SIM_SDK="iphonesimulator" + DEST_PLATFORM="iOS Simulator" + RUNTIME_TOKEN="iOS" + DEFAULT_DEVICE="iPhone 16" + ;; + watchos) + BUILD_DIR="build-watchsim" + SCHEME="SimBLEWatchExample" + BUNDLE_ID="dev.simble.SimBLEWatchExample" + SIM_SDK="watchsimulator" + DEST_PLATFORM="watchOS Simulator" + RUNTIME_TOKEN="watchOS" + DEFAULT_DEVICE="Apple Watch Series 10 (46mm)" + ;; + *) + echo "usage: run-mechanism-central.sh [ios|watchos]" >&2 + exit 2 + ;; +esac + +WORKDIR="$(mktemp -d)" +HELPER_LOG="$WORKDIR/helper.log" +GUEST_LOG="$WORKDIR/guest.log" +HELPER_PID="" +GUEST_PID="" +BOOTED_BY_SCRIPT="" +SIMBLECTL="" + +# Kill the helper and the guest console, disarm the sim, clear the discovery record, and shut down +# the sim only when this script booted it. Runs on any exit. +cleanup() { + local code=$? + if [ -n "$GUEST_PID" ]; then kill "$GUEST_PID" 2>/dev/null || true; fi + if [ -n "${UDID:-}" ]; then + xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true + fi + if [ -n "$HELPER_PID" ]; then kill "$HELPER_PID" 2>/dev/null || true; fi + if [ -n "$SIMBLECTL" ]; then "$SIMBLECTL" disarm >/dev/null 2>&1 || true; fi + if [ -n "$BOOTED_BY_SCRIPT" ] && [ -n "${UDID:-}" ]; then + xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true + fi + rm -rf "$WORKDIR" + exit "$code" +} +trap cleanup EXIT INT TERM + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +# Wait until grep -q "$2" matches file "$1", or until "$3" seconds pass. Returns 0 on a match, +# 1 on timeout. +wait_for_line() { + local file="$1" pattern="$2" timeout="$3" waited=0 + while [ "$waited" -lt "$timeout" ]; do + if [ -f "$file" ] && grep -q "$pattern" "$file"; then return 0; fi + sleep 1 + waited=$((waited + 1)) + done + return 1 +} + +# Step 1: preconditions. +echo "== preconditions ==" +xcode-select -p >/dev/null 2>&1 || fail "Xcode command-line tools not selected; run xcode-select --install or xcode-select -s." +command -v xcrun >/dev/null 2>&1 || fail "xcrun not found; install Xcode." +xcrun simctl help >/dev/null 2>&1 || fail "xcrun simctl unavailable; install Xcode and its simulators." +command -v xcodegen >/dev/null 2>&1 || fail "xcodegen not found; install it (brew install xcodegen)." + +# Step 2: build the slice, the helper, and simblectl. +echo "== build interposer slice ($BUILD_DIR) ==" +make configure +cmake --build "$BUILD_DIR" -j || fail "interposer slice build failed for $PLATFORM." + +echo "== build helper ==" +( cd apps/helper && xcrun swift build ) || fail "helper build failed." +HELPER_BIN="$(cd apps/helper && xcrun swift build --show-bin-path)/simble-helper" +[ -x "$HELPER_BIN" ] || fail "helper binary not found at $HELPER_BIN." + +echo "== build simblectl ==" +( cd tools/simblectl && xcrun swift build ) || fail "simblectl build failed." +SIMBLECTL="$(cd tools/simblectl && xcrun swift build --show-bin-path)/simblectl" +[ -x "$SIMBLECTL" ] || fail "simblectl binary not found at $SIMBLECTL." + +# A device udid from `simctl list devices` plain text, restricted to runtime sections matching +# token $1. With a name/udid in $2, match that device in any state; without, the first Booted one. +# Output is the first match, or empty. +select_device() { + local token="$1" want="${2:-}" + xcrun simctl list devices 2>/dev/null | awk -v token="$token" -v want="$want" ' + /^-- / { insection = (index($0, token) > 0); next } + !insection { next } + { + if (match($0, /\(([0-9A-Fa-f-]{36})\)/)) { + udid = substr($0, RSTART + 1, RLENGTH - 2) + } else { next } + booted = (index($0, "(Booted)") > 0) + name = $0 + sub(/^[[:space:]]+/, "", name) + sub(/ \([0-9A-Fa-f-]{36}\).*/, "", name) + if (want == "") { if (booted) { print udid; exit } } + else if (name == want || udid == want) { print udid; exit } + }' +} + +# Step 3: pick or boot a simulator of the platform. +echo "== select simulator ==" +UDID="" +if [ -n "${SIMBLE_DEVICE:-}" ]; then + UDID="$(select_device "$RUNTIME_TOKEN" "$SIMBLE_DEVICE")" + [ -n "$UDID" ] || fail "SIMBLE_DEVICE '$SIMBLE_DEVICE' is not a $PLATFORM simulator." +else + UDID="$(select_device "$RUNTIME_TOKEN")" +fi + +if [ -z "$UDID" ]; then + echo "no booted $PLATFORM simulator; booting $DEFAULT_DEVICE" + UDID="$(xcrun simctl create "simble-$PLATFORM" "$DEFAULT_DEVICE" 2>/dev/null || true)" + [ -n "$UDID" ] || fail "could not create a $PLATFORM simulator; check available device types (xcrun simctl list devicetypes) and runtimes." + xcrun simctl boot "$UDID" || fail "could not boot simulator $UDID." + BOOTED_BY_SCRIPT=1 +fi + +xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true +echo "using simulator $UDID" + +# Step 4: build and install the central example. +echo "== build and install example ($SCHEME) ==" +( cd examples/native && xcodegen generate >/dev/null ) || fail "xcodegen generate failed." +DERIVED="$WORKDIR/DerivedData" +( cd examples/native && xcodebuild build \ + -project SimBLEExample.xcodeproj -scheme "$SCHEME" \ + -sdk "$SIM_SDK" -destination "platform=$DEST_PLATFORM,id=$UDID" \ + -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO >/dev/null ) \ + || fail "example build failed for $SCHEME." + +APP_PATH="$(find "$DERIVED/Build/Products" -maxdepth 2 -name "$SCHEME.app" -print -quit)" +[ -n "$APP_PATH" ] || fail "built $SCHEME.app not found under $DERIVED." +bash scripts/fence-check.sh --bundle "$APP_PATH" || fail "fence rejected the built app bundle." +xcrun simctl install "$UDID" "$APP_PATH" || fail "simctl install failed." + +# Step 5: start the helper; it arms the booted sim and writes its discovery record. +echo "== start helper ==" +"$HELPER_BIN" >"$HELPER_LOG" 2>&1 & +HELPER_PID=$! + +waited=0 +while [ "$waited" -lt "$TIMEOUT" ]; do + if grep -q '"ready":true' "$HELPER_LOG"; then break; fi + if grep -q "not authorized" "$HELPER_LOG"; then + echo "--- helper output ---" >&2 + cat "$HELPER_LOG" >&2 + fail "Bluetooth is not authorized for the helper. Grant it once interactively: run apps/helper (or the bundled host app) and approve the Bluetooth prompt, then retry." + fi + if ! kill -0 "$HELPER_PID" 2>/dev/null; then + echo "--- helper output ---" >&2 + cat "$HELPER_LOG" >&2 + fail "helper exited before it became ready." + fi + sleep 1 + waited=$((waited + 1)) +done +grep -q '"ready":true' "$HELPER_LOG" || fail "helper did not report ready within ${TIMEOUT}s." +echo "helper ready" + +# Step 6: confirm the bridge. +echo "== confirm bridge ==" +"$SIMBLECTL" status | grep -q '"running":true' || fail "simblectl status did not report the bridge running." +echo "bridge running" + +# Step 7: launch the guest and assert a discovery line. +echo "== launch guest and observe ($BUNDLE_ID) ==" +xcrun simctl launch --terminate-running-process --console-pty "$UDID" "$BUNDLE_ID" \ + >"$GUEST_LOG" 2>&1 & +GUEST_PID=$! + +if wait_for_line "$GUEST_LOG" '\[simble-example\] Found ' "$TIMEOUT"; then + echo "PASS: the guest central saw a peripheral through the bridge." + grep '\[simble-example\]' "$GUEST_LOG" | tail -5 + exit 0 +fi + +echo "--- guest console ---" >&2 +grep '\[simble-example\]' "$GUEST_LOG" >&2 || true +fail "no discovery within ${TIMEOUT}s. A real BLE peripheral must be advertising in range, and the guest must be scanning (tap Scan in the booted Simulator, or pair it with the peripheral lane)." diff --git a/packages/interpose/tests/run-mechanism-peripheral-ios.sh b/packages/interpose/tests/run-mechanism-peripheral-ios.sh new file mode 100755 index 0000000..9f9ac97 --- /dev/null +++ b/packages/interpose/tests/run-mechanism-peripheral-ios.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 Nirapod Labs +# +# The in-simulator peripheral lane: the interposer slice, injected into a guest app booted in the +# iOS Simulator, routes the guest's CoreBluetooth peripheral calls to the helper, which drives the +# Mac's real radio so the advertisement leaves the host. This needs a booted simulator and the +# running helper with Bluetooth authorized, so it runs on a self-hosted machine with Bluetooth, not +# in CI. It is not a CI gate. watchOS has no CBPeripheralManager, so this lane is iOS only. +# +# Usage: run-mechanism-peripheral-ios.sh +# +# Env: +# SIMBLE_DEVICE a simulator name or udid to use; else a booted iOS one, else a default the +# script boots and shuts down on exit. +# SIMBLE_TIMEOUT per-wait timeout in seconds (default 30). +# +# Steps: +# 1. Preconditions: Xcode, xcrun simctl, xcodegen. +# 2. Build the iphonesimulator interposer slice, the helper, and simblectl. +# 3. Pick or boot an iOS simulator. +# 4. Build and install the peripheral example into it. +# 5. Start the helper; it arms the booted sim and writes its discovery record. Bail if Bluetooth +# is not authorized for the helper. +# 6. Confirm the bridge over simblectl status. +# 7. Launch the guest, capture its console, and assert it engages advertising within the timeout. +# +# Confirming that a separate central discovers the advertisement is the operator's cross-device +# step: a phone, another Mac, or a second simulator running the central lane. + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$REPO" + +TIMEOUT="${SIMBLE_TIMEOUT:-30}" +BUILD_DIR="build-sim" +SCHEME="SimBLEPeripheralExample" +BUNDLE_ID="dev.simble.SimBLEPeripheralExample" +SIM_SDK="iphonesimulator" +DEST_PLATFORM="iOS Simulator" +RUNTIME_TOKEN="iOS" +DEFAULT_DEVICE="iPhone 16" + +WORKDIR="$(mktemp -d)" +HELPER_LOG="$WORKDIR/helper.log" +GUEST_LOG="$WORKDIR/guest.log" +HELPER_PID="" +GUEST_PID="" +BOOTED_BY_SCRIPT="" +SIMBLECTL="" + +# Kill the helper and the guest console, disarm the sim, clear the discovery record, and shut down +# the sim only when this script booted it. Runs on any exit. +cleanup() { + local code=$? + if [ -n "$GUEST_PID" ]; then kill "$GUEST_PID" 2>/dev/null || true; fi + if [ -n "${UDID:-}" ]; then + xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true + fi + if [ -n "$HELPER_PID" ]; then kill "$HELPER_PID" 2>/dev/null || true; fi + if [ -n "$SIMBLECTL" ]; then "$SIMBLECTL" disarm >/dev/null 2>&1 || true; fi + if [ -n "$BOOTED_BY_SCRIPT" ] && [ -n "${UDID:-}" ]; then + xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true + fi + rm -rf "$WORKDIR" + exit "$code" +} +trap cleanup EXIT INT TERM + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +# Wait until grep -q "$2" matches file "$1", or until "$3" seconds pass. Returns 0 on a match, +# 1 on timeout. +wait_for_line() { + local file="$1" pattern="$2" timeout="$3" waited=0 + while [ "$waited" -lt "$timeout" ]; do + if [ -f "$file" ] && grep -q "$pattern" "$file"; then return 0; fi + sleep 1 + waited=$((waited + 1)) + done + return 1 +} + +# A device udid from `simctl list devices` plain text, restricted to runtime sections matching +# token $1. With a name/udid in $2, match that device in any state; without, the first Booted one. +# Output is the first match, or empty. +select_device() { + local token="$1" want="${2:-}" + xcrun simctl list devices 2>/dev/null | awk -v token="$token" -v want="$want" ' + /^-- / { insection = (index($0, token) > 0); next } + !insection { next } + { + if (match($0, /\(([0-9A-Fa-f-]{36})\)/)) { + udid = substr($0, RSTART + 1, RLENGTH - 2) + } else { next } + booted = (index($0, "(Booted)") > 0) + name = $0 + sub(/^[[:space:]]+/, "", name) + sub(/ \([0-9A-Fa-f-]{36}\).*/, "", name) + if (want == "") { if (booted) { print udid; exit } } + else if (name == want || udid == want) { print udid; exit } + }' +} + +# Step 1: preconditions. +echo "== preconditions ==" +xcode-select -p >/dev/null 2>&1 || fail "Xcode command-line tools not selected; run xcode-select --install or xcode-select -s." +command -v xcrun >/dev/null 2>&1 || fail "xcrun not found; install Xcode." +xcrun simctl help >/dev/null 2>&1 || fail "xcrun simctl unavailable; install Xcode and its simulators." +command -v xcodegen >/dev/null 2>&1 || fail "xcodegen not found; install it (brew install xcodegen)." + +# Step 2: build the slice, the helper, and simblectl. +echo "== build interposer slice ($BUILD_DIR) ==" +make configure +cmake --build "$BUILD_DIR" -j || fail "interposer slice build failed." + +echo "== build helper ==" +( cd apps/helper && xcrun swift build ) || fail "helper build failed." +HELPER_BIN="$(cd apps/helper && xcrun swift build --show-bin-path)/simble-helper" +[ -x "$HELPER_BIN" ] || fail "helper binary not found at $HELPER_BIN." + +echo "== build simblectl ==" +( cd tools/simblectl && xcrun swift build ) || fail "simblectl build failed." +SIMBLECTL="$(cd tools/simblectl && xcrun swift build --show-bin-path)/simblectl" +[ -x "$SIMBLECTL" ] || fail "simblectl binary not found at $SIMBLECTL." + +# Step 3: pick or boot an iOS simulator. +echo "== select simulator ==" +UDID="" +if [ -n "${SIMBLE_DEVICE:-}" ]; then + UDID="$(select_device "$RUNTIME_TOKEN" "$SIMBLE_DEVICE")" + [ -n "$UDID" ] || fail "SIMBLE_DEVICE '$SIMBLE_DEVICE' is not an iOS simulator." +else + UDID="$(select_device "$RUNTIME_TOKEN")" +fi + +if [ -z "$UDID" ]; then + echo "no booted iOS simulator; booting $DEFAULT_DEVICE" + UDID="$(xcrun simctl create "simble-ios" "$DEFAULT_DEVICE" 2>/dev/null || true)" + [ -n "$UDID" ] || fail "could not create an iOS simulator; check available device types (xcrun simctl list devicetypes) and runtimes." + xcrun simctl boot "$UDID" || fail "could not boot simulator $UDID." + BOOTED_BY_SCRIPT=1 +fi + +xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true +echo "using simulator $UDID" + +# Step 4: build and install the peripheral example. +echo "== build and install example ($SCHEME) ==" +( cd examples/native && xcodegen generate >/dev/null ) || fail "xcodegen generate failed." +DERIVED="$WORKDIR/DerivedData" +( cd examples/native && xcodebuild build \ + -project SimBLEExample.xcodeproj -scheme "$SCHEME" \ + -sdk "$SIM_SDK" -destination "platform=$DEST_PLATFORM,id=$UDID" \ + -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO >/dev/null ) \ + || fail "example build failed for $SCHEME." + +APP_PATH="$(find "$DERIVED/Build/Products" -maxdepth 2 -name "$SCHEME.app" -print -quit)" +[ -n "$APP_PATH" ] || fail "built $SCHEME.app not found under $DERIVED." +bash scripts/fence-check.sh --bundle "$APP_PATH" || fail "fence rejected the built app bundle." +xcrun simctl install "$UDID" "$APP_PATH" || fail "simctl install failed." + +# Step 5: start the helper; it arms the booted sim and writes its discovery record. +echo "== start helper ==" +"$HELPER_BIN" >"$HELPER_LOG" 2>&1 & +HELPER_PID=$! + +waited=0 +while [ "$waited" -lt "$TIMEOUT" ]; do + if grep -q '"ready":true' "$HELPER_LOG"; then break; fi + if grep -q "not authorized" "$HELPER_LOG"; then + echo "--- helper output ---" >&2 + cat "$HELPER_LOG" >&2 + fail "Bluetooth is not authorized for the helper. Grant it once interactively: run apps/helper (or the bundled host app) and approve the Bluetooth prompt, then retry." + fi + if ! kill -0 "$HELPER_PID" 2>/dev/null; then + echo "--- helper output ---" >&2 + cat "$HELPER_LOG" >&2 + fail "helper exited before it became ready." + fi + sleep 1 + waited=$((waited + 1)) +done +grep -q '"ready":true' "$HELPER_LOG" || fail "helper did not report ready within ${TIMEOUT}s." +echo "helper ready" + +# Step 6: confirm the bridge. +echo "== confirm bridge ==" +"$SIMBLECTL" status | grep -q '"running":true' || fail "simblectl status did not report the bridge running." +echo "bridge running" + +# Step 7: launch the guest and assert it engages advertising. +echo "== launch guest and observe ($BUNDLE_ID) ==" +xcrun simctl launch --terminate-running-process --console-pty "$UDID" "$BUNDLE_ID" \ + >"$GUEST_LOG" 2>&1 & +GUEST_PID=$! + +if wait_for_line "$GUEST_LOG" '\[simble-example\] Advertising as ' "$TIMEOUT"; then + echo "PASS: the guest peripheral engaged advertising through the bridge." + grep '\[simble-example\]' "$GUEST_LOG" | tail -5 + echo "NOTE: confirm a separate central discovers it (a phone, another Mac, or a second simulator running the central lane, run-mechanism-central.sh ios)." + exit 0 +fi + +echo "--- guest console ---" >&2 +grep '\[simble-example\]' "$GUEST_LOG" >&2 || true +fail "advertising did not engage within ${TIMEOUT}s. The guest must start advertising (tap Advertise in the booted Simulator); check the helper has Bluetooth and the bridge is up." diff --git a/tests/README.md b/tests/README.md index 9c54f55..b1d483e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,5 +5,39 @@ SPDX-FileCopyrightText: 2026 Nirapod Labs # Tests -The scaffold includes portable C and Swift package tests. The hardware mechanism -tests are not implemented yet. +Portable C and Swift package tests run with `make test`. They cover the swizzle +install and uninstall, the shadow registry, the passthrough invariant, and the +transport round-trip, with no radio and no simulator. + +## Mechanism lane + +The mechanism lane runs a guest example app in the Simulator end to end: it +builds the interposer slice, starts the helper (which arms the booted simulator +and drives the Mac's radio), installs and launches the example, and reads the +guest's console to confirm a real radio event reached it. + +```sh +make mechanism-ios # iOS central: assert the guest sees a peripheral +make mechanism-watchos # watchOS central: same, on the watch slice +make mechanism-peripheral-ios # iOS peripheral: assert the guest engages advertising +``` + +Preconditions, checked by each script with a clear message when one is missing: + +- Xcode and `xcrun simctl`, plus `xcodegen` to generate the example project. +- A simulator runtime for the platform. With none booted, the script boots a + default device and shuts it down on exit. +- Bluetooth granted to the helper. The helper refuses to touch the radio without + it; grant it once by running the helper (or the bundled host app) and + approving the prompt. +- For the central lanes, a real BLE peripheral advertising in range. For the + peripheral lane, confirming a separate central discovers the advertisement is + a cross-device step (a phone, another Mac, or a second simulator running the + central lane). + +Set `SIMBLE_DEVICE` to a simulator name or udid to pick one; set `SIMBLE_TIMEOUT` +to change the per-wait timeout (default 30 seconds). + +These lanes need Bluetooth and a peer, so they are operator-run, not CI gates. +The `ci-hardware` workflow runs `make mechanism-ios` on a self-hosted macOS +runner with Bluetooth, dispatched manually.