Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .github/workflows/app-host-test-rerun.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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"
Expand All @@ -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
Expand Down
30 changes: 25 additions & 5 deletions scripts/ci/app_host_test_rerun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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)


Expand Down Expand Up @@ -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],
Expand Down
73 changes: 70 additions & 3 deletions tests/test_app_host_test_rerun.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import subprocess
import sys
import tempfile
import textwrap
import unittest
import unittest.mock
from pathlib import Path
Expand Down Expand Up @@ -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 (
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading