Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions scripts/android/screenshots/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Android Instrumentation Test Screenshots

This directory stores reference screenshots for Android native instrumentation tests.

Each PNG file should be named after the test stream that emits the screenshot
(e.g. `MainActivity.png` or `BrowserComponent.png`). The automation in
`scripts/run-android-instrumentation-tests.sh` compares the screenshots emitted
by the emulator with the files stored here. If the pixels differ (ignoring PNG
metadata) or if a reference image is missing, the workflow posts a pull request
comment that includes the updated screenshot.

When the comparison passes, no screenshot artifacts are published and no
comment is created.
214 changes: 153 additions & 61 deletions scripts/android/tests/HelloCodenameOneInstrumentedTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,42 @@
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;

import com.codename1.ui.BrowserComponent;
import com.codename1.ui.Display;
import com.codename1.ui.Form;
import com.codename1.ui.layouts.BorderLayout;

import org.junit.Assert;
import org.junit.Assume;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.io.ByteArrayOutputStream;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

@RunWith(AndroidJUnit4.class)
public class HelloCodenameOneInstrumentedTest {

private static final int CHUNK_SIZE = 2000;
private static final String MAIN_SCREEN_TEST = "MainActivity";
private static final String BROWSER_TEST = "BrowserComponent";

private static void println(String s) {
System.out.println(s);
}

@Test
public void testUseAppContext_andEmitScreenshot() throws Exception {
Context ctx = ApplicationProvider.getApplicationContext();
String pkg = "@PACKAGE@";
Assert.assertEquals("Package mismatch", pkg, ctx.getPackageName());
private static void settle(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}

private static ActivityScenario<Activity> launchMainActivity(Context ctx) {
String pkg = "@PACKAGE@";
Intent launch = ctx.getPackageManager().getLaunchIntentForPackage(pkg);
if (launch == null) {
Intent q = new Intent(Intent.ACTION_MAIN);
Expand All @@ -40,73 +57,148 @@ public void testUseAppContext_andEmitScreenshot() throws Exception {
launch = q;
}
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
println("CN1SS:INFO: launching activity for test");
return ActivityScenario.launch(launch);
}

println("CN1SS:INFO: about to launch Activity");
byte[] pngBytes = null;

try (ActivityScenario<Activity> scenario = ActivityScenario.launch(launch)) {
Thread.sleep(750);

println("CN1SS:INFO: activity launched");

final byte[][] holder = new byte[1][];
scenario.onActivity(activity -> {
try {
View root = activity.getWindow().getDecorView().getRootView();
int w = root.getWidth();
int h = root.getHeight();
if (w <= 0 || h <= 0) {
DisplayMetrics dm = activity.getResources().getDisplayMetrics();
w = Math.max(1, dm.widthPixels);
h = Math.max(1, dm.heightPixels);
int sw = View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY);
int sh = View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY);
root.measure(sw, sh);
root.layout(0, 0, w, h);
println("CN1SS:INFO: forced layout to " + w + "x" + h);
} else {
println("CN1SS:INFO: natural layout " + w + "x" + h);
}

Bitmap bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
Canvas c = new Canvas(bmp);
root.draw(c);

ByteArrayOutputStream baos = new ByteArrayOutputStream(Math.max(1024, w * h / 2));
boolean ok = bmp.compress(Bitmap.CompressFormat.PNG, 100, baos);
if (!ok) {
throw new RuntimeException("Bitmap.compress returned false");
}
holder[0] = baos.toByteArray();
println("CN1SS:INFO: png_bytes=" + holder[0].length);
} catch (Throwable t) {
println("CN1SS:ERR: onActivity " + t);
t.printStackTrace(System.out);
private static byte[] captureScreenshot(ActivityScenario<Activity> scenario, String testName) {
final byte[][] holder = new byte[1][];
scenario.onActivity(activity -> {
try {
View root = activity.getWindow().getDecorView().getRootView();
int w = root.getWidth();
int h = root.getHeight();
if (w <= 0 || h <= 0) {
DisplayMetrics dm = activity.getResources().getDisplayMetrics();
w = Math.max(1, dm.widthPixels);
h = Math.max(1, dm.heightPixels);
int sw = View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY);
int sh = View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY);
root.measure(sw, sh);
root.layout(0, 0, w, h);
println("CN1SS:INFO:test=" + testName + " forced layout to " + w + "x" + h);
} else {
println("CN1SS:INFO:test=" + testName + " natural layout " + w + "x" + h);
}
});

pngBytes = holder[0];
} catch (Throwable t) {
println("CN1SS:ERR: launch " + t);
t.printStackTrace(System.out);
}
Bitmap bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
Canvas c = new Canvas(bmp);
root.draw(c);

ByteArrayOutputStream baos = new ByteArrayOutputStream(Math.max(1024, w * h / 2));
if (!bmp.compress(Bitmap.CompressFormat.PNG, 100, baos)) {
throw new RuntimeException("Bitmap.compress returned false");
}
holder[0] = baos.toByteArray();
println("CN1SS:INFO:test=" + testName + " png_bytes=" + holder[0].length);
} catch (Throwable t) {
println("CN1SS:ERR:test=" + testName + " " + t);
t.printStackTrace(System.out);
}
});
return holder[0];
}

