diff --git a/.github/actions/setup-windows-opengl/action.yml b/.github/actions/setup-windows-opengl/action.yml new file mode 100644 index 0000000..8578f24 --- /dev/null +++ b/.github/actions/setup-windows-opengl/action.yml @@ -0,0 +1,25 @@ +name: Set up Windows software OpenGL +description: Install a pinned Mesa driver on the runner for native graphics smoke checks. +runs: + using: composite + steps: + - shell: pwsh + run: | + # Hyper-V's inbox OpenGL driver lacks Processing's framebuffer support. + # Install the CI host driver after packaging; downloads keep their own runtime. + $mesaDirectory = Join-Path $env:RUNNER_TEMP 'scratch4j-mesa' + $mesaArchive = Join-Path $env:RUNNER_TEMP 'mesa3d-26.2.4-release-msvc.7z' + Invoke-WebRequest 'https://github.com/pal1000/mesa-dist-win/releases/download/26.2.4/mesa3d-26.2.4-release-msvc.7z' -OutFile $mesaArchive + $expectedHash = '351fc8c8b695878ffb3eaa044b3ead08672a48b1a045e3c3e3975811df0f6695' + if ((Get-FileHash $mesaArchive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expectedHash) { + throw 'Mesa archive checksum mismatch' + } + & 7z x -y "-o$mesaDirectory" $mesaArchive | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Mesa archive extraction failed' } + & (Join-Path $mesaDirectory 'systemwidedeploy.cmd') 1 + if ($LASTEXITCODE -ne 0) { throw 'Mesa driver installation failed' } + if (!(Test-Path (Join-Path $env:SystemRoot 'System32/mesadrv.dll'))) { + throw 'Mesa system OpenGL driver is missing' + } + 'GALLIUM_DRIVER=llvmpipe' >> $env:GITHUB_ENV + 'LIBGL_ALWAYS_SOFTWARE=1' >> $env:GITHUB_ENV diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e499d7e..4f5fd5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,11 +25,26 @@ jobs: - name: Build and test run: mvn --batch-mode --no-transfer-progress verify + - name: Identify the bundled library release + if: runner.os == 'Linux' + id: library-release + run: | + python3 - <<'PY' + import os + import xml.etree.ElementTree as ET + namespace = '{http://maven.apache.org/POM/4.0.0}' + version = ET.parse('pom.xml').findtext(f'{namespace}properties/{namespace}scratch.version') + if not version: + raise SystemExit('Missing bundled library version') + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + print('ref=v' + version, file=output) + PY - name: Check shared release metadata if: runner.os == 'Linux' uses: actions/checkout@v7 with: repository: openpatch/scratch-for-java + ref: ${{ steps.library-release.outputs.ref }} path: .validation/library - name: Set up the catalog toolchain if: runner.os == 'Linux' diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 7f2945d..3dd05d4 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -128,6 +128,10 @@ jobs: & $signtool.FullName sign /f $pfx /p $env:WINDOWS_CERTIFICATE_PASSWORD /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 $_.FullName } + - name: Set up Windows software OpenGL + if: runner.os == 'Windows' + uses: ./.github/actions/setup-windows-opengl + - name: Start packaged Studio and run offline projects shell: bash run: | diff --git a/scripts/smoke-packaged.py b/scripts/smoke-packaged.py index d5a8e00..a9268ed 100644 --- a/scripts/smoke-packaged.py +++ b/scripts/smoke-packaged.py @@ -23,5 +23,5 @@ report = Path(temporary) / "report.txt" message = report.read_text() if report.is_file() else "No packaged smoke report" if result.returncode or not message.startswith("PASS:"): - raise SystemExit(message + "\n" + result.stdout[-2000:] + result.stderr[-4000:]) + raise SystemExit(message + "\n" + result.stdout[-64000:] + result.stderr[-10000:]) print(message.strip()) diff --git a/ui/src/main/java/org/openpatch/scratch4j/ui/PackagedSmoke.java b/ui/src/main/java/org/openpatch/scratch4j/ui/PackagedSmoke.java index 0fd4934..9381b5d 100644 --- a/ui/src/main/java/org/openpatch/scratch4j/ui/PackagedSmoke.java +++ b/ui/src/main/java/org/openpatch/scratch4j/ui/PackagedSmoke.java @@ -33,16 +33,32 @@ static void start(StudioApp app, javafx.stage.Stage stage, Path output) { Files.writeString(project.root().resolve("List.java"), "public class List {}\n"); } List errors = new java.util.concurrent.CopyOnWriteArrayList<>(); + List outputLines = new java.util.concurrent.CopyOnWriteArrayList<>(); AtomicLong frames = new AtomicLong(-1); RunHandle handle = new ProjectRunner().run(project, RunConfig.of(project.startStage()).withJava(java).withControl().withExitAfter(4), new RunListener() { public void onStderr(String line) { errors.add(line); } + public void onStdout(String line) { outputLines.add(line); } public void onFrames(long count) { frames.set(count); } }); int code; try { code = handle.exitFuture().get(40, TimeUnit.SECONDS); } - catch (Exception e) { handle.stop(); throw e; } + catch (Exception e) { + Path jcmd = java.resolveSibling(isWindows() ? "jcmd.exe" : "jcmd"); + try { + if (Files.isRegularFile(jcmd)) { + Process dump = new ProcessBuilder(jcmd.toString(), Long.toString(handle.pid()), + "Thread.print").inheritIO().start(); + if (!dump.waitFor(5, TimeUnit.SECONDS)) dump.destroyForcibly(); + } + } catch (Exception diagnosticError) { + System.err.println("Thread diagnostic failed: " + diagnosticError); + } + handle.stop(); + throw new IllegalStateException(name + " timed out; frames=" + frames.get() + + "; stdout=" + outputLines + "; stderr=" + errors, e); + } if (code != 0 || frames.get() <= 0) throw new IllegalStateException(name + " did not draw: " + code + " " + errors); Path source = project.root().resolve("MyStage.java"); String saved = Files.readString(source);