Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Tests

on:
push:
branches: [ master ]
pull_request:
workflow_dispatch:

concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true

env:
DOTNET_NOLOGO: 'true'
DOTNET_CLI_TELEMETRY_OPTOUT: 'true'

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive

- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

# Software OpenGL ES (Mesa/llvmpipe) + SDL2 + a virtual display. The tests render offscreen
# on llvmpipe (forced by tests/NImpeller.Tests/test.runsettings) but SDL still needs a video
# driver, so we provide one with Xvfb.
- name: Install GL + SDL runtime dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
xvfb \
libsdl2-2.0-0 \
libegl1 libgles2 libglvnd0 \
libegl-mesa0 libgl1-mesa-dri libglx-mesa0
# Silk.NET.SDL dlopens "libSDL2-2.0.so", but the runtime package only ships
# "libSDL2-2.0.so.0" (the unversioned symlink lives in libsdl2-dev). Create it.
SO=$(ldconfig -p | awk '/libSDL2-2.0.so.0/{print $NF; exit}')
echo "Linking libSDL2-2.0.so -> $SO"
sudo ln -sf "$SO" "$(dirname "$SO")/libSDL2-2.0.so"
sudo ldconfig

# Bindings (src/NImpeller/Generated/Bindings.g.cs) and the native libimpeller.so are not
# committed; download the Linux SDK at the latest Flutter engine commit and generate them.
- name: Download Impeller SDK (linux-x64) and generate bindings
run: |
chmod +x ./build.sh
./build.sh DownloadLatestImpeller --platform linux-x64
./build.sh GenerateBindings --platform linux-x64

# Prints what GL/EGL stack the runner actually exposes (helps diagnose context failures).
- name: GL diagnostics
continue-on-error: true
env:
LIBGL_ALWAYS_SOFTWARE: '1'
GALLIUM_DRIVER: llvmpipe
__EGL_VENDOR_LIBRARY_FILENAMES: /usr/share/glvnd/egl_vendor.d/50_mesa.json
run: |
sudo apt-get install -y --no-install-recommends mesa-utils mesa-utils-extra || true
ls -l /usr/share/glvnd/egl_vendor.d/ || true
echo "== eglinfo =="; xvfb-run -a eglinfo 2>&1 | head -60 || true
echo "== glxinfo -B =="; xvfb-run -a glxinfo -B 2>&1 | head -25 || true

- name: Test
run: xvfb-run -a dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj -c Release