private static String sanitizeTestName(String testName) {
return testName.replaceAll("[^A-Za-z0-9_.-]", "_");
}

private static void emitScreenshot(byte[] pngBytes, String testName) {
String safeName = sanitizeTestName(testName);
if (pngBytes == null || pngBytes.length == 0) {
println("CN1SS:END");
Assert.fail("Screenshot capture produced 0 bytes");
println("CN1SS:END:" + safeName);
Assert.fail("Screenshot capture produced 0 bytes for " + testName);
return;
}

String b64 = Base64.encodeToString(pngBytes, Base64.NO_WRAP);
final int chunkSize = 2000;
int count = 0;
for (int pos = 0; pos < b64.length(); pos += chunkSize) {
int end = Math.min(pos + chunkSize, b64.length());
System.out.println("CN1SS:" + String.format("%06d", pos) + ":" + b64.substring(pos, end));
for (int pos = 0; pos < b64.length(); pos += CHUNK_SIZE) {
int end = Math.min(pos + CHUNK_SIZE, b64.length());
String chunk = b64.substring(pos, end);
System.out.println("CN1SS:" + safeName + ":" + String.format(Locale.US, "%06d", pos) + ":" + chunk);
count++;
}
println("CN1SS:INFO: chunks=" + count + " total_b64_len=" + b64.length());
System.out.println("CN1SS:END");
println("CN1SS:INFO:test=" + safeName + " chunks=" + count + " total_b64_len=" + b64.length());
System.out.println("CN1SS:END:" + safeName);
System.out.flush();
}

private static void prepareBrowserComponentContent(ActivityScenario<Activity> scenario) throws InterruptedException {
final CountDownLatch supportLatch = new CountDownLatch(1);
final boolean[] supported = new boolean[1];

scenario.onActivity(activity -> Display.getInstance().callSerially(() -> {
try {
supported[0] = BrowserComponent.isNativeBrowserSupported();
} finally {
supportLatch.countDown();
}
}));

if (!supportLatch.await(5, TimeUnit.SECONDS)) {
Assert.fail("Timed out while verifying BrowserComponent support");
}

Assume.assumeTrue("BrowserComponent native support required for this test", supported[0]);

final CountDownLatch loadLatch = new CountDownLatch(1);
final String html = "<html><head><meta charset='utf-8'/>"
+ "<style>body{margin:0;font-family:sans-serif;background:#0e1116;color:#f3f4f6;}"
+ ".container{padding:24px;text-align:center;}h1{font-size:24px;margin-bottom:12px;}"
+ "p{font-size:16px;line-height:1.4;}span{color:#4cc9f0;}</style></head>"
+ "<body><div class='container'><h1>Codename One</h1>"
+ "<p>BrowserComponent <span>instrumentation</span> test content.</p></div></body></html>";

scenario.onActivity(activity -> Display.getInstance().callSerially(() -> {
Form current = Display.getInstance().getCurrent();
if (current == null) {
current = new Form("Browser Test", new BorderLayout());
current.show();
} else {
current.setLayout(new BorderLayout());
current.setTitle("Browser Test");
current.removeAll();
}

BrowserComponent browser = new BrowserComponent();
browser.addWebEventListener(BrowserComponent.onLoad, evt -> loadLatch.countDown());
browser.setPage(html, null);
current.add(BorderLayout.CENTER, browser);
current.revalidate();
}));

if (!loadLatch.await(10, TimeUnit.SECONDS)) {
Assert.fail("Timed out waiting for BrowserComponent to load content");
}
}

@Test
public void testUseAppContext_andEmitScreenshot() throws Exception {
Context ctx = ApplicationProvider.getApplicationContext();
String pkg = "@PACKAGE@";
Assert.assertEquals("Package mismatch", pkg, ctx.getPackageName());

byte[] pngBytes;
try (ActivityScenario<Activity> scenario = launchMainActivity(ctx)) {
settle(750);
pngBytes = captureScreenshot(scenario, MAIN_SCREEN_TEST);
}

emitScreenshot(pngBytes, MAIN_SCREEN_TEST);
}

@Test
public void testBrowserComponentScreenshot() throws Exception {
Context ctx = ApplicationProvider.getApplicationContext();
byte[] pngBytes;

try (ActivityScenario<Activity> scenario = launchMainActivity(ctx)) {
settle(750);
prepareBrowserComponentContent(scenario);
settle(500);
pngBytes = captureScreenshot(scenario, BROWSER_TEST);
}

emitScreenshot(pngBytes, BROWSER_TEST);
}
}
60 changes: 40 additions & 20 deletions scripts/android/tests/cn1ss_chunk_tools.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,40 @@
#!/usr/bin/env python3
"""Helpers for extracting CN1SS chunked screenshot payloads."""
from __future__ import annotations

