diff --git a/.github/actions/save-macos-caches/action.yml b/.github/actions/save-macos-caches/action.yml new file mode 100644 index 000000000..ea38f45be --- /dev/null +++ b/.github/actions/save-macos-caches/action.yml @@ -0,0 +1,44 @@ +name: Save macOS build caches +description: Save the caches that setup-macos restored without an exact match. Run it at the end of the job, after the build input times are saved. + +# The repository has a 10 GB cache quota, and the large entries (SwiftPM, CLI and App build +# state) change with every source change. Only main saves them, so that pull requests do not +# evict the entries of main, which all pull requests restore. The small Mise and GhosttyKit +# entries change rarely, and a GhosttyKit miss is slow to rebuild, so pull requests save them. +runs: + using: composite + steps: + - if: env.PROWL_MISE_CACHE_HIT != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.local/share/mise + ~/.cache/mise + key: ${{ env.PROWL_MISE_CACHE_KEY }} + - if: env.PROWL_GHOSTTY_CACHE_HIT != 'true' + uses: actions/cache/save@v4 + with: + path: | + App/Frameworks/GhosttyKit.xcframework + App/Resources/ghostty + App/Resources/terminfo + .ghostty_hash + .ghostty_build_stamp + key: ${{ env.PROWL_GHOSTTY_CACHE_KEY }} + - if: env.PROWL_SPM_CACHE_HIT != 'true' && github.ref == 'refs/heads/main' + uses: actions/cache/save@v4 + with: + path: ~/Library/Caches/prowl-spm-cache/SourcePackages + key: ${{ env.PROWL_SPM_CACHE_KEY }} + - if: env.PROWL_CLI_CACHE_HIT != 'true' && github.ref == 'refs/heads/main' + uses: actions/cache/save@v4 + with: + path: | + CLI/.build + Mirror/Relay/.build + key: ${{ env.PROWL_CLI_CACHE_KEY }} + - if: env.PROWL_APP_CACHE_HIT != 'true' && github.ref == 'refs/heads/main' + uses: actions/cache/save@v4 + with: + path: ${{ env.PROWL_DERIVED_DATA_PATH }} + key: ${{ env.PROWL_APP_CACHE_KEY }} diff --git a/.github/actions/setup-macos/action.yml b/.github/actions/setup-macos/action.yml index a095f5777..0474118a5 100644 --- a/.github/actions/setup-macos/action.yml +++ b/.github/actions/setup-macos/action.yml @@ -1,11 +1,12 @@ name: Setup macOS build deps -description: Install + cache mise tools and GhosttyKit build outputs +description: Install mise tools and restore the build caches. Run save-macos-caches at the end of the job to save them. runs: using: composite steps: - name: Mise cache - uses: actions/cache@v4 + id: mise_cache + uses: actions/cache/restore@v4 with: path: | ~/.local/share/mise @@ -31,7 +32,8 @@ runs: echo "Xcode cache identity: $XCODE_CACHE_ID" - name: CLI SwiftPM build cache - uses: actions/cache@v4 + id: cli_cache + uses: actions/cache/restore@v4 with: path: | CLI/.build @@ -53,7 +55,7 @@ runs: printf '%s\n' "GHOSTTY_SHA=$GHOSTTY_SHA" >> "$GITHUB_ENV" - name: Ghostty cache id: ghostty_cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: | App/Frameworks/GhosttyKit.xcframework @@ -76,26 +78,14 @@ runs: touch .ghostty_build_stamp - name: SPM cache - uses: actions/cache@v4 + id: spm_cache + uses: actions/cache/restore@v4 with: path: ~/Library/Caches/prowl-spm-cache/SourcePackages key: ${{ runner.os }}-${{ runner.arch }}-spm-v1-${{ hashFiles('.package.resolved') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-spm-v1- - - name: Xcode compilation cache - uses: actions/cache@v4 - with: - path: ~/Library/Developer/Xcode/DerivedData/CompilationCache.noindex - # GitHub cache entries are immutable. A source-state suffix guarantees that a - # successful changed build saves newly compiled objects; restore-keys select the - # newest compatible CAS. Never restore across Xcode builds because compiler cache - # keys and storage formats are toolchain-specific. - key: ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v3-${{ env.XCODE_CACHE_ID }}-${{ hashFiles('.package.resolved', 'Tuist.swift', 'Workspace.swift', 'App/Project.swift', 'App/Config/**', 'mise.toml', 'Makefile', '.github/actions/setup-macos/action.yml', '.github/workflows/test.yml') }}-${{ hashFiles('App/**/*.swift', 'Shared/Sources/**/*.swift', 'Mirror/Shared/*.swift', 'Mirror/Relay/Sources/**/*.swift') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v3-${{ env.XCODE_CACHE_ID }}-${{ hashFiles('.package.resolved', 'Tuist.swift', 'Workspace.swift', 'App/Project.swift', 'App/Config/**', 'mise.toml', 'Makefile', '.github/actions/setup-macos/action.yml', '.github/workflows/test.yml') }}- - ${{ runner.os }}-${{ runner.arch }}-xcode-compilation-cache-v3-${{ env.XCODE_CACHE_ID }}- - - name: App incremental cache identity shell: bash run: | @@ -105,7 +95,8 @@ runs: APP_WORKSPACE_ID="$(pwd -P | shasum -a 256 | cut -d ' ' -f 1)" printf '%s\n' "APP_INPUT_PATHS_ID=$APP_INPUT_PATHS_ID" "APP_WORKSPACE_ID=$APP_WORKSPACE_ID" >> "$GITHUB_ENV" - name: App incremental build cache - uses: actions/cache@v4 + id: app_cache + uses: actions/cache/restore@v4 with: path: ${{ env.PROWL_DERIVED_DATA_PATH }} # Incremental state needs a stricter boundary than the content-addressed cache. @@ -113,6 +104,43 @@ runs: key: ${{ runner.os }}-${{ runner.arch }}-app-incremental-v2-${{ env.XCODE_CACHE_ID }}-${{ env.APP_WORKSPACE_ID }}-${{ env.GHOSTTY_SHA }}-${{ env.APP_INPUT_PATHS_ID }}-${{ hashFiles('CLI/Package.swift', 'CLI/Package.resolved', 'Shared/Package.swift', 'Mirror/Relay/Package.swift', '.package.resolved', 'Tuist.swift', 'Workspace.swift', 'App/Project.swift', 'App/Config/**', 'mise.toml', 'Makefile', '.github/actions/setup-macos/action.yml', '.github/workflows/test.yml', 'scripts/ci-source-mtimes.py') }}-${{ hashFiles('App/**/*.swift', 'Shared/Sources/**/*.swift', 'Mirror/Shared/*.swift', 'Mirror/Relay/Sources/**/*.swift') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-app-incremental-v2-${{ env.XCODE_CACHE_ID }}-${{ env.APP_WORKSPACE_ID }}-${{ env.GHOSTTY_SHA }}-${{ env.APP_INPUT_PATHS_ID }}-${{ hashFiles('CLI/Package.swift', 'CLI/Package.resolved', 'Shared/Package.swift', 'Mirror/Relay/Package.swift', '.package.resolved', 'Tuist.swift', 'Workspace.swift', 'App/Project.swift', 'App/Config/**', 'mise.toml', 'Makefile', '.github/actions/setup-macos/action.yml', '.github/workflows/test.yml', 'scripts/ci-source-mtimes.py') }}- + # Xcode keeps the compilation cache (CAS) in the DerivedData folder, so the App incremental + # cache carries it. When no compatible incremental state exists (changed file roster or build + # configuration), keep only the CAS of the newest App build for this Xcode. The build is then + # clean, but it replays compiler outputs from the content-addressed cache. + - name: Compilation cache of an earlier App build + id: app_cas + if: steps.app_cache.outputs.cache-matched-key == '' + uses: actions/cache/restore@v4 + with: + path: ${{ env.PROWL_DERIVED_DATA_PATH }} + key: ${{ runner.os }}-${{ runner.arch }}-app-incremental-v2-${{ env.XCODE_CACHE_ID }}-${{ env.APP_WORKSPACE_ID }}- + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-app-incremental-v2-${{ env.XCODE_CACHE_ID }}-${{ env.APP_WORKSPACE_ID }}- + - name: Keep only the compilation cache + if: steps.app_cas.outputs.cache-matched-key != '' + shell: bash + run: | + set -euo pipefail + find "$PROWL_DERIVED_DATA_PATH" -mindepth 1 -maxdepth 1 ! -name CompilationCache.noindex -exec rm -rf {} + + du -sh "$PROWL_DERIVED_DATA_PATH/CompilationCache.noindex" 2>/dev/null || echo "No compilation cache in the restored state" - name: Restore unchanged App source times shell: bash run: python3 scripts/ci-source-mtimes.py restore --scope app --manifest "$PROWL_DERIVED_DATA_PATH/source-times.json" + + - name: Cache keys for save-macos-caches + shell: bash + run: | + set -euo pipefail + { + echo "PROWL_MISE_CACHE_KEY=${{ steps.mise_cache.outputs.cache-primary-key }}" + echo "PROWL_MISE_CACHE_HIT=${{ steps.mise_cache.outputs.cache-hit }}" + echo "PROWL_CLI_CACHE_KEY=${{ steps.cli_cache.outputs.cache-primary-key }}" + echo "PROWL_CLI_CACHE_HIT=${{ steps.cli_cache.outputs.cache-hit }}" + echo "PROWL_GHOSTTY_CACHE_KEY=${{ steps.ghostty_cache.outputs.cache-primary-key }}" + echo "PROWL_GHOSTTY_CACHE_HIT=${{ steps.ghostty_cache.outputs.cache-hit }}" + echo "PROWL_SPM_CACHE_KEY=${{ steps.spm_cache.outputs.cache-primary-key }}" + echo "PROWL_SPM_CACHE_HIT=${{ steps.spm_cache.outputs.cache-hit }}" + echo "PROWL_APP_CACHE_KEY=${{ steps.app_cache.outputs.cache-primary-key }}" + echo "PROWL_APP_CACHE_HIT=${{ steps.app_cache.outputs.cache-hit }}" + } >> "$GITHUB_ENV" diff --git a/.github/workflows/mirror-android.yml b/.github/workflows/mirror-android.yml new file mode 100644 index 000000000..2d88f9090 --- /dev/null +++ b/.github/workflows/mirror-android.yml @@ -0,0 +1,44 @@ +name: mirror-android + +# The Android mirror client needs only a minimal check, so CI runs it only when the client +# changes and for each published release. +on: + push: + branches: + - main + paths: + - Mirror/Android/** + - .github/workflows/mirror-android.yml + pull_request: + paths: + - Mirror/Android/** + - .github/workflows/mirror-android.yml + # Release tags are lightweight, so `git push --follow-tags` does not push them and + # `gh release create` makes them on GitHub. That sends no tag push event. + release: + types: + - published + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mirror-android: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '17' + cache: gradle + - uses: android-actions/setup-android@v4 + - name: Install Android SDK 34 + run: sdkmanager 'platforms;android-34' 'build-tools;34.0.0' + - name: Build and test Android mirror + working-directory: Mirror/Android + run: ./gradlew --no-daemon :app:assembleDebug :app:testDebugUnitTest :app:lintDebug diff --git a/.github/workflows/mirror-ios.yml b/.github/workflows/mirror-ios.yml new file mode 100644 index 000000000..08e6d2e0f --- /dev/null +++ b/.github/workflows/mirror-ios.yml @@ -0,0 +1,64 @@ +name: mirror-ios + +# The iOS mirror client needs only a minimal check, so CI runs its unit tests only when the +# client changes and for each published release. The UI suite runs locally with +# `make test-mirror-ios`. +on: + push: + branches: + - main + paths: + - Mirror/iOS/** + - Mirror/Shared/** + - scripts/select_ios_simulator.py + - .github/workflows/mirror-ios.yml + pull_request: + paths: + - Mirror/iOS/** + - Mirror/Shared/** + - scripts/select_ios_simulator.py + - .github/workflows/mirror-ios.yml + # Release tags are lightweight, so `git push --follow-tags` does not push them and + # `gh release create` makes them on GitHub. That sends no tag push event. + release: + types: + - published + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The project needs no submodule, GhosttyKit, CLI, or Swift package, so this job does not use + # setup-macos. It also must not save the setup-macos caches, or it would take the cache keys + # of the Mac app job. + mirror-ios: + runs-on: macos-26 + timeout-minutes: 20 + permissions: + contents: read + env: + MISE_HTTP_TIMEOUT: 120 + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Install only the tools that the iOS mirror targets use. + MISE_EXEC_AUTO_INSTALL: false + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - uses: actions/checkout@v6 + - name: Install Tuist and xcsift + run: | + brew install mise + mise install tuist github:ldomaradzki/xcsift + - name: Test iOS mirror + run: make test-mirror-ios-unit + - name: Upload xcresult bundle and log (on failure) + # A job that reaches its timeout is cancelled, and its partial results are still useful. + if: failure() || cancelled() + uses: actions/upload-artifact@v4 + with: + name: mirror-ios-xcresult-${{ github.run_id }}-${{ github.run_attempt }} + path: | + build/test-results/mirror-ios-unit.xcresult + build/test-results/mirror-ios-unit.xcresult.log + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 306e7f23b..272773276 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,20 @@ name: test +# The Mac app. The mirror clients have their own workflows, and a change to only those +# clients does not run this one. on: push: branches: - main + paths-ignore: + - Mirror/iOS/** + - Mirror/Android/** + - .github/workflows/mirror-*.yml pull_request: + paths-ignore: + - Mirror/iOS/** + - Mirror/Android/** + - .github/workflows/mirror-*.yml concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -28,8 +38,6 @@ jobs: with: submodules: recursive - uses: ./.github/actions/setup-macos - - run: make lint - - run: make check-localization - name: Stage debug app resources run: make embed-cli-debug embed-docs embed-skills - name: Generate workspace @@ -74,18 +82,19 @@ jobs: # SwiftPM serializes access to .build; keep its checks in one branch. run_task cli-tests make test-cli-smoke test-cli-unit test-cli-integration & pid_cli=$! - run_task scripts make test-scripts & - pid_scripts=$! + # Lint and the localization check need no build, so they run beside it. + run_task checks make lint check-localization test-scripts & + pid_checks=$! failed=0 wait "$pid_app" || failed=1 wait "$pid_cli" || failed=1 - wait "$pid_scripts" || failed=1 + wait "$pid_checks" || failed=1 { printf '## Build and test wall time\n\n| Task | Seconds | Exit status |\n| --- | ---: | ---: |\n' cat build/ci-logs/*.timing printf '\nTasks run in parallel; their times must not be added.\n\n' - python3 scripts/summarize-xcode-build.py build/test-results/*.build.json + python3 scripts/summarize-xcode-build.py build/test-results/*.build.json build/test-results/*.action.json } >> "$GITHUB_STEP_SUMMARY" exit "$failed" - name: Collect mirror diagnostics @@ -100,6 +109,7 @@ jobs: run: | python3 scripts/ci-source-mtimes.py save python3 scripts/ci-source-mtimes.py save --scope app --manifest "$PROWL_DERIVED_DATA_PATH/source-times.json" + - uses: ./.github/actions/save-macos-caches - name: Upload build and test logs if: always() uses: actions/upload-artifact@v4 @@ -109,6 +119,7 @@ jobs: build/ci-logs/ build/test-results/*.log build/test-results/*.build.json + build/test-results/*.action.json if-no-files-found: ignore retention-days: 7 - name: Upload xcresult bundle (on failure) @@ -119,59 +130,3 @@ jobs: path: build/test-results/**/*.xcresult if-no-files-found: ignore retention-days: 7 - - # Runs in parallel with `build`, one job for each simulator form factor. The iOS mirror - # project needs no submodule, GhosttyKit, CLI, or Swift package, so this job does not use - # setup-macos. It also must not save the setup-macos caches: this job ends first and would - # take the cache keys of `build`. - ios-mirror: - name: ios-mirror (${{ matrix.form-factor }}) - runs-on: macos-26 - timeout-minutes: 30 - permissions: - contents: read - strategy: - fail-fast: false - matrix: - form-factor: [ipad, iphone] - env: - MISE_HTTP_TIMEOUT: 120 - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Install only the tools that the iOS mirror targets use. - MISE_EXEC_AUTO_INSTALL: false - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - steps: - - uses: actions/checkout@v6 - - name: Install Tuist and xcsift - run: | - brew install mise - mise install tuist github:ldomaradzki/xcsift - - name: Test iOS mirror - run: make test-mirror-ios-${{ matrix.form-factor }} - - name: Upload xcresult bundle (on failure) - if: failure() - uses: actions/upload-artifact@v4 - with: - name: ios-mirror-${{ matrix.form-factor }}-xcresult-${{ github.run_id }}-${{ github.run_attempt }} - path: build/test-results/mirror-ios-${{ matrix.form-factor }}.xcresult - if-no-files-found: ignore - retention-days: 7 - - android-mirror: - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v6 - with: - distribution: temurin - java-version: '17' - cache: gradle - - uses: android-actions/setup-android@v4 - - name: Install Android SDK 34 - run: sdkmanager 'platforms;android-34' 'build-tools;34.0.0' - - name: Build and test Android mirror - working-directory: Mirror/Android - run: ./gradlew --no-daemon :app:assembleDebug :app:testDebugUnitTest :app:lintDebug diff --git a/AGENTS.md b/AGENTS.md index b52670c31..3fb42fe86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,8 @@ make format # Run full-tree swift-format cleanup make lint # Run swiftlint only make check # Run changed-file format, swift-format lint, swiftlint, and the string catalog check make audit-localization # Release check of the string catalog and unlocalized UI copy (see the sync-l10n skill) -make test # Run all tests +make test # Run the Mac app tests +make test-all # Run every test suite: scripts, CLI, Mac app, iOS mirror (unit and UI), Android mirror make benchmark-build # Benchmark CI-like clean/warm-CAS build and test time make bench # Run performance benchmarks with -O; append absolute medians to ~/Library/Logs/Prowl/measurements/bench/ make measure-cpu # Steady-state CPU + per-symbol attribution of the running Prowl Debug app diff --git a/Makefile b/Makefile index ba1180463..b11c084dd 100644 --- a/Makefile +++ b/Makefile @@ -26,18 +26,9 @@ APP_SCHEME := Prowl TEST_TARGET := ProwlTests IOS_MIRROR_PROJECT := $(CURRENT_MAKEFILE_DIR)/Mirror/iOS/ProwlMirror-iOS.xcodeproj IOS_MIRROR_SCHEME := ProwlMirror-iOS -# Regular expressions for the full simulator name. Each Xcode release renames the models, +# Regular expression for the full simulator name. Each Xcode release renames the models, # so scripts/select_ios_simulator.py uses the newest match on the newest supported runtime. -IOS_MIRROR_IPAD_SIMULATOR_PATTERN ?= iPad Pro 11-inch \(M[0-9]+\) -IOS_MIRROR_IPHONE_SIMULATOR_PATTERN ?= iPhone [0-9]+ Pro -IOS_MIRROR_IPHONE_ONLY_TESTS := \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testClearingBothPairingHalvesAllowsCredentialReconnect \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testHostBoundariesDisableOnlyTheReachedDirection \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testRemoteScrollAlwaysRevealsTheTopWhileInteriorDragStaysLocal \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testRemoteScrollButtonsLeaveLocalGesturesAndHistoryUnchanged \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testRemoteScrollShowsLoadingAndKeepsHistoryIndependent \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testSelectingTextDoesNotScrollTheHost \ - ProwlMirror-iOSUITests/ProwlMirror_iOSUITests/testPhoneStartsInDetailAndCanAddConnection +IOS_MIRROR_SIMULATOR_PATTERN ?= iPad Pro 11-inch \(M[0-9]+\) XCODE_CONFIG_DIR := $(APP_DIR)/Config VERSION_XCCONFIG := $(XCODE_CONFIG_DIR)/Version.xcconfig TUIST_STAMP := $(CURRENT_MAKEFILE_DIR)/.tuist_generated_stamp @@ -100,7 +91,7 @@ TEST_SIGNING_ARGS := CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_ endif .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash generate ensure-project ensure-mirror-ios-project build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs embed-skills run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-app test-mirror-ios test-mirror-ios-ipad test-mirror-ios-iphone test-scripts test-cli-smoke test-cli-unit test-cli-integration benchmark-build bump-version log-stream agent-versions +.PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash generate ensure-project ensure-mirror-ios-project build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs embed-skills run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-all test-app test-mirror-ios test-mirror-ios-unit test-mirror-android test-scripts test-cli-smoke test-cli-unit test-cli-integration benchmark-build bump-version log-stream agent-versions .PHONY: test-agent-contracts _test-agent-contract-codex _test-agent-contract-export help: # Display this help. @@ -419,34 +410,50 @@ export-archive: # Export xarchive test: ensure-ghostty embed-cli-debug embed-docs embed-skills test-app +# Recipe lines, not prerequisites, so that `make -j` cannot start two xcodebuild runs: a second +# `xcodebuild test` in the same checkout can hang. The quick suites run first. +test-all: # Run every test suite: scripts, CLI, Mac app, iOS mirror (unit and UI), Android mirror + $(MAKE) test-scripts + $(MAKE) test-cli-unit + $(MAKE) test-cli-smoke + $(MAKE) test-cli-integration + $(MAKE) test + $(MAKE) test-mirror-ios + $(MAKE) test-mirror-android + # Internal: generate only the iOS mirror project. It has no package and does not need the # Mac app's build inputs (GhosttyKit, CLI, bundled resources), so the CI job can skip them. ensure-mirror-ios-project: @cd "$(CURRENT_MAKEFILE_DIR)" && mise exec -- tuist generate --path Mirror/iOS --no-open -# Run iOS mirror tests on one simulator form factor. -# $(1): result bundle name, $(2): simulator name pattern, $(3): test selection arguments +# Run iOS mirror tests on the iPad simulator. +# $(1): result bundle name, $(2): test selection arguments +# The UI tests take minutes and xcsift reports only at the end, so a heartbeat on stderr shows +# progress, and the raw log stays next to the result bundle. The result bundle keeps failures and +# screenshots; the simulator diagnostics collection can wait 10 minutes for a response, so it is off. define run_mirror_ios_tests @set -euo pipefail; \ result_bundle="$(CURRENT_MAKEFILE_DIR)/build/test-results/$(1).xcresult"; \ mkdir -p "$${result_bundle%/*}"; \ -destination="$$(python3 "$(CURRENT_MAKEFILE_DIR)/scripts/select_ios_simulator.py" "$(2)")"; \ +destination="$$(python3 "$(CURRENT_MAKEFILE_DIR)/scripts/select_ios_simulator.py" "$(IOS_MIRROR_SIMULATOR_PATTERN)")"; \ rm -rf "$$result_bundle"; \ xcodebuild test -project "$(IOS_MIRROR_PROJECT)" -scheme "$(IOS_MIRROR_SCHEME)" \ -destination "$$destination" -resultBundlePath "$$result_bundle" -parallel-testing-enabled NO \ - $(3) 2>&1 | mise exec -- xcsift -w --format toon + -collect-test-diagnostics never \ + $(2) 2>&1 | tee "$$result_bundle.log" \ + | tee >(PROWL_TEST_PROGRESS_LABEL=$(1) PROWL_TEST_PROGRESS_INTERVAL=5 \ + awk -f "$(CURRENT_MAKEFILE_DIR)/scripts/test-progress.awk" >&2) \ + | mise exec -- xcsift -w --format toon endef -test-mirror-ios: test-mirror-ios-ipad test-mirror-ios-iphone # Run iOS mirror unit/UI tests on their matching simulator form factors +test-mirror-ios: ensure-mirror-ios-project # Run the iOS mirror unit and UI tests + $(call run_mirror_ios_tests,mirror-ios,-only-testing:ProwlMirror-iOSTests -only-testing:ProwlMirror-iOSUITests) -test-mirror-ios-ipad: ensure-mirror-ios-project # Run the iOS mirror unit tests and the iPad UI tests - $(call run_mirror_ios_tests,mirror-ios-ipad,$(IOS_MIRROR_IPAD_SIMULATOR_PATTERN),\ - -only-testing:ProwlMirror-iOSTests -only-testing:ProwlMirror-iOSUITests \ - $(foreach test,$(IOS_MIRROR_IPHONE_ONLY_TESTS),-skip-testing:$(test))) +test-mirror-ios-unit: ensure-mirror-ios-project # Run the iOS mirror unit tests (CI runs only these) + $(call run_mirror_ios_tests,mirror-ios-unit,-only-testing:ProwlMirror-iOSTests) -test-mirror-ios-iphone: ensure-mirror-ios-project # Run the iOS mirror UI tests that need an iPhone - $(call run_mirror_ios_tests,mirror-ios-iphone,$(IOS_MIRROR_IPHONE_SIMULATOR_PATTERN),\ - $(foreach test,$(IOS_MIRROR_IPHONE_ONLY_TESTS),-only-testing:$(test))) +test-mirror-android: # Build, unit test and lint the Android mirror (needs JDK 17 and Android SDK 34) + cd "$(CURRENT_MAKEFILE_DIR)/Mirror/Android" && ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug test-scripts: # Run tests for the repository's scripts @python3 -m unittest discover -s "$(CURRENT_MAKEFILE_DIR)/scripts" -p 'test_*.py' @@ -474,6 +481,10 @@ test-app: ensure-ghostty ensure-project # Run app/unit tests via xcodebuild xcrun xcresulttool get log --path "$$result_bundle" --type build --compact > "$$result_bundle.build.json" \ || echo "warning: could not export Xcode build metrics" >&2; \ fi; \ + if [ -d "$$result_bundle" ]; then \ + xcrun xcresulttool get log --path "$$result_bundle" --type action --compact > "$$result_bundle.action.json" \ + || echo "warning: could not export the Xcode test action log" >&2; \ + fi; \ if [ "$$xcodebuild_status" -ne 0 ]; then \ bash "$(CURRENT_MAKEFILE_DIR)/scripts/print-xcresult-failures.sh" "$$result_bundle" || true; \ return "$$xcodebuild_status"; \ diff --git a/Mirror/Android/README.md b/Mirror/Android/README.md index b7a846b83..855a5e79a 100644 --- a/Mirror/Android/README.md +++ b/Mirror/Android/README.md @@ -64,6 +64,8 @@ Android Studio's bundled JBR or JDK 17 with an installed Android SDK 34. Set ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug +From the repository root, `make test-mirror-android` runs the same tasks. + APK: `app/build/outputs/apk/debug/app-debug.apk`. With an available emulator/device: diff --git a/Mirror/iOS/ProwlMirror-iOS/ContentView.swift b/Mirror/iOS/ProwlMirror-iOS/ContentView.swift index 978db87bf..5d6f3c7e0 100644 --- a/Mirror/iOS/ProwlMirror-iOS/ContentView.swift +++ b/Mirror/iOS/ProwlMirror-iOS/ContentView.swift @@ -6,13 +6,21 @@ struct ContentView: View { @State private var sessions: [MirrorSession] = [] @State private var selectedID: UUID? @State private var showsConnection = false - @State private var columns: NavigationSplitViewVisibility = - UIDevice.current.userInterfaceIdiom == .phone ? .detailOnly : .all - @State private var compactColumn: NavigationSplitViewColumn = - UIDevice.current.userInterfaceIdiom == .phone ? .detail : .sidebar + @State private var columns: NavigationSplitViewVisibility + @State private var compactColumn: NavigationSplitViewColumn @State private var wasBackgrounded = false + /// iPhone starts on the detail column with the session list hidden; larger devices show both. + static func initialLayout(for idiom: UIUserInterfaceIdiom) -> ( + columns: NavigationSplitViewVisibility, compactColumn: NavigationSplitViewColumn + ) { + idiom == .phone ? (.detailOnly, .detail) : (.all, .sidebar) + } + init() { + let layout = Self.initialLayout(for: UIDevice.current.userInterfaceIdiom) + _columns = State(initialValue: layout.columns) + _compactColumn = State(initialValue: layout.compactColumn) #if DEBUG if CommandLine.arguments.contains("--mirror-ui-fixture") { let multiple = CommandLine.arguments.contains("--mirror-ui-multiple-fixtures") @@ -279,16 +287,26 @@ private struct AddConnectionView: View { } } +/// The row tops and the visible top of the live reader. It is not observable: it changes on every +/// scroll, and no view shows it. +private final class MirrorLiveReadingLayout { + var rowTops: [MirrorDocument.Row.ID: CGFloat] = [:] + var visibleTop: CGFloat = 0 +} + private struct MirrorReadingView: View { @Bindable var session: MirrorSession @State private var showsConnectionEditor = false - @State private var position: ScrollPosition - @State private var observedInitialPosition = false + @State private var position = ScrollPosition(edge: .top) + @State private var layout = MirrorLiveReadingLayout() + /// The anchor to restore when the live reader appears. While it is set, scrolling does not + /// replace the stored anchor. + @State private var restoring: MirrorReadingAnchor? @State private var isEditing = false init(session: MirrorSession) { self.session = session - _position = State(initialValue: ScrollPosition(y: session.liveReadingOffset)) + _restoring = State(initialValue: session.liveReadingAnchor) } var body: some View { @@ -331,37 +349,42 @@ private struct MirrorReadingView: View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 12) { - MirrorDocumentView(text: session.text) + MirrorDocumentView(text: session.text, onRowTop: rowTopChanged) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("mirror-live-text") Color.clear.frame(height: 1).id("latest") } .padding() + .coordinateSpace(MirrorDocumentView.rowSpace) } .scrollPosition($position) .accessibilityIdentifier("mirror-live-scroll") - .onDisappear { observedInitialPosition = false } + .onDisappear { restoring = session.liveReadingAnchor } .onAppear { if session.followsLatest { + restoring = nil position.scrollTo(edge: .bottom) - } else { - position.scrollTo(y: session.liveReadingOffset) + } else if let restoring { + restore(restoring, with: proxy) } } .onScrollGeometryChange(for: CGFloat.self) { geometry in max(0, geometry.contentOffset.y + geometry.contentInsets.top) - } action: { _, offset in - if observedInitialPosition { session.liveReadingOffset = offset } - observedInitialPosition = true + } action: { _, top in + layout.visibleTop = top + storeAnchor() } .onScrollPhaseChange { _, phase in - if phase == .interacting { session.followsLatest = false } + guard phase == .interacting else { return } + session.followsLatest = false + restoring = nil } .onChange(of: session.revision) { _, _ in if session.followsLatest { proxy.scrollTo("latest", anchor: .bottom) } } .onChange(of: session.completedScroll) { _, completion in guard completion != nil else { return } + restoring = nil position.scrollTo(edge: .top) } .safeAreaInset(edge: .top) { @@ -470,6 +493,38 @@ private struct MirrorReadingView: View { } } } + + private func restore(_ anchor: MirrorReadingAnchor, with proxy: ScrollViewProxy) { + if let top = layout.rowTops[anchor.row] { + restoring = nil + position.scrollTo(y: top + anchor.offset) + } else if MirrorDocument(session.text).rows.contains(where: { $0.id == anchor.row }) { + // Lay out the anchor row first. `rowTopChanged` then applies the offset in the row. + proxy.scrollTo(anchor.row, anchor: .top) + } else { + restoring = nil + position.scrollTo(edge: .bottom) + } + } + + private func rowTopChanged(_ row: MirrorDocument.Row.ID, _ top: CGFloat?) { + layout.rowTops[row] = top + guard let top else { return } + if let anchor = restoring { + guard anchor.row == row else { return } + restoring = nil + position.scrollTo(y: top + anchor.offset) + } else { + storeAnchor() + } + } + + private func storeAnchor() { + guard restoring == nil, + let anchor = MirrorReadingAnchor(top: layout.visibleTop, rowTops: layout.rowTops) + else { return } + session.liveReadingAnchor = anchor + } } private struct MirrorConnectionEditor: View { @@ -551,18 +606,21 @@ private struct MirrorHistoryView: View { .font(.caption).foregroundStyle(.secondary) ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading) { + VStack(alignment: .leading) { Button("Load Earlier 200 Lines") { session.loadHistory() } .disabled( session.historyOffset == 0 || session.isLoadingHistory || session.status != .live) - ForEach( - session.historyOffset..<(session.historyOffset + session.historyLines.count), - id: \.self - ) { index in - let line = session.historyLines[index - session.historyOffset] - Text(line.isEmpty ? " " : line).textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .id(index) + // iOS 27 sends no taps to a button in the same stack as selectable text rows. + LazyVStack(alignment: .leading) { + ForEach( + session.historyOffset..<(session.historyOffset + session.historyLines.count), + id: \.self + ) { index in + let line = session.historyLines[index - session.historyOffset] + Text(line.isEmpty ? " " : line).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .id(index) + } } } } diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Composer/MirrorComposer.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Composer/MirrorComposer.swift index 9ad502447..e42e45f8d 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Composer/MirrorComposer.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Composer/MirrorComposer.swift @@ -29,7 +29,7 @@ struct MirrorComposer: UIViewRepresentable { } view.canSubmit = session.canSubmit if !view.canSubmit { view.gesture.cancel() } - if !isEditing, view.isFirstResponder { view.resignFirstResponder() } + if !isEditing, view.isFirstResponder { _ = view.resignFirstResponder() } } func sizeThatFits(_ proposal: ProposedViewSize, uiView: ComposerTextView, context: Context) diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocument.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocument.swift index f6d25fc70..49d93017b 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocument.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocument.swift @@ -19,8 +19,34 @@ nonisolated struct MirrorDocument { } } + /// One item of the reading layout and a scroll anchor: a code or table block, a text block + /// that fits in one layout chunk, or one chunk of a longer text block. + struct Row: Identifiable { + struct ID: Hashable, Comparable { + let block: Int + let chunk: Int + + static func < (lhs: ID, rhs: ID) -> Bool { (lhs.block, lhs.chunk) < (rhs.block, rhs.chunk) } + } + + let id: ID + let block: Block + /// The chunk to show, or nil to show the whole block. + let chunk: MirrorTextLayout.Chunk? + } + let blocks: [Block] + var rows: [Row] { + blocks.flatMap { block -> [Row] in + let whole = [Row(id: .init(block: block.id, chunk: 0), block: block, chunk: nil)] + guard case .text(_, let content) = block else { return whole } + let chunks = MirrorTextLayout.chunks(content) + guard chunks.count > 1 else { return whole } + return chunks.map { Row(id: .init(block: block.id, chunk: $0.id), block: block, chunk: $0) } + } + } + init(_ text: String) { let lines = text.components(separatedBy: "\n") var result: [Block] = [] diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocumentView.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocumentView.swift index ed4247768..351cea6a5 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocumentView.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorDocumentView.swift @@ -1,36 +1,29 @@ import SwiftUI struct MirrorDocumentView: View { + /// The coordinate space of the row tops. Apply it to the scroll content. + nonisolated static var rowSpace: NamedCoordinateSpace { .named("MirrorDocumentView.rows") } + let text: String + /// Receives the top of each row in `rowSpace` when the row is laid out, and nil when it leaves + /// the layout. + var onRowTop: ((MirrorDocument.Row.ID, CGFloat?) -> Void)? @State private var expanded: MirrorDocument.Block? var body: some View { - LazyVStack(alignment: .leading, spacing: 12) { - ForEach(MirrorDocument(text).blocks) { block in - switch block { - case .text(_, let content): - textBlock(content) - case .code(_, let language, let code): - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(language.isEmpty ? "Code" : language).font(.caption).foregroundStyle(.secondary) - Spacer() - Button("Expand") { expanded = block } - } - Text(code).font(.body.monospaced()).lineLimit(6).textSelection(.enabled) - } - .padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) - case .table(_, _, let rows): - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Table · \(rows.count - 1) rows").font(.caption).foregroundStyle(.secondary) - Spacer() - Button("Expand") { expanded = block } - } - ScrollView(.horizontal) { table(Array(rows.prefix(5))) } + // One flat lazy stack: each chunk of a long text block is a row, so a reading position can + // anchor to the text near the top of the visible area. + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(MirrorDocument(text).rows) { row in + rowContent(row) + .padding(.top, row.id.chunk == 0 && row.id.block > 0 ? 12 : 0) + .id(row.id) + .onGeometryChange(for: CGFloat.self) { + $0.frame(in: Self.rowSpace).minY + } action: { top in + onRowTop?(row.id, top) } - .padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) - } + .onDisappear { onRowTop?(row.id, nil) } } } .sheet(item: $expanded) { block in @@ -45,31 +38,50 @@ struct MirrorDocumentView: View { } } - private func renderInline(_ text: String) -> AttributedString { - (try? AttributedString( - markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) - ?? AttributedString(text) - } - @ViewBuilder - private func textBlock(_ content: String) -> some View { - let chunks = MirrorTextLayout.chunks(content) - if chunks.count == 1 { - Text(renderInline(content)).textSelection(.enabled) - } else { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(chunks) { chunk in - Text(verbatim: chunk.display) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + private func rowContent(_ row: MirrorDocument.Row) -> some View { + let block = row.block + switch block { + case .text(_, let content): + if let chunk = row.chunk { + Text(verbatim: chunk.display) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { + Button("Copy Full Text Block") { UIPasteboard.general.string = content } + } + } else { + Text(renderInline(content)).textSelection(.enabled) + } + case .code(_, let language, let code): + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(language.isEmpty ? "Code" : language).font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Expand") { expanded = block } } + Text(code).font(.body.monospaced()).lineLimit(6).textSelection(.enabled) } - .contextMenu { - Button("Copy Full Text Block") { UIPasteboard.general.string = content } + .padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) + case .table(_, _, let rows): + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Table · \(rows.count - 1) rows").font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Expand") { expanded = block } + } + ScrollView(.horizontal) { table(Array(rows.prefix(5))) } } + .padding().background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) } } + private func renderInline(_ text: String) -> AttributedString { + (try? AttributedString( + markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) + ?? AttributedString(text) + } + private func table(_ rows: [[String]]) -> some View { Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { ForEach(Array(rows.enumerated()), id: \.offset) { index, cells in diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorReadingAnchor.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorReadingAnchor.swift new file mode 100644 index 000000000..5cc48bd61 --- /dev/null +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Reading/MirrorReadingAnchor.swift @@ -0,0 +1,22 @@ +import CoreGraphics + +/// A live reading position: a document row and the distance from the top of that row to the +/// top of the visible area. When a pane switch creates the reading view again, `LazyVStack` +/// estimates the heights of the rows that it has not laid out, so an absolute scroll offset +/// does not show the same text again. +nonisolated struct MirrorReadingAnchor: Equatable { + let row: MirrorDocument.Row.ID + let offset: CGFloat +} + +extension MirrorReadingAnchor { + /// Makes the anchor for the visible top `top` from the content tops of the rows that are laid + /// out: the last row that starts at or above `top`, else the first row below it. + nonisolated init?(top: CGFloat, rowTops: [MirrorDocument.Row.ID: CGFloat]) { + let rows = rowTops.map { ($0.value, $0.key) } + guard let (rowTop, row) = rows.filter({ $0.0 <= top }).max(by: <) ?? rows.min(by: <) else { + return nil + } + self.init(row: row, offset: top - rowTop) + } +} diff --git a/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift b/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift index e22343f8e..82abee957 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Features/Sessions/MirrorSession.swift @@ -50,6 +50,7 @@ final class MirrorSession: Identifiable { @ObservationIgnored private var pendingCommand: PendingCommand? @ObservationIgnored private var commandTimeout: Task? @ObservationIgnored private let clock: any Clock + @ObservationIgnored private let scrollConfirmationTimeout: Duration private struct PendingCommand { let id: UUID @@ -89,7 +90,9 @@ final class MirrorSession: Identifiable { private(set) var historyTruncated = false private(set) var isLoadingHistory = false var showsHistory = false - var liveReadingOffset: CGFloat = 0 + /// The reading view writes it on every scroll and reads it only when it appears, so a change + /// must not update views. + @ObservationIgnored var liveReadingAnchor: MirrorReadingAnchor? var historyReadingOffset: CGFloat = 0 @ObservationIgnored private var historyID: UUID? @ObservationIgnored private var historyBytes = 0 @@ -139,12 +142,14 @@ final class MirrorSession: Identifiable { init( configuration: MirrorSavedConnection, clock: any Clock = ContinuousClock(), + scrollConfirmationTimeout: Duration = .seconds(5), makeTransport: @escaping (MirrorSavedConnection) throws -> any MirrorTransport = { configuration in MirrorRemoteConnection(configuration: configuration) } ) { self.clock = clock + self.scrollConfirmationTimeout = scrollConfirmationTimeout self.configuration = configuration self.makeTransport = makeTransport } @@ -242,7 +247,7 @@ final class MirrorSession: Identifiable { text = "" revision = 0 updatedAt = nil - liveReadingOffset = 0 + liveReadingAnchor = nil historyReadingOffset = 0 historyID = nil historyLines = [] @@ -439,8 +444,9 @@ final class MirrorSession: Identifiable { scrollError = nil followsLatest = false let clock = clock + let timeout = scrollConfirmationTimeout scrollTimeout = Task { [weak self] in - do { try await clock.sleep(for: .seconds(5)) } catch { return } + do { try await clock.sleep(for: timeout) } catch { return } guard self?.pendingScroll?.id == request.id else { return } self?.clearScroll() self?.scrollError = String( diff --git a/Mirror/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift b/Mirror/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift index 99801e8bf..172be77ba 100644 --- a/Mirror/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift +++ b/Mirror/iOS/ProwlMirror-iOS/Infrastructure/MirrorUIFixture.swift @@ -28,11 +28,19 @@ return try JSONDecoder().decode(MirrorJSON.self, from: Data(payload.utf8)) } + /// With `--mirror-ui-scroll-hold-fixture`, the fixture Host confirms a scroll only when the UI + /// test posts this Darwin notification, so the test can check the loading state at any speed. + static let releaseScrollNotification = "com.awhisper.ProwlMirror-iOS.ui-fixture.release-scroll" + private static var holdsScroll: Bool { + CommandLine.arguments.contains("--mirror-ui-scroll-hold-fixture") + } + static func session(name: String = "UI Fixture", longOutput: Bool = false) -> MirrorSession { let channel = Channel(name: name, longOutput: longOutput) let session = MirrorSession( configuration: .init( address: "127.0.0.1", port: 7880, pairingKey: ""), + scrollConfirmationTimeout: holdsScroll ? .seconds(120) : .seconds(5), makeTransport: { _ in channel }) session.connect() session.select(channel.pane) @@ -50,6 +58,20 @@ pane = MirrorPaneDescriptor( id: UUID(), title: "\(name) · Codex", directory: "/fixture", busy: false, projectName: name, subtitle: "Codex · main") + if MirrorUIFixture.holdsScroll { + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), Unmanaged.passUnretained(self).toOpaque(), + { _, observer, _, _, _ in + guard let observer else { return } + let channel = Unmanaged.fromOpaque(observer).takeUnretainedValue() + Task { @MainActor in channel.releaseHeldScrolls() } + }, + MirrorUIFixture.releaseScrollNotification as CFString, nil, .deliverImmediately) + } + } + deinit { + CFNotificationCenterRemoveEveryObserver( + CFNotificationCenterGetDarwinNotifyCenter(), Unmanaged.passUnretained(self).toOpaque()) } private let lease = UUID() private let run = UUID() @@ -57,12 +79,12 @@ text: (1...401).map { "Retained line \($0)" }.joined(separator: "\n"), truncated: true) private var sequence: UInt64 = 0 private var scrollPage = 0 - private var scrollTask: Task? + private var heldScrolls: [MirrorMessage] = [] private var includesScrollState = false func start() { onReady?() } func close(_ reason: String?) { - scrollTask?.cancel() + heldScrolls = [] onClose?(reason) } @@ -148,16 +170,19 @@ } private func scroll(_ message: MirrorMessage) { - if CommandLine.arguments.contains("--mirror-ui-scroll-delay-fixture") { - scrollTask = Task { [weak self] in - do { try await ContinuousClock().sleep(for: .seconds(2)) } catch { return } - self?.completeScroll(message) - } + if MirrorUIFixture.holdsScroll { + heldScrolls.append(message) } else { completeScroll(message) } } + private func releaseHeldScrolls() { + let scrolls = heldScrolls + heldScrolls = [] + scrolls.forEach(completeScroll) + } + private func completeScroll(_ message: MirrorMessage) { guard let id = message.scrollRequestID, let direction = message.scrollDirection else { return } scrollPage += direction == .upward ? -1 : 1 diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorComposerTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorComposerTests.swift index 4aac713e2..718bd44d6 100644 --- a/Mirror/iOS/ProwlMirror-iOSTests/MirrorComposerTests.swift +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorComposerTests.swift @@ -13,7 +13,7 @@ struct MirrorComposerTests { view.selectedRange = NSRange(location: 8, length: 0) var sends = 0 view.onSubmit = { sends += 1 } - let press = ReturnPress() + let press = ReturnPress(key: ReturnKey()) let event = KeyEvent() view.pressesBegan([press], with: event) #expect(view.text == "message\n\n") @@ -57,8 +57,15 @@ struct MirrorComposerTests { override var characters: String { "\r" } override var charactersIgnoringModifiers: String { "\r" } } + /// `UIPress` is not isolated to the main actor, but `UIKey` is. The test makes the key on the + /// main actor, and `key` only returns it. private final class ReturnPress: UIPress { - override var key: UIKey? { ReturnKey() } + private let returnKey: UIKey + init(key: UIKey) { + returnKey = key + super.init() + } + override var key: UIKey? { returnKey } } private final class KeyEvent: UIPressesEvent { var time: TimeInterval = 0 diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorDocumentTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorDocumentTests.swift index 04c45eece..952e60fb1 100644 --- a/Mirror/iOS/ProwlMirror-iOSTests/MirrorDocumentTests.swift +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorDocumentTests.swift @@ -22,4 +22,18 @@ struct MirrorDocumentTests { #expect(MirrorDocument("").blocks == [.text(0, "")]) #expect(MirrorDocument("done").blocks == [.text(0, "done")]) } + + @Test func rowsKeepShortBlocksWholeAndSplitLongTextIntoChunks() { + let long = (1...40).map { "line \($0)" }.joined(separator: "\n") + let document = MirrorDocument("intro\n```\ncode\n```\n" + long) + #expect( + document.rows.map(\.id) == [ + .init(block: 0, chunk: 0), .init(block: 1, chunk: 0), .init(block: 2, chunk: 0), + .init(block: 2, chunk: 1), + ]) + #expect(document.rows[0].chunk == nil) + #expect(document.rows[1].chunk == nil) + #expect(document.rows[2...].map { $0.chunk?.raw ?? "" }.joined() == long) + #expect(document.rows[2...].allSatisfy { $0.block == document.blocks[2] }) + } } diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorInitialLayoutTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorInitialLayoutTests.swift new file mode 100644 index 000000000..cb1465319 --- /dev/null +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorInitialLayoutTests.swift @@ -0,0 +1,21 @@ +import SwiftUI +import Testing +import UIKit + +@testable import ProwlMirror_iOS + +@MainActor +struct MirrorInitialLayoutTests { + @Test func phoneStartsOnTheDetailColumnWithTheSessionListHidden() { + let layout = ContentView.initialLayout(for: .phone) + #expect(layout.columns == .detailOnly) + #expect(layout.compactColumn == .detail) + } + + @Test(arguments: [UIUserInterfaceIdiom.pad, .mac]) + func largerDevicesShowTheSessionList(_ idiom: UIUserInterfaceIdiom) { + let layout = ContentView.initialLayout(for: idiom) + #expect(layout.columns == .all) + #expect(layout.compactColumn == .sidebar) + } +} diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorReadingAnchorTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorReadingAnchorTests.swift new file mode 100644 index 000000000..ea3e05050 --- /dev/null +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorReadingAnchorTests.swift @@ -0,0 +1,35 @@ +import CoreGraphics +import Testing + +@testable import ProwlMirror_iOS + +struct MirrorReadingAnchorTests { + private let rowTops: [MirrorDocument.Row.ID: CGFloat] = [ + .init(block: 0, chunk: 0): 16, .init(block: 1, chunk: 0): 120, .init(block: 1, chunk: 1): 400, + ] + + @Test func anchorIsTheLastRowThatStartsAtOrAboveTheVisibleTop() { + #expect( + MirrorReadingAnchor(top: 250, rowTops: rowTops) + == .init(row: .init(block: 1, chunk: 0), offset: 130)) + #expect( + MirrorReadingAnchor(top: 400, rowTops: rowTops) + == .init(row: .init(block: 1, chunk: 1), offset: 0)) + } + + @Test func anchorUsesTheFirstRowWhenNoRowStartsAboveTheVisibleTop() { + #expect( + MirrorReadingAnchor(top: 0, rowTops: rowTops) + == .init(row: .init(block: 0, chunk: 0), offset: -16)) + #expect(MirrorReadingAnchor(top: 0, rowTops: [:]) == nil) + } + + @Test func rowsWithTheSameTopUseTheLaterRow() { + let empty: [MirrorDocument.Row.ID: CGFloat] = [ + .init(block: 0, chunk: 0): 16, .init(block: 1, chunk: 0): 16, .init(block: 2, chunk: 0): 16, + ] + #expect( + MirrorReadingAnchor(top: 20, rowTops: empty) + == .init(row: .init(block: 2, chunk: 0), offset: 4)) + } +} diff --git a/Mirror/iOS/ProwlMirror-iOSTests/MirrorSessionTests.swift b/Mirror/iOS/ProwlMirror-iOSTests/MirrorSessionTests.swift index 0aae13448..f9cbbca58 100644 --- a/Mirror/iOS/ProwlMirror-iOSTests/MirrorSessionTests.swift +++ b/Mirror/iOS/ProwlMirror-iOSTests/MirrorSessionTests.swift @@ -92,7 +92,7 @@ struct MirrorSessionTests { .textFrame(.init(sequence: 2, text: "new live", subscriptionID: lease))) #expect(session.historyLines == ["first", "last"]) #expect(session.text == "new live") - session.liveReadingOffset = 300 + session.liveReadingAnchor = .init(row: .init(block: 0, chunk: 0), offset: 300) session.historyReadingOffset = 200 session.updateConnection( .init( @@ -100,7 +100,7 @@ struct MirrorSessionTests { pairingKey: String(repeating: "a", count: 64))) #expect(session.historyLines.isEmpty) #expect(!session.showsHistory) - #expect(session.liveReadingOffset == 0) + #expect(session.liveReadingAnchor == nil) #expect(session.historyReadingOffset == 0) } diff --git a/Mirror/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift b/Mirror/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift index 621e37221..0ba1d1664 100644 --- a/Mirror/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift +++ b/Mirror/iOS/ProwlMirror-iOSUITests/ProwlMirror_iOSUITests.swift @@ -121,7 +121,7 @@ final class ProwlMirror_iOSUITests: XCTestCase { func testRemoteScrollShowsLoadingAndKeepsHistoryIndependent() { let app = XCUIApplication() app.launchArguments = [ - "--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-scroll-delay-fixture", + "--mirror-ui-fixture", "--mirror-ui-scroll-fixture", "--mirror-ui-scroll-hold-fixture", ] app.launch() XCUIDevice.shared.orientation = .portrait @@ -137,13 +137,17 @@ final class ProwlMirror_iOSUITests: XCTestCase { attachment.name = "Remote scroll loading" attachment.lifetime = .keepAlways add(attachment) + releaseHeldScrolls() let olderPage = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Remote page -1\n")).firstMatch XCTAssertTrue(olderPage.waitForExistence(timeout: 8)) XCTAssertFalse(progress.exists) scrollDown.tap() + XCTAssertTrue(progress.waitForExistence(timeout: 5)) app.buttons["History"].tap() XCTAssertTrue(app.staticTexts["Loaded lines 202–401"].waitForExistence(timeout: 5)) XCTAssertFalse(scrollUp.exists) + // Host confirms the downward scroll while History is open. + releaseHeldScrolls() app.buttons["Load Earlier 200 Lines"].tap() XCTAssertTrue(app.staticTexts["Loaded lines 2–401"].waitForExistence(timeout: 5)) app.buttons["Live Output"].tap() @@ -249,20 +253,6 @@ final class ProwlMirror_iOSUITests: XCTestCase { XCTAssertTrue(app.staticTexts["created-mirror"].waitForExistence(timeout: 5)) } - @MainActor - func testPhoneStartsInDetailAndCanAddConnection() throws { - try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .phone, "Phone navigation coverage") - let app = XCUIApplication() - app.launch() - XCUIDevice.shared.orientation = .portrait - XCTAssertTrue(app.staticTexts["Choose a Remote Pane"].waitForExistence(timeout: 10)) - XCTAssertFalse(app.staticTexts["Connect to Prowl"].isHittable) - app.buttons["Add Remote Pane"].tap() - XCTAssertTrue(app.textFields["host-address"].waitForExistence(timeout: 5)) - app.buttons["Cancel"].tap() - XCTAssertTrue(app.staticTexts["Choose a Remote Pane"].waitForExistence(timeout: 5)) - } - @MainActor func testSidebarClosesMirrorsWithOneClick() { let app = XCUIApplication() @@ -535,4 +525,13 @@ final class ProwlMirror_iOSUITests: XCTestCase { app.buttons["Cancel"].tap() XCTAssertTrue(app.buttons["Add Remote Pane"].firstMatch.waitForExistence(timeout: 5)) } + + /// Makes the fixture Host confirm the scrolls that `--mirror-ui-scroll-hold-fixture` holds. + /// The name matches `MirrorUIFixture.releaseScrollNotification`. + private func releaseHeldScrolls() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName("com.awhisper.ProwlMirror-iOS.ui-fixture.release-scroll" as CFString), nil, nil, + true) + } } diff --git a/Mirror/iOS/README.md b/Mirror/iOS/README.md index 672c84a30..1a5639227 100644 --- a/Mirror/iOS/README.md +++ b/Mirror/iOS/README.md @@ -99,19 +99,19 @@ command correlation/routing, reading and keyboard behavior. The Host's real App target covers pairing, revocation, terminal capture and input protection; there is no copied Host implementation in this client's tests. -Run the iOS mirror unit and UI suites on their matching simulator form factors: +Run the iOS mirror unit and UI suites on an iPad simulator: ```bash make test-mirror-ios ``` The target generates only this project, so it does not need the Mac app's build inputs. -It runs the split-pane and composer tests on iPad (`make test-mirror-ios-ipad`), then the -remote-scroll, pairing, and phone-navigation tests on iPhone (`make test-mirror-ios-iphone`). -The UI suites take several minutes. It uses the newest iPad Pro 11-inch and iPhone Pro -simulators on the newest runtime that the selected Xcode supports. To use other models, -set `IOS_MIRROR_IPAD_SIMULATOR_PATTERN` or `IOS_MIRROR_IPHONE_SIMULATOR_PATTERN` to a -regular expression for the full simulator name. +The UI suite takes several minutes. `make test-mirror-ios-unit` runs only the unit tests. +CI runs only the unit tests, and only when a pull request changes `Mirror/iOS/` or +`Mirror/Shared/`, and for each published release. Run the UI suite locally before you merge changes to +this client. The target uses the newest iPad Pro 11-inch simulator on the newest runtime that +the selected Xcode supports. To use another model, set `IOS_MIRROR_SIMULATOR_PATTERN` to a +regular expression for the full simulator name. The UI tests also pass on iPhone. DEBUG-only `--mirror-ui-fixture` and `--mirror-ui-launch-fixture` supply deterministic UI data without connecting to Host or starting an Agent. Component and socket tests diff --git a/README.md b/README.md index e3f7d5854..171131885 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ make format-changed # Format changed Swift files only make format # Full-tree Swift format cleanup make lint # SwiftLint only make test # Run app/unit tests +make test-all # Run every test suite, including the CLI and both mirror clients make log-stream # Stream app logs (subsystem: com.onevcat.prowl) ``` diff --git a/docs-ai/016-dev-build-and-ci-workflow/000-plan.md b/docs-ai/016-dev-build-and-ci-workflow/000-plan.md index bd274dea9..2e0c4728a 100644 --- a/docs-ai/016-dev-build-and-ci-workflow/000-plan.md +++ b/docs-ai/016-dev-build-and-ci-workflow/000-plan.md @@ -98,3 +98,7 @@ Anchor wave, all landed 2026-04-04/05: [006-ci-source-cache-reuse.md](006-ci-source-cache-reuse.md) - Updated 2026-09-06: App incremental-state reuse and measured module extraction — see [007-app-incremental-and-module-boundaries.md](007-app-incremental-and-module-boundaries.md) +- Updated 2026-10-04: the CAS cache step used a path that never existed after 007; restore the + CAS from the newest App incremental entry on a miss, save large caches only from main, and + run the mirror client jobs only when they change (corrects 007's CAS note) — see + [008-cas-fallback-and-cache-scope.md](008-cas-fallback-and-cache-scope.md) diff --git a/docs-ai/016-dev-build-and-ci-workflow/007-app-incremental-and-module-boundaries.md b/docs-ai/016-dev-build-and-ci-workflow/007-app-incremental-and-module-boundaries.md index 36b2827c0..bf00fef6e 100644 --- a/docs-ai/016-dev-build-and-ci-workflow/007-app-incremental-and-module-boundaries.md +++ b/docs-ai/016-dev-build-and-ci-workflow/007-app-incremental-and-module-boundaries.md @@ -36,7 +36,9 @@ App state is cached at a fixed CI DerivedData path. Its restore prefix includes Xcode build, Ghostty revision, project/package/build configuration and tracked input paths. Added/deleted source or resource paths therefore start fresh instead of retaining stale products. CLI-owned Shared timestamps are excluded from App timestamp restoration. Content -changes retain normal compiler/resource invalidation; the existing CAS remains available. +changes retain normal compiler/resource invalidation. (Correction, 2026-10-04: the separate +CAS cache step did not remain available. With the fixed DerivedData path the CAS moved into it, +so that step cached a path that never existed; see [008](008-cas-fallback-and-cache-scope.md).) ## Verification diff --git a/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md b/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md new file mode 100644 index 000000000..ad20c14b2 --- /dev/null +++ b/docs-ai/016-dev-build-and-ci-workflow/008-cas-fallback-and-cache-scope.md @@ -0,0 +1,82 @@ +# 016.008 — CAS Fallback, Cache Save Scope and Mirror Jobs + +## Context + +In #851, a pull request that changed the Makefile and the test workflow spent about 10 minutes +compiling the App (2,000+ compile steps) in the `build` job; the same job with a restored App +incremental cache compiles in under 2 minutes. Three problems caused this or made it likely: + +- **The CAS cache was never used.** The `xcode-compilation-cache` step from + [005](005-build-test-time-optimization.md) cached + `~/Library/Developer/Xcode/DerivedData/CompilationCache.noindex`. Since + [007](007-app-incremental-and-module-boundaries.md) the App builds with + `-derivedDataPath build/ci-derived-data`, and Xcode then keeps the CAS at + `/CompilationCache.noindex` (`COMPILATION_CACHE_CAS_PATH`). The cached + path never existed: every run logged "Cache not found", and no run saved an entry. A miss of + the strict App incremental key (changed build configuration, Makefile, workflow or tracked + file roster) therefore built without any compiler cache. +- **Pull requests evicted the entries of main.** Every pull request run that changed sources + saved a new App incremental entry (about 930 MB) and CLI entry (about 670 MB). The repository + used 10.7 GB of its 10 GB quota, and least-recently-used eviction removes the main entries + that every pull request restores. Saving also added up to 53 s to pull request runs. +- **Mirror client jobs ran for every change.** The iOS mirror UI suite took 11–20 minutes on + hosted runners for each pull request, including pull requests that changed only the Mac app. + +## Change + +- `.github/actions/setup-macos/action.yml` restores caches with `actions/cache/restore`. + The dead CAS step is removed. When the strict App incremental key finds nothing, the action + restores the newest App incremental entry for the same Xcode build and workspace, and keeps + only its `CompilationCache.noindex`. The build is clean (no stale products from a changed + file roster or configuration) but replays compiler outputs from the content-addressed cache. + This reuses existing entries and needs no more cache space. +- `.github/actions/save-macos-caches/action.yml` saves the caches at the end of the job, after + the build input times. Only `main` saves the SwiftPM, CLI and App entries. Pull requests + still save the Mise and GhosttyKit entries, which are small and change rarely, because a + GhosttyKit miss is slow to rebuild. +- The mirror clients have their own workflows, `.github/workflows/mirror-ios.yml` and + `.github/workflows/mirror-android.yml`. They run for changes to their client (and + `Mirror/Shared/` for iOS) and for each published release. They use the `release` event, + not a `v*` tag push: `scripts/release.sh` makes lightweight tags, which + `git push --follow-tags` does not push, and `gh release create` then makes the tag on + GitHub. The v2026.9.29 release recorded no tag push event. The release check runs after + publication, so it reports problems but does not block the Mac app release. CI runs only the iOS mirror unit tests (`make test-mirror-ios-unit`); the UI suite runs + locally with `make test-mirror-ios`. The Mac app workflow ignores changes to only the + mirror clients. +- `make test-app` exports each pass's test action log (`*.action.json`), and the CI summary + shows when the test runner launch and the first test suite start. +- `make lint` and `make check-localization` run in the parallel step beside the build, in the + `checks` task with the script tests, not as steps before it. + +## Test start time + +The test-progress heartbeat suggested that the first `xcodebuild test` pass waited 1.5–2 +minutes after the build before its first suite. The test action log shows that this is +output buffering: xcodebuild writes test results to the pipe in bursts. In the measured run +the build ended at 96 s, the test runner launched at 98.8 s and the first suite started at +105.3 s; the tests then ran for about 85 s. Read test timing from the action-log summary, not +from heartbeat timestamps. + +## Measurements (#851, hosted `macos-26` runner) + +Both runs missed the strict App incremental key because the pull request changes the +Makefile and the workflows. + +| Run | Compile | "Build app and run tests" step | `build` job | +| --- | ---: | ---: | ---: | +| Before (no CAS) | about 10 min | 782 s | 16 min 28 s | +| After (CAS from the newest main entry, 1.3 GB) | about 2 min 20 s | 322 s | 8 min 46 s | +| After, second run (516 Swift cache hits, 30 misses) | 96 s build | 267 s | 7 min 33 s | + +Hosted runner speed varies about 2x between runs, so these are single samples, not medians. +In the second run, restoring caches took 98 s (SwiftPM 34 s, App state 25 s, CLI 15 s), the +main test pass about 90 s, and the three isolated `test-without-building` passes about 60 s. + +## Open questions + +- The SwiftPM cache (1.46 GB, mostly repository clones and the Sentry binary artifact) is the + slowest restore. Removing the clones may make SwiftPM fetch them again; not measured. + +## Refs + +PR #851. diff --git a/docs-ai/README.md b/docs-ai/README.md index 11aca6631..d76d41f11 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -98,7 +98,7 @@ keep the old paths. Use this table to find a file that an old entry names: | 013 | [prowl-cli](013-prowl-cli/000-plan.md) | 2026-03-30 | Contract-first `prowl` CLI: socket service, v1 commands, hardening, agents | | 014 | [terminal-layout-persistence](014-terminal-layout-persistence/000-plan.md) | 2026-03-31 | Layout snapshot save/restore; font-size persistence; launch races | | 015 | [repositories-feature-refactor](015-repositories-feature-refactor/000-plan.md) | 2026-04-03 | TCA decomposition of RepositoriesFeature and later code-health splits | -| 016 | [dev-build-and-ci-workflow](016-dev-build-and-ci-workflow/000-plan.md) | 2026-04-04 | Build/test tooling, CI parallelism, Debug identity, incremental caching, Shared module boundary | +| 016 | [dev-build-and-ci-workflow](016-dev-build-and-ci-workflow/000-plan.md) | 2026-04-04 | Build/test tooling, CI parallelism, Debug identity, incremental caching, Shared module boundary, CAS fallback and cache save scope | | 017 | [upstream-sync-process](017-upstream-sync-process/000-plan.md) | 2026-04-08 | Upstream review discipline, baselines, batch decisions | | 018 | [archived-worktrees](018-archived-worktrees/000-plan.md) | 2026-04-09 | Archived worktree discoverability and auto-delete | | 019 | [worktree-creation-and-lifecycle](019-worktree-creation-and-lifecycle/000-plan.md) | 2026-04-12 | Creation/merge flows, safe deletion, Add-to-Prowl redesign | diff --git a/scripts/select_ios_simulator.py b/scripts/select_ios_simulator.py index 530704698..69e5c3954 100644 --- a/scripts/select_ios_simulator.py +++ b/scripts/select_ios_simulator.py @@ -5,6 +5,9 @@ expression for the full name (for example `iPhone [0-9]+ Pro`). The newest matching model on the newest runtime that the selected Xcode supports is used. """ +# macOS includes Python 3.9, which cannot evaluate `X | None` annotations at run time. +from __future__ import annotations + import json import re import subprocess diff --git a/scripts/summarize-xcode-build.py b/scripts/summarize-xcode-build.py index 6f9643cb9..2eb64e26b 100644 --- a/scripts/summarize-xcode-build.py +++ b/scripts/summarize-xcode-build.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Summarize xcresulttool build logs without adding overlapping task times.""" +"""Summarize xcresulttool build logs without adding overlapping task times, and test action logs.""" import json from pathlib import Path @@ -26,11 +26,45 @@ def summarize(log, name): return "\n".join(lines) + "\n" +def sections(node): + for child in node.get("subsections", []): + yield child + yield from sections(child) + + +def summarize_test_launch(log, name): + """Show when the test runner launch and the first test suite start in a test action log. + + A large gap between them is time before any test runs (for example a slow first launch of + the test host), which the build log and the test results do not show. + """ + start = log.get("startTime", 0) + + def first(prefix): + times = [ + section["startTime"] for section in sections(log) + if section.get("title", "").startswith(prefix) and "startTime" in section + ] + return min(times) - start if times else None + + lines = [f"### {name}", "", f"Test action: **{log.get('duration', 0):.1f} s**", ""] + for label, offset in ( + ("Test runner launch", first("Launching ")), + ("First test suite", first("Run test suite ")), + ): + lines.append(f"- {label}: not recorded" if offset is None else f"- {label} starts at **{offset:.1f} s**") + return "\n".join(lines) + "\n" + + if __name__ == "__main__": for argument in sys.argv[1:]: path = Path(argument) try: - print(summarize(json.loads(path.read_text()), path.name)) + log = json.loads(path.read_text()) + if path.name.endswith(".action.json"): + print(summarize_test_launch(log, path.name)) + else: + print(summarize(log, path.name)) except (OSError, ValueError, KeyError, TypeError) as error: # Build diagnostics must not replace the build or test exit status. print(f"Build metrics unavailable for {path.name}: {error}", file=sys.stderr) diff --git a/scripts/test_developer_onboarding.py b/scripts/test_developer_onboarding.py index 6cc4b2558..a0b442f6e 100644 --- a/scripts/test_developer_onboarding.py +++ b/scripts/test_developer_onboarding.py @@ -25,10 +25,10 @@ def test_mirror_readmes_describe_revision_matched_hosts(self): self.assertIn(expected, readme) self.assertNotIn("feat/mobile-mirror", readme) - def test_ios_readme_has_a_repeatable_cross_device_test_command(self): + def test_ios_readme_has_a_repeatable_test_command(self): readme = (ROOT / "Mirror/iOS/README.md").read_text() self.assertIn("make test-mirror-ios", readme) - self.assertIn("matching simulator form factors", readme) + self.assertIn("CI runs only the unit tests", readme) def test_android_readme_accepts_android_studios_bundled_jbr(self): readme = (ROOT / "Mirror/Android/README.md").read_text() diff --git a/scripts/test_summarize_xcode_build.py b/scripts/test_summarize_xcode_build.py index 11cc5abc3..014e807ff 100644 --- a/scripts/test_summarize_xcode_build.py +++ b/scripts/test_summarize_xcode_build.py @@ -38,6 +38,34 @@ def test_missing_metrics_remain_unknown(self): self.assertIn("Cache counters unavailable", result) self.assertNotIn("0%", result) + def test_reports_when_the_test_runner_and_the_first_suite_start(self): + log = { + "startTime": 100, + "duration": 200, + "subsections": [ + {"title": "Launch actions", "startTime": 100, "subsections": [ + {"title": "Launch AppTests", "startTime": 140, "subsections": [ + {"title": "Launching AppTests", "startTime": 140}, + ]}, + ]}, + {"title": "Test target AppTests", "startTime": 140, "subsections": [ + {"title": "App (42)", "startTime": 140, "subsections": [ + {"title": "Run test suite B", "startTime": 231}, + {"title": "Run test suite A", "startTime": 230}, + ]}, + ]}, + ], + } + result = module.summarize_test_launch(log, "test.action.json") + self.assertIn("Test action: **200.0 s**", result) + self.assertIn("Test runner launch starts at **40.0 s**", result) + self.assertIn("First test suite starts at **130.0 s**", result) + + def test_missing_launch_sections_remain_unknown(self): + result = module.summarize_test_launch({"startTime": 0, "duration": 5}, "test.action.json") + self.assertIn("Test runner launch: not recorded", result) + self.assertIn("First test suite: not recorded", result) + if __name__ == "__main__": unittest.main()