# On a golden mismatch the tests write actual/golden PNGs here for inspection.
- name: Upload golden diffs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: golden-failures
path: tests/NImpeller.Tests/bin/Release/net8.0/TestFailures/
if-no-files-found: ignore
14 changes: 14 additions & 0 deletions NImpeller.sln
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.SDL", "samples\Sand
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.Metal", "samples\Sandbox.Metal\Sandbox.Metal.csproj", "{554505AE-9958-4740-91B1-FA285716F5E5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NImpeller.Tests", "tests\NImpeller.Tests\NImpeller.Tests.csproj", "{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -126,6 +128,18 @@ Global
{554505AE-9958-4740-91B1-FA285716F5E5}.Release|x64.Build.0 = Release|Any CPU
{554505AE-9958-4740-91B1-FA285716F5E5}.Release|x86.ActiveCfg = Release|Any CPU
{554505AE-9958-4740-91B1-FA285716F5E5}.Release|x86.Build.0 = Release|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x64.ActiveCfg = Debug|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x64.Build.0 = Debug|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x86.ActiveCfg = Debug|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Debug|x86.Build.0 = Debug|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|Any CPU.Build.0 = Release|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x64.ActiveCfg = Release|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x64.Build.0 = Release|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x86.ActiveCfg = Release|Any CPU
{5CDD2905-02B5-4A31-AA07-E45D45A1A5B8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,15 @@ mkdir -p ide
#jq 'map(select(.command | startswith("vpython") or startswith("rm") or startswith("ln") | not))'

```

### Tests

Tests are included in `tests/NImpeller.Tests` and can be run with

```sh
dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj
```

or run `./test-headless.sh`

At the moment, they assume you're using `llvmpipe` for a software GL context.
48 changes: 48 additions & 0 deletions test-headless.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# Usage:
# ./test-headless.sh # one run
# ./test-headless.sh -n 5 # run the suite 5 times (catch nondeterministic failures)
# ./test-headless.sh --filter Golden # pass extra args straight through to `dotnet test`
# RUNS=5 ./test-headless.sh # same as -n 5
#
# UPDATE_GOLDENS=1 ./test-headless.sh # regenerate baselines (honored by the tests)

set -euo pipefail

cd "$(dirname "$0")"

RUNS="${RUNS:-1}"

# Peel a leading -n/--runs off the arg list; everything else is forwarded to `dotnet test`.
if [[ "${1:-}" == "-n" || "${1:-}" == "--runs" ]]; then
RUNS="${2:?missing count after $1}"
shift 2
fi

if ! command -v xvfb-run >/dev/null 2>&1; then
echo "error: xvfb-run not found. Install it (Arch: xorg-server-xvfb, Debian/Ubuntu: xvfb)." >&2
exit 1
fi

# Force the software renderer and the EGL path (a GLES context on X11/Xvfb must come through EGL).
export LIBGL_ALWAYS_SOFTWARE=1
export GALLIUM_DRIVER=llvmpipe
export MESA_LOADER_DRIVER_OVERRIDE=llvmpipe
export SDL_VIDEO_X11_FORCE_EGL=1
export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json
# Make RenderGate treat missing/non-software GL as a hard failure instead of a skip, like CI.
export CI=true

status=0
for ((i = 1; i <= RUNS; i++)); do
if [[ "$RUNS" -gt 1 ]]; then
echo "===== run $i / $RUNS ====="
fi
if ! xvfb-run -a dotnet test tests/NImpeller.Tests/NImpeller.Tests.csproj -c Release "$@"; then
status=1
echo "----- run $i FAILED -----" >&2
fi
done

exit "$status"
5 changes: 5 additions & 0 deletions tests/NImpeller.Tests/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using Xunit;

// A GL context is thread-affine and we share a single one across the suite, so tests must not
// run in parallel. (Renders are still marshalled onto the context's own worker thread.)
[assembly: CollectionBehavior(DisableTestParallelization = true)]
124 changes: 124 additions & 0 deletions tests/NImpeller.Tests/GoldenImageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using NImpeller.Tests.Headless;
using NImpeller.Tests.Scenes;
using Xunit;

namespace NImpeller.Tests;

/// <summary>
/// Golden-image regression tests: render a deterministic scene offscreen and compare the pixels
/// against a committed baseline.
///
/// Generate or refresh baselines by setting <c>UPDATE_GOLDENS=1</c>:
/// <c>UPDATE_GOLDENS=1 dotnet test</c>
/// A missing baseline is written automatically and the test is skipped (so first run is green and
/// the new baseline can be reviewed before committing).
/// </summary>
[Collection(ImpellerGLCollection.Name)]
public sealed class GoldenImageTests
{
private const int PerChannelTolerance = 8;
private const double MaxDiffFraction = 0.0001; // 0.01% of pixels may differ

private readonly ImpellerGLFixture _gl;

public GoldenImageTests(ImpellerGLFixture gl) => _gl = gl;

private sealed record SceneCase(IScene Scene, int Width, int Height);

private static readonly Dictionary<string, SceneCase> Cases = BuildCases(
new SolidShapesScene(), 320, 280,
new PrimitivesScene(), 360, 280,
new StrokesScene(), 360, 300,
new TransformsScene(), 320, 300,
new ClipsScene(), 320, 300,
new BlendModesScene(), 372, 312,
new ColorFiltersScene(), 340, 190,
new MaskBlurScene(), 360, 300,
new ShadowsScene(), 340, 180,
new GroupOpacityScene(), 300, 260,
new ImageFiltersScene(), 260, 240);

public static TheoryData<string> SceneNames()
{
var data = new TheoryData<string>();
foreach (var name in Cases.Keys)
{
data.Add(name);
}
return data;
}

[Theory]
[MemberData(nameof(SceneNames))]
public void Scene_matches_golden(string sceneName)
{
RenderGate.Require(_gl);

var @case = Cases[sceneName];
var actual = _gl.Render(@case.Scene, @case.Width, @case.Height);
Assert.True(actual.HasContent(), $"Scene '{sceneName}' produced an empty (all-zero) image.");

GoldenAssert.Matches(sceneName, actual, PerChannelTolerance, MaxDiffFraction);
}

private static Dictionary<string, SceneCase> BuildCases(params object[] entries)
{
var dict = new Dictionary<string, SceneCase>();
for (int i = 0; i < entries.Length; i += 3)
{
var scene = (IScene)entries[i];
dict[scene.TestName] = new SceneCase(scene, (int)entries[i + 1], (int)entries[i + 2]);
}
return dict;
}
}

internal static class GoldenAssert
{
public static void Matches(string name, RawImage actual, int perChannelTolerance, double maxDiffFraction)
{
string rawPath = Path.Combine(GoldensDir(), name + ".rimg");
bool update = Environment.GetEnvironmentVariable("UPDATE_GOLDENS") == "1";

if (update || !File.Exists(rawPath))
{
if (!update && RenderGate.IsCI)
{
Assert.Fail($"Golden baseline not found in CI: {Path.GetFullPath(rawPath)} — it must be committed.");
}

actual.SaveRaw(rawPath);
actual.SavePng(Path.Combine(GoldensDir(), name + ".png"));
Assert.Skip(update
? $"Baseline updated: {rawPath}"
: $"Baseline created (review before committing): {rawPath}");
}

var golden = RawImage.LoadRaw(rawPath);
var diff = golden.Compare(actual, perChannelTolerance);

if (!diff.SizeMatches || diff.DiffFraction > maxDiffFraction)
{
string outDir = FailureDir();
actual.SavePng(Path.Combine(outDir, name + ".actual.png"));
golden.SavePng(Path.Combine(outDir, name + ".golden.png"));
Assert.Fail(
$"'{name}' differs from golden: sizeMatches={diff.SizeMatches}, " +
$"diffPixels={diff.DiffPixels}/{diff.TotalPixels} ({diff.DiffFraction:P3}), " +
$"maxChannelDiff={diff.MaxChannelDiff}. Wrote actual/golden PNGs to {outDir}. " +
$"If this change is intended, regenerate with UPDATE_GOLDENS=1.");
}
}

public static string GoldensDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile)!, "Goldens");

private static string FailureDir()
{
string dir = Path.Combine(AppContext.BaseDirectory, "TestFailures");
Directory.CreateDirectory(dir);
return dir;
}
}
2 changes: 2 additions & 0 deletions tests/NImpeller.Tests/Goldens/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Golden baselines (.rimg + .png) live here and ARE committed.
# Generate/refresh with: UPDATE_GOLDENS=1 dotnet test
Binary file added tests/NImpeller.Tests/Goldens/blendmodes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/blendmodes.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/clips.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/clips.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/colorfilters.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/colorfilters.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/groupopacity.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/groupopacity.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/imagefilters.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/imagefilters.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/maskblur.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/maskblur.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/primitives.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/primitives.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/shadows.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/shadows.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/solidshapes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/solidshapes.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/strokes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/strokes.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/textureinterop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/textureinterop.rimg
Binary file not shown.
Binary file added tests/NImpeller.Tests/Goldens/transforms.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/NImpeller.Tests/Goldens/transforms.rimg
Binary file not shown.
Loading
Loading