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.
This commit is contained in:
Executable
+134
@@ -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<indent>[ \t]*)(?P<uuid>[0-9A-F]+) /\* (?P<comment>[^\n]+?) \*/ = \{\n"
|
||||||
|
r"(?P<body>.*?)^(?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<entries>.*?)^[ \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())
|
||||||
@@ -17,6 +17,7 @@ cd "$ROOT_DIR"
|
|||||||
for checker in \
|
for checker in \
|
||||||
scripts/check_build_workflow.py \
|
scripts/check_build_workflow.py \
|
||||||
scripts/check_apple_spm_locks.py \
|
scripts/check_apple_spm_locks.py \
|
||||||
|
scripts/check_tvos_test_wiring.py \
|
||||||
scripts/verify_runtime_inputs.py \
|
scripts/verify_runtime_inputs.py \
|
||||||
scripts/check_workflow_security.py \
|
scripts/check_workflow_security.py \
|
||||||
scripts/check_workflow_action_pins.py \
|
scripts/check_workflow_action_pins.py \
|
||||||
|
|||||||
Executable
+116
@@ -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 = \"<group>\";",
|
||||||
|
"\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()
|
||||||
@@ -82,17 +82,16 @@ project.files.select { |file| file.display_name == 'Foundation.framework' }.each
|
|||||||
end
|
end
|
||||||
file_ref.remove_from_project unless still_used
|
file_ref.remove_from_project unless still_used
|
||||||
end
|
end
|
||||||
runner_test_sources = %w[
|
RUNNER_TESTS_DIR = File.expand_path('../RunnerTests', __dir__)
|
||||||
MpvPlayerContractTests.swift
|
COMPILED_TEST_EXTENSIONS = %w[.swift .m .mm].freeze
|
||||||
TvosEventDeliveryCoordinatorTests.swift
|
runner_test_files = Dir.children(RUNNER_TESTS_DIR).reject { |name| name.start_with?('.') }.sort
|
||||||
ConnectivityPlusPluginTests.swift
|
runner_test_sources = runner_test_files.select { |name| COMPILED_TEST_EXTENSIONS.include?(File.extname(name)) }
|
||||||
SystemShelfPluginTests.swift
|
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|
|
test_target.source_build_phase.files.delete_if do |build_file|
|
||||||
file_ref = build_file.file_ref
|
file_ref = build_file.file_ref
|
||||||
file_ref && !runner_test_sources.include?(file_ref.display_name)
|
file_ref && !runner_test_sources.include?(file_ref.display_name)
|
||||||
end
|
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
|
file_ref.remove_from_project
|
||||||
end
|
end
|
||||||
runner_test_sources.each do |filename|
|
runner_test_sources.each do |filename|
|
||||||
|
|||||||
Reference in New Issue
Block a user