Skip to content

test(vitest-browser): state-based integration suites for GenericViewport and tools#2792

Merged
sedghi merged 2 commits into
mainfrom
cornerstoneVitestStateTests
Jul 9, 2026
Merged

test(vitest-browser): state-based integration suites for GenericViewport and tools#2792
sedghi merged 2 commits into
mainfrom
cornerstoneVitestStateTests

Conversation

@sedghi

@sedghi sedghi commented Jul 9, 2026

Copy link
Copy Markdown
Member

What

Adds 12 state-based (non-screenshot) integration test suites running in Vitest Browser Mode (real headless Chromium + WebGL, pnpm test:vitest:browser), plus a shared harness under tests/vitest-browser/harness/:

  • deterministic N-slice fake image stack (known pixel values: background 10 + sliceIndex, bar 255 at columns 20-25, 1 mm spacing) so every expected value is closed-form
  • viewport factory for all four planar render modes, normalized state-snapshot capture, event recorder, and tools input synthesis (mouse click/drag/wheel)

All assertions target public API state, events, and exact expected values. No pixel comparisons.

Core viewport suites

Suite Covers
renderPathParity identical public-API scenario across vtkImage / vtkVolumeSlice / cpuImage / cpuVolume must yield identical core state
planarInvariants canvasToWorld/worldToCanvas round trips, fixed-point zoom, setter/getter symmetry, view reference round trips, scroll clamping
eventContracts exact event counts per discrete op, render coalescing, golden event sequences, silence after destroy
lifecycleAndRaces enable/disable/re-enable, destroy semantics, cache accounting, setDisplaySets races, resize anchoring
viewStateSerialization JSON persistence round trip, destroy/recreate restore, cross-render-mode restore, idempotent restore
toolMeasurements Length + RectangleROI stats vs closed-form values (first tools-in-vitest proof)

Tools suites (black-box: public exports, synthesized input, observable state only)

Suite Covers
annotationToolsMatrix Probe/Angle/Bidirectional/Rectangle/Elliptical/Circle ROI: handle world coords + cachedStats vs closed-form values, handle drag, whole-annotation translation
toolGroupAndManipulation ToolGroupManager state machine, mode transitions/events, binding exclusivity, Pan/Zoom/WindowLevel/StackScroll exact-value behavior
annotationStateManagement CRUD, selection/locking/visibility contracts, slice binding, JSON persistence, golden annotation event sequence
segmentationState labelmap paint/erase exact voxel counts, segment index switching, active segmentation, visibility, events, worker-based statistics
undoRedoHistory byte-exact labelmap undo/redo, LIFO ordering, branch truncation, annotation memo integration, HISTORY event payloads
synchronizers zoomPan/VOI/imageSlice/cameraPosition on GenericViewports, Synchronizer API surface, custom callback contract, no sync storms

Result

100 passed, 12 expected-fail, in ~90 s. Each test.fails pins a real engine finding with an in-file comment (observed vs expected), so fixing the underlying bug flips the test to green and fails the build until the marker is removed.

Findings pinned by test.fails

  • volume-backed render paths initially resolve to the last slice instead of initialImageIdIndex: 0, while getSliceIndex() reports 0 (createInitialVolumeSliceState)
  • PlanarViewport has no getCamera(), which breaks CircleROITool rendering (uncaught RAF error) and silently disables the ImageSlice synchronizer (async throw swallowed by Promise.allSettled)
  • VOI synchronizer never syncs invert on GenericViewports (invertStateChanged is never set); CameraPosition synchronizer drops rotation
  • setDisplaySetPresentation / setViewState emit VOI_MODIFIED / CAMERA_MODIFIED without no-op detection
  • getViewReference() numbers are not byte-stable through JSON (float32 promotion + trig round-off)
  • resetDisplaySetPresentation resets to {} rather than pre-mutation defaults
  • SEGMENTATION_REPRESENTATION_ADDED is declared and listened for but never fired
  • getUniqueSegmentIndices is metadata-driven and reports stale indices after undo/erase

Other contracts documented in-file

Annotation JSON persistence silently loses selection/locking/visibility (separate stores + preprocessing hook); activating a second tool on a binding does not demote the first and dispatch resolves by first-inserted tool; cancelActiveManipulations completes rather than deletes the in-progress annotation; labelmap-on-generic-stack setup requires awaiting the first SEGMENTATION_RENDERED before painting.

Notes

  • vitest.browser.config.ts: adds a @cornerstonejs/tools source alias (tools now testable in vitest browser mode).
  • .gitignore: vitest browser-mode failure artifacts (.vitest-attachments/, tests/vitest-browser/__screenshots__/).
  • The pre-existing genericStackApi.browser.test.ts screenshot test is environment-sensitive (software-rasterizer baselines); it is untouched here and unrelated to these suites.

https://claude.ai/code/session_01NJNAWVWWtguh3aA8w1R9mp

