diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index ca404b2..00ad8d8 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -3,6 +3,16 @@ name: GitHub Pages
on:
push:
branches: [main]
+ pull_request:
+ # The Pages tree is built by a separate pipeline from the solution; a browser regression
+ # should be caught before merge, and the deploy jobs below are gated to main.
+ paths:
+ - 'samples/CuiSharp.Gallery.Browser/**'
+ - 'src/CuiSharp/**'
+ - 'native/**'
+ - 'tools/build-cui-wasm.ps1'
+ - 'tools/test-browser-smoke.py'
+ - '.github/workflows/pages.yml'
workflow_dispatch:
permissions:
@@ -59,12 +69,16 @@ jobs:
$bytes = (Get-ChildItem $site -Recurse -File | Measure-Object Length -Sum).Sum
Write-Host "Pages payload after removing redundant sidecars: $bytes bytes"
- - name: Smoke-test the exact Pages tree in headless Chrome
- run: python tools/test-browser-smoke.py artifacts/pages/wwwroot
+ - name: Smoke-test the exact Pages tree in headless Chrome (both renderers)
+ run: |
+ python tools/test-browser-smoke.py artifacts/pages/wwwroot --renderer webgl2
+ python tools/test-browser-smoke.py artifacts/pages/wwwroot --renderer 2d
- uses: actions/configure-pages@v5
+ if: github.ref == 'refs/heads/main'
- uses: actions/upload-pages-artifact@v4
+ if: github.ref == 'refs/heads/main'
with:
path: artifacts/pages/wwwroot
diff --git a/samples/CuiSharp.Gallery.Browser/BrowserHost.cs b/samples/CuiSharp.Gallery.Browser/BrowserHost.cs
index 210af32..362a99b 100644
--- a/samples/CuiSharp.Gallery.Browser/BrowserHost.cs
+++ b/samples/CuiSharp.Gallery.Browser/BrowserHost.cs
@@ -109,12 +109,9 @@ public static void Frame(double timestampMilliseconds)
if (_gpu)
{
// GPU mode: present the draw data pointer to JS, which draws it with WebGL2.
- // No CPU rasterization, no pixel buffer copy.
- nint dd = surface.GetDrawData();
- if (dd != 0)
- {
- PresentGpu(dd, surface.PixelWidth, surface.PixelHeight);
- }
+ // No CPU rasterization, no pixel buffer copy. The viewport is inside the draw
+ // data, so JS needs nothing but the pointer.
+ PresentGpu(surface.GetDrawData());
}
else
{
@@ -239,7 +236,7 @@ public static void Shutdown()
private static partial void Present(int pixelAddress, int width, int height, int x, int y, int dirtyWidth, int dirtyHeight);
[JSImport("canvas.presentGpu", "main.js")]
- private static partial void PresentGpu(nint drawData, int width, int height);
+ private static partial void PresentGpu(nint drawData);
private static Key MapKey(string code)
{
diff --git a/samples/CuiSharp.Gallery.Browser/wwwroot/main.js b/samples/CuiSharp.Gallery.Browser/wwwroot/main.js
index 78c16d7..ece21f1 100644
--- a/samples/CuiSharp.Gallery.Browser/wwwroot/main.js
+++ b/samples/CuiSharp.Gallery.Browser/wwwroot/main.js
@@ -64,10 +64,10 @@ const VERTEX_SIZE = 64; // sizeof(cui_vertex)
const CMD_SIZE = 28; // sizeof(cui_draw_cmd) on wasm32: 4-byte pointers
class GLRenderer {
- constructor(canvas, smoke) {
+ constructor(canvas, keepDrawingBuffer) {
const gl = canvas.getContext('webgl2', {
alpha: false, antialias: false, depth: false, stencil: false,
- premultipliedAlpha: true, preserveDrawingBuffer: keepBuffer,
+ premultipliedAlpha: true, preserveDrawingBuffer: keepDrawingBuffer,
});
if (!gl) throw new Error('WebGL2 is not available');
this.gl = gl;
@@ -80,11 +80,14 @@ class GLRenderer {
return s;
};
const p = gl.createProgram();
- gl.attachShader(p, sh(gl.VERTEX_SHADER, VS));
- gl.attachShader(p, sh(gl.FRAGMENT_SHADER, FS));
+ const vs = sh(gl.VERTEX_SHADER, VS), fs = sh(gl.FRAGMENT_SHADER, FS);
+ gl.attachShader(p, vs);
+ gl.attachShader(p, fs);
['a_pos', 'a_uv', 'a_local', 'a_half', 'a_radii', 'a_params', 'a_color', 'a_bcolor'].forEach((n, i) => gl.bindAttribLocation(p, i, n));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error('CUI program: ' + gl.getProgramInfoLog(p));
+ gl.deleteShader(vs);
+ gl.deleteShader(fs);
this.program = p;
this.uViewport = gl.getUniformLocation(p, 'u_viewport');
this.uTex = gl.getUniformLocation(p, 'u_tex');
@@ -116,8 +119,11 @@ class GLRenderer {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);
- if (fmt === 1) gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, heapBytes(px, w * h));
- else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, heapBytes(px, w * h * 4));
+ // CUI creates empty textures with a null pixel pointer and fills them in through
+ // texture_update later (a fresh emoji atlas does exactly that), so null has to stay
+ // null: a view starting at wasm address 0 would upload the heap's first bytes as pixels.
+ if (fmt === 1) gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, px ? heapBytes(px, w * h) : null);
+ else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, px ? heapBytes(px, w * h * 4) : null);
const id = this.nextTex++;
this.textures.set(id, { t, fmt });
return id;
@@ -128,8 +134,12 @@ class GLRenderer {
gl.bindTexture(gl.TEXTURE_2D, e.t);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, stride / e.fmt);
+ // px addresses the first pixel of the damaged rect, not the start of a full row of it,
+ // so the span ends at the last pixel of the last row. stride * h would read up to
+ // x * fmt bytes past the end of the source for a rect that is not flush left - and
+ // throw outright when the source ends near the top of the wasm heap.
gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, w, h, e.fmt === 1 ? gl.RED : gl.RGBA, gl.UNSIGNED_BYTE,
- heapBytes(px, stride * h));
+ heapBytes(px, (h - 1) * stride + w * e.fmt));
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0);
}
destroyTexture(id) {
@@ -138,7 +148,7 @@ class GLRenderer {
}
// draws the cui_draw_data at `dd` (a wasm heap pointer)
- draw(dd) {
+ draw(dd, canvas) {
const gl = this.gl, buf = heapBuffer();
const i32 = new Int32Array(buf), u32 = new Uint32Array(buf), f32 = new Float32Array(buf);
const verts = u32[dd >> 2], vcount = i32[(dd + 4) >> 2];
@@ -147,7 +157,11 @@ class GLRenderer {
const W = i32[(dd + 24) >> 2], H = i32[(dd + 28) >> 2];
const cc = u32[(dd + 32) >> 2]; // r | g<<8 | b<<16, straight from cui_color
const atlas = u32[(dd + 36) >> 2];
- if (W !== canvas.width || H !== canvas.height) throw new Error(`draw data ${W}x${H} != canvas ${canvas.width}x${canvas.height}`);
+ // The drawing buffer has to match the frame CUI laid out or the viewport clips it. Follow
+ // the draw data instead of trusting the resize path to agree on how a fractional display
+ // scale rounds: a backing store resized here is valid at once, and the alternative -
+ // refusing the frame - loses the whole canvas over a one-pixel disagreement.
+ if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; }
gl.viewport(0, 0, W, H);
gl.clearColor((cc & 255) / 255, ((cc >> 8) & 255) / 255, ((cc >> 16) & 255) / 255, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
@@ -245,8 +259,10 @@ function resize() {
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
const scale = Math.max(1, window.devicePixelRatio || 1);
- const pixelWidth = Math.max(1, Math.floor(width * scale));
- const pixelHeight = Math.max(1, Math.floor(height * scale));
+ // Rounded, matching CUI's own width_px = vw * scale + 0.5f, so the canvas and the frame
+ // CUI lays out agree at fractional display scales such as 1.5.
+ const pixelWidth = Math.max(1, Math.round(width * scale));
+ const pixelHeight = Math.max(1, Math.round(height * scale));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
@@ -273,10 +289,10 @@ function present(pointer, width, height, x, y, dirtyWidth, dirtyHeight) {
context.putImageData(imageData, 0, 0, x, y, dirtyWidth, dirtyHeight);
}
-function presentGpu(dd, width, height) {
+function presentGpu(dd) {
if (!gpu) return;
if (query.has('debug')) window.__cuiDD = dd;
- gpu.draw(dd);
+ gpu.draw(dd, canvas);
}
function animationFrame(timestamp) {
@@ -290,7 +306,6 @@ function animationFrame(timestamp) {
smokeState = 5;
} else if (smoke && smokeState === 6) {
const typedNameHash = hashNameField();
- smokeState = 5;
status.style.display = 'grid';
const passed = typedNameHash !== selectedNameHash;
smokeState = 7;
@@ -392,22 +407,19 @@ async function start() {
const exports = await dotnetRuntime.getAssemblyExports(dotnetRuntime.getConfig().mainAssemblyName);
host = exports.CuiSharp.Gallery.Browser.BrowserHost;
- // Renderer choice: WebGL2 by default, software with ?renderer=software or
- // where WebGL2 is missing. The surface has to exist before the first frame.
- // Under --disable-gpu (CI) the first webgl2 request can race the GPU
- // process coming up and return null; a short retry covers that, and the
- // later attempt does succeed.
- let supported = false;
- if (wantGpu) supported = !!document.createElement('canvas').getContext('webgl2');
- if (supported) {
- for (let attempt = 0; attempt < 12 && !gpu; attempt++) {
- try {
- gpu = new GLRenderer(canvas, smoke);
- } catch (error) {
- gpu = null;
- if (attempt === 11) console.error('WebGL2 renderer failed to initialize:', error);
- else await new Promise(resolve => setTimeout(resolve, 250));
- }
+ // Renderer choice: WebGL2 by default, software with ?renderer=software or where WebGL2
+ // is unavailable. The surface has to exist before the first frame, so the choice is made
+ // here. Under --disable-gpu the first webgl2 request can race the GPU process coming up
+ // and return null; the retry covers that and the later attempt succeeds. Ask the real
+ // canvas directly - probing a scratch one would spend the one attempt the retry exists
+ // to repeat, and a failed request leaves the canvas free to take a 2d context.
+ for (let attempt = 0; wantGpu && !gpu && attempt < 12; attempt++) {
+ try {
+ gpu = new GLRenderer(canvas, keepBuffer);
+ } catch (error) {
+ gpu = null;
+ if (attempt === 11) console.error('WebGL2 renderer failed to initialize:', error);
+ else await new Promise(resolve => setTimeout(resolve, 250));
}
}
await host.Initialize(!!gpu);
diff --git a/src/CuiSharp.Tests/GpuSurfaceTests.cs b/src/CuiSharp.Tests/GpuSurfaceTests.cs
new file mode 100644
index 0000000..1edfac7
--- /dev/null
+++ b/src/CuiSharp.Tests/GpuSurfaceTests.cs
@@ -0,0 +1,221 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using CuiSharp.Interop;
+
+namespace CuiSharp.Tests;
+
+///
+/// A GPU surface produces draw data and owns no pixels: the host's renderer consumes the
+/// frame, and CUI reaches the host through the texture callbacks. These pin what the CPU can
+/// observe about that contract, so a desktop CI leg catches a break before a browser does.
+///
+public sealed unsafe class GpuSurfaceTests
+{
+ // The frame tests need the native library, which a checkout may not have built yet;
+ // same probe LocalizationTests uses.
+ private static readonly bool NativeReady = CanLoadNative();
+
+ private static bool CanLoadNative()
+ {
+ try
+ {
+ _ = Native.KeyName(Key.A);
+ return true;
+ }
+ catch (Exception error) when (error is DllNotFoundException or BadImageFormatException or EntryPointNotFoundException)
+ {
+ return false;
+ }
+ }
+ [Fact]
+ public void AFrameCarriesDrawDataAndUploadsTheFontAtlas()
+ {
+ if (!NativeReady) return;
+
+ var calls = (TextureCalls*)NativeMemory.AllocZeroed((nuint)sizeof(TextureCalls));
+ try
+ {
+ using var surface = CuiSurface.CreateGpu(320, 200, Iface(calls));
+
+ surface.BeginFrame();
+ surface.Ui.Label("hello");
+ surface.EndFrame();
+
+ // The frame is built for a renderer to draw; the renderer is what the counts feed.
+ Assert.True(surface.CommandCount > 0);
+ Assert.NotEqual(0, surface.GetDrawData());
+
+ // CUI uploads its glyph atlas through the interface on the first text it draws,
+ // which is the reason a GPU surface cannot take a null callback.
+ Assert.True(calls->Created > 0, "the font atlas should have been created");
+ Assert.Equal((int)TextureFormat.R8, calls->LastCreateFormat);
+ Assert.True(calls->LastCreatePixels != 0, "the atlas arrives with its pixels");
+ Assert.True((long)calls->LastCreateWidth * calls->LastCreateHeight > 0);
+ }
+ finally
+ {
+ NativeMemory.Free(calls);
+ }
+ }
+
+ [Fact]
+ public void RedrawingTextUpdatesTheExistingAtlasInsteadOfReplacingIt()
+ {
+ if (!NativeReady) return;
+
+ var calls = (TextureCalls*)NativeMemory.AllocZeroed((nuint)sizeof(TextureCalls));
+ try
+ {
+ using var surface = CuiSurface.CreateGpu(320, 200, Iface(calls));
+
+ surface.BeginFrame();
+ surface.Ui.Label("aaa");
+ surface.EndFrame();
+ int afterFirstFrame = calls->Created;
+
+ // New glyphs extend the atlas: they arrive as a partial update of the texture
+ // already created, which is what the renderer's update callback has to survive.
+ surface.BeginFrame();
+ surface.Ui.Label("bbb");
+ surface.EndFrame();
+
+ Assert.Equal(afterFirstFrame, calls->Created);
+ Assert.True(calls->Updated > 0, "new glyphs should update the atlas in place");
+ Assert.True(calls->LastUpdatePixels != 0);
+ Assert.True(calls->LastUpdateWidth > 0 && calls->LastUpdateHeight > 0);
+ Assert.True(calls->LastUpdateStride >= calls->LastUpdateWidth);
+ }
+ finally
+ {
+ NativeMemory.Free(calls);
+ }
+ }
+
+ [Fact]
+ public void SoftwareOnlyMembersRefuseAGpuSurface()
+ {
+ if (!NativeReady) return;
+
+ using var surface = CuiSurface.CreateGpu(64, 64, Iface(null));
+
+ Assert.Equal(RendererKind.Gpu, surface.Kind);
+ Assert.Null(surface.LastDamage);
+ var pixels = new byte[surface.PixelWidth * surface.PixelHeight * 4];
+ Assert.Throws(() => surface.Render(pixels));
+ Assert.Throws(() => surface.Incremental = true);
+ }
+
+ [Fact]
+ public void ASoftwareSurfaceStillReportsItsOwnKind()
+ {
+ if (!NativeReady) return;
+
+ using var surface = CuiSurface.Create(64, 64);
+
+ Assert.Equal(RendererKind.Software, surface.Kind);
+ Assert.NotEqual(0, surface.GetDrawData());
+ }
+
+ [Fact]
+ public void MissingTextureCallbacksAreRejectedRatherThanLeftToCrashLater()
+ {
+ // CUI calls texture_create unconditionally for the font atlas, so a surface without
+ // the callbacks would fail on the first glyph instead of here.
+ var partial = new NativeRendererIface
+ {
+ TextureCreate = (nint)(delegate* unmanaged[Cdecl])&CreateTexture,
+ TextureUpdate = (nint)(delegate* unmanaged[Cdecl])&UpdateTexture,
+ };
+
+ Assert.Throws(() => CuiSurface.CreateGpu(64, 64, partial));
+ }
+
+ [Fact]
+ public void DrawDataIsNotReadableAfterDispose()
+ {
+ if (!NativeReady) return;
+
+ var surface = CuiSurface.CreateGpu(64, 64, Iface(null));
+ surface.Dispose();
+
+ // cui_get_draw_data returns &ui->draw_data, so a destroyed context yields the field's
+ // offset inside a freed struct: a plausible pointer to nothing.
+ Assert.Throws(() => surface.GetDrawData());
+ }
+
+ [Fact]
+ public void PixelSizeRoundsTheWayCuiRoundsTheFrame()
+ {
+ if (!NativeReady) return;
+
+ // cui_draw_data.width_px is vw * scale + 0.5f; a surface that truncated would disagree
+ // by a pixel at 1.5 and a renderer comparing the two would refuse every frame.
+ using var surface = CuiSurface.Create(1001, 601, scale: 1.5f);
+
+ Assert.Equal(1502, surface.PixelWidth);
+ Assert.Equal(902, surface.PixelHeight);
+ }
+
+ // ---- the host side of the contract -------------------------------------
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct TextureCalls
+ {
+ public int Created;
+ public int Updated;
+ public int Destroyed;
+ public int LastCreateFormat;
+ public int LastCreateWidth;
+ public int LastCreateHeight;
+ public nint LastCreatePixels;
+ public int LastUpdateWidth;
+ public int LastUpdateHeight;
+ public int LastUpdateStride;
+ public nint LastUpdatePixels;
+ }
+
+ private static NativeRendererIface Iface(TextureCalls* calls) => new()
+ {
+ User = (nint)calls,
+ TextureCreate = (nint)(delegate* unmanaged[Cdecl])&CreateTexture,
+ TextureUpdate = (nint)(delegate* unmanaged[Cdecl])&UpdateTexture,
+ TextureDestroy = (nint)(delegate* unmanaged[Cdecl])&DestroyTexture,
+ };
+
+ [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
+ private static nuint CreateTexture(void* user, int w, int h, int format, void* pixels)
+ {
+ if (user != null)
+ {
+ var calls = (TextureCalls*)user;
+ calls->Created++;
+ calls->LastCreateFormat = format;
+ calls->LastCreateWidth = w;
+ calls->LastCreateHeight = h;
+ calls->LastCreatePixels = (nint)pixels;
+ return (nuint)calls->Created;
+ }
+
+ return 1;
+ }
+
+ [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
+ private static void UpdateTexture(
+ void* user, nuint texture, int x, int y, int w, int h, void* pixels, int stride)
+ {
+ if (user == null) return;
+ var calls = (TextureCalls*)user;
+ calls->Updated++;
+ calls->LastUpdateWidth = w;
+ calls->LastUpdateHeight = h;
+ calls->LastUpdateStride = stride;
+ calls->LastUpdatePixels = (nint)pixels;
+ }
+
+ [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
+ private static void DestroyTexture(void* user, nuint texture)
+ {
+ if (user == null) return;
+ ((TextureCalls*)user)->Destroyed++;
+ }
+}
diff --git a/src/CuiSharp/CuiSurface.cs b/src/CuiSharp/CuiSurface.cs
index 2be54a7..f7917d0 100644
--- a/src/CuiSharp/CuiSurface.cs
+++ b/src/CuiSharp/CuiSurface.cs
@@ -100,10 +100,17 @@ public Ui Ui
public RendererKind Kind { get; }
/// Framebuffer width in pixels.
- public int PixelWidth => (int)(_width * _scale);
+ ///
+ /// Rounded, not truncated, because that is how CUI computes cui_draw_data.width_px
+ /// (cui_core.c: vw * scale + 0.5f). A renderer that sizes its target by
+ /// truncating disagrees by a pixel on fractional display scales such as 1.5, and a scaler
+ /// comparing the two would refuse every frame.
+ ///
+ public int PixelWidth => (int)(_width * _scale + 0.5f);
/// Framebuffer height in pixels.
- public int PixelHeight => (int)(_height * _scale);
+ /// Rounded like .
+ public int PixelHeight => (int)(_height * _scale + 0.5f);
///
/// What the last frame this surface drew cost: the same numbers as , but
@@ -181,7 +188,8 @@ public static CuiSurface CreateGpu(
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
if (textures.TextureCreate == 0 || textures.TextureUpdate == 0 || textures.TextureDestroy == 0)
{
- throw new ArgumentException("a GPU surface needs texture create, update and destroy callbacks");
+ throw new ArgumentException(
+ "a GPU surface needs texture create, update and destroy callbacks", nameof(textures));
}
var desc = new NativeCtxDesc
@@ -197,6 +205,8 @@ public static CuiSurface CreateGpu(
Native.SetViewport(ctx, width, height, scale);
return new CuiSurface(0, ctx, width, height, scale, RendererKind.Gpu);
}
+
+ /// Starts a frame. Call when the UI is built.
///
/// runs layout and paint, so by the time the next
/// frame starts the previous one's deferred canvas callbacks and arena
@@ -296,8 +306,9 @@ public bool Incremental
get => _incremental;
set
{
- if (_ctx == 0 || _incremental == value) return;
+ if (_ctx == 0) return;
if (_sw == 0) throw new InvalidOperationException("incremental rendering is a software-renderer feature.");
+ if (_incremental == value) return;
_incremental = value;
Native.SwSetIncremental(_sw, (byte)(value ? 1 : 0));
}
@@ -370,7 +381,13 @@ public int CommandCount
/// A GPU renderer reads vertices, indices, commands and the font atlas id straight out of
/// this structure — the same cui_draw_data the built-in renderers consume.
///
- public nint GetDrawData() => Native.GetDrawData(_ctx);
+ public nint GetDrawData()
+ {
+ // cui_get_draw_data returns &ui->draw_data: on a destroyed context that is not null,
+ // it is the offset of the field inside a freed struct.
+ ObjectDisposedException.ThrowIf(_ctx == 0, this);
+ return Native.GetDrawData(_ctx);
+ }
///
public void Dispose()
diff --git a/tools/test-browser-smoke.py b/tools/test-browser-smoke.py
index ea9a81b..f8bd8e9 100644
--- a/tools/test-browser-smoke.py
+++ b/tools/test-browser-smoke.py
@@ -79,7 +79,7 @@ def evaluate(ws, expression: str, command_id: int, deadline: float, errors: list
return None, command_id
-def run(site: Path, timeout_seconds: float) -> None:
+def run(site: Path, timeout_seconds: float, expect_renderer: str) -> str:
site = site.resolve()
if not (site / "index.html").is_file():
raise FileNotFoundError(f"No index.html in published site: {site}")
@@ -151,7 +151,10 @@ def send(method: str, params: dict | None = None) -> None:
send("Runtime.enable")
send("Page.enable")
- send("Page.navigate", {"url": base_url + "?page=2&smoke=1"})
+ url = base_url + "?page=2&smoke=1"
+ if expect_renderer == "2d":
+ url += "&renderer=software"
+ send("Page.navigate", {"url": url})
expression = """(() => {
const canvas = document.querySelector('#surface');
@@ -188,12 +191,21 @@ def send(method: str, params: dict | None = None) -> None:
if "BROWSER-SMOKE-OK" in status:
if result.get("nonBackground", 0) < 1000:
raise RuntimeError(f"smoke marker reported but canvas was blank: {result}")
+ # The marker only proves a frame was drawn and input arrived; without this
+ # the test would pass on the software fallback while the GPU path was
+ # broken, which is the one thing it cannot notice from the pixels.
+ renderer = result.get("renderer", "")
+ if renderer != expect_renderer:
+ raise RuntimeError(
+ f"the page rendered with {renderer!r}, expected {expect_renderer!r}: {result}"
+ )
print(
"BROWSER-WASM-SMOKE PASS "
f"{result.get('width')}x{result.get('height')} "
+ f"renderer={renderer} "
f"nonBackgroundSamples={result.get('nonBackground')}"
)
- break
+ return renderer
if errors:
raise RuntimeError("browser error: " + " | ".join(errors[-5:]))
time.sleep(0.15)
@@ -228,9 +240,15 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("site", type=Path, help="the published wwwroot directory")
parser.add_argument("--timeout", type=float, default=45)
+ parser.add_argument(
+ "--renderer",
+ choices=["webgl2", "2d"],
+ default="webgl2",
+ help="the renderer the page must report having used (default: webgl2, the page's default)",
+ )
args = parser.parse_args()
try:
- run(args.site, args.timeout)
+ run(args.site, args.timeout, args.renderer)
return 0
except Exception as error:
print(f"BROWSER-WASM-SMOKE FAIL: {error}", file=sys.stderr)