Skip to content

test(editor): retain a tested offscreen editor surface capture path - #323

Merged
Eli Pinkerton (wallstop) merged 3 commits into
masterfrom
dev/wallstop/editor-surface-capture
Jul 31, 2026
Merged

test(editor): retain a tested offscreen editor surface capture path#323
Eli Pinkerton (wallstop) merged 3 commits into
masterfrom
dev/wallstop/editor-surface-capture

Conversation

@wallstop

@wallstop Eli Pinkerton (wallstop) commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the mechanism half of PLAN.md WS-7.3 / #314: a retained, test-pinned path that renders a real shipped editor surface into an offscreen render target and writes a cropped 24-bit PNG, never reading the desktop.

Session 177 proved the idea but retained no helper, produced RGBA rather than the manifest's 24-bit RGB, and emitted an IMGUI cursor diagnostic. Tests/Editor/EditorSurfaceCapture.cs is that helper, with eight tests in EditorSurfaceCaptureTests.

It hosts the surface in a HideAndDontSave window, drives the panel through ValidateLayout() -> Repaint(Event) -> Render() into a linear RenderTexture, crops to the surface's own worldBound, and verifies PNG color type 2 before writing. It restores the render target and GL.sRGBWrite, destroys everything it made on both paths, and reports the editor skin instead of switching it.

No tracked screenshot is replaced. The artwork half of WS-7.3 stays blocked on the host: it is Pro/dark, the manifest requires Personal/light, and skin switching is banned. #314 stays open for that.

Findings

Four things were wrong on the first attempt. Each is now fixed in code or recorded in the manifest.

A never-shown window has no panel, so there is nothing to lay out or render. That also surfaced a real defect in the shared test host: EditorWindowTestUtility.CloseWindow called EditorWindow.Close() unconditionally, and Close() dereferences a null parent for a window that was never shown. Because the throw came from a finally, it replaced the original exception and hid the actual failure. CloseWindow now destroys a parentless window instead.

Omitting the repaint step yields a valid PNG of a blank frame. ValidateLayout() then Render() alone produces a correctly sized, correctly typed, entirely empty image. The tests count distinct colors so a blank frame fails rather than passing as a valid PNG.

A full-canvas readback frames the host window's chrome. The first visually inspected artifact showed the test host's tab strip with the surface clipped underneath it. Cropping to worldBound removes the chrome and is also what gives each image the tight frame the capture list asks for. Render-target rows start at the bottom while UI Toolkit measures from the top, so the vertical origin is canvasHeight - worldBound.yMax.

The AddCursorRect diagnostic does not apply to the overlay crops. Session 177 hit EditorGUIUtility.AddCursorRect called outside an editor OnGUI while rendering a whole live Inspector with an IMGUI body. The overlay images the manifest asks for are crops of the package-owned UI Toolkit view, which renders directly and emits nothing.

Red/green evidence

