ohif-viewer/tests/pages/ViewportPageObject.ts
Joe Boccanfuso fe16e80cf3
test(e2e): update Playwright screenshots and add better tests and assertions for area calculations (#6022)
* Add (better) assertions for area calculation fixes.
* Add SVG and measurement side panel area for freehand ROI test.
* Add Playwright viewport screenshot scope migration guide.
* Update cornerstonjs dependencies to 4.22.8. Ensure all versions of @babel/preset-env are 7.29.5.

---------

Co-authored-by: Ghadeer Albattarni <165973963+GhadeerAlbattarni@users.noreply.github.com>
2026-05-18 10:36:05 -04:00

452 lines
14 KiB
TypeScript

import { Locator, Page } from '@playwright/test';
import {
getMousePosition,
simulateClicksOnElement,
simulateDoubleClickOnElement,
simulateNormalizedClicksOnElement,
simulateNormalizedDragOnElement,
simulateNormalizedPathDragOnElement,
} from '../utils';
import { DataOverlayPageObject } from './DataOverlayPageObject';
import { DOMOverlayPageObject } from './DOMOverlayPageObject';
import { MagnifyGlassPageObject } from './MagnifyGlassPageObject';
type SvgInnerElement = 'circle' | 'path' | 'd' | 'line' | 'g';
type NormalizedDragParams = {
start: { x: number; y: number };
end: { x: number; y: number };
config?: { button?: 'left' | 'right' | 'middle'; delay?: number; steps?: number };
};
type NormalizedPathDragParams = {
path: { x: number; y: number }[];
config?: { button?: 'left' | 'right' | 'middle'; delay?: number; steps?: number };
};
export interface IOverlayText {
get windowLevel(): Locator;
get instanceNumber(): Locator;
}
function overlayTextFactory(viewport: Locator, id: string): IOverlayText {
const locator = viewport.getByTestId(id);
return {
get windowLevel() {
return locator.getByTitle('Window Level');
},
get instanceNumber() {
return locator.getByTitle('Instance Number');
},
};
}
export interface IViewportPageObject {
nthAnnotation(nth: number): {
locator: Locator;
click: () => Promise<void>;
contextMenu: {
open: () => Promise<void>;
};
text: {
locator: Locator;
click: () => Promise<void>;
};
};
clickAt: (
points: { x: number; y: number }[],
button?: 'left' | 'right' | 'middle'
) => Promise<void>;
doubleClickAt: (point: { x: number; y: number }) => Promise<void>;
normalizedClickAt: (
normalizedPoints: { x: number; y: number }[],
button?: 'left' | 'right' | 'middle'
) => Promise<void>;
normalizedDragAt: (params: NormalizedDragParams) => Promise<void>;
normalizedPathDragAt: (params: NormalizedPathDragParams) => Promise<void>;
orientationMarkers: {
topMid: Locator;
leftMid: Locator;
rightMid: Locator;
bottomMid: Locator;
};
overlayText: {
topLeft: IOverlayText;
topRight: IOverlayText;
bottomLeft: IOverlayText;
bottomRight: IOverlayText;
};
overlayMenu: {
dataOverlay: DataOverlayPageObject;
orientation: {
button: Locator;
click: () => Promise<void>;
};
windowLevel: {
button: Locator;
click: () => Promise<void>;
};
};
pane: Locator;
svg: (innerElement?: SvgInnerElement) => Locator;
getSvgAnnotationStatTextLines: (uid: string) => Locator;
navigationArrows: {
locator: Locator;
prev: {
button: Locator;
click: () => Promise<void>;
};
next: {
button: Locator;
click: () => Promise<void>;
};
};
sliceNavigation: {
toSlice: (sliceIndex: number) => Promise<void>;
toFirstSlice: () => Promise<void>;
toLastSlice: () => Promise<void>;
scrollBy: (delta: number) => Promise<void>;
};
magnifyGlass: MagnifyGlassPageObject;
}
export class ViewportPageObject {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
private getAnnotation(viewport: Locator, nth: number) {
const page = this.page;
const domOverlayPageObject = new DOMOverlayPageObject(page);
const annotation = viewport.locator('g[data-annotation-uid]').nth(nth);
const textLocator = annotation.locator('text').first();
return {
locator: annotation,
click: async () => {
await annotation.click();
},
contextMenu: {
open: async () => {
await domOverlayPageObject.viewport.annotationContextMenu.open(annotation);
},
},
text: {
locator: textLocator,
click: async () => {
await textLocator.click({ force: true });
},
},
};
}
private async getViewportId(viewport: Locator): Promise<string> {
const id = await viewport.locator('[data-viewportid]').getAttribute('data-viewportid');
if (id === null) {
throw new Error('Could not resolve data-viewportid from viewport locator');
}
return id;
}
private getOrientationMarkers(viewport: Locator) {
return {
topMid: viewport.locator('.top-mid.orientation-marker'),
leftMid: viewport.locator('.left-mid.orientation-marker'),
rightMid: viewport.locator('.right-mid.orientation-marker'),
bottomMid: viewport.locator('.bottom-mid.orientation-marker'),
};
}
private getOverlayText(viewport: Locator) {
return {
topLeft: overlayTextFactory(viewport, 'viewport-overlay-top-left'),
topRight: overlayTextFactory(viewport, 'viewport-overlay-top-right'),
bottomLeft: overlayTextFactory(viewport, 'viewport-overlay-bottom-left'),
bottomRight: overlayTextFactory(viewport, 'viewport-overlay-bottom-right'),
};
}
private async getOverlayMenu(viewport: Locator) {
return {
dataOverlay: new DataOverlayPageObject(this.page, await this.getViewportId(viewport)),
get orientation() {
const button = viewport.locator('[data-cy^="orientationMenu"]');
return {
button,
async click() {
await button.click();
},
};
},
get windowLevel() {
const button = viewport.locator('[data-cy^="windowLevelMenu"]');
return {
button,
async click() {
await button.click();
},
};
},
};
}
private getSvg(viewport: Locator, innerElement?: SvgInnerElement) {
return viewport.locator(`svg.svg-layer${innerElement ? ` ${innerElement}` : ''}`);
}
private getNavigationArrows(viewport: Locator) {
const container = viewport.getByTestId('viewport-action-arrows');
const prevButton = viewport.getByTestId('viewport-action-arrows-left');
const nextButton = viewport.getByTestId('viewport-action-arrows-right');
return {
locator: container,
prev: {
button: prevButton,
click: async () => {
await prevButton.click();
},
},
next: {
button: nextButton,
click: async () => {
await nextButton.click();
},
},
};
}
/**
* Note: awaiting the returned methods (toSlice, toFirstSlice, toLastSlice, scrollBy)
* does not guarantee the viewport has finished rendering. Follow up with
* `waitForViewportsRendered` if you need pixel-stable state.
*/
private getSliceNavigation(viewport: Locator) {
const page = this.page;
const jumpToImage = async (imageIndex: number) => {
const viewportId = await this.getViewportId(viewport);
await page.evaluate(
({ commandsManager, viewportId, imageIndex }) => {
return commandsManager.runCommand('jumpToImage', {
imageIndex,
viewport: { id: viewportId },
});
},
{
viewportId,
imageIndex,
commandsManager: await page.evaluateHandle('window.commandsManager'),
}
);
};
const scrollBy = async (delta: number) => {
const viewportId = await this.getViewportId(viewport);
await page.evaluate(
({ services, viewportId, delta }) => {
const cornerstoneViewport = (
services as any
).cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!cornerstoneViewport) {
return;
}
return cornerstoneViewport.scroll(delta);
},
{
viewportId,
delta,
services: await page.evaluateHandle('window.services'),
}
);
};
return {
toSlice: jumpToImage,
toFirstSlice: () => jumpToImage(0),
toLastSlice: () => jumpToImage(-1),
scrollBy,
};
}
private async viewportPageObjectFactory(viewport: Locator): Promise<IViewportPageObject> {
return {
nthAnnotation: (nth: number) => this.getAnnotation(viewport, nth),
doubleClickAt: async (point: { x: number; y: number }) => {
await simulateDoubleClickOnElement({
locator: viewport,
point,
});
},
clickAt: async (points: { x: number; y: number }[], button?: 'left' | 'right' | 'middle') => {
await simulateClicksOnElement({
locator: viewport,
points,
button,
});
},
normalizedClickAt: async (
normalizedPoints: { x: number; y: number }[],
button?: 'left' | 'right' | 'middle'
) => {
await simulateNormalizedClicksOnElement({
locator: viewport,
normalizedPoints,
button,
});
},
normalizedDragAt: async (params: NormalizedDragParams) => {
await simulateNormalizedDragOnElement({
locator: viewport,
start: params.start,
end: params.end,
button: params.config?.button,
delay: params.config?.delay,
steps: params.config?.steps,
});
},
normalizedPathDragAt: async (params: NormalizedPathDragParams) => {
await simulateNormalizedPathDragOnElement({
locator: viewport,
path: params.path,
button: params.config?.button,
delay: params.config?.delay,
steps: params.config?.steps,
});
},
orientationMarkers: this.getOrientationMarkers(viewport),
overlayText: this.getOverlayText(viewport),
overlayMenu: await this.getOverlayMenu(viewport),
pane: viewport,
svg: (innerElement?: SvgInnerElement) => {
return this.getSvg(viewport, innerElement);
},
getSvgAnnotationStatTextLines: (uid: string) => {
return this.getSvg(viewport)
.locator(`g[data-annotation-uid="${uid}"]`)
.locator('tspan');
},
navigationArrows: this.getNavigationArrows(viewport),
sliceNavigation: this.getSliceNavigation(viewport),
magnifyGlass: new MagnifyGlassPageObject(this.page, viewport),
};
}
get active(): Promise<IViewportPageObject> {
const viewport = this.page.locator('[data-cy="viewport-pane"][data-is-active="true"]');
return this.viewportPageObjectFactory(viewport);
}
get crosshairs() {
const page = this.page;
const crosshairHoverTimeout = 20000;
async function getSlabHandleLocator(locator: Locator) {
const startTime = Date.now();
const rectLocator = locator.locator('rect').first();
const circleLocator = locator.locator('circle').first();
while (Date.now() - startTime < crosshairHoverTimeout) {
if ((await rectLocator.count()) > 0) {
return rectLocator;
}
if ((await circleLocator.count()) > 0) {
return circleLocator;
}
await page.waitForTimeout(250);
}
throw new Error('Could not find slab thickness handle for crosshairs interaction');
}
async function increaseSlabThickness(locator: Locator, lineNumber: number, axis: string) {
const lineLocator = locator.locator('line').nth(lineNumber);
await lineLocator.click({ force: true });
await lineLocator.hover({ force: true, timeout: crosshairHoverTimeout });
const slabHandleLocator = await getSlabHandleLocator(locator);
await slabHandleLocator.hover({ force: true, timeout: crosshairHoverTimeout });
await page.mouse.down();
const position = await getMousePosition(page);
switch (axis) {
case 'x':
await page.mouse.move(position.x + 100, position.y);
break;
case 'y':
await page.mouse.move(position.x, position.y + 100);
break;
}
await page.mouse.up();
}
async function rotateCrosshairs(locator: Locator, lineNumber: number) {
const lineLocator = locator.locator('line').nth(lineNumber);
await lineLocator.click({ force: true });
await lineLocator.hover({ force: true, timeout: crosshairHoverTimeout });
const circleLocator = locator.locator('circle').nth(1);
await circleLocator.waitFor({ state: 'attached', timeout: crosshairHoverTimeout });
await circleLocator.hover({ force: true, timeout: crosshairHoverTimeout });
await page.mouse.down();
const position = await getMousePosition(page);
await page.mouse.move(position.x, position.y + 100);
await page.mouse.up();
}
function crosshairsFactory(
locator: Locator,
increaseLineNumber: number,
increaseAxis: 'x' | 'y',
rotateLineNumber: number
) {
return {
increase: async () => {
await increaseSlabThickness(locator, increaseLineNumber, increaseAxis);
},
locator,
rotate: () => {
return rotateCrosshairs(locator, rotateLineNumber);
},
};
}
return {
axial: crosshairsFactory(page.locator('#svg-layer-mpr-axial'), 0, 'x', 3),
sagittal: crosshairsFactory(page.locator('#svg-layer-mpr-sagittal'), 2, 'x', 0),
coronal: crosshairsFactory(page.locator('#svg-layer-mpr-coronal'), 0, 'y', 0),
};
}
async getAll(): Promise<IViewportPageObject[]> {
const viewports = await this.page.getByTestId('viewport-pane').all();
return await Promise.all(viewports.map(viewport => this.viewportPageObjectFactory(viewport)));
}
getNth(index: number): Promise<IViewportPageObject> {
const viewport = this.getNthLocator(index);
return this.viewportPageObjectFactory(viewport);
}
getNthLocator(index: number): Locator {
return this.page.getByTestId('viewport-pane').nth(index);
}
getById(viewportId: string): Promise<IViewportPageObject> {
const viewport = this.page.locator(
`[data-cy="viewport-pane"]:has(div[data-viewportid="${viewportId}"])`
);
return this.viewportPageObjectFactory(viewport);
}
get grid() {
return this.page.getByTestId('viewport-grid');
}
}