diff --git a/.github/workflows/app-host-test-rerun.yml b/.github/workflows/app-host-test-rerun.yml index 7ada1895a9b4..9d63bf3a8f47 100644 --- a/.github/workflows/app-host-test-rerun.yml +++ b/.github/workflows/app-host-test-rerun.yml @@ -375,7 +375,6 @@ jobs: for selector in "${selectors[@]}"; do args+=("-only-testing:$selector"); done case "$TEST_ITERATIONS" in ''|*[!0-9]*) echo "test_iterations must be a number" >&2; exit 2 ;; esac if [ "$TEST_ITERATIONS" -gt 1 ]; then args+=(-test-iterations "$TEST_ITERATIONS"); fi - sudo rm -f /tmp/cmux-debug*.log /private/tmp/cmux-debug*.log sw_vers scripts/ci/run-in-console-session.sh \ scripts/ci/run-app-host-xcodebuild.sh \ @@ -393,8 +392,11 @@ jobs: TEST_ITERATIONS: ${{ inputs.test_iterations }} run: | set -euo pipefail - sudo sh -c "mkdir -p '$RUNNER_TEMP/rerun-out/rebuilt' && cp /tmp/cmux-debug*.log '$RUNNER_TEMP/rerun-out/rebuilt/' 2>/dev/null" || true - sudo rm -f /tmp/cmux-debug*.log /private/tmp/cmux-debug*.log + mkdir -p "$RUNNER_TEMP/rerun-out/rebuilt" + cp "$RUNNER_TEMP"/cmux-app-host-xcodebuild-*.log \ + "$RUNNER_TEMP"/cmux-app-host-xcodebuild-*.meta \ + "$RUNNER_TEMP"/cmux-app-host-console-capture-*.log \ + "$RUNNER_TEMP/rerun-out/rebuilt/" 2>/dev/null || true bundle="$CMUX_DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app/Contents/PlugIns/cmuxTests.xctest" rm -rf "$bundle" ditto "$RUNNER_TEMP/prebuilt-cmuxTests.xctest" "$bundle" @@ -416,13 +418,15 @@ jobs: set -u out="$RUNNER_TEMP/rerun-out" mkdir -p "$out" - sudo sh -c "cp /tmp/cmux-debug*.log /private/tmp/cmux-debug*.log '$out'/ 2>/dev/null" || true - cp "$RUNNER_TEMP"/cmux-app-host-xcodebuild-*.log "$RUNNER_TEMP"/cmux-app-host-xcodebuild-*.meta "$out"/ 2>/dev/null || true + # These captures belong to this job; global debug logs may belong to + # another runner, and collecting diagnostics must not require sudo. + cp "$RUNNER_TEMP"/cmux-app-host-xcodebuild-*.log \ + "$RUNNER_TEMP"/cmux-app-host-xcodebuild-*.meta \ + "$RUNNER_TEMP"/cmux-app-host-console-capture-*.log "$out"/ 2>/dev/null || true cp "$RUNNER_TEMP/cmuxTests-detached.xcconfig" "$out"/ 2>/dev/null || true if [ -d "$RUNNER_TEMP/cmux-app-host-xcresults" ]; then cp -R "$RUNNER_TEMP/cmux-app-host-xcresults" "$out"/ fi - sudo chown -R "$(id -u)" "$out" ls -la "$out" - name: Upload results diff --git a/scripts/ci/app_host_test_rerun.py b/scripts/ci/app_host_test_rerun.py index 7d4cd2901d91..8948829ed527 100644 --- a/scripts/ci/app_host_test_rerun.py +++ b/scripts/ci/app_host_test_rerun.py @@ -443,11 +443,25 @@ def locate_c_targets(names: list[str], roots: list[Path], dump: Callable[[Path], return found -def binary_framework_search_paths(roots: list[Path], arch: str | None) -> list[str]: +def binary_framework_search_paths( + roots: list[Path], arch: str | None, debug_products: Path, target: str +) -> list[str]: """Use resolved macOS XCFramework slices when archived runtime copies lack modules.""" if roots and not arch: raise ValueError("binary framework selection requires the product architecture") - paths = set() + required = set() + for bundle in debug_products.glob(f"*.app/Contents/PlugIns/{target}.xctest"): + for directory in (bundle.parents[1] / "Frameworks", bundle / "Contents" / "Frameworks"): + required.update(framework.name for framework in directory.glob("*.framework")) + # The product tells us which variants it used. Do not expose unused binary + # targets just because SwiftPM downloaded them while resolving a package. + for name in list(required): + for directory in (debug_products, debug_products / "PackageFrameworks"): + modules = directory / name / "Modules" + if (modules / "module.modulemap").is_file() or any(modules.glob("*.swiftmodule")): + required.discard(name) + break + candidates: dict[str, set[Path]] = {} for root in roots: if not root.is_dir(): raise ValueError(f"resolved binary artifact directory is missing: {root}") @@ -460,12 +474,17 @@ def binary_framework_search_paths(roots: list[Path], arch: str | None) -> list[s or arch not in library.get("SupportedArchitectures", [])): continue relative = Path(library["LibraryIdentifier"]) / library["LibraryPath"] - if relative.suffix != ".framework": + if relative.suffix != ".framework" or relative.name not in required: continue framework = (manifest.parent / relative).resolve() if not framework.is_relative_to(manifest.parent.resolve()) or not framework.is_dir(): raise ValueError(f"invalid binary framework slice: {framework}") - paths.add(str(framework.parent)) + candidates.setdefault(relative.name, set()).add(framework) + paths = set() + for name, frameworks in sorted(candidates.items()): + if len(frameworks) != 1: + raise ValueError(f"ambiguous binary framework slices for {name}: {sorted(map(str, frameworks))}") + paths.add(str(next(iter(frameworks)).parent)) return sorted(paths) @@ -497,7 +516,8 @@ def detach(args: argparse.Namespace, dump: Callable[[Path], dict] = dump_package "FRAMEWORK_SEARCH_PATHS": [ "$(inherited)", "$(BUILT_PRODUCTS_DIR)/PackageFrameworks", *binary_framework_search_paths( - [Path(root) for root in getattr(args, "xcframework_root", [])], getattr(args, "arch", None) + [Path(root) for root in getattr(args, "xcframework_root", [])], getattr(args, "arch", None), + debug_products, args.target ), ], "OTHER_LDFLAGS": ["$(inherited)", *ldflags], diff --git a/tests/test_app_host_test_rerun.py b/tests/test_app_host_test_rerun.py index 89b659c0bce5..d434f6aa052f 100644 --- a/tests/test_app_host_test_rerun.py +++ b/tests/test_app_host_test_rerun.py @@ -11,6 +11,7 @@ import subprocess import sys import tempfile +import textwrap import unittest import unittest.mock from pathlib import Path @@ -450,11 +451,15 @@ def test_detach_imports_only_matching_resolved_binary_framework_slices(self) -> host = debug / "Host App.app" / "Contents" (host / "PlugIns" / "cmuxTests.xctest").mkdir(parents=True) # Runtime copies can survive while Xcode removes importable build products. - (host / "Frameworks" / "Sparkle.framework").mkdir(parents=True) + for name in ("Sparkle", "Iroh", "Existing"): + (host / "Frameworks" / f"{name}.framework").mkdir(parents=True) + # A usable archived module wins; a resolved but unused variant must not leak in. + (debug / "Existing.framework" / "Modules").mkdir(parents=True) + (debug / "Existing.framework" / "Modules" / "module.modulemap").write_text("framework module Existing {}") artifacts = root / "resolved artifacts" expected = [] excluded = [] - for name in ("Sparkle", "Iroh"): + for name in ("Sparkle", "Iroh", "UnusedVariant", "Existing"): xcframework = artifacts / name / f"{name}.xcframework" libraries = [] for identifier, platform, architectures, variant in ( @@ -471,7 +476,7 @@ def test_detach_imports_only_matching_resolved_binary_framework_slices(self) -> if variant: library["SupportedPlatformVariant"] = variant libraries.append(library) - (expected if identifier == "macos-universal" else excluded).append(framework.parent) + (expected if identifier == "macos-universal" and name in ("Sparkle", "Iroh") else excluded).append(framework.parent) (xcframework / "Info.plist").write_bytes(plistlib.dumps(dict(AvailableLibraries=libraries))) project = root / "project.pbxproj" project.write_text(PROJECT) @@ -489,6 +494,16 @@ def test_detach_imports_only_matching_resolved_binary_framework_slices(self) -> for directory in excluded: self.assertNotIn(str(directory), search_paths) self.assertNotIn(str(host / "Frameworks"), search_paths) + # An embedded name alone cannot choose between multiple compatible targets. + alternate = artifacts / "alternate" / "Sparkle.xcframework" + (alternate / "macos" / "Sparkle.framework").mkdir(parents=True) + (alternate / "Info.plist").write_bytes(plistlib.dumps(dict(AvailableLibraries=[dict( + LibraryIdentifier="macos", LibraryPath="Sparkle.framework", + SupportedPlatform="macos", SupportedArchitectures=["arm64"], + )]))) + project.write_text(PROJECT) + with unittest.mock.patch("sys.stdout"), self.assertRaisesRegex(ValueError, "ambiguous.*Sparkle"): + rerun.detach(args) def test_umbrella_header_wins_over_directory(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -587,6 +602,58 @@ def test_the_workflow_falls_back_to_every_source(self) -> None: class WorkflowTests(unittest.TestCase): + def test_test_and_log_steps_run_without_sudo_and_collect_job_captures(self) -> None: + workflow = WORKFLOW.read_text() + names = ["Run selected tests", "Run the same tests against the CI-built bundle", "Collect logs"] + with tempfile.TemporaryDirectory(prefix="rerun job ") as directory: + root = Path(directory) + temporary = root / "runner-temp" + temporary.mkdir() + binaries = root / "bin" + binaries.mkdir() + scripts = root / "scripts" / "ci" + scripts.mkdir(parents=True) + commands = { + binaries / "sudo": '#!/bin/bash\necho "$*" >> "$RUNNER_TEMP/sudo-attempts"\nexit 91\n', + binaries / "sw_vers": "#!/bin/bash\nexit 0\n", + binaries / "ditto": '#!/bin/bash\nexec cp -R "$1" "$2"\n', + scripts / "run-in-console-session.sh": ( + '#!/bin/bash\nprintf "%s\\n" "$*" >> "$RUNNER_TEMP/launched"\n' + 'echo captured > "$RUNNER_TEMP/cmux-app-host-console-capture-${CMUX_TAG:-rebuilt}.log"\n' + ), + } + for path, content in commands.items(): + path.write_text(content) + path.chmod(0o755) + captures = ["cmux-app-host-xcodebuild-selected.log", "cmux-app-host-xcodebuild-selected.meta"] + for name in captures: + (temporary / name).write_text(name) + (temporary / "unrelated.log").write_text("unrelated") + (temporary / "prebuilt-cmuxTests.xctest").mkdir() + (temporary / "cmux-app-host-xcresults").mkdir() + (temporary / "cmux-app-host-xcresults" / "receipt").write_text("result") + derived = root / "derived" + (derived / "Build/Products/Debug/cmux DEV.app/Contents/PlugIns/cmuxTests.xctest").mkdir(parents=True) + env = {**os.environ, "PATH": f"{binaries}:{os.environ['PATH']}", "HOME": str(root), + "RUNNER_TEMP": str(temporary), "CMUX_DERIVED_DATA_PATH": str(derived), + "CMUX_APP_HOST_XCTESTRUN": str(root / "test.xctestrun"), + "SELECTORS": "cmuxTests/ExampleTests", "TEST_ITERATIONS": "1"} + for name in names: + step = workflow.split(f" - name: {name}\n", 1)[1].split("\n - name:", 1)[0] + command = textwrap.dedent(step.split(" run: |\n", 1)[1]) + result = subprocess.run(["bash", "-c", command], cwd=root, env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, f"{name}: {result.stderr}") + self.assertFalse((temporary / "sudo-attempts").exists()) + self.assertEqual(len((temporary / "launched").read_text().splitlines()), 2) + out = temporary / "rerun-out" + for name in captures + ["cmux-app-host-console-capture-rebuilt.log"]: + self.assertEqual((out / name).read_text(), (temporary / name).read_text()) + self.assertTrue((out / "rebuilt" / name).is_file()) + self.assertTrue((out / "cmux-app-host-console-capture-baseline.log").is_file()) + self.assertEqual((out / "cmux-app-host-xcresults" / "receipt").read_text(), "result") + self.assertFalse((out / "unrelated.log").exists()) + self.assertEqual((temporary / "unrelated.log").read_text(), "unrelated") + def test_runs_on_a_fork_without_repository_variables(self) -> None: labels = re.findall(r"runs-on: (.*)", WORKFLOW.read_text()) self.assertEqual(len(labels), 2)