Summary by CodeRabbit

  • New Features

    • Expanded browser test coverage across viewport lifecycle, invariants, render-path parity, tools (annotation, measurements, segmentation), undo/redo history, and synchronizers.
    • Added shared test utilities for deterministic planar viewports, synthetic input, event recording, and viewport state snapshot comparisons.
  • Bug Fixes

    • Updated test artifact exclusions to prevent browser-mode outputs from being tracked.
    • Improved reliability of DICOM fixture loading by retrying transient remote fetch failures and extending render wait timeouts.
  • Chores

    • Updated browser test configuration to reliably resolve the tools package.

…iewport and tools

Adds 12 state-based (non-screenshot) integration test suites in Vitest
Browser Mode, plus a shared harness (deterministic fake image stack,
viewport factory, state-snapshot capture, event recorder, tools input
synthesis). All assertions target public API state, events, and
closed-form expected values on synthetic data.

Core viewport suites: render-path state parity, geometric round-trip
invariants, event contracts, lifecycle/teardown/races, serialization
and session restore.

Tools suites (black-box, public API only): annotation tool measurement
matrix, tool group state machine + manipulation tools, annotation state
management and persistence, labelmap segmentation state, undo/redo
history, cross-viewport synchronizers.

Runs green in ~90s: 100 passed, 12 expected-fail (each test.fails pins
a real engine finding documented in-file), no pixel comparisons.

Claude-Session: https://claude.ai/code/session_01NJNAWVWWtguh3aA8w1R9mp
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a large Vitest browser test harness and contract suite for Cornerstone viewport, annotation, segmentation, synchronizer, tool-group, and serialization behavior. Also adds a Playwright helper for retrying remote fixture requests and wires it into DICOM loader specs, plus minor browser-test config and ignore updates.

Changes

Vitest Browser Test Coverage

