@@ -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