Skip to content

Commit 960f1e7

Browse files
authored
Unify CN1 screenshot extraction for Android and iOS tests (#3999)
1 parent 7049a90 commit 960f1e7

File tree

4 files changed

+580
-331
lines changed

4 files changed

+580
-331
lines changed

scripts/ios/tests/HelloCodenameOneUITests.swift.tmpl

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import XCTest
2+
import UIKit
23

34
final class HelloCodenameOneUITests: XCTestCase {
45
private var app: XCUIApplication!
56
private var outputDirectory: URL!
7+
private let chunkSize = 2000
8+
private let previewChannel = "PREVIEW"
9+
private let previewQualities: [CGFloat] = [0.60, 0.50, 0.40, 0.35, 0.30, 0.25, 0.20, 0.18, 0.16, 0.14, 0.12, 0.10, 0.08, 0.06, 0.05, 0.04, 0.03, 0.02, 0.01]
10+
private let maxPreviewBytes = 20 * 1024
611

712
override func setUpWithError() throws {
813
continueAfterFailure = false
@@ -43,6 +48,8 @@ final class HelloCodenameOneUITests: XCTestCase {
4348
att.name = name
4449
att.lifetime = .keepAlways
4550
add(att)
51+
52+
emitScreenshotPayloads(for: shot, name: name)
4653
}
4754

4855
/// Wait for foreground + a short settle time
@@ -71,4 +78,82 @@ final class HelloCodenameOneUITests: XCTestCase {
7178
RunLoop.current.run(until: Date(timeIntervalSinceNow: 2.0))
7279
try captureScreenshot(named: "BrowserComponent")
7380
}
74-
}
81+
82+
private func sanitizeTestName(_ name: String) -> String {
83+
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-")
84+
let underscore: UnicodeScalar = "_"
85+
var scalars: [UnicodeScalar] = []
86+
scalars.reserveCapacity(name.unicodeScalars.count)
87+
for scalar in name.unicodeScalars {
88+
scalars.append(allowed.contains(scalar) ? scalar : underscore)
89+
}
90+
return String(String.UnicodeScalarView(scalars))
91+
}
92+
93+
private func emitScreenshotPayloads(for shot: XCUIScreenshot, name: String) {
94+
let safeName = sanitizeTestName(name)
95+
let pngData = shot.pngRepresentation
96+
print("CN1SS:INFO:test=\(safeName) png_bytes=\(pngData.count)")
97+
emitScreenshotChannel(data: pngData, name: safeName, channel: "")
98+
99+
if let preview = makePreviewJPEG(from: shot, pngData: pngData) {
100+
print("CN1SS:INFO:test=\(safeName) preview_jpeg_bytes=\(preview.data.count) preview_quality=\(preview.quality)")
101+
if preview.data.count > maxPreviewBytes {
102+
print("CN1SS:WARN:test=\(safeName) preview_exceeds_limit_bytes=\(preview.data.count) max_preview_bytes=\(maxPreviewBytes)")
103+
}
104+
emitScreenshotChannel(data: preview.data, name: safeName, channel: previewChannel)
105+
} else {
106+
print("CN1SS:INFO:test=\(safeName) preview_jpeg_bytes=0 preview_quality=0")
107+
}
108+
}
109+
110+
private func makePreviewJPEG(from shot: XCUIScreenshot, pngData: Data) -> (data: Data, quality: Int)? {
111+
guard let image = UIImage(data: pngData) else {
112+
return nil
113+
}
114+
var chosenData: Data?
115+
var chosenQuality = 0
116+
var smallest = Int.max
117+
for quality in previewQualities {
118+
guard let jpeg = image.jpegData(compressionQuality: quality) else { continue }
119+
let length = jpeg.count
120+
if length < smallest {
121+
smallest = length
122+
chosenData = jpeg
123+
chosenQuality = Int((quality * 100).rounded())
124+
}
125+
if length <= maxPreviewBytes {
126+
break
127+
}
128+
}
129+
guard let finalData = chosenData, !finalData.isEmpty else {
130+
return nil
131+
}
132+
return (finalData, chosenQuality)
133+
}
134+
135+
private func emitScreenshotChannel(data: Data, name: String, channel: String) {
136+
var prefix = "CN1SS"
137+
if !channel.isEmpty {
138+
prefix += channel
139+
}
140+
guard !data.isEmpty else {
141+
print("\(prefix):END:\(name)")
142+
return
143+
}
144+
let base64 = data.base64EncodedString()
145+
var current = base64.startIndex
146+
var position = 0
147+
var chunkCount = 0
148+
while current < base64.endIndex {
149+
let next = base64.index(current, offsetBy: chunkSize, limitedBy: base64.endIndex) ?? base64.endIndex
150+
let chunk = base64[current..<next]
151+
print("\(prefix):\(name):\(String(format: "%06d", position)):\(chunk)")
152+
chunkCount += 1
153+
position += chunk.count
154+
current = next
155+
}
156+
print("CN1SS:INFO:test=\(name) chunks=\(chunkCount) total_b64_len=\(base64.count)")
157+
print("\(prefix):END:\(name)")
158+
}
159+
}

scripts/lib/cn1ss.sh

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
#!/usr/bin/env bash
2+
# Shared helpers for Codename One screenshot (CN1SS) chunk processing
3+
4+
# Default class names used by the Java source helpers
5+
: "${CN1SS_MAIN_CLASS:=Cn1ssChunkTools}"
6+
: "${CN1SS_PROCESS_CLASS:=ProcessScreenshots}"
7+
: "${CN1SS_RENDER_CLASS:=RenderScreenshotReport}"
8+
: "${CN1SS_POST_COMMENT_CLASS:=PostPrComment}"
9+
10+
CN1SS_INITIALIZED=0
11+
CN1SS_JAVA_BIN=""
12+
CN1SS_JAVAC_BIN=""
13+
CN1SS_SOURCE_PATH=""
14+
CN1SS_CACHE_ROOT=""
15+
CN1SS_CLASS_DIR=""
16+
CN1SS_STAMP_FILE=""
17+
CN1SS_JAVA_CLASSPATH=""
18+
19+
if ! declare -p CN1SS_JAVA_OPTS >/dev/null 2>&1; then
20+
declare -a CN1SS_JAVA_OPTS=()
21+
fi
22+
if [ "${#CN1SS_JAVA_OPTS[@]}" -eq 0 ]; then
23+
CN1SS_JAVA_OPTS+=(-Djava.awt.headless=true)
24+
fi
25+
26+
cn1ss_setup() {
27+
CN1SS_JAVA_BIN="$1"
28+
CN1SS_SOURCE_PATH="$2"
29+
local cache_override="${3:-}" tmp_root
30+
31+
if [ -z "$CN1SS_JAVA_BIN" ] || [ ! -x "$CN1SS_JAVA_BIN" ]; then
32+
cn1ss_log "CN1SS setup failed: java binary not executable ($CN1SS_JAVA_BIN)"
33+
return 1
34+
fi
35+
if [ -z "$CN1SS_SOURCE_PATH" ] || [ ! -d "$CN1SS_SOURCE_PATH" ]; then
36+
cn1ss_log "CN1SS setup failed: source directory missing ($CN1SS_SOURCE_PATH)"
37+
return 1
38+
fi
39+
40+
if [ -z "$CN1SS_JAVAC_BIN" ]; then
41+
local java_dir
42+
java_dir="$(dirname "$CN1SS_JAVA_BIN")"
43+
if [ -x "$java_dir/javac" ]; then
44+
CN1SS_JAVAC_BIN="$java_dir/javac"
45+
elif command -v javac >/dev/null 2>&1; then
46+
CN1SS_JAVAC_BIN="$(command -v javac)"
47+
else
48+
cn1ss_log "CN1SS setup failed: unable to locate javac"
49+
return 1
50+
fi
51+
fi
52+
53+
tmp_root="${TMPDIR:-/tmp}"
54+
tmp_root="${tmp_root%/}"
55+
CN1SS_CACHE_ROOT="${cache_override:-${CN1SS_CACHE_DIR:-$tmp_root/cn1ss-java-cache}}"
56+
CN1SS_CLASS_DIR="$CN1SS_CACHE_ROOT/classes"
57+
CN1SS_STAMP_FILE="$CN1SS_CACHE_ROOT/.stamp"
58+
59+
if [ "$CN1SS_INITIALIZED" -eq 1 ] && [ -n "$CN1SS_JAVA_CLASSPATH" ] && [ -d "$CN1SS_JAVA_CLASSPATH" ]; then
60+
return 0
61+
fi
62+
63+
local need_compile=1
64+
if [ -d "$CN1SS_CLASS_DIR" ] && [ -f "$CN1SS_STAMP_FILE" ]; then
65+
if ! find "$CN1SS_SOURCE_PATH" -type f -name '*.java' -newer "$CN1SS_STAMP_FILE" -print -quit | grep -q .; then
66+
need_compile=0
67+
fi
68+
fi
69+
70+
if [ "$need_compile" -eq 1 ]; then
71+
mkdir -p "$CN1SS_CACHE_ROOT"
72+
rm -rf "$CN1SS_CLASS_DIR"
73+
mkdir -p "$CN1SS_CLASS_DIR"
74+
local -a sources=()
75+
while IFS= read -r -d '' src; do
76+
if grep -q '@PACKAGE@' "$src" 2>/dev/null; then
77+
cn1ss_log "Skipping template source $src"
78+
continue
79+
fi
80+
sources+=("$src")
81+
done < <(find "$CN1SS_SOURCE_PATH" -type f -name '*.java' -print0 | sort -z)
82+
if [ "${#sources[@]}" -eq 0 ]; then
83+
cn1ss_log "CN1SS setup failed: no Java sources found under $CN1SS_SOURCE_PATH"
84+
return 1
85+
fi
86+
cn1ss_log "Compiling CN1SS helpers -> $CN1SS_CLASS_DIR"
87+
local src display
88+
for src in "${sources[@]}"; do
89+
display="${src#$CN1SS_SOURCE_PATH/}"
90+
display="${display:-$(basename "$src")}"
91+
cn1ss_log " javac $display"
92+
if ! "$CN1SS_JAVAC_BIN" -d "$CN1SS_CLASS_DIR" -cp "$CN1SS_CLASS_DIR" "$src"; then
93+
cn1ss_log "CN1SS setup failed: javac returned non-zero status ($display)"
94+
return 1
95+
fi
96+
done
97+
touch "$CN1SS_STAMP_FILE" 2>/dev/null || true
98+
else
99+
cn1ss_log "Reusing CN1SS helpers in $CN1SS_CLASS_DIR"
100+
fi
101+
102+
CN1SS_JAVA_CLASSPATH="$CN1SS_CLASS_DIR"
103+
CN1SS_INITIALIZED=1
104+
}
105+
106+
cn1ss_log() {
107+
echo "[cn1ss] $*"
108+
}
109+
110+
cn1ss_java_run() {
111+
local class_name="$1"; shift
112+
if [ -z "${CN1SS_JAVA_BIN:-}" ] || [ ! -x "$CN1SS_JAVA_BIN" ]; then
113+
cn1ss_log "CN1SS_JAVA_BIN is not configured"
114+
return 1
115+
fi
116+
if [ -z "${CN1SS_JAVA_CLASSPATH:-}" ] || [ ! -d "$CN1SS_JAVA_CLASSPATH" ]; then
117+
cn1ss_log "CN1SS Java helpers not initialized; call cn1ss_setup first"
118+
return 1
119+
fi
120+
"$CN1SS_JAVA_BIN" "${CN1SS_JAVA_OPTS[@]}" -cp "$CN1SS_JAVA_CLASSPATH" "$class_name" "$@"
121+
}
122+
123+
cn1ss_count_chunks() {
124+
local file="$1"
125+
local test="${2:-}"
126+
local channel="${3:-}"
127+
if [ -z "$file" ] || [ ! -r "$file" ]; then
128+
echo 0
129+
return
130+
fi
131+
local args=("count" "$file")
132+
if [ -n "$test" ]; then
133+
args+=("--test" "$test")
134+
fi
135+
if [ -n "$channel" ]; then
136+
args+=("--channel" "$channel")
137+
fi
138+
cn1ss_java_run "$CN1SS_MAIN_CLASS" "${args[@]}" 2>/dev/null || echo 0
139+
}
140+
141+
cn1ss_extract_base64() {
142+
local file="$1"
143+
local test="${2:-}"
144+
local channel="${3:-}"
145+
if [ -z "$file" ] || [ ! -r "$file" ]; then
146+
return 1
147+
fi
148+
local args=("extract" "$file")
149+
if [ -n "$test" ]; then
150+
args+=("--test" "$test")
151+
fi
152+
if [ -n "$channel" ]; then
153+
args+=("--channel" "$channel")
154+
fi
155+
cn1ss_java_run "$CN1SS_MAIN_CLASS" "${args[@]}"
156+
}
157+
158+
cn1ss_decode_binary() {
159+
local file="$1"
160+
local test="${2:-}"
161+
local channel="${3:-}"
162+
if [ -z "$file" ] || [ ! -r "$file" ]; then
163+
return 1
164+
fi
165+
local args=("extract" "$file" "--decode")
166+
if [ -n "$test" ]; then
167+
args+=("--test" "$test")
168+
fi
169+
if [ -n "$channel" ]; then
170+
args+=("--channel" "$channel")
171+
fi
172+
cn1ss_java_run "$CN1SS_MAIN_CLASS" "${args[@]}"
173+
}
174+
175+
cn1ss_list_tests() {
176+
local file="$1"
177+
if [ -z "$file" ] || [ ! -r "$file" ]; then
178+
return 1
179+
fi
180+
cn1ss_java_run "$CN1SS_MAIN_CLASS" tests "$file"
181+
}
182+
183+
cn1ss_verify_png() {
184+
local file="$1"
185+
[ -s "$file" ] || return 1
186+
head -c 8 "$file" | od -An -t x1 | tr -d ' \n' | grep -qi '^89504e470d0a1a0a$'
187+
}
188+
189+
cn1ss_verify_jpeg() {
190+
local file="$1"
191+
[ -s "$file" ] || return 1
192+
local header trailer
193+
header="$(head -c 2 "$file" | od -An -t x1 | tr -d ' \n' | tr '[:lower:]' '[:upper:]')"
194+
trailer="$(tail -c 2 "$file" | od -An -t x1 | tr -d ' \n' | tr '[:lower:]' '[:upper:]')"
195+
[ "$header" = "FFD8" ] && [ "$trailer" = "FFD9" ]
196+
}
197+
198+
cn1ss_decode_test_asset() {
199+
local test="$1"; shift
200+
local dest="$1"; shift
201+
local channel="$1"; shift
202+
local verifier="$1"; shift
203+
local entry source_type source_path count
204+
205+
rm -f "$dest" 2>/dev/null || true
206+
for entry in "$@"; do
207+
source_type="${entry%%:*}"
208+
source_path="${entry#*:}"
209+
[ -s "$source_path" ] || continue
210+
count="$(cn1ss_count_chunks "$source_path" "$test" "$channel")"
211+
count="${count//[^0-9]/}"; : "${count:=0}"
212+
[ "$count" -gt 0 ] || continue
213+
cn1ss_log "Reassembling test '$test' from ${source_type} source: $source_path (chunks=$count)"
214+
if cn1ss_decode_binary "$source_path" "$test" "$channel" > "$dest" 2>/dev/null; then
215+
if [ -z "$verifier" ] || "$verifier" "$dest"; then
216+
echo "${source_type}:$(basename "$source_path")"
217+
return 0
218+
fi
219+
fi
220+
done
221+
rm -f "$dest" 2>/dev/null || true
222+
return 1
223+
}
224+
225+
cn1ss_decode_test_png() {
226+
local test="$1"; shift
227+
local dest="$1"; shift
228+
cn1ss_decode_test_asset "$test" "$dest" "" cn1ss_verify_png "$@"
229+
}
230+
231+
cn1ss_decode_test_preview() {
232+
local test="$1"; shift
233+
local dest="$1"; shift
234+
cn1ss_decode_test_asset "$test" "$dest" "PREVIEW" cn1ss_verify_jpeg "$@"
235+
}
236+
237+
cn1ss_file_size() {
238+
local file="$1"
239+
if [ ! -f "$file" ]; then
240+
echo 0
241+
return
242+
fi
243+
if stat --version >/dev/null 2>&1; then
244+
stat --printf='%s' "$file"
245+
elif stat -f '%z' "$file" >/dev/null 2>&1; then
246+
stat -f '%z' "$file"
247+
else
248+
wc -c < "$file" 2>/dev/null | tr -d ' \n'
249+
fi
250+
}
251+
252+
cn1ss_post_pr_comment() {
253+
local body_file="$1"
254+
local preview_dir="$2"
255+
if [ -z "$body_file" ] || [ ! -s "$body_file" ]; then
256+
cn1ss_log "Skipping PR comment post (no content)."
257+
return 0
258+
fi
259+
local comment_token="${GITHUB_TOKEN:-}"; local body_size
260+
if [ -z "$comment_token" ] && [ -n "${GH_TOKEN:-}" ]; then
261+
comment_token="${GH_TOKEN}"
262+
cn1ss_log "PR comment auth using GH_TOKEN fallback"
263+
fi
264+
if [ -z "$comment_token" ]; then
265+
cn1ss_log "PR comment skipped (no GitHub token available)"
266+
return 0
267+
fi
268+
if [ -z "${GITHUB_EVENT_PATH:-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ]; then
269+
cn1ss_log "PR comment skipped (GITHUB_EVENT_PATH unavailable)"
270+
return 0
271+
fi
272+
body_size=$(wc -c < "$body_file" 2>/dev/null || echo 0)
273+
cn1ss_log "Attempting to post PR comment (payload bytes=${body_size})"
274+
GITHUB_TOKEN="$comment_token" cn1ss_java_run "$CN1SS_POST_COMMENT_CLASS" \
275+
--body "$body_file" \
276+
--preview-dir "$preview_dir"
277+
local rc=$?
278+
if [ $rc -eq 0 ]; then
279+
cn1ss_log "Posted screenshot comparison comment to PR"
280+
else
281+
cn1ss_log "STAGE:COMMENT_POST_FAILED (see stderr for details)"
282+
if [ -n "${ARTIFACTS_DIR:-}" ]; then
283+
local failure_flag="$ARTIFACTS_DIR/pr-comment-failed.txt"
284+
printf 'Comment POST failed at %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" > "$failure_flag" 2>/dev/null || true
285+
fi
286+
fi
287+
return $rc
288+
}

0 commit comments

Comments
 (0)