From 0ed0ebf22a180beb733f3ff8cb7b783386475d60 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:44:52 +0200 Subject: [PATCH] fix(tvos): derive the RunnerTests roster from the test directory Read the sources from disk instead of a hand-maintained allow-list that the script uses destructively, and add a guard that fails when the project and directory disagree. FlutterNativeTextInputTests.mm was the second test the list would have silently unwired. --- scripts/check_tvos_test_wiring.py | 134 +++++++++++++++++++++++++ scripts/ci_guard_checks.sh | 1 + scripts/test_check_tvos_test_wiring.py | 116 +++++++++++++++++++++ tvos/scripts/wire_top_shelf.rb | 13 ++- 4 files changed, 257 insertions(+), 7 deletions(-) create mode 100755 scripts/check_tvos_test_wiring.py create mode 100755 scripts/test_check_tvos_test_wiring.py diff --git a/scripts/check_tvos_test_wiring.py b/scripts/check_tvos_test_wiring.py new file mode 100755 index 00000000..16d2152f --- /dev/null +++ b/scripts/check_tvos_test_wiring.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Validate tvOS RunnerTests project wiring against the files on disk.""" + +from __future__ import annotations + +import argparse +import re +from collections import Counter +from pathlib import Path + +PROJECT_PATH = Path("tvos/Runner.xcodeproj/project.pbxproj") +RUNNER_TESTS_PATH = Path("tvos/RunnerTests") +COMPILED_TEST_EXTENSIONS = {".swift", ".m", ".mm"} + +_OBJECT = re.compile( + r"^(?P[ \t]*)(?P[0-9A-F]+) /\* (?P[^\n]+?) \*/ = \{\n" + r"(?P.*?)^(?P=indent)\};", + re.MULTILINE | re.DOTALL, +) +_LIST_ENTRY = re.compile(r"^[ \t]*([0-9A-F]+) /\* ([^\n]+?) \*/,?[ \t]*$", re.MULTILINE) + + +def _assignment(body: str, name: str) -> str | None: + match = re.search(rf"^[ \t]*{re.escape(name)} = ([^;\n]+);[ \t]*$", body, re.MULTILINE) + return match.group(1) if match else None + + +def _list_entries(body: str, name: str) -> list[tuple[str, str]] | None: + match = re.search( + rf"^[ \t]*{re.escape(name)} = \(\n(?P.*?)^[ \t]*\);[ \t]*$", + body, + re.MULTILINE | re.DOTALL, + ) + if match is None: + return None + return [(entry.group(1), entry.group(2)) for entry in _LIST_ENTRY.finditer(match.group("entries"))] + + +def _describe_difference(label: str, actual: list[str], expected: list[str], errors: list[str]) -> None: + duplicates = sorted(name for name, count in Counter(actual).items() if count > 1) + if duplicates: + errors.append(f"{label} has duplicate entries: {', '.join(duplicates)}") + + missing = sorted(set(expected) - set(actual)) + if missing: + errors.append(f"{label} is missing: {', '.join(missing)}") + unexpected = sorted(set(actual) - set(expected)) + if unexpected: + errors.append(f"{label} has stale entries: {', '.join(unexpected)}") + + +def validate(root: Path) -> list[str]: + root = root.resolve() + errors: list[str] = [] + tests_path = root / RUNNER_TESTS_PATH + project_path = root / PROJECT_PATH + + try: + test_files = sorted(path.name for path in tests_path.iterdir() if not path.name.startswith(".")) + except OSError as error: + errors.append(f"{tests_path}: cannot read RunnerTests directory: {error}") + return errors + + try: + project = project_path.read_text(encoding="utf-8") + except OSError as error: + errors.append(f"{project_path}: cannot read Xcode project: {error}") + return errors + + objects = {match.group("uuid"): match.group("body") for match in _OBJECT.finditer(project)} + groups = [ + body + for body in objects.values() + if _assignment(body, "isa") == "PBXGroup" + and _assignment(body, "name") == "RunnerTests" + and _assignment(body, "path") == "RunnerTests" + ] + if len(groups) != 1: + errors.append(f"{project_path}: expected one RunnerTests PBXGroup, found {len(groups)}") + else: + children = _list_entries(groups[0], "children") + if children is None: + errors.append(f"{project_path}: RunnerTests PBXGroup has no children list") + else: + _describe_difference("RunnerTests PBXGroup", [name for _, name in children], test_files, errors) + + targets = [ + body + for body in objects.values() + if _assignment(body, "isa") == "PBXNativeTarget" and _assignment(body, "name") == "RunnerTests" + ] + if len(targets) != 1: + errors.append(f"{project_path}: expected one RunnerTests PBXNativeTarget, found {len(targets)}") + return errors + + build_phases = _list_entries(targets[0], "buildPhases") + if build_phases is None: + errors.append(f"{project_path}: RunnerTests target has no buildPhases list") + return errors + source_phase_ids = [uuid for uuid, comment in build_phases if comment == "Sources"] + if len(source_phase_ids) != 1: + errors.append(f"{project_path}: RunnerTests target must reference one Sources phase, found {len(source_phase_ids)}") + return errors + + source_phase = objects.get(source_phase_ids[0]) + if source_phase is None or _assignment(source_phase, "isa") != "PBXSourcesBuildPhase": + errors.append(f"{project_path}: RunnerTests Sources phase object is missing or invalid") + return errors + source_entries = _list_entries(source_phase, "files") + if source_entries is None: + errors.append(f"{project_path}: RunnerTests Sources phase has no files list") + return errors + + source_names = [name.removesuffix(" in Sources") for _, name in source_entries] + compiled_test_files = [name for name in test_files if Path(name).suffix in COMPILED_TEST_EXTENSIONS] + _describe_difference("RunnerTests Sources phase", source_names, compiled_test_files, errors) + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args(argv) + errors = validate(args.root) + if errors: + for error in errors: + print(f"error: {error}") + return 1 + print("tvOS RunnerTests project wiring matches the files on disk.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci_guard_checks.sh b/scripts/ci_guard_checks.sh index be182dbb..2af8a7a4 100644 --- a/scripts/ci_guard_checks.sh +++ b/scripts/ci_guard_checks.sh @@ -17,6 +17,7 @@ cd "$ROOT_DIR" for checker in \ scripts/check_build_workflow.py \ scripts/check_apple_spm_locks.py \ + scripts/check_tvos_test_wiring.py \ scripts/verify_runtime_inputs.py \ scripts/check_workflow_security.py \ scripts/check_workflow_action_pins.py \ diff --git a/scripts/test_check_tvos_test_wiring.py b/scripts/test_check_tvos_test_wiring.py new file mode 100755 index 00000000..d63e7b6a --- /dev/null +++ b/scripts/test_check_tvos_test_wiring.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("check_tvos_test_wiring.py") +SPEC = importlib.util.spec_from_file_location("check_tvos_test_wiring", SCRIPT) +CHECKER = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(CHECKER) + + +class TvosTestWiringCheckerTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.tests_path = self.root / "tvos/RunnerTests" + self.tests_path.mkdir(parents=True) + self.compiled_files = ["ExampleTests.swift", "WidgetTests.mm"] + for name in self.compiled_files: + (self.tests_path / name).write_text("// fixture\n", encoding="utf-8") + self._write_project() + + def tearDown(self) -> None: + self.temporary.cleanup() + + def _entries(self, names: list[str], *, in_sources: bool = False) -> str: + entries = [] + for index, name in enumerate(names, start=1): + suffix = " in Sources" if in_sources else "" + uuid = f"{index:024X}" + entries.append(f"\t\t\t\t{uuid} /* {name}{suffix} */,") + return "\n".join(entries) + + def _write_project( + self, + *, + group_names: list[str] | None = None, + source_names: list[str] | None = None, + ) -> None: + group_names = self.compiled_files if group_names is None else group_names + source_names = self.compiled_files if source_names is None else source_names + project_path = self.root / "tvos/Runner.xcodeproj/project.pbxproj" + project_path.parent.mkdir(parents=True, exist_ok=True) + project_path.write_text( + "\n".join( + [ + "// !$*UTF8*$!", + "{", + "\tobjects = {", + "\t\tAAAAAAAAAAAAAAAAAAAAAAAA /* RunnerTests */ = {", + "\t\t\tisa = PBXGroup;", + "\t\t\tchildren = (", + self._entries(group_names), + "\t\t\t);", + "\t\t\tname = RunnerTests;", + "\t\t\tpath = RunnerTests;", + "\t\t\tsourceTree = \"\";", + "\t\t};", + "\t\tBBBBBBBBBBBBBBBBBBBBBBBB /* RunnerTests */ = {", + "\t\t\tisa = PBXNativeTarget;", + "\t\t\tbuildPhases = (", + "\t\t\t\tCCCCCCCCCCCCCCCCCCCCCCCC /* Sources */,", + "\t\t\t);", + "\t\t\tname = RunnerTests;", + "\t\t};", + "\t\tCCCCCCCCCCCCCCCCCCCCCCCC /* Sources */ = {", + "\t\t\tisa = PBXSourcesBuildPhase;", + "\t\t\tfiles = (", + self._entries(source_names, in_sources=True), + "\t\t\t);", + "\t\t};", + "\t};", + "}", + "", + ] + ), + encoding="utf-8", + ) + + def test_matching_group_and_sources_phase_pass(self) -> None: + self.assertEqual([], CHECKER.validate(self.root)) + + def test_missing_sources_entry_reports_file_on_disk(self) -> None: + self._write_project(source_names=["WidgetTests.mm"]) + + errors = CHECKER.validate(self.root) + + self.assertTrue(any("Sources phase" in error and "ExampleTests.swift" in error for error in errors)) + + def test_missing_group_entry_reports_file_on_disk(self) -> None: + self._write_project(group_names=["WidgetTests.mm"]) + + errors = CHECKER.validate(self.root) + + self.assertTrue(any("PBXGroup" in error and "ExampleTests.swift" in error for error in errors)) + + def test_new_swift_file_missing_from_project_is_reported(self) -> None: + (self.tests_path / "UnwiredTests.swift").write_text("// fixture\n", encoding="utf-8") + + errors = CHECKER.validate(self.root) + + self.assertTrue(any("PBXGroup" in error and "UnwiredTests.swift" in error for error in errors)) + self.assertTrue(any("Sources phase" in error and "UnwiredTests.swift" in error for error in errors)) + + def test_non_compiled_sibling_is_required_only_in_group(self) -> None: + (self.tests_path / "TestSupport.h").write_text("// fixture\n", encoding="utf-8") + self._write_project(group_names=[*self.compiled_files, "TestSupport.h"]) + + self.assertEqual([], CHECKER.validate(self.root)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tvos/scripts/wire_top_shelf.rb b/tvos/scripts/wire_top_shelf.rb index b9fb2791..7a84ed96 100644 --- a/tvos/scripts/wire_top_shelf.rb +++ b/tvos/scripts/wire_top_shelf.rb @@ -82,17 +82,16 @@ project.files.select { |file| file.display_name == 'Foundation.framework' }.each end file_ref.remove_from_project unless still_used end -runner_test_sources = %w[ - MpvPlayerContractTests.swift - TvosEventDeliveryCoordinatorTests.swift - ConnectivityPlusPluginTests.swift - SystemShelfPluginTests.swift -] +RUNNER_TESTS_DIR = File.expand_path('../RunnerTests', __dir__) +COMPILED_TEST_EXTENSIONS = %w[.swift .m .mm].freeze +runner_test_files = Dir.children(RUNNER_TESTS_DIR).reject { |name| name.start_with?('.') }.sort +runner_test_sources = runner_test_files.select { |name| COMPILED_TEST_EXTENSIONS.include?(File.extname(name)) } +raise "No RunnerTests sources found in #{RUNNER_TESTS_DIR}" if runner_test_sources.empty? test_target.source_build_phase.files.delete_if do |build_file| file_ref = build_file.file_ref file_ref && !runner_test_sources.include?(file_ref.display_name) end -tests_group.files.reject { |file_ref| runner_test_sources.include?(file_ref.display_name) }.each do |file_ref| +tests_group.files.reject { |file_ref| runner_test_files.include?(file_ref.display_name) }.each do |file_ref| file_ref.remove_from_project end runner_test_sources.each do |filename|