Skip to content

Commit 896c0e0

Browse files
committed
Add BrowserComponent instrumentation screenshot regression test
1 parent 08b863b commit 896c0e0

File tree

5 files changed

+776
-135
lines changed

5 files changed

+776
-135
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Android Instrumentation Test Screenshots
2+
3+
This directory stores reference screenshots for Android native instrumentation tests.
4+
5+
Each PNG file should be named after the test stream that emits the screenshot
6+
(e.g. `MainActivity.png` or `BrowserComponent.png`). The automation in
7+
`scripts/run-android-instrumentation-tests.sh` compares the screenshots emitted
8+
by the emulator with the files stored here. If the pixels differ (ignoring PNG
9+
metadata) or if a reference image is missing, the workflow posts a pull request
10+
comment that includes the updated screenshot.
11+
12+
When the comparison passes, no screenshot artifacts are published and no
13+
comment is created.

scripts/android/tests/HelloCodenameOneInstrumentedTest.java

Lines changed: 153 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,42 @@
1313
import androidx.test.core.app.ApplicationProvider;
1414
import androidx.test.ext.junit.runners.AndroidJUnit4;
1515

16+
import com.codename1.ui.BrowserComponent;
17+
import com.codename1.ui.Display;
18+
import com.codename1.ui.Form;
19+
import com.codename1.ui.layouts.BorderLayout;
20+
1621
import org.junit.Assert;
22+
import org.junit.Assume;
1723
import org.junit.Test;
1824
import org.junit.runner.RunWith;
1925

2026
import java.io.ByteArrayOutputStream;
27+
import java.util.Locale;
28+
import java.util.concurrent.CountDownLatch;
29+
import java.util.concurrent.TimeUnit;
2130

