diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 91600595..00000000 --- a/.coveralls.yml +++ /dev/null @@ -1 +0,0 @@ -service_name: travis-ci diff --git a/.github/actions-tests/README.md b/.github/actions-tests/README.md new file mode 100644 index 00000000..f8f4628a --- /dev/null +++ b/.github/actions-tests/README.md @@ -0,0 +1,42 @@ +# Actions Tests + +This folder contains the Python unit-test harness for the reusable GitHub Actions under `.github/actions`. +The subfolder layout mirrors the action names so each action's tests live beside the corresponding action name under `.github/actions-tests`. + +## Local setup + +1. Run the setup script: + + ```bash + ./.github/actions-tests/setup_tests.sh + ``` + +2. Run the tests and generate coverage artifacts: + + ```bash + ./.github/actions-tests/run_tests.sh + ``` + +If `.github/actions-tests/.venv` exists, the test runner will automatically use it. + +To use a specific Python interpreter during setup, set `PYTHON_ACTION_TEST_SETUP_PYTHON_BIN`: + +```bash +PYTHON_ACTION_TEST_SETUP_PYTHON_BIN=python3.13 ./.github/actions-tests/setup_tests.sh +``` + +## Generated artifacts + +By default, test artifacts are written under `.github/actions-tests/build/python-action-test-results/`: + +- `junit.xml` +- `coverage.xml` +- `htmlcov/` + +## Optional coverage threshold + +To fail the test run if total coverage drops below a minimum percentage: + +```bash +PYTHON_ACTION_TEST_COVERAGE_FAIL_UNDER=90 ./.github/actions-tests/run_tests.sh +``` diff --git a/.github/actions-tests/conftest.py b/.github/actions-tests/conftest.py new file mode 100644 index 00000000..b0852c6c --- /dev/null +++ b/.github/actions-tests/conftest.py @@ -0,0 +1,92 @@ +import importlib.util +import sys +from pathlib import Path + +import pytest + + +def determine_repo_root() -> Path: + current_path = Path(__file__).resolve() + for candidate in current_path.parents: + if (candidate / ".git").exists(): + return candidate + + raise RuntimeError("Unable to determine repository root for Python action tests") + + +REPO_ROOT = determine_repo_root() + + +@pytest.fixture(scope="session") +def repo_root() -> Path: + return REPO_ROOT + + +@pytest.fixture(scope="session") +def python_executable() -> str: + return sys.executable + + +def load_module(module_name: str, relative_path: str): + module_path = REPO_ROOT / relative_path + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load module: {module_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="session") +def pick_simulator_module(): + return load_module( + "test_pick_simulator_module", + ".github/actions/simctl-tricorder-selector/pick_simulator.py", + ) + + +@pytest.fixture(scope="session") +def run_xcode_tests_module(): + return load_module( + "test_run_xcode_tests_module", + ".github/actions/xcode-tricorder-tester/run_xcode_tests.py", + ) + + +@pytest.fixture(scope="session") +def coverage_summary_module(): + return load_module( + "test_generate_coverage_summary_module", + ".github/actions/xccov-warp-bubble/generate_coverage_summary.py", + ) + + +@pytest.fixture() +def sample_devices_payload(): + return { + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-18-0": [ + {"name": "iPhone 16", "udid": "IPHONE16", "isAvailable": True}, + {"name": "iPhone 16 Pro Max", "udid": "IPHONE16PM", "isAvailable": True}, + {"name": "iPad Pro 13-inch (M4)", "udid": "IPADPRO", "isAvailable": True}, + {"name": "iPhone 14", "udid": "IPHONE14", "isAvailable": False}, + ], + "com.apple.CoreSimulator.SimRuntime.iOS-17-5": [ + {"name": "iPhone SE (3rd generation)", "udid": "IPHONESE", "isAvailable": True}, + ], + "com.apple.CoreSimulator.SimRuntime.macOS-15-0": [ + {"name": "My Mac", "udid": "MYMAC", "isAvailable": True}, + ], + "com.apple.CoreSimulator.SimRuntime.watchOS-11-0": [ + {"name": "Apple Watch Series 10 (42mm)", "udid": "WATCH10", "isAvailable": True}, + ], + "com.apple.CoreSimulator.SimRuntime.visionOS-2-0": [ + {"name": "Apple Vision Pro", "udid": "VISIONPRO", "isAvailable": True}, + ], + "com.apple.CoreSimulator.SimRuntime.tvOS-18-0": [ + {"name": "Apple TV", "udid": "TV", "isAvailable": True}, + ], + } + } diff --git a/.github/actions-tests/pyproject.toml b/.github/actions-tests/pyproject.toml new file mode 100644 index 00000000..02edb3ef --- /dev/null +++ b/.github/actions-tests/pyproject.toml @@ -0,0 +1,26 @@ +[tool.pytest.ini_options] +testpaths = [ + "simctl-tricorder-selector", + "xcode-tricorder-tester", + "xccov-warp-bubble", +] +python_files = ["*_tests.py"] +junit_family = "xunit2" +addopts = ["-ra"] + +[tool.coverage.run] +branch = true +relative_files = true +patch = ["subprocess"] +source = [ + ".github/actions/simctl-tricorder-selector", + ".github/actions/xccov-warp-bubble", + ".github/actions/xcode-tricorder-tester", +] +omit = [".github/actions-tests/*"] + +[tool.coverage.report] +show_missing = true +skip_empty = true +precision = 2 +omit = [".github/actions-tests/*"] diff --git a/.github/actions-tests/requirements.txt b/.github/actions-tests/requirements.txt new file mode 100644 index 00000000..09ab3511 --- /dev/null +++ b/.github/actions-tests/requirements.txt @@ -0,0 +1,2 @@ +pytest>=8.0,<9 +pytest-cov>=5.0,<7 diff --git a/.github/actions-tests/run_tests.sh b/.github/actions-tests/run_tests.sh new file mode 100755 index 00000000..1d6f5d15 --- /dev/null +++ b/.github/actions-tests/run_tests.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +results_dir="${PYTHON_ACTION_TEST_RESULTS_DIR:-$script_dir/build/python-action-test-results}" +coverage_fail_under="${PYTHON_ACTION_TEST_COVERAGE_FAIL_UNDER:-}" +python_bin="${PYTHON_ACTION_TEST_PYTHON_BIN:-}" + +if [[ -z "$python_bin" && -x "$script_dir/.venv/bin/python" ]]; then + python_bin="$script_dir/.venv/bin/python" +fi + +if [[ -z "$python_bin" ]]; then + python_bin="python3" +fi + +mkdir -p "$results_dir" +cd "$repo_root" +export COVERAGE_FILE="$script_dir/.coverage" +rm -f "$script_dir"/.coverage "$script_dir"/.coverage.* + +cleanup_python_caches() { + find "$repo_root" -type d \( -name "__pycache__" -o -name ".pytest_cache" \) -prune -exec rm -rf {} + +} + +trap cleanup_python_caches EXIT + +pytest_args=( + "-c" "$script_dir/pyproject.toml" + "$script_dir/simctl-tricorder-selector" + "$script_dir/xcode-tricorder-tester" + "$script_dir/xccov-warp-bubble" + "--junitxml=$results_dir/junit.xml" + "--cov=.github/actions/simctl-tricorder-selector" + "--cov=.github/actions/xcode-tricorder-tester" + "--cov=.github/actions/xccov-warp-bubble" + "--cov-branch" + "--cov-report=" +) + +if [[ -n "$coverage_fail_under" ]]; then + pytest_args+=("--cov-fail-under=$coverage_fail_under") +fi + +set +e +"$python_bin" -m pytest "${pytest_args[@]}" +pytest_exit_code=$? +set -e + +shopt -s nullglob +coverage_shards=( "$script_dir"/.coverage.* ) +shopt -u nullglob + +if [[ ${#coverage_shards[@]} -gt 0 ]]; then + "$python_bin" -m coverage combine "$script_dir" +fi + +"$python_bin" -m coverage report --skip-covered --show-missing +"$python_bin" -m coverage xml -o "$results_dir/coverage.xml" +"$python_bin" -m coverage html -d "$results_dir/htmlcov" + +exit "$pytest_exit_code" diff --git a/.github/actions-tests/setup_tests.sh b/.github/actions-tests/setup_tests.sh new file mode 100755 index 00000000..af1be3ca --- /dev/null +++ b/.github/actions-tests/setup_tests.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +python_bin="${PYTHON_ACTION_TEST_SETUP_PYTHON_BIN:-python3}" +venv_dir="${PYTHON_ACTION_TEST_VENV_DIR:-$script_dir/.venv}" +venv_python="$venv_dir/bin/python" + +if [[ ! -d "$venv_dir" ]]; then + "$python_bin" -m venv "$venv_dir" +fi + +"$venv_python" -m pip install -r "$script_dir/requirements.txt" + +echo "Python action test environment is ready: $venv_dir" diff --git a/.github/actions-tests/simctl-tricorder-selector/pick_simulator_tests.py b/.github/actions-tests/simctl-tricorder-selector/pick_simulator_tests.py new file mode 100644 index 00000000..dc6ce782 --- /dev/null +++ b/.github/actions-tests/simctl-tricorder-selector/pick_simulator_tests.py @@ -0,0 +1,402 @@ +import argparse +import json +import os +import subprocess +import textwrap + +import pytest + + +def make_candidate(module, *, device_type: str, name: str, udid: str, runtime: str, family: str, version: tuple[int, ...]): + return module.Candidate( + deviceType=device_type, + name=name, + udid=udid, + runtimeIdentifier=runtime, + runtimeFamily=family, + osVersion=version, + ) + + +def test_setup_argument_parser_parses_valid_values(pick_simulator_module): + parser = pick_simulator_module.setupArgumentParser() + script_args = parser.parse_args([ + "--device-types", "iphone,ipad", + "--selection-mode", "latest-model", + "--iphoneos-version", "18.0", + ]) + + assert script_args.deviceTypes == "iphone,ipad" + assert script_args.selectionMode == "latest-model" + assert script_args.iphoneosVersion == "18.0" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("iphone, ipad", ["iphone", "ipad"]), + (" iphone ", ["iphone"]), + ("iphone,,watch", ["iphone", "watch"]), + ], +) +def test_parse_comma_separated_list(pick_simulator_module, value, expected): + assert pick_simulator_module.parseCommaSeparatedList(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("latest", None), + ("18", (18,)), + ("18.2", (18, 2)), + ], +) +def test_parse_requested_version_valid_values(pick_simulator_module, value, expected): + assert pick_simulator_module.parseRequestedVersion(value) == expected + + +def test_parse_requested_version_invalid_value_raises(pick_simulator_module): + with pytest.raises(ValueError): + pick_simulator_module.parseRequestedVersion("18.x") + + +def test_parse_model_preferences_parses_expected_values(pick_simulator_module): + preferences = pick_simulator_module.parseModelPreferences("iphone=Pro Max,Plus;ipad=Pro") + assert preferences == {"iphone": ["pro max", "plus"], "ipad": ["pro"]} + + +def test_parse_model_preferences_rejects_invalid_segment(pick_simulator_module): + with pytest.raises(ValueError): + pick_simulator_module.parseModelPreferences("iphone") + + +def test_read_devices_json_reads_from_file(pick_simulator_module, tmp_path, sample_devices_payload): + json_file = tmp_path / "devices.json" + json_file.write_text(json.dumps(sample_devices_payload), encoding="utf-8") + + loaded_payload = pick_simulator_module.readDevicesJson(str(json_file)) + assert loaded_payload == sample_devices_payload + + +def test_parse_runtime_identifier_and_classification_helpers(pick_simulator_module): + assert pick_simulator_module.parseRuntimeIdentifier( + "com.apple.CoreSimulator.SimRuntime.iOS-18-2" + ) == ("iOS", (18, 2)) + assert pick_simulator_module.parseRuntimeIdentifier("com.apple.CoreSimulator.SimRuntime.tvOS-18-0") is None + assert pick_simulator_module.classifyDeviceType("iPhone 16 Pro") == "iphone" + assert pick_simulator_module.classifyDeviceType("iPad Air 13-inch") == "ipad" + assert pick_simulator_module.classifyDeviceType("Apple Watch Ultra 2") == "watch" + assert pick_simulator_module.classifyDeviceType("Apple Vision Pro") == "vision" + assert pick_simulator_module.classifyDeviceType("My Mac") == "macos" + assert pick_simulator_module.versionToString((18, 2, 1)) == "18.2.1" + + +def test_variant_and_model_helpers(pick_simulator_module): + pro_max = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16 Pro Max", + udid="A", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + base = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16", + udid="B", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + + assert pick_simulator_module.determineVariantDetails(pro_max.name) == ("pro max", 70) + assert pick_simulator_module.determineModelType(pro_max.name) == "Pro Max" + assert pick_simulator_module.determineModelType(base.name) == "" + assert pick_simulator_module.createSafeName("iPhone 16 Pro Max", "18.0") == "iPhone-16-Pro-Max-18.0" + assert pick_simulator_module.filterToLatestModel([base, pro_max]) == [pro_max] + + +def test_filter_to_model_preferences(pick_simulator_module): + pro = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16 Pro", + udid="PRO", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + plus = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16 Plus", + udid="PLUS", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + + filtered = pick_simulator_module.filterToModelPreferences( + [pro, plus], + {"iphone": ["plus"]}, + ) + assert filtered == [plus] + + +def test_enumerate_candidates_groups_by_device_type(pick_simulator_module, sample_devices_payload, capsys): + candidates_by_type = pick_simulator_module.enumerateCandidates( + sample_devices_payload, + ["iphone", "ipad", "macos", "watch", "vision"], + ) + + assert [candidate.name for candidate in candidates_by_type["iphone"]] == [ + "iPhone 16", + "iPhone 16 Pro Max", + "iPhone SE (3rd generation)", + ] + assert [candidate.name for candidate in candidates_by_type["ipad"]] == ["iPad Pro 13-inch (M4)"] + assert [candidate.name for candidate in candidates_by_type["macos"]] == ["My Mac"] + assert [candidate.name for candidate in candidates_by_type["watch"]] == ["Apple Watch Series 10 (42mm)"] + assert [candidate.name for candidate in candidates_by_type["vision"]] == ["Apple Vision Pro"] + + error_output = capsys.readouterr().err + assert "Runtime: com.apple.CoreSimulator.SimRuntime.iOS-18-0" in error_output + assert "Apple TV" not in error_output + + +def test_enumerate_candidates_rejects_invalid_devices_map(pick_simulator_module): + with pytest.raises(ValueError): + pick_simulator_module.enumerateCandidates({"devices": []}, ["iphone"]) + + +def test_filter_candidates_for_requested_os_and_candidate_pool(pick_simulator_module, sample_devices_payload, capsys): + candidates = pick_simulator_module.enumerateCandidates(sample_devices_payload, ["iphone"])["iphone"] + + latest_candidates = pick_simulator_module.filterCandidatesForRequestedOs( + candidates, + None, + "iphone", + ) + assert [candidate.name for candidate in latest_candidates] == ["iPhone 16", "iPhone 16 Pro Max"] + + specific_candidates = pick_simulator_module.filterCandidatesForRequestedOs( + candidates, + (17, 5), + "iphone", + ) + assert [candidate.name for candidate in specific_candidates] == ["iPhone SE (3rd generation)"] + + pool = pick_simulator_module.buildCandidatePool( + deviceType="iphone", + candidates=candidates, + requestedVersion=None, + selectionMode="latest-model", + modelPreferences={}, + ) + assert [candidate.name for candidate in pool] == ["iPhone 16 Pro Max"] + + error_output = capsys.readouterr().err + assert "iphone: using latest OS 18.0" in error_output + assert "iphone: requested OS 17.5" in error_output + + +def test_select_destination_from_pool_and_requested_versions(pick_simulator_module, monkeypatch): + first = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16", + udid="FIRST", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + second = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16 Pro Max", + udid="SECOND", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + + monkeypatch.setattr(pick_simulator_module.random, "choice", lambda values: values[0]) + assert pick_simulator_module.selectDestinationFromPool([first, second], "random-compatible") == first + assert pick_simulator_module.selectDestinationFromPool([first, second], "latest-model") == second + assert pick_simulator_module.selectDestinationFromPool([], "latest-model") is None + + script_args = argparse.Namespace( + iphoneosVersion="18.0", + ipadosVersion="latest", + macosVersion="15.0", + watchosVersion="latest", + visionosVersion="2.0", + ) + assert pick_simulator_module.determineRequestedVersions(script_args) == { + "iphone": (18, 0), + "ipad": None, + "macos": (15, 0), + "watch": None, + "vision": (2, 0), + } + + +def test_validate_script_arguments_and_selection_failures(pick_simulator_module): + valid_args = argparse.Namespace(deviceTypes="iphone,watch", selectionMode="latest-model") + assert pick_simulator_module.validateScriptArguments(valid_args) == ["iphone", "watch"] + + with pytest.raises(ValueError): + pick_simulator_module.validateScriptArguments( + argparse.Namespace(deviceTypes="iphone,tvos", selectionMode="latest-model") + ) + + with pytest.raises(SystemExit): + pick_simulator_module.determineSelectedDestinations( + requestedDeviceTypes=["iphone"], + candidatesByType={"iphone": []}, + requestedVersions={"iphone": None}, + selectionMode="latest-model", + modelPreferences={}, + ) + + +def test_determine_selected_destinations_success_and_publish_outputs( + pick_simulator_module, + tmp_path, + sample_devices_payload, + monkeypatch, +): + candidates_by_type = pick_simulator_module.enumerateCandidates(sample_devices_payload, ["iphone", "watch"]) + monkeypatch.setattr(pick_simulator_module.random, "choice", lambda values: values[0]) + + selected_candidates = pick_simulator_module.determineSelectedDestinations( + requestedDeviceTypes=["iphone", "watch"], + candidatesByType=candidates_by_type, + requestedVersions={"iphone": None, "watch": None}, + selectionMode="random-compatible", + modelPreferences={}, + ) + + assert [candidate.udid for candidate in selected_candidates] == ["IPHONE16", "WATCH10"] + + output_file = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + pick_simulator_module.publishOutputs(selected_candidates) + + output_contents = output_file.read_text(encoding="utf-8") + assert "simulator_jsons=" in output_contents + assert "destination_ids<<__SIMCTL_PICK_A_TRICORDER__" in output_contents + assert "IPHONE16" in output_contents + assert "WATCH10" in output_contents + + +def test_main_runs_end_to_end(pick_simulator_module, tmp_path, sample_devices_payload, monkeypatch, capsys): + class FakeParser: + def parse_args(self): + return argparse.Namespace( + deviceTypes="iphone,watch", + selectionMode="random-compatible", + modelPreferences="", + iphoneosVersion="latest", + ipadosVersion="latest", + macosVersion="latest", + watchosVersion="latest", + visionosVersion="latest", + devicesJsonFile=None, + ) + + output_file = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setattr(pick_simulator_module, "setupArgumentParser", lambda: FakeParser()) + monkeypatch.setattr(pick_simulator_module, "readDevicesJson", lambda *_args, **_kwargs: sample_devices_payload) + monkeypatch.setattr(pick_simulator_module.random, "choice", lambda values: values[0]) + + pick_simulator_module.main() + + output_contents = output_file.read_text(encoding="utf-8") + assert "simulator_jsons=" in output_contents + assert "IPHONE16" in output_contents + assert "WATCH10" in output_contents + + error_output = capsys.readouterr().err + assert "Selected Simulators:" in error_output + + +def test_pick_simulator_script_runs_as_black_box(repo_root, python_executable, tmp_path, sample_devices_payload): + script_path = repo_root / ".github/actions/simctl-tricorder-selector/pick_simulator.py" + output_file = tmp_path / "github-output.txt" + fake_bin_dir = tmp_path / "bin" + fake_bin_dir.mkdir() + fake_xcrun_path = fake_bin_dir / "xcrun" + devices_json_path = tmp_path / "devices.json" + devices_json_path.write_text(json.dumps(sample_devices_payload), encoding="utf-8") + + fake_xcrun_path.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import os + import sys + + with open(os.environ["SIMCTL_LOG_FILE"], "a", encoding="utf-8") as handle: + handle.write(" ".join(sys.argv[1:]) + "\\n") + + if sys.argv[1:] == ["simctl", "list", "devices", "--json"]: + with open(os.environ["SIMCTL_DEVICES_JSON_FILE"], "r", encoding="utf-8") as handle: + sys.stdout.write(handle.read()) + raise SystemExit(0) + + raise SystemExit(f"Unexpected xcrun arguments: {sys.argv[1:]}") + """ + ), + encoding="utf-8", + ) + fake_xcrun_path.chmod(0o755) + + command = [ + python_executable, + str(script_path), + "--device-types", "iphone,watch", + "--selection-mode", "latest-model", + "--iphoneos-version", "latest", + "--watchos-version", "latest", + ] + environment = os.environ.copy() + environment["GITHUB_OUTPUT"] = str(output_file) + environment["SIMCTL_DEVICES_JSON_FILE"] = str(devices_json_path) + environment["SIMCTL_LOG_FILE"] = str(tmp_path / "xcrun.log") + environment["PATH"] = str(fake_bin_dir) + os.pathsep + environment["PATH"] + + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=environment, + ) + + output_contents = output_file.read_text(encoding="utf-8") + assert "simulator_jsons=" in output_contents + assert "destination_ids<<__SIMCTL_PICK_A_TRICORDER__" in output_contents + assert "IPHONE16PM" in output_contents + assert "WATCH10" in output_contents + + simulator_payload = json.loads(output_contents.split("simulator_jsons=", 1)[1].splitlines()[0]) + assert simulator_payload[0] == { + "udid": "IPHONE16PM", + "name": "iPhone 16 Pro Max", + "os": "18.0", + "modelType": "Pro Max", + "safe_name": "iPhone-16-Pro-Max-18.0", + } + assert simulator_payload[1]["udid"] == "WATCH10" + assert simulator_payload[1]["name"] == "Apple Watch Series 10 (42mm)" + assert simulator_payload[1]["os"] == "11.0" + assert simulator_payload[1]["safe_name"].startswith("Apple-Watch-Series-10-42mm") + + assert "Selected Simulators:" in result.stderr + assert "iPhone 16 Pro Max" in result.stderr + assert "Apple Watch Series 10 (42mm)" in result.stderr + assert (tmp_path / "xcrun.log").read_text(encoding="utf-8").strip() == "simctl list devices --json" diff --git a/.github/actions-tests/xccov-warp-bubble/generate_coverage_summary_tests.py b/.github/actions-tests/xccov-warp-bubble/generate_coverage_summary_tests.py new file mode 100644 index 00000000..5b62691d --- /dev/null +++ b/.github/actions-tests/xccov-warp-bubble/generate_coverage_summary_tests.py @@ -0,0 +1,374 @@ +import argparse +import json +import os +import subprocess +import textwrap + +import pytest + + +def build_script_args(tmp_path, **overrides): + values = { + "xcresultsDirectory": str(tmp_path / "CoverageResults" / "xcresults"), + "summaryFile": str(tmp_path / "CoverageResults" / "summary.md"), + "summaryJsonFile": str(tmp_path / "CoverageResults" / "summary.json"), + "failingCoverageThreshold": 60.0, + "passingCoverageThreshold": 75.0, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def test_setup_argument_parser_parses_valid_values(coverage_summary_module): + parser = coverage_summary_module.setupArgumentParser() + script_args = parser.parse_args([ + "--xcresults-directory", "CoverageResults/xcresults", + "--summary-file", "CoverageResults/summary.md", + "--summary-json-file", "CoverageResults/summary.json", + "--failing-coverage-threshold", "60", + "--passing-coverage-threshold", "75", + ]) + + assert script_args.xcresultsDirectory == "CoverageResults/xcresults" + assert script_args.failingCoverageThreshold == 60.0 + assert script_args.passingCoverageThreshold == 75.0 + + +def test_argument_parsing_helpers(coverage_summary_module): + assert coverage_summary_module.parseNonEmptyArgument(" summary.md ") == "summary.md" + assert coverage_summary_module.parseThreshold("75", "passing coverage threshold") == 75.0 + assert coverage_summary_module.parseCoverageThresholdArgument("60") == 60.0 + + with pytest.raises(ValueError): + coverage_summary_module.parseNonEmptyArgument(" ") + + with pytest.raises(ValueError): + coverage_summary_module.parseThreshold("abc", "coverage threshold") + + with pytest.raises(ValueError): + coverage_summary_module.parseThreshold("120", "coverage threshold") + + +def test_validate_script_arguments_rejects_invalid_threshold_order(coverage_summary_module, tmp_path): + script_args = build_script_args(tmp_path, failingCoverageThreshold=80.0, passingCoverageThreshold=75.0) + + with pytest.raises(ValueError): + coverage_summary_module.validateScriptArguments(script_args) + + +def test_normalize_scope_name_and_find_result_bundles(coverage_summary_module, tmp_path): + assert coverage_summary_module.normalizeScopeName("project-unit-tests-libPhoneNumber") == "libPhoneNumber" + assert coverage_summary_module.normalizeScopeName("libPhoneNumber.xcresult") == "libPhoneNumber" + + root = tmp_path / "xcresults" + nested_bundle = root / "project-unit-tests-libPhoneNumber" / "libPhoneNumber-iPhone-16.xcresult" + nested_bundle.mkdir(parents=True) + + bundles = coverage_summary_module.findResultBundles(str(root)) + assert bundles == [str(nested_bundle)] + + +def test_discover_coverage_scopes_prefers_scope_directories(coverage_summary_module, tmp_path, capsys): + root = tmp_path / "xcresults" + (root / "project-unit-tests-libPhoneNumber" / "libPhoneNumber-iPhone-16.xcresult").mkdir(parents=True) + (root / "project-unit-tests-libPhoneNumberGeocoding" / "libPhoneNumberGeocoding-iPhone-16.xcresult").mkdir(parents=True) + (root / "empty-scope").mkdir(parents=True) + + scopes = coverage_summary_module.discoverCoverageScopes(str(root)) + assert sorted(scopes.keys()) == ["libPhoneNumber", "libPhoneNumberGeocoding"] + + error_output = capsys.readouterr().err + assert "empty-scope: no downloaded .xcresult bundles found" in error_output + + +def test_discover_coverage_scopes_falls_back_to_root_bundles(coverage_summary_module, tmp_path): + root = tmp_path / "xcresults" + bundle = root / "libPhoneNumber-iPhone-16.xcresult" + bundle.mkdir(parents=True) + + scopes = coverage_summary_module.discoverCoverageScopes(str(root)) + assert scopes == {"xcresults": [str(bundle)]} + + +def test_merge_coverage_report_and_scope_calculation(coverage_summary_module, tmp_path, monkeypatch): + scope_a_bundle = tmp_path / "scope-a.xcresult" + scope_b_bundle = tmp_path / "scope-b.xcresult" + scope_a_bundle.mkdir() + scope_b_bundle.mkdir() + + reports = { + str(scope_a_bundle): { + "/tmp/FileA.swift": [ + {"line": 1, "isExecutable": True, "executionCount": 1}, + {"line": 2, "isExecutable": True, "executionCount": 0}, + {"line": 3, "isExecutable": False, "executionCount": 0}, + ], + }, + str(scope_b_bundle): { + "/tmp/FileA.swift": [ + {"line": 2, "isExecutable": True, "executionCount": 1}, + ], + "/tmp/FileB.swift": [ + {"line": 10, "isExecutable": True, "executionCount": 1}, + ], + }, + } + + def fake_check_output(command, text): + assert text is True + bundle_path = command[-1] + return json.dumps(reports[bundle_path]) + + monkeypatch.setattr(coverage_summary_module.subprocess, "check_output", fake_check_output) + + merged = {} + coverage_summary_module.mergeCoverageReport(merged, str(scope_a_bundle)) + assert merged["/tmp/FileA.swift"] == {1: True, 2: False} + + scope_coverages, overall_coverage = coverage_summary_module.calculateScopeCoverages( + { + "ScopeA": [str(scope_a_bundle)], + "ScopeB": [str(scope_b_bundle)], + } + ) + + assert [(scope.name, round(scope.coveragePercent, 2)) for scope in scope_coverages] == [ + ("ScopeA", 50.0), + ("ScopeB", 100.0), + ] + assert overall_coverage is not None + assert overall_coverage.name == "Combined" + assert round(overall_coverage.coveragePercent, 2) == 100.0 + + +def test_rendering_and_serialization_helpers(coverage_summary_module, capsys): + thresholds = coverage_summary_module.CoverageThresholds(failing=60.0, passing=75.0) + scope_a = coverage_summary_module.ScopeCoverage( + name="ScopeA", + coveredLines=3, + executableLines=5, + coveragePercent=60.0, + ) + scope_b = coverage_summary_module.ScopeCoverage( + name="ScopeB", + coveredLines=5, + executableLines=5, + coveragePercent=100.0, + ) + combined = coverage_summary_module.ScopeCoverage( + name="Combined", + coveredLines=8, + executableLines=10, + coveragePercent=80.0, + ) + + assert coverage_summary_module.determineCoverageStatus(50.0, thresholds) == "fail" + assert coverage_summary_module.determineCoverageStatus(65.0, thresholds) == "warn" + assert coverage_summary_module.determineCoverageStatus(80.0, thresholds) == "pass" + assert coverage_summary_module.determineStatusEmoji("pass") == "✅" + assert coverage_summary_module.serializeScope(scope_a, thresholds)["status"] == "warn" + assert coverage_summary_module.determineOverallCoveragePercent([scope_a], None) == "60.00" + assert coverage_summary_module.determineOverallCoveragePercent([scope_a, scope_b], combined) == "80.00" + + coverage_summary_module.printCoverageSummary([scope_a, scope_b], combined, thresholds) + output = capsys.readouterr().out + assert "ScopeA - 60.00% ⚠️" in output + assert "Combined - 80.00% ✅" in output + + markdown = coverage_summary_module.renderMarkdownSummary([scope_a, scope_b], combined, thresholds) + assert "| ScopeA | 60.00% | ⚠️ |" in markdown + assert "**Combined**" in markdown + + json_payload = coverage_summary_module.renderJsonSummary([scope_a, scope_b], combined, thresholds) + assert json_payload["scope_count"] == 2 + assert json_payload["combined"]["status"] == "pass" + + +def test_file_output_helpers(coverage_summary_module, tmp_path, monkeypatch): + thresholds = coverage_summary_module.CoverageThresholds(failing=60.0, passing=75.0) + summary_file = tmp_path / "results" / "summary.md" + summary_json_file = tmp_path / "results" / "summary.json" + output_file = tmp_path / "github-output.txt" + + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + + coverage_summary_module.writeTextFile(str(summary_file), "hello\n") + coverage_summary_module.writeJsonFile(str(summary_json_file), {"ok": True}) + coverage_summary_module.publishOutputs(str(summary_file), str(summary_json_file), "88.00", 2) + + assert summary_file.read_text(encoding="utf-8") == "hello\n" + assert json.loads(summary_json_file.read_text(encoding="utf-8")) == {"ok": True} + + output_contents = output_file.read_text(encoding="utf-8") + assert f"summary_file={summary_file}" in output_contents + assert "coverage_percent=88.00" in output_contents + assert "scope_count=2" in output_contents + + coverage_summary_module.writeUnavailableSummaries( + str(summary_file), + str(summary_json_file), + "No coverage available.", + thresholds, + ) + assert "No coverage available." in summary_file.read_text(encoding="utf-8") + assert json.loads(summary_json_file.read_text(encoding="utf-8"))["scope_count"] == 0 + + +def test_main_writes_unavailable_summaries(coverage_summary_module, tmp_path, monkeypatch): + script_args = build_script_args(tmp_path) + output_file = tmp_path / "github-output.txt" + + class FakeParser: + def parse_args(self): + return script_args + + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setattr(coverage_summary_module, "setupArgumentParser", lambda: FakeParser()) + + coverage_summary_module.main() + + summary_contents = (tmp_path / "CoverageResults" / "summary.md").read_text(encoding="utf-8") + json_payload = json.loads((tmp_path / "CoverageResults" / "summary.json").read_text(encoding="utf-8")) + output_contents = output_file.read_text(encoding="utf-8") + + assert "Code coverage unavailable because no unit test result bundles were downloaded." in summary_contents + assert json_payload["scope_count"] == 0 + assert "coverage_percent=" in output_contents + + +def test_main_writes_summary_for_downloaded_results(coverage_summary_module, tmp_path, monkeypatch): + xcresults_root = tmp_path / "CoverageResults" / "xcresults" + bundle = xcresults_root / "project-unit-tests-libPhoneNumber" / "libPhoneNumber-iPhone-16.xcresult" + bundle.mkdir(parents=True) + + script_args = build_script_args(tmp_path) + output_file = tmp_path / "github-output.txt" + + class FakeParser: + def parse_args(self): + return script_args + + def fake_check_output(command, text): + assert command[-1] == str(bundle) + return json.dumps( + { + "/tmp/FileA.swift": [ + {"line": 1, "isExecutable": True, "executionCount": 1}, + {"line": 2, "isExecutable": True, "executionCount": 0}, + ], + } + ) + + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setattr(coverage_summary_module, "setupArgumentParser", lambda: FakeParser()) + monkeypatch.setattr(coverage_summary_module.subprocess, "check_output", fake_check_output) + + coverage_summary_module.main() + + summary_contents = (tmp_path / "CoverageResults" / "summary.md").read_text(encoding="utf-8") + json_payload = json.loads((tmp_path / "CoverageResults" / "summary.json").read_text(encoding="utf-8")) + output_contents = output_file.read_text(encoding="utf-8") + + assert "| libPhoneNumber | 50.00% | ❌ |" in summary_contents + assert json_payload["overall_coverage_percent"] == 50.0 + assert "coverage_percent=50.00" in output_contents + + +def test_generate_coverage_summary_script_runs_as_black_box(repo_root, python_executable, tmp_path): + script_path = repo_root / ".github/actions/xccov-warp-bubble/generate_coverage_summary.py" + output_file = tmp_path / "github-output.txt" + fake_bin_dir = tmp_path / "bin" + fake_bin_dir.mkdir() + fake_xcrun_path = fake_bin_dir / "xcrun" + report_mapping = { + "libPhoneNumber-iPhone-16.xcresult": { + "/tmp/FileA.swift": [ + {"line": 1, "isExecutable": True, "executionCount": 1}, + {"line": 2, "isExecutable": True, "executionCount": 0}, + ] + }, + "libPhoneNumberGeocoding-iPhone-16.xcresult": { + "/tmp/FileA.swift": [ + {"line": 2, "isExecutable": True, "executionCount": 1}, + ], + "/tmp/FileB.swift": [ + {"line": 10, "isExecutable": True, "executionCount": 1}, + ], + }, + } + mapping_file = tmp_path / "xccov-reports.json" + mapping_file.write_text(json.dumps(report_mapping), encoding="utf-8") + + fake_xcrun_path.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import sys + from pathlib import Path + + args = sys.argv[1:] + with open(os.environ["XCRUN_LOG_FILE"], "a", encoding="utf-8") as handle: + handle.write(" ".join(args) + "\\n") + + if args[:3] != ["xccov", "view", "--archive"] or args[3] != "--json": + raise SystemExit(f"Unexpected xcrun arguments: {args}") + + report_key = Path(args[4]).name + with open(os.environ["XCCOV_REPORTS_FILE"], "r", encoding="utf-8") as handle: + reports = json.load(handle) + + sys.stdout.write(json.dumps(reports[report_key])) + raise SystemExit(0) + """ + ), + encoding="utf-8", + ) + fake_xcrun_path.chmod(0o755) + + xcresults_root = tmp_path / "CoverageResults" / "xcresults" + (xcresults_root / "project-unit-tests-libPhoneNumber" / "libPhoneNumber-iPhone-16.xcresult").mkdir(parents=True) + (xcresults_root / "project-unit-tests-libPhoneNumberGeocoding" / "libPhoneNumberGeocoding-iPhone-16.xcresult").mkdir(parents=True) + summary_file = tmp_path / "CoverageResults" / "summary.md" + summary_json_file = tmp_path / "CoverageResults" / "summary.json" + + command = [ + python_executable, + str(script_path), + "--xcresults-directory", str(xcresults_root), + "--summary-file", str(summary_file), + "--summary-json-file", str(summary_json_file), + "--failing-coverage-threshold", "60", + "--passing-coverage-threshold", "75", + ] + environment = os.environ.copy() + environment["GITHUB_OUTPUT"] = str(output_file) + environment["XCCOV_REPORTS_FILE"] = str(mapping_file) + environment["XCRUN_LOG_FILE"] = str(tmp_path / "xcrun.log") + environment["PATH"] = str(fake_bin_dir) + os.pathsep + environment["PATH"] + + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=environment, + ) + + summary_contents = summary_file.read_text(encoding="utf-8") + summary_json = json.loads(summary_json_file.read_text(encoding="utf-8")) + output_contents = output_file.read_text(encoding="utf-8") + xcrun_log_lines = (tmp_path / "xcrun.log").read_text(encoding="utf-8").splitlines() + + assert "| libPhoneNumber | 50.00% | ❌ |" in summary_contents + assert "| libPhoneNumberGeocoding | 100.00% | ✅ |" in summary_contents + assert "**Combined**" in summary_contents + assert summary_json["combined"]["coverage_percent"] == 100.0 + assert "coverage_percent=100.00" in output_contents + assert "scope_count=2" in output_contents + assert "Processing result bundle for libPhoneNumber:" in result.stdout + assert "Combined - 100.00% ✅" in result.stdout + assert len(xcrun_log_lines) == 4 + assert sum("libPhoneNumber-iPhone-16.xcresult" in line for line in xcrun_log_lines) == 2 + assert sum("libPhoneNumberGeocoding-iPhone-16.xcresult" in line for line in xcrun_log_lines) == 2 diff --git a/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py b/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py new file mode 100644 index 00000000..c007276c --- /dev/null +++ b/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py @@ -0,0 +1,228 @@ +import argparse +import json +import os +import subprocess +import textwrap + +import pytest + + +def build_script_args(tmp_path, **overrides): + values = { + "scheme": "libPhoneNumber", + "xcodeContainer": "libPhoneNumber.xcodeproj", + "destinationIds": "SIM-001\nSIM-002\n", + "simulatorJsons": json.dumps([ + {"name": "iPhone 16", "os": "18.0", "safe_name": "iPhone-16-18.0"}, + {"name": "iPhone 16 Pro Max", "os": "18.0", "safe_name": "iPhone-16-Pro-Max-18.0"}, + ]), + "resultBundleDirectory": str(tmp_path / "TestResults"), + "destinationArch": "arm64", + "xcodebuildExtraArgs": "--test-iterations 2", + } + values.update(overrides) + return argparse.Namespace(**values) + + +def test_setup_argument_parser_parses_valid_values(run_xcode_tests_module): + parser = run_xcode_tests_module.setupArgumentParser() + script_args = parser.parse_args([ + "--scheme", "libPhoneNumber", + "--xcode-container", "libPhoneNumber.xcodeproj", + "--destination-ids", "SIM-001", + "--simulator-jsons", '[{"name":"iPhone 16","os":"18.0","safe_name":"iphone-16"}]', + "--destination-arch", "arm64", + ]) + + assert script_args.scheme == "libPhoneNumber" + assert script_args.destinationArch == "arm64" + + +def test_argument_parsing_helpers(run_xcode_tests_module): + assert run_xcode_tests_module.parseNonEmptyArgument(" libPhoneNumber ") == "libPhoneNumber" + assert run_xcode_tests_module.parseDestinationIds("A\nB\n\n") == ["A", "B"] + + with pytest.raises(ValueError): + run_xcode_tests_module.parseNonEmptyArgument(" ") + + +def test_determine_xcode_container_type(run_xcode_tests_module): + assert run_xcode_tests_module.determineXcodeContainerType("App.xcodeproj") == "project" + assert run_xcode_tests_module.determineXcodeContainerType("App.xcworkspace") == "workspace" + + with pytest.raises(ValueError): + run_xcode_tests_module.determineXcodeContainerType("App.swift") + + +def test_parse_simulator_jsons_validation(run_xcode_tests_module): + simulators = run_xcode_tests_module.parseSimulatorJsons( + '[{"name":"iPhone 16","os":"18.0","safe_name":"iphone-16"}]' + ) + assert simulators == [{"name": "iPhone 16", "os": "18.0", "safe_name": "iphone-16"}] + + with pytest.raises(ValueError): + run_xcode_tests_module.parseSimulatorJsons('{"name":"iPhone 16"}') + + with pytest.raises(ValueError): + run_xcode_tests_module.parseSimulatorJsons('[{"name":"iPhone 16","os":"18.0"}]') + + +def test_validate_script_arguments(run_xcode_tests_module, tmp_path): + script_args = build_script_args(tmp_path) + destination_ids, simulators = run_xcode_tests_module.validateScriptArguments(script_args) + + assert destination_ids == ["SIM-001", "SIM-002"] + assert len(simulators) == 2 + + with pytest.raises(ValueError): + run_xcode_tests_module.validateScriptArguments( + build_script_args(tmp_path, destinationIds="SIM-001") + ) + + +def test_write_github_outputs(run_xcode_tests_module, tmp_path, monkeypatch): + output_file = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + + run_xcode_tests_module.writeGithubOutput("result_bundle_directory", "TestResults") + run_xcode_tests_module.writeGithubMultilineOutput("result_bundle_paths", ["A.xcresult", "B.xcresult"]) + + contents = output_file.read_text(encoding="utf-8") + assert "result_bundle_directory=TestResults" in contents + assert "result_bundle_paths<<__XCODE_TEST_THE_TRICORDERS__" in contents + assert "A.xcresult" in contents + assert "B.xcresult" in contents + + +def test_run_tests_executes_xcodebuild_for_each_destination(run_xcode_tests_module, tmp_path, monkeypatch): + script_args = build_script_args(tmp_path) + destination_ids = ["SIM-001", "SIM-002"] + simulators = run_xcode_tests_module.parseSimulatorJsons(script_args.simulatorJsons) + recorded_commands = [] + + def fake_check_call(command): + recorded_commands.append(command) + + monkeypatch.setattr(run_xcode_tests_module.subprocess, "check_call", fake_check_call) + + result_bundle_directory, result_bundle_paths = run_xcode_tests_module.runTests( + scriptArgs=script_args, + destinationIds=destination_ids, + simulators=simulators, + ) + + assert result_bundle_directory == script_args.resultBundleDirectory + assert result_bundle_paths == [ + str(tmp_path / "TestResults" / "libPhoneNumber-iPhone-16-18.0.xcresult"), + str(tmp_path / "TestResults" / "libPhoneNumber-iPhone-16-Pro-Max-18.0.xcresult"), + ] + assert len(recorded_commands) == 2 + assert recorded_commands[0][:4] == ["xcodebuild", "-project", "libPhoneNumber.xcodeproj", "-scheme"] + assert "--test-iterations" in recorded_commands[0] + assert "2" in recorded_commands[0] + assert "CODE_SIGNING_ALLOWED=NO" in recorded_commands[0] + assert recorded_commands[0][-1] == "test" + + +def test_main_runs_end_to_end(run_xcode_tests_module, tmp_path, monkeypatch): + script_args = build_script_args(tmp_path) + output_file = tmp_path / "github-output.txt" + recorded_commands = [] + + class FakeParser: + def parse_args(self): + return script_args + + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setattr(run_xcode_tests_module, "setupArgumentParser", lambda: FakeParser()) + monkeypatch.setattr(run_xcode_tests_module.subprocess, "check_call", lambda command: recorded_commands.append(command)) + + run_xcode_tests_module.main() + + contents = output_file.read_text(encoding="utf-8") + assert "result_bundle_directory=" in contents + assert "result_bundle_paths<<__XCODE_TEST_THE_TRICORDERS__" in contents + assert len(recorded_commands) == 2 + + +def test_run_xcode_tests_script_runs_as_black_box(repo_root, python_executable, tmp_path): + script_path = repo_root / ".github/actions/xcode-tricorder-tester/run_xcode_tests.py" + output_file = tmp_path / "github-output.txt" + fake_bin_dir = tmp_path / "bin" + fake_bin_dir.mkdir() + fake_xcodebuild_path = fake_bin_dir / "xcodebuild" + fake_xcodebuild_path.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import sys + from pathlib import Path + + args = sys.argv[1:] + with open(os.environ["XCODEBUILD_LOG_FILE"], "a", encoding="utf-8") as handle: + handle.write(json.dumps(args) + "\\n") + + if "-resultBundlePath" in args: + result_bundle_path = args[args.index("-resultBundlePath") + 1] + Path(result_bundle_path).mkdir(parents=True, exist_ok=True) + + raise SystemExit(0) + """ + ), + encoding="utf-8", + ) + fake_xcodebuild_path.chmod(0o755) + + result_bundle_directory = tmp_path / "TestResults" + command = [ + python_executable, + str(script_path), + "--scheme", "libPhoneNumber", + "--xcode-container", "libPhoneNumber.xcodeproj", + "--destination-ids", "SIM-001\nSIM-002", + "--simulator-jsons", json.dumps([ + {"name": "iPhone 16", "os": "18.0", "safe_name": "iPhone-16-18.0"}, + {"name": "iPhone 16 Pro Max", "os": "18.0", "safe_name": "iPhone-16-Pro-Max-18.0"}, + ]), + "--result-bundle-directory", str(result_bundle_directory), + "--destination-arch", "arm64", + "--xcodebuild-extra-args", "--test-iterations 2", + ] + environment = os.environ.copy() + environment["GITHUB_OUTPUT"] = str(output_file) + environment["XCODEBUILD_LOG_FILE"] = str(tmp_path / "xcodebuild.log") + environment["PATH"] = str(fake_bin_dir) + os.pathsep + environment["PATH"] + + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=environment, + ) + + output_contents = output_file.read_text(encoding="utf-8") + assert f"result_bundle_directory={result_bundle_directory}" in output_contents + assert "result_bundle_paths<<__XCODE_TEST_THE_TRICORDERS__" in output_contents + assert "libPhoneNumber-iPhone-16-18.0.xcresult" in output_contents + assert "libPhoneNumber-iPhone-16-Pro-Max-18.0.xcresult" in output_contents + + logged_commands = [ + json.loads(line) + for line in (tmp_path / "xcodebuild.log").read_text(encoding="utf-8").splitlines() + ] + assert len(logged_commands) == 2 + assert logged_commands[0][:4] == ["-project", "libPhoneNumber.xcodeproj", "-scheme", "libPhoneNumber"] + assert "--test-iterations" in logged_commands[0] + assert "2" in logged_commands[0] + assert any( + path.name == "libPhoneNumber-iPhone-16-18.0.xcresult" + for path in result_bundle_directory.iterdir() + ) + assert any( + path.name == "libPhoneNumber-iPhone-16-Pro-Max-18.0.xcresult" + for path in result_bundle_directory.iterdir() + ) + assert "Running libPhoneNumber on iPhone 16 (18.0)" in result.stderr diff --git a/.github/actions/simctl-tricorder-selector/README.md b/.github/actions/simctl-tricorder-selector/README.md new file mode 100644 index 00000000..c51200b9 --- /dev/null +++ b/.github/actions/simctl-tricorder-selector/README.md @@ -0,0 +1,61 @@ +# Simctl Pick A Tricorder + +Local composite action for choosing an installed simulator device from `xcrun simctl list devices --json`. + +## Inputs + +- `device_types` + - Ordered, comma-separated list of device types to return simulators for. + - Supported values: `iphone`, `ipad`, `macos`, `watch`, `vision` +- `selection_mode` + - `random-compatible` + - `random-latest-compatible` + - `model-type` + - `latest-model` +- `model_preferences` + - Semicolon-separated mappings such as `iphone=Pro Max;ipad=Pro;watch=Ultra` +- `iphoneos_version`, `ipados_version`, `macos_version`, `watchos_version`, `visionos_version` + - A specific version like `18`, `18.2`, `15.0` + - Or `latest` + +`device_types` is required. The action returns exactly one simulator destination per requested device type, in the same order the device types were specified. If any requested device type cannot be satisfied, the action fails. + +## Outputs + +- `simulator_jsons` +- `destination_ids` + +`simulator_jsons` returns one object per found simulator with: +- `udid` +- `name` +- `os` +- `modelType` +- `safe_name` + +## Example + +```yaml +- name: Pick simulator + id: simulator + uses: ./.github/actions/simctl-tricorder-selector + with: + device_types: iphone,ipad + iphoneos_version: latest + ipados_version: latest + selection_mode: random-latest-compatible + +- name: Run tests + id: tests + uses: ./.github/actions/xcode-tricorder-tester + with: + scheme: libPhoneNumber + xcode_container: libPhoneNumber.xcodeproj + destination_ids: ${{ steps.simulator.outputs.destination_ids }} + simulator_jsons: ${{ steps.simulator.outputs.simulator_jsons }} + +- name: Upload unit test results + uses: actions/upload-artifact@v7 + with: + name: project-unit-tests-libPhoneNumber + path: ${{ steps.tests.outputs.result_bundle_directory }} +``` diff --git a/.github/actions/simctl-tricorder-selector/action.yml b/.github/actions/simctl-tricorder-selector/action.yml new file mode 100644 index 00000000..c8f8e559 --- /dev/null +++ b/.github/actions/simctl-tricorder-selector/action.yml @@ -0,0 +1,61 @@ +name: Simctl Tricorder Selector +description: Select a compatible Apple simulator device from the installed simctl inventory. + +inputs: + device_types: + description: "Ordered, comma-separated list of device types to consider. Supported values: iphone, ipad, macos, watch, vision." + required: true + selection_mode: + description: "One of random-compatible, random-latest-compatible, model-type, latest-model." + required: false + default: random-compatible + model_preferences: + description: 'Semicolon-separated device type mappings such as "iphone=Pro Max;ipad=Pro;watch=Ultra;vision=Pro".' + required: false + default: "" + iphoneos_version: + description: "Specific iOS version for iPhone devices, or latest." + required: false + default: latest + ipados_version: + description: "Specific iOS version for iPad devices, or latest." + required: false + default: latest + macos_version: + description: "Specific macOS version for macOS simulator devices, or latest." + required: false + default: latest + watchos_version: + description: "Specific watchOS version for watch devices, or latest." + required: false + default: latest + visionos_version: + description: "Specific visionOS version for Vision devices, or latest." + required: false + default: latest + +outputs: + simulator_jsons: + description: "The selected simulators as a JSON array of objects with udid, name, os, and modelType." + value: ${{ steps.pick.outputs.simulator_jsons }} + destination_ids: + description: "The selected simulator UDIDs as a newline-separated list." + value: ${{ steps.pick.outputs.destination_ids }} + +runs: + using: composite + steps: + - name: Pick simulator + id: pick + shell: bash + run: | + set -eo pipefail + python3 "${{ github.action_path }}/pick_simulator.py" \ + --device-types "${{ inputs.device_types }}" \ + --selection-mode "${{ inputs.selection_mode }}" \ + --model-preferences "${{ inputs.model_preferences }}" \ + --iphoneos-version "${{ inputs.iphoneos_version }}" \ + --ipados-version "${{ inputs.ipados_version }}" \ + --macos-version "${{ inputs.macos_version }}" \ + --watchos-version "${{ inputs.watchos_version }}" \ + --visionos-version "${{ inputs.visionos_version }}" diff --git a/.github/actions/simctl-tricorder-selector/pick_simulator.py b/.github/actions/simctl-tricorder-selector/pick_simulator.py new file mode 100644 index 00000000..11fc128e --- /dev/null +++ b/.github/actions/simctl-tricorder-selector/pick_simulator.py @@ -0,0 +1,923 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# pick_simulator.py +# simctl-tricorder-selector +# +# Created by Kodex on 4/17/26. +# +# This script inspects the locally installed Xcode simulator inventory and chooses one +# compatible destination per requested device type for downstream use in GitHub Actions +# or local automation. + +import argparse +import json +import os +import random +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Optional + + +SCRIPT_VERSION: str = "0.3.0" +"""The current version of the script""" + + +SUPPORTED_DEVICE_TYPES: tuple[str, ...] = ("iphone", "ipad", "macos", "watch", "vision") +"""Supported Apple simulator device types""" + + +SUPPORTED_SELECTION_MODES: tuple[str, ...] = ( + "random-compatible", + "random-latest-compatible", + "model-type", + "latest-model", +) +"""Supported simulator selection modes""" + + +RUNTIME_FAMILY_MAP: dict[str, tuple[str, ...]] = { + "iOS": ("iphone", "ipad"), + "macOS": ("macos",), + "watchOS": ("watch",), + "visionOS": ("vision",), + "xrOS": ("vision",), +} +"""Maps simctl runtime families to the supported device types""" + + +VARIANT_SCORES: tuple[tuple[str, int], ...] = ( + ("ultra", 80), + ("pro max", 70), + ("max", 60), + ("plus", 50), + ("pro", 40), + ("air", 30), + ("mini", 20), + ("se", 10), +) +"""Relative ranking for Apple simulator model variants""" + + +OUTPUT_MARKER: str = "__SIMCTL_PICK_A_TRICORDER__" +"""The multiline GitHub Actions output marker""" + + +@dataclass(frozen=True) +class Candidate: + """Represents a single compatible simulator candidate""" + + deviceType: str + """The normalized device type""" + + name: str + """The simulator display name""" + + udid: str + """The simulator UDID""" + + runtimeIdentifier: str + """The CoreSimulator runtime identifier""" + + runtimeFamily: str + """The runtime family, such as iOS or watchOS""" + + osVersion: tuple[int, ...] + """The parsed OS version tuple""" + + +def setupArgumentParser() -> argparse.ArgumentParser: + """ + Sets up the Arugment Parser + + Returns + ------- + ArgumentParser + The created argument parser for this script + """ + + parser: argparse.ArgumentParser = argparse.ArgumentParser(description=""" + This script inspects the available Xcode simulators and selects one + compatible Apple simulator destination per requested device type.""") + + parser.add_argument("--version", "-v", action="version", + version="%(prog)s " + SCRIPT_VERSION) + parser.add_argument("-?", action="help", + help="show this help message and exit") + parser.add_argument("--device-types", metavar="iphone,ipad", required=True, + help="Ordered, comma-separated list of device types to consider - iphone, ipad, macos, watch, vision", + dest='deviceTypes') + parser.add_argument("--selection-mode", metavar="random-compatible", + help="How to choose the simulator(s) - random-compatible, random-latest-compatible, model-type, latest-model", + dest='selectionMode', default="random-compatible", + choices=SUPPORTED_SELECTION_MODES) + parser.add_argument("--model-preferences", metavar="iphone=Pro Max;ipad=Pro", + help="Semicolon-separated model keywords per device type", + dest='modelPreferences', default="") + parser.add_argument("--iphoneos-version", metavar="latest", + help="Specific iOS version for iPhone simulators, or latest", + dest='iphoneosVersion', default="latest") + parser.add_argument("--ipados-version", metavar="latest", + help="Specific iOS version for iPad simulators, or latest", + dest='ipadosVersion', default="latest") + parser.add_argument("--macos-version", metavar="latest", + help="Specific macOS version for macOS simulators, or latest", + dest='macosVersion', default="latest") + parser.add_argument("--watchos-version", metavar="latest", + help="Specific watchOS version for watch simulators, or latest", + dest='watchosVersion', default="latest") + parser.add_argument("--visionos-version", metavar="latest", + help="Specific visionOS version for Vision simulators, or latest", + dest='visionosVersion', default="latest") + parser.add_argument("--devices-json-file", metavar="devices.json", + help="Optional path to a simctl devices JSON file for local testing", + dest='devicesJsonFile', default=None) + + return parser + + +def printScriptStart(): + """Prints the info for the start of the script""" + + print(f"Starting {os.path.basename(__file__)} v{SCRIPT_VERSION}", file=sys.stderr) + + +def parseCommaSeparatedList(value: str) -> list[str]: + """ + Parses a comma-separated string into a normalized list of values + + Parameters + ---------- + value + The comma-separated string to parse + + Returns + ------- + list[str] + The normalized list of values + """ + + return [part.strip().lower() for part in value.split(",") if len(part.strip()) > 0] + + +def parseRequestedVersion(value: str) -> Optional[tuple[int, ...]]: + """ + Parses the requested OS version string + + Parameters + ---------- + value + The requested version string + + Returns + ------- + tuple[int, ...] | None + The parsed version tuple, or None when the latest available version should be used + """ + + normalized = value.strip().lower() + if normalized == "latest": + return None + + if not re.fullmatch(r"\d+(?:\.\d+)*", normalized): + raise ValueError(f"Unsupported version value specified: {value}") + + return tuple(int(part) for part in normalized.split(".")) + + +def parseModelPreferences(value: str) -> dict[str, list[str]]: + """ + Parses the model preference string into a dictionary keyed by device type + + Parameters + ---------- + value + The raw model preference string + + Returns + ------- + dict[str, list[str]] + The parsed model preferences by device type + """ + + preferences: dict[str, list[str]] = {} + if value is None or len(value.strip()) <= 0: + return preferences + + for segment in value.split(";"): + trimmedSegment = segment.strip() + if len(trimmedSegment) <= 0: + continue + + if "=" not in trimmedSegment: + raise ValueError( + "Model preferences must use the format " + '"device=keyword1,keyword2;other-device=keyword"' + ) + + deviceType, keywordsString = trimmedSegment.split("=", 1) + normalizedDeviceType = deviceType.strip().lower() + + if normalizedDeviceType not in SUPPORTED_DEVICE_TYPES: + raise ValueError(f"Unsupported device type in model preferences: {deviceType}") + + preferences[normalizedDeviceType] = [ + keyword.strip().lower() for keyword in keywordsString.split(",") if len(keyword.strip()) > 0 + ] + + return preferences + + +def readDevicesJson(devicesJsonFile: Optional[str]=None) -> dict[str, object]: + """ + Reads the simulator device inventory as JSON + + Parameters + ---------- + devicesJsonFile + Optional path to a pre-generated simctl devices JSON file + + Returns + ------- + dict[str, object] + The simctl device inventory payload + """ + + if devicesJsonFile is not None and len(devicesJsonFile.strip()) > 0: + with open(devicesJsonFile, "r", encoding="utf-8") as file: + return json.load(file) + + result = subprocess.check_output( + ["xcrun", "simctl", "list", "devices", "--json"], + text=True, + ) + + return json.loads(result) + + +def parseRuntimeIdentifier(runtimeIdentifier: str) -> Optional[tuple[str, tuple[int, ...]]]: + """ + Parses a CoreSimulator runtime identifier + + Parameters + ---------- + runtimeIdentifier + The CoreSimulator runtime identifier + + Returns + ------- + tuple[str, tuple[int, ...]] | None + The runtime family and parsed version tuple, or None if unsupported + """ + + runtimeMatch = re.match( + r"^com\.apple\.CoreSimulator\.SimRuntime\.([A-Za-z]+)-(\d+(?:-\d+)*)$", + runtimeIdentifier, + ) + if runtimeMatch is None: + return None + + runtimeFamily = runtimeMatch.group(1) + if runtimeFamily not in RUNTIME_FAMILY_MAP: + return None + + runtimeVersion = tuple(int(part) for part in runtimeMatch.group(2).split("-")) + return (runtimeFamily, runtimeVersion) + + +def classifyDeviceType(name: str) -> Optional[str]: + """ + Determines the normalized device type from the simulator display name + + Parameters + ---------- + name + The simulator display name + + Returns + ------- + str | None + The normalized device type, or None if unsupported + """ + + normalizedName = name.strip() + + if normalizedName.startswith("iPhone"): + return "iphone" + if normalizedName.startswith("iPad"): + return "ipad" + if normalizedName.startswith("Apple Watch"): + return "watch" + if "Vision" in normalizedName: + return "vision" + if normalizedName.startswith("Mac") or normalizedName == "My Mac": + return "macos" + + return None + + +def versionToString(version: tuple[int, ...]) -> str: + """ + Converts a version tuple into a human-readable string + + Parameters + ---------- + version + The version tuple to convert + + Returns + ------- + str + The version string + """ + + return ".".join(str(part) for part in version) + + +def matchesRequestedVersion(candidateVersion: tuple[int, ...], requestedVersion: Optional[tuple[int, ...]]) -> bool: + """ + Determines whether a simulator OS version matches the requested version + + Parameters + ---------- + candidateVersion + The simulator candidate OS version + requestedVersion + The requested OS version, or None when any/latest version is acceptable + + Returns + ------- + bool + Whether the candidate version matches the requested version + """ + + if requestedVersion is None: + return True + + return candidateVersion[:len(requestedVersion)] == requestedVersion + + +def determineVariantDetails(name: str) -> tuple[str, int] | None: + """ + Determines the detected model variant keyword and score + + Parameters + ---------- + name + The simulator display name + + Returns + ------- + tuple[str, int] | None + The detected variant keyword and score, or None if no variant matched + """ + + normalizedName = name.lower() + + for keyword, score in VARIANT_SCORES: + if keyword in normalizedName: + return (keyword, score) + + return None + + +def determineModelType(name: str) -> str: + """ + Determines the simulator model type from the simulator display name + + Parameters + ---------- + name + The simulator display name + + Returns + ------- + str + The detected model type, or an empty string if none is present + """ + + variantDetails = determineVariantDetails(name) + return variantDetails[0].title() if variantDetails is not None else "" + + +def createSafeName(name: str, osVersion: str) -> str: + """ + Creates a filesystem-safe simulator identifier string + + Parameters + ---------- + name + The simulator display name + osVersion + The simulator OS version string + + Returns + ------- + str + The filesystem-safe simulator identifier + """ + + return re.sub(r"[^A-Za-z0-9._-]+", "-", f"{name}-{osVersion}").strip("-") + + +def determineModelRank(candidate: Candidate) -> tuple[tuple[int, ...], int, tuple[int, ...], str]: + """ + Determines the rank tuple for a simulator candidate + + Parameters + ---------- + candidate + The simulator candidate to rank + + Returns + ------- + tuple[tuple[int, ...], int, tuple[int, ...], str] + The rank tuple used for deterministic sorting and comparisons + """ + + variantDetails = determineVariantDetails(candidate.name) + numericParts = tuple(int(part) for part in re.findall(r"\d+", candidate.name)) + return ( + candidate.osVersion, + variantDetails[1] if variantDetails is not None else 25, + numericParts, + candidate.name, + ) + + +def filterToLatestModel(candidates: list[Candidate]) -> list[Candidate]: + """ + Filters the candidate list down to the highest-ranked model(s) + + Parameters + ---------- + candidates + The candidate simulators to filter + + Returns + ------- + list[Candidate] + The highest-ranked simulator candidate(s) + """ + + if len(candidates) <= 0: + return [] + + bestRank = max(determineModelRank(candidate) for candidate in candidates) + return [candidate for candidate in candidates if determineModelRank(candidate) == bestRank] + + +def filterToModelPreferences(candidates: list[Candidate], modelPreferences: dict[str, list[str]]) -> list[Candidate]: + """ + Filters the candidate list using the requested model preference keywords + + Parameters + ---------- + candidates + The candidate simulators to filter + modelPreferences + The requested model preferences by device type + + Returns + ------- + list[Candidate] + The filtered candidate list + """ + + if len(candidates) <= 0: + return [] + + keywords = modelPreferences.get(candidates[0].deviceType, []) + if len(keywords) <= 0: + return candidates + + return [ + candidate + for candidate in candidates + if all(keyword in candidate.name.lower() for keyword in keywords) + ] + + +def enumerateCandidates(devicePayload: dict[str, object], requestedDeviceTypes: list[str]) -> dict[str, list[Candidate]]: + """ + Enumerates all compatible simulator candidates from the simctl payload + + Parameters + ---------- + devicePayload + The simctl device payload + requestedDeviceTypes + The normalized device types to consider + + Returns + ------- + dict[str, list[Candidate]] + The compatible candidates grouped by device type + """ + + requestedDeviceTypesSet = set(requestedDeviceTypes) + candidatesByType: dict[str, list[Candidate]] = { + deviceType: [] for deviceType in requestedDeviceTypes + } + + devicesByRuntime = devicePayload.get("devices", {}) + if not isinstance(devicesByRuntime, dict): + raise ValueError("Unexpected simctl JSON format: missing devices map") + + for runtimeIdentifier, entries in devicesByRuntime.items(): + parsedRuntime = parseRuntimeIdentifier(runtimeIdentifier) + if parsedRuntime is None: + continue + + runtimeFamily, runtimeVersion = parsedRuntime + if not isinstance(entries, list): + continue + + print(f"Runtime: {runtimeIdentifier} ({len(entries)} devices)", file=sys.stderr) + + for entry in entries: + if not isinstance(entry, dict): + continue + + name = str(entry.get("name") or "").strip() + udid = str(entry.get("udid") or "").strip() + isAvailable = bool(entry.get("isAvailable")) + + if not isAvailable or len(name) <= 0 or len(udid) <= 0: + continue + + deviceType = classifyDeviceType(name) + if deviceType is None: + continue + if deviceType not in requestedDeviceTypesSet: + continue + if deviceType not in RUNTIME_FAMILY_MAP.get(runtimeFamily, ()): + continue + + candidate = Candidate( + deviceType=deviceType, + name=name, + udid=udid, + runtimeIdentifier=runtimeIdentifier, + runtimeFamily=runtimeFamily, + osVersion=runtimeVersion, + ) + + candidatesByType[deviceType].append(candidate) + print( + f" {name} ({runtimeFamily} {versionToString(runtimeVersion)}) [{udid}]", + file=sys.stderr, + ) + + return candidatesByType + + +def filterCandidatesForRequestedOs(candidates: list[Candidate], requestedVersion: Optional[tuple[int, ...]], deviceType: str) -> list[Candidate]: + """ + Filters candidates to the requested OS version, or the latest available version when unspecified + + Parameters + ---------- + candidates + The candidate simulators to filter + requestedVersion + The requested OS version, or None for latest + deviceType + The device type being filtered + + Returns + ------- + list[Candidate] + The filtered candidates for the requested or latest OS version + """ + + matchingCandidates = [candidate for candidate in candidates if matchesRequestedVersion(candidate.osVersion, requestedVersion)] + + if requestedVersion is not None: + print( + f"{deviceType}: requested OS {versionToString(requestedVersion)}", + file=sys.stderr, + ) + return matchingCandidates + + if len(matchingCandidates) <= 0: + return matchingCandidates + + latestVersion = max(candidate.osVersion for candidate in matchingCandidates) + print(f"{deviceType}: using latest OS {versionToString(latestVersion)}", file=sys.stderr) + + return [ + candidate for candidate in matchingCandidates if candidate.osVersion == latestVersion + ] + + +def buildCandidatePool(deviceType: str, + candidates: list[Candidate], + requestedVersion: Optional[tuple[int, ...]], + selectionMode: str, + modelPreferences: dict[str, list[str]]) -> list[Candidate]: + """ + Builds the final candidate pool for the specified device type and selection mode + + Parameters + ---------- + deviceType + The device type being evaluated + candidates + The compatible candidates for this device type + requestedVersion + The requested OS version, or None for latest + selectionMode + The simulator selection mode + modelPreferences + The requested model preferences by device type + + Returns + ------- + list[Candidate] + The narrowed candidate pool for final selection + """ + + versionFilteredCandidates = filterCandidatesForRequestedOs( + candidates=candidates, + requestedVersion=requestedVersion, + deviceType=deviceType, + ) + + if selectionMode == "random-compatible": + return versionFilteredCandidates + + if selectionMode == "model-type": + versionFilteredCandidates = filterToModelPreferences( + versionFilteredCandidates, + modelPreferences, + ) + + if selectionMode in {"random-latest-compatible", "model-type", "latest-model"}: + return filterToLatestModel(versionFilteredCandidates) + + raise ValueError(f"Unsupported selection mode: {selectionMode}") + + +def selectDestinationFromPool(candidates: list[Candidate], + selectionMode: str) -> Optional[Candidate]: + """ + Selects a single destination from a prepared candidate pool + + Parameters + ---------- + candidates + The candidate pool to select from + selectionMode + The simulator selection mode + + Returns + ------- + Candidate | None + The selected candidate destination + """ + + if len(candidates) <= 0: + return None + + if selectionMode.startswith("random-"): + return random.choice(candidates) + + rankedCandidates = sorted(candidates, key=determineModelRank, reverse=True) + return rankedCandidates[0] + + +def determineRequestedVersions(scriptArgs: argparse.Namespace) -> dict[str, Optional[tuple[int, ...]]]: + """ + Determines the requested OS versions for all supported device types + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + dict[str, tuple[int, ...] | None] + The requested OS versions by device type + """ + + return { + "iphone": parseRequestedVersion(scriptArgs.iphoneosVersion), + "ipad": parseRequestedVersion(scriptArgs.ipadosVersion), + "macos": parseRequestedVersion(scriptArgs.macosVersion), + "watch": parseRequestedVersion(scriptArgs.watchosVersion), + "vision": parseRequestedVersion(scriptArgs.visionosVersion), + } + + +def validateScriptArguments(scriptArgs: argparse.Namespace) -> list[str]: + """ + Validates the parsed script arguments + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + list[str] + The normalized requested device types + """ + + requestedDeviceTypes = parseCommaSeparatedList(scriptArgs.deviceTypes) + if len(requestedDeviceTypes) <= 0: + raise ValueError("At least one device type must be provided") + + unsupportedDeviceTypes = [ + deviceType for deviceType in requestedDeviceTypes + if deviceType not in SUPPORTED_DEVICE_TYPES + ] + if unsupportedDeviceTypes: + raise ValueError( + f"Unsupported device type specified: {', '.join(unsupportedDeviceTypes)}" + ) + + return requestedDeviceTypes + + +def determineSelectedDestinations(requestedDeviceTypes: list[str], + candidatesByType: dict[str, list[Candidate]], + requestedVersions: dict[str, Optional[tuple[int, ...]]], + selectionMode: str, + modelPreferences: dict[str, list[str]]) -> list[Candidate]: + """ + Determines the final selected destinations across the requested device types + + Parameters + ---------- + requestedDeviceTypes + The ordered requested device types + candidatesByType + The compatible candidates grouped by device type + requestedVersions + The requested OS versions by device type + selectionMode + The simulator selection mode + modelPreferences + The requested model preferences by device type + + Returns + ------- + list[Candidate] + The selected destinations + """ + + selectedCandidates: list[Candidate] = [] + missingDeviceTypes: list[str] = [] + + for deviceType in requestedDeviceTypes: + compatibleCandidates = candidatesByType.get(deviceType, []) + if len(compatibleCandidates) <= 0: + print(f"{deviceType}: no compatible devices found", file=sys.stderr) + missingDeviceTypes.append(deviceType) + continue + + candidatePool = buildCandidatePool( + deviceType=deviceType, + candidates=compatibleCandidates, + requestedVersion=requestedVersions[deviceType], + selectionMode=selectionMode, + modelPreferences=modelPreferences, + ) + + if len(candidatePool) <= 0: + print(f"{deviceType}: no devices matched the requested filters", file=sys.stderr) + missingDeviceTypes.append(deviceType) + continue + + selectedCandidate = selectDestinationFromPool( + candidates=candidatePool, + selectionMode=selectionMode, + ) + if selectedCandidate is None: + missingDeviceTypes.append(deviceType) + continue + + selectedCandidates.append(selectedCandidate) + + if len(missingDeviceTypes) > 0: + missingDeviceTypesLabel = ", ".join(missingDeviceTypes) + raise SystemExit( + f"Unable to determine simulator destinations for the requested device types: " + f"{missingDeviceTypesLabel}" + ) + + return selectedCandidates + + +def writeGithubOutput(name: str, value: str): + """ + Writes a single GitHub Actions output value + + Parameters + ---------- + name + The output name + value + The output value + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}={value}", file=file) + + +def writeGithubMultilineOutput(name: str, values: list[str]): + """ + Writes a multiline GitHub Actions output value + + Parameters + ---------- + name + The output name + values + The list of values to write + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}<<{OUTPUT_MARKER}", file=file) + for value in values: + print(value, file=file) + print(OUTPUT_MARKER, file=file) + + +def publishOutputs(selectedCandidates: list[Candidate]): + """ + Publishes the selected simulator outputs for GitHub Actions + + Parameters + ---------- + selectedCandidates + The selected simulator candidates + """ + + if len(selectedCandidates) <= 0: + return + + destinationIds = [candidate.udid for candidate in selectedCandidates] + simulators = [ + { + "udid": candidate.udid, + "name": candidate.name, + "os": versionToString(candidate.osVersion), + "modelType": determineModelType(candidate.name), + "safe_name": createSafeName(candidate.name, versionToString(candidate.osVersion)), + } + for candidate in selectedCandidates + ] + + writeGithubOutput("simulator_jsons", json.dumps(simulators)) + writeGithubMultilineOutput("destination_ids", destinationIds) + + +def main(): + """Runs the simulator picker script""" + + parser = setupArgumentParser() + scriptArgs = parser.parse_args() + + printScriptStart() + + requestedDeviceTypes = validateScriptArguments(scriptArgs) + requestedVersions = determineRequestedVersions(scriptArgs) + modelPreferences = parseModelPreferences(scriptArgs.modelPreferences) + devicePayload = readDevicesJson(scriptArgs.devicesJsonFile) + candidatesByType = enumerateCandidates(devicePayload, requestedDeviceTypes) + + selectedCandidates = determineSelectedDestinations( + requestedDeviceTypes=requestedDeviceTypes, + candidatesByType=candidatesByType, + requestedVersions=requestedVersions, + selectionMode=scriptArgs.selectionMode, + modelPreferences=modelPreferences, + ) + + print("Selected Simulators:", file=sys.stderr) + for candidate in selectedCandidates: + print( + f" {candidate.name} ({candidate.runtimeFamily} {versionToString(candidate.osVersion)}) " + f"[{candidate.udid}]", + file=sys.stderr, + ) + + publishOutputs(selectedCandidates) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/xccov-warp-bubble/README.md b/.github/actions/xccov-warp-bubble/README.md new file mode 100644 index 00000000..03d48bd3 --- /dev/null +++ b/.github/actions/xccov-warp-bubble/README.md @@ -0,0 +1,54 @@ +# Xccov Warp Bubble + +Local composite action for generating a code coverage summary from downloaded `.xcresult` bundles. + +## Inputs + +- `xcresults_directory` + - Root directory containing downloaded artifact folders with `.xcresult` bundles + - Default: `CoverageResults/xcresults` +- `summary_file` + - Markdown file path where the coverage summary should be written + - Default: `CoverageResults/code-coverage-summary.md` +- `summary_json_file` + - JSON file path where the coverage summary should be written + - Default: `CoverageResults/code-coverage-summary.json` +- `failing_coverage_threshold` + - Coverage percent below which the status is marked with a red X + - Default: `60` +- `passing_coverage_threshold` + - Coverage percent at or above which the status is marked with a green checkmark + - Default: `75` + +If only one coverage scope is found, the action reports coverage for that scope only. If multiple scopes are found, the action also computes and reports combined coverage across all scopes. + +## Outputs + +- `coverage_percent` +- `summary_file` +- `summary_json_file` +- `scope_count` + +## Example + +```yaml +- name: Download unit test results + uses: actions/download-artifact@v8 + with: + pattern: project-unit-tests-* + path: CoverageResults/xcresults + +- name: Generate code coverage summary + id: coverage + uses: ./.github/actions/xccov-warp-bubble + with: + xcresults_directory: CoverageResults/xcresults + summary_file: CoverageResults/code-coverage-summary.md + summary_json_file: CoverageResults/code-coverage-summary.json + +- name: Publish coverage comment to pull request + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: combined-code-coverage + path: ${{ steps.coverage.outputs.summary_file }} +``` diff --git a/.github/actions/xccov-warp-bubble/action.yml b/.github/actions/xccov-warp-bubble/action.yml new file mode 100644 index 00000000..b5490d4e --- /dev/null +++ b/.github/actions/xccov-warp-bubble/action.yml @@ -0,0 +1,61 @@ +name: Xccov Warp Bubble +description: Generate a code coverage summary from downloaded xcresult bundles. + +inputs: + xcresults_directory: + description: Root directory containing downloaded artifact folders with xcresult bundles. + required: false + default: CoverageResults/xcresults + summary_file: + description: Markdown file path where the coverage summary should be written. + required: false + default: CoverageResults/code-coverage-summary.md + summary_json_file: + description: JSON file path where the coverage summary should be written. + required: false + default: CoverageResults/code-coverage-summary.json + failing_coverage_threshold: + description: Coverage percent below which the status is marked as failing. + required: false + default: "60" + passing_coverage_threshold: + description: Coverage percent at or above which the status is marked as passing. + required: false + default: "75" + +outputs: + coverage_percent: + description: The overall coverage percent. For one scope this is that scope coverage; for multiple scopes this is the combined coverage. + value: ${{ steps.summary.outputs.coverage_percent }} + summary_file: + description: Markdown summary file path. + value: ${{ steps.summary.outputs.summary_file }} + summary_json_file: + description: JSON summary file path. + value: ${{ steps.summary.outputs.summary_json_file }} + scope_count: + description: The number of coverage scopes that were summarized. + value: ${{ steps.summary.outputs.scope_count }} + +runs: + using: composite + steps: + - name: Generate code coverage summary + id: summary + shell: bash + env: + INPUT_XCRESULTS_DIRECTORY: ${{ inputs.xcresults_directory }} + INPUT_SUMMARY_FILE: ${{ inputs.summary_file }} + INPUT_SUMMARY_JSON_FILE: ${{ inputs.summary_json_file }} + INPUT_FAILING_COVERAGE_THRESHOLD: ${{ inputs.failing_coverage_threshold }} + INPUT_PASSING_COVERAGE_THRESHOLD: ${{ inputs.passing_coverage_threshold }} + run: | + set -eo pipefail + python3 "${{ github.action_path }}/generate_coverage_summary.py" \ + --xcresults-directory "$INPUT_XCRESULTS_DIRECTORY" \ + --summary-file "$INPUT_SUMMARY_FILE" \ + --summary-json-file "$INPUT_SUMMARY_JSON_FILE" \ + --failing-coverage-threshold "$INPUT_FAILING_COVERAGE_THRESHOLD" \ + --passing-coverage-threshold "$INPUT_PASSING_COVERAGE_THRESHOLD" + + cat "$INPUT_SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/xccov-warp-bubble/generate_coverage_summary.py b/.github/actions/xccov-warp-bubble/generate_coverage_summary.py new file mode 100644 index 00000000..5370b7e4 --- /dev/null +++ b/.github/actions/xccov-warp-bubble/generate_coverage_summary.py @@ -0,0 +1,817 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# generate_coverage_summary.py +# xccov-warp-bubble +# +# Created by Kodex on 4/17/26. +# +# This script reads downloaded xcresult bundles, calculates per-scope coverage, +# optionally calculates combined coverage across multiple scopes, prints the +# results to the GitHub Actions log, and writes markdown and JSON summary files. + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass + + +SCRIPT_VERSION: str = "0.3.1" +"""The current version of the script""" + + +XCRESULT_SUFFIX: str = ".xcresult" +"""The filesystem suffix used for Xcode result bundles""" + + +DEFAULT_SCOPE_PREFIXES_TO_TRIM: tuple[str, ...] = ("project-unit-tests-",) +"""Common artifact name prefixes that should be trimmed from scope labels""" + + +@dataclass(frozen=True) +class CoverageThresholds: + """Represents the configured coverage thresholds""" + + failing: float + """Coverage percent below which the status is considered failing""" + + passing: float + """Coverage percent at or above which the status is considered passing""" + + +@dataclass(frozen=True) +class ScopeCoverage: + """Represents coverage summary details for a single scope""" + + name: str + """The display name of the scope""" + + coveredLines: int + """The number of covered executable lines""" + + executableLines: int + """The total number of executable lines""" + + coveragePercent: float + """The coverage percent for this scope""" + + +def setupArgumentParser() -> argparse.ArgumentParser: + """ + Sets up the argument parser + + Returns + ------- + ArgumentParser + The created argument parser for this script + """ + + parser: argparse.ArgumentParser = argparse.ArgumentParser(description=""" + This script generates a code coverage summary from downloaded + xcresult bundles.""") + + parser.add_argument("--version", "-v", action="version", + version="%(prog)s " + SCRIPT_VERSION) + parser.add_argument("-?", action="help", + help="show this help message and exit") + parser.add_argument("--xcresults-directory", metavar="CoverageResults/xcresults", + help="The root directory containing downloaded xcresult artifacts", + dest='xcresultsDirectory', required=True, + type=parseNonEmptyArgument) + parser.add_argument("--summary-file", metavar="CoverageResults/code-coverage-summary.md", + help="The markdown file path where the coverage summary should be written", + dest='summaryFile', required=True, + type=parseNonEmptyArgument) + parser.add_argument("--summary-json-file", metavar="CoverageResults/code-coverage-summary.json", + help="The JSON file path where the coverage summary should be written", + dest='summaryJsonFile', required=True, + type=parseNonEmptyArgument) + parser.add_argument("--failing-coverage-threshold", metavar="60", + help="Coverage percent below which the status is marked as failing", + dest='failingCoverageThreshold', required=True, + type=parseCoverageThresholdArgument) + parser.add_argument("--passing-coverage-threshold", metavar="75", + help="Coverage percent at or above which the status is marked as passing", + dest='passingCoverageThreshold', required=True, + type=parseCoverageThresholdArgument) + + return parser + + +def printScriptStart(): + """Prints the info for the start of the script""" + + print(f"Starting {os.path.basename(__file__)} v{SCRIPT_VERSION}", file=sys.stderr) + + +def parseNonEmptyArgument(value: str) -> str: + """ + Parses and validates a non-empty string argument + + Parameters + ---------- + value + The raw argument value + + Returns + ------- + str + The normalized non-empty argument value + """ + + normalizedValue = value.strip() + if len(normalizedValue) <= 0: + raise ValueError("Argument value must not be empty") + + return normalizedValue + + +def parseThreshold(value: str, label: str) -> float: + """ + Parses a coverage threshold value + + Parameters + ---------- + value + The raw threshold string + label + The threshold label for error reporting + + Returns + ------- + float + The parsed threshold value + """ + + try: + threshold = float(value.strip()) + except ValueError as error: + raise ValueError(f"Unsupported {label} value specified: {value}") from error + + if threshold < 0.0 or threshold > 100.0: + raise ValueError(f"{label} must be between 0 and 100: {value}") + + return threshold + + +def parseCoverageThresholdArgument(value: str) -> float: + """ + Parses and validates a coverage threshold argument + + Parameters + ---------- + value + The raw threshold argument value + + Returns + ------- + float + The parsed threshold value + """ + + return parseThreshold(value, "coverage threshold") + + +def parseThresholds(scriptArgs: argparse.Namespace) -> CoverageThresholds: + """ + Parses and validates the configured coverage thresholds + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + CoverageThresholds + The parsed coverage thresholds + """ + + thresholds = CoverageThresholds( + failing=float(scriptArgs.failingCoverageThreshold), + passing=float(scriptArgs.passingCoverageThreshold), + ) + if thresholds.failing >= thresholds.passing: + raise ValueError( + "The failing coverage threshold must be less than the passing coverage threshold" + ) + + return thresholds + + +def validateScriptArguments(scriptArgs: argparse.Namespace) -> CoverageThresholds: + """ + Validates the parsed script arguments + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + CoverageThresholds + The parsed coverage thresholds + """ + + return parseThresholds(scriptArgs) + + +def normalizeScopeName(scopeName: str) -> str: + """ + Normalizes a downloaded artifact directory name into a scope label + + Parameters + ---------- + scopeName + The raw artifact directory name + + Returns + ------- + str + The normalized scope label + """ + + normalizedScopeName = scopeName.strip() + + for prefix in DEFAULT_SCOPE_PREFIXES_TO_TRIM: + if normalizedScopeName.startswith(prefix): + normalizedScopeName = normalizedScopeName[len(prefix):] + break + + if normalizedScopeName.endswith(XCRESULT_SUFFIX): + normalizedScopeName = normalizedScopeName[:-len(XCRESULT_SUFFIX)] + + return normalizedScopeName or scopeName + + +def findResultBundles(searchRoot: str) -> list[str]: + """ + Finds all xcresult bundles under the specified directory + + Parameters + ---------- + searchRoot + The directory to search for xcresult bundles + + Returns + ------- + list[str] + The discovered xcresult bundle paths + """ + + resultBundles: list[str] = [] + if not os.path.isdir(searchRoot): + return resultBundles + + for root, dirnames, _filenames in os.walk(searchRoot): + remainingDirnames: list[str] = [] + for dirname in dirnames: + fullPath = os.path.join(root, dirname) + if dirname.endswith(XCRESULT_SUFFIX): + resultBundles.append(fullPath) + else: + remainingDirnames.append(dirname) + dirnames[:] = remainingDirnames + + return sorted(resultBundles) + + +def discoverCoverageScopes(searchRoot: str) -> dict[str, list[str]]: + """ + Discovers the downloaded coverage scopes and their xcresult bundles + + Parameters + ---------- + searchRoot + The root directory containing downloaded coverage artifacts + + Returns + ------- + dict[str, list[str]] + The discovered coverage scopes and their xcresult bundle paths + """ + + if not os.path.isdir(searchRoot): + return {} + + scopeBundles: dict[str, list[str]] = {} + for entryName in sorted(os.listdir(searchRoot)): + entryPath = os.path.join(searchRoot, entryName) + if not os.path.isdir(entryPath): + continue + + resultBundles = findResultBundles(entryPath) + if len(resultBundles) <= 0: + print(f"{entryName}: no downloaded .xcresult bundles found", file=sys.stderr) + continue + + scopeBundles[normalizeScopeName(entryName)] = resultBundles + + if len(scopeBundles) > 0: + return scopeBundles + + rootResultBundles = findResultBundles(searchRoot) + if len(rootResultBundles) <= 0: + return {} + + fallbackScopeName = os.path.basename(os.path.normpath(searchRoot)) or "Coverage" + return {normalizeScopeName(fallbackScopeName): rootResultBundles} + + +def mergeCoverageReport(target: dict[str, dict[int, bool]], resultBundlePath: str): + """ + Merges an xcresult coverage report into an aggregated line coverage map + + Parameters + ---------- + target + The target aggregated line coverage map + resultBundlePath + The xcresult bundle path to process + """ + + report = json.loads( + subprocess.check_output( + ["xcrun", "xccov", "view", "--archive", "--json", resultBundlePath], + text=True, + ) + ) + + for filePath, entries in report.items(): + if not isinstance(entries, list): + continue + + combinedLines = target.setdefault(filePath, {}) + for entry in entries: + if not isinstance(entry, dict): + continue + + lineNumber = entry.get("line") + if lineNumber is None or not entry.get("isExecutable"): + continue + + isCovered = int(entry.get("executionCount", 0) or 0) > 0 + combinedLines[int(lineNumber)] = combinedLines.get(int(lineNumber), False) or isCovered + + +def summarizeLineCoverage(lineCoverageMap: dict[str, dict[int, bool]]) -> ScopeCoverage: + """ + Summarizes an aggregated line coverage map + + Parameters + ---------- + lineCoverageMap + The aggregated line coverage map + + Returns + ------- + ScopeCoverage + The summarized coverage details + """ + + executableLines = sum(len(lines) for lines in lineCoverageMap.values()) + coveredLines = sum( + 1 for lines in lineCoverageMap.values() for isCovered in lines.values() if isCovered + ) + coveragePercent = ( + coveredLines / executableLines * 100.0 + if executableLines > 0 + else 0.0 + ) + + return ScopeCoverage( + name="", + coveredLines=coveredLines, + executableLines=executableLines, + coveragePercent=coveragePercent, + ) + + +def withName(scopeCoverage: ScopeCoverage, name: str) -> ScopeCoverage: + """ + Applies a display name to a scope coverage summary + + Parameters + ---------- + scopeCoverage + The coverage summary to rename + name + The scope name to apply + + Returns + ------- + ScopeCoverage + The renamed coverage summary + """ + + return ScopeCoverage( + name=name, + coveredLines=scopeCoverage.coveredLines, + executableLines=scopeCoverage.executableLines, + coveragePercent=scopeCoverage.coveragePercent, + ) + + +def calculateScopeCoverages(discoveredScopes: dict[str, list[str]]) -> tuple[list[ScopeCoverage], ScopeCoverage | None]: + """ + Calculates coverage summaries for each discovered scope + + Parameters + ---------- + discoveredScopes + The discovered scopes and their xcresult bundle paths + + Returns + ------- + tuple[list[ScopeCoverage], ScopeCoverage | None] + The per-scope summaries and optional combined summary + """ + + combinedCoverageMap: dict[str, dict[int, bool]] = {} + scopeCoverages: list[ScopeCoverage] = [] + + for scopeName, resultBundles in sorted(discoveredScopes.items()): + scopeCoverageMap: dict[str, dict[int, bool]] = {} + for resultBundle in resultBundles: + print(f"Processing result bundle for {scopeName}: {resultBundle}") + mergeCoverageReport(scopeCoverageMap, resultBundle) + mergeCoverageReport(combinedCoverageMap, resultBundle) + + scopeCoverages.append(withName(summarizeLineCoverage(scopeCoverageMap), scopeName)) + + if len(scopeCoverages) <= 1: + return (scopeCoverages, None) + + return (scopeCoverages, withName(summarizeLineCoverage(combinedCoverageMap), "Combined")) + + +def determineCoverageStatus(coveragePercent: float, thresholds: CoverageThresholds) -> str: + """ + Determines the coverage status label for a coverage percent + + Parameters + ---------- + coveragePercent + The coverage percent to evaluate + thresholds + The configured coverage thresholds + + Returns + ------- + str + The coverage status label + """ + + if coveragePercent < thresholds.failing: + return "fail" + if coveragePercent < thresholds.passing: + return "warn" + + return "pass" + + +def determineStatusEmoji(status: str) -> str: + """ + Determines the emoji for a coverage status label + + Parameters + ---------- + status + The coverage status label + + Returns + ------- + str + The corresponding emoji + """ + + return {"fail": "❌", "warn": "⚠️", "pass": "✅"}[status] + + +def serializeScope(scopeCoverage: ScopeCoverage, thresholds: CoverageThresholds) -> dict[str, object]: + """ + Serializes a scope coverage summary for JSON output + + Parameters + ---------- + scopeCoverage + The scope coverage summary + thresholds + The configured coverage thresholds + + Returns + ------- + dict[str, object] + The serialized scope coverage payload + """ + + status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) + return { + "name": scopeCoverage.name, + "covered_lines": scopeCoverage.coveredLines, + "executable_lines": scopeCoverage.executableLines, + "coverage_percent": round(scopeCoverage.coveragePercent, 2), + "status": status, + "status_emoji": determineStatusEmoji(status), + } + + +def printCoverageSummary(scopeCoverages: list[ScopeCoverage], + overallCoverage: ScopeCoverage | None, + thresholds: CoverageThresholds): + """ + Prints a concise coverage summary to the GitHub Actions log + + Parameters + ---------- + scopeCoverages + The per-scope coverage summaries + overallCoverage + The optional combined coverage summary + thresholds + The configured coverage thresholds + """ + + for scopeCoverage in scopeCoverages: + status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) + print(f"{scopeCoverage.name} - {scopeCoverage.coveragePercent:.2f}% {determineStatusEmoji(status)}") + + if overallCoverage is not None: + status = determineCoverageStatus(overallCoverage.coveragePercent, thresholds) + print(f"Combined - {overallCoverage.coveragePercent:.2f}% {determineStatusEmoji(status)}") + + +def renderMarkdownSummary(scopeCoverages: list[ScopeCoverage], + overallCoverage: ScopeCoverage | None, + thresholds: CoverageThresholds) -> str: + """ + Renders the markdown coverage summary + + Parameters + ---------- + scopeCoverages + The per-scope coverage summaries + overallCoverage + The optional combined coverage summary + thresholds + The configured coverage thresholds + + Returns + ------- + str + The markdown summary contents + """ + + lines = [ + "### Code Coverage", + "", + "| Scope | Coverage | Status |", + "| --- | :---: | :---: |", + ] + + for scopeCoverage in scopeCoverages: + status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) + lines.append(f"| {scopeCoverage.name} | {scopeCoverage.coveragePercent:.2f}% | {determineStatusEmoji(status)} |") + + if overallCoverage is not None: + status = determineCoverageStatus(overallCoverage.coveragePercent, thresholds) + indent = "          " + lines.append(f"| {indent} **Combined** | **{overallCoverage.coveragePercent:.2f}%** | **{determineStatusEmoji(status)}** |") + + return "\n".join(lines) + "\n" + + +def renderJsonSummary(scopeCoverages: list[ScopeCoverage], + overallCoverage: ScopeCoverage | None, + thresholds: CoverageThresholds) -> dict[str, object]: + """ + Renders the JSON coverage summary payload + + Parameters + ---------- + scopeCoverages + The per-scope coverage summaries + overallCoverage + The optional combined coverage summary + thresholds + The configured coverage thresholds + + Returns + ------- + dict[str, object] + The JSON coverage summary payload + """ + + payload: dict[str, object] = { + "scope_count": len(scopeCoverages), + "thresholds": { + "failing_coverage_threshold": thresholds.failing, + "passing_coverage_threshold": thresholds.passing, + }, + "scopes": [serializeScope(scopeCoverage, thresholds) for scopeCoverage in scopeCoverages], + "overall_coverage_percent": "", + } + + if overallCoverage is not None: + payload["combined"] = serializeScope(overallCoverage, thresholds) + payload["overall_coverage_percent"] = round(overallCoverage.coveragePercent, 2) + elif len(scopeCoverages) == 1: + payload["overall_coverage_percent"] = round(scopeCoverages[0].coveragePercent, 2) + + return payload + + +def ensureParentDirectory(filePath: str): + """ + Ensures the parent directory for a file path exists + + Parameters + ---------- + filePath + The file path whose parent directory should be created + """ + + parentDirectory = os.path.dirname(filePath) + if len(parentDirectory) > 0: + os.makedirs(parentDirectory, exist_ok=True) + + +def writeTextFile(filePath: str, contents: str): + """ + Writes text contents to a file path + + Parameters + ---------- + filePath + The file path to write + contents + The text contents to write + """ + + ensureParentDirectory(filePath) + with open(filePath, "w", encoding="utf-8") as file: + file.write(contents) + + +def writeJsonFile(filePath: str, payload: dict[str, object]): + """ + Writes a JSON payload to a file path + + Parameters + ---------- + filePath + The file path to write + payload + The JSON payload to write + """ + + ensureParentDirectory(filePath) + with open(filePath, "w", encoding="utf-8") as file: + json.dump(payload, file, indent=2, sort_keys=True) + file.write("\n") + + +def writeGithubOutput(name: str, value: str): + """ + Writes a single GitHub Actions output value + + Parameters + ---------- + name + The output name + value + The output value + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}={value}", file=file) + + +def publishOutputs(summaryFile: str, summaryJsonFile: str, coveragePercent: str, scopeCount: int): + """ + Publishes the generated coverage summary outputs for GitHub Actions + + Parameters + ---------- + summaryFile + The markdown summary file path + summaryJsonFile + The JSON summary file path + coveragePercent + The overall coverage percent string + scopeCount + The number of summarized scopes + """ + + writeGithubOutput("summary_file", summaryFile) + writeGithubOutput("summary_json_file", summaryJsonFile) + writeGithubOutput("coverage_percent", coveragePercent) + writeGithubOutput("scope_count", str(scopeCount)) + + +def writeUnavailableSummaries(summaryFile: str, + summaryJsonFile: str, + message: str, + thresholds: CoverageThresholds): + """ + Writes markdown and JSON summary files for an unavailable coverage result + + Parameters + ---------- + summaryFile + The markdown summary file path + summaryJsonFile + The JSON summary file path + message + The message to write + thresholds + The configured coverage thresholds + """ + + writeTextFile(summaryFile, f"### Code Coverage\n\n{message}\n") + writeJsonFile( + summaryJsonFile, + { + "message": message, + "overall_coverage_percent": "", + "scope_count": 0, + "scopes": [], + "thresholds": { + "failing_coverage_threshold": thresholds.failing, + "passing_coverage_threshold": thresholds.passing, + }, + }, + ) + + +def determineOverallCoveragePercent(scopeCoverages: list[ScopeCoverage], overallCoverage: ScopeCoverage | None) -> str: + """ + Determines the overall coverage percent string for action outputs + + Parameters + ---------- + scopeCoverages + The per-scope coverage summaries + overallCoverage + The optional combined coverage summary + + Returns + ------- + str + The overall coverage percent string + """ + + if overallCoverage is not None: + return f"{overallCoverage.coveragePercent:.2f}" + if len(scopeCoverages) == 1: + return f"{scopeCoverages[0].coveragePercent:.2f}" + + return "" + + +def main(): + """Runs the coverage summary generation script""" + + parser = setupArgumentParser() + scriptArgs = parser.parse_args() + + printScriptStart() + + thresholds = validateScriptArguments(scriptArgs) + discoveredScopes = discoverCoverageScopes(scriptArgs.xcresultsDirectory) + + if len(discoveredScopes) <= 0: + message = "Code coverage unavailable because no unit test result bundles were downloaded." + writeUnavailableSummaries(scriptArgs.summaryFile, scriptArgs.summaryJsonFile, message, thresholds) + publishOutputs(scriptArgs.summaryFile, scriptArgs.summaryJsonFile, "", 0) + return + + scopeCoverages, overallCoverage = calculateScopeCoverages(discoveredScopes) + printCoverageSummary(scopeCoverages, overallCoverage, thresholds) + + writeTextFile( + scriptArgs.summaryFile, + renderMarkdownSummary(scopeCoverages, overallCoverage, thresholds), + ) + writeJsonFile( + scriptArgs.summaryJsonFile, + renderJsonSummary(scopeCoverages, overallCoverage, thresholds), + ) + + publishOutputs( + scriptArgs.summaryFile, + scriptArgs.summaryJsonFile, + determineOverallCoveragePercent(scopeCoverages, overallCoverage), + len(scopeCoverages), + ) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/xcode-tricorder-tester/README.md b/.github/actions/xcode-tricorder-tester/README.md new file mode 100644 index 00000000..6734ab35 --- /dev/null +++ b/.github/actions/xcode-tricorder-tester/README.md @@ -0,0 +1,58 @@ +# Xcode Test The Tricorders + +Local composite action for running `xcodebuild test` across simulator destinations selected by `simctl-tricorder-selector`. + +## Inputs + +- `scheme` + - Xcode scheme to test +- `xcode_container` + - Path to the Xcode project or workspace + - The action infers the type from the file extension + - Supported values end in `.xcodeproj` or `.xcworkspace` +- `destination_ids` + - Newline-separated simulator UDIDs from `simctl-tricorder-selector` +- `simulator_jsons` + - JSON array from `simctl-tricorder-selector` +- `result_bundle_directory` + - Directory where `.xcresult` bundles should be created + - Default: `TestResults` +- `destination_arch` + - Architecture used in each `xcodebuild -destination` + - Default: `arm64` +- `xcodebuild_extra_args` + - Optional extra `xcodebuild` arguments + +This action always runs with `-enableCodeCoverage YES` and `CODE_SIGNING_ALLOWED=NO`. + +## Outputs + +- `result_bundle_directory` +- `result_bundle_paths` + +## Example + +```yaml +- name: Pick simulator + id: simulator + uses: ./.github/actions/simctl-tricorder-selector + with: + device_types: iphone + iphoneos_version: latest + selection_mode: random-compatible + +- name: Run unit tests + id: tests + uses: ./.github/actions/xcode-tricorder-tester + with: + scheme: libPhoneNumber + xcode_container: libPhoneNumber.xcodeproj + destination_ids: ${{ steps.simulator.outputs.destination_ids }} + simulator_jsons: ${{ steps.simulator.outputs.simulator_jsons }} + +- name: Upload unit test results + uses: actions/upload-artifact@v7 + with: + name: project-unit-tests-libPhoneNumber + path: ${{ steps.tests.outputs.result_bundle_directory }} +``` diff --git a/.github/actions/xcode-tricorder-tester/action.yml b/.github/actions/xcode-tricorder-tester/action.yml new file mode 100644 index 00000000..0e30e08c --- /dev/null +++ b/.github/actions/xcode-tricorder-tester/action.yml @@ -0,0 +1,61 @@ +name: Xcode Tricorder Tester +description: Run xcodebuild tests against simulator destinations selected by simctl-tricorder-selector. + +inputs: + scheme: + description: Xcode scheme to test. + required: true + xcode_container: + description: Path to the Xcode project or workspace. + required: true + destination_ids: + description: Newline-separated list of simulator destination UDIDs. + required: true + simulator_jsons: + description: JSON array of simulator objects returned by simctl-tricorder-selector. + required: true + result_bundle_directory: + description: Directory where generated xcresult bundles should be written. + required: false + default: TestResults + destination_arch: + description: Destination architecture to use with xcodebuild. + required: false + default: arm64 + xcodebuild_extra_args: + description: Optional extra xcodebuild arguments. + required: false + default: "" + +outputs: + result_bundle_directory: + description: Directory containing the generated xcresult bundles. + value: ${{ steps.run.outputs.result_bundle_directory }} + result_bundle_paths: + description: Newline-separated list of generated xcresult bundle paths. + value: ${{ steps.run.outputs.result_bundle_paths }} + +runs: + using: composite + steps: + - name: Run xcodebuild tests + id: run + shell: bash + env: + INPUT_SCHEME: ${{ inputs.scheme }} + INPUT_XCODE_CONTAINER: ${{ inputs.xcode_container }} + INPUT_DESTINATION_IDS: ${{ inputs.destination_ids }} + INPUT_SIMULATOR_JSONS: ${{ inputs.simulator_jsons }} + INPUT_RESULT_BUNDLE_DIRECTORY: ${{ inputs.result_bundle_directory }} + INPUT_DESTINATION_ARCH: ${{ inputs.destination_arch }} + INPUT_XCODEBUILD_EXTRA_ARGS: ${{ inputs.xcodebuild_extra_args }} + run: | + set -eo pipefail + python3 "${{ github.action_path }}/run_xcode_tests.py" \ + --scheme "$INPUT_SCHEME" \ + --xcode-container "$INPUT_XCODE_CONTAINER" \ + --destination-ids "$INPUT_DESTINATION_IDS" \ + --simulator-jsons "$INPUT_SIMULATOR_JSONS" \ + --result-bundle-directory "$INPUT_RESULT_BUNDLE_DIRECTORY" \ + --destination-arch "$INPUT_DESTINATION_ARCH" \ + --xcodebuild-extra-args "$INPUT_XCODEBUILD_EXTRA_ARGS" diff --git a/.github/actions/xcode-tricorder-tester/run_xcode_tests.py b/.github/actions/xcode-tricorder-tester/run_xcode_tests.py new file mode 100644 index 00000000..4895bc8f --- /dev/null +++ b/.github/actions/xcode-tricorder-tester/run_xcode_tests.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# run_xcode_tests.py +# xcode-tricorder-tester +# +# Created by Kodex on 4/17/26. +# +# This script runs xcodebuild tests against one or more simulator destinations +# selected upstream and publishes the generated xcresult bundle paths for +# downstream GitHub Actions steps. + +import argparse +import json +import os +import shlex +import subprocess +import sys + + +SCRIPT_VERSION: str = "0.2.2" +"""The current version of the script""" + + +SUPPORTED_XCODE_CONTAINERS: dict[str, str] = { + ".xcodeproj": "project", + ".xcworkspace": "workspace", +} +"""Supported Xcode container extensions mapped to xcodebuild argument types""" + + +OUTPUT_MARKER: str = "__XCODE_TEST_THE_TRICORDERS__" +"""The multiline GitHub Actions output marker""" + + +def setupArgumentParser() -> argparse.ArgumentParser: + """ + Sets up the argument parser + + Returns + ------- + ArgumentParser + The created argument parser for this script + """ + + parser: argparse.ArgumentParser = argparse.ArgumentParser(description=""" + This script runs xcodebuild tests against simulator destinations + selected by an upstream simulator-selection step.""") + + parser.add_argument("--version", "-v", action="version", + version="%(prog)s " + SCRIPT_VERSION) + parser.add_argument("-?", action="help", + help="show this help message and exit") + parser.add_argument("--scheme", metavar="SchemeName", required=True, + help="The Xcode scheme to run tests for", + dest='scheme', type=parseNonEmptyArgument) + parser.add_argument("--xcode-container", metavar="Project.xcodeproj", required=True, + help="The path to the Xcode project or workspace", + dest='xcodeContainer', type=parseNonEmptyArgument) + parser.add_argument("--destination-ids", metavar="DESTINATION_IDS", required=True, + help="Newline-separated simulator destination UDIDs", + dest='destinationIds', type=parseNonEmptyArgument) + parser.add_argument("--simulator-jsons", metavar="SIMULATOR_JSONS", required=True, + help="JSON array of simulator objects from the picker action", + dest='simulatorJsons', type=parseNonEmptyArgument) + parser.add_argument("--result-bundle-directory", metavar="TestResults", + help="The directory where xcresult bundles should be written", + dest='resultBundleDirectory', default="TestResults", + type=parseNonEmptyArgument) + parser.add_argument("--destination-arch", metavar="arm64", required=True, + help="The destination architecture to use with xcodebuild", + dest='destinationArch', type=parseNonEmptyArgument) + parser.add_argument("--xcodebuild-extra-args", metavar="--test-iterations 2", + help="Optional extra xcodebuild arguments", + dest='xcodebuildExtraArgs', default="") + + return parser + + +def printScriptStart(): + """Prints the info for the start of the script""" + + print(f"Starting {os.path.basename(__file__)} v{SCRIPT_VERSION}", file=sys.stderr) + + +def parseNonEmptyArgument(value: str) -> str: + """ + Parses and validates a non-empty string argument + + Parameters + ---------- + value + The raw argument value + + Returns + ------- + str + The normalized non-empty argument value + """ + + normalizedValue = value.strip() + if len(normalizedValue) <= 0: + raise ValueError("Argument value must not be empty") + + return normalizedValue + + +def determineXcodeContainerType(xcodeContainer: str) -> str: + """ + Determines the Xcode container type from the specified path + + Parameters + ---------- + xcodeContainer + The path to the Xcode project or workspace + + Returns + ------- + str + The Xcode container type to pass to xcodebuild + """ + + _root, extension = os.path.splitext(xcodeContainer.strip()) + containerType = SUPPORTED_XCODE_CONTAINERS.get(extension.lower()) + if containerType is None: + raise ValueError( + f"Unsupported Xcode container specified: {xcodeContainer}. " + f"Expected a path ending in {', '.join(SUPPORTED_XCODE_CONTAINERS.keys())}" + ) + + return containerType + + +def parseDestinationIds(value: str) -> list[str]: + """ + Parses the destination ID input into a list of UDIDs + + Parameters + ---------- + value + The raw newline-separated destination ID string + + Returns + ------- + list[str] + The parsed destination IDs + """ + + return [part.strip() for part in value.splitlines() if len(part.strip()) > 0] + + +def parseSimulatorJsons(value: str) -> list[dict[str, str]]: + """ + Parses the simulator JSON payload + + Parameters + ---------- + value + The raw simulator JSON string + + Returns + ------- + list[dict[str, str]] + The parsed simulator objects + """ + + simulators = json.loads(value) + if not isinstance(simulators, list): + raise ValueError("Simulator JSON payload must be a list") + + normalizedSimulators: list[dict[str, str]] = [] + for simulator in simulators: + if not isinstance(simulator, dict): + raise ValueError("Simulator JSON payload entries must be objects") + + safeName = str(simulator.get("safe_name") or "").strip() + if len(safeName) <= 0: + raise ValueError("Simulator output is missing a safe_name value") + + normalizedSimulators.append({ + "name": str(simulator.get("name") or "").strip(), + "os": str(simulator.get("os") or "").strip(), + "safe_name": safeName, + }) + + return normalizedSimulators + + +def validateScriptArguments(scriptArgs: argparse.Namespace) -> tuple[list[str], list[dict[str, str]]]: + """ + Validates the parsed script arguments + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + tuple[list[str], list[dict[str, str]]] + The parsed destination IDs and simulator objects + """ + + determineXcodeContainerType(scriptArgs.xcodeContainer) + + destinationIds = parseDestinationIds(scriptArgs.destinationIds) + simulators = parseSimulatorJsons(scriptArgs.simulatorJsons) + if len(destinationIds) != len(simulators): + raise ValueError("Destination ID and simulator output counts do not match") + + return (destinationIds, simulators) + + +def writeGithubOutput(name: str, value: str): + """ + Writes a single GitHub Actions output value + + Parameters + ---------- + name + The output name + value + The output value + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}={value}", file=file) + + +def writeGithubMultilineOutput(name: str, values: list[str]): + """ + Writes a multiline GitHub Actions output value + + Parameters + ---------- + name + The output name + values + The list of values to write + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}<<{OUTPUT_MARKER}", file=file) + for value in values: + print(value, file=file) + print(OUTPUT_MARKER, file=file) + + +def runTests(scriptArgs: argparse.Namespace, + destinationIds: list[str], + simulators: list[dict[str, str]]) -> tuple[str, list[str]]: + """ + Runs xcodebuild tests for all selected simulator destinations + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + tuple[str, list[str]] + The result bundle directory and generated xcresult bundle paths + """ + + xcodeContainerType = determineXcodeContainerType(scriptArgs.xcodeContainer) + extraArgs = shlex.split(scriptArgs.xcodebuildExtraArgs) + + os.makedirs(scriptArgs.resultBundleDirectory, exist_ok=True) + + resultBundlePaths: list[str] = [] + for destinationId, simulator in zip(destinationIds, simulators): + resultBundlePath = os.path.join( + scriptArgs.resultBundleDirectory, + f"{scriptArgs.scheme}-{simulator['safe_name']}.xcresult", + ) + destination = f"id={destinationId},arch={scriptArgs.destinationArch}" + + print( + f"Running {scriptArgs.scheme} on {simulator['name']} ({simulator['os']}) -> {resultBundlePath}", + file=sys.stderr, + ) + + subprocess.check_call( + [ + "xcodebuild", + f"-{xcodeContainerType}", + scriptArgs.xcodeContainer, + "-scheme", + scriptArgs.scheme, + "-destination", + destination, + "-resultBundlePath", + resultBundlePath, + "-enableCodeCoverage", + "YES", + "CODE_SIGNING_ALLOWED=NO", + *extraArgs, + "test", + ] + ) + resultBundlePaths.append(resultBundlePath) + + return (scriptArgs.resultBundleDirectory, resultBundlePaths) + + +def main(): + """Runs the Xcode test execution script""" + + parser = setupArgumentParser() + scriptArgs = parser.parse_args() + + printScriptStart() + + destinationIds, simulators = validateScriptArguments(scriptArgs) + resultBundleDirectory, resultBundlePaths = runTests( + scriptArgs=scriptArgs, + destinationIds=destinationIds, + simulators=simulators, + ) + writeGithubOutput("result_bundle_directory", resultBundleDirectory) + writeGithubMultilineOutput("result_bundle_paths", resultBundlePaths) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml new file mode 100644 index 00000000..27062d36 --- /dev/null +++ b/.github/workflows/ci-core.yml @@ -0,0 +1,74 @@ +name: CI Core + +on: + workflow_call: + outputs: + combined_coverage_percent: + description: Combined unit test coverage percent across all schemes. + value: ${{ jobs.coverage-summary.outputs.combined_coverage_percent }} + +jobs: + unit-tests: + name: Unit Tests (${{ matrix.scheme }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + scheme: + - libPhoneNumber + - libPhoneNumberGeocoding + - libPhoneNumberShortNumber + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Resolve iPhone simulator destination + id: destination + uses: ./.github/actions/simctl-tricorder-selector + with: + device_types: iphone + iphoneos_version: latest + selection_mode: random-compatible + + - name: Run unit tests + id: run-tests + uses: ./.github/actions/xcode-tricorder-tester + with: + scheme: ${{ matrix.scheme }} + xcode_container: libPhoneNumber.xcodeproj + destination_ids: ${{ steps.destination.outputs.destination_ids }} + simulator_jsons: ${{ steps.destination.outputs.simulator_jsons }} + + - name: Upload unit test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: project-unit-tests-${{ matrix.scheme }} + path: ${{ steps.run-tests.outputs.result_bundle_directory }} + + coverage-summary: + name: Combined Code Coverage + runs-on: macos-latest + needs: unit-tests + if: always() + outputs: + combined_coverage_percent: ${{ steps.coverage.outputs.coverage_percent }} + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Download unit test results + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: project-unit-tests-* + path: CoverageResults/xcresults + + - name: Generate code coverage summary + id: coverage + uses: ./.github/actions/xccov-warp-bubble + with: + xcresults_directory: CoverageResults/xcresults + summary_file: CoverageResults/code-coverage-summary.md diff --git a/.github/workflows/github-holodeck-trials.yml b/.github/workflows/github-holodeck-trials.yml new file mode 100644 index 00000000..aef5afbf --- /dev/null +++ b/.github/workflows/github-holodeck-trials.yml @@ -0,0 +1,168 @@ +name: GitHub Holodeck Trials + +on: + workflow_call: + inputs: + schemes: + description: | + JSON array of Xcode schemes to test. + Example: ["libPhoneNumber", "libPhoneNumberGeocoding"] + required: true + type: string + xcode_container: + description: Path to the Xcode project or workspace to test. + required: true + type: string + device_types: + description: | + Comma-separated list of simulator device types to resolve. + Supported values include iphone, ipad, macos, watch, and vision. + Example: iphone,ipad + required: true + type: string + selection_mode: + description: | + Simulator selection mode to pass to simctl-tricorder-selector. + Supported values: + - random-compatible + - random-latest-compatible + - model-type + - latest-model + required: false + default: random-compatible + type: string + model_preferences: + description: Optional model preferences such as iphone=Pro Max;ipad=Pro. + required: false + default: "" + type: string + iphoneos_version: + description: Specific iOS version for iPhone devices, or latest. + required: false + default: latest + type: string + ipados_version: + description: Specific iPadOS version for iPad devices, or latest. + required: false + default: latest + type: string + macos_version: + description: Specific macOS version for macOS simulator devices, or latest. + required: false + default: latest + type: string + watchos_version: + description: Specific watchOS version for watch devices, or latest. + required: false + default: latest + type: string + visionos_version: + description: Specific visionOS version for Vision devices, or latest. + required: false + default: latest + type: string + destination_arch: + description: Destination architecture to use with xcodebuild. + required: false + default: arm64 + type: string + xcodebuild_extra_args: + description: Optional extra xcodebuild arguments. + required: false + default: "" + type: string + failing_coverage_threshold: + description: Coverage percent below which the status is marked as failing. + required: false + default: "60" + type: string + passing_coverage_threshold: + description: Coverage percent at or above which the status is marked as passing. + required: false + default: "75" + type: string + outputs: + combined_coverage_percent: + description: Combined unit test coverage percent across all schemes. + value: ${{ jobs.coverage-summary.outputs.combined_coverage_percent }} + +jobs: + unit-tests: + name: Unit Tests (${{ matrix.scheme }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + scheme: ${{ fromJson(inputs.schemes) }} + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Resolve simulator destinations + id: destination + uses: ./.github/actions/simctl-tricorder-selector + with: + device_types: ${{ inputs.device_types }} + selection_mode: ${{ inputs.selection_mode }} + model_preferences: ${{ inputs.model_preferences }} + iphoneos_version: ${{ inputs.iphoneos_version }} + ipados_version: ${{ inputs.ipados_version }} + macos_version: ${{ inputs.macos_version }} + watchos_version: ${{ inputs.watchos_version }} + visionos_version: ${{ inputs.visionos_version }} + + - name: Run unit tests + id: run-tests + uses: ./.github/actions/xcode-tricorder-tester + with: + scheme: ${{ matrix.scheme }} + xcode_container: ${{ inputs.xcode_container }} + destination_ids: ${{ steps.destination.outputs.destination_ids }} + simulator_jsons: ${{ steps.destination.outputs.simulator_jsons }} + destination_arch: ${{ inputs.destination_arch }} + xcodebuild_extra_args: ${{ inputs.xcodebuild_extra_args }} + + - name: Upload unit test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: project-unit-tests-${{ matrix.scheme }} + path: ${{ steps.run-tests.outputs.result_bundle_directory }} + + coverage-summary: + name: Combined Code Coverage + runs-on: macos-latest + needs: unit-tests + if: always() + outputs: + combined_coverage_percent: ${{ steps.coverage.outputs.coverage_percent }} + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Download unit test results + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: project-unit-tests-* + path: CoverageResults/xcresults + + - name: Generate code coverage summary + id: coverage + uses: ./.github/actions/xccov-warp-bubble + with: + xcresults_directory: CoverageResults/xcresults + summary_file: CoverageResults/code-coverage-summary.md + summary_json_file: CoverageResults/code-coverage-summary.json + failing_coverage_threshold: ${{ inputs.failing_coverage_threshold }} + passing_coverage_threshold: ${{ inputs.passing_coverage_threshold }} + + - name: Publish combined coverage comment to pull request + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: combined-code-coverage + path: ${{ steps.coverage.outputs.summary_file }} + skip_unchanged: true diff --git a/.github/workflows/github-podspec-sensor-sweep.yml b/.github/workflows/github-podspec-sensor-sweep.yml new file mode 100644 index 00000000..5c5ef6ca --- /dev/null +++ b/.github/workflows/github-podspec-sensor-sweep.yml @@ -0,0 +1,38 @@ +name: GitHub Podspec Sensor Sweep + +on: + workflow_call: + inputs: + podspecs: + description: | + JSON array of podspec paths to lint. + Example: ["libPhoneNumber-iOS.podspec", "libPhoneNumberGeocoding.podspec"] + required: true + type: string + cocoapods_version: + description: CocoaPods version to install. + required: false + default: latest + type: string + +jobs: + podspec-lint: + name: Podspec Linting (${{ matrix.podspec }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + podspec: ${{ fromJson(inputs.podspecs) }} + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up CocoaPods + uses: maxim-lobanov/setup-cocoapods@v1 + with: + version: ${{ inputs.cocoapods_version }} + + - name: Lint podspec + run: | + pod lib lint "${{ matrix.podspec }}" --verbose diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml new file mode 100644 index 00000000..c721fe56 --- /dev/null +++ b/.github/workflows/main-ci.yml @@ -0,0 +1,31 @@ +name: Main CI + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: main-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + uses: ./.github/workflows/ci-core.yml + secrets: inherit + + podspec-lint: + name: Podspec Linting + uses: ./.github/workflows/github-podspec-sensor-sweep.yml + with: + podspecs: >- + [ + "libPhoneNumber-iOS.podspec", + "libPhoneNumberGeocoding.podspec", + "libPhoneNumberShortNumber.podspec" + ] + secrets: inherit diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml new file mode 100644 index 00000000..ebec1a01 --- /dev/null +++ b/.github/workflows/pull-request-ci.yml @@ -0,0 +1,39 @@ +name: Pull Request CI + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pull-request-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: Unit Testing + uses: ./.github/workflows/github-holodeck-trials.yml + with: + schemes: >- + [ + "libPhoneNumber", + "libPhoneNumberGeocoding", + "libPhoneNumberShortNumber" + ] + xcode_container: libPhoneNumber.xcodeproj + device_types: iphone + secrets: inherit + + podspec-lint: + name: Podspec Linting + uses: ./.github/workflows/github-podspec-sensor-sweep.yml + with: + podspecs: >- + [ + "libPhoneNumber-iOS.podspec", + "libPhoneNumberGeocoding.podspec", + "libPhoneNumberShortNumber.podspec" + ] diff --git a/.gitignore b/.gitignore index 549e675f..2e40e972 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,17 @@ ObjectiveC.gcda # SPM .build .swiftpm + +# Python +*.py[cod] +**/__pycache__/ +**/.pytest_cache/ + +# Python action tests +.coverage +.coverage.* +.github/actions-tests/.coverage +.github/actions-tests/.coverage.* +.github/actions-tests/.venv/ +.github/actions-tests/build/ +.venv-python-action-tests/ diff --git a/.slather.yml b/.slather.yml deleted file mode 100644 index b1c40362..00000000 --- a/.slather.yml +++ /dev/null @@ -1,12 +0,0 @@ -ci_service: travis_ci -coverage_service: coveralls -xcodeproj: libPhoneNumber.xcodeproj -ignore: - - libPhoneNumber/AppDelegate.m - - libPhoneNumber/main.m - - libPhoneNumber/NBPhoneMetaDataGenerator.m - - libPhoneNumber/NBPhoneMetaData.m - - libPhoneNumber/NBPhoneNumberDesc.m - - libPhoneNumber/NBMetadataHelper.m - - libPhoneNumber/NBNumberFormat.m - - libPhoneNumber/NBPhoneNumber.m diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 94d69ebb..00000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: objective-c - -before_install: -- gem install slather -N - -script: -- xcodebuild -project libPhoneNumber.xcodeproj -scheme libPhoneNumberiOS clean -sdk iphonesimulator -- xcodebuild -project libPhoneNumber.xcodeproj -scheme libPhoneNumberiOS -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES build-for-testing -- xctool -project libPhoneNumber.xcodeproj -scheme libPhoneNumberiOS run-tests -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES GCC_GENERATE_TEST_COVERAGE_FILES=YES - -- xcodebuild -project libPhoneNumber.xcodeproj -scheme libPhoneNumberGeocoding -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES build-for-testing -- xctool -project libPhoneNumber.xcodeproj -scheme libPhoneNumberGeocoding run-tests -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES GCC_GENERATE_TEST_COVERAGE_FILES=YES - -- xcodebuild -project libPhoneNumber.xcodeproj -scheme libPhoneNumberShortNumber -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES build-for-testing -- xctool -project libPhoneNumber.xcodeproj -scheme libPhoneNumberShortNumber run-tests -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES GCC_GENERATE_TEST_COVERAGE_FILES=YES - -after_success: slather diff --git a/README.md b/README.md index 3d910320..69a52336 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ [![CocoaPods](https://img.shields.io/cocoapods/p/libPhoneNumber-iOS.svg?style=flat)](http://cocoapods.org/?q=libPhoneNumber-iOS) [![CocoaPods](https://img.shields.io/cocoapods/v/libPhoneNumber-iOS.svg?style=flat)](http://cocoapods.org/?q=libPhoneNumber-iOS) -[![Travis](https://travis-ci.org/iziz/libPhoneNumber-iOS.svg?branch=master)](https://travis-ci.org/iziz/libPhoneNumber-iOS) -[![Coveralls](https://coveralls.io/repos/iziz/libPhoneNumber-iOS/badge.svg?branch=master&service=github)](https://coveralls.io/github/iziz/libPhoneNumber-iOS?branch=master) +[![Main CI](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/main-ci.yml/badge.svg?branch=master)](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/main-ci.yml) [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) # **libPhoneNumber for iOS** diff --git a/libPhoneNumber.xcodeproj/project.pbxproj b/libPhoneNumber.xcodeproj/project.pbxproj index a8dbd477..5311f0bd 100755 --- a/libPhoneNumber.xcodeproj/project.pbxproj +++ b/libPhoneNumber.xcodeproj/project.pbxproj @@ -36,8 +36,8 @@ 8B0FD2FF1E4A88AC0049DF81 /* NSArray+NBAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 8B0FD2FA1E4A88AC0049DF81 /* NSArray+NBAdditions.m */; }; 9407259424BE768A0011AE05 /* libPhoneNumberShortNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9407258B24BE768A0011AE05 /* libPhoneNumberShortNumber.framework */; }; 9407259B24BE768A0011AE05 /* libPhoneNumberShortNumber.h in Headers */ = {isa = PBXBuildFile; fileRef = 9407258D24BE768A0011AE05 /* libPhoneNumberShortNumber.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 940725A224BE769D0011AE05 /* libPhoneNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; platformFilter = ios; }; - 940725A324BE769D0011AE05 /* libPhoneNumber.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; platformFilter = ios; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 940725A224BE769D0011AE05 /* libPhoneNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; }; + 940725A324BE769D0011AE05 /* libPhoneNumber.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 940725A924BE77420011AE05 /* NBShortNumberUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = 940725A724BE77420011AE05 /* NBShortNumberUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; 940725AA24BE77420011AE05 /* NBShortNumberUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = 940725A824BE77420011AE05 /* NBShortNumberUtil.m */; }; 940725AC24BF63050011AE05 /* NBShortNumberInfoTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 940725AB24BF63050011AE05 /* NBShortNumberInfoTest.m */; }; @@ -80,6 +80,7 @@ BB9B604A2EBE9A7800C48233 /* libPhoneNumber.h in Headers */ = {isa = PBXBuildFile; fileRef = BB9B60492EBE9A7800C48233 /* libPhoneNumber.h */; settings = {ATTRIBUTES = (Public, ); }; }; BB9B604B2EBE9B0C00C48233 /* libPhoneNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; platformFilter = ios; }; BB9B604C2EBE9B2200C48233 /* libPhoneNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; platformFilter = ios; }; + BB9FA4CB2F85BF9800CCF4FC /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = BB9FA4CA2F85BF8600CCF4FC /* libsqlite3.tbd */; }; BBF66CA92EBBF9B9005E3382 /* NBGeneratedPhoneNumberMetaData.m in Sources */ = {isa = PBXBuildFile; fileRef = BBF66CA82EBBF9B9005E3382 /* NBGeneratedPhoneNumberMetaData.m */; }; BBF66CAB2EBBF9E2005E3382 /* NBGeneratedShortNumberMetaData.m in Sources */ = {isa = PBXBuildFile; fileRef = BBF66CAA2EBBF9E2005E3382 /* NBGeneratedShortNumberMetaData.m */; }; BBF66CB02EBC0783005E3382 /* NSBundle+Extensions.m in Sources */ = {isa = PBXBuildFile; fileRef = BBF66CAD2EBC0783005E3382 /* NSBundle+Extensions.m */; }; @@ -196,6 +197,9 @@ BB3F7C672EBD34DB0091CF5B /* _HeaderOnlyShim.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = _HeaderOnlyShim.c; sourceTree = ""; }; BB3F7C682EBD34DB0091CF5B /* NBGeneratedShortNumberMetaData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NBGeneratedShortNumberMetaData.h; sourceTree = ""; }; BB3F7C692EBD34DB0091CF5B /* NBShortNumberMetadataHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NBShortNumberMetadataHelper.h; sourceTree = ""; }; + BB4248A42F6C8AB300A7438E /* libPhoneNumberTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = libPhoneNumberTests.xctestplan; sourceTree = ""; }; + BB4248A52F6C8AEE00A7438E /* libPhoneNumberShortNumberTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = libPhoneNumberShortNumberTests.xctestplan; sourceTree = ""; }; + BB4248A72F6C8B5600A7438E /* libPhoneNumberGeocodingTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = libPhoneNumberGeocodingTests.xctestplan; sourceTree = ""; }; BB6A710C2EBD02F500292CA8 /* libPhoneNumberMetaDataForTesting.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = libPhoneNumberMetaDataForTesting.zip; sourceTree = ""; }; BB6A710D2EBD02F500292CA8 /* NBTestingMetaData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NBTestingMetaData.h; sourceTree = ""; }; BB6A71132EBD306400292CA8 /* _HeaderOnlyShim.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = _HeaderOnlyShim.c; sourceTree = ""; }; @@ -213,6 +217,7 @@ BB9B60432EBE9A0500C48233 /* _HeaderOnlyShim.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = _HeaderOnlyShim.c; sourceTree = ""; }; BB9B60442EBE9A0500C48233 /* GeocodingMetaData.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = GeocodingMetaData.bundle; sourceTree = ""; }; BB9B60492EBE9A7800C48233 /* libPhoneNumber.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libPhoneNumber.h; sourceTree = ""; }; + BB9FA4CA2F85BF8600CCF4FC /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; BBCABBF32EE0E61E0011A4C7 /* updateProjectVersions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = updateProjectVersions.swift; sourceTree = ""; }; BBCABBF52EE0F6E90011A4C7 /* versionCommitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = versionCommitter.swift; sourceTree = ""; }; BBF66CA82EBBF9B9005E3382 /* NBGeneratedPhoneNumberMetaData.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NBGeneratedPhoneNumberMetaData.m; sourceTree = ""; }; @@ -247,6 +252,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BB9FA4CB2F85BF9800CCF4FC /* libsqlite3.tbd in Frameworks */, 0FC0D36D24A29F680087AFCF /* libPhoneNumber.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -315,6 +321,7 @@ isa = PBXGroup; children = ( 0FC0D37A24A2A05E0087AFCF /* Info.plist */, + BB4248A72F6C8B5600A7438E /* libPhoneNumberGeocodingTests.xctestplan */, 94C9AF0E24B3AAF900469F54 /* NBPhoneNumberOfflineGeocoderTest.m */, CA55ED88296F51E0005E98A1 /* TestingSource.bundle */, ); @@ -325,6 +332,7 @@ isa = PBXGroup; children = ( 1485C52B1E06F4930092F541 /* Info.plist */, + BB4248A42F6C8AB300A7438E /* libPhoneNumberTests.xctestplan */, 1485C5231E06F4890092F541 /* NBAsYouTypeFormatterTest.m */, 0FAE11902037959800193503 /* NBPhoneNumberParsingPerfTest.m */, 1485C5251E06F4890092F541 /* NBPhoneNumberUtilTest.m */, @@ -351,6 +359,7 @@ isa = PBXGroup; children = ( 9407259A24BE768A0011AE05 /* Info.plist */, + BB4248A52F6C8AEE00A7438E /* libPhoneNumberShortNumberTests.xctestplan */, 940725AB24BF63050011AE05 /* NBShortNumberInfoTest.m */, 940725B024BF7B040011AE05 /* NBShortNumberTestHelper.h */, 940725B124BF7B040011AE05 /* NBShortNumberTestHelper.m */, @@ -477,6 +486,7 @@ FD7A061F167715A0004BBEB6 /* Frameworks */ = { isa = PBXGroup; children = ( + BB9FA4CA2F85BF8600CCF4FC /* libsqlite3.tbd */, CAA5E78C29F84B7B00550AA7 /* Contacts.framework */, FD7A0624167715A0004BBEB6 /* CoreGraphics.framework */, FD7A0622167715A0004BBEB6 /* Foundation.framework */, @@ -708,9 +718,10 @@ FD7A0613167715A0004BBEB6 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0830; LastTestingUpgradeCheck = 0510; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 2640; ORGANIZATIONNAME = Google; TargetAttributes = { 0FC0D36324A29F510087AFCF = { @@ -913,7 +924,6 @@ }; 940725A524BE769D0011AE05 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - platformFilter = ios; target = 34ACBB841B7122AC0064B3BD /* libPhoneNumber */; targetProxy = 940725A424BE769D0011AE05 /* PBXContainerItemProxy */; }; @@ -925,8 +935,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -934,13 +942,11 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = MQV8HVXR99; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberGeocoding/Info.plist; @@ -967,8 +973,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -977,14 +981,12 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = MQV8HVXR99; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberGeocoding/Info.plist; @@ -1011,9 +1013,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_CODE_COVERAGE = NO; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1021,8 +1020,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberGeocodingTests/Info.plist; @@ -1044,9 +1041,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_CODE_COVERAGE = NO; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1056,8 +1050,6 @@ COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberGeocodingTests/Info.plist; @@ -1079,14 +1071,12 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = NO; CLANG_ANALYZER_NONNULL = YES; - CLANG_ENABLE_CODE_COVERAGE = NO; CLANG_ENABLE_MODULES = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_SUSPICIOUS_MOVES = YES; DEBUG_INFORMATION_FORMAT = dwarf; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; @@ -1107,7 +1097,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = NO; CLANG_ANALYZER_NONNULL = YES; - CLANG_ENABLE_CODE_COVERAGE = NO; CLANG_ENABLE_MODULES = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1116,7 +1105,6 @@ COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; @@ -1135,7 +1123,6 @@ 34ACBB9E1B7122AC0064B3BD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CLANG_ENABLE_MODULES = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; @@ -1143,9 +1130,7 @@ CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1177,7 +1162,6 @@ 34ACBB9F1B7122AC0064B3BD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CLANG_ENABLE_MODULES = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; @@ -1185,17 +1169,14 @@ CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; @@ -1224,7 +1205,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; @@ -1233,12 +1213,10 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 973LHT5R86; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberShortNumber/Info.plist; @@ -1265,7 +1243,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; @@ -1275,13 +1252,11 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 973LHT5R86; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberShortNumber/Info.plist; @@ -1308,8 +1283,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1318,7 +1291,6 @@ CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 973LHT5R86; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberShortNumberTests/Info.plist; @@ -1340,8 +1312,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1352,7 +1322,6 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 973LHT5R86; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberShortNumberTests/Info.plist; @@ -1394,15 +1363,14 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_IDENTITY = ""; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1.4.0; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; - GCC_GENERATE_TEST_COVERAGE_FILES = YES; - GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = YES; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -1422,6 +1390,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; TVOS_DEPLOYMENT_TARGET = 12.0; @@ -1456,13 +1425,12 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_IDENTITY = ""; COPY_PHASE_STRIP = YES; CURRENT_PROJECT_VERSION = 1.4.0; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_GENERATE_TEST_COVERAGE_FILES = YES; - GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = YES; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES; @@ -1475,6 +1443,7 @@ MARKETING_VERSION = 1.4.0; OTHER_CFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumber.xcscheme b/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumber.xcscheme index 4bab9f45..e19219da 100644 --- a/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumber.xcscheme +++ b/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumber.xcscheme @@ -1,10 +1,10 @@ + buildImplicitDependencies = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + diff --git a/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumberGeocoding.xcscheme b/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumberGeocoding.xcscheme index 440e8886..7be2b763 100644 --- a/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumberGeocoding.xcscheme +++ b/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumberGeocoding.xcscheme @@ -1,10 +1,10 @@ + buildImplicitDependencies = "NO"> - - - - - - + + + + + LastUpgradeVersion = "2640" + version = "1.7"> + buildImplicitDependencies = "NO"> - - - - - - + + + +