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
18 changes: 15 additions & 3 deletions .claude/skills/e2e-testing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ Test project: `XnaFiddle.E2E.Tests` (NUnit + `Microsoft.Playwright.NUnit`). `Nul
3. `StaticSiteHost.StartAsync(webRoot)` serves it in-process via Kestrel on an OS-assigned port (`http://127.0.0.1:0`). Uses `UseBlazorFrameworkFiles` so `_framework` Webcil assemblies get `application/wasm` (a plain static server serves octet-stream, which the browser refuses). `MapFallbackToFile("index.html")` for deep links.
4. Playwright drives headless Chromium against `host.BaseUrl`.

`OneTimeSetUp`/`OneTimeTearDown` in `SmokeTest.cs` own host + browser + page lifetime; individual tests just navigate and assert.
`E2ETestBase` (base fixture) owns the expensive shared pieces in `OneTimeSetUp`: the Kestrel host, Playwright, the browser, and **one** `IBrowserContext`. Each `[Test]` gets a **fresh `IPage`** (`[SetUp]`/`[TearDown]`) so a still-running game, a set `window._canvasContextType`, or leftover DOM can't leak across tests. The context is shared (not per-test) so the ~15-20 MB WASM payload stays in the HTTP cache between pages instead of re-downloading each test. `SmokeTest` and `CoreBehaviorsTest` both extend it; put shared helpers (`BootAsync`, `ClickRunAsync`, `WaitForWebGlContextAsync`, `SetEditorValueAsync`, etc.) on the base.

**Locally:** if `artifacts/e2e-publish/wwwroot/index.html` already exists, skip publish — just `dotnet test`. CI (`.github/workflows/e2e.yml`) does publish → build test proj → `playwright.ps1 install chromium` → `dotnet test --no-build`.
**Locally:** if `artifacts/e2e-publish/wwwroot/index.html` already exists, skip publish — just `dotnet test`. **But if you changed app code** (a new `data-testid`, a hook), re-publish first or the served app is stale and tests fail on the missing hook. CI (`.github/workflows/e2e.yml`) does publish → build test proj → `playwright.ps1 install chromium` → `dotnet test --no-build`. Full suite (14 tests, no shader test) runs ~1.5 min locally with warm caches.

## Windows-only publish (CI gotcha)

Expand Down Expand Up @@ -54,9 +54,21 @@ await WaitForWebGlContextAsync(); // passes only when THIS run re-sets it

Deterministic, no app-code change. The restart loop in `RepeatedRestart_UnchangedSource_StaysAliveAndKeepsWebGl` is the reference example.

## Deep-link (`#code=` / `#snippet=`) reload gotcha

A browser treats a navigation that differs **only by `#fragment`** as *in-page* — it does NOT reload the document, so Blazor's `OnAfterRender(firstRender)` never re-runs and the deep-link payload is silently ignored (editor keeps the old code, no compile fires). A brand-new page's *first* `GotoAsync` is a real cross-document load, so cold-boot deep-link tests are fine. But when the page is **already on the served app** (e.g. you booted, opened Share, grabbed the URL), navigating to that `#code=`/`#snippet=` URL needs a forced cross-document load: go to `about:blank` first (`BootFreshAsync` on the base). Symptom if you forget: the round-trip times out at `WaitForWebGlContextAsync` while the editor shows the *original* (not the shared) code and diagnostics are empty.

To build a deep-link payload deterministically in a test, reference `XnaFiddle.Core` (net8.0) and call `UrlCodec.Encode(code)` — same codec the app uses, so it round-trips. The E2E project already has this `ProjectReference`.

## Test hooks available (issue #112)

