Skip to content

Commit e910892

Browse files
committed
Capture UI screenshots via Android view rendering
1 parent acfea09 commit e910892

File tree

5 files changed

+338
-6
lines changed

5 files changed

+338
-6
lines changed

.github/workflows/scripts-android.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,10 @@ jobs:
2323
run: ./scripts/build-android-port.sh -q -DskipTests
2424
- name: Build Hello Codename One Android app
2525
run: ./scripts/build-android-app.sh -q -DskipTests
26+
- name: Upload UI test screenshot artifact
27+
if: always()
28+
uses: actions/upload-artifact@v3
29+
with:
30+
name: hello-codenameone-ui-test-screenshot
31+
path: ${{ env.CN1_UI_TEST_SCREENSHOT }}
32+
if-no-files-found: warn

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
**/.idea/*
2727
**/build/*
2828
**/dist/*
29+
build-artifacts/
2930
*.zip
3031
CodenameOneDesigner/src/version.properties
3132
/Ports/iOSPort/build/

scripts/build-android-app.sh

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -265,21 +265,95 @@ if [ -z "$GRADLE_PROJECT_DIR" ]; then
265265
exit 1
266266
fi
267267

268+
# --- Inject Robolectric UI test into Gradle project ---
269+
APP_MODULE_DIR=$(find "$GRADLE_PROJECT_DIR" -maxdepth 1 -type d -name "app" | head -n 1 || true)
270+
if [ -z "$APP_MODULE_DIR" ]; then
271+
ba_log "Unable to locate Gradle app module inside $GRADLE_PROJECT_DIR" >&2
272+
exit 1
273+
fi
274+
275+
UI_TEST_TEMPLATE="$SCRIPT_DIR/templates/HelloCodenameOneUiTest.java.tmpl"
276+
if [ ! -f "$UI_TEST_TEMPLATE" ]; then
277+
ba_log "UI test template not found: $UI_TEST_TEMPLATE" >&2
278+
exit 1
279+
fi
280+
281+
UI_TEST_DIR="$APP_MODULE_DIR/src/test/java/${PACKAGE_PATH}"
282+
mkdir -p "$UI_TEST_DIR"
283+
UI_TEST_FILE="$UI_TEST_DIR/${MAIN_NAME}UiTest.java"
284+
285+
sed -e "s|@PACKAGE@|$PACKAGE_NAME|g" \
286+
-e "s|@MAIN_NAME@|$MAIN_NAME|g" \
287+
"$UI_TEST_TEMPLATE" > "$UI_TEST_FILE"
288+
ba_log "Created Robolectric UI test at $UI_TEST_FILE"
289+
290+
APP_BUILD_GRADLE="$APP_MODULE_DIR/build.gradle"
291+
if [ ! -f "$APP_BUILD_GRADLE" ]; then
292+
ba_log "Expected Gradle build file not found at $APP_BUILD_GRADLE" >&2
293+
exit 1
294+
fi
295+
296+
"$SCRIPT_DIR/update_android_ui_test_gradle.py" "$APP_BUILD_GRADLE"
297+
298+
# Capture UI test screenshots in a deterministic directory
299+
SCREENSHOT_OUTPUT_DIR="$GRADLE_PROJECT_DIR/test-artifacts/screenshots"
300+
rm -rf "$SCREENSHOT_OUTPUT_DIR"
301+
mkdir -p "$SCREENSHOT_OUTPUT_DIR"
302+
export CN1_TEST_SCREENSHOT_DIR="$SCREENSHOT_OUTPUT_DIR"
303+
268304
ba_log "Invoking Gradle build in $GRADLE_PROJECT_DIR"
269305
chmod +x "$GRADLE_PROJECT_DIR/gradlew"
270306
ORIGINAL_JAVA_HOME="$JAVA_HOME"
271307
export JAVA_HOME="$JAVA17_HOME"
308+
if command -v sdkmanager >/dev/null 2>&1; then
309+
yes | sdkmanager --licenses >/dev/null 2>&1 || true
310+
elif [ -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then
311+
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
312+
fi
313+
314+
set +e
272315
(
273316
cd "$GRADLE_PROJECT_DIR"
274-
if command -v sdkmanager >/dev/null 2>&1; then
275-
yes | sdkmanager --licenses >/dev/null 2>&1 || true
276-
elif [ -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then
277-
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
278-
fi
279-
./gradlew --no-daemon assembleDebug
317+
./gradlew --no-daemon test
280318
)
319+
TEST_EXIT_CODE=$?
320+
set -e
321+
322+
if [ "$TEST_EXIT_CODE" -eq 0 ]; then
323+
(
324+
cd "$GRADLE_PROJECT_DIR"
325+
./gradlew --no-daemon assembleDebug
326+
)
327+
else
328+
ba_log "UI tests failed (exit code $TEST_EXIT_CODE); skipping assembleDebug"
329+
fi
281330
export JAVA_HOME="$ORIGINAL_JAVA_HOME"
282331

332+
SCREENSHOT_FILE=$(find "$SCREENSHOT_OUTPUT_DIR" -maxdepth 1 -name '*.png' | head -n 1 || true)
333+
SCREENSHOT_STATUS=0
334+
if [ -z "$SCREENSHOT_FILE" ]; then
335+
ba_log "UI test completed but no screenshot was produced in $SCREENSHOT_OUTPUT_DIR" >&2
336+
SCREENSHOT_STATUS=1
337+
else
338+
FINAL_SCREENSHOT_DIR="${CN1_TEST_SCREENSHOT_EXPORT_DIR:-$REPO_ROOT/build-artifacts}"
339+
mkdir -p "$FINAL_SCREENSHOT_DIR"
340+
FINAL_SCREENSHOT="$FINAL_SCREENSHOT_DIR/ui-test-screenshot.png"
341+
cp "$SCREENSHOT_FILE" "$FINAL_SCREENSHOT"
342+
if [ -n "${GITHUB_ENV:-}" ]; then
343+
printf 'CN1_UI_TEST_SCREENSHOT=%s\n' "$FINAL_SCREENSHOT" >> "$GITHUB_ENV"
344+
fi
345+
ba_log "UI test screenshot available at $FINAL_SCREENSHOT"
346+
fi
347+
unset CN1_TEST_SCREENSHOT_DIR
348+
349+
if [ "$TEST_EXIT_CODE" -ne 0 ]; then
350+
exit "$TEST_EXIT_CODE"
351+
fi
352+
353+
if [ "$SCREENSHOT_STATUS" -ne 0 ]; then
354+
exit 1
355+
fi
356+
283357
APK_PATH=$(find "$GRADLE_PROJECT_DIR" -path "*/outputs/apk/debug/*.apk" | head -n 1 || true)
284358
[ -n "$APK_PATH" ] || { ba_log "Gradle build completed but no APK was found" >&2; exit 1; }
285359
ba_log "Successfully built Android APK at $APK_PATH"
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package @PACKAGE@;
2+
3+
import static org.junit.Assert.assertNotNull;
4+
import static org.junit.Assert.assertTrue;
5+
6+
import android.graphics.Bitmap;
7+
import android.graphics.Canvas;
8+
import android.util.DisplayMetrics;
9+
import android.view.View;
10+
11+
import com.codename1.ui.Display;
12+
import com.codename1.ui.Form;
13+
14+
import java.io.File;
15+
import java.io.FileOutputStream;
16+
import java.io.IOException;
17+
import java.util.concurrent.CountDownLatch;
18+
import java.util.concurrent.TimeUnit;
19+
import java.util.concurrent.atomic.AtomicReference;
20+
21+
import org.junit.Test;
22+
import org.junit.runner.RunWith;
23+
import org.robolectric.Robolectric;
24+
import org.robolectric.RobolectricTestRunner;
25+
import org.robolectric.android.controller.ActivityController;
26+
import org.robolectric.annotation.Config;
27+
28+
@RunWith(RobolectricTestRunner.class)
29+
@Config(sdk = 30)
30+
public class @MAIN_NAME@UiTest {
31+
32+
@Test
33+
public void mainFormScreenshotContainsRenderedContent() throws Exception {
34+
ActivityController<@MAIN_NAME@Stub> controller = Robolectric.buildActivity(@[email protected]).setup();
35+
try {
36+
Form displayed = waitForDisplayedForm(5000L);
37+
assertNotNull("Codename One form should be displayed", displayed);
38+
39+
@MAIN_NAME@Stub activity = controller.get();
40+
Bitmap screenshot = captureScreenshot(activity);
41+
assertNotNull("Screenshot capture should succeed", screenshot);
42+
assertTrue("Screenshot width should be positive", screenshot.getWidth() > 0);
43+
assertTrue("Screenshot height should be positive", screenshot.getHeight() > 0);
44+
assertTrue("Screenshot should contain rendered content beyond the background", hasRenderableContent(screenshot));
45+
46+
File screenshotFile = saveScreenshot(screenshot);
47+
assertTrue("Screenshot file should exist", screenshotFile.isFile());
48+
assertTrue("Screenshot file should not be empty", screenshotFile.length() > 0L);
49+
} finally {
50+
controller.pause().stop().destroy();
51+
}
52+
}
53+
54+
private static Form waitForDisplayedForm(long timeoutMillis) throws InterruptedException {
55+
long deadline = System.currentTimeMillis() + timeoutMillis;
56+
final Form[] current = new Form[1];
57+
while (System.currentTimeMillis() < deadline) {
58+
if (Display.isInitialized()) {
59+
Display.getInstance().callSeriallyAndWait(() -> current[0] = Display.getInstance().getCurrent());
60+
if (current[0] != null) {
61+
return current[0];
62+
}
63+
}
64+
Thread.sleep(50L);
65+
}
66+
throw new AssertionError("Timed out waiting for Codename One form to be displayed");
67+
}
68+
69+
private static Bitmap captureScreenshot(@MAIN_NAME@Stub activity) throws InterruptedException {
70+
View decorView = waitForDecorViewWithDimensions(activity, 5000L);
71+
Bitmap bitmap = Bitmap.createBitmap(decorView.getWidth(), decorView.getHeight(), Bitmap.Config.ARGB_8888);
72+
runOnUiThreadAndWait(activity, () -> {
73+
Canvas canvas = new Canvas(bitmap);
74+
decorView.draw(canvas);
75+
});
76+
return bitmap;
77+
}
78+
79+
private static boolean hasRenderableContent(Bitmap screenshot) {
80+
int width = screenshot.getWidth();
81+
int height = screenshot.getHeight();
82+
if (width <= 0 || height <= 0) {
83+
return false;
84+
}
85+
int[] pixels = new int[width * height];
86+
screenshot.getPixels(pixels, 0, width, 0, 0, width, height);
87+
if (pixels.length == 0) {
88+
return false;
89+
}
90+
int background = pixels[0];
91+
int contentPixels = 0;
92+
for (int argb : pixels) {
93+
int alpha = (argb >>> 24) & 0xFF;
94+
if (alpha == 0) {
95+
continue;
96+
}
97+
if (argb != background) {
98+
contentPixels++;
99+
if (contentPixels > width) {
100+
return true;
101+
}
102+
}
103+
}
104+
return false;
105+
}
106+
107+
private static File saveScreenshot(Bitmap screenshot) throws IOException {
108+
String directory = System.getenv("CN1_TEST_SCREENSHOT_DIR");
109+
File outputDir = (directory != null && !directory.isEmpty()) ? new File(directory) : new File("build/ui-test-screenshots");
110+
if (!outputDir.exists() && !outputDir.mkdirs()) {
111+
throw new IOException("Failed to create screenshot directory " + outputDir.getAbsolutePath());
112+
}
113+
File screenshotFile = new File(outputDir, "@[email protected]");
114+
try (FileOutputStream out = new FileOutputStream(screenshotFile)) {
115+
if (!screenshot.compress(Bitmap.CompressFormat.PNG, 100, out)) {
116+
throw new IOException("Failed to encode screenshot as PNG");
117+
}
118+
}
119+
return screenshotFile;
120+
}
121+
122+
private static View waitForDecorViewWithDimensions(@MAIN_NAME@Stub activity, long timeoutMillis) throws InterruptedException {
123+
long deadline = System.currentTimeMillis() + timeoutMillis;
124+
while (System.currentTimeMillis() < deadline) {
125+
View decorView = activity.getWindow().getDecorView();
126+
if (decorView != null) {
127+
ensureViewHasLayout(activity, decorView);
128+
if (decorView.getWidth() > 0 && decorView.getHeight() > 0) {
129+
return decorView;
130+
}
131+
}
132+
Thread.sleep(50L);
133+
}
134+
throw new AssertionError("Timed out waiting for decor view layout");
135+
}
136+
137+
private static void ensureViewHasLayout(@MAIN_NAME@Stub activity, View view) throws InterruptedException {
138+
if (view.getWidth() > 0 && view.getHeight() > 0) {
139+
return;
140+
}
141+
runOnUiThreadAndWait(activity, () -> {
142+
DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
143+
int widthSpec = View.MeasureSpec.makeMeasureSpec(metrics.widthPixels, View.MeasureSpec.EXACTLY);
144+
int heightSpec = View.MeasureSpec.makeMeasureSpec(metrics.heightPixels, View.MeasureSpec.EXACTLY);
145+
view.measure(widthSpec, heightSpec);
146+
view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight());
147+
});
148+
}
149+
150+
private static void runOnUiThreadAndWait(@MAIN_NAME@Stub activity, Runnable runnable) throws InterruptedException {
151+
CountDownLatch latch = new CountDownLatch(1);
152+
AtomicReference<Throwable> error = new AtomicReference<>();
153+
activity.runOnUiThread(() -> {
154+
try {
155+
runnable.run();
156+
} catch (Throwable t) {
157+
error.set(t);
158+
} finally {
159+
latch.countDown();
160+
}
161+
});
162+
if (!latch.await(5, TimeUnit.SECONDS)) {
163+
throw new AssertionError("Timed out waiting for UI thread task");
164+
}
165+
if (error.get() != null) {
166+
Throwable thrown = error.get();
167+
if (thrown instanceof RuntimeException) {
168+
throw (RuntimeException) thrown;
169+
}
170+
if (thrown instanceof Error) {
171+
throw (Error) thrown;
172+
}
173+
throw new RuntimeException(thrown);
174+
}
175+
}
176+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env python3
2+
"""Utility to inject Robolectric UI test settings into an Android Gradle module."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import pathlib
7+
import re
8+
import sys
9+
10+
11+
def ensure_test_options(content: str) -> str:
12+
if "unitTests.includeAndroidResources" in content:
13+
return content
14+
15+
pattern = re.compile(r"(android\s*\{\s*\r?\n)")
16+
match = pattern.search(content)
17+
if not match:
18+
raise SystemExit("Unable to locate android block in Gradle file")
19+
20+
injection = (
21+
" testOptions {\n"
22+
" unitTests.includeAndroidResources = true\n"
23+
" }\n\n"
24+
)
25+
start, end = match.span(1)
26+
return content[:end] + injection + content[end:]
27+
28+
29+
def ensure_dependencies(content: str) -> str:
30+
if "org.robolectric:robolectric" in content:
31+
return content
32+
33+
additions = (
34+
" testImplementation 'junit:junit:4.13.2'\n"
35+
" testImplementation 'androidx.test:core:1.5.0'\n"
36+
" testImplementation 'org.robolectric:robolectric:4.11.1'\n"
37+
" androidTestImplementation 'androidx.test.ext:junit:1.1.5'\n"
38+
" androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'\n"
39+
" androidTestImplementation 'androidx.test:rules:1.5.0'\n"
40+
" androidTestImplementation 'androidx.test:runner:1.5.2'\n"
41+
)
42+
43+
pattern = re.compile(r"dependencies\s*\{\s*\r?\n", re.MULTILINE)
44+
matches = list(pattern.finditer(content))
45+
if not matches:
46+
raise SystemExit("Unable to locate dependencies block in Gradle file")
47+
48+
target = matches[-1] if len(matches) == 1 else matches[1]
49+
insert_at = target.end()
50+
return content[:insert_at] + additions + content[insert_at:]
51+
52+
53+
def process_gradle_file(path: pathlib.Path) -> None:
54+
content = path.read_text(encoding="utf-8")
55+
updated = ensure_test_options(content)
56+
updated = ensure_dependencies(updated)
57+
if updated != content:
58+
path.write_text(updated, encoding="utf-8")
59+
60+
61+
def main(argv: list[str] | None = None) -> int:
62+
parser = argparse.ArgumentParser(description=__doc__)
63+
parser.add_argument("gradle_file", type=pathlib.Path, help="Path to module build.gradle file")
64+
args = parser.parse_args(argv)
65+
66+
if not args.gradle_file.is_file():
67+
parser.error(f"Gradle file not found: {args.gradle_file}")
68+
69+
process_gradle_file(args.gradle_file)
70+
return 0
71+
72+
73+
if __name__ == "__main__":
74+
sys.exit(main())

0 commit comments

Comments
 (0)