2231
@RunWith(AndroidJUnit4.class)
2332
public class HelloCodenameOneInstrumentedTest {
2433

34+
private static final int CHUNK_SIZE = 2000;
35+
private static final String MAIN_SCREEN_TEST = "MainActivity";
36+
private static final String BROWSER_TEST = "BrowserComponent";
37+
2538
private static void println(String s) {
2639
System.out.println(s);
2740
}
2841

29-
@Test
30-
public void testUseAppContext_andEmitScreenshot() throws Exception {
31-
Context ctx = ApplicationProvider.getApplicationContext();
32-
String pkg = "@PACKAGE@";
33-
Assert.assertEquals("Package mismatch", pkg, ctx.getPackageName());
42+
private static void settle(long millis) {
43+
try {
44+
Thread.sleep(millis);
45+
} catch (InterruptedException ie) {
46+
Thread.currentThread().interrupt();
47+
}
48+
}
3449

50+
private static ActivityScenario<Activity> launchMainActivity(Context ctx) {
51+
String pkg = "@PACKAGE@";
3552
Intent launch = ctx.getPackageManager().getLaunchIntentForPackage(pkg);
3653
if (launch == null) {
3754
Intent q = new Intent(Intent.ACTION_MAIN);
@@ -40,73 +57,148 @@ public void testUseAppContext_andEmitScreenshot() throws Exception {
4057
launch = q;
4158
}
4259
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
60+
println("CN1SS:INFO: launching activity for test");
61+
return ActivityScenario.launch(launch);
62+
}
4363

44-
println("CN1SS:INFO: about to launch Activity");
45-
byte[] pngBytes = null;
46-
47-
try (ActivityScenario<Activity> scenario = ActivityScenario.launch(launch)) {
48-
Thread.sleep(750);
49-
50-
println("CN1SS:INFO: activity launched");
51-
52-
final byte[][] holder = new byte[1][];
53-
scenario.onActivity(activity -> {
54-
try {
55-
View root = activity.getWindow().getDecorView().getRootView();
56-
int w = root.getWidth();
57-
int h = root.getHeight();
58-
if (w <= 0 || h <= 0) {
59-
DisplayMetrics dm = activity.getResources().getDisplayMetrics();
60-
w = Math.max(1, dm.widthPixels);
61-
h = Math.max(1, dm.heightPixels);
62-
int sw = View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY);
63-
int sh = View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY);
64-
root.measure(sw, sh);
65-
root.layout(0, 0, w, h);
66-
println("CN1SS:INFO: forced layout to " + w + "x" + h);
67-
} else {
68-
println("CN1SS:INFO: natural layout " + w + "x" + h);
69-
}
70-
71-
Bitmap bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
72-
Canvas c = new Canvas(bmp);
73-
root.draw(c);
74-
75-
ByteArrayOutputStream baos = new ByteArrayOutputStream(Math.max(1024, w * h / 2));
76-
boolean ok = bmp.compress(Bitmap.CompressFormat.PNG, 100, baos);
77-
if (!ok) {
78-
throw new RuntimeException("Bitmap.compress returned false");
79-
}
80-
holder[0] = baos.toByteArray();
81-
println("CN1SS:INFO: png_bytes=" + holder[0].length);
82-
} catch (Throwable t) {
83-
println("CN1SS:ERR: onActivity " + t);
84-
t.printStackTrace(System.out);
64+
private static byte[] captureScreenshot(ActivityScenario<Activity> scenario, String testName) {
65+
final byte[][] holder = new byte[1][];
66+
scenario.onActivity(activity -> {
67+
try {
68+
View root = activity.getWindow().getDecorView().getRootView();
69+
int w = root.getWidth();
70+
int h = root.getHeight();
71+
if (w <= 0 || h <= 0) {
72+
DisplayMetrics dm = activity.getResources().getDisplayMetrics();
73+
w = Math.max(1, dm.widthPixels);
74+
h = Math.max(1, dm.heightPixels);
75+
int sw = View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY);
76+
int sh = View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY);
77+
root.measure(sw, sh);
78+
root.layout(0, 0, w, h);
79+
println("CN1SS:INFO:test=" + testName + " forced layout to " + w + "x" + h);
80+
} else {
81+
println("CN1SS:INFO:test=" + testName + " natural layout " + w + "x" + h);
8582
}
86-
});
8783

88-
pngBytes = holder[0];
89-
} catch (Throwable t) {
90-
println("CN1SS:ERR: launch " + t);
91-
t.printStackTrace(System.out);
92-
}
84+
Bitmap bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
85+
Canvas c = new Canvas(bmp);
86+
root.draw(c);
87+
88+
ByteArrayOutputStream baos = new ByteArrayOutputStream(Math.max(1024, w * h / 2));
89+
if (!bmp.compress(Bitmap.CompressFormat.PNG, 100, baos)) {
90+
throw new RuntimeException("Bitmap.compress returned false");
91+
}
92+
holder[0] = baos.toByteArray();
93+
println("CN1SS:INFO:test=" + testName + " png_bytes=" + holder[0].length);
94+
} catch (Throwable t) {
95+
println("CN1SS:ERR:test=" + testName + " " + t);
96+
t.printStackTrace(System.out);
97+
}
98+
});
99+
return holder[0];
100+
}
101+
102+
private static String sanitizeTestName(String testName) {
103+
return testName.replaceAll("[^A-Za-z0-9_.-]", "_");
104+
}
93105