`data-testid` hooks on the UI (add more the same way — they're inert): `run-button` (Run/Restart, label toggles), `stop-button` (StopGame, only while a game runs), `examples-button`, `example-card` (+ `data-example-name="<name>"` for precise selection), `share-button`, `share-snippet-toggle`, `share-url-input` (the readonly URL box, present in both Code and Snippet modes), `assets-button`, `diagnostics` (the diagnostics `<pre>`, only rendered once non-empty), `game-canvas` (the `<canvas>`, both modes). Diagnostics text to sync on: `"Compiled in"` (fresh compile), `"Restarted without recompile"` (cache hit), and the status span text `"Compilation failed."` / `"Stopped."`.

One debug hook exists: **`GetInputDebugState`** (JSInvokable on `Index`, reachable via `window.theInstance.invokeMethodAsync('GetInputDebugState')`). Returns `{ gameRunning, mouseX, mouseY, touchCount }` (camelCase — Blazor JSInterop's default). Used by the A→B→A sample-switch test for a deterministic aliveness assertion instead of a pixel read.

## When to add an app-code hook

Rule of thumb: use the existing observable signals above; add a new app debug hook **only** for input/game-state assertions the DOM can't express. Example: issue #95's planned `GetInputDebugState` (exposing `Mouse.GetState()` position / touch count via JS interop) for a deterministic input assertion instead of a flaky pixel-read. Don't add hooks for anything already observable.
Rule of thumb: use the existing observable signals above; add a new app debug hook **only** for input/game-state assertions the DOM can't express. `GetInputDebugState` (above) is the one such hook — don't add more for anything already observable via a `data-testid` or `_canvasContextType`.

## Choosing N for restart/stress loops

Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ jobs:
with:
dotnet-version: 8.0.x

- name: Publish
# This is the real deploy: the published output is uploaded as the GitHub Pages artifact below.
# (The E2E workflow also runs `dotnet publish`, but only to serve the app locally to a test
# browser — see e2e.yml. Same command, different purpose.)
- name: Publish for GitHub Pages deploy
run: dotnet publish XnaFiddle.BlazorGL/XnaFiddle.BlazorGL.csproj -c Release -o output

- name: Add .nojekyll
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ jobs:
with:
dotnet-version: 8.0.x

- name: Publish Blazor WASM app
# "publish" here is the dotnet verb (Blazor WASM needs `publish`, not `build`, to lay out the
# _framework boot files a browser can run) — NOT a live deploy. The output is served locally to
# headless Chromium as this job's test input and never leaves the runner. The real site deploy is
# a separate workflow (deploy.yml -> GitHub Pages, on push to main).
- name: Publish WASM app for the local E2E test host (not a deploy)
run: dotnet publish XnaFiddle.BlazorGL/XnaFiddle.BlazorGL.csproj -c Release -o "${{ env.E2E_PUBLISH_DIR }}"

- name: Build E2E test project
Expand Down
17 changes: 10 additions & 7 deletions XnaFiddle.BlazorGL/Pages/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -47,25 +47,27 @@
@if (_game != null)
{
<button @onclick="StopGame"
data-testid="stop-button"
style="padding: 4px 14px; background: #e04040; color: #fff; border: none; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;">
<i class="codicon codicon-debug-stop"></i>
Stop
</button>
}
}
<button @onclick="OpenExampleBrowser"
data-testid="examples-button"
style="padding: 4px 8px; background: @(_exampleBrowserOpen ? "#0e639c" : "#3c3c3c"); color: #d4d4d4;
border: 1px solid #555; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;
user-select: none; -webkit-user-select: none;">
<i class="codicon codicon-library"></i>
@(string.IsNullOrEmpty(_selectedExample) ? "Examples" : _selectedExample)
</button>
<button @onclick="OpenShareDialog" title="Share"
<button @onclick="OpenShareDialog" title="Share" data-testid="share-button"
style="padding: 4px 8px; background: @(_shareOpen ? "#0e639c" : "#3c3c3c"); color: #d4d4d4;
border: 1px solid #555; cursor: pointer; font-size: 14px; display: flex; align-items: center;">
<i class="codicon codicon-link-external"></i>
</button>
<button @onclick="() => _assetsOpen = !_assetsOpen" title="@(_assets.Count > 0 ? $"Assets ({_assets.Count})" : "Assets")"
<button @onclick="() => _assetsOpen = !_assetsOpen" title="@(_assets.Count > 0 ? $"Assets ({_assets.Count})" : "Assets")" data-testid="assets-button"
style="padding: 4px 8px; background: @(_assets.Count > 0 ? "#2d5a2d" : "#3c3c3c"); color: #d4d4d4;
border: 1px solid #555; cursor: pointer; font-size: 14px; display: flex; align-items: center; position: relative;">
<i class="codicon codicon-file-media"></i>
Expand Down Expand Up @@ -443,7 +445,7 @@
color: @(!_shareAsSnippet ? "white" : "#aaa"); font-size: 12px;">
Code
</button>
<button @onclick='() => _shareAsSnippet = true'
<button @onclick='() => _shareAsSnippet = true' data-testid="share-snippet-toggle"
style="padding: 3px 12px; cursor: pointer; border: 1px solid #555;
background: @(_shareAsSnippet ? "#0e639c" : "#3c3c3c");
color: @(_shareAsSnippet ? "white" : "#aaa"); font-size: 12px;">
Expand All @@ -455,7 +457,7 @@
{
@* Code mode: URL + copy *@
<div style="display: flex; align-items: center; gap: 6px;">
<input readonly value="@_shareCodeUrl"
<input readonly value="@_shareCodeUrl" data-testid="share-url-input"
style="flex: 1; padding: 3px 8px; background: #1e1e1e; color: #9cdcfe;
border: 1px solid #555; font-size: 12px; font-family: Consolas, monospace;" />
<button @onclick="CopyShareUrl"
Expand Down Expand Up @@ -519,7 +521,7 @@

@* Live URL + copy *@
<div style="display: flex; align-items: center; gap: 6px;">
<input readonly value="@_snippetPreviewUrl"
<input readonly value="@_snippetPreviewUrl" data-testid="share-url-input"
style="flex: 1; padding: 3px 8px; background: #1e1e1e; color: #9cdcfe;
border: 1px solid #555; font-size: 12px; font-family: Consolas, monospace;" />
<button @onclick="CopyShareUrl"
Expand Down Expand Up @@ -615,7 +617,7 @@
@if (!string.IsNullOrEmpty(_diagnosticsOutput))
{
<div id="diagnosticsSplitter" style="background: #007acc; cursor: ns-resize; flex-shrink: 0; touch-action: none;"></div>
<pre id="diagnosticsPanel" style="margin: 0; padding: 8px 12px; background: #1a1a1a; color: @_diagnosticsColor;
<pre id="diagnosticsPanel" data-testid="diagnostics" style="margin: 0; padding: 8px 12px; background: #1a1a1a; color: @_diagnosticsColor;
font-size: 12px; font-family: Consolas, monospace; overflow: auto;
height: 100px; flex-shrink: 0; white-space: pre-wrap; word-wrap: break-word;">@_diagnosticsOutput</pre>
}
Expand Down Expand Up @@ -674,6 +676,7 @@
if (example.Category != _selectedCategory) continue;
var isSelected = example.Name == _selectedExample;
<div @onclick="() => SelectExample(example.Name)"
data-testid="example-card" data-example-name="@example.Name"
style="padding: 10px 14px; margin-bottom: 6px; cursor: pointer;
background: @(isSelected ? "#0e639c33" : "#2d2d2d");
border: 1px solid @(isSelected ? "#0e639c" : "#3c3c3c");
Expand Down Expand Up @@ -729,7 +732,7 @@
bottom: 0;
overflow: hidden;
left: @(_embedMode ? "0" : "calc(45vw + 2px)");">
<canvas @key="_canvasGen" id="theCanvas" style="touch-action:none;"></canvas>
<canvas @key="_canvasGen" id="theCanvas" data-testid="game-canvas" style="touch-action:none;"></canvas>
@if (!_embedMode && _editorCollapsed)
{
@* Issue #74: faint restore handle while the editor is collapsed. Kept low-opacity
Expand Down
38 changes: 38 additions & 0 deletions XnaFiddle.BlazorGL/Pages/Index.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
using Microsoft.JSInterop;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Input.Touch;

namespace XnaFiddle.Pages
{
Expand Down Expand Up @@ -355,6 +357,42 @@ public void TriggerCompileAndRun()
CompileAndRun();
}

// Test-only debug hook (issue #112): exposes the current input state and whether a game is
// live so an e2e test can make a DETERMINISTIC assertion that the input subsystem is wired
// after a run / example switch, instead of a flaky pixel read of the canvas. This is the
// one app-code hook the e2e-testing skill's boundary allows — input/game state the DOM
// can't express. Reading input is wrapped in try/catch and gated on a running game so a
// pre-run probe (no window/device yet) returns zeros rather than throwing.
[JSInvokable]
public InputDebugState GetInputDebugState()
{
var state = new InputDebugState { GameRunning = _game != null };
if (_game == null)
return state;
try
{
MouseState mouse = Mouse.GetState();
state.MouseX = mouse.X;
state.MouseY = mouse.Y;
state.TouchCount = TouchPanel.GetState().Count;
}
catch
{
// Input not queryable yet (device/window still coming up) — leave the zeroed
// defaults; GameRunning already conveys the game is alive.
}
return state;
}

// Serialized to JS for GetInputDebugState. Public so JSInterop can marshal it.
public sealed class InputDebugState
{
public bool GameRunning { get; set; }
public int MouseX { get; set; }
public int MouseY { get; set; }
public int TouchCount { get; set; }
}

private string GetAssetUrlsFragment()
{
var urls = new List<string>();
Expand Down
Loading