From 3c7008a4310e63d7043377726aa60922073dfb2e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 17 Oct 2025 06:30:52 +0300 Subject: [PATCH] Refine mobile CI triggers and modularize Android scripts --- .github/workflows/scripts-android.yml | 39 +- .github/workflows/scripts-ios.yml | 32 +- scripts/README.md | 32 ++ scripts/android/tests/post_pr_comment.py | 316 +++++++++++ .../android/tests/render_screenshot_report.py | 257 +++++++++ scripts/run-android-instrumentation-tests.sh | 516 +----------------- 6 files changed, 675 insertions(+), 517 deletions(-) create mode 100644 scripts/README.md create mode 100644 scripts/android/tests/post_pr_comment.py create mode 100644 scripts/android/tests/render_screenshot_report.py diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index d8beb91aa4..e0bf575f56 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -4,13 +4,44 @@ name: Test Android build scripts 'on': pull_request: paths: - - 'scripts/**' - - 'BUILDING.md' + - '.github/workflows/scripts-android.yml' + - 'scripts/setup-workspace.sh' + - 'scripts/build-android-port.sh' + - 'scripts/build-android-app.sh' + - 'scripts/run-android-instrumentation-tests.sh' + - 'scripts/android/lib/**/*.py' + - 'scripts/android/tests/**/*.py' + - 'scripts/android/screenshots/**' + - '!scripts/android/screenshots/**/*.md' + - 'scripts/templates/**' + - '!scripts/templates/**/*.md' + - 'CodenameOne/src/**' + - '!CodenameOne/src/**/*.md' + - 'Ports/Android/**' + - '!Ports/Android/**/*.md' + - 'tests/**' + - '!tests/**/*.md' push: branches: - master - paths-ignore: - - '**/*.md' + paths: + - '.github/workflows/scripts-android.yml' + - 'scripts/setup-workspace.sh' + - 'scripts/build-android-port.sh' + - 'scripts/build-android-app.sh' + - 'scripts/run-android-instrumentation-tests.sh' + - 'scripts/android/lib/**/*.py' + - 'scripts/android/tests/**/*.py' + - 'scripts/android/screenshots/**' + - '!scripts/android/screenshots/**/*.md' + - 'scripts/templates/**' + - '!scripts/templates/**/*.md' + - 'CodenameOne/src/**' + - '!CodenameOne/src/**/*.md' + - 'Ports/Android/**' + - '!Ports/Android/**/*.md' + - 'tests/**' + - '!tests/**/*.md' jobs: build-android: diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index 84bc7f3abc..d4c0795cc5 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -3,13 +3,37 @@ name: Test iOS build scripts on: pull_request: paths: - - 'scripts/**' - '.github/workflows/scripts-ios.yml' - - 'BUILDING.md' + - 'scripts/setup-workspace.sh' + - 'scripts/build-ios-port.sh' + - 'scripts/build-ios-app.sh' + - 'scripts/templates/**' + - '!scripts/templates/**/*.md' + - 'CodenameOne/src/**' + - '!CodenameOne/src/**/*.md' + - 'Ports/iOSPort/**' + - '!Ports/iOSPort/**/*.md' + - 'vm/**' + - '!vm/**/*.md' + - 'tests/**' + - '!tests/**/*.md' push: branches: [ master ] - paths-ignore: - - '**/*.md' + paths: + - '.github/workflows/scripts-ios.yml' + - 'scripts/setup-workspace.sh' + - 'scripts/build-ios-port.sh' + - 'scripts/build-ios-app.sh' + - 'scripts/templates/**' + - '!scripts/templates/**/*.md' + - 'CodenameOne/src/**' + - '!CodenameOne/src/**/*.md' + - 'Ports/iOSPort/**' + - '!Ports/iOSPort/**/*.md' + - 'vm/**' + - '!vm/**/*.md' + - 'tests/**' + - '!tests/**/*.md' jobs: build-ios: diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000000..4427b304b9 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,32 @@ +# Codename One build scripts + +This directory houses helper scripts used by local contributors and the CI +workflows to build and validate Codename One ports. + +## Top-level shell scripts + +- `setup-workspace.sh` – provisions the JDKs, Maven installation, and other + tooling required to build Codename One locally or inside CI. +- `build-android-port.sh` / `build-ios-port.sh` – compile the Android and iOS + native ports from source. +- `build-android-app.sh` / `build-ios-app.sh` – generate a "Hello Codename One" + sample application and build it against the freshly compiled port. +- `run-android-instrumentation-tests.sh` – launches the Android emulator, + executes instrumentation tests, and prepares screenshot reports for pull + requests. + +## Subdirectories + +- `android/` – Python helpers, baseline screenshots, and utilities that power + the Android instrumentation test workflow. + - `android/lib/` – library-style Python modules shared across Android + automation scripts. + - `android/tests/` – command-line tools used by CI for processing screenshots + and posting feedback to pull requests. + - `android/screenshots/` – reference images used when comparing emulator + output. +- `templates/` – code templates consumed by the sample app builders. + +These scripts are designed so that shell logic focuses on orchestration, while +Python modules encapsulate the heavier data processing steps. This separation +keeps the entry points easy to follow and simplifies maintenance. diff --git a/scripts/android/tests/post_pr_comment.py b/scripts/android/tests/post_pr_comment.py new file mode 100644 index 0000000000..4e90685673 --- /dev/null +++ b/scripts/android/tests/post_pr_comment.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Publish screenshot comparison feedback as a pull request comment.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +from typing import Dict, List, Optional +from urllib.request import Request, urlopen + +MARKER = "" +LOG_PREFIX = "[run-android-instrumentation-tests]" + + +def log(message: str) -> None: + print(f"{LOG_PREFIX} {message}", file=sys.stdout) + + +def err(message: str) -> None: + print(f"{LOG_PREFIX} {message}", file=sys.stderr) + + +def load_event(path: pathlib.Path) -> Dict[str, object]: + return json.loads(path.read_text(encoding="utf-8")) + + +def find_pr_number(event: Dict[str, object]) -> Optional[int]: + if "pull_request" in event: + pr_data = event.get("pull_request") + if isinstance(pr_data, dict): + number = pr_data.get("number") + if isinstance(number, int): + return number + issue = event.get("issue") + if isinstance(issue, dict) and issue.get("pull_request"): + number = issue.get("number") + if isinstance(number, int): + return number + return None + + +def next_link(header: Optional[str]) -> Optional[str]: + if not header: + return None + for part in header.split(","): + segment = part.strip() + if segment.endswith('rel="next"'): + url_part = segment.split(";", 1)[0].strip() + if url_part.startswith("<") and url_part.endswith(">"): + return url_part[1:-1] + return None + + +def publish_previews_to_branch( + preview_dir: Optional[pathlib.Path], + repo: str, + pr_number: int, + token: str, + allow_push: bool, +) -> Dict[str, str]: + """Publish preview images to the cn1ss-previews branch and return name->URL.""" + + if not preview_dir or not preview_dir.exists(): + return {} + + image_files = [ + path + for path in sorted(preview_dir.iterdir()) + if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg", ".png"} + ] + if not image_files: + return {} + if not allow_push: + log("Preview publishing skipped for forked PR") + return {} + if not repo or not token: + return {} + + workspace = pathlib.Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() + worktree = workspace / f".cn1ss-previews-pr-{pr_number}" + if worktree.exists(): + shutil.rmtree(worktree) + worktree.mkdir(parents=True, exist_ok=True) + + try: + env = os.environ.copy() + env.setdefault("GIT_TERMINAL_PROMPT", "0") + + def run_git(args: List[str], check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["git", *args], + cwd=worktree, + env=env, + capture_output=True, + text=True, + ) + if check and result.returncode != 0: + raise RuntimeError( + f"git {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}" + ) + return result + + run_git(["init"]) + actor = os.environ.get("GITHUB_ACTOR", "github-actions") or "github-actions" + run_git(["config", "user.name", actor]) + run_git(["config", "user.email", "github-actions@users.noreply.github.com"]) + remote_url = f"https://x-access-token:{token}@github.com/{repo}.git" + run_git(["remote", "add", "origin", remote_url]) + + has_branch = run_git(["ls-remote", "--heads", "origin", "cn1ss-previews"], check=False) + if has_branch.returncode == 0 and has_branch.stdout.strip(): + run_git(["fetch", "origin", "cn1ss-previews"]) + run_git(["checkout", "cn1ss-previews"]) + else: + run_git(["checkout", "--orphan", "cn1ss-previews"]) + + dest = worktree / f"pr-{pr_number}" + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True, exist_ok=True) + + for source in image_files: + shutil.copy2(source, dest / source.name) + + run_git(["add", "-A", "."]) + status = run_git(["status", "--porcelain"]) + if status.stdout.strip(): + run_git(["commit", "-m", f"Add previews for PR #{pr_number}"]) + push = run_git(["push", "origin", "HEAD:cn1ss-previews"], check=False) + if push.returncode != 0: + raise RuntimeError(push.stderr.strip() or push.stdout.strip()) + log(f"Published {len(image_files)} preview(s) to cn1ss-previews/pr-{pr_number}") + else: + log(f"Preview branch already up-to-date for PR #{pr_number}") + + raw_base = f"https://raw.githubusercontent.com/{repo}/cn1ss-previews/pr-{pr_number}" + urls: Dict[str, str] = {} + if dest.exists(): + for file in sorted(dest.iterdir()): + if file.is_file(): + urls[file.name] = f"{raw_base}/{file.name}" + return urls + finally: + shutil.rmtree(worktree, ignore_errors=True) + + +def replace_attachments(body: str, urls: Dict[str, str]) -> tuple[str, List[str]]: + attachment_pattern = re.compile(r"\(attachment:([^)]+)\)") + missing: List[str] = [] + + def repl(match: re.Match[str]) -> str: + name = match.group(1) + url = urls.get(name) + if url: + return f"({url})" + missing.append(name) + log(f"Preview URL missing for {name}; leaving placeholder") + return "(#)" + + return attachment_pattern.sub(repl, body), missing + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--body", required=True, help="Path to markdown body to publish") + parser.add_argument( + "--preview-dir", + help="Directory containing preview images referenced by attachment placeholders", + ) + args = parser.parse_args() + + body_path = pathlib.Path(args.body) + if not body_path.is_file(): + return 0 + + raw_body = body_path.read_text(encoding="utf-8") + body = raw_body.strip() + if not body: + return 0 + + if MARKER not in body: + body = body.rstrip() + "\n\n" + MARKER + + body_without_marker = body.replace(MARKER, "").strip() + if not body_without_marker: + return 0 + + event_path_env = os.environ.get("GITHUB_EVENT_PATH") + repo = os.environ.get("GITHUB_REPOSITORY") + token = os.environ.get("GITHUB_TOKEN") + if not event_path_env or not repo or not token: + return 0 + + event_path = pathlib.Path(event_path_env) + if not event_path.is_file(): + return 0 + + event = load_event(event_path) + pr_number = find_pr_number(event) + if not pr_number: + return 0 + + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + } + + pr_data = event.get("pull_request") + is_fork_pr = False + if isinstance(pr_data, dict): + head = pr_data.get("head") + if isinstance(head, dict): + head_repo = head.get("repo") + if isinstance(head_repo, dict): + is_fork_pr = bool(head_repo.get("fork")) + + comments_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments?per_page=100" + existing_comment: Optional[Dict[str, object]] = None + preferred_comment: Optional[Dict[str, object]] = None + actor = os.environ.get("GITHUB_ACTOR") + preferred_logins = {login for login in (actor, "github-actions[bot]") if login} + + while comments_url: + req = Request(comments_url, headers=headers) + with urlopen(req) as resp: + comments = json.load(resp) + for comment in comments: + body_text = comment.get("body") or "" + if MARKER in body_text: + existing_comment = comment + login = comment.get("user", {}).get("login") + if login in preferred_logins: + preferred_comment = comment + comments_url = next_link(resp.headers.get("Link")) + + if preferred_comment is not None: + existing_comment = preferred_comment + + comment_id: Optional[int] = None + created_placeholder = False + + if existing_comment is not None: + cid = existing_comment.get("id") + if isinstance(cid, int): + comment_id = cid + else: + create_payload = json.dumps({"body": MARKER}).encode("utf-8") + create_req = Request( + f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments", + data=create_payload, + headers=headers, + method="POST", + ) + with urlopen(create_req) as resp: + created = json.load(resp) + cid = created.get("id") + if isinstance(cid, int): + comment_id = cid + created_placeholder = comment_id is not None + if created_placeholder: + log(f"Created new screenshot comment placeholder (id={comment_id})") + + if comment_id is None: + return 1 + + preview_dir = pathlib.Path(args.preview_dir).resolve() if args.preview_dir else None + attachment_urls: Dict[str, str] = {} + if "(attachment:" in body: + try: + attachment_urls = publish_previews_to_branch( + preview_dir, + repo, + pr_number, + token, + allow_push=not is_fork_pr, + ) + for name, url in attachment_urls.items(): + log(f"Preview available for {name}: {url}") + except Exception as exc: # pragma: no cover - defensive logging + err(f"Preview publishing failed: {exc}") + return 1 + + final_body, missing = replace_attachments(body, attachment_urls) + if missing and not is_fork_pr: + err(f"Failed to resolve preview URLs for: {', '.join(sorted(set(missing)))}") + return 1 + if missing and is_fork_pr: + log("Preview URLs unavailable in forked PR context; placeholders left as-is") + + update_payload = json.dumps({"body": final_body}).encode("utf-8") + update_req = Request( + f"https://api.github.com/repos/{repo}/issues/comments/{comment_id}", + data=update_payload, + headers=headers, + method="PATCH", + ) + + with urlopen(update_req) as resp: + resp.read() + action = "updated" + if created_placeholder: + action = "posted" + log(f"PR comment {action} (status={resp.status}, bytes={len(update_payload)})") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/android/tests/render_screenshot_report.py b/scripts/android/tests/render_screenshot_report.py new file mode 100644 index 0000000000..cd032846ea --- /dev/null +++ b/scripts/android/tests/render_screenshot_report.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Render screenshot comparison summaries and PR comment content. + +This module transforms the JSON output produced by ``process_screenshots.py`` +into a short summary file (used for logs/artifacts) and a Markdown document +that can be posted back to a pull request. It mirrors the logic that used to +live inline inside ``run-android-instrumentation-tests.sh``. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +from typing import Any, Dict, List + +MARKER = "" + + +def build_summary_and_comment(data: Dict[str, Any]) -> tuple[List[str], List[str]]: + summary_lines: List[str] = [] + comment_entries: List[Dict[str, Any]] = [] + + for result in data.get("results", []): + test = result.get("test", "unknown") + status = result.get("status", "unknown") + expected_path = result.get("expected_path") + actual_path = result.get("actual_path", "") + details = result.get("details") or {} + base64_data = result.get("base64") + base64_omitted = result.get("base64_omitted") + base64_length = result.get("base64_length") + base64_mime = result.get("base64_mime") or "image/png" + base64_codec = result.get("base64_codec") + base64_quality = result.get("base64_quality") + base64_note = result.get("base64_note") + message = "" + copy_flag = "0" + + preview = result.get("preview") or {} + preview_name = preview.get("name") + preview_path = preview.get("path") + preview_mime = preview.get("mime") + preview_note = preview.get("note") + preview_quality = preview.get("quality") + + if status == "equal": + message = "Matches stored reference." + elif status == "missing_expected": + message = f"Reference screenshot missing at {expected_path}." + copy_flag = "1" + comment_entries.append( + { + "test": test, + "status": "missing reference", + "message": message, + "artifact_name": f"{test}.png", + "preview_name": preview_name, + "preview_path": preview_path, + "preview_mime": preview_mime, + "preview_note": preview_note, + "preview_quality": preview_quality, + "base64": base64_data, + "base64_omitted": base64_omitted, + "base64_length": base64_length, + "base64_mime": base64_mime, + "base64_codec": base64_codec, + "base64_quality": base64_quality, + "base64_note": base64_note, + } + ) + elif status == "different": + dims = "" + if details: + dims = ( + f" ({details.get('width')}x{details.get('height')} px, " + f"bit depth {details.get('bit_depth')})" + ) + message = f"Screenshot differs{dims}." + copy_flag = "1" + comment_entries.append( + { + "test": test, + "status": "updated screenshot", + "message": message, + "artifact_name": f"{test}.png", + "preview_name": preview_name, + "preview_path": preview_path, + "preview_mime": preview_mime, + "preview_note": preview_note, + "preview_quality": preview_quality, + "base64": base64_data, + "base64_omitted": base64_omitted, + "base64_length": base64_length, + "base64_mime": base64_mime, + "base64_codec": base64_codec, + "base64_quality": base64_quality, + "base64_note": base64_note, + } + ) + elif status == "error": + message = f"Comparison error: {result.get('message', 'unknown error')}" + copy_flag = "1" + comment_entries.append( + { + "test": test, + "status": "comparison error", + "message": message, + "artifact_name": f"{test}.png", + "preview_name": preview_name, + "preview_path": preview_path, + "preview_mime": preview_mime, + "preview_note": preview_note, + "preview_quality": preview_quality, + "base64": None, + "base64_omitted": base64_omitted, + "base64_length": base64_length, + "base64_mime": base64_mime, + "base64_codec": base64_codec, + "base64_quality": base64_quality, + "base64_note": base64_note, + } + ) + elif status == "missing_actual": + message = "Actual screenshot missing (test did not produce output)." + copy_flag = "1" + comment_entries.append( + { + "test": test, + "status": "missing actual screenshot", + "message": message, + "artifact_name": None, + "preview_name": preview_name, + "preview_path": preview_path, + "preview_mime": preview_mime, + "preview_note": preview_note, + "preview_quality": preview_quality, + "base64": None, + "base64_omitted": base64_omitted, + "base64_length": base64_length, + "base64_mime": base64_mime, + "base64_codec": base64_codec, + "base64_quality": base64_quality, + "base64_note": base64_note, + } + ) + else: + message = f"Status: {status}." + + note_column = preview_note or base64_note or "" + summary_lines.append("|".join([status, test, message, copy_flag, actual_path, note_column])) + + comment_lines: List[str] = [] + if comment_entries: + comment_lines.extend(["### Android screenshot updates", ""]) + + def add_line(text: str = "") -> None: + comment_lines.append(text) + + for entry in comment_entries: + entry_header = f"- **{entry['test']}** — {entry['status']}. {entry['message']}" + add_line(entry_header) + + preview_name = entry.get("preview_name") + preview_quality = entry.get("preview_quality") + preview_note = entry.get("preview_note") + base64_note = entry.get("base64_note") + preview_mime = entry.get("preview_mime") + + preview_notes: List[str] = [] + if preview_mime == "image/jpeg" and preview_quality: + preview_notes.append(f"JPEG preview quality {preview_quality}") + if preview_note: + preview_notes.append(str(preview_note)) + if base64_note and base64_note != preview_note: + preview_notes.append(str(base64_note)) + + if preview_name: + add_line("") + add_line(f" ![{entry['test']}](attachment:{preview_name})") + if preview_notes: + add_line(f" _Preview info: {'; '.join(preview_notes)}._") + elif entry.get("base64"): + add_line("") + add_line( + " _Preview generated but could not be published; see workflow artifacts for JPEG preview._" + ) + if preview_notes: + add_line(f" _Preview info: {'; '.join(preview_notes)}._") + elif entry.get("base64_omitted") == "too_large": + size_note = "" + if entry.get("base64_length"): + size_note = f" (base64 length ≈ {entry['base64_length']:,} chars)" + codec = entry.get("base64_codec") + quality = entry.get("base64_quality") + note = entry.get("base64_note") + extra_bits: List[str] = [] + if codec == "jpeg" and quality: + extra_bits.append(f"attempted JPEG quality {quality}") + if note: + extra_bits.append(str(note)) + tail = f" ({'; '.join(extra_bits)})" if extra_bits else "" + add_line("") + add_line( + " _Screenshot omitted from comment because the encoded payload exceeded GitHub's size limits" + + size_note + + "." + + tail + + "_" + ) + else: + add_line("") + add_line(" _No preview available for this screenshot._") + + artifact_name = entry.get("artifact_name") + if artifact_name: + add_line(f" _Full-resolution PNG saved as `{artifact_name}` in workflow artifacts._") + add_line("") + + if comment_lines and comment_lines[-1] != "": + comment_lines.append("") + comment_lines.append(MARKER) + else: + comment_lines = ["✅ Native Android screenshot tests passed.", "", MARKER] + + return summary_lines, comment_lines + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--compare-json", required=True, help="Path to screenshot comparison JSON output") + parser.add_argument("--comment-out", required=True, help="Destination Markdown file for PR comment") + parser.add_argument("--summary-out", required=True, help="Destination summary file") + args = parser.parse_args() + + compare_path = pathlib.Path(args.compare_json) + comment_path = pathlib.Path(args.comment_out) + summary_path = pathlib.Path(args.summary_out) + + if not compare_path.is_file(): + raise SystemExit(f"Comparison JSON not found: {compare_path}") + + data = json.loads(compare_path.read_text(encoding="utf-8")) + summary_lines, comment_lines = build_summary_and_comment(data) + + summary_text = "\n".join(summary_lines) + if summary_text: + summary_text += "\n" + summary_path.write_text(summary_text, encoding="utf-8") + + comment_text = "\n".join(line.rstrip() for line in comment_lines).rstrip() + "\n" + comment_path.write_text(comment_text, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh index 03c0ddd072..bd76d1bd79 100755 --- a/scripts/run-android-instrumentation-tests.sh +++ b/scripts/run-android-instrumentation-tests.sh @@ -111,310 +111,9 @@ post_pr_comment() { local body_size body_size=$(wc -c < "$body_file" 2>/dev/null || echo 0) ra_log "Attempting to post PR comment (payload bytes=${body_size})" - GITHUB_TOKEN="$comment_token" python3 - "$body_file" "$preview_dir" <<'PY' -import json -import os -import pathlib -import re -import shutil -import subprocess -import sys -from typing import Dict, List, Match, Optional -from urllib.request import Request, urlopen - -MARKER = "" - - -def load_event(path: str) -> Dict[str, object]: - with open(path, "r", encoding="utf-8") as fh: - return json.load(fh) - - -def find_pr_number(event: Dict[str, object]) -> Optional[int]: - if "pull_request" in event: - return event["pull_request"].get("number") - issue = event.get("issue") - if isinstance(issue, dict) and issue.get("pull_request"): - return issue.get("number") - return None - - -def next_link(header: Optional[str]) -> Optional[str]: - if not header: - return None - for part in header.split(","): - segment = part.strip() - if segment.endswith('rel="next"'): - url_part = segment.split(";", 1)[0].strip() - if url_part.startswith("<") and url_part.endswith(">"): - return url_part[1:-1] - return None - - -def publish_previews_to_branch( - preview_dir: Optional[pathlib.Path], - repo: str, - pr_number: int, - token: Optional[str], - allow_push: bool, -) -> Dict[str, str]: - """Publish preview images to the cn1ss-previews branch and return name->URL.""" - - if not preview_dir or not preview_dir.exists(): - return {} - image_files = [ - path - for path in sorted(preview_dir.iterdir()) - if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg", ".png"} - ] - if not image_files: - return {} - if not allow_push: - print( - "[run-android-instrumentation-tests] Preview publishing skipped for forked PR", # noqa: E501 - file=sys.stdout, - ) - return {} - if not repo or not token: - return {} - - workspace = pathlib.Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() - worktree = workspace / f".cn1ss-previews-pr-{pr_number}" - if worktree.exists(): - shutil.rmtree(worktree) - worktree.mkdir(parents=True, exist_ok=True) - - try: - env = os.environ.copy() - env.setdefault("GIT_TERMINAL_PROMPT", "0") - - def run_git(args, check: bool = True): - result = subprocess.run( - ["git", *args], - cwd=worktree, - env=env, - capture_output=True, - text=True, - ) - if check and result.returncode != 0: - raise RuntimeError( - f"git {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}" - ) - return result - - run_git(["init"]) - run_git(["config", "user.name", os.environ.get("GITHUB_ACTOR", "github-actions") or "github-actions"]) - run_git(["config", "user.email", "github-actions@users.noreply.github.com"]) - remote_url = f"https://x-access-token:{token}@github.com/{repo}.git" - run_git(["remote", "add", "origin", remote_url]) - - has_branch = run_git(["ls-remote", "--heads", "origin", "cn1ss-previews"], check=False) - if has_branch.returncode == 0 and has_branch.stdout.strip(): - run_git(["fetch", "origin", "cn1ss-previews"]) - run_git(["checkout", "cn1ss-previews"]) - else: - run_git(["checkout", "--orphan", "cn1ss-previews"]) - - dest = worktree / f"pr-{pr_number}" - if dest.exists(): - shutil.rmtree(dest) - dest.mkdir(parents=True, exist_ok=True) - - for source in image_files: - shutil.copy2(source, dest / source.name) - - run_git(["add", "-A", "."]) - status = run_git(["status", "--porcelain"]) - if status.stdout.strip(): - run_git(["commit", "-m", f"Add previews for PR #{pr_number}"]) - push = run_git(["push", "origin", "HEAD:cn1ss-previews"], check=False) - if push.returncode != 0: - raise RuntimeError(f"git push failed: {push.stderr.strip() or push.stdout.strip()}") - print( - f"[run-android-instrumentation-tests] Published {len(image_files)} preview(s) to cn1ss-previews/pr-{pr_number}", - file=sys.stdout, - ) - else: - print( - f"[run-android-instrumentation-tests] Preview branch already up-to-date for PR #{pr_number}", - file=sys.stdout, - ) - - raw_base = f"https://raw.githubusercontent.com/{repo}/cn1ss-previews/pr-{pr_number}" - urls: Dict[str, str] = {} - if dest.exists(): - for file in sorted(dest.iterdir()): - if file.is_file(): - urls[file.name] = f"{raw_base}/{file.name}" - return urls - finally: - shutil.rmtree(worktree, ignore_errors=True) - - -body_path = pathlib.Path(sys.argv[1]) -preview_dir_arg: Optional[pathlib.Path] = None -if len(sys.argv) > 2: - candidate = pathlib.Path(sys.argv[2]) - if candidate.exists(): - preview_dir_arg = candidate -raw_body = body_path.read_text(encoding="utf-8") -body = raw_body.strip() -if not body: - sys.exit(0) - -if MARKER not in body: - body = body.rstrip() + "\n\n" + MARKER - -body_without_marker = body.replace(MARKER, "").strip() -if not body_without_marker: - sys.exit(0) - -event_path = os.environ.get("GITHUB_EVENT_PATH") -repo = os.environ.get("GITHUB_REPOSITORY") -token = os.environ.get("GITHUB_TOKEN") -actor = os.environ.get("GITHUB_ACTOR") -if not event_path or not repo or not token: - sys.exit(0) - -event = load_event(event_path) -pr_number = find_pr_number(event) -if not pr_number: - sys.exit(0) - -headers = { - "Authorization": f"token {token}", - "Accept": "application/vnd.github+json", - "Content-Type": "application/json", -} - -pr_data = event.get("pull_request") -is_fork_pr = False -if isinstance(pr_data, dict): - head = pr_data.get("head") - if isinstance(head, dict): - head_repo = head.get("repo") - if isinstance(head_repo, dict): - is_fork_pr = bool(head_repo.get("fork")) - -comments_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments?per_page=100" -existing_comment: Optional[Dict[str, object]] = None -preferred_comment: Optional[Dict[str, object]] = None -preferred_logins = set() -if actor: - preferred_logins.add(actor) -preferred_logins.add("github-actions[bot]") - -while comments_url: - req = Request(comments_url, headers=headers) - with urlopen(req) as resp: - comments = json.load(resp) - for comment in comments: - body_text = comment.get("body") or "" - if MARKER in body_text: - existing_comment = comment - login = comment.get("user", {}).get("login") - if login in preferred_logins: - preferred_comment = comment - comments_url = next_link(resp.headers.get("Link")) - -comment_id: Optional[int] = None -created_placeholder = False - -if preferred_comment is not None: - existing_comment = preferred_comment - -if existing_comment is not None: - comment_id = existing_comment.get("id") -else: - create_payload = json.dumps({"body": MARKER}).encode("utf-8") - create_req = Request( - f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments", - data=create_payload, - headers=headers, - method="POST", - ) - with urlopen(create_req) as resp: - created = json.load(resp) - comment_id = created.get("id") - created_placeholder = True - print( - f"[run-android-instrumentation-tests] Created new screenshot comment placeholder (id={comment_id})", - file=sys.stdout, - ) - -if comment_id is None: - sys.exit(1) - -attachment_pattern = re.compile(r"\(attachment:([^)]+)\)") -attachment_urls: Dict[str, str] = {} -missing_previews: List[str] = [] -if attachment_pattern.search(body): - try: - attachment_urls = publish_previews_to_branch( - preview_dir_arg, - repo, - pr_number, - token, - allow_push=not is_fork_pr, - ) - if attachment_urls: - for name, url in attachment_urls.items(): - print( - f"[run-android-instrumentation-tests] Preview available for {name}: {url}", - file=sys.stdout, - ) - except Exception as exc: - print( - f"[run-android-instrumentation-tests] Preview publishing failed: {exc}", - file=sys.stderr, - ) - sys.exit(1) - - -def replace_attachment(match: re.Match[str]) -> str: - name = match.group(1) - url = attachment_urls.get(name) - if url: - return f"({url})" - print( - f"[run-android-instrumentation-tests] Preview URL missing for {name}; leaving placeholder", - file=sys.stdout, - ) - missing_previews.append(name) - return "(#)" - - -final_body = attachment_pattern.sub(replace_attachment, body) - -if missing_previews: - if is_fork_pr: - print( - "[run-android-instrumentation-tests] Preview URLs unavailable in forked PR context; placeholders left as-is", - file=sys.stdout, - ) - else: - print( - f"[run-android-instrumentation-tests] Failed to resolve preview URLs for: {', '.join(sorted(set(missing_previews)))}", - file=sys.stderr, - ) - sys.exit(1) - -update_payload = json.dumps({"body": final_body}).encode("utf-8") -update_req = Request( - f"https://api.github.com/repos/{repo}/issues/comments/{comment_id}", - data=update_payload, - headers=headers, - method="PATCH", -) - -with urlopen(update_req) as resp: - resp.read() - action = "updated" if not created_placeholder else "posted" - print( - f"[run-android-instrumentation-tests] PR comment {action} (status={resp.status}, bytes={len(update_payload)})", - file=sys.stdout, - ) -PY + GITHUB_TOKEN="$comment_token" python3 "$SCRIPT_DIR/android/tests/post_pr_comment.py" \ + --body "$body_file" \ + --preview-dir "$preview_dir" local rc=$? if [ $rc -eq 0 ]; then ra_log "Posted screenshot comparison comment to PR" @@ -739,211 +438,10 @@ SUMMARY_FILE="$SCREENSHOT_TMP_DIR/screenshot-summary.txt" COMMENT_FILE="$SCREENSHOT_TMP_DIR/screenshot-comment.md" ra_log "STAGE:COMMENT_BUILD -> Rendering summary and PR comment markdown" -python3 - "$COMPARE_JSON" "$COMMENT_FILE" "$SUMMARY_FILE" <<'PY' -import json -import pathlib -import sys - -compare_path = pathlib.Path(sys.argv[1]) -comment_path = pathlib.Path(sys.argv[2]) -summary_path = pathlib.Path(sys.argv[3]) - -data = json.loads(compare_path.read_text(encoding="utf-8")) -summary_lines = [] -comment_entries = [] - -for result in data.get("results", []): - test = result.get("test", "unknown") - status = result.get("status", "unknown") - expected_path = result.get("expected_path") - actual_path = result.get("actual_path", "") - details = result.get("details") or {} - base64_data = result.get("base64") - base64_omitted = result.get("base64_omitted") - base64_length = result.get("base64_length") - base64_mime = result.get("base64_mime") or "image/png" - base64_codec = result.get("base64_codec") - base64_quality = result.get("base64_quality") - base64_note = result.get("base64_note") - message = "" - copy_flag = "0" - - preview = result.get("preview") or {} - preview_name = preview.get("name") - preview_path = preview.get("path") - preview_mime = preview.get("mime") - preview_note = preview.get("note") - preview_quality = preview.get("quality") - if status == "equal": - message = "Matches stored reference." - elif status == "missing_expected": - message = f"Reference screenshot missing at {expected_path}." - copy_flag = "1" - comment_entries.append({ - "test": test, - "status": "missing reference", - "message": message, - "artifact_name": f"{test}.png", - "preview_name": preview_name, - "preview_path": preview_path, - "preview_mime": preview_mime, - "preview_note": preview_note, - "preview_quality": preview_quality, - "base64": base64_data, - "base64_omitted": base64_omitted, - "base64_length": base64_length, - "base64_mime": base64_mime, - "base64_codec": base64_codec, - "base64_quality": base64_quality, - "base64_note": base64_note, - }) - elif status == "different": - dims = "" - if details: - dims = f" ({details.get('width')}x{details.get('height')} px, bit depth {details.get('bit_depth')})" - message = f"Screenshot differs{dims}." - copy_flag = "1" - comment_entries.append({ - "test": test, - "status": "updated screenshot", - "message": message, - "artifact_name": f"{test}.png", - "preview_name": preview_name, - "preview_path": preview_path, - "preview_mime": preview_mime, - "preview_note": preview_note, - "preview_quality": preview_quality, - "base64": base64_data, - "base64_omitted": base64_omitted, - "base64_length": base64_length, - "base64_mime": base64_mime, - "base64_codec": base64_codec, - "base64_quality": base64_quality, - "base64_note": base64_note, - }) - elif status == "error": - message = f"Comparison error: {result.get('message', 'unknown error')}" - copy_flag = "1" - comment_entries.append({ - "test": test, - "status": "comparison error", - "message": message, - "artifact_name": f"{test}.png", - "preview_name": preview_name, - "preview_path": preview_path, - "preview_mime": preview_mime, - "preview_note": preview_note, - "preview_quality": preview_quality, - "base64": None, - "base64_omitted": base64_omitted, - "base64_length": base64_length, - "base64_mime": base64_mime, - "base64_codec": base64_codec, - "base64_quality": base64_quality, - "base64_note": base64_note, - }) - elif status == "missing_actual": - message = "Actual screenshot missing (test did not produce output)." - copy_flag = "1" - comment_entries.append({ - "test": test, - "status": "missing actual screenshot", - "message": message, - "artifact_name": None, - "preview_name": preview_name, - "preview_path": preview_path, - "preview_mime": preview_mime, - "preview_note": preview_note, - "preview_quality": preview_quality, - "base64": None, - "base64_omitted": base64_omitted, - "base64_length": base64_length, - "base64_mime": base64_mime, - "base64_codec": base64_codec, - "base64_quality": base64_quality, - "base64_note": base64_note, - }) - else: - message = f"Status: {status}." - - note_column = preview_note or base64_note or "" - summary_lines.append("|".join([status, test, message, copy_flag, actual_path, note_column])) - -summary_path.write_text("\n".join(summary_lines) + ("\n" if summary_lines else ""), encoding="utf-8") - -if comment_entries: - lines = ["### Android screenshot updates", ""] - - def add_line(text: str = "") -> None: - lines.append(text) - - for entry in comment_entries: - entry_header = f"- **{entry['test']}** — {entry['status']}. {entry['message']}" - add_line(entry_header) - preview_name = entry.get("preview_name") - preview_quality = entry.get("preview_quality") - preview_note = entry.get("preview_note") - base64_note = entry.get("base64_note") - preview_mime = entry.get("preview_mime") - - preview_notes = [] - if preview_mime == "image/jpeg" and preview_quality: - preview_notes.append(f"JPEG preview quality {preview_quality}") - if preview_note: - preview_notes.append(preview_note) - if base64_note and base64_note != preview_note: - preview_notes.append(base64_note) - - if preview_name: - add_line("") - add_line(f" ![{entry['test']}](attachment:{preview_name})") - if preview_notes: - add_line(f" _Preview info: {'; '.join(preview_notes)}._") - elif entry.get("base64"): - add_line("") - add_line( - " _Preview generated but could not be published; see workflow artifacts for JPEG preview._" - ) - if preview_notes: - add_line(f" _Preview info: {'; '.join(preview_notes)}._") - elif entry.get("base64_omitted") == "too_large": - size_note = "" - if entry.get("base64_length"): - size_note = f" (base64 length ≈ {entry['base64_length']:,} chars)" - add_line("") - codec = entry.get("base64_codec") - quality = entry.get("base64_quality") - note = entry.get("base64_note") - extra_bits = [] - if codec == "jpeg" and quality: - extra_bits.append(f"attempted JPEG quality {quality}") - if note: - extra_bits.append(note) - tail = "" - if extra_bits: - tail = " (" + "; ".join(extra_bits) + ")" - add_line( - " _Screenshot omitted from comment because the encoded payload exceeded GitHub's size limits" - + size_note - + "." + tail + "_" - ) - else: - add_line("") - add_line(" _No preview available for this screenshot._") - artifact_name = entry.get("artifact_name") - if artifact_name: - add_line(f" _Full-resolution PNG saved as `{artifact_name}` in workflow artifacts._") - add_line("") - MARKER = "" - if lines[-1] != "": - lines.append("") - lines.append(MARKER) - comment_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") -else: - MARKER = "" - passed = "✅ Native Android screenshot tests passed." - comment_path.write_text(passed + "\n\n" + MARKER + "\n", encoding="utf-8") -PY +python3 "$SCRIPT_DIR/android/tests/render_screenshot_report.py" \ + --compare-json "$COMPARE_JSON" \ + --comment-out "$COMMENT_FILE" \ + --summary-out "$SUMMARY_FILE" if [ -s "$SUMMARY_FILE" ]; then ra_log " -> Wrote summary entries to $SUMMARY_FILE ($(wc -l < "$SUMMARY_FILE" 2>/dev/null || echo 0) line(s))"