106+
private static void emitScreenshot(byte[] pngBytes, String testName) {
107+
String safeName = sanitizeTestName(testName);
94108
if (pngBytes == null || pngBytes.length == 0) {
95-
println("CN1SS:END");
96-
Assert.fail("Screenshot capture produced 0 bytes");
109+
println("CN1SS:END:" + safeName);
110+
Assert.fail("Screenshot capture produced 0 bytes for " + testName);
97111
return;
98112
}
99-
100113
String b64 = Base64.encodeToString(pngBytes, Base64.NO_WRAP);
101-
final int chunkSize = 2000;
102114
int count = 0;
103-
for (int pos = 0; pos < b64.length(); pos += chunkSize) {
104-
int end = Math.min(pos + chunkSize, b64.length());
105-
System.out.println("CN1SS:" + String.format("%06d", pos) + ":" + b64.substring(pos, end));
115+
for (int pos = 0; pos < b64.length(); pos += CHUNK_SIZE) {
116+
int end = Math.min(pos + CHUNK_SIZE, b64.length());
117+
String chunk = b64.substring(pos, end);
118+
System.out.println("CN1SS:" + safeName + ":" + String.format(Locale.US, "%06d", pos) + ":" + chunk);
106119
count++;
107120
}
108-
println("CN1SS:INFO: chunks=" + count + " total_b64_len=" + b64.length());
109-
System.out.println("CN1SS:END");
121+
println("CN1SS:INFO:test=" + safeName + " chunks=" + count + " total_b64_len=" + b64.length());
122+
System.out.println("CN1SS:END:" + safeName);
110123
System.out.flush();
111124
}
125+
126+
private static void prepareBrowserComponentContent(ActivityScenario<Activity> scenario) throws InterruptedException {
127+
final CountDownLatch supportLatch = new CountDownLatch(1);
128+
final boolean[] supported = new boolean[1];
129+
130+
scenario.onActivity(activity -> Display.getInstance().callSerially(() -> {
131+
try {
132+
supported[0] = BrowserComponent.isNativeBrowserSupported();
133+
} finally {
134+
supportLatch.countDown();
135+
}
136+
}));
137+
138+
if (!supportLatch.await(5, TimeUnit.SECONDS)) {
139+
Assert.fail("Timed out while verifying BrowserComponent support");
140+
}
141+
142+
Assume.assumeTrue("BrowserComponent native support required for this test", supported[0]);
143+
144+
final CountDownLatch loadLatch = new CountDownLatch(1);
145+
final String html = "<html><head><meta charset='utf-8'/>"
146+
+ "<style>body{margin:0;font-family:sans-serif;background:#0e1116;color:#f3f4f6;}"
147+
+ ".container{padding:24px;text-align:center;}h1{font-size:24px;margin-bottom:12px;}"
148+
+ "p{font-size:16px;line-height:1.4;}span{color:#4cc9f0;}</style></head>"
149+
+ "<body><div class='container'><h1>Codename One</h1>"
150+
+ "<p>BrowserComponent <span>instrumentation</span> test content.</p></div></body></html>";
151+
152+
scenario.onActivity(activity -> Display.getInstance().callSerially(() -> {
153+
Form current = Display.getInstance().getCurrent();
154+
if (current == null) {
155+
current = new Form("Browser Test", new BorderLayout());
156+
current.show();
157+
} else {
158+
current.setLayout(new BorderLayout());
159+
current.setTitle("Browser Test");
160+
current.removeAll();
161+
}
162+
163+
BrowserComponent browser = new BrowserComponent();
164+
browser.addWebEventListener(BrowserComponent.onLoad, evt -> loadLatch.countDown());
165+
browser.setPage(html, null);
166+
current.add(BorderLayout.CENTER, browser);
167+
current.revalidate();
168+
}));
169+
170+
if (!loadLatch.await(10, TimeUnit.SECONDS)) {
171+
Assert.fail("Timed out waiting for BrowserComponent to load content");
172+
}
173+
}
174+
175+
@Test
176+
public void testUseAppContext_andEmitScreenshot() throws Exception {
177+
Context ctx = ApplicationProvider.getApplicationContext();
178+
String pkg = "@PACKAGE@";
179+
Assert.assertEquals("Package mismatch", pkg, ctx.getPackageName());
180+
181+
byte[] pngBytes;
182+
try (ActivityScenario<Activity> scenario = launchMainActivity(ctx)) {
183+
settle(750);
184+
pngBytes = captureScreenshot(scenario, MAIN_SCREEN_TEST);
185+
}
186+
187+
emitScreenshot(pngBytes, MAIN_SCREEN_TEST);
188+
}
189+
190+
@Test
191+
public void testBrowserComponentScreenshot() throws Exception {
192+
Context ctx = ApplicationProvider.getApplicationContext();
193+
byte[] pngBytes;
194+
195+
try (ActivityScenario<Activity> scenario = launchMainActivity(ctx)) {
196+
settle(750);
197+
prepareBrowserComponentContent(scenario);
198+
settle(500);
199+
pngBytes = captureScreenshot(scenario, BROWSER_TEST);
200+
}
201+
202+
emitScreenshot(pngBytes, BROWSER_TEST);
203+
}
112204
}

