Repository navigation
UI test frames: sample XCTest screen recordings; SIGKILL stuck prompts #14956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -37,6 +37,10 @@ | |||||||||||||||||||
| REPO = "manaflow-ai/cmux" | ||||||||||||||||||||
| ARTIFACT = "test-results" | ||||||||||||||||||||
| IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".heic"} | ||||||||||||||||||||
| # Some hosts' XCTest attaches a screen recording of each failing test instead | ||||||||||||||||||||
| # of per-step screenshots; frames are sampled from it. | ||||||||||||||||||||
| VIDEO_SUFFIXES = {".mp4", ".mov"} | ||||||||||||||||||||
| VIDEO_FPS = 2 | ||||||||||||||||||||
| FRAME_WIDTH = 960 | ||||||||||||||||||||
| SHEET_COLUMNS, SHEET_ROWS = 3, 4 | ||||||||||||||||||||
| SHEET_TILE_WIDTH = 640 | ||||||||||||||||||||
|
|
@@ -108,6 +112,23 @@ def to_png(source: Path, destination: Path) -> None: | |||||||||||||||||||
| run(["sips", "-s", "format", "png", "--resampleWidth", str(FRAME_WIDTH), str(source), "--out", str(destination)]) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def video_frames(source: Path, frames: Path, start: int) -> int: | ||||||||||||||||||||
| """Sample a recording at VIDEO_FPS into frames numbered after `start`; returns how many.""" | ||||||||||||||||||||
| ffmpeg = shutil.which("ffmpeg") | ||||||||||||||||||||
| if not ffmpeg: | ||||||||||||||||||||
| return 0 | ||||||||||||||||||||
| with tempfile.TemporaryDirectory() as tmp: | ||||||||||||||||||||
| subprocess.run( | ||||||||||||||||||||
| [ffmpeg, "-loglevel", "error", "-y", "-i", str(source), | ||||||||||||||||||||
| "-vf", f"fps={VIDEO_FPS},scale={FRAME_WIDTH}:-2", str(Path(tmp) / "%04d.png")], | ||||||||||||||||||||
| check=False, | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
Comment on lines
+121
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reject partial frames when If 🧰 Tools🪛 ast-grep (0.45.3)[error] 120-124: Command coming from incoming request (subprocess-from-request) 🪛 Ruff (0.16.6)[error] 121-121: (S603) 🤖 Prompt for AI Agents |
||||||||||||||||||||
| written = sorted(Path(tmp).glob("*.png")) | ||||||||||||||||||||
| for offset, frame in enumerate(written, start=1): | ||||||||||||||||||||
| shutil.move(frame, frames / f"{start + offset:04d}-video.png") | ||||||||||||||||||||
| return len(written) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| def build_sheets(frames: Path, test_dir: Path) -> list[Path]: | ||||||||||||||||||||
| ffmpeg = shutil.which("ffmpeg") | ||||||||||||||||||||
| if not ffmpeg or not any(frames.iterdir()): | ||||||||||||||||||||
|
|
@@ -181,7 +202,7 @@ def main() -> int: | |||||||||||||||||||
| # captures kept with `.keepAlways` survive. | ||||||||||||||||||||
| summary.append({ | ||||||||||||||||||||
| "test": identifier, "result": outcome["result"], "failures": outcome["failures"], | ||||||||||||||||||||
| "frames": 0, "captures": [], "failure_frame": None, "sheets": [], "slideshow": None, "dir": None, | ||||||||||||||||||||
| "frames": 0, "captures": [], "failure_frame": None, "recordings": [], "sheets": [], "slideshow": None, "dir": None, | ||||||||||||||||||||
| }) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| for entry in manifest: | ||||||||||||||||||||
|
|
@@ -195,25 +216,53 @@ def main() -> int: | |||||||||||||||||||
| shutil.rmtree(test_dir) | ||||||||||||||||||||
| frames.mkdir(parents=True) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| images = sorted( | ||||||||||||||||||||
| media = sorted( | ||||||||||||||||||||
| (a for a in entry.get("attachments", []) | ||||||||||||||||||||
| if Path(a.get("exportedFileName", "")).suffix.lower() in IMAGE_SUFFIXES), | ||||||||||||||||||||
| key=lambda a: a.get("timestamp", 0), | ||||||||||||||||||||
| if Path(a.get("exportedFileName", "")).suffix.lower() in IMAGE_SUFFIXES | VIDEO_SUFFIXES), | ||||||||||||||||||||
| key=lambda a: a.get("timestamp") or 0, | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| captures, failure_frame = [], None | ||||||||||||||||||||
| for index, attachment in enumerate(images, start=1): | ||||||||||||||||||||
| captures, failure_frame, recordings, index = [], None, [], 0 | ||||||||||||||||||||
| clips = [] # (start timestamp, frames before it, frames written) per recording | ||||||||||||||||||||
| images = [] | ||||||||||||||||||||
| for attachment in media: | ||||||||||||||||||||
| name = attachment.get("suggestedHumanReadableName", "") | ||||||||||||||||||||
| frame = frames / f"{index:03d}-{label_for(name)}.png" | ||||||||||||||||||||
| source = exported / attachment["exportedFileName"] | ||||||||||||||||||||
| if source.suffix.lower() in VIDEO_SUFFIXES: | ||||||||||||||||||||
| recordings.append(str(source)) | ||||||||||||||||||||
| written = video_frames(source, frames, index) | ||||||||||||||||||||
| if not written: | ||||||||||||||||||||
| why = "ffmpeg failed" if shutil.which("ffmpeg") else "needs ffmpeg" | ||||||||||||||||||||
| print(f"skipped recording {source.name} ({identifier}): {why}", file=sys.stderr) | ||||||||||||||||||||
| else: | ||||||||||||||||||||
| clips.append((attachment.get("timestamp") or 0, index, written)) | ||||||||||||||||||||
| images.extend([attachment] * written) | ||||||||||||||||||||
| index += written | ||||||||||||||||||||
| continue | ||||||||||||||||||||
| index += 1 | ||||||||||||||||||||
| frame = frames / f"{index:04d}-{label_for(name)}.png" | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| to_png(exported / attachment["exportedFileName"], frame) | ||||||||||||||||||||
| to_png(source, frame) | ||||||||||||||||||||
| except subprocess.CalledProcessError: | ||||||||||||||||||||
| print(f"skipped unreadable image {attachment['exportedFileName']} ({identifier})", file=sys.stderr) | ||||||||||||||||||||
| index -= 1 | ||||||||||||||||||||
| continue | ||||||||||||||||||||
| images.append(attachment) | ||||||||||||||||||||
| if not name.startswith("Screenshot "): | ||||||||||||||||||||
| captures.append(str(frame)) | ||||||||||||||||||||
| if attachment.get("isAssociatedWithFailure") and failure_frame is None: | ||||||||||||||||||||
| failure_frame = str(frame) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # XCTest flags the failure's snapshot and logs, not the recording, and | ||||||||||||||||||||
| # stamps a recording with its start; find the failure inside the clip. | ||||||||||||||||||||
| failed_at = min((a["timestamp"] for a in entry.get("attachments", []) | ||||||||||||||||||||
| if a.get("isAssociatedWithFailure") and a.get("timestamp") is not None), | ||||||||||||||||||||
| default=None) | ||||||||||||||||||||
| if failure_frame is None and failed_at is not None: | ||||||||||||||||||||
| for start_ts, before, written in clips: | ||||||||||||||||||||
| offset = int((failed_at - start_ts) * VIDEO_FPS) + 1 | ||||||||||||||||||||
| if 1 <= offset: | ||||||||||||||||||||
| failure_frame = str(frames / f"{before + min(offset, written):04d}-video.png") | ||||||||||||||||||||
|
Comment on lines
+261
to
+264
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '195,280p' scripts/ci/e2e-frames.pyRepository: manaflow-ai/cmux Length of output: 4761 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- script definitions and imports ---'
sed -n '1,145p' scripts/ci/e2e-frames.py
printf '%s\n' '--- timing-related references ---'
rg -n --glob '!node_modules' 'VIDEO_FPS|video_frames|failed_at|clips|duration|recording|timestamp' scripts/ci README.md .github 2>/dev/null | head -200
printf '%s\n' '--- PR diff summary and changed hunks ---'
git diff --stat cfdde0b6ce757ac50af26e13ad9301242c6a3bdf a64682c7e9a0da0d1e9243e10528463e78e8d295 -- scripts/ci/e2e-frames.py
git diff --unified=25 cfdde0b6ce757ac50af26e13ad9301242c6a3bdf a64682c7e9a0da0d1e9243e10528463e78e8d295 -- scripts/ci/e2e-frames.py | sed -n '1,260p'Repository: manaflow-ai/cmux Length of output: 34486 Check the sampled span before selecting a failure frame. The loop processes Suggested fix- for start_ts, before, written in clips:
+ for start_ts, before, written in reversed(clips):
offset = int((failed_at - start_ts) * VIDEO_FPS) + 1
- if 1 <= offset:
+ if 1 <= offset <= written:
failure_frame = str(frames / f"{before + min(offset, written):04d}-video.png")
+ break📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.16.6)[warning] 263-263: Yoda condition detected Rewrite as (SIM300) 🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| outcome = results.get(identifier, {"result": "?", "failures": []}) | ||||||||||||||||||||
| summary.append({ | ||||||||||||||||||||
| "test": identifier, | ||||||||||||||||||||
|
|
@@ -222,6 +271,7 @@ def main() -> int: | |||||||||||||||||||
| "frames": len(images), | ||||||||||||||||||||
| "captures": captures, | ||||||||||||||||||||
| "failure_frame": failure_frame, | ||||||||||||||||||||
| "recordings": recordings, | ||||||||||||||||||||
| "sheets": [str(p) for p in build_sheets(frames, test_dir)], | ||||||||||||||||||||
| "slideshow": str(test_dir / "steps.mp4") if (test_dir / "steps.mp4").exists() else None, | ||||||||||||||||||||
| "dir": str(test_dir), | ||||||||||||||||||||
|
|
@@ -241,6 +291,8 @@ def main() -> int: | |||||||||||||||||||
| print(f" failure: {failure.splitlines()[0] if failure else ''}") | ||||||||||||||||||||
| if item["failure_frame"]: | ||||||||||||||||||||
| print(f" at failure: {item['failure_frame']}") | ||||||||||||||||||||
| for recording in item.get("recordings", []): | ||||||||||||||||||||
| print(f" recording: {recording}") | ||||||||||||||||||||
| for capture in item["captures"]: | ||||||||||||||||||||
| print(f" capture: {capture}") | ||||||||||||||||||||
| for sheet in item["sheets"]: | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 29244
🤖 get_repo_knowledge executed:
get_repo_knowledge manaflow-ai/cmux /tmp/coderabbit-repo-knowledge/manaflow-ai-cmux-b0f68d40/conventionsLength of output: 47496
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 42696
Do not kill every
SecurityAgentprocess in the persistent GUI session.pkill -KILL -x -u "$uid" SecurityAgentcancels every matching prompt. Owned minis keep one GUI session across jobs, so a prompt from another operation can remain in that session. Scope cleanup to dialogs owned by this job, or enforce an explicit exclusive-session contract before sendingSIGKILL.🤖 Prompt for AI Agents