Live host, Unity 6000.4.6f1, Direct3D11. The capture runs are the red-green record: 3/5 on the first run (teardown NRE), 6/2 once the panel existed (blank frames), 8/0 once the repaint step was added, 8/0 again after the crop change.

  • EditorSurfaceCaptureTests: 8 passed, 0 failed;
  • full WallstopStudios.DxMessaging.Tests.Editor assembly: 557 passed, 0 failed (549 before this session's 8 new tests, so no regression from the CloseWindow change);
  • visual inspection: MessageAwareComponentInspectorView renders to a 720x63 RGB24 crop, 293 distinct colors, no window chrome, no clipping, complete 4-side border;
  • Console after capture: one pre-existing unrelated Hot Reload warning, no capture diagnostic, nothing cleared;
  • host after capture: seven windows, all standard Unity, no test-host leak, no leaked GameObjects, skin unchanged at isProSkin=True / UserSkin=1;
  • design-system-dumps.test.js and editor-window-test-host.test.js green, including the blocked-capture-primitive bans;
  • full Node/script suite: 406 passed, 0 failed;
  • npm run validate:all, spelling, prettier, markdownlint, csharpier, pre-commit: passed.

Progress log: progress/session-180-editor-surface-capture.md.

Refs #314


Note

Low Risk
Changes are confined to editor test infrastructure and documentation; production runtime code is untouched, with tests guarding render-state cleanup and crop refusal.

Overview
Adds EditorSurfaceCapture and nine EditorSurfaceCaptureTests to close the WS-7.3 mechanism half: render real shipped UI Toolkit editor surfaces offscreen into a linear render target, crop to the surface’s worldBound, and write manifest-compliant 24-bit RGB PNGs (color type 2) without desktop or screen capture.

The capture path shows a hidden test host window, reflects inherited panel ValidateLayoutRepaintRender, restores RenderTexture.active and GL.sRGBWrite, and refuses oversized crops instead of silently clipping. EditorWindowTestUtility.CloseWindow now destroys never-shown windows (no m_Parent) so offscreen capture teardown does not NRE.

Docs in docs/images/inspector-overlay/README.md document the retained helper; progress/session-180-editor-surface-capture.md logs findings. No tracked screenshot PNGs are replaced—final Personal/light artwork remains blocked on host theme (#314).

Reviewed by Cursor Bugbot for commit 2ac96a6. Bugbot is set up for automated code reviews on this repo. Configure here.

PLAN.md WS-7.3 had two halves: a repeatable capture mechanism, and the final
Personal/light artwork. This closes the mechanism half. Session 177 proved the
idea but retained no helper, produced RGBA rather than the manifest's 24-bit
RGB, and emitted an IMGUI cursor diagnostic.

EditorSurfaceCapture renders a real shipped surface into an offscreen linear
RenderTexture, crops to the surface's own worldBound, and writes RGB24 after
verifying PNG color type 2. It restores the render target and GL.sRGBWrite,
destroys everything it made on both paths, and reports the editor skin instead
of switching it. Eight tests pin that contract; the full editor assembly is
557/0.

Three findings are recorded in the manifest so they are not rediscovered:

- A never-shown window has no panel, so there is nothing to lay out or render.
  That also surfaced a real defect in the shared test host --
  EditorWindowTestUtility.CloseWindow called EditorWindow.Close()
  unconditionally, and Close() dereferences a null parent for a window that was
  never shown. Because the throw came from a finally, it replaced the original
  exception and hid the actual failure. CloseWindow now destroys a parentless
  window instead.
- Omitting the Repaint step between ValidateLayout and Render yields a valid PNG
  of a blank frame, which is why the tests count distinct colors.
- A full-canvas readback frames the host window's tab strip and clips the
  surface underneath it. Cropping is also what gives each image the tight frame
  the capture list asks for.

The AddCursorRect diagnostic came from driving a whole live Inspector with an
IMGUI body; the overlay crops render the package-owned UI Toolkit view directly
and emit nothing.

No tracked screenshot is replaced. The artwork half stays blocked on the host:
it is Pro/dark, the manifest requires Personal/light, and skin switching is
banned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 06:00
@wallstop

Copy link
Copy Markdown
Collaborator Author

Cursor (@cursor) review
Copilot review

Please focus on the capture helper's failure paths and on whether the tests can pass while the capture is actually broken.

Specifically:

  • EditorSurfaceCapture.Capture restores RenderTexture.active and GL.sRGBWrite and destroys the host window plus both textures in a finally. Is there a path that leaks one of them, or that leaves a global mutated?
  • The crop math converts UI Toolkit's top-left worldBound into bottom-up render-target coordinates and clamps to the canvas. Are the clamps correct for a surface that starts inside the canvas but extends past its right or bottom edge?
  • The distinct-color assertion is the only thing standing between "rendered the real view" and "wrote a valid PNG of nothing". Is there a cheaper way for it to pass falsely?
  • EditorWindowTestUtility.CloseWindow now destroys a window whose m_Parent is null instead of calling Close(). Does that change teardown for any existing fixture that shows its windows?

Evidence in the description: 8/0 capture tests, 557/0 full editor assembly, a visually inspected 720x63 RGB24 crop with no chrome or clipping, a clean Console, and a host left at its seven standard windows.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dec91ce. Configure here.

Comment thread Tests/Editor/EditorSurfaceCapture.cs Outdated
Comment thread Tests/Editor/EditorSurfaceCaptureTests.cs
…tore

Two review findings, one of which an adversarial pass had already found.

Refuse a surface that does not fit the canvas instead of clamping it. Clamping
returned a silently trimmed image, so the helper built to prevent clipped
screenshots could quietly produce one. Bugbot found the same defect from the
bottom edge, where the clamped origin kept the full height and pulled rows
above the surface into the readback.

Make the failure-path restore test actually exercise the finally. It triggered
the failure with a zero canvas width, which throws in argument validation
BEFORE the capture takes over the render target or creates its host window, so
the whole finally block could have been deleted and the test would still pass.
It now fails from inside the try via an oversized surface, and also asserts the
host window was closed.

Verified by mutation: deleting the two restore lines from the finally fails both
restore tests including the failure-path one (7 passed, 2 failed). Under the old
test body that mutation went undetected. Full editor assembly 558/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 06:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@wallstop

Copy link
Copy Markdown
Collaborator Author

Cursor (@cursor) review
Copilot review

Both findings are fixed in 9a566c51.

Bottom crop clamp keeps full height. Correct, and the fix goes further than the report: the clamp is gone entirely. ResolveCropRect now refuses a surface that does not fit the canvas and names both rects, rather than adjusting the rect to fit. Clamping in a screenshot helper is the wrong shape regardless of which edge it happens on -- it makes the tool built to prevent clipped documentation images capable of silently producing one. CaptureRefusesASurfaceLargerThanTheCanvas pins the refusal and also asserts no partial file is left behind.

Failure restore test skips finally. Also correct, and it was the more serious of the two, because it meant the finally block had no coverage at all. The test now fails from inside the try by way of an oversized surface, which throws at the crop step -- past the render-target takeover, past GL.sRGBWrite, and past host-window creation. It additionally asserts the host window was closed, which the argument-validation path could never have exercised because no window existed.

Proven by mutation rather than by inspection: deleting the two restore lines from the finally now fails both restore tests, including the failure-path one (7 passed, 2 failed, Expected: same as <RenderTexture> But was: null). Under the previous test body that same mutation went undetected.

Full editor assembly after the fixes: 558 passed, 0 failed.

Worth re-reviewing with fresh eyes: the refusal predicate now compares cropX + cropWidth > canvasWidth and cropY + cropHeight > canvasHeight against rounded integers derived from a float worldBound. Is there a rounding case where a surface that visually fits is rejected, or vice versa?

The crop rounded bounds.x and bounds.width independently, which lets the two
drift a pixel apart: a surface at x=10.5 w=10.5 rounds to x=10 w=10, a right
edge of 20 where the real edge is 21. In a helper whose whole purpose is
unclipped documentation images, a lost pixel is a clipped surface.

Round the edges and derive the size from them, so the integer rect always
tracks the float rect. The existing exact-size assertions cover the systematic
case; full editor assembly 558/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 06:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@wallstop
Eli Pinkerton (wallstop) merged commit 69af9d1 into master Jul 31, 2026
41 of 42 checks passed
@wallstop
Eli Pinkerton (wallstop) deleted the dev/wallstop/editor-surface-capture branch July 31, 2026 07:40
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.

2 participants