Skip to content

Run screenshot tests on macOS and Windows CI#2636

Draft
Copilot wants to merge 10 commits intomasterfrom
copilot/run-screenshot-tests-android-windows-macos
Draft

Run screenshot tests on macOS and Windows CI#2636
Copilot wants to merge 10 commits intomasterfrom
copilot/run-screenshot-tests-android-windows-macos

Conversation

Copy link
Contributor

Copilot AI commented Mar 3, 2026

Screenshot tests previously only ran on Linux (Ubuntu + OpenGL Docker container). This adds macOS and Windows CI jobs so visual regressions are caught across platforms.

Changes

CI (main.yml)

  • ScreenshotTestsMacOS — new job on macos-13 (Intel x86_64) using Java 21; artifacts as screenshot-test-report-macos. Uses macos-13 rather than macos-latest because GLFW 3.4's NSGL backend can fail with "NSGL: Failed to find a suitable pixel format" on Apple Silicon CI environments where Apple's OpenGL compatibility layer has restrictions in virtualised setups.
  • ScreenshotTestsWindows — new job on windows-latest using Java 21; downloads Mesa3D pal1000/mesa-dist-win v24.2.4 for software OpenGL (no hardware GPU on GH Actions Windows runners); uses GALLIUM_DRIVER=softpipe to avoid slow LLVM JIT startup; all Mesa env vars (GALLIUM_DRIVER, MESA_GL_VERSION_OVERRIDE, MESA_GLSL_VERSION_OVERRIDE) are persisted via GITHUB_ENV so they are available to the test step; artifacts as screenshot-test-report-windows

jme3-screenshot-tests/build.gradle

  • Adds -XstartOnFirstThread JVM arg on macOS — GLFW/LWJGL3 requires all window operations on the main OS thread
  • Reads MESA3D_LIBS_PATH env var and forwards it as org.lwjgl.librarypath system property so LWJGL3 extracts its native DLLs into the same directory as Mesa's opengl32.dll; Windows DLL search order then picks up Mesa's software renderer over the system stub
  • Sets jme.screenshotTest.pixelTolerance=5 on macOS and Windows so that minor per-pixel rendering differences (GPU driver rounding) don't cause spurious failures

TestDriver.java

On macOS with -XstartOnFirstThread, JUnit runs on the macOS main thread. app.start() is now called directly (blocking) on the current thread instead of dispatching to an executor, satisfying GLFW's main-thread requirement.

Additional robustness improvements for all non-Linux platforms:

  • App.onError is pre-set in bootAppForTest() before app.start() so that context-creation failures (which happen before TestDriver.initialize() runs) immediately count down the latch instead of leaving the test thread blocked for the full timeout
  • The executor lambda is wrapped in try/catch to handle UnsatisfiedLinkError and similar exceptions that would otherwise swallow startup failures silently
  • app.stop(true) in the timeout path is replaced with app.stop(false) (non-blocking) to prevent the test thread from deadlocking if glfwTerminate() hangs inside Mesa3D
  • Added PIXEL_TOLERANCE constant read from jme.screenshotTest.pixelTolerance system property; imagesAreTheSame() uses getMaximumComponentDifference() per pixel when tolerance > 0, allowing minor cross-platform rendering differences while still catching real visual regressions

screenshot-test-comment.yml

  • Waits for all three screenshot jobs (Linux, macOS, Windows) before checking status
  • PR comment now lists which platform(s) failed and notes that macOS/Windows pixel differences vs Linux reference images may reflect driver variation rather than regressions
  • Timeout bumped from 60 → 90 min to accommodate three sequential waits

Notes

  • Reference images are generated on the Linux CI pipeline and act as the ground truth. macOS and Windows tests compare against these images with a tolerance of 5 per channel (≤ ~2% per channel), so they pass if rendering is visually equivalent while still catching real visual regressions.
  • Android is not included — it requires an emulator + Android rendering backend and is significantly more involved
Original prompt

This section details on the original issue you should resolve

