diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index ee0966af38..08b377c9f5 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -9,8 +9,8 @@ name: Test Android build scripts - '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/lib/**/*.java' + - 'scripts/android/tests/**/*.java' - 'scripts/android/screenshots/**' - '!scripts/android/screenshots/**/*.md' - 'scripts/templates/**' @@ -30,8 +30,8 @@ name: Test Android build scripts - '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/lib/**/*.java' + - 'scripts/android/tests/**/*.java' - 'scripts/android/screenshots/**' - '!scripts/android/screenshots/**/*.md' - 'scripts/templates/**' @@ -70,10 +70,6 @@ jobs: GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} steps: - uses: actions/checkout@v4 - - name: Install Pillow for image processing - run: | - python3 -m pip install --upgrade pip - python3 -m pip install pillow - name: Setup workspace run: ./scripts/setup-workspace.sh -q -DskipTests - name: Build Android port diff --git a/scripts/android/lib/PatchGradleFiles.java b/scripts/android/lib/PatchGradleFiles.java new file mode 100644 index 0000000000..549da1dbf5 --- /dev/null +++ b/scripts/android/lib/PatchGradleFiles.java @@ -0,0 +1,319 @@ +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class PatchGradleFiles { + private static final String REPOSITORIES_BLOCK = """ + repositories { + google() + mavenCentral() + } + """.stripTrailing(); + + private static final Pattern REPOSITORIES_PATTERN = Pattern.compile("(?ms)^\\s*repositories\\s*\\{.*?\\}"); + + private static final Pattern ANDROID_BLOCK_PATTERN = Pattern.compile("(?m)^\\s*android\\s*\\{"); + private static final Pattern DEFAULT_CONFIG_PATTERN = Pattern.compile("(?ms)^\\s*defaultConfig\\s*\\{.*?^\\s*\\}"); + private static final Pattern DEFAULT_CONFIG_HEADER_PATTERN = Pattern.compile("(?ms)^\\s*defaultConfig\\s*\\{"); + private static final Pattern COMPILE_SDK_PATTERN = Pattern.compile("(?m)^\\s*compileSdkVersion\\s+\\d+"); + private static final Pattern TARGET_SDK_PATTERN = Pattern.compile("(?m)^\\s*targetSdkVersion\\s+\\d+"); + private static final Pattern TEST_INSTRUMENTATION_PATTERN = Pattern.compile("(?m)^\\s*testInstrumentationRunner\\s*\".*?\"\\s*$"); + private static final Pattern USE_LIBRARY_PATTERN = Pattern.compile("(?m)^\\s*useLibrary\\s+'android\\.test\\.(?:base|mock|runner)'\\s*$"); + private static final Pattern DEPENDENCY_PATTERN = Pattern.compile("(?m)^\\s*(implementation|api|testImplementation|androidTestImplementation)\\b"); + + public static void main(String[] args) throws Exception { + Arguments arguments = Arguments.parse(args); + if (arguments == null) { + System.exit(2); + return; + } + + boolean modifiedRoot = patchRootBuildGradle(arguments.root); + boolean modifiedApp = patchAppBuildGradle(arguments.app, arguments.compileSdk, arguments.targetSdk); + + if (modifiedRoot) { + System.out.println("Patched " + arguments.root); + } + if (modifiedApp) { + System.out.println("Patched " + arguments.app); + } + if (!modifiedRoot && !modifiedApp) { + System.out.println("Gradle files already normalized"); + } + } + + private static boolean patchRootBuildGradle(Path path) throws IOException { + String content = Files.readString(path, StandardCharsets.UTF_8); + Matcher matcher = REPOSITORIES_PATTERN.matcher(content); + if (!matcher.find()) { + if (!content.endsWith("\n")) { + content += "\n"; + } + content += REPOSITORIES_BLOCK; + Files.writeString(path, ensureTrailingNewline(content), StandardCharsets.UTF_8); + return true; + } + + String block = matcher.group(); + boolean changed = false; + if (!block.contains("google()") || !block.contains("mavenCentral()")) { + String[] lines = block.split("\n"); + java.util.LinkedHashSet body = new java.util.LinkedHashSet<>(); + for (int i = 1; i < lines.length - 1; i++) { + String line = lines[i].trim(); + if (!line.isEmpty()) { + body.add(" " + line.trim()); + } + } + body.add(" google()"); + body.add(" mavenCentral()"); + StringBuilder newBlock = new StringBuilder(); + newBlock.append(lines[0]).append('\n'); + for (String line : body) { + newBlock.append(line).append('\n'); + } + newBlock.append(lines[lines.length - 1]); + content = content.substring(0, matcher.start()) + newBlock + content.substring(matcher.end()); + changed = true; + } + + if (changed) { + Files.writeString(path, ensureTrailingNewline(content), StandardCharsets.UTF_8); + } + return changed; + } + + private static boolean patchAppBuildGradle(Path path, int compileSdk, int targetSdk) throws IOException { + String content = Files.readString(path, StandardCharsets.UTF_8); + boolean changed = false; + + Result r = ensureAndroidBlock(content, compileSdk, targetSdk); + content = r.content(); + changed |= r.changed(); + + r = ensureInstrumentationRunner(content); + content = r.content(); + changed |= r.changed(); + + r = removeLegacyUseLibrary(content); + content = r.content(); + changed |= r.changed(); + + r = ensureTestDependencies(content); + content = r.content(); + changed |= r.changed(); + + if (changed) { + Files.writeString(path, ensureTrailingNewline(content), StandardCharsets.UTF_8); + } + return changed; + } + + private static Result ensureAndroidBlock(String content, int compileSdk, int targetSdk) { + Matcher androidBlockMatcher = ANDROID_BLOCK_PATTERN.matcher(content); + if (!androidBlockMatcher.find()) { + if (!content.endsWith("\n")) { + content += "\n"; + } + String block = "\nandroid {\n" + + " compileSdkVersion " + compileSdk + "\n" + + " defaultConfig {\n" + + " targetSdkVersion " + targetSdk + "\n" + + " }\n}"; + return new Result(content + block, true); + } + + boolean changed = false; + Matcher compileMatcher = COMPILE_SDK_PATTERN.matcher(content); + if (compileMatcher.find()) { + String replacement = " compileSdkVersion " + compileSdk; + String newContent = compileMatcher.replaceFirst(replacement); + if (!newContent.equals(content)) { + content = newContent; + changed = true; + } + } else { + Matcher insertMatcher = ANDROID_BLOCK_PATTERN.matcher(content); + if (insertMatcher.find()) { + int pos = insertMatcher.end(); + content = content.substring(0, pos) + "\n compileSdkVersion " + compileSdk + content.substring(pos); + changed = true; + } + } + + Matcher defaultConfigMatcher = DEFAULT_CONFIG_PATTERN.matcher(content); + if (defaultConfigMatcher.find()) { + String block = defaultConfigMatcher.group(); + Matcher targetMatcher = TARGET_SDK_PATTERN.matcher(block); + String replacement = " targetSdkVersion " + targetSdk; + String updated; + if (targetMatcher.find()) { + updated = targetMatcher.replaceFirst(replacement); + } else { + int brace = block.indexOf('{'); + if (brace >= 0) { + updated = block.substring(0, brace + 1) + "\n" + replacement + block.substring(brace + 1); + } else { + updated = block; + } + } + if (!updated.equals(block)) { + content = content.substring(0, defaultConfigMatcher.start()) + updated + content.substring(defaultConfigMatcher.end()); + changed = true; + } + } else { + Matcher insertMatcher = ANDROID_BLOCK_PATTERN.matcher(content); + if (insertMatcher.find()) { + int pos = insertMatcher.end(); + String snippet = "\n defaultConfig {\n targetSdkVersion " + targetSdk + "\n }"; + content = content.substring(0, pos) + snippet + content.substring(pos); + changed = true; + } + } + + return new Result(content, changed); + } + + private static Result ensureInstrumentationRunner(String content) { + String runner = "androidx.test.runner.AndroidJUnitRunner"; + if (content.contains(runner)) { + return new Result(content, false); + } + Matcher matcher = TEST_INSTRUMENTATION_PATTERN.matcher(content); + if (matcher.find()) { + String replacement = " testInstrumentationRunner \"" + runner + "\""; + String newContent = matcher.replaceAll(replacement); + return new Result(newContent, !newContent.equals(content)); + } + + Matcher defaultConfigHeaderMatcher = DEFAULT_CONFIG_HEADER_PATTERN.matcher(content); + if (defaultConfigHeaderMatcher.find()) { + int pos = defaultConfigHeaderMatcher.end(); + String snippet = "\n testInstrumentationRunner \"" + runner + "\""; + content = content.substring(0, pos) + snippet + content.substring(pos); + return new Result(content, true); + } + + Matcher androidMatcher = ANDROID_BLOCK_PATTERN.matcher(content); + if (androidMatcher.find()) { + int pos = androidMatcher.end(); + String snippet = "\n defaultConfig {\n testInstrumentationRunner \"" + runner + "\"\n }"; + content = content.substring(0, pos) + snippet + content.substring(pos); + return new Result(content, true); + } + return new Result(content, false); + } + + private static Result removeLegacyUseLibrary(String content) { + Matcher matcher = USE_LIBRARY_PATTERN.matcher(content); + String newContent = matcher.replaceAll(""); + return new Result(newContent, !newContent.equals(content)); + } + + private static Result ensureTestDependencies(String content) { + String moduleView = content.replaceAll("(?ms)^\\s*(buildscript|pluginManagement)\\s*\\{.*?^\\s*\\}", ""); + boolean usesModern = DEPENDENCY_PATTERN.matcher(moduleView).find(); + String configuration = usesModern ? "androidTestImplementation" : "androidTestCompile"; + String[] dependencies = { + "androidx.test.ext:junit:1.1.5", + "androidx.test:runner:1.5.2", + "androidx.test:core:1.5.0", + "androidx.test.services:storage:1.4.2" + }; + boolean missing = false; + for (String dep : dependencies) { + if (!moduleView.contains(dep)) { + missing = true; + break; + } + } + if (!missing) { + return new Result(content, false); + } + StringBuilder block = new StringBuilder(); + block.append("\n\ndependencies {\n"); + for (String dep : dependencies) { + if (!moduleView.contains(dep)) { + block.append(" ").append(configuration).append(" \"").append(dep).append("\"\n"); + } + } + block.append("}\n"); + if (!content.endsWith("\n")) { + content += "\n"; + } + return new Result(content + block, true); + } + + private static String ensureTrailingNewline(String content) { + return content.endsWith("\n") ? content : content + "\n"; + } + + private record Result(String content, boolean changed) { + } + + private static class Arguments { + final Path root; + final Path app; + final int compileSdk; + final int targetSdk; + + Arguments(Path root, Path app, int compileSdk, int targetSdk) { + this.root = root; + this.app = app; + this.compileSdk = compileSdk; + this.targetSdk = targetSdk; + } + + static Arguments parse(String[] args) { + Path root = null; + Path app = null; + int compileSdk = 33; + int targetSdk = 33; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--root" -> { + if (i + 1 >= args.length) { + System.err.println("Missing value for --root"); + return null; + } + root = Path.of(args[++i]); + } + case "--app" -> { + if (i + 1 >= args.length) { + System.err.println("Missing value for --app"); + return null; + } + app = Path.of(args[++i]); + } + case "--compile-sdk" -> { + if (i + 1 >= args.length) { + System.err.println("Missing value for --compile-sdk"); + return null; + } + compileSdk = Integer.parseInt(args[++i]); + } + case "--target-sdk" -> { + if (i + 1 >= args.length) { + System.err.println("Missing value for --target-sdk"); + return null; + } + targetSdk = Integer.parseInt(args[++i]); + } + default -> { + System.err.println("Unknown argument: " + arg); + return null; + } + } + } + if (root == null || app == null) { + System.err.println("--root and --app are required"); + return null; + } + return new Arguments(root, app, compileSdk, targetSdk); + } + } +} diff --git a/scripts/android/lib/patch_gradle_files.py b/scripts/android/lib/patch_gradle_files.py deleted file mode 100755 index e6168a4854..0000000000 --- a/scripts/android/lib/patch_gradle_files.py +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env python3 -"""Utilities to normalize generated Gradle build files for Android tests.""" -from __future__ import annotations - -import argparse -import pathlib -import re -from typing import Tuple - -REPOSITORIES_BLOCK = """\ -repositories { - google() - mavenCentral() -} -""" - - -def _ensure_repositories(content: str) -> Tuple[str, bool]: - """Ensure a repositories block exists with google() and mavenCentral().""" - pattern = re.compile(r"(?ms)^\s*repositories\s*\{.*?\}") - match = pattern.search(content) - block_added = False - - if not match: - # Append a canonical repositories block to the end of the file. - if not content.endswith("\n"): - content += "\n" - content += REPOSITORIES_BLOCK - return content, True - - block = match.group(0) - if "google()" not in block or "mavenCentral()" not in block: - lines = block.splitlines() - header = lines[0] - body = [line for line in lines[1:-1] if line.strip()] - if "google()" not in block: - body.append(" google()") - if "mavenCentral()" not in block: - body.append(" mavenCentral()") - new_block = "\n".join([header, *sorted(set(body)), lines[-1]]) - content = content[: match.start()] + new_block + content[match.end() :] - block_added = True - return content, block_added - - -def _ensure_android_sdk(content: str, compile_sdk: int, target_sdk: int) -> Tuple[str, bool]: - changed = False - - def insert_or_replace(pattern: str, repl: str, search_scope: str) -> Tuple[str, bool]: - nonlocal content - match = re.search(pattern, content, re.MULTILINE) - if match: - new_content = re.sub(pattern, repl, content, count=1, flags=re.MULTILINE) - if new_content != content: - content = new_content - return content, True - return content, False - anchor = re.search(search_scope, content, re.MULTILINE) - if not anchor: - return content, False - start = anchor.end() - content = content[:start] + f"\n{repl}" + content[start:] - return content, True - - # Ensure android block exists - if re.search(r"(?m)^\s*android\s*\{", content) is None: - if not content.endswith("\n"): - content += "\n" - content += ( - "\nandroid {\n" - f" compileSdkVersion {compile_sdk}\n" - " defaultConfig {\n" - f" targetSdkVersion {target_sdk}\n" - " }\n}" - ) - return content, True - - new_content, changed_once = insert_or_replace( - pattern=rf"(?m)^\s*compileSdkVersion\s+\d+", - repl=f" compileSdkVersion {compile_sdk}", - search_scope=r"(?m)^\s*android\s*\{", - ) - changed = changed or changed_once - - default_config = re.search(r"(?ms)^\s*defaultConfig\s*\{.*?^\s*\}", content) - if default_config: - block = default_config.group(0) - if re.search(r"(?m)^\s*targetSdkVersion\s+\d+", block): - updated = re.sub( - r"(?m)^\s*targetSdkVersion\s+\d+", - f" targetSdkVersion {target_sdk}", - block, - ) - else: - updated = re.sub(r"{", "{\n targetSdkVersion %d" % target_sdk, block, count=1) - if updated != block: - content = content[: default_config.start()] + updated + content[default_config.end() :] - changed = True - else: - content, changed_once = insert_or_replace( - pattern=r"(?ms)(^\s*android\s*\{)", - repl=" defaultConfig {\n targetSdkVersion %d\n }" % target_sdk, - search_scope=r"(?m)^\s*android\s*\{", - ) - changed = changed or changed_once - - return content, changed - - -def _ensure_instrumentation_runner(content: str) -> Tuple[str, bool]: - runner = "androidx.test.runner.AndroidJUnitRunner" - if runner in content: - return content, False - changed = False - pattern = re.compile(r"(?m)^\s*testInstrumentationRunner\s*\".*?\"\s*$") - if pattern.search(content): - new_content = pattern.sub(f" testInstrumentationRunner \"{runner}\"", content) - return new_content, True - default_config = re.search(r"(?ms)^\s*defaultConfig\s*\{", content) - if default_config: - insert_point = default_config.end() - content = ( - content[:insert_point] - + f"\n testInstrumentationRunner \"{runner}\"" - + content[insert_point:] - ) - changed = True - else: - android_block = re.search(r"(?ms)^\s*android\s*\{", content) - if android_block: - insert_point = android_block.end() - snippet = ( - "\n defaultConfig {\n" - f" testInstrumentationRunner \"{runner}\"\n" - " }" - ) - content = content[:insert_point] + snippet + content[insert_point:] - changed = True - return content, changed - - -def _remove_legacy_use_library(content: str) -> Tuple[str, bool]: - new_content, count = re.subn( - r"(?m)^\s*useLibrary\s+'android\.test\.(?:base|mock|runner)'\s*$", - "", - content, - ) - return new_content, bool(count) - - -def _ensure_test_dependencies(content: str) -> Tuple[str, bool]: - module_view = re.sub(r"(?ms)^\s*(buildscript|pluginManagement)\s*\{.*?^\s*\}", "", content) - uses_modern = re.search( - r"(?m)^\s*(implementation|api|testImplementation|androidTestImplementation)\b", - module_view, - ) - configuration = "androidTestImplementation" if uses_modern else "androidTestCompile" - dependencies = [ - "androidx.test.ext:junit:1.1.5", - "androidx.test:runner:1.5.2", - "androidx.test:core:1.5.0", - "androidx.test.services:storage:1.4.2", - ] - missing = [dep for dep in dependencies if dep not in module_view] - if not missing: - return content, False - block = "\n\ndependencies {\n" + "".join( - f" {configuration} \"{dep}\"\n" for dep in missing - ) + "}\n" - if not content.endswith("\n"): - content += "\n" - return content + block, True - - -def patch_app_build_gradle(path: pathlib.Path, compile_sdk: int, target_sdk: int) -> bool: - text = path.read_text(encoding="utf-8") - changed = False - - for transform in ( - lambda c: _ensure_android_sdk(c, compile_sdk, target_sdk), - _ensure_instrumentation_runner, - _remove_legacy_use_library, - _ensure_test_dependencies, - ): - text, modified = transform(text) - changed = changed or modified - - if changed: - path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8") - return changed - - -def patch_root_build_gradle(path: pathlib.Path) -> bool: - text = path.read_text(encoding="utf-8") - text, changed = _ensure_repositories(text) - if changed: - path.write_text(text if text.endswith("\n") else text + "\n", encoding="utf-8") - return changed - - -def main() -> int: - parser = argparse.ArgumentParser(description="Normalize Gradle build files") - parser.add_argument("--root", required=True, type=pathlib.Path, help="Path to root build.gradle") - parser.add_argument("--app", required=True, type=pathlib.Path, help="Path to app/build.gradle") - parser.add_argument("--compile-sdk", type=int, default=33) - parser.add_argument("--target-sdk", type=int, default=33) - args = parser.parse_args() - - modified_root = patch_root_build_gradle(args.root) - modified_app = patch_app_build_gradle(args.app, args.compile_sdk, args.target_sdk) - - if modified_root: - print(f"Patched {args.root}") - if modified_app: - print(f"Patched {args.app}") - if not (modified_root or modified_app): - print("Gradle files already normalized") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/android/tests/Cn1ssChunkTools.java b/scripts/android/tests/Cn1ssChunkTools.java new file mode 100644 index 0000000000..8386c8410e --- /dev/null +++ b/scripts/android/tests/Cn1ssChunkTools.java @@ -0,0 +1,202 @@ +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Cn1ssChunkTools { + private static final String DEFAULT_TEST_NAME = "default"; + private static final String DEFAULT_CHANNEL = ""; + private static final Pattern CHUNK_PATTERN = Pattern.compile( + "CN1SS(?:(?[A-Z]+))?:(?:(?[A-Za-z0-9_.-]+):)?(?\\d{6}):(?.*)"); + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + usage(); + System.exit(2); + } + String command = args[0]; + switch (command) { + case "count" -> runCount(slice(args, 1)); + case "extract" -> runExtract(slice(args, 1)); + case "tests" -> runTests(slice(args, 1)); + default -> { + usage(); + System.exit(2); + } + } + } + + private static void runCount(String[] args) throws IOException { + Path path = null; + String test = null; + String channel = DEFAULT_CHANNEL; + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--test" -> { + if (++i >= args.length) { + throw new IllegalArgumentException("Missing value for --test"); + } + test = args[i]; + } + case "--channel" -> { + if (++i >= args.length) { + throw new IllegalArgumentException("Missing value for --channel"); + } + channel = args[i]; + } + default -> { + if (path != null) { + throw new IllegalArgumentException("Multiple paths provided"); + } + path = Path.of(args[i]); + } + } + } + if (path == null) { + throw new IllegalArgumentException("Path is required for count"); + } + int count = 0; + for (Chunk chunk : iterateChunks(path, Optional.ofNullable(test), Optional.ofNullable(channel))) { + count++; + } + System.out.println(count); + } + + private static void runExtract(String[] args) throws IOException { + Path path = null; + boolean decode = false; + String test = null; + String channel = DEFAULT_CHANNEL; + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--decode" -> decode = true; + case "--test" -> { + if (++i >= args.length) { + throw new IllegalArgumentException("Missing value for --test"); + } + test = args[i]; + } + case "--channel" -> { + if (++i >= args.length) { + throw new IllegalArgumentException("Missing value for --channel"); + } + channel = args[i]; + } + default -> { + if (path != null) { + throw new IllegalArgumentException("Multiple paths provided"); + } + path = Path.of(args[i]); + } + } + } + if (path == null) { + throw new IllegalArgumentException("Path is required for extract"); + } + String targetTest = test != null ? test : DEFAULT_TEST_NAME; + List chunks = new ArrayList<>(); + for (Chunk chunk : iterateChunks(path, Optional.ofNullable(targetTest), Optional.ofNullable(channel))) { + chunks.add(chunk); + } + Collections.sort(chunks); + StringBuilder payload = new StringBuilder(); + for (Chunk chunk : chunks) { + payload.append(chunk.payload); + } + if (decode) { + byte[] data; + try { + data = Base64.getDecoder().decode(payload.toString()); + } catch (IllegalArgumentException ex) { + data = new byte[0]; + } + System.out.write(data); + } else { + System.out.print(payload.toString()); + } + } + + private static void runTests(String[] args) throws IOException { + if (args.length != 1) { + throw new IllegalArgumentException("tests command requires a path argument"); + } + Path path = Path.of(args[0]); + List names = new ArrayList<>(); + for (Chunk chunk : iterateChunks(path, Optional.empty(), Optional.of(DEFAULT_CHANNEL))) { + if (!names.contains(chunk.testName)) { + names.add(chunk.testName); + } + } + Collections.sort(names); + for (String name : names) { + System.out.println(name); + } + } + + private static Iterable iterateChunks(Path path, Optional testFilter, Optional channelFilter) throws IOException { + String text = Files.readString(path, StandardCharsets.UTF_8); + List result = new ArrayList<>(); + String[] lines = text.split("\r?\n"); + for (String line : lines) { + Matcher matcher = CHUNK_PATTERN.matcher(line); + if (!matcher.find()) { + continue; + } + String test = Optional.ofNullable(matcher.group("test")).orElse(DEFAULT_TEST_NAME); + if (testFilter.isPresent() && !test.equals(testFilter.get())) { + continue; + } + String channel = Optional.ofNullable(matcher.group("channel")).orElse(DEFAULT_CHANNEL); + if (channelFilter.isPresent() && !channel.equals(channelFilter.get())) { + continue; + } + int index = Integer.parseInt(matcher.group("index")); + String payload = matcher.group("payload").replaceAll("[^A-Za-z0-9+/=]", ""); + if (!payload.isEmpty()) { + result.add(new Chunk(test, channel, index, payload)); + } + } + return result; + } + + private static void usage() { + System.err.println("Usage: java Cn1ssChunkTools.java [options]"); + System.err.println("Commands: count, extract, tests"); + } + + private static String[] slice(String[] args, int from) { + String[] out = new String[args.length - from]; + System.arraycopy(args, from, out, 0, out.length); + return out; + } + + private static final class Chunk implements Comparable { + final String testName; + final String channel; + final int index; + final String payload; + + Chunk(String testName, String channel, int index, String payload) { + this.testName = testName; + this.channel = channel; + this.index = index; + this.payload = payload; + } + + @Override + public int compareTo(Chunk other) { + int cmp = Integer.compare(this.index, other.index); + if (cmp != 0) { + return cmp; + } + return this.payload.compareTo(other.payload); + } + } +} diff --git a/scripts/android/tests/PostPrComment.java b/scripts/android/tests/PostPrComment.java new file mode 100644 index 0000000000..dab82e1d1d --- /dev/null +++ b/scripts/android/tests/PostPrComment.java @@ -0,0 +1,779 @@ +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class PostPrComment { + private static final String MARKER = ""; + private static final String LOG_PREFIX = "[run-android-instrumentation-tests]"; + + public static void main(String[] args) throws Exception { + int exitCode = execute(args); + System.exit(exitCode); + } + + private static int execute(String[] args) throws Exception { + Arguments arguments = Arguments.parse(args); + if (arguments == null) { + return 2; + } + Path bodyPath = arguments.body; + if (!Files.isRegularFile(bodyPath)) { + return 0; + } + String rawBody = Files.readString(bodyPath, StandardCharsets.UTF_8); + String body = rawBody.trim(); + if (body.isEmpty()) { + return 0; + } + if (!body.contains(MARKER)) { + body = body.stripTrailing() + "\n\n" + MARKER; + } + String bodyWithoutMarker = body.replace(MARKER, "").trim(); + if (bodyWithoutMarker.isEmpty()) { + return 0; + } + + String eventPathEnv = System.getenv("GITHUB_EVENT_PATH"); + String repo = System.getenv("GITHUB_REPOSITORY"); + String token = System.getenv("GITHUB_TOKEN"); + if (eventPathEnv == null || repo == null || token == null) { + return 0; + } + Path eventPath = Path.of(eventPathEnv); + if (!Files.isRegularFile(eventPath)) { + return 0; + } + + Map event = JsonUtil.asObject(JsonUtil.parse(Files.readString(eventPath, StandardCharsets.UTF_8))); + Integer prNumber = findPrNumber(event); + if (prNumber == null) { + return 0; + } + + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); + Map headers = Map.of( + "Authorization", "token " + token, + "Accept", "application/vnd.github+json", + "Content-Type", "application/json" + ); + + boolean isForkPr = isForkPullRequest(event); + CommentContext context = locateExistingComment(client, headers, repo, prNumber, body, event); + if (context == null) { + return 1; + } + Long commentId = context.commentId; + boolean createdPlaceholder = context.createdPlaceholder; + + Path previewDir = arguments.previewDir; + Map attachmentUrls = new HashMap<>(); + if (body.contains("(attachment:")) { + try { + attachmentUrls = publishPreviewsToBranch(previewDir, repo, prNumber, token, !isForkPr); + for (Map.Entry entry : attachmentUrls.entrySet()) { + log("Preview available for " + entry.getKey() + ": " + entry.getValue()); + } + } catch (Exception ex) { + err("Preview publishing failed: " + ex.getMessage()); + return 1; + } + } + + AttachmentReplacement replacement = replaceAttachments(body, attachmentUrls); + if (!replacement.missing.isEmpty()) { + if (isForkPr) { + log("Preview URLs unavailable in forked PR context; placeholders left as-is"); + } else { + err("Failed to resolve preview URLs for: " + String.join(", ", replacement.missing)); + return 1; + } + } + + String finalBody = replacement.body; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://api.github.com/repos/" + repo + "/issues/comments/" + commentId)) + .timeout(Duration.ofSeconds(20)) + .headers(headers.entrySet().stream().flatMap(e -> java.util.stream.Stream.of(e.getKey(), e.getValue())).toArray(String[]::new)) + .method("PATCH", HttpRequest.BodyPublishers.ofString(JsonUtil.stringify(Map.of("body", finalBody)))) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() >= 200 && response.statusCode() < 300) { + String action = createdPlaceholder ? "posted" : "updated"; + log("PR comment " + action + " (status=" + response.statusCode() + ", bytes=" + finalBody.length() + ")"); + return 0; + } else { + err("Failed to update PR comment: HTTP " + response.statusCode() + " - " + response.body()); + return 1; + } + } + + private static CommentContext locateExistingComment(HttpClient client, Map headers, String repo, int prNumber, String body, Map event) throws IOException, InterruptedException { + String commentsUrl = "https://api.github.com/repos/" + repo + "/issues/" + prNumber + "/comments?per_page=100"; + Map existingComment = null; + Map preferredComment = null; + String actor = System.getenv("GITHUB_ACTOR"); + Set preferredLogins = new java.util.HashSet<>(); + if (actor != null && !actor.isEmpty()) { + preferredLogins.add(actor); + } + preferredLogins.add("github-actions[bot]"); + + while (commentsUrl != null) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(commentsUrl)) + .timeout(Duration.ofSeconds(20)) + .headers(headers.entrySet().stream().flatMap(e -> java.util.stream.Stream.of(e.getKey(), e.getValue())).toArray(String[]::new)) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + err("Failed to list PR comments: HTTP " + response.statusCode()); + return null; + } + Object parsed = JsonUtil.parse(response.body()); + List comments = parsed instanceof List list ? (List) list : List.of(); + for (Object comment : comments) { + Map commentMap = JsonUtil.asObject(comment); + String bodyText = stringValue(commentMap.get("body"), ""); + if (bodyText.contains(MARKER)) { + existingComment = commentMap; + Map user = JsonUtil.asObject(commentMap.get("user")); + String login = stringValue(user.get("login"), null); + if (login != null && preferredLogins.contains(login)) { + preferredComment = commentMap; + } + } + } + commentsUrl = nextLink(response.headers().firstValue("Link").orElse(null)); + } + if (preferredComment != null) { + existingComment = preferredComment; + } + + Long commentId = null; + boolean createdPlaceholder = false; + if (existingComment != null) { + Object idValue = existingComment.get("id"); + if (idValue instanceof Number number) { + commentId = number.longValue(); + } + } else { + HttpRequest createRequest = HttpRequest.newBuilder() + .uri(URI.create("https://api.github.com/repos/" + repo + "/issues/" + prNumber + "/comments")) + .timeout(Duration.ofSeconds(20)) + .headers(headers.entrySet().stream().flatMap(e -> java.util.stream.Stream.of(e.getKey(), e.getValue())).toArray(String[]::new)) + .POST(HttpRequest.BodyPublishers.ofString(JsonUtil.stringify(Map.of("body", MARKER)))) + .build(); + HttpResponse createResponse = client.send(createRequest, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (createResponse.statusCode() >= 200 && createResponse.statusCode() < 300) { + Map created = JsonUtil.asObject(JsonUtil.parse(createResponse.body())); + Object idValue = created.get("id"); + if (idValue instanceof Number number) { + commentId = number.longValue(); + } + createdPlaceholder = commentId != null; + if (createdPlaceholder) { + log("Created new screenshot comment placeholder (id=" + commentId + ")"); + } + } + } + if (commentId == null) { + err("Unable to locate or create PR comment placeholder"); + return null; + } + return new CommentContext(commentId, createdPlaceholder); + } + + private static Integer findPrNumber(Map event) { + Object prData = event.get("pull_request"); + if (prData instanceof Map map) { + Object number = ((Map) prData).get("number"); + if (number instanceof Number num) { + return num.intValue(); + } + } + Object issue = event.get("issue"); + if (issue instanceof Map issueMap) { + Object pr = ((Map) issueMap).get("pull_request"); + if (pr instanceof Map) { + Object number = issueMap.get("number"); + if (number instanceof Number num) { + return num.intValue(); + } + } + } + return null; + } + + private static boolean isForkPullRequest(Map event) { + Map pr = JsonUtil.asObject(event.get("pull_request")); + Map head = JsonUtil.asObject(pr.get("head")); + Map repo = JsonUtil.asObject(head.get("repo")); + Object fork = repo.get("fork"); + if (fork instanceof Boolean b) { + return b; + } + return false; + } + + private static String nextLink(String header) { + if (header == null || header.isEmpty()) { + return null; + } + String[] parts = header.split(","); + for (String part : parts) { + String segment = part.trim(); + if (segment.endsWith("rel=\"next\"")) { + int lt = segment.indexOf('<'); + int gt = segment.indexOf('>'); + if (lt >= 0 && gt > lt) { + return segment.substring(lt + 1, gt); + } + } + } + return null; + } + + private static Map publishPreviewsToBranch(Path previewDir, String repo, int prNumber, String token, boolean allowPush) throws IOException, InterruptedException { + if (previewDir == null || !Files.isDirectory(previewDir)) { + return Map.of(); + } + List imageFiles = new ArrayList<>(); + try (var stream = Files.list(previewDir)) { + stream.filter(path -> Files.isRegularFile(path)) + .filter(path -> { + String lower = path.getFileName().toString().toLowerCase(); + return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png"); + }) + .sorted() + .forEach(imageFiles::add); + } + if (imageFiles.isEmpty()) { + return Map.of(); + } + if (!allowPush) { + log("Preview publishing skipped for forked PR"); + return Map.of(); + } + if (repo == null || repo.isEmpty() || token == null || token.isEmpty()) { + return Map.of(); + } + Path workspace = Path.of(Optional.ofNullable(System.getenv("GITHUB_WORKSPACE")).orElse(".")).toAbsolutePath(); + Path worktree = workspace.resolve(".cn1ss-previews-pr-" + prNumber); + deleteRecursively(worktree); + Files.createDirectories(worktree); + Map env = new HashMap<>(System.getenv()); + env.putIfAbsent("GIT_TERMINAL_PROMPT", "0"); + runGit(worktree, env, "init"); + String actor = Optional.ofNullable(System.getenv("GITHUB_ACTOR")).filter(s -> !s.isBlank()).orElse("github-actions"); + runGit(worktree, env, "config", "user.name", actor); + runGit(worktree, env, "config", "user.email", "github-actions@users.noreply.github.com"); + String remoteUrl = "https://x-access-token:" + token + "@github.com/" + repo + ".git"; + runGit(worktree, env, "remote", "add", "origin", remoteUrl); + ProcessResult hasBranch = runGit(worktree, env, false, "ls-remote", "--heads", "origin", "cn1ss-previews"); + if (hasBranch.exitCode == 0 && !hasBranch.stdout.trim().isEmpty()) { + runGit(worktree, env, "fetch", "origin", "cn1ss-previews"); + runGit(worktree, env, "checkout", "cn1ss-previews"); + } else { + runGit(worktree, env, "checkout", "--orphan", "cn1ss-previews"); + } + Path dest = worktree.resolve("pr-" + prNumber); + deleteRecursively(dest); + Files.createDirectories(dest); + for (Path source : imageFiles) { + Files.copy(source, dest.resolve(source.getFileName()), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + runGit(worktree, env, "add", "-A", "."); + ProcessResult status = runGit(worktree, env, true, "status", "--porcelain"); + if (!status.stdout.trim().isEmpty()) { + runGit(worktree, env, "commit", "-m", "Add previews for PR #" + prNumber); + ProcessResult push = runGit(worktree, env, false, "push", "origin", "HEAD:cn1ss-previews"); + if (push.exitCode != 0) { + throw new IOException(push.stderr.isEmpty() ? push.stdout : push.stderr); + } + log("Published " + imageFiles.size() + " preview(s) to cn1ss-previews/pr-" + prNumber); + } else { + log("Preview branch already up-to-date for PR #" + prNumber); + } + String rawBase = "https://raw.githubusercontent.com/" + repo + "/cn1ss-previews/pr-" + prNumber; + Map urls = new LinkedHashMap<>(); + try (var stream = Files.list(dest)) { + stream.filter(Files::isRegularFile) + .sorted() + .forEach(path -> urls.put(path.getFileName().toString(), rawBase + "/" + path.getFileName())); + } + deleteRecursively(worktree); + return urls; + } + + private static void deleteRecursively(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + try (var stream = Files.walk(path)) { + stream.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }); + } catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + private static ProcessResult runGit(Path cwd, Map env, String... args) throws IOException, InterruptedException { + return runGit(cwd, env, true, args); + } + + private static ProcessResult runGit(Path cwd, Map env, boolean check, String... args) throws IOException, InterruptedException { + ProcessBuilder builder = new ProcessBuilder(); + List command = new ArrayList<>(); + command.add("git"); + java.util.Collections.addAll(command, args); + builder.command(command); + builder.directory(cwd.toFile()); + builder.environment().putAll(env); + Process process = builder.start(); + int exitCode = process.waitFor(); + String stdout = readStream(process.getInputStream()); + String stderr = readStream(process.getErrorStream()); + if (check && exitCode != 0) { + throw new IOException("git " + String.join(" ", args) + " failed: " + (stderr.isEmpty() ? stdout : stderr)); + } + return new ProcessResult(exitCode, stdout, stderr); + } + + private static String readStream(java.io.InputStream stream) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + return sb.toString(); + } + } + + private static AttachmentReplacement replaceAttachments(String body, Map urls) { + Pattern pattern = Pattern.compile("\\(attachment:([^\\)]+)\\)"); + Matcher matcher = pattern.matcher(body); + StringBuffer sb = new StringBuffer(); + List missing = new ArrayList<>(); + while (matcher.find()) { + String name = matcher.group(1); + String url = urls.get(name); + if (url != null) { + matcher.appendReplacement(sb, Matcher.quoteReplacement("(" + url + ")")); + } else { + missing.add(name); + log("Preview URL missing for " + name + "; leaving placeholder"); + matcher.appendReplacement(sb, "(#)"); + } + } + matcher.appendTail(sb); + return new AttachmentReplacement(sb.toString(), missing); + } + + private static String stringValue(Object value, String fallback) { + if (value == null) { + return fallback; + } + if (value instanceof String s) { + return s; + } + return value.toString(); + } + + private static void log(String message) { + System.out.println(LOG_PREFIX + " " + message); + } + + private static void err(String message) { + System.err.println(LOG_PREFIX + " " + message); + } + + private record CommentContext(long commentId, boolean createdPlaceholder) { + } + + private record AttachmentReplacement(String body, List missing) { + } + + private static class Arguments { + final Path body; + final Path previewDir; + + private Arguments(Path body, Path previewDir) { + this.body = body; + this.previewDir = previewDir; + } + + static Arguments parse(String[] args) { + Path body = null; + Path previewDir = null; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--body" -> { + if (++i >= args.length) { + System.err.println("Missing value for --body"); + return null; + } + body = Path.of(args[i]); + } + case "--preview-dir" -> { + if (++i >= args.length) { + System.err.println("Missing value for --preview-dir"); + return null; + } + previewDir = Path.of(args[i]); + } + default -> { + System.err.println("Unknown argument: " + arg); + return null; + } + } + } + if (body == null) { + System.err.println("--body is required"); + return null; + } + return new Arguments(body, previewDir); + } + } + + private record ProcessResult(int exitCode, String stdout, String stderr) { + } +} + +class JsonUtil { + private JsonUtil() {} + + public static Object parse(String text) { + return new Parser(text).parseValue(); + } + + public static String stringify(Object value) { + StringBuilder sb = new StringBuilder(); + writeValue(sb, value); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + public static Map asObject(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (key instanceof String s) { + result.put(s, entry.getValue()); + } + } + return result; + } + return new LinkedHashMap<>(); + } + + @SuppressWarnings("unchecked") + public static List asArray(Object value) { + if (value instanceof List list) { + return new ArrayList<>((List) list); + } + return new ArrayList<>(); + } + + private static void writeValue(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String s) { + writeString(sb, s); + } else if (value instanceof Number || value instanceof Boolean) { + sb.append(value.toString()); + } else if (value instanceof Map map) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (!(key instanceof String sKey)) { + continue; + } + if (!first) { + sb.append(','); + } + first = false; + writeString(sb, sKey); + sb.append(':'); + writeValue(sb, entry.getValue()); + } + sb.append('}'); + } else if (value instanceof List list) { + sb.append('['); + boolean first = true; + for (Object item : list) { + if (!first) { + sb.append(','); + } + first = false; + writeValue(sb, item); + } + sb.append(']'); + } else { + writeString(sb, value.toString()); + } + } + + private static void writeString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (ch < 0x20) { + sb.append(String.format("\\u%04x", (int) ch)); + } else { + sb.append(ch); + } + } + } + } + sb.append('"'); + } + + private static final class Parser { + private final String text; + private int index; + + Parser(String text) { + this.text = text; + } + + Object parseValue() { + skipWhitespace(); + if (index >= text.length()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + char ch = text.charAt(index); + return switch (ch) { + case '{' -> parseObject(); + case '[' -> parseArray(); + case '"' -> parseString(); + case 't' -> parseLiteral("true", Boolean.TRUE); + case 'f' -> parseLiteral("false", Boolean.FALSE); + case 'n' -> parseLiteral("null", null); + default -> parseNumber(); + }; + } + + private Map parseObject() { + index++; + Map result = new LinkedHashMap<>(); + skipWhitespace(); + if (peek('}')) { + index++; + return result; + } + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + index++; + Object value = parseValue(); + result.put(key, value); + skipWhitespace(); + if (peek('}')) { + index++; + break; + } + expect(','); + index++; + } + return result; + } + + private List parseArray() { + index++; + List result = new ArrayList<>(); + skipWhitespace(); + if (peek(']')) { + index++; + return result; + } + while (true) { + Object value = parseValue(); + result.add(value); + skipWhitespace(); + if (peek(']')) { + index++; + break; + } + expect(','); + index++; + } + return result; + } + + private String parseString() { + expect('"'); + index++; + StringBuilder sb = new StringBuilder(); + while (index < text.length()) { + char ch = text.charAt(index++); + if (ch == '"') { + return sb.toString(); + } + if (ch == '\\') { + if (index >= text.length()) { + throw new IllegalArgumentException("Invalid escape sequence"); + } + char esc = text.charAt(index++); + sb.append(switch (esc) { + case '"' -> '"'; + case '\\' -> '\\'; + case '/' -> '/'; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'u' -> parseUnicode(); + default -> throw new IllegalArgumentException("Invalid escape character: " + esc); + }); + } else { + sb.append(ch); + } + } + throw new IllegalArgumentException("Unterminated string"); + } + + private char parseUnicode() { + if (index + 4 > text.length()) { + throw new IllegalArgumentException("Incomplete unicode escape"); + } + int value = 0; + for (int i = 0; i < 4; i++) { + char ch = text.charAt(index++); + int digit = Character.digit(ch, 16); + if (digit < 0) { + throw new IllegalArgumentException("Invalid hex digit in unicode escape"); + } + value = (value << 4) | digit; + } + return (char) value; + } + + private Object parseLiteral(String literal, Object value) { + if (!text.startsWith(literal, index)) { + throw new IllegalArgumentException("Expected '" + literal + "'"); + } + index += literal.length(); + return value; + } + + private Number parseNumber() { + int start = index; + if (peek('-')) { + index++; + } + if (peek('0')) { + index++; + } else { + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid number"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + boolean isFloat = false; + if (peek('.')) { + isFloat = true; + index++; + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid fractional number"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + if (peek('e') || peek('E')) { + isFloat = true; + index++; + if (peek('+') || peek('-')) { + index++; + } + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid exponent"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + String number = text.substring(start, index); + try { + if (!isFloat) { + long value = Long.parseLong(number); + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return (int) value; + } + return value; + } + return Double.parseDouble(number); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Invalid number: " + number, ex); + } + } + + private void expect(char ch) { + if (!peek(ch)) { + throw new IllegalArgumentException("Expected '" + ch + "'"); + } + } + + private boolean peek(char ch) { + return index < text.length() && text.charAt(index) == ch; + } + + private char peekChar() { + return index < text.length() ? text.charAt(index) : '\0'; + } + + private void skipWhitespace() { + while (index < text.length()) { + char ch = text.charAt(index); + if (ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t') { + index++; + } else { + break; + } + } + } + } +} diff --git a/scripts/android/tests/ProcessScreenshots.java b/scripts/android/tests/ProcessScreenshots.java new file mode 100644 index 0000000000..c7cd8e2ab4 --- /dev/null +++ b/scripts/android/tests/ProcessScreenshots.java @@ -0,0 +1,987 @@ +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.MemoryCacheImageOutputStream; + +public class ProcessScreenshots { + private static final int MAX_COMMENT_BASE64 = 60_000; + private static final int[] JPEG_QUALITY_CANDIDATES = {70, 60, 50, 40, 30, 20, 10}; + private static final byte[] PNG_SIGNATURE = new byte[]{ + (byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A + }; + + public static void main(String[] args) throws Exception { + Arguments arguments = Arguments.parse(args); + if (arguments == null) { + System.exit(2); + return; + } + Map payload = buildResults( + arguments.referenceDir, + arguments.actualEntries, + arguments.emitBase64, + arguments.previewDir + ); + String json = JsonUtil.stringify(payload); + System.out.print(json); + } + + static Map buildResults( + Path referenceDir, + List> actualEntries, + boolean emitBase64, + Path previewDir + ) throws IOException { + List> results = new ArrayList<>(); + for (Map.Entry entry : actualEntries) { + String testName = entry.getKey(); + Path actualPath = entry.getValue(); + Path expectedPath = referenceDir.resolve(testName + ".png"); + Map record = new LinkedHashMap<>(); + record.put("test", testName); + record.put("actual_path", actualPath.toString()); + record.put("expected_path", expectedPath.toString()); + if (!Files.exists(actualPath)) { + record.put("status", "missing_actual"); + record.put("message", "Actual screenshot not found"); + } else if (!Files.exists(expectedPath)) { + record.put("status", "missing_expected"); + if (emitBase64) { + CommentPayload payload = loadPreviewOrBuild(testName, actualPath, previewDir); + recordPayload(record, payload, actualPath.getFileName().toString(), previewDir); + } + } else { + try { + PNGImage actual = loadPng(actualPath); + PNGImage expected = loadPng(expectedPath); + Map outcome = compareImages(expected, actual); + if (Boolean.TRUE.equals(outcome.get("equal"))) { + record.put("status", "equal"); + } else { + record.put("status", "different"); + record.put("details", outcome); + if (emitBase64) { + CommentPayload payload = loadPreviewOrBuild(testName, actualPath, previewDir, actual); + recordPayload(record, payload, actualPath.getFileName().toString(), previewDir); + } + } + } catch (Exception ex) { + record.put("status", "error"); + record.put("message", ex.getMessage()); + } + } + results.add(record); + } + Map payload = new LinkedHashMap<>(); + payload.put("results", results); + return payload; + } + + private static CommentPayload loadPreviewOrBuild(String testName, Path actualPath, Path previewDir) throws IOException { + return loadPreviewOrBuild(testName, actualPath, previewDir, null); + } + + private static CommentPayload loadPreviewOrBuild(String testName, Path actualPath, Path previewDir, PNGImage cached) throws IOException { + if (previewDir != null) { + CommentPayload external = loadExternalPreviewPayload(testName, previewDir); + if (external != null) { + return external; + } + } + PNGImage image = cached != null ? cached : loadPng(actualPath); + return buildCommentPayload(image, MAX_COMMENT_BASE64); + } + + private static CommentPayload loadExternalPreviewPayload(String testName, Path previewDir) throws IOException { + String slug = slugify(testName); + Path jpg = previewDir.resolve(slug + ".jpg"); + Path jpeg = previewDir.resolve(slug + ".jpeg"); + Path png = previewDir.resolve(slug + ".png"); + List candidates = new ArrayList<>(); + if (Files.exists(jpg)) candidates.add(jpg); + if (Files.exists(jpeg)) candidates.add(jpeg); + if (Files.exists(png)) candidates.add(png); + if (candidates.isEmpty()) { + return null; + } + Path path = candidates.get(0); + byte[] data = Files.readAllBytes(path); + String encoded = Base64.getEncoder().encodeToString(data); + String mime = path.toString().toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg"; + if (encoded.length() <= MAX_COMMENT_BASE64) { + return new CommentPayload(encoded.length(), encoded, mime, mime.endsWith("jpeg") ? "jpeg" : "png", null, null, "Preview provided by instrumentation", data); + } + return new CommentPayload(encoded.length(), null, mime, mime.endsWith("jpeg") ? "jpeg" : "png", null, "too_large", "Preview provided by instrumentation", data); + } + + private static void recordPayload(Map record, CommentPayload payload, String defaultName, Path previewDir) throws IOException { + if (payload == null) { + return; + } + if (payload.base64 != null) { + record.put("base64", payload.base64); + } else { + record.put("base64_omitted", payload.omittedReason); + record.put("base64_length", payload.base64Length); + } + record.put("base64_mime", payload.mime); + record.put("base64_codec", payload.codec); + if (payload.quality != null) { + record.put("base64_quality", payload.quality); + } + if (payload.note != null) { + record.put("base64_note", payload.note); + } + if (previewDir != null && payload.data != null) { + Files.createDirectories(previewDir); + String suffix = payload.mime.equals("image/jpeg") ? ".jpg" : ".png"; + String baseName = slugify(defaultName.contains(".") ? defaultName.substring(0, defaultName.lastIndexOf('.')) : defaultName); + Path previewPath = previewDir.resolve(baseName + suffix); + Files.write(previewPath, payload.data); + Map preview = new HashMap<>(); + preview.put("path", previewPath.toString()); + preview.put("name", previewPath.getFileName().toString()); + preview.put("mime", payload.mime); + preview.put("codec", payload.codec); + if (payload.quality != null) { + preview.put("quality", payload.quality); + } + if (payload.note != null) { + preview.put("note", payload.note); + } + record.put("preview", preview); + } + } + + private static String slugify(String name) { + StringBuilder sb = new StringBuilder(); + for (char ch : name.toCharArray()) { + if (Character.isLetterOrDigit(ch)) { + sb.append(Character.toLowerCase(ch)); + } else { + sb.append('_'); + } + } + if (sb.length() == 0) { + sb.append("preview"); + } + return sb.toString(); + } + + private static CommentPayload buildCommentPayload(PNGImage image, int maxLength) { + BufferedImage rgbImage = toRgbImage(image); + List scales = List.of(1.0, 0.7, 0.5, 0.35, 0.25); + byte[] smallestData = null; + Integer smallestQuality = null; + for (double scale : scales) { + BufferedImage candidate = rgbImage; + if (scale < 0.999) { + candidate = scaleImage(rgbImage, scale); + } + for (int quality : JPEG_QUALITY_CANDIDATES) { + byte[] data = writeJpeg(candidate, quality); + if (data == null) { + continue; + } + smallestData = data; + smallestQuality = quality; + String encoded = Base64.getEncoder().encodeToString(data); + if (encoded.length() <= maxLength) { + String note = "JPEG preview quality " + quality; + if (scale < 0.999) { + note += "; downscaled to " + candidate.getWidth() + "x" + candidate.getHeight(); + } + return new CommentPayload(encoded.length(), encoded, "image/jpeg", "jpeg", quality, null, note, data); + } + } + } + if (smallestData != null) { + String encoded = Base64.getEncoder().encodeToString(smallestData); + return new CommentPayload(encoded.length(), null, "image/jpeg", "jpeg", smallestQuality, "too_large", "All JPEG previews exceeded limit even after downscaling", smallestData); + } + byte[] pngBytes = encodePng(image); + String encoded = Base64.getEncoder().encodeToString(pngBytes); + if (encoded.length() <= maxLength) { + return new CommentPayload(encoded.length(), encoded, "image/png", "png", null, null, null, pngBytes); + } + return new CommentPayload(encoded.length(), null, "image/png", "png", null, "too_large", null, pngBytes); + } + + private static BufferedImage scaleImage(BufferedImage source, double scale) { + int width = Math.max(1, (int) Math.round(source.getWidth() * scale)); + int height = Math.max(1, (int) Math.round(source.getHeight() * scale)); + BufferedImage dest = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = dest.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.drawImage(source, 0, 0, width, height, null); + g.dispose(); + return dest; + } + + private static byte[] writeJpeg(BufferedImage image, int quality) { + try { + ImageWriter writer = ImageIO.getImageWritersByFormatName("jpeg").next(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (MemoryCacheImageOutputStream ios = new MemoryCacheImageOutputStream(baos)) { + writer.setOutput(ios); + ImageWriteParam param = writer.getDefaultWriteParam(); + if (param.canWriteCompressed()) { + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(Math.max(0.0f, Math.min(1.0f, quality / 100f))); + } + writer.write(null, new IIOImage(image, null, null), param); + } finally { + writer.dispose(); + } + return baos.toByteArray(); + } catch (Exception ex) { + return null; + } + } + + private static BufferedImage toRgbImage(PNGImage image) { + BufferedImage output = new BufferedImage(image.width, image.height, BufferedImage.TYPE_INT_RGB); + int stride = image.width * image.bytesPerPixel; + int offset = 0; + for (int y = 0; y < image.height; y++) { + for (int x = 0; x < image.width; x++) { + int r, g, b, a; + switch (image.colorType) { + case 0 -> { + int v = image.pixels[offset] & 0xFF; + r = g = b = v; + a = 255; + offset += 1; + } + case 2 -> { + r = image.pixels[offset] & 0xFF; + g = image.pixels[offset + 1] & 0xFF; + b = image.pixels[offset + 2] & 0xFF; + a = 255; + offset += 3; + } + case 4 -> { + int v = image.pixels[offset] & 0xFF; + a = image.pixels[offset + 1] & 0xFF; + r = g = b = v; + offset += 2; + } + case 6 -> { + r = image.pixels[offset] & 0xFF; + g = image.pixels[offset + 1] & 0xFF; + b = image.pixels[offset + 2] & 0xFF; + a = image.pixels[offset + 3] & 0xFF; + offset += 4; + } + default -> throw new IllegalArgumentException("Unsupported PNG color type: " + image.colorType); + } + int rgb = compositePixel(r, g, b, a); + output.setRGB(x, y, rgb); + } + } + return output; + } + + private static int compositePixel(int r, int g, int b, int a) { + if (a >= 255) { + return (r << 16) | (g << 8) | b; + } + double alpha = a / 255.0; + int outR = (int) Math.round(r * alpha + 255 * (1 - alpha)); + int outG = (int) Math.round(g * alpha + 255 * (1 - alpha)); + int outB = (int) Math.round(b * alpha + 255 * (1 - alpha)); + return (clamp(outR) << 16) | (clamp(outG) << 8) | clamp(outB); + } + + private static int clamp(int value) { + return Math.max(0, Math.min(255, value)); + } + + private static Map compareImages(PNGImage expected, PNGImage actual) { + boolean equal = expected.width == actual.width + && expected.height == actual.height + && expected.bitDepth == actual.bitDepth + && expected.colorType == actual.colorType + && java.util.Arrays.equals(expected.pixels, actual.pixels); + Map result = new LinkedHashMap<>(); + result.put("equal", equal); + result.put("width", actual.width); + result.put("height", actual.height); + result.put("bit_depth", actual.bitDepth); + result.put("color_type", actual.colorType); + return result; + } + + private static PNGImage loadPng(Path path) throws IOException { + byte[] data = Files.readAllBytes(path); + for (int i = 0; i < PNG_SIGNATURE.length; i++) { + if (data[i] != PNG_SIGNATURE[i]) { + throw new IOException(path + " is not a PNG file (missing signature)"); + } + } + int offset = PNG_SIGNATURE.length; + int width = 0; + int height = 0; + int bitDepth = 0; + int colorType = 0; + int interlace = 0; + List idatChunks = new ArrayList<>(); + while (offset + 8 <= data.length) { + int length = readInt(data, offset); + byte[] type = java.util.Arrays.copyOfRange(data, offset + 4, offset + 8); + offset += 8; + if (offset + length + 4 > data.length) { + throw new IOException("PNG chunk truncated before CRC"); + } + byte[] chunkData = java.util.Arrays.copyOfRange(data, offset, offset + length); + offset += length + 4; // skip data + CRC + String chunkType = new String(type, StandardCharsets.US_ASCII); + if ("IHDR".equals(chunkType)) { + width = readInt(chunkData, 0); + height = readInt(chunkData, 4); + bitDepth = chunkData[8] & 0xFF; + colorType = chunkData[9] & 0xFF; + int compression = chunkData[10] & 0xFF; + int filter = chunkData[11] & 0xFF; + interlace = chunkData[12] & 0xFF; + if (compression != 0 || filter != 0) { + throw new IOException("Unsupported PNG compression or filter method"); + } + } else if ("IDAT".equals(chunkType)) { + idatChunks.add(chunkData); + } else if ("IEND".equals(chunkType)) { + break; + } + } + if (width <= 0 || height <= 0) { + throw new IOException("Missing IHDR chunk"); + } + if (interlace != 0) { + throw new IOException("Interlaced PNGs are not supported"); + } + int bytesPerPixel = bytesPerPixel(bitDepth, colorType); + byte[] combined = concat(idatChunks); + byte[] raw = inflate(combined); + byte[] pixels = unfilter(width, height, bytesPerPixel, raw); + return new PNGImage(width, height, bitDepth, colorType, pixels, bytesPerPixel); + } + + private static byte[] encodePng(PNGImage image) { + try { + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + int stride = image.width * image.bytesPerPixel; + for (int y = 0; y < image.height; y++) { + raw.write(0); + raw.write(image.pixels, y * stride, stride); + } + byte[] compressed = deflate(raw.toByteArray()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(PNG_SIGNATURE); + out.write(chunk("IHDR", buildIhdr(image))); + out.write(chunk("IDAT", compressed)); + out.write(chunk("IEND", new byte[0])); + return out.toByteArray(); + } catch (IOException ex) { + return new byte[0]; + } + } + + private static byte[] deflate(byte[] data) throws IOException { + java.util.zip.Deflater deflater = new java.util.zip.Deflater(); + deflater.setInput(data); + deflater.finish(); + byte[] buffer = new byte[8192]; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + while (!deflater.finished()) { + int count = deflater.deflate(buffer); + out.write(buffer, 0, count); + } + deflater.end(); + return out.toByteArray(); + } + + private static byte[] buildIhdr(PNGImage image) { + byte[] ihdr = new byte[13]; + writeInt(ihdr, 0, image.width); + writeInt(ihdr, 4, image.height); + ihdr[8] = (byte) image.bitDepth; + ihdr[9] = (byte) image.colorType; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + return ihdr; + } + + private static byte[] chunk(String type, byte[] payload) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeInt(out, payload.length); + byte[] typeBytes = type.getBytes(StandardCharsets.US_ASCII); + out.write(typeBytes); + out.write(payload); + java.util.zip.CRC32 crc = new java.util.zip.CRC32(); + crc.update(typeBytes); + crc.update(payload); + writeInt(out, (int) crc.getValue()); + return out.toByteArray(); + } + + private static void writeInt(ByteArrayOutputStream out, int value) { + out.write((value >>> 24) & 0xFF); + out.write((value >>> 16) & 0xFF); + out.write((value >>> 8) & 0xFF); + out.write(value & 0xFF); + } + + private static void writeInt(byte[] array, int offset, int value) { + array[offset] = (byte) ((value >>> 24) & 0xFF); + array[offset + 1] = (byte) ((value >>> 16) & 0xFF); + array[offset + 2] = (byte) ((value >>> 8) & 0xFF); + array[offset + 3] = (byte) (value & 0xFF); + } + + private static byte[] concat(List chunks) { + int total = 0; + for (byte[] chunk : chunks) { + total += chunk.length; + } + byte[] combined = new byte[total]; + int offset = 0; + for (byte[] chunk : chunks) { + System.arraycopy(chunk, 0, combined, offset, chunk.length); + offset += chunk.length; + } + return combined; + } + + private static byte[] inflate(byte[] data) throws IOException { + Inflater inflater = new Inflater(); + inflater.setInput(data); + byte[] buffer = new byte[8192]; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + while (!inflater.finished()) { + int count = inflater.inflate(buffer); + if (count == 0 && inflater.needsInput()) { + break; + } + out.write(buffer, 0, count); + } + } catch (DataFormatException ex) { + throw new IOException("Failed to decompress IDAT data: " + ex.getMessage(), ex); + } finally { + inflater.end(); + } + return out.toByteArray(); + } + + private static byte[] unfilter(int width, int height, int bytesPerPixel, byte[] raw) throws IOException { + int stride = width * bytesPerPixel; + int expected = height * (stride + 1); + if (raw.length != expected) { + throw new IOException("PNG IDAT payload has unexpected length"); + } + byte[] result = new byte[height * stride]; + int inOffset = 0; + int outOffset = 0; + for (int row = 0; row < height; row++) { + int filter = raw[inOffset++] & 0xFF; + switch (filter) { + case 0 -> { + System.arraycopy(raw, inOffset, result, outOffset, stride); + } + case 1 -> { + for (int i = 0; i < stride; i++) { + int left = i >= bytesPerPixel ? result[outOffset + i - bytesPerPixel] & 0xFF : 0; + int val = (raw[inOffset + i] & 0xFF) + left; + result[outOffset + i] = (byte) (val & 0xFF); + } + } + case 2 -> { + for (int i = 0; i < stride; i++) { + int up = row > 0 ? result[outOffset + i - stride] & 0xFF : 0; + int val = (raw[inOffset + i] & 0xFF) + up; + result[outOffset + i] = (byte) (val & 0xFF); + } + } + case 3 -> { + for (int i = 0; i < stride; i++) { + int left = i >= bytesPerPixel ? result[outOffset + i - bytesPerPixel] & 0xFF : 0; + int up = row > 0 ? result[outOffset + i - stride] & 0xFF : 0; + int val = (raw[inOffset + i] & 0xFF) + ((left + up) / 2); + result[outOffset + i] = (byte) (val & 0xFF); + } + } + case 4 -> { + for (int i = 0; i < stride; i++) { + int left = i >= bytesPerPixel ? result[outOffset + i - bytesPerPixel] & 0xFF : 0; + int up = row > 0 ? result[outOffset + i - stride] & 0xFF : 0; + int upLeft = (row > 0 && i >= bytesPerPixel) ? result[outOffset + i - stride - bytesPerPixel] & 0xFF : 0; + int paeth = paethPredictor(left, up, upLeft); + int val = (raw[inOffset + i] & 0xFF) + paeth; + result[outOffset + i] = (byte) (val & 0xFF); + } + } + default -> throw new IOException("Unsupported PNG filter type: " + filter); + } + inOffset += stride; + outOffset += stride; + } + return result; + } + + private static int paethPredictor(int a, int b, int c) { + int p = a + b - c; + int pa = Math.abs(p - a); + int pb = Math.abs(p - b); + int pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) { + return a; + } + if (pb <= pc) { + return b; + } + return c; + } + + private static int bytesPerPixel(int bitDepth, int colorType) throws IOException { + if (bitDepth != 8) { + throw new IOException("Unsupported bit depth: " + bitDepth); + } + return switch (colorType) { + case 0 -> 1; + case 2 -> 3; + case 4 -> 2; + case 6 -> 4; + default -> throw new IOException("Unsupported color type: " + colorType); + }; + } + + private static int readInt(byte[] data, int offset) { + return ((data[offset] & 0xFF) << 24) + | ((data[offset + 1] & 0xFF) << 16) + | ((data[offset + 2] & 0xFF) << 8) + | (data[offset + 3] & 0xFF); + } + + private static final class CommentPayload { + final int base64Length; + final String base64; + final String mime; + final String codec; + final Integer quality; + final String omittedReason; + final String note; + final byte[] data; + + CommentPayload(int base64Length, String base64, String mime, String codec, Integer quality, String omittedReason, String note, byte[] data) { + this.base64Length = base64Length; + this.base64 = base64; + this.mime = mime; + this.codec = codec; + this.quality = quality; + this.omittedReason = omittedReason; + this.note = note; + this.data = data; + } + } + + private record PNGImage(int width, int height, int bitDepth, int colorType, byte[] pixels, int bytesPerPixel) { + } + + private static class Arguments { + final Path referenceDir; + final List> actualEntries; + final boolean emitBase64; + final Path previewDir; + + private Arguments(Path referenceDir, List> actualEntries, boolean emitBase64, Path previewDir) { + this.referenceDir = referenceDir; + this.actualEntries = actualEntries; + this.emitBase64 = emitBase64; + this.previewDir = previewDir; + } + + static Arguments parse(String[] args) { + Path reference = null; + boolean emitBase64 = false; + Path previewDir = null; + List> actuals = new ArrayList<>(); + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--reference-dir" -> { + if (++i >= args.length) { + System.err.println("Missing value for --reference-dir"); + return null; + } + reference = Path.of(args[i]); + } + case "--emit-base64" -> emitBase64 = true; + case "--preview-dir" -> { + if (++i >= args.length) { + System.err.println("Missing value for --preview-dir"); + return null; + } + previewDir = Path.of(args[i]); + } + case "--actual" -> { + if (++i >= args.length) { + System.err.println("Missing value for --actual"); + return null; + } + String value = args[i]; + int idx = value.indexOf('='); + if (idx < 0) { + System.err.println("Invalid --actual value: " + value); + return null; + } + String name = value.substring(0, idx); + Path path = Path.of(value.substring(idx + 1)); + actuals.add(Map.entry(name, path)); + } + default -> { + System.err.println("Unknown argument: " + arg); + return null; + } + } + } + if (reference == null) { + System.err.println("--reference-dir is required"); + return null; + } + return new Arguments(reference, actuals, emitBase64, previewDir); + } + } +} + +class JsonUtil { + private JsonUtil() {} + + public static Object parse(String text) { + return new Parser(text).parseValue(); + } + + public static String stringify(Object value) { + StringBuilder sb = new StringBuilder(); + writeValue(sb, value); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + public static Map asObject(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (key instanceof String s) { + result.put(s, entry.getValue()); + } + } + return result; + } + return new LinkedHashMap<>(); + } + + @SuppressWarnings("unchecked") + public static List asArray(Object value) { + if (value instanceof List list) { + return new ArrayList<>((List) list); + } + return new ArrayList<>(); + } + + private static void writeValue(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String s) { + writeString(sb, s); + } else if (value instanceof Number || value instanceof Boolean) { + sb.append(value.toString()); + } else if (value instanceof Map map) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (!(key instanceof String sKey)) { + continue; + } + if (!first) { + sb.append(','); + } + first = false; + writeString(sb, sKey); + sb.append(':'); + writeValue(sb, entry.getValue()); + } + sb.append('}'); + } else if (value instanceof List list) { + sb.append('['); + boolean first = true; + for (Object item : list) { + if (!first) { + sb.append(','); + } + first = false; + writeValue(sb, item); + } + sb.append(']'); + } else { + writeString(sb, value.toString()); + } + } + + private static void writeString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (ch < 0x20) { + sb.append(String.format("\\u%04x", (int) ch)); + } else { + sb.append(ch); + } + } + } + } + sb.append('"'); + } + + private static final class Parser { + private final String text; + private int index; + + Parser(String text) { + this.text = text; + } + + Object parseValue() { + skipWhitespace(); + if (index >= text.length()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + char ch = text.charAt(index); + return switch (ch) { + case '{' -> parseObject(); + case '[' -> parseArray(); + case '"' -> parseString(); + case 't' -> parseLiteral("true", Boolean.TRUE); + case 'f' -> parseLiteral("false", Boolean.FALSE); + case 'n' -> parseLiteral("null", null); + default -> parseNumber(); + }; + } + + private Map parseObject() { + index++; + Map result = new LinkedHashMap<>(); + skipWhitespace(); + if (peek('}')) { + index++; + return result; + } + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + index++; + Object value = parseValue(); + result.put(key, value); + skipWhitespace(); + if (peek('}')) { + index++; + break; + } + expect(','); + index++; + } + return result; + } + + private List parseArray() { + index++; + List result = new ArrayList<>(); + skipWhitespace(); + if (peek(']')) { + index++; + return result; + } + while (true) { + Object value = parseValue(); + result.add(value); + skipWhitespace(); + if (peek(']')) { + index++; + break; + } + expect(','); + index++; + } + return result; + } + + private String parseString() { + expect('"'); + index++; + StringBuilder sb = new StringBuilder(); + while (index < text.length()) { + char ch = text.charAt(index++); + if (ch == '"') { + return sb.toString(); + } + if (ch == '\\') { + if (index >= text.length()) { + throw new IllegalArgumentException("Invalid escape sequence"); + } + char esc = text.charAt(index++); + sb.append(switch (esc) { + case '"' -> '"'; + case '\\' -> '\\'; + case '/' -> '/'; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'u' -> parseUnicode(); + default -> throw new IllegalArgumentException("Invalid escape character: " + esc); + }); + } else { + sb.append(ch); + } + } + throw new IllegalArgumentException("Unterminated string"); + } + + private char parseUnicode() { + if (index + 4 > text.length()) { + throw new IllegalArgumentException("Incomplete unicode escape"); + } + int value = 0; + for (int i = 0; i < 4; i++) { + char ch = text.charAt(index++); + int digit = Character.digit(ch, 16); + if (digit < 0) { + throw new IllegalArgumentException("Invalid hex digit in unicode escape"); + } + value = (value << 4) | digit; + } + return (char) value; + } + + private Object parseLiteral(String literal, Object value) { + if (!text.startsWith(literal, index)) { + throw new IllegalArgumentException("Expected '" + literal + "'"); + } + index += literal.length(); + return value; + } + + private Number parseNumber() { + int start = index; + if (peek('-')) { + index++; + } + if (peek('0')) { + index++; + } else { + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid number"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + boolean isFloat = false; + if (peek('.')) { + isFloat = true; + index++; + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid fractional number"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + if (peek('e') || peek('E')) { + isFloat = true; + index++; + if (peek('+') || peek('-')) { + index++; + } + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid exponent"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + String number = text.substring(start, index); + try { + if (!isFloat) { + long value = Long.parseLong(number); + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return (int) value; + } + return value; + } + return Double.parseDouble(number); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Invalid number: " + number, ex); + } + } + + private void expect(char ch) { + if (!peek(ch)) { + throw new IllegalArgumentException("Expected '" + ch + "'"); + } + } + + private boolean peek(char ch) { + return index < text.length() && text.charAt(index) == ch; + } + + private char peekChar() { + return index < text.length() ? text.charAt(index) : '\0'; + } + + private void skipWhitespace() { + while (index < text.length()) { + char ch = text.charAt(index); + if (ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t') { + index++; + } else { + break; + } + } + } + } +} + diff --git a/scripts/android/tests/RenderScreenshotReport.java b/scripts/android/tests/RenderScreenshotReport.java new file mode 100644 index 0000000000..791f62451f --- /dev/null +++ b/scripts/android/tests/RenderScreenshotReport.java @@ -0,0 +1,621 @@ +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class RenderScreenshotReport { + private static final String MARKER = ""; + + public static void main(String[] args) throws Exception { + Arguments arguments = Arguments.parse(args); + if (arguments == null) { + System.exit(2); + return; + } + Path comparePath = arguments.compareJson; + if (!Files.isRegularFile(comparePath)) { + System.err.println("Comparison JSON not found: " + comparePath); + System.exit(1); + } + String text = Files.readString(comparePath, StandardCharsets.UTF_8); + Object parsed = JsonUtil.parse(text); + Map data = JsonUtil.asObject(parsed); + SummaryAndComment output = buildSummaryAndComment(data); + writeLines(arguments.summaryOut, output.summaryLines); + writeLines(arguments.commentOut, output.commentLines); + } + + private static void writeLines(Path path, List lines) throws IOException { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + sb.append(lines.get(i)); + if (i + 1 < lines.size()) { + sb.append('\n'); + } + } + if (!lines.isEmpty()) { + sb.append('\n'); + } + Files.writeString(path, sb.toString(), StandardCharsets.UTF_8); + } + + private static SummaryAndComment buildSummaryAndComment(Map data) { + List summaryLines = new ArrayList<>(); + List commentLines = new ArrayList<>(); + Object resultsObj = data.get("results"); + List results = resultsObj instanceof List list ? (List) list : List.of(); + List> commentEntries = new ArrayList<>(); + for (Object item : results) { + Map result = JsonUtil.asObject(item); + String test = stringValue(result.get("test"), "unknown"); + String status = stringValue(result.get("status"), "unknown"); + String expectedPath = stringValue(result.get("expected_path"), ""); + String actualPath = stringValue(result.get("actual_path"), ""); + Map details = JsonUtil.asObject(result.get("details")); + String base64 = stringValue(result.get("base64"), null); + String base64Omitted = stringValue(result.get("base64_omitted"), null); + Integer base64Length = toInteger(result.get("base64_length")); + String base64Mime = stringValue(result.get("base64_mime"), "image/png"); + String base64Codec = stringValue(result.get("base64_codec"), null); + Integer base64Quality = toInteger(result.get("base64_quality")); + String base64Note = stringValue(result.get("base64_note"), null); + Map preview = JsonUtil.asObject(result.get("preview")); + String previewName = stringValue(preview.get("name"), null); + String previewPath = stringValue(preview.get("path"), null); + String previewMime = stringValue(preview.get("mime"), null); + String previewNote = stringValue(preview.get("note"), null); + Integer previewQuality = toInteger(preview.get("quality")); + String message; + String copyFlag = "0"; + switch (status) { + case "equal" -> message = "Matches stored reference."; + case "missing_expected" -> { + message = "Reference screenshot missing at " + expectedPath + "."; + copyFlag = "1"; + commentEntries.add(commentEntry(test, "missing reference", message, previewName, previewPath, previewMime, previewNote, + previewQuality, base64, base64Omitted, base64Length, base64Mime, base64Codec, base64Quality, base64Note, test + ".png")); + } + case "different" -> { + String dims = ""; + if (!details.isEmpty()) { + dims = String.format(" (%sx%s px, bit depth %s)", + stringValue(details.get("width"), ""), + stringValue(details.get("height"), ""), + stringValue(details.get("bit_depth"), "")); + } + message = "Screenshot differs" + dims + "."; + copyFlag = "1"; + commentEntries.add(commentEntry(test, "updated screenshot", message, previewName, previewPath, previewMime, + previewNote, previewQuality, base64, base64Omitted, base64Length, base64Mime, base64Codec, + base64Quality, base64Note, test + ".png")); + } + case "error" -> { + message = "Comparison error: " + stringValue(result.get("message"), "unknown error"); + copyFlag = "1"; + commentEntries.add(commentEntry(test, "comparison error", message, previewName, previewPath, previewMime, + previewNote, previewQuality, null, base64Omitted, base64Length, base64Mime, base64Codec, + base64Quality, base64Note, test + ".png")); + } + case "missing_actual" -> { + message = "Actual screenshot missing (test did not produce output)."; + copyFlag = "1"; + commentEntries.add(commentEntry(test, "missing actual screenshot", message, previewName, previewPath, + previewMime, previewNote, previewQuality, null, base64Omitted, base64Length, base64Mime, + base64Codec, base64Quality, base64Note, null)); + } + default -> message = "Status: " + status + "."; + } + String noteColumn = previewNote != null ? previewNote : base64Note != null ? base64Note : ""; + summaryLines.add(String.join("|", List.of(status, test, message, copyFlag, actualPath, noteColumn))); + } + + if (!commentEntries.isEmpty()) { + commentLines.add("### Android screenshot updates"); + commentLines.add(""); + for (Map entry : commentEntries) { + String test = stringValue(entry.get("test"), ""); + String status = stringValue(entry.get("status"), ""); + String message = stringValue(entry.get("message"), ""); + commentLines.add(String.format("- **%s** — %s. %s", test, status, message)); + addPreviewSection(commentLines, entry); + commentLines.add(""); + } + if (!commentLines.isEmpty() && !commentLines.get(commentLines.size() - 1).isEmpty()) { + commentLines.add(""); + } + commentLines.add(MARKER); + } else { + commentLines.add("✅ Native Android screenshot tests passed."); + commentLines.add(""); + commentLines.add(MARKER); + } + return new SummaryAndComment(summaryLines, commentLines); + } + + private static Map commentEntry( + String test, + String status, + String message, + String previewName, + String previewPath, + String previewMime, + String previewNote, + Integer previewQuality, + String base64, + String base64Omitted, + Integer base64Length, + String base64Mime, + String base64Codec, + Integer base64Quality, + String base64Note, + String artifactName + ) { + Map entry = new LinkedHashMap<>(); + entry.put("test", test); + entry.put("status", status); + entry.put("message", message); + entry.put("artifact_name", artifactName); + entry.put("preview_name", previewName); + entry.put("preview_path", previewPath); + entry.put("preview_mime", previewMime); + entry.put("preview_note", previewNote); + entry.put("preview_quality", previewQuality); + entry.put("base64", base64); + entry.put("base64_omitted", base64Omitted); + entry.put("base64_length", base64Length); + entry.put("base64_mime", base64Mime); + entry.put("base64_codec", base64Codec); + entry.put("base64_quality", base64Quality); + entry.put("base64_note", base64Note); + return entry; + } + + private static void addPreviewSection(List lines, Map entry) { + String previewName = stringValue(entry.get("preview_name"), null); + Integer previewQuality = toInteger(entry.get("preview_quality")); + String previewNote = stringValue(entry.get("preview_note"), null); + String base64Note = stringValue(entry.get("base64_note"), null); + String previewMime = stringValue(entry.get("preview_mime"), null); + List notes = new ArrayList<>(); + if ("image/jpeg".equals(previewMime) && previewQuality != null) { + notes.add("JPEG preview quality " + previewQuality); + } + if (previewNote != null && !previewNote.isEmpty()) { + notes.add(previewNote); + } + if (base64Note != null && !base64Note.isEmpty() && (previewNote == null || !previewNote.equals(base64Note))) { + notes.add(base64Note); + } + if (previewName != null) { + lines.add(""); + lines.add(" ![" + entry.get("test") + "](attachment:" + previewName + ")"); + if (!notes.isEmpty()) { + lines.add(" _Preview info: " + String.join("; ", notes) + "._"); + } + } else if (entry.get("base64") != null) { + lines.add(""); + lines.add(" _Preview generated but could not be published; see workflow artifacts for JPEG preview._"); + if (!notes.isEmpty()) { + lines.add(" _Preview info: " + String.join("; ", notes) + "._"); + } + } else if ("too_large".equals(entry.get("base64_omitted"))) { + lines.add(""); + String sizeNote = ""; + Integer length = toInteger(entry.get("base64_length")); + if (length != null && length > 0) { + sizeNote = " (base64 length ≈ " + String.format("%,d", length) + " chars)"; + } + List extra = new ArrayList<>(); + if ("jpeg".equals(stringValue(entry.get("base64_codec"), null)) && entry.get("base64_quality") != null) { + extra.add("attempted JPEG quality " + entry.get("base64_quality")); + } + if (base64Note != null && !base64Note.isEmpty()) { + extra.add(base64Note); + } + String tail = extra.isEmpty() ? "" : " (" + String.join("; ", extra) + ")"; + lines.add(" _Screenshot omitted from comment because the encoded payload exceeded GitHub's size limits" + sizeNote + "." + tail + "_"); + } else { + lines.add(""); + lines.add(" _No preview available for this screenshot._"); + } + String artifactName = stringValue(entry.get("artifact_name"), null); + if (artifactName != null) { + lines.add(" _Full-resolution PNG saved as `" + artifactName + "` in workflow artifacts._"); + } + } + + private static String stringValue(Object value, String fallback) { + if (value == null) { + return fallback; + } + if (value instanceof String s) { + return s; + } + return value.toString(); + } + + private static Integer toInteger(Object value) { + if (value instanceof Integer i) { + return i; + } + if (value instanceof Long l) { + return l.intValue(); + } + if (value instanceof Number n) { + return n.intValue(); + } + return null; + } + + private record SummaryAndComment(List summaryLines, List commentLines) { + } + + private static class Arguments { + final Path compareJson; + final Path commentOut; + final Path summaryOut; + + private Arguments(Path compareJson, Path commentOut, Path summaryOut) { + this.compareJson = compareJson; + this.commentOut = commentOut; + this.summaryOut = summaryOut; + } + + static Arguments parse(String[] args) { + Path compare = null; + Path comment = null; + Path summary = null; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--compare-json" -> { + if (++i >= args.length) { + System.err.println("Missing value for --compare-json"); + return null; + } + compare = Path.of(args[i]); + } + case "--comment-out" -> { + if (++i >= args.length) { + System.err.println("Missing value for --comment-out"); + return null; + } + comment = Path.of(args[i]); + } + case "--summary-out" -> { + if (++i >= args.length) { + System.err.println("Missing value for --summary-out"); + return null; + } + summary = Path.of(args[i]); + } + default -> { + System.err.println("Unknown argument: " + arg); + return null; + } + } + } + if (compare == null || comment == null || summary == null) { + System.err.println("--compare-json, --comment-out, and --summary-out are required"); + return null; + } + return new Arguments(compare, comment, summary); + } + } +} + +class JsonUtil { + private JsonUtil() {} + + public static Object parse(String text) { + return new Parser(text).parseValue(); + } + + public static String stringify(Object value) { + StringBuilder sb = new StringBuilder(); + writeValue(sb, value); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + public static Map asObject(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (key instanceof String s) { + result.put(s, entry.getValue()); + } + } + return result; + } + return new LinkedHashMap<>(); + } + + @SuppressWarnings("unchecked") + public static List asArray(Object value) { + if (value instanceof List list) { + return new ArrayList<>((List) list); + } + return new ArrayList<>(); + } + + private static void writeValue(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String s) { + writeString(sb, s); + } else if (value instanceof Number || value instanceof Boolean) { + sb.append(value.toString()); + } else if (value instanceof Map map) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (!(key instanceof String sKey)) { + continue; + } + if (!first) { + sb.append(','); + } + first = false; + writeString(sb, sKey); + sb.append(':'); + writeValue(sb, entry.getValue()); + } + sb.append('}'); + } else if (value instanceof List list) { + sb.append('['); + boolean first = true; + for (Object item : list) { + if (!first) { + sb.append(','); + } + first = false; + writeValue(sb, item); + } + sb.append(']'); + } else { + writeString(sb, value.toString()); + } + } + + private static void writeString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (ch < 0x20) { + sb.append(String.format("\\u%04x", (int) ch)); + } else { + sb.append(ch); + } + } + } + } + sb.append('"'); + } + + private static final class Parser { + private final String text; + private int index; + + Parser(String text) { + this.text = text; + } + + Object parseValue() { + skipWhitespace(); + if (index >= text.length()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + char ch = text.charAt(index); + return switch (ch) { + case '{' -> parseObject(); + case '[' -> parseArray(); + case '"' -> parseString(); + case 't' -> parseLiteral("true", Boolean.TRUE); + case 'f' -> parseLiteral("false", Boolean.FALSE); + case 'n' -> parseLiteral("null", null); + default -> parseNumber(); + }; + } + + private Map parseObject() { + index++; + Map result = new LinkedHashMap<>(); + skipWhitespace(); + if (peek('}')) { + index++; + return result; + } + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + index++; + Object value = parseValue(); + result.put(key, value); + skipWhitespace(); + if (peek('}')) { + index++; + break; + } + expect(','); + index++; + } + return result; + } + + private List parseArray() { + index++; + List result = new ArrayList<>(); + skipWhitespace(); + if (peek(']')) { + index++; + return result; + } + while (true) { + Object value = parseValue(); + result.add(value); + skipWhitespace(); + if (peek(']')) { + index++; + break; + } + expect(','); + index++; + } + return result; + } + + private String parseString() { + expect('"'); + index++; + StringBuilder sb = new StringBuilder(); + while (index < text.length()) { + char ch = text.charAt(index++); + if (ch == '"') { + return sb.toString(); + } + if (ch == '\\') { + if (index >= text.length()) { + throw new IllegalArgumentException("Invalid escape sequence"); + } + char esc = text.charAt(index++); + sb.append(switch (esc) { + case '"' -> '"'; + case '\\' -> '\\'; + case '/' -> '/'; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'u' -> parseUnicode(); + default -> throw new IllegalArgumentException("Invalid escape character: " + esc); + }); + } else { + sb.append(ch); + } + } + throw new IllegalArgumentException("Unterminated string"); + } + + private char parseUnicode() { + if (index + 4 > text.length()) { + throw new IllegalArgumentException("Incomplete unicode escape"); + } + int value = 0; + for (int i = 0; i < 4; i++) { + char ch = text.charAt(index++); + int digit = Character.digit(ch, 16); + if (digit < 0) { + throw new IllegalArgumentException("Invalid hex digit in unicode escape"); + } + value = (value << 4) | digit; + } + return (char) value; + } + + private Object parseLiteral(String literal, Object value) { + if (!text.startsWith(literal, index)) { + throw new IllegalArgumentException("Expected '" + literal + "'"); + } + index += literal.length(); + return value; + } + + private Number parseNumber() { + int start = index; + if (peek('-')) { + index++; + } + if (peek('0')) { + index++; + } else { + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid number"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + boolean isFloat = false; + if (peek('.')) { + isFloat = true; + index++; + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid fractional number"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + if (peek('e') || peek('E')) { + isFloat = true; + index++; + if (peek('+') || peek('-')) { + index++; + } + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid exponent"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + String number = text.substring(start, index); + try { + if (!isFloat) { + long value = Long.parseLong(number); + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return (int) value; + } + return value; + } + return Double.parseDouble(number); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Invalid number: " + number, ex); + } + } + + private void expect(char ch) { + if (!peek(ch)) { + throw new IllegalArgumentException("Expected '" + ch + "'"); + } + } + + private boolean peek(char ch) { + return index < text.length() && text.charAt(index) == ch; + } + + private char peekChar() { + return index < text.length() ? text.charAt(index) : '\0'; + } + + private void skipWhitespace() { + while (index < text.length()) { + char ch = text.charAt(index); + if (ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t') { + index++; + } else { + break; + } + } + } + } +} diff --git a/scripts/android/tests/cn1ss_chunk_tools.py b/scripts/android/tests/cn1ss_chunk_tools.py deleted file mode 100755 index 0845047b22..0000000000 --- a/scripts/android/tests/cn1ss_chunk_tools.py +++ /dev/null @@ -1,130 +0,0 @@ -import argparse -import base64 -import pathlib -import re -import sys -from typing import Iterable, List, Optional, Tuple - -DEFAULT_TEST_NAME = "default" -DEFAULT_CHANNEL = "" -CHUNK_PATTERN = re.compile( - r"CN1SS(?:(?P[A-Z]+))?:(?:(?P[A-Za-z0-9_.-]+):)?(?P\d{6}):(?P.*)" -) - - -def _iter_chunk_lines( - path: pathlib.Path, - test_filter: Optional[str] = None, - channel_filter: Optional[str] = DEFAULT_CHANNEL, -) -> Iterable[Tuple[str, int, str]]: - text = path.read_text(encoding="utf-8", errors="ignore") - for line in text.splitlines(): - match = CHUNK_PATTERN.search(line) - if not match: - continue - test_name = match.group("test") or DEFAULT_TEST_NAME - if test_filter is not None and test_name != test_filter: - continue - channel = match.group("channel") or DEFAULT_CHANNEL - if channel_filter is not None and channel != channel_filter: - continue - index = int(match.group("index")) - payload = re.sub(r"[^A-Za-z0-9+/=]", "", match.group("payload")) - if payload: - yield test_name, index, payload - - -def count_chunks( - path: pathlib.Path, test: Optional[str] = None, channel: Optional[str] = DEFAULT_CHANNEL -) -> int: - return sum(1 for _ in _iter_chunk_lines(path, test_filter=test, channel_filter=channel)) - - -def concatenate_chunks( - path: pathlib.Path, test: Optional[str] = None, channel: Optional[str] = DEFAULT_CHANNEL -) -> str: - ordered = sorted( - _iter_chunk_lines(path, test_filter=test, channel_filter=channel), - key=lambda item: item[1], - ) - return "".join(payload for _, _, payload in ordered) - - -def decode_chunks( - path: pathlib.Path, - test: Optional[str] = None, - channel: Optional[str] = DEFAULT_CHANNEL, -) -> bytes: - data = concatenate_chunks(path, test=test, channel=channel) - if not data: - return b"" - try: - return base64.b64decode(data) - except Exception: - return b"" - - -def list_tests(path: pathlib.Path) -> List[str]: - seen = { - test - for test, _, _ in _iter_chunk_lines(path, channel_filter=DEFAULT_CHANNEL) - } - return sorted(seen) - - -def main(argv: List[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - p_count = subparsers.add_parser("count", help="Count CN1SS chunks in a file") - p_count.add_argument("path", type=pathlib.Path) - p_count.add_argument("--test", dest="test", default=None, help="Optional test name filter") - p_count.add_argument( - "--channel", - dest="channel", - default=DEFAULT_CHANNEL, - help="Optional channel (default=primary)", - ) - - p_extract = subparsers.add_parser("extract", help="Concatenate CN1SS payload chunks") - p_extract.add_argument("path", type=pathlib.Path) - p_extract.add_argument("--decode", action="store_true", help="Decode payload to binary PNG") - p_extract.add_argument("--test", dest="test", default=None, help="Test name to extract (default=unnamed)") - p_extract.add_argument( - "--channel", - dest="channel", - default=DEFAULT_CHANNEL, - help="Optional channel (default=primary)", - ) - - p_tests = subparsers.add_parser("tests", help="List distinct test names found in CN1SS chunks") - p_tests.add_argument("path", type=pathlib.Path) - - args = parser.parse_args(argv) - - if args.command == "count": - print(count_chunks(args.path, args.test, args.channel)) - return 0 - - if args.command == "extract": - target_test: Optional[str] - if args.test is None: - target_test = DEFAULT_TEST_NAME - else: - target_test = args.test - if args.decode: - sys.stdout.buffer.write(decode_chunks(args.path, target_test, args.channel)) - else: - sys.stdout.write(concatenate_chunks(args.path, target_test, args.channel)) - return 0 - - if args.command == "tests": - for name in list_tests(args.path): - print(name) - return 0 - - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/android/tests/post_pr_comment.py b/scripts/android/tests/post_pr_comment.py deleted file mode 100644 index 4e90685673..0000000000 --- a/scripts/android/tests/post_pr_comment.py +++ /dev/null @@ -1,316 +0,0 @@ -#!/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/process_screenshots.py b/scripts/android/tests/process_screenshots.py deleted file mode 100644 index 2e7bd8b9c5..0000000000 --- a/scripts/android/tests/process_screenshots.py +++ /dev/null @@ -1,630 +0,0 @@ -#!/usr/bin/env python3 -"""Compare CN1 screenshot outputs against stored references.""" - -from __future__ import annotations - -import argparse -import base64 -import io -import json -import pathlib -import shutil -import struct -import subprocess -import sys -import tempfile -import zlib -from dataclasses import dataclass -from typing import Dict, Iterable, List, Optional, Tuple, cast - -try: - from PIL import Image # type: ignore -except Exception: # pragma: no cover - optional dependency - Image = None - -MAX_COMMENT_BASE64 = 60_000 -JPEG_QUALITY_CANDIDATES = (70, 60, 50, 40, 30, 20, 10) - -PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" - - -class PNGError(Exception): - """Raised when a PNG cannot be parsed.""" - - -@dataclass -class PNGImage: - width: int - height: int - bit_depth: int - color_type: int - pixels: bytes - bytes_per_pixel: int - - -@dataclass -class CommentPayload: - base64: Optional[str] - base64_length: int - mime: str - codec: str - quality: Optional[int] = None - omitted_reason: Optional[str] = None - note: Optional[str] = None - data: Optional[bytes] = None - - -def _read_chunks(path: pathlib.Path) -> Iterable[Tuple[bytes, bytes]]: - data = path.read_bytes() - if not data.startswith(PNG_SIGNATURE): - raise PNGError(f"{path} is not a PNG file (missing signature)") - offset = len(PNG_SIGNATURE) - length = len(data) - while offset + 8 <= length: - chunk_len = int.from_bytes(data[offset : offset + 4], "big") - chunk_type = data[offset + 4 : offset + 8] - offset += 8 - if offset + chunk_len + 4 > length: - raise PNGError("PNG chunk truncated before CRC") - chunk_data = data[offset : offset + chunk_len] - offset += chunk_len + 4 # skip data + CRC - yield chunk_type, chunk_data - if chunk_type == b"IEND": - break - - -def _bytes_per_pixel(bit_depth: int, color_type: int) -> int: - if bit_depth != 8: - raise PNGError(f"Unsupported bit depth: {bit_depth}") - if color_type == 0: # greyscale - return 1 - if color_type == 2: # RGB - return 3 - if color_type == 4: # greyscale + alpha - return 2 - if color_type == 6: # RGBA - return 4 - raise PNGError(f"Unsupported color type: {color_type}") - - -def _paeth_predict(a: int, b: int, c: int) -> int: - p = a + b - c - pa = abs(p - a) - pb = abs(p - b) - pc = abs(p - c) - if pa <= pb and pa <= pc: - return a - if pb <= pc: - return b - return c - - -def _unfilter(width: int, height: int, bpp: int, raw: bytes) -> bytes: - stride = width * bpp - expected = height * (stride + 1) - if len(raw) != expected: - raise PNGError("PNG IDAT payload has unexpected length") - result = bytearray(height * stride) - in_offset = 0 - out_offset = 0 - for row in range(height): - filter_type = raw[in_offset] - in_offset += 1 - row_data = bytearray(raw[in_offset : in_offset + stride]) - in_offset += stride - if filter_type == 0: # None - pass - elif filter_type == 1: # Sub - for i in range(stride): - left = row_data[i - bpp] if i >= bpp else 0 - row_data[i] = (row_data[i] + left) & 0xFF - elif filter_type == 2: # Up - for i in range(stride): - up = result[out_offset - stride + i] if row > 0 else 0 - row_data[i] = (row_data[i] + up) & 0xFF - elif filter_type == 3: # Average - for i in range(stride): - left = row_data[i - bpp] if i >= bpp else 0 - up = result[out_offset - stride + i] if row > 0 else 0 - row_data[i] = (row_data[i] + ((left + up) // 2)) & 0xFF - elif filter_type == 4: # Paeth - for i in range(stride): - left = row_data[i - bpp] if i >= bpp else 0 - up = result[out_offset - stride + i] if row > 0 else 0 - up_left = result[out_offset - stride + i - bpp] if (row > 0 and i >= bpp) else 0 - row_data[i] = (row_data[i] + _paeth_predict(left, up, up_left)) & 0xFF - else: - raise PNGError(f"Unsupported PNG filter type: {filter_type}") - result[out_offset : out_offset + stride] = row_data - out_offset += stride - return bytes(result) - - -def load_png(path: pathlib.Path) -> PNGImage: - ihdr = None - idat_chunks: List[bytes] = [] - for chunk_type, chunk_data in _read_chunks(path): - if chunk_type == b"IHDR": - if ihdr is not None: - raise PNGError("Duplicate IHDR chunk") - if len(chunk_data) != 13: - raise PNGError("Invalid IHDR length") - width = int.from_bytes(chunk_data[0:4], "big") - height = int.from_bytes(chunk_data[4:8], "big") - bit_depth = chunk_data[8] - color_type = chunk_data[9] - # compression (10), filter (11), interlace (12) must be default values - if chunk_data[10] != 0 or chunk_data[11] != 0: - raise PNGError("Unsupported PNG compression or filter method") - if chunk_data[12] not in (0, 1): - raise PNGError("Unsupported PNG interlace method") - ihdr = (width, height, bit_depth, color_type, chunk_data[12]) - elif chunk_type == b"IDAT": - idat_chunks.append(chunk_data) - elif chunk_type == b"IEND": - break - else: - # Ancillary chunks are ignored (metadata) - continue - - if ihdr is None: - raise PNGError("Missing IHDR chunk") - if not idat_chunks: - raise PNGError("Missing IDAT data") - - width, height, bit_depth, color_type, interlace = ihdr - if interlace != 0: - raise PNGError("Interlaced PNGs are not supported") - - bpp = _bytes_per_pixel(bit_depth, color_type) - compressed = b"".join(idat_chunks) - try: - raw = zlib.decompress(compressed) - except Exception as exc: # pragma: no cover - defensive - raise PNGError(f"Failed to decompress IDAT data: {exc}") from exc - - pixels = _unfilter(width, height, bpp, raw) - return PNGImage(width, height, bit_depth, color_type, pixels, bpp) - - -def compare_images(expected: PNGImage, actual: PNGImage) -> Dict[str, bool]: - equal = ( - expected.width == actual.width - and expected.height == actual.height - and expected.bit_depth == actual.bit_depth - and expected.color_type == actual.color_type - and expected.pixels == actual.pixels - ) - return { - "equal": equal, - "width": actual.width, - "height": actual.height, - "bit_depth": actual.bit_depth, - "color_type": actual.color_type, - } - - -def _encode_png(width: int, height: int, bit_depth: int, color_type: int, bpp: int, pixels: bytes) -> bytes: - import zlib as _zlib - - if len(pixels) != width * height * bpp: - raise PNGError("Pixel buffer length does not match dimensions") - - def chunk(tag: bytes, payload: bytes) -> bytes: - crc = _zlib.crc32(tag + payload) & 0xFFFFFFFF - return ( - len(payload).to_bytes(4, "big") - + tag - + payload - + crc.to_bytes(4, "big") - ) - - raw = bytearray() - stride = width * bpp - for row in range(height): - raw.append(0) - start = row * stride - raw.extend(pixels[start : start + stride]) - - ihdr = struct.pack( - ">IIBBBBB", - width, - height, - bit_depth, - color_type, - 0, - 0, - 0, - ) - - compressed = _zlib.compress(bytes(raw)) - return b"".join( - [PNG_SIGNATURE, chunk(b"IHDR", ihdr), chunk(b"IDAT", compressed), chunk(b"IEND", b"")] - ) - - -def _prepare_pillow_image(image: PNGImage): - if Image is None: - raise RuntimeError("Pillow is not available") - mode_map = {0: "L", 2: "RGB", 4: "LA", 6: "RGBA"} - mode = mode_map.get(image.color_type) - if mode is None: - raise PNGError(f"Unsupported PNG color type for conversion: {image.color_type}") - pil_img = Image.frombytes(mode, (image.width, image.height), image.pixels) - if pil_img.mode == "LA": - pil_img = pil_img.convert("RGBA") - if pil_img.mode == "RGBA": - background = Image.new("RGB", pil_img.size, (255, 255, 255)) - alpha = pil_img.split()[-1] - background.paste(pil_img.convert("RGB"), mask=alpha) - pil_img = background - elif pil_img.mode != "RGB": - pil_img = pil_img.convert("RGB") - return pil_img - - -def _build_png_payload(image: PNGImage) -> bytes: - return _encode_png( - image.width, - image.height, - image.bit_depth, - image.color_type, - image.bytes_per_pixel, - image.pixels, - ) - - -def build_comment_payload(image: PNGImage, max_length: int = MAX_COMMENT_BASE64) -> CommentPayload: - note: Optional[str] = None - if Image is not None: - pil_img = cast("Image.Image", _prepare_pillow_image(image)) - scales = [1.0, 0.7, 0.5, 0.35, 0.25] - smallest_data: Optional[bytes] = None - smallest_quality: Optional[int] = None - for scale in scales: - candidate = pil_img - if scale < 1.0: - width = max(1, int(image.width * scale)) - height = max(1, int(image.height * scale)) - candidate = pil_img.copy() - candidate.thumbnail((width, height)) - for quality in JPEG_QUALITY_CANDIDATES: - buffer = io.BytesIO() - try: - candidate.save(buffer, format="JPEG", quality=quality, optimize=True) - except OSError: - buffer = io.BytesIO() - candidate.save(buffer, format="JPEG", quality=quality) - data = buffer.getvalue() - smallest_data = data - smallest_quality = quality - encoded = base64.b64encode(data).decode("ascii") - if len(encoded) <= max_length: - note_bits = [f"JPEG preview quality {quality}"] - if scale < 1.0: - note_bits.append(f"downscaled to {candidate.width}x{candidate.height}") - return CommentPayload( - base64=encoded, - base64_length=len(encoded), - mime="image/jpeg", - codec="jpeg", - quality=quality, - omitted_reason=None, - note="; ".join(note_bits), - data=data, - ) - if smallest_data is not None and smallest_quality is not None: - return CommentPayload( - base64=None, - base64_length=len(base64.b64encode(smallest_data).decode("ascii")), - mime="image/jpeg", - codec="jpeg", - quality=smallest_quality, - omitted_reason="too_large", - note="All JPEG previews exceeded limit even after downscaling", - data=smallest_data, - ) - note = "JPEG conversion unavailable" - else: - # Attempt an external conversion using ImageMagick/GraphicsMagick if - # Pillow isn't present on the runner. This keeps the previews JPEG-based - # while avoiding large dependencies in the workflow environment. - cli_payload = _build_comment_payload_via_cli(image, max_length) - if cli_payload is not None: - return cli_payload - note = "Pillow library not available; falling back to PNG previews." - - png_bytes = _build_png_payload(image) - encoded = base64.b64encode(png_bytes).decode("ascii") - if len(encoded) <= max_length: - return CommentPayload( - base64=encoded, - base64_length=len(encoded), - mime="image/png", - codec="png", - quality=None, - omitted_reason=None, - note=note, - data=png_bytes, - ) - return CommentPayload( - base64=None, - base64_length=len(encoded), - mime="image/png", - codec="png", - quality=None, - omitted_reason="too_large", - note=note, - data=png_bytes, - ) - - -def _build_comment_payload_via_cli( - image: PNGImage, max_length: int -) -> Optional[CommentPayload]: - """Attempt to generate a JPEG preview using an external CLI.""" - - converters = _detect_cli_converters() - if not converters: - return None - - png_bytes = _build_png_payload(image) - - with tempfile.TemporaryDirectory(prefix="cn1ss-cli-jpeg-") as tmp_dir: - tmp_dir_path = pathlib.Path(tmp_dir) - src = tmp_dir_path / "input.png" - dst = tmp_dir_path / "preview.jpg" - src.write_bytes(png_bytes) - - last_encoded: Optional[str] = None - last_length = 0 - last_quality: Optional[int] = None - last_data: Optional[bytes] = None - last_error: Optional[str] = None - - for quality in JPEG_QUALITY_CANDIDATES: - for converter in converters: - try: - _run_cli_converter(converter, src, dst, quality) - except RuntimeError as exc: - last_error = str(exc) - continue - if not dst.exists(): - last_error = "CLI converter did not create JPEG output" - continue - data = dst.read_bytes() - encoded = base64.b64encode(data).decode("ascii") - last_encoded = encoded - last_length = len(encoded) - last_quality = quality - last_data = data - if len(encoded) <= max_length: - return CommentPayload( - base64=encoded, - base64_length=len(encoded), - mime="image/jpeg", - codec="jpeg", - quality=quality, - omitted_reason=None, - note=f"JPEG preview generated via {converter[0]}", - data=data, - ) - break # try next quality once any converter succeeded - - if last_encoded is not None: - note = "" - if last_error: - note = last_error - return CommentPayload( - base64=None, - base64_length=last_length, - mime="image/jpeg", - codec="jpeg", - quality=last_quality, - omitted_reason="too_large", - note=(note or f"JPEG preview generated via {converters[0][0]}") - if converters - else note, - data=last_data, - ) - - return None - - -def _record_comment_payload( - record: Dict[str, object], - payload: CommentPayload, - default_name: str, - preview_dir: Optional[pathlib.Path], -) -> None: - if payload.base64 is not None: - record["base64"] = payload.base64 - else: - record.update( - {"base64_omitted": payload.omitted_reason, "base64_length": payload.base64_length} - ) - record.update({ - "base64_mime": payload.mime, - "base64_codec": payload.codec, - }) - if payload.quality is not None: - record["base64_quality"] = payload.quality - if payload.note: - record["base64_note"] = payload.note - - if preview_dir is None or payload.data is None: - return - - preview_dir.mkdir(parents=True, exist_ok=True) - suffix = ".jpg" if payload.mime == "image/jpeg" else ".png" - base_name = _slugify(default_name.rsplit(".", 1)[0] or "preview") - preview_path = preview_dir / f"{base_name}{suffix}" - preview_path.write_bytes(payload.data) - record["preview"] = { - "path": str(preview_path), - "name": preview_path.name, - "mime": payload.mime, - "codec": payload.codec, - "quality": payload.quality, - "note": payload.note, - } - - -def _load_external_preview_payload( - test_name: str, preview_dir: pathlib.Path -) -> Optional[CommentPayload]: - slug = _slugify(test_name) - candidates = ( - (preview_dir / f"{slug}.jpg", "image/jpeg", "jpeg"), - (preview_dir / f"{slug}.jpeg", "image/jpeg", "jpeg"), - (preview_dir / f"{slug}.png", "image/png", "png"), - ) - for path, mime, codec in candidates: - if not path.exists(): - continue - data = path.read_bytes() - encoded = base64.b64encode(data).decode("ascii") - note = "Preview provided by instrumentation" - if len(encoded) <= MAX_COMMENT_BASE64: - return CommentPayload( - base64=encoded, - base64_length=len(encoded), - mime=mime, - codec=codec, - quality=None, - omitted_reason=None, - note=note, - data=data, - ) - return CommentPayload( - base64=None, - base64_length=len(encoded), - mime=mime, - codec=codec, - quality=None, - omitted_reason="too_large", - note=note, - data=data, - ) - return None - - -def _detect_cli_converters() -> List[Tuple[str, ...]]: - """Return a list of available CLI converters (command tuples).""" - - candidates: List[Tuple[str, ...]] = [] - for cmd in (("magick", "convert"), ("convert",)): - if shutil.which(cmd[0]): - candidates.append(cmd) - return candidates - - -def _run_cli_converter( - command: Tuple[str, ...], src: pathlib.Path, dst: pathlib.Path, quality: int -) -> None: - """Execute the CLI converter.""" - - if not command: - raise RuntimeError("No converter command provided") - - cmd = list(command) - if len(cmd) == 2 and cmd[0] == "magick": - # magick convert -quality - cmd.extend([str(src), "-quality", str(quality), str(dst)]) - else: - # convert -quality - cmd.extend([str(src), "-quality", str(quality), str(dst)]) - - result = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - text=True, - ) - if result.returncode != 0: - raise RuntimeError( - f"{' '.join(command)} exited with {result.returncode}: {result.stderr.strip()}" - ) - - -def _slugify(name: str) -> str: - return "".join(ch if ch.isalnum() else "_" for ch in name) - - -def build_results( - reference_dir: pathlib.Path, - actual_entries: List[Tuple[str, pathlib.Path]], - emit_base64: bool, - preview_dir: Optional[pathlib.Path] = None, -) -> Dict[str, List[Dict[str, object]]]: - results: List[Dict[str, object]] = [] - for test_name, actual_path in actual_entries: - expected_path = reference_dir / f"{test_name}.png" - record: Dict[str, object] = { - "test": test_name, - "actual_path": str(actual_path), - "expected_path": str(expected_path), - } - if not actual_path.exists(): - record.update({"status": "missing_actual", "message": "Actual screenshot not found"}) - elif not expected_path.exists(): - record.update({"status": "missing_expected"}) - if emit_base64: - payload = None - if preview_dir is not None: - payload = _load_external_preview_payload(test_name, preview_dir) - if payload is None: - payload = build_comment_payload(load_png(actual_path)) - _record_comment_payload(record, payload, actual_path.name, preview_dir) - else: - try: - actual_img = load_png(actual_path) - expected_img = load_png(expected_path) - outcome = compare_images(expected_img, actual_img) - except Exception as exc: - record.update({"status": "error", "message": str(exc)}) - else: - if outcome["equal"]: - record.update({"status": "equal"}) - else: - record.update({"status": "different", "details": outcome}) - if emit_base64: - payload = None - if preview_dir is not None: - payload = _load_external_preview_payload(test_name, preview_dir) - if payload is None: - payload = build_comment_payload(actual_img) - _record_comment_payload(record, payload, actual_path.name, preview_dir) - results.append(record) - return {"results": results} - - -def parse_args(argv: List[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--reference-dir", required=True, type=pathlib.Path) - parser.add_argument("--emit-base64", action="store_true", help="Include base64 payloads for updated screenshots") - parser.add_argument("--preview-dir", type=pathlib.Path, help="Directory to store generated preview images") - parser.add_argument("--actual", action="append", default=[], help="Mapping of test=path to evaluate") - return parser.parse_args(argv) - - -def main(argv: List[str] | None = None) -> int: - args = parse_args(argv) - reference_dir: pathlib.Path = args.reference_dir - actual_entries: List[Tuple[str, pathlib.Path]] = [] - for item in args.actual: - if "=" not in item: - print(f"Invalid --actual value: {item}", file=sys.stderr) - return 2 - name, path_str = item.split("=", 1) - actual_entries.append((name, pathlib.Path(path_str))) - - preview_dir = args.preview_dir - payload = build_results(reference_dir, actual_entries, bool(args.emit_base64), preview_dir) - json.dump(payload, sys.stdout) - 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 deleted file mode 100644 index cd032846ea..0000000000 --- a/scripts/android/tests/render_screenshot_report.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/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/build-android-app.sh b/scripts/build-android-app.sh index 2d90cdc52c..0c2d8f6f2d 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -276,14 +276,21 @@ grep -q '^android.enableJetifier=' "$GRADLE_PROPS" 2>/dev/null || echo 'android. APP_BUILD_GRADLE="$GRADLE_PROJECT_DIR/app/build.gradle" ROOT_BUILD_GRADLE="$GRADLE_PROJECT_DIR/build.gradle" -PATCH_GRADLE_SCRIPT="$SCRIPT_DIR/android/lib/patch_gradle_files.py" +PATCH_GRADLE_SOURCE_PATH="$SCRIPT_DIR/android/lib" +PATCH_GRADLE_MAIN_CLASS="PatchGradleFiles" -if [ ! -x "$PATCH_GRADLE_SCRIPT" ]; then - ba_log "Missing gradle patch helper: $PATCH_GRADLE_SCRIPT" >&2 +if [ ! -f "$PATCH_GRADLE_SOURCE_PATH/$PATCH_GRADLE_MAIN_CLASS.java" ]; then + ba_log "Missing gradle patch helper: $PATCH_GRADLE_SOURCE_PATH/$PATCH_GRADLE_MAIN_CLASS.java" >&2 exit 1 fi -python3 "$PATCH_GRADLE_SCRIPT" \ +PATCH_GRADLE_JAVA="${JAVA17_HOME}/bin/java" +if [ ! -x "$PATCH_GRADLE_JAVA" ]; then + ba_log "JDK 17 java binary missing at $PATCH_GRADLE_JAVA" >&2 + exit 1 +fi + +"$PATCH_GRADLE_JAVA" "$PATCH_GRADLE_SOURCE_PATH/$PATCH_GRADLE_MAIN_CLASS.java" \ --root "$ROOT_BUILD_GRADLE" \ --app "$APP_BUILD_GRADLE" \ --compile-sdk 33 \ diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh index bd76d1bd79..081970f52f 100755 --- a/scripts/run-android-instrumentation-tests.sh +++ b/scripts/run-android-instrumentation-tests.sh @@ -8,14 +8,18 @@ ra_log() { echo "[run-android-instrumentation-tests] $1"; } ensure_dir() { mkdir -p "$1" 2>/dev/null || true; } -# CN1SS helpers are implemented in Python for easier maintenance -CN1SS_TOOL="" +# CN1SS helpers are implemented in Java for easier maintenance +CN1SS_SOURCE_PATH="" +CN1SS_MAIN_CLASS="Cn1ssChunkTools" +POST_COMMENT_CLASS="PostPrComment" +PROCESS_SCREENSHOTS_CLASS="ProcessScreenshots" +RENDER_SCREENSHOT_REPORT_CLASS="RenderScreenshotReport" count_chunks() { local f="${1:-}" local test="${2:-}" local channel="${3:-}" - if [ -z "$CN1SS_TOOL" ] || [ ! -x "$CN1SS_TOOL" ]; then + if [ ! -f "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" ]; then echo 0 return fi @@ -30,14 +34,14 @@ count_chunks() { if [ -n "$channel" ]; then args+=("--channel" "$channel") fi - python3 "$CN1SS_TOOL" "${args[@]}" 2>/dev/null || echo 0 + "$JAVA17_BIN" "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" "${args[@]}" 2>/dev/null || echo 0 } extract_cn1ss_base64() { local f="${1:-}" local test="${2:-}" local channel="${3:-}" - if [ -z "$CN1SS_TOOL" ] || [ ! -x "$CN1SS_TOOL" ]; then + if [ ! -f "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" ]; then return 1 fi if [ -z "$f" ] || [ ! -r "$f" ]; then @@ -50,14 +54,14 @@ extract_cn1ss_base64() { if [ -n "$channel" ]; then args+=("--channel" "$channel") fi - python3 "$CN1SS_TOOL" "${args[@]}" + "$JAVA17_BIN" "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" "${args[@]}" } decode_cn1ss_binary() { local f="${1:-}" local test="${2:-}" local channel="${3:-}" - if [ -z "$CN1SS_TOOL" ] || [ ! -x "$CN1SS_TOOL" ]; then + if [ ! -f "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" ]; then return 1 fi if [ -z "$f" ] || [ ! -r "$f" ]; then @@ -70,18 +74,18 @@ decode_cn1ss_binary() { if [ -n "$channel" ]; then args+=("--channel" "$channel") fi - python3 "$CN1SS_TOOL" "${args[@]}" + "$JAVA17_BIN" "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" "${args[@]}" } list_cn1ss_tests() { local f="${1:-}" - if [ -z "$CN1SS_TOOL" ] || [ ! -x "$CN1SS_TOOL" ]; then + if [ ! -f "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" ]; then return 1 fi if [ -z "$f" ] || [ ! -r "$f" ]; then return 1 fi - python3 "$CN1SS_TOOL" tests "$f" + "$JAVA17_BIN" "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" tests "$f" } @@ -111,7 +115,7 @@ 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 "$SCRIPT_DIR/android/tests/post_pr_comment.py" \ + GITHUB_TOKEN="$comment_token" "$JAVA17_BIN" "$CN1SS_SOURCE_PATH/$POST_COMMENT_CLASS.java" \ --body "$body_file" \ --preview-dir "$preview_dir" local rc=$? @@ -214,9 +218,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" -CN1SS_TOOL="$SCRIPT_DIR/android/tests/cn1ss_chunk_tools.py" -if [ ! -x "$CN1SS_TOOL" ]; then - ra_log "Missing CN1SS helper: $CN1SS_TOOL" >&2 +CN1SS_SOURCE_PATH="$SCRIPT_DIR/android/tests" +if [ ! -f "$CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" ]; then + ra_log "Missing CN1SS helper: $CN1SS_SOURCE_PATH/$CN1SS_MAIN_CLASS.java" >&2 exit 3 fi @@ -238,6 +242,17 @@ ra_log "Loading workspace environment from $ENV_FILE" # shellcheck disable=SC1090 source "$ENV_FILE" +if [ -z "${JAVA17_HOME:-}" ]; then + ra_log "JAVA17_HOME not set in workspace environment" >&2 + exit 3 +fi + +JAVA17_BIN="$JAVA17_HOME/bin/java" +if [ ! -x "$JAVA17_BIN" ]; then + ra_log "JDK 17 java binary missing at $JAVA17_BIN" >&2 + exit 3 +fi + [ -d "$GRADLE_PROJECT_DIR" ] || { ra_log "Gradle project directory not found: $GRADLE_PROJECT_DIR"; exit 4; } [ -x "$GRADLE_PROJECT_DIR/gradlew" ] || chmod +x "$GRADLE_PROJECT_DIR/gradlew" @@ -428,7 +443,7 @@ done COMPARE_JSON="$SCREENSHOT_TMP_DIR/screenshot-compare.json" export CN1SS_PREVIEW_DIR="$SCREENSHOT_PREVIEW_DIR" ra_log "STAGE:COMPARE -> Evaluating screenshots against stored references" -python3 "$SCRIPT_DIR/android/tests/process_screenshots.py" \ +"$JAVA17_BIN" "$CN1SS_SOURCE_PATH/$PROCESS_SCREENSHOTS_CLASS.java" \ --reference-dir "$SCREENSHOT_REF_DIR" \ --emit-base64 \ --preview-dir "$SCREENSHOT_PREVIEW_DIR" \ @@ -438,7 +453,7 @@ 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 "$SCRIPT_DIR/android/tests/render_screenshot_report.py" \ +"$JAVA17_BIN" "$CN1SS_SOURCE_PATH/$RENDER_SCREENSHOT_REPORT_CLASS.java" \ --compare-json "$COMPARE_JSON" \ --comment-out "$COMMENT_FILE" \ --summary-out "$SUMMARY_FILE"