Layer / File(s) Summary
Shared harness and config
tests/vitest-browser/harness/*, .gitignore, vitest.browser.config.ts
Adds browser-test harness modules for fake stacks, planar viewport creation, viewport-state capture, event recording, tool setup, and barrel exports; updates browser config aliasing and ignore rules.
Annotation state management tests
tests/vitest-browser/annotationStateManagement.browser.test.ts
Adds tests for annotation CRUD, selection, locking, visibility, slice binding, JSON persistence round-trips, and a golden event sequence.
Annotation tool matrix and measurement tests
tests/vitest-browser/annotationToolsMatrix.browser.test.ts, tests/vitest-browser/toolMeasurements.browser.test.ts
Adds per-tool state coverage for Probe, Angle, Bidirectional, RectangleROI, EllipticalROI, CircleROI, and Length tools, plus measurement tests for handle positions, cachedStats, navigation persistence, and ROI statistics.
Viewport event contract tests
tests/vitest-browser/eventContracts.browser.test.ts
Pins event ordering and payload contracts for lifecycle, camera, pan-coalescing, scroll, VOI, and post-teardown events.
Lifecycle and race-condition tests
tests/vitest-browser/lifecycleAndRaces.browser.test.ts
Adds tests covering re-enable/destroy behavior, cache accounting, setDisplaySets races, removeData, resize recentering, and multi-viewport independence.
Planar invariants, render-path parity, and view-state serialization tests
tests/vitest-browser/planarInvariants.browser.test.ts, tests/vitest-browser/renderPathParity.browser.test.ts, tests/vitest-browser/viewStateSerialization.browser.test.ts
Adds coordinate round-trip and invariance tests, cross-render-mode parity comparisons using state snapshots, and view-state JSON persistence/restoration tests including known divergence cases.
Segmentation state and undo/redo history tests
tests/vitest-browser/segmentationState.browser.test.ts, tests/vitest-browser/undoRedoHistory.browser.test.ts
Adds labelmap segmentation lifecycle tests and DefaultHistoryMemo undo/redo contract tests for voxels and annotations.
Synchronizer contract tests
tests/vitest-browser/synchronizers.browser.test.ts
Adds tests for ZoomPan, VOI, ImageSlice, and CameraPosition synchronizers, the SynchronizerManager API, custom callback contracts, and sync-storm bounding.
Tool group and manipulation tests
tests/vitest-browser/toolGroupAndManipulation.browser.test.ts
Adds ToolGroupManager state-transition tests and manipulation tests for Pan, Zoom, WindowLevel, and StackScroll tools plus cancelActiveManipulations behavior.

Remote fixture retry utility

Layer / File(s) Summary
Retry helper and spec wiring
tests/utils/retryRemoteFixtures.ts, tests/utils/index.ts, tests/dicomImageLoaderWADOURI.spec.ts, tests/genericViewport/genericDicomImageLoaderWADOURI.spec.ts
Adds a Playwright route helper for retrying transient remote fixture requests, re-exports it, and uses it in the DICOM loader specs with longer render waits.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • cornerstonejs/cornerstone3D#2790: Adds contract-style tests for overlapping @cornerstonejs/tools annotation and synchronizer behavior.
  • Suggested reviewers: wayfarer3130, jbocce
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Concise, semantic-release styled, and accurately describes the main change: vitest-browser state-based integration suites for GenericViewport and tools.
Description check ✅ Passed The description thoroughly summarizes the changes, results, and test coverage, though it doesn't follow the template headings exactly.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cornerstoneVitestStateTests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (7)
tests/vitest-browser/planarInvariants.browser.test.ts (1)

92-129: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider sharing the normalization helper across test files.

deepRound here and normalizeDeep in viewStateSerialization.browser.test.ts (lines 86–123) implement the same rounding/typed-array/undefined-key normalization logic. Both comments note this duplication is intentional per the plan's "add local helpers rather than edit harness files" instruction. If the harness's internal normalizer were ever exported in the future, both copies could be replaced with a single import, reducing the maintenance surface.

This is purely optional given the explicit plan constraint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/planarInvariants.browser.test.ts` around lines 92 - 129,
The deepRound helper duplicates the same normalization logic already implemented
as normalizeDeep in viewStateSerialization.browser.test.ts. Keep the current
local helper for now, but if the shared harness normalization becomes available
later, replace both copies with a single imported utility so the rounding,
typed-array conversion, and undefined-key filtering stay consistent across test
files.
tests/vitest-browser/synchronizers.browser.test.ts (1)

673-708: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: wait(500) vs NO_PROPAGATION_WAIT_MS (400) inconsistency.

Line 692 uses a hardcoded 500 for the quiescence window while NO_PROPAGATION_WAIT_MS = 400 (line 266) was defined for the same negative-result pattern. The comment explains the 500ms is per the plan's wording, but a future reader may wonder why two different constants exist for the same purpose. Consider either reusing NO_PROPAGATION_WAIT_MS or defining a separate QUIESCENCE_WAIT_MS constant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/synchronizers.browser.test.ts` around lines 673 - 708,
The no-sync-storms test is using a hardcoded quiescence delay that duplicates an
existing timeout constant. Update the test around createZoomPanSynchronizer and
the wait-based negative-result check to either reuse NO_PROPAGATION_WAIT_MS or
introduce a clearly named QUIESCENCE_WAIT_MS constant for this bounded wait, so
the intent stays consistent and the timeout value is defined in one place.
tests/vitest-browser/eventContracts.browser.test.ts (1)

198-232: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

secondElement bypasses the safety-net teardown.

Unlike every other resource in this suite, secondElement is only removed at Lines 230-232. If any assertion between Lines 213-228 fails, the test throws and the element stays attached to document.body (per test 2, destroy() leaves DOM children in place), leaking into subsequent tests. Registering it with the track() net keeps it consistent with the file's stated leak-prevention intent.

♻️ Track secondElement removal in the safety net
   document.body.appendChild(secondElement);
+  track(() => secondElement.parentNode?.removeChild(secondElement));
 
   ctx.renderingEngine.enableElement({

Then the explicit removal at the end (Lines 230-232) becomes redundant and can be dropped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/eventContracts.browser.test.ts` around lines 198 - 232,
The `secondElement` in the `eventContracts.browser.test.ts` teardown is not
covered by the suite’s `track()` safety net, so a failure before the final
cleanup can leak a DOM node into later tests. Update the test setup around
`secondElement` to register it with `track()` alongside the other resources,
using the existing teardown helpers in this file. Then remove the manual
`parentNode.removeChild` cleanup at the end since the tracked teardown should
handle it consistently.
tests/vitest-browser/toolGroupAndManipulation.browser.test.ts (1)

184-197: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard against a leaked first viewport if the second createPlanarViewport() throws.

a is created, then b = await createPlanarViewport(). If b's creation rejects, the cleanup closure is never returned and activeCleanup is never assigned in the calling test, so a's RenderingEngine, DOM element, fake image-stack registration, and the flipped useGenericViewport config all leak into subsequent tests. In CI a flaky WebGL context on the second viewport would then cascade failures across the file instead of failing this one test cleanly.

♻️ Clean up a if b fails
-  const a = await createPlanarViewport();
-  const b = await createPlanarViewport();
+  const a = await createPlanarViewport();
+  let b: PlanarViewportContext;
+  try {
+    b = await createPlanarViewport();
+  } catch (error) {
+    a.cleanup();
+    cornerstoneTools.destroy();
+    throw error;
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/toolGroupAndManipulation.browser.test.ts` around lines
184 - 197, Guard the first viewport setup in this test so it cannot leak if the
second createPlanarViewport call fails. Update the test flow around
createPlanarViewport, activeCleanup, and the cleanup handling so that if b
creation rejects after a is created, a.cleanup() is still invoked before the
error propagates. Make the fix in the browser test’s two-viewport setup path so
RenderingEngine, DOM, and config state are always released even when the second
viewport creation throws.
tests/vitest-browser/undoRedoHistory.browser.test.ts (1)

118-133: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Wait for SEGMENTATION_RENDERED before the first paint

renderAndWait only waits for IMAGE_RENDERED. This setup attaches the labelmap and starts painting immediately afterward, but the sibling segmentation suite waits for the first SEGMENTATION_RENDERED because the labelmap-to-viewport resolver can still be unresolved until that pass. Switching this setup to the segmentation render event would avoid a latent no-op stroke and SEGMENTATION_DATA_MODIFIED hang.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/undoRedoHistory.browser.test.ts` around lines 118 - 133,
The setup in undoRedoHistory.browser.test.ts starts painting after
renderAndWait, but that helper only waits for IMAGE_RENDERED and can leave the
segmentation layer unresolved. Update the first paint path to wait for the first
SEGMENTATION_RENDERED event instead, using the same segmentation render listener
approach already used in the sibling segmentation suite, so the
labelmap-to-viewport resolver is ready before strokes begin.
tests/vitest-browser/annotationToolsMatrix.browser.test.ts (1)

49-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider resetting selection/locking/visibility in afterEach for consistency with annotationStateManagement.

The afterEach in annotationStateManagement.browser.test.ts (lines 85–122) explicitly resets annotation.selection, annotation.locking, and annotation.visibility because these are module-level UID-keyed Sets not cleared by destroy()/init() or removeAllAnnotations(). Drawing annotations auto-selects them, leaving stale UIDs that could leak into subsequent tests. While no current test in this file asserts on selection/locking/visibility state, adding the same resets here would ensure consistency and prevent subtle issues if future tests are added.

♻️ Suggested addition
 afterEach(() => {
   if (!active) {
     return;
   }

   const ctx = active;
   active = null;
   ctx.cleanup();
+
+  // Reset module-level UID sets not cleared by cleanup(), matching
+  // annotationStateManagement.browser.test.ts afterEach.
+  try { annotation.selection.deselectAnnotation(); } catch {}
+  try { annotation.locking.unlockAllAnnotations(); } catch {}
+  try { annotation.visibility.showAllAnnotations(); } catch {}
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/annotationToolsMatrix.browser.test.ts` around lines 49 -
57, The afterEach cleanup in annotationToolsMatrix.browser.test.ts only calls
ctx.cleanup(), but it should also reset the shared annotation.selection,
annotation.locking, and annotation.visibility state for consistency with
annotationStateManagement.browser.test.ts. Update the afterEach block near
active/ctx.cleanup to clear those module-level UID-keyed Sets after cleanup so
auto-selected annotations do not leak stale state into later tests.
tests/vitest-browser/toolMeasurements.browser.test.ts (1)

34-195: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate duplicated utilities and setup with the shared harness.

This file imports createPlanarViewport and renderAndWait from ./harness but duplicates several utilities that the harness already provides:

  • round2 (lines 34–36) — duplicated in annotationToolsMatrix.browser.test.ts
  • distance3 (lines 38–43) — equivalent to worldDistance from the harness
  • clientPointFromCanvasPoint, dispatchMouseDown, dispatchMouseMove, dispatchMouseUp (lines 45–105) — equivalent to the harness's internal dispatch helpers in tools.ts
  • drawAnnotationByDrag (lines 119–142) — equivalent to mouseDrag + waitForAnnotationRendered from the harness, but dispatches a single mousemove instead of interpolated steps
  • setupToolTest (lines 160–195) — equivalent to setupTools from the harness

annotationToolsMatrix.browser.test.ts successfully uses setupTools, mouseDrag, waitForAnnotationRendered, and worldDistance from the harness for the same tool types. Migrating this file to those same imports would eliminate ~100 lines of duplicated code and ensure consistent test infrastructure. The single-mousemove difference in drawAnnotationByDrag works for Length/RectangleROI but could silently break if path-sensitive tools are added later.

♻️ Suggested migration sketch
 import {
   createPlanarViewport,
   renderAndWait,
+  setupTools,
+  mouseDrag,
+  waitForAnnotationRendered,
+  worldDistance,
   type PlanarViewportContext,
+  type ToolsContext,
 } from './harness';

-const { LengthTool, RectangleROITool, ToolGroupManager, annotation } =
-  cornerstoneTools;
-const { Events: ToolsEvents, MouseBindings } = cornerstoneTools.Enums;
-const { filterAnnotationsForDisplay } = cornerstoneTools.utilities.planar;
-
-function round2(point: Types.Point2): [number, number] {
-  return [Math.round(point[0]), Math.round(point[1])];
-}
-
-function distance3(a: Types.Point3, b: Types.Point3): number {
-  ...
-}
-
-// ... dispatchMouseDown, dispatchMouseMove, dispatchMouseUp,
-// ... drawAnnotationByDrag, setupToolTest all removed
-
-async function drawAnnotationByDrag(element, p1, p2) { ... }
-
-async function setupToolTest(toolClass, createOpts) { ... }
+const { LengthTool, RectangleROITool, annotation } = cornerstoneTools;
+const { Events: ToolsEvents } = cornerstoneTools.Enums;
+const { filterAnnotationsForDisplay } = cornerstoneTools.utilities.planar;
+
+// Use setupTools from harness, then mouseDrag + waitForAnnotationRendered
+// for drawing, and worldDistance for length calculations.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/toolMeasurements.browser.test.ts` around lines 34 - 195,
This test file reimplements harness helpers and setup logic that already exist
in the shared browser test harness. Replace the local `round2`, `distance3`,
mouse dispatch helpers, `drawAnnotationByDrag`, and `setupToolTest` usage with
the shared helpers used by `annotationToolsMatrix.browser.test.ts`
(`setupTools`, `mouseDrag`, `waitForAnnotationRendered`, and `worldDistance`).
Keep the test behavior aligned with the harness by using the same drag/wait flow
and remove the duplicated utility code so the test suite stays consistent with
`harness` and `tools.ts`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/vitest-browser/harness/createPlanarViewport.ts`:
- Around line 120-153: The setup in createPlanarViewport leaves
renderingConfig.useGenericViewport set to true before cleanup is available, so
any throw from RenderingEngine, enableElement, or nearby setup can leak state
into later tests. Move the restoration logic into a try/finally (or equivalent
guarded setup) around the initialization path so useGenericViewport is always
reset, and make sure partially created resources like the rendering engine, DOM
element, and fake stack are cleaned up even when setup fails. Keep the fix
localized to createPlanarViewport and its cleanup handling.

In `@tests/vitest-browser/harness/fakeImageStack.ts`:
- Around line 181-196: The imagePlaneModule shape in fakeImageStack is using the
wrong geometry mapping: swap width/height to match the IImage object, and fix
pixelSpacing to follow DICOM row/column ordering. Update the imagePlaneModule
return in fakeImageStack so width uses columns, height uses rows, and
pixelSpacing matches rowPixelSpacing/columnPixelSpacing semantics (with
rowPixelSpacing and columnPixelSpacing left consistent with the existing
values).

---

Nitpick comments:
In `@tests/vitest-browser/annotationToolsMatrix.browser.test.ts`:
- Around line 49-57: The afterEach cleanup in
annotationToolsMatrix.browser.test.ts only calls ctx.cleanup(), but it should
also reset the shared annotation.selection, annotation.locking, and
annotation.visibility state for consistency with
annotationStateManagement.browser.test.ts. Update the afterEach block near
active/ctx.cleanup to clear those module-level UID-keyed Sets after cleanup so
auto-selected annotations do not leak stale state into later tests.

In `@tests/vitest-browser/eventContracts.browser.test.ts`:
- Around line 198-232: The `secondElement` in the
`eventContracts.browser.test.ts` teardown is not covered by the suite’s
`track()` safety net, so a failure before the final cleanup can leak a DOM node
into later tests. Update the test setup around `secondElement` to register it
with `track()` alongside the other resources, using the existing teardown
helpers in this file. Then remove the manual `parentNode.removeChild` cleanup at
the end since the tracked teardown should handle it consistently.

In `@tests/vitest-browser/planarInvariants.browser.test.ts`:
- Around line 92-129: The deepRound helper duplicates the same normalization
logic already implemented as normalizeDeep in
viewStateSerialization.browser.test.ts. Keep the current local helper for now,
but if the shared harness normalization becomes available later, replace both
copies with a single imported utility so the rounding, typed-array conversion,
and undefined-key filtering stay consistent across test files.

In `@tests/vitest-browser/synchronizers.browser.test.ts`:
- Around line 673-708: The no-sync-storms test is using a hardcoded quiescence
delay that duplicates an existing timeout constant. Update the test around
createZoomPanSynchronizer and the wait-based negative-result check to either
reuse NO_PROPAGATION_WAIT_MS or introduce a clearly named QUIESCENCE_WAIT_MS
constant for this bounded wait, so the intent stays consistent and the timeout
value is defined in one place.

In `@tests/vitest-browser/toolGroupAndManipulation.browser.test.ts`:
- Around line 184-197: Guard the first viewport setup in this test so it cannot
leak if the second createPlanarViewport call fails. Update the test flow around
createPlanarViewport, activeCleanup, and the cleanup handling so that if b
creation rejects after a is created, a.cleanup() is still invoked before the
error propagates. Make the fix in the browser test’s two-viewport setup path so
RenderingEngine, DOM, and config state are always released even when the second
viewport creation throws.

In `@tests/vitest-browser/toolMeasurements.browser.test.ts`:
- Around line 34-195: This test file reimplements harness helpers and setup
logic that already exist in the shared browser test harness. Replace the local
`round2`, `distance3`, mouse dispatch helpers, `drawAnnotationByDrag`, and
`setupToolTest` usage with the shared helpers used by
`annotationToolsMatrix.browser.test.ts` (`setupTools`, `mouseDrag`,
`waitForAnnotationRendered`, and `worldDistance`). Keep the test behavior
aligned with the harness by using the same drag/wait flow and remove the
duplicated utility code so the test suite stays consistent with `harness` and
`tools.ts`.

In `@tests/vitest-browser/undoRedoHistory.browser.test.ts`:
- Around line 118-133: The setup in undoRedoHistory.browser.test.ts starts
painting after renderAndWait, but that helper only waits for IMAGE_RENDERED and
can leave the segmentation layer unresolved. Update the first paint path to wait
for the first SEGMENTATION_RENDERED event instead, using the same segmentation
render listener approach already used in the sibling segmentation suite, so the
labelmap-to-viewport resolver is ready before strokes begin.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19f53b78-3fcf-4c44-9148-523435e84e23

📥 Commits

Reviewing files that changed from the base of the PR and between f5b38d2 and 565c089.

📒 Files selected for processing (20)
  • .gitignore
  • tests/vitest-browser/annotationStateManagement.browser.test.ts
  • tests/vitest-browser/annotationToolsMatrix.browser.test.ts
  • tests/vitest-browser/eventContracts.browser.test.ts
  • tests/vitest-browser/harness/captureViewportState.ts
  • tests/vitest-browser/harness/createPlanarViewport.ts
  • tests/vitest-browser/harness/fakeImageStack.ts
  • tests/vitest-browser/harness/index.ts
  • tests/vitest-browser/harness/recordEvents.ts
  • tests/vitest-browser/harness/tools.ts
  • tests/vitest-browser/lifecycleAndRaces.browser.test.ts
  • tests/vitest-browser/planarInvariants.browser.test.ts
  • tests/vitest-browser/renderPathParity.browser.test.ts
  • tests/vitest-browser/segmentationState.browser.test.ts
  • tests/vitest-browser/synchronizers.browser.test.ts
  • tests/vitest-browser/toolGroupAndManipulation.browser.test.ts
  • tests/vitest-browser/toolMeasurements.browser.test.ts
  • tests/vitest-browser/undoRedoHistory.browser.test.ts
  • tests/vitest-browser/viewStateSerialization.browser.test.ts
  • vitest.browser.config.ts

Comment on lines +120 to +153
init();

const renderingConfig = getConfiguration().rendering;
const previousUseGenericViewport = renderingConfig.useGenericViewport;
renderingConfig.useGenericViewport = true;

const isVolumeBacked =
renderMode === 'vtkVolumeSlice' || renderMode === 'cpuVolume';
const isCpu = renderMode === 'cpuImage' || renderMode === 'cpuVolume';

if (isVolumeBacked) {
ensureFakeVolumeLoaderRegistered();
}

const fakeStack = registerFakeImageStack(opts.stack);
const { imageIds } = fakeStack;

const renderingEngine = new RenderingEngine(renderingEngineId);
const element = document.createElement('div');
element.dataset.testid = 'planar-harness-viewport';
element.style.width = `${width}px`;
element.style.height = `${height}px`;
document.body.appendChild(element);

renderingEngine.enableElement({
viewportId,
type: ViewportType.PLANAR_NEXT,
element,
defaultOptions: {
orientation,
},
});

const viewport = renderingEngine.getViewport<PlanarViewport>(viewportId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

useGenericViewport config leaks if setup fails before cleanup is defined.

renderingConfig.useGenericViewport is set to true at line 124, but the cleanup function (which restores it) isn't defined until line 156. If new RenderingEngine(), enableElement(), or any intervening call throws, the config is never restored — all subsequent tests in the run would use the wrong viewport type, producing cascading failures that are hard to attribute.

Wrap the setup section in a try/catch that restores the config and cleans up partially created resources (rendering engine, DOM element, fake stack).

🔧 Proposed fix
   init();

   const renderingConfig = getConfiguration().rendering;
   const previousUseGenericViewport = renderingConfig.useGenericViewport;
   renderingConfig.useGenericViewport = true;

+  let earlyFakeStack: ReturnType<typeof registerFakeImageStack> | undefined;
+  let earlyEngine: RenderingEngine | undefined;
+  let earlyElement: HTMLDivElement | undefined;
+
+  try {
   const isVolumeBacked =
     renderMode === 'vtkVolumeSlice' || renderMode === 'cpuVolume';
   const isCpu = renderMode === 'cpuImage' || renderMode === 'cpuVolume';

   if (isVolumeBacked) {
     ensureFakeVolumeLoaderRegistered();
   }

-  const fakeStack = registerFakeImageStack(opts.stack);
+  earlyFakeStack = registerFakeImageStack(opts.stack);
+  const fakeStack = earlyFakeStack;
   const { imageIds } = fakeStack;

-  const renderingEngine = new RenderingEngine(renderingEngineId);
-  const element = document.createElement('div');
+  earlyEngine = new RenderingEngine(renderingEngineId);
+  earlyElement = document.createElement('div');
+  const renderingEngine = earlyEngine;
+  const element = earlyElement;
   element.dataset.testid = 'planar-harness-viewport';
   element.style.width = `${width}px`;
   element.style.height = `${height}px`;
   document.body.appendChild(element);

   renderingEngine.enableElement({
     viewportId,
     type: ViewportType.PLANAR_NEXT,
     element,
     defaultOptions: {
       orientation,
     },
   });

   const viewport = renderingEngine.getViewport<PlanarViewport>(viewportId);
+  } catch (error) {
+    if (previousUseGenericViewport !== undefined) {
+      getConfiguration().rendering.useGenericViewport =
+        previousUseGenericViewport;
+    }
+    earlyFakeStack?.unregister();
+    earlyEngine?.destroy();
+    if (earlyElement?.parentNode) {
+      earlyElement.parentNode.removeChild(earlyElement);
+    }
+    throw error;
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/harness/createPlanarViewport.ts` around lines 120 - 153,
The setup in createPlanarViewport leaves renderingConfig.useGenericViewport set
to true before cleanup is available, so any throw from RenderingEngine,
enableElement, or nearby setup can leak state into later tests. Move the
restoration logic into a try/finally (or equivalent guarded setup) around the
initialization path so useGenericViewport is always reset, and make sure
partially created resources like the rendering engine, DOM element, and fake
stack are cleaned up even when setup fails. Keep the fix localized to
createPlanarViewport and its cleanup handling.

Comment on lines +181 to +196
if (type === 'imagePlaneModule') {
return {
rows,
columns,
width: rows,
height: columns,
imageOrientationPatient: [1, 0, 0, 0, 1, 0],
rowCosines: [1, 0, 0],
columnCosines: [0, 1, 0],
imagePositionPatient: [0, 0, sliceIndex],
pixelSpacing: [xSpacing, ySpacing],
rowPixelSpacing: ySpacing,
columnPixelSpacing: xSpacing,
frameOfReferenceUID,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

imagePlaneModule has swapped width/height and reversed pixelSpacing order.

width: rows and height: columns are swapped — should be width: columns and height: rows, matching the IImage object at lines 124–125. Additionally, pixelSpacing: [xSpacing, ySpacing] is reversed from the DICOM convention [rowSpacing, columnSpacing] = [ySpacing, xSpacing] (the individual rowPixelSpacing/columnPixelSpacing at lines 193–194 are correct).

Both are currently masked by the 64×64 default and 1×1 uniform spacing, but would produce incorrect geometry for any test using non-square images or non-uniform spacing.

🔧 Proposed fix
     if (type === 'imagePlaneModule') {
       return {
         rows,
         columns,
-        width: rows,
-        height: columns,
+        width: columns,
+        height: rows,
         imageOrientationPatient: [1, 0, 0, 0, 1, 0],
         rowCosines: [1, 0, 0],
         columnCosines: [0, 1, 0],
         imagePositionPatient: [0, 0, sliceIndex],
-        pixelSpacing: [xSpacing, ySpacing],
+        pixelSpacing: [ySpacing, xSpacing],
         rowPixelSpacing: ySpacing,
         columnPixelSpacing: xSpacing,
         frameOfReferenceUID,
       };
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (type === 'imagePlaneModule') {
return {
rows,
columns,
width: rows,
height: columns,
imageOrientationPatient: [1, 0, 0, 0, 1, 0],
rowCosines: [1, 0, 0],
columnCosines: [0, 1, 0],
imagePositionPatient: [0, 0, sliceIndex],
pixelSpacing: [xSpacing, ySpacing],
rowPixelSpacing: ySpacing,
columnPixelSpacing: xSpacing,
frameOfReferenceUID,
};
}
if (type === 'imagePlaneModule') {
return {
rows,
columns,
width: columns,
height: rows,
imageOrientationPatient: [1, 0, 0, 0, 1, 0],
rowCosines: [1, 0, 0],
columnCosines: [0, 1, 0],
imagePositionPatient: [0, 0, sliceIndex],
pixelSpacing: [ySpacing, xSpacing],
rowPixelSpacing: ySpacing,
columnPixelSpacing: xSpacing,
frameOfReferenceUID,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vitest-browser/harness/fakeImageStack.ts` around lines 181 - 196, The
imagePlaneModule shape in fakeImageStack is using the wrong geometry mapping:
swap width/height to match the IImage object, and fix pixelSpacing to follow
DICOM row/column ordering. Update the imagePlaneModule return in fakeImageStack
so width uses columns, height uses rows, and pixelSpacing matches
rowPixelSpacing/columnPixelSpacing semantics (with rowPixelSpacing and
columnPixelSpacing left consistent with the existing values).

The WADOURI examples fetch every image over HTTP from
raw.githubusercontent.com (most from the cornerstone3D repo, the TG18 set
from the external OHIF/viewer-testdata repo). Under the parallel Playwright
workers on the self-hosted runner, GitHub raw intermittently rate-limits
(429) or drops these requests. The DICOM loader issues a single XHR per
image with no retry, so one bad response fails the whole load and surfaces
as a flaky waitForImageRendered timeout (reproduced locally on
TG18-LN-1k-01).

Add a test-side retryRemoteFixtures helper that intercepts those requests
and retries them with exponential backoff from the Node side via
route.fetch, then replays the successful response to the browser. Retries
stay off the browser's single-shot XHR, product code is untouched, and the
example's public URLs are unchanged so the deployed docs demo is
unaffected. Also give the remote-load render gate a 60s budget to absorb
the retry backoff on the large 1k/2k images.

Verified: TG18-LN-1k-01 (legacy + generic), previously a hard failure,
now passes 3/3 repeats locally.

Claude-Session: https://claude.ai/code/session_01NJNAWVWWtguh3aA8w1R9mp

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/utils/retryRemoteFixtures.ts (1)

68-71: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add jitter to exponential backoff to avoid thundering herd.

Parallel Playwright workers hitting the same rate-limited endpoint will retry in lockstep with deterministic backoff (500, 1000, 2000ms), amplifying the rate-limiting the helper is trying to mitigate. Adding random jitter spreads retries across time.

♻️ Proposed fix: add ±25% jitter
         if (attempt < attempts - 1) {
-          const backoffMs = 500 * 2 ** attempt;
+          const baseMs = 500 * 2 ** attempt;
+          const jitter = baseMs * 0.25 * (Math.random() * 2 - 1);
+          const backoffMs = Math.round(baseMs + jitter);
           await new Promise((resolve) => setTimeout(resolve, backoffMs));
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/utils/retryRemoteFixtures.ts` around lines 68 - 71, The
retryRemoteFixtures backoff in the retry loop is fully deterministic, so
parallel Playwright workers will retry in sync and keep hammering the same
endpoint. Update the backoff calculation in the retry helper (the
attempt/backoffMs logic inside the retry loop) to add random ±25% jitter around
the exponential delay before the setTimeout call, while preserving the existing
retry structure and attempt-based scaling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/utils/retryRemoteFixtures.ts`:
- Around line 38-42: The retry fixture defaults in retryRemoteFixtures are
allowing a worst-case duration that exceeds the 60s render gate, so adjust the
defaults in the options destructuring and related helper usage to keep the route
handler under the render timeout. Update the perAttemptTimeoutMs default in
retryRemoteFixtures (and any linked call sites/specs that rely on it) so the
total retry window stays below waitForImageRendered, or add a clear
comment/docstring in retryRemoteFixtures explaining the required relationship
between attempts, perAttemptTimeoutMs, and the 60s timeout.

---

Nitpick comments:
In `@tests/utils/retryRemoteFixtures.ts`:
- Around line 68-71: The retryRemoteFixtures backoff in the retry loop is fully
deterministic, so parallel Playwright workers will retry in sync and keep
hammering the same endpoint. Update the backoff calculation in the retry helper
(the attempt/backoffMs logic inside the retry loop) to add random ±25% jitter
around the exponential delay before the setTimeout call, while preserving the
existing retry structure and attempt-based scaling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a8da020c-2fd3-47b8-9f4f-be7ec3362bb0

📥 Commits

Reviewing files that changed from the base of the PR and between 565c089 and 2c6fc18.

📒 Files selected for processing (4)
  • tests/dicomImageLoaderWADOURI.spec.ts
  • tests/genericViewport/genericDicomImageLoaderWADOURI.spec.ts
  • tests/utils/index.ts
  • tests/utils/retryRemoteFixtures.ts
✅ Files skipped from review due to trivial changes (1)
  • tests/utils/index.ts

Comment on lines +38 to +42
const {
attempts = 4,
hosts = DEFAULT_HOSTS,
perAttemptTimeoutMs = 20000,
} = options;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Worst-case retry duration (83.5s) exceeds the 60s render timeout in both spec files.

With defaults (4 attempts × 20s timeout + 500+1000+2000ms backoff = 83.5s), if every attempt times out, the route handler alone runs 23.5s past the waitForImageRendered 60s gate. The browser's XHR is blocked inside the route handler until fulfill/continue, so the render timeout fires while retries are still in-flight, producing a confusing timeout failure rather than a clear network error.

Consider reducing perAttemptTimeoutMs to ~12s (worst case 51.5s < 60s) or documenting the relationship between these two values.

💡 Suggested fix: reduce default per-attempt timeout
   /** Per-attempt fetch timeout in ms. Default 20000. */
-  perAttemptTimeoutMs?: number;
+  perAttemptTimeoutMs?: number;
   const {
     attempts = 4,
     hosts = DEFAULT_HOSTS,
-    perAttemptTimeoutMs = 20000,
+    perAttemptTimeoutMs = 12000,
   } = options;

Also applies to: 68-71

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/utils/retryRemoteFixtures.ts` around lines 38 - 42, The retry fixture
defaults in retryRemoteFixtures are allowing a worst-case duration that exceeds
the 60s render gate, so adjust the defaults in the options destructuring and
related helper usage to keep the route handler under the render timeout. Update
the perAttemptTimeoutMs default in retryRemoteFixtures (and any linked call
sites/specs that rely on it) so the total retry window stays below
waitForImageRendered, or add a clear comment/docstring in retryRemoteFixtures
explaining the required relationship between attempts, perAttemptTimeoutMs, and
the 60s timeout.

@sedghi
sedghi merged commit 9c8254a into main Jul 9, 2026
17 checks passed
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.

1 participant