scripts/android/tests/cn1ss_chunk_tools.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,40 @@
1-
#!/usr/bin/env python3
2-
"""Helpers for extracting CN1SS chunked screenshot payloads."""
3-
from __future__ import annotations
4-
51
import argparse
62
import base64
73
import pathlib
84
import re
95
import sys
10-
from typing import Iterable, List, Tuple
6+
from typing import Iterable, List, Optional, Tuple
117

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

1411

15-
def _iter_chunk_lines(path: pathlib.Path) -> Iterable[Tuple[int, str]]:
12+
def _iter_chunk_lines(path: pathlib.Path, test_filter: Optional[str] = None) -> Iterable[Tuple[str, int, str]]:
1613
text = path.read_text(encoding="utf-8", errors="ignore")
1714
for line in text.splitlines():
1815
match = CHUNK_PATTERN.search(line)
1916
if not match:
2017
continue
21-
index = int(match.group(1))
22-
payload = re.sub(r"[^A-Za-z0-9+/=]", "", match.group(2))
18+
test_name = match.group("test") or DEFAULT_TEST_NAME
19+
if test_filter is not None and test_name != test_filter:
20+
continue
21+
index = int(match.group("index"))
22+
payload = re.sub(r"[^A-Za-z0-9+/=]", "", match.group(3))
2323
if payload:
24-
yield index, payload
24+
yield test_name, index, payload
2525

2626

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

3030

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

3535

36-
def decode_chunks(path: pathlib.Path) -> bytes:
37-
data = concatenate_chunks(path)
36+
def decode_chunks(path: pathlib.Path, test: Optional[str] = None) -> bytes:
37+
data = concatenate_chunks(path, test=test)
3838
if not data:
3939
return b""
4040
try:
@@ -43,28 +43,48 @@ def decode_chunks(path: pathlib.Path) -> bytes:
4343
return b""
4444

4545

46+
def list_tests(path: pathlib.Path) -> List[str]:
47+
seen = {test for test, _, _ in _iter_chunk_lines(path)}
48+
return sorted(seen)
49+
50+
4651
def main(argv: List[str] | None = None) -> int:
4752
parser = argparse.ArgumentParser(description=__doc__)
4853
subparsers = parser.add_subparsers(dest="command", required=True)
4954

5055
p_count = subparsers.add_parser("count", help="Count CN1SS chunks in a file")
5156
p_count.add_argument("path", type=pathlib.Path)
57+
p_count.add_argument("--test", dest="test", default=None, help="Optional test name filter")
5258

5359
p_extract = subparsers.add_parser("extract", help="Concatenate CN1SS payload chunks")
5460
p_extract.add_argument("path", type=pathlib.Path)
5561
p_extract.add_argument("--decode", action="store_true", help="Decode payload to binary PNG")
62+
p_extract.add_argument("--test", dest="test", default=None, help="Test name to extract (default=unnamed)")
63+
64+
p_tests = subparsers.add_parser("tests", help="List distinct test names found in CN1SS chunks")
65+
p_tests.add_argument("path", type=pathlib.Path)
5666

5767
args = parser.parse_args(argv)
5868

5969
if args.command == "count":
60-
print(count_chunks(args.path))
70+
print(count_chunks(args.path, args.test))
6171
return 0
6272

6373
if args.command == "extract":
74+
target_test: Optional[str]
75+
if args.test is None:
76+
target_test = DEFAULT_TEST_NAME
77+
else:
78+
target_test = args.test
6479
if args.decode:
65-
sys.stdout.buffer.write(decode_chunks(args.path))
80+
sys.stdout.buffer.write(decode_chunks(args.path, target_test))
6681
else:
67-
sys.stdout.write(concatenate_chunks(args.path))
82+
sys.stdout.write(concatenate_chunks(args.path, target_test))
83+
return 0
84+
85+
if args.command == "tests":
86+
for name in list_tests(args.path):
87+
print(name)
6888
return 0
6989

7090
return 1

0 commit comments

Comments
 (0)