fix(seg): prevent segmentations from spreading to all viewports before hydration confirmation in 3D four-up (#5967)

This commit is contained in:
Ghadeer Albattarni authored and GitHub committed 2026-04-27 12:55:12 -04:00
1 parent c9c4f11b82
commit f8ccf9ff2e
23 files changed
+558 -39

No files matched your search

+63 -2
View File
@@ -1,5 +1,7 @@
import { expect } from 'playwright-test-coverage';
import { expect, test } from 'playwright-test-coverage';
import { Locator, Page } from 'playwright';
import { promises as fs } from 'fs';
import path from 'path';
type CheckForScreenshotProps = {
page: Page;
@@ -18,12 +20,69 @@ type CheckForScreenshotProps = {
fullPage?: boolean;
};
const _isIntermediateScreenshotArtifact = (filename: string, screenshotPath: string) => {
const { name } = path.parse(screenshotPath);
const lowerFilename = filename.toLowerCase();
if (!lowerFilename.endsWith('.png')) {
return false;
}
return (
lowerFilename.startsWith(`${name.toLowerCase()}-`) &&
(lowerFilename.endsWith('-actual.png') ||
lowerFilename.endsWith('-diff.png') ||
lowerFilename.endsWith('-expected.png'))
);
};
const _cleanupIntermediateScreenshotArtifacts = async (
outputDir: string,
screenshotPath: string
) => {
const stack = [outputDir];
while (stack.length) {
const currentDir = stack.pop();
if (!currentDir) {
continue;
}
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (!_isIntermediateScreenshotArtifact(entry.name, screenshotPath)) {
continue;
}
try {
await fs.unlink(fullPath);
} catch {
// Best-effort cleanup only.
}
}
}
};
const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
const {
page,
screenshotPath,
attempts = 10,
delay = 500,
delay = 1250,
maxDiffPixelRatio = 0.02,
threshold = 0.05,
normalizedClip,
@@ -31,6 +90,7 @@ const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
} = props;
let { locator = page } = props;
const testOutputDir = test.info().outputDir;
await page.waitForLoadState('networkidle');
@@ -70,6 +130,7 @@ const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
console.debug('Screenshot comparison failed after all attempts');
throw error; // Throw the original error with details instead of a generic message
}
await _cleanupIntermediateScreenshotArtifacts(testOutputDir, screenshotPath);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
+8
View File
@@ -24,6 +24,11 @@ import { getSvgPath } from './getSvgPath';
import { navigateWithViewportArrow } from './navigateWithViewportArrow';
import { test, expect } from './fixture';
import { subscribeToMeasurementAdded } from './subscribeToMeasurement';
import {
waitForAnyViewportNeedsRender,
waitForViewportsRendered,
waitForViewportRenderCycle,
} from './waitForViewportsRendered';
export {
visitStudy,
@@ -48,6 +53,9 @@ export {
subscribeToMeasurementAdded,
getSvgPath,
navigateWithViewportArrow,
waitForAnyViewportNeedsRender,
waitForViewportsRendered,
waitForViewportRenderCycle,
test,
expect,
};
+163
View File
@@ -0,0 +1,163 @@
import type { Page } from '@playwright/test';
type WaitForAnyViewportNeedsRenderOptions = {
timeout?: number;
};
type WaitForViewportsRenderedOptions = {
timeout?: number;
/**
* If true (default), also waits for any volume actors referenced by the
* viewports to report loaded.
*/
waitVolumeLoad?: boolean;
};
type WaitForRenderCycleToCompleteOptions = {
/**
* Timeout for waiting until at least one viewport reaches `needsRender`.
*/
needsRenderTimeout?: number;
/**
* Timeout for waiting until all viewports are `rendered`
* (and optionally volume-loaded).
*/
renderedTimeout?: number;
/**
* If true (default), also waits for any volume actors referenced by the
* viewports to report loaded during the rendered phase.
*/
waitVolumeLoad?: boolean;
};
/**
* Waits for a full render cycle:
* 1) any viewport requests render (`needsRender`)
* 2) all viewports finish rendering (`rendered`)
*/
const waitForViewportRenderCycle = async (
page: Page,
options: WaitForRenderCycleToCompleteOptions = {}
) => {
const { needsRenderTimeout = 5000, renderedTimeout = 15000, waitVolumeLoad = true } = options;
await waitForAnyViewportNeedsRender(page, { timeout: needsRenderTimeout });
await waitForViewportsRendered(page, { timeout: renderedTimeout, waitVolumeLoad });
};
/**
* Waits until at least one viewport enters the 'needsRender' state, indicating
* that a render has been requested but not yet started.
*/
const waitForAnyViewportNeedsRender = async (
page: Page,
options: WaitForAnyViewportNeedsRenderOptions = {}
) => {
const { timeout = 5000 } = options;
await page.waitForFunction(
() => {
const cornerstone = (window as any).cornerstone;
if (!cornerstone?.getRenderingEngines) {
return false;
}
const renderingEngines = cornerstone.getRenderingEngines();
const viewports = renderingEngines.flatMap(engine =>
engine.getViewports ? engine.getViewports() : []
);
if (!viewports.length) {
return false;
}
const needsRender = viewports.some(viewport => viewport?.viewportStatus === 'needsRender');
return needsRender;
},
{},
{ timeout }
);
};
/**
* Stabilize tests by waiting for a short tick, network idle, then viewport render completion.
* To use this method safely, you may need to make changes to OHIF and/or CS3D
* methods handling clicks (SHOULD be commands modules only). These should set the
* state to needs render synchronously so that this method can safely wait for the render to complete.
* Examples such as changing the hanging protocol currently don't set such a state
* and thus can't be rendered without a delay.
*
* If options.waitVolumeLoad is not false, then this method will wait for all volumes
* associated with viewports to be loaded.
*/
const waitForViewportsRendered = async (
page: Page,
options: WaitForViewportsRenderedOptions = {}
) => {
const { timeout = 15000, waitVolumeLoad = true } = options;
await page.waitForFunction(
({ waitVolumeLoad }) => {
const cornerstone = (window as any).cornerstone;
if (!cornerstone?.getRenderingEngines) {
return false;
}
const renderingEngines = cornerstone.getRenderingEngines();
const viewports = renderingEngines.flatMap(engine =>
engine.getViewports ? engine.getViewports() : []
);
if (!viewports.length) {
return false;
}
const allRendered = viewports.every(viewport => viewport?.viewportStatus === 'rendered');
if (!allRendered) {
return false;
}
if (!waitVolumeLoad) {
return true;
}
const cache = cornerstone.cache;
if (!cache?.getVolume) {
return true;
}
const actorEntries = viewports.flatMap(viewport =>
viewport?.getActors ? viewport.getActors() : []
);
for (const actorEntry of actorEntries) {
const id = actorEntry?.referencedId || actorEntry?.uid;
if (!id) {
continue;
}
let volume: any;
try {
volume = cache.getVolume(id);
} catch {
continue;
}
const loaded =
volume?.loadStatus && typeof volume.loadStatus.loaded === 'boolean'
? volume.loadStatus.loaded
: true;
if (!loaded) {
return false;
}
}
return true;
},
{ waitVolumeLoad },
{ timeout }
);
};
export { waitForViewportsRendered, waitForAnyViewportNeedsRender, waitForViewportRenderCycle };