import argparse
import base64
import pathlib
import re
import sys
from typing import Iterable, List, Tuple
from typing import Iterable, List, Optional, Tuple

CHUNK_PATTERN = re.compile(r"CN1SS:(\d{6}):(.*)")
DEFAULT_TEST_NAME = "default"
CHUNK_PATTERN = re.compile(r"CN1SS:(?:(?P<test>[A-Za-z0-9_.-]+):)?(?P<index>\d{6}):(.*)")


def _iter_chunk_lines(path: pathlib.Path) -> Iterable[Tuple[int, str]]:
def _iter_chunk_lines(path: pathlib.Path, test_filter: Optional[str] = None) -> 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
index = int(match.group(1))
payload = re.sub(r"[^A-Za-z0-9+/=]", "", match.group(2))
test_name = match.group("test") or DEFAULT_TEST_NAME
if test_filter is not None and test_name != test_filter:
continue
index = int(match.group("index"))
payload = re.sub(r"[^A-Za-z0-9+/=]", "", match.group(3))
if payload:
yield index, payload
yield test_name, index, payload


def count_chunks(path: pathlib.Path) -> int:
return sum(1 for _ in _iter_chunk_lines(path))
def count_chunks(path: pathlib.Path, test: Optional[str] = None) -> int:
return sum(1 for _ in _iter_chunk_lines(path, test_filter=test))


def concatenate_chunks(path: pathlib.Path) -> str:
ordered = sorted(_iter_chunk_lines(path), key=lambda item: item[0])
return "".join(payload for _, payload in ordered)
def concatenate_chunks(path: pathlib.Path, test: Optional[str] = None) -> str:
ordered = sorted(_iter_chunk_lines(path, test_filter=test), key=lambda item: item[1])
return "".join(payload for _, _, payload in ordered)


def decode_chunks(path: pathlib.Path) -> bytes:
data = concatenate_chunks(path)
def decode_chunks(path: pathlib.Path, test: Optional[str] = None) -> bytes:
data = concatenate_chunks(path, test=test)
if not data:
return b""
try:
Expand All @@ -43,28 +43,48 @@ def decode_chunks(path: pathlib.Path) -> bytes:
return b""


def list_tests(path: pathlib.Path) -> List[str]:
seen = {test for test, _, _ in _iter_chunk_lines(path)}
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_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_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))
print(count_chunks(args.path, args.test))
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))
sys.stdout.buffer.write(decode_chunks(args.path, target_test))
else:
sys.stdout.write(concatenate_chunks(args.path))
sys.stdout.write(concatenate_chunks(args.path, target_test))
return 0

if args.command == "tests":
for name in list_tests(args.path):
print(name)
return 0

return 1
Expand Down
Loading
Loading