<issue_title>Run screenshot tests on android, windows and macos</issue_title>
<issue_description>Screenshot tests should run also on:

  • window
  • macos
  • android
    (if possible)</issue_description>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

- Add ScreenshotTestsMacOS job (macos-latest) to main.yml
- Add ScreenshotTestsWindows job (windows-latest) with Mesa3D setup to main.yml
- Update build.gradle to add -XstartOnFirstThread on macOS and Mesa3D lib path support
- Update TestDriver.java to run app on main thread on macOS (GLFW requirement)
- Update screenshot-test-comment.yml to monitor all three platform jobs

Co-authored-by: riccardobl <4943530+riccardobl@users.noreply.github.com>
Copilot AI changed the title [WIP] Run screenshot tests on Android, Windows, and macOS Run screenshot tests on macOS and Windows CI Mar 3, 2026
@riccardobl
Copy link
Member

riccardobl commented Mar 3, 2026

@copilot fails on macos

Mar 03, 2026 2:27:40 AM com.jme3.app.LegacyApplication handleError SEVERE: NSGL: Failed to find a suitable pixel format java.lang.Exception: NSGL: Failed to find a suitable pixel format at com.jme3.system.lwjgl.LwjglWindow$1.invoke(LwjglWindow.java:271) at org.lwjgl.glfw.GLFWErrorCallbackI.callback(GLFWErrorCallbackI.java:34) at org.lwjgl.system.JNI.invokePPPP(Native Method) at org.lwjgl.glfw.GLFW.nglfwCreateWindow(GLFW.java:1007) at org.lwjgl.glfw.GLFW.glfwCreateWindow(GLFW.java:1026) at com.jme3.system.lwjgl.LwjglWindow.createContext(LwjglWindow.java:388) at com.jme3.system.lwjgl.LwjglWindow.initInThread(LwjglWindow.java:691) at com.jme3.system.lwjgl.LwjglWindow.run(LwjglWindow.java:813) at com.jme3.system.lwjgl.LwjglWindow.create(LwjglWindow.java:654) at com.jme3.app.LegacyApplication.start(LegacyApplication.java:510) at com.jme3.app.LegacyApplication.start(LegacyApplication.java:485) at org.jmonkeyengine.screenshottests.testframework.TestDriver.bootAppForTest(TestDriver.java:195) at org.jmonkeyengine.screenshottests.testframework.ScreenshotTest.run(ScreenshotTest.java:111) at org.jmonkeyengine.screenshottests.animation.TestIssue2076.testIssue2076(TestIssue2076.java:141) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) at java.base/java.lang.reflect.Method.invoke(Method.java:580) at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:727) at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60) at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131) at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:156) at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:147) at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86) at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:103) at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:93) at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106) at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64) at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45) at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37) at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:92) at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:86) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$7(TestMethodTestDescriptor.java:217) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:213) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:138) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35) at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57) at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:107) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52) at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:86) at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:86) at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.processAllTestClasses(JUnitPlatformTestClassProcessor.java:119) at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.access$000(JUnitPlatformTestClassProcessor.java:94) at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor.stop(JUnitPlatformTestClassProcessor.java:89) at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.stop(SuiteTestClassProcessor.java:62) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) at java.base/java.lang.reflect.Method.invoke(Method.java:580) at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:36) at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:24) at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:94) at jdk.proxy1/jdk.proxy1.$Proxy2.stop(Unknown Source) at org.gradle.api.internal.tasks.testing.worker.TestWorker$3.run(TestWorker.java:193) at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:129) at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:100) at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:60) at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:119) at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:66) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) 

and it seems stuck on windows (can't terminate after the test, somehow)

also, please make the linux screenshots the ground truth and compare all the other platforms to linux output, make the test pass if the results are within a reasonable threshold.

- macOS: change runner to macos-13 (Intel) to avoid NSGL pixel format error on
  Apple Silicon CI with GLFW 3.4; the Apple OpenGL compat layer on macOS 14+
  can fail to find a suitable pixel format in virtualised CI environments
- Windows: pre-set onError before app.start() so context-creation failures
  count down the latch immediately; wrap executor lambda in try/catch for
  UnsatisfiedLinkError; use app.stop(false) in timeout path to avoid
  deadlocking if glfwTerminate hangs inside Mesa3D; fix Mesa3D env vars
  (GALLIUM_DRIVER, MESA_GL_VERSION_OVERRIDE) to persist via GITHUB_ENV;
  use softpipe driver to avoid slow LLVM JIT startup from llvmpipe
- Cross-platform threshold: add jme.screenshotTest.pixelTolerance system
  property; imagesAreTheSame() now allows per-pixel differences within the
  tolerance (using existing getMaximumComponentDifference); set tolerance=5
  for macOS/Windows in build.gradle so Linux stays the ground truth

Co-authored-by: riccardobl <4943530+riccardobl@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 3, 2026

@copilot fails on macos

Mar 03, 2026 2:27:40 AM com.jme3.app.LegacyApplication handleError SEVERE: NSGL: Failed to find a suitable pixel format java.lang.Exception: NSGL: Failed to find a suitab...

Fixed in 8f774d3. Three changes:

macOS pixel-format failure: Switched the runner from macos-latest (Apple Silicon) to macos-13 (Intel x86_64). GLFW 3.4's NSGL backend can fail with "Failed to find a suitable pixel format" on Apple Silicon CI environments because Apple's OpenGL compatibility layer has restrictions in virtualised CI setups. The Intel runner has well-supported hardware-accelerated NSGL.

Windows stuck: Multiple fixes:

  • Pre-set App.onError in bootAppForTest() before app.start() so context-creation failures (which happen before TestDriver.initialize() runs) immediately count down the latch instead of leaving the test thread blocked for 45 s
  • Wrapped the executor lambda in try/catch to handle UnsatisfiedLinkError and similar startup exceptions
  • Changed app.stop(true) in the timeout path to app.stop(false) (non-blocking) — prevents the test thread from deadlocking if glfwTerminate() hangs inside Mesa3D
  • Fixed Mesa3D env vars (GALLIUM_DRIVER, MESA_GL_VERSION_OVERRIDE, MESA_GLSL_VERSION_OVERRIDE) to persist to subsequent steps via GITHUB_ENV (they were only set within the step's shell before)
  • Use GALLIUM_DRIVER=softpipe (pure-software, no LLVM JIT) instead of the default llvmpipe to avoid slow first-boot initialisation

Linux as ground truth / threshold comparison: Added jme.screenshotTest.pixelTolerance system property. imagesAreTheSame() now uses getMaximumComponentDifference() per pixel when tolerance > 0. build.gradle sets pixelTolerance=5 on macOS and Windows (minor GPU-driver rounding differences), while Linux keeps exact matching (tolerance=0).

@github-actions
Copy link

github-actions bot commented Mar 3, 2026

🖼️ Screenshot tests have failed.

The purpose of these tests is to ensure that changes introduced in this PR don't break visual features. They are visual unit tests.

📄 Where to find the report:

  • Go to the (failed run) > Summary > Artifacts > screenshot-test-report
  • Download the zip and open jme3-screenshot-tests/build/reports/ScreenshotDiffReport.html

⚠️ If you didn't expect to change anything visual:
Fix your changes so the screenshot tests pass.

If you did mean to change things:
Review the replacement images in jme3-screenshot-tests/build/changed-images to make sure they really are improvements and then replace and commit the replacement images at jme3-screenshot-tests/src/test/resources.

If you are creating entirely new tests:
Find the new images in jme3-screenshot-tests/build/changed-images and commit the new images at jme3-screenshot-tests/src/test/resources.

Note; it is very important that the committed reference images are created on the build pipeline, locally created images are not reliable. Similarly tests will fail locally but you can look at the report to check they are "visually similar".

See https://github.com/jMonkeyEngine/jmonkeyengine/blob/master/jme3-screenshot-tests/README.md for more information

Contact @richardTingle (aka richtea) for guidance if required

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run screenshot tests on android, windows and macos

2 participants