@@ -6,89 +6,147 @@ final class HelloCodenameOneUITests: XCTestCase {
66 private var app: XCUIApplication!
77 private var outputDirectory: URL!
88 private var targetBundleIdentifier: String?
9- private var resolvedBundleIdentifier: String?
10- private let codenameOneSurfaceIdentifier = "cn1.glview"
9+ private var candidateDisplayNames: [String] = []
1110 private let chunkSize = 2000
1211 private let previewChannel = "PREVIEW"
1312 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]
1413 private let maxPreviewBytes = 20 * 1024
1514
1615 override func setUpWithError() throws {
1716 continueAfterFailure = false
18- if let bundleID = ProcessInfo.processInfo.environment["CN1_AUT_BUNDLE_ID"], !bundleID.isEmpty {
17+ let env = ProcessInfo.processInfo.environment
18+
19+ if let bundleID = env["CN1_AUT_BUNDLE_ID"], !bundleID.isEmpty {
1920 targetBundleIdentifier = bundleID
2021 app = XCUIApplication(bundleIdentifier: bundleID)
2122 } else {
2223 app = XCUIApplication()
23- targetBundleIdentifier = nil
2424 }
2525
26+ candidateDisplayNames = buildCandidateDisplayNames(from: env)
27+
2628 // Locale for determinism
2729 app.launchArguments += ["-AppleLocale", "en_US", "-AppleLanguages", "(en)"]
28- // Tip: force light mode or content size if you need pixel-stable shots
29- // app.launchArguments += ["-uiuserInterfaceStyle", "Light"]
3030
3131 // IMPORTANT: write to the app's sandbox, not a host path
3232 let tmp = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
33- if let tag = ProcessInfo.processInfo.environment ["CN1SS_OUTPUT_DIR"], !tag.isEmpty {
33+ if let tag = env ["CN1SS_OUTPUT_DIR"], !tag.isEmpty {
3434 outputDirectory = tmp.appendingPathComponent(tag, isDirectory: true)
3535 } else {
3636 outputDirectory = tmp.appendingPathComponent("cn1screens", isDirectory: true)
3737 }
3838 try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
3939
40- app.launch()
41- let reportedBundleId = targetBundleIdentifier ?? "(scheme-default)"
42- print("CN1SS:INFO:ui_test_target_bundle_id=\(reportedBundleId)")
40+ print("CN1SS:INFO:ui_test_target_bundle_id=\(targetBundleIdentifier ?? "(scheme-default)")")
4341 print("CN1SS:INFO:ui_test_launch_arguments=\(app.launchArguments.joined(separator: " "))")
44- if let resolved = resolveBundleIdentifier() {
45- resolvedBundleIdentifier = resolved
46- print("CN1SS:INFO:ui_test_resolved_bundle_id=\(resolved)")
47- } else {
48- print("CN1SS:WARN:ui_test_resolved_bundle_id_unavailable=true")
49- }
5042
51- let launchSurface = waitForCodenameOneSurface(timeout: 40, context: "post_launch")
52- logSurfaceMetrics(launchSurface, context: "post_launch")
43+ ensureAppLaunched()
5344 waitForStableFrame()
54-
55- let launchProbe = pollForRenderableContent(label: "launch_probe", timeout: 25, poll: 0.75)
56- if launchProbe.hasRenderableContent {
57- print("CN1SS:INFO:test=launch_probe rendered_content_detected=true attempts=\(launchProbe.attempts)")
58- } else {
59- print("CN1SS:WARN:test=launch_probe rendered_content_detected=false attempts=\(launchProbe.attempts)")
60- }
6145 }
6246
6347 override func tearDownWithError() throws {
6448 app?.terminate()
6549 app = nil
6650 }
6751
68- private func captureScreenshot(named name: String) throws {
69- let surface = waitForCodenameOneSurface(timeout: 20, context: "\(name)_pre_capture")
70- logSurfaceMetrics(surface, context: "\(name)_pre_capture")
52+ private func ensureAppLaunched(timeout: TimeInterval = 45) {
53+ if app.state == .runningForeground {
54+ logLaunchState(label: "already_running")
55+ return
56+ }
57+
58+ app.launch()
59+ if app.state == .runningForeground {
60+ logLaunchState(label: "launch")
61+ return
62+ }
63+
64+ if activateViaSpringboard(deadline: Date().addingTimeInterval(timeout)) {
65+ logLaunchState(label: "springboard")
66+ return
67+ }
68+
69+ app.activate()
70+ logLaunchState(label: "activate")
71+ }
72+
73+ private func activateViaSpringboard(deadline: Date) -> Bool {
74+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
75+ springboard.activate()
76+
77+ for name in candidateDisplayNames where Date() < deadline {
78+ let icon = springboard.icons[name]
79+ if icon.waitForExistence(timeout: 3) {
80+ print("CN1SS:INFO:springboard_icon_tap name=\(name)")
81+ icon.tap()
82+ if app.wait(for: .runningForeground, timeout: 5) {
83+ return true
84+ }
85+ }
86+ }
87+
88+ if Date() < deadline {
89+ let predicate = NSPredicate(format: "label CONTAINS[c] %@", "Codename")
90+ let fallbackIcon = springboard.icons.matching(predicate).firstMatch
91+ if fallbackIcon.waitForExistence(timeout: 3) {
92+ print("CN1SS:INFO:springboard_icon_fallback label=\(fallbackIcon.label)")
93+ fallbackIcon.tap()
94+ if app.wait(for: .runningForeground, timeout: 5) {
95+ return true
96+ }
97+ }
98+ }
99+ return app.state == .runningForeground
100+ }
101+
102+ private func buildCandidateDisplayNames(from env: [String: String]) -> [String] {
103+ var names: [String] = []
104+ if let explicit = env["CN1_AUT_APP_NAME"], !explicit.isEmpty {
105+ names.append(explicit)
106+ }
107+ names.append("HelloCodenameOne")
108+ names.append("Hello Codename One")
109+ if let bundle = env["CN1_AUT_BUNDLE_ID"], !bundle.isEmpty {
110+ if let suffix = bundle.split(separator: ".").last, !suffix.isEmpty {
111+ names.append(String(suffix))
112+ }
113+ }
114+ return Array(Set(names)).sorted()
115+ }
116+
117+ private func logLaunchState(label: String) {
118+ let state: String
119+ switch app.state {
120+ case .runningForeground: state = "running_foreground"
121+ case .runningBackground: state = "running_background"
122+ case .runningBackgroundSuspended: state = "running_background_suspended"
123+ case .notRunning: state = "not_running"
124+ @unknown default: state = "unknown"
125+ }
126+ print("CN1SS:INFO:launch_state label=\(label) state=\(state)")
127+ if let resolved = resolveBundleIdentifier() {
128+ print("CN1SS:INFO:ui_test_resolved_bundle_id=\(resolved)")
129+ } else {
130+ print("CN1SS:WARN:ui_test_resolved_bundle_id_unavailable=true")
131+ }
132+ }
71133
72- let result = pollForRenderableContent(label: name, timeout: 25, poll: 0.5)
134+ private func captureScreenshot(named name: String) throws {
135+ ensureAppLaunched()
136+ waitForStableFrame()
137+ let result = pollForRenderableContent(label: name, timeout: 30, poll: 0.6)
73138 let shot = result.screenshot
74139 if !result.hasRenderableContent {
75- print("CN1SS:WARN:test=\(name) rendered_content_not_detected_after_timeout=true attempts=\(result.attempts)")
76- print("CN1SS:ERROR:test=\(name) codenameone_render_assertion_failed attempts=\(result.attempts)")
77- attachDebugDescription(name: "\(name)_ui_tree")
78- XCTFail("Codename One UI did not render for test \(name) after \(result.attempts) attempt(s)")
140+ print("CN1SS:WARN:test=\(name) rendered_content_not_detected attempts=\(result.attempts) luma_variance=\(result.lumaVariance)")
79141 }
80142
81- logSurfaceMetrics(surface, context: "\(name)_post_capture")
82-
83- // Save into sandbox tmp (optional – mainly for local debugging)
84143 let pngURL = outputDirectory.appendingPathComponent("\(name).png")
85144 do { try shot.pngRepresentation.write(to: pngURL) } catch { /* ignore */ }
86145
87- // ALWAYS attach so we can export from the .xcresult
88- let att = XCTAttachment(screenshot: shot)
89- att.name = name
90- att.lifetime = .keepAlways
91- add(att)
146+ let attachment = XCTAttachment(screenshot: shot)
147+ attachment.name = name
148+ attachment.lifetime = .keepAlways
149+ add(attachment)
92150
93151 emitScreenshotPayloads(for: shot, name: name)
94152 }
@@ -101,84 +159,52 @@ final class HelloCodenameOneUITests: XCTestCase {
101159
102160 /// Tap using normalized coordinates (0...1)
103161 private func tapNormalized(_ dx: CGFloat, _ dy: CGFloat) {
162+ let frame = app.frame
163+ guard frame.width > 0, frame.height > 0 else {
164+ return
165+ }
104166 let origin = app.coordinate(withNormalizedOffset: .zero)
105- let target = origin.withOffset(.init(dx: app.frame.size.width * dx,
106- dy: app.frame.size.height * dy))
167+ let target = origin.withOffset(.init(dx: frame.width * dx, dy: frame.height * dy))
107168 target.tap()
108169 }
109170
110171 func testMainScreenScreenshot() throws {
111- waitForStableFrame()
112172 try captureScreenshot(named: "MainActivity")
113173 }
114174
115175 func testBrowserComponentScreenshot() throws {
116- waitForStableFrame()
117176 tapNormalized(0.5, 0.70)
118177 print("CN1SS:INFO:navigation_tap=browser_screen normalized_x=0.50 normalized_y=0.70")
119178 RunLoop.current.run(until: Date(timeIntervalSinceNow: 2.0))
120179 try captureScreenshot(named: "BrowserComponent")
121180 }
122181
123- private func pollForRenderableContent(label: String, timeout: TimeInterval, poll: TimeInterval) -> (screenshot: XCUIScreenshot, hasRenderableContent: Bool, attempts: Int) {
182+ private func pollForRenderableContent(label: String, timeout: TimeInterval, poll: TimeInterval) -> (screenshot: XCUIScreenshot, hasRenderableContent: Bool, attempts: Int, lumaVariance: Int ) {
124183 let deadline = Date(timeIntervalSinceNow: timeout)
125184 var attempts = 0
185+ var latestVariance = 0
126186 while true {
127187 attempts += 1
128188 let screenshot = app.screenshot()
129189 let analysis = analyzeScreenshot(screenshot)
130- let hasContent = analysis.hasRenderableContent
131- if hasContent {
132- print("CN1SS:INFO:test=\(label) rendered_frame_detected_attempt =\(attempts)")
133- return (screenshot, true, attempts)
190+ latestVariance = analysis.lumaVariance
191+ if analysis.hasRenderableContent {
192+ print("CN1SS:INFO:test=\(label) rendered_frame_detected attempt =\(attempts) luma_variance=\(analysis.lumaVariance )")
193+ return (screenshot, true, attempts, analysis.lumaVariance )
134194 }
135195
196+ print("CN1SS:INFO:test=\(label) waiting_for_rendered_frame attempt=\(attempts) luma_variance=\(analysis.lumaVariance)")
136197 let now = Date()
137198 if now >= deadline {
138- print("CN1SS:INFO :test=\(label) rendered_frame_luma_variance =\(analysis.lumaVariance)")
139- return (screenshot, false, attempts)
199+ print("CN1SS:WARN :test=\(label) rendered_content_timeout attempts=\(attempts) final_luma_variance =\(analysis.lumaVariance)")
200+ return (screenshot, false, attempts, analysis.lumaVariance )
140201 }
141202
142- print("CN1SS:INFO:test=\(label) waiting_for_rendered_frame attempt=\(attempts) luma_variance=\(analysis.lumaVariance)")
143203 let nextInterval = min(poll, deadline.timeIntervalSince(now))
144204 RunLoop.current.run(until: Date(timeIntervalSinceNow: nextInterval))
145205 }
146206 }
147207
148- private func waitForCodenameOneSurface(timeout: TimeInterval, context: String) -> XCUIElement? {
149- let surface = app.otherElements[codenameOneSurfaceIdentifier]
150- let exists = surface.waitForExistence(timeout: timeout)
151- print("CN1SS:INFO:codenameone_surface_wait context=\(context) identifier=\(codenameOneSurfaceIdentifier) timeout=\(timeout) exists=\(exists)")
152- if exists {
153- return surface
154- }
155- let fallback = app.screenshot()
156- let screenshotAttachment = XCTAttachment(screenshot: fallback)
157- screenshotAttachment.name = "\(context)_missing_surface_screen"
158- screenshotAttachment.lifetime = .keepAlways
159- add(screenshotAttachment)
160- attachDebugDescription(name: "\(context)_missing_surface")
161- print("CN1SS:WARN:codenameone_surface_missing context=\(context)")
162- return nil
163- }
164-
165- private func logSurfaceMetrics(_ surface: XCUIElement?, context: String) {
166- guard let surface = surface else {
167- print("CN1SS:INFO:codenameone_surface_metrics context=\(context) frame=absent hittable=false")
168- return
169- }
170- let frame = surface.frame
171- let formatted = String(format: "x=%.1f y=%.1f width=%.1f height=%.1f", frame.origin.x, frame.origin.y, frame.size.width, frame.size.height)
172- print("CN1SS:INFO:codenameone_surface_metrics context=\(context) frame=\(formatted) hittable=\(surface.isHittable)")
173- }
174-
175- private func attachDebugDescription(name: String) {
176- let attachment = XCTAttachment(string: app.debugDescription)
177- attachment.name = name
178- attachment.lifetime = .keepAlways
179- add(attachment)
180- }
181-
182208 private func analyzeScreenshot(_ screenshot: XCUIScreenshot) -> (hasRenderableContent: Bool, lumaVariance: Int) {
183209 guard let cgImage = screenshot.image.cgImage else {
184210 return (true, 255)
0 commit comments