chore: recovery 2 (#5014) merge to 3.11 (#5175)

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
Co-authored-by: Tang Cheng <45505657+tctco@users.noreply.github.com>
This commit is contained in:
Joe Boccanfuso 2025-07-03 14:08:46 -04:00 committed by GitHub
parent 17ed63eb1d
commit 24e0263fb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 516 additions and 258 deletions

View File

@ -29,6 +29,19 @@ jobs:
run: | run: |
export NODE_OPTIONS="--max_old_space_size=10192" export NODE_OPTIONS="--max_old_space_size=10192"
bun run test:e2e:coverage bun run test:e2e:coverage
- name: Create directory of test results
if: ${{ !cancelled() }}
run: |
mkdir -p packaged-test-results
cp -r ./tests/test-results packaged-test-results/ || true
cp ./tests/playwright-report.json packaged-test-results/ || true
- name: Upload directory of test results artifact
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: packaged-test-results/
retention-days: 5
- name: create the coverage report - name: create the coverage report
run: | run: |
bun nyc report --reporter=lcov --reporter=text bun nyc report --reporter=lcov --reporter=text

View File

@ -64,7 +64,7 @@ export const fourUp = {
customViewportProps: { customViewportProps: {
hideOverlays: true, hideOverlays: true,
}, },
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP], syncGroups: [HYDRATE_SEG_SYNC_GROUP],
}, },
displaySets: [ displaySets: [
{ {

View File

@ -71,7 +71,7 @@ export const mprAnd3DVolumeViewport = {
customViewportProps: { customViewportProps: {
hideOverlays: true, hideOverlays: true,
}, },
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP], syncGroups: [HYDRATE_SEG_SYNC_GROUP],
}, },
displaySets: [ displaySets: [
{ {

View File

@ -47,7 +47,7 @@ export const only3D = {
orientation: 'coronal', orientation: 'coronal',
customViewportProps: { customViewportProps: {
hideOverlays: true, hideOverlays: true,
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP], syncGroups: [HYDRATE_SEG_SYNC_GROUP],
}, },
}, },
displaySets: [ displaySets: [

View File

@ -39,6 +39,7 @@ import { useLutPresentationStore } from './stores/useLutPresentationStore';
import { usePositionPresentationStore } from './stores/usePositionPresentationStore'; import { usePositionPresentationStore } from './stores/usePositionPresentationStore';
import { useSegmentationPresentationStore } from './stores/useSegmentationPresentationStore'; import { useSegmentationPresentationStore } from './stores/useSegmentationPresentationStore';
import { imageRetrieveMetadataProvider } from '@cornerstonejs/core/utilities'; import { imageRetrieveMetadataProvider } from '@cornerstonejs/core/utilities';
import { initializeWebWorkerProgressHandler } from './utils/initWebWorkerProgressHandler';
const { registerColormap } = csUtilities.colormap; const { registerColormap } = csUtilities.colormap;
@ -307,76 +308,6 @@ export default async function init({
initializeWebWorkerProgressHandler(servicesManager.services.uiNotificationService); initializeWebWorkerProgressHandler(servicesManager.services.uiNotificationService);
} }
function initializeWebWorkerProgressHandler(uiNotificationService) {
// Use a single map to track all active worker tasks
const activeWorkerTasks = new Map();
// Create a normalized task key that doesn't include the random ID
// This helps us identify and deduplicate the same type of task
const getNormalizedTaskKey = type => {
return `worker-task-${type.toLowerCase().replace(/\s+/g, '-')}`;
};
eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
const { progress, type, id } = detail;
// Skip notifications for compute statistics
if (type === cornerstoneTools.Enums.WorkerTypes.COMPUTE_STATISTICS) {
return;
}
const normalizedKey = getNormalizedTaskKey(type);
if (progress === 0) {
// Check if we're already tracking a task of this type
if (!activeWorkerTasks.has(normalizedKey)) {
const progressPromise = new Promise((resolve, reject) => {
activeWorkerTasks.set(normalizedKey, {
resolve,
reject,
originalId: id,
type,
});
});
uiNotificationService.show({
id: normalizedKey, // Use the normalized key as ID for better deduplication
title: `${type}`,
message: `Computing...`,
autoClose: false,
allowDuplicates: false,
deduplicationInterval: 60000, // 60 seconds - prevent frequent notifications of same type
promise: progressPromise,
promiseMessages: {
loading: `Computing...`,
success: `Completed successfully`,
error: 'Web Worker failed',
},
});
} else {
// Already tracking this type of task, just let it continue
console.debug(`Already tracking a "${type}" task, skipping duplicate notification`);
}
}
// Task completed
else if (progress === 100) {
// Check if we have this task type in our tracking map
const taskData = activeWorkerTasks.get(normalizedKey);
if (taskData) {
// Resolve the promise to update the notification
const { resolve } = taskData;
resolve({ progress, type });
// Remove from tracking
activeWorkerTasks.delete(normalizedKey);
console.debug(`Worker task "${type}" completed successfully`);
}
}
});
}
/** /**
* Creates a wrapped image load strategy with metadata handling * Creates a wrapped image load strategy with metadata handling
* @param strategyFn - The image loading strategy function to wrap * @param strategyFn - The image loading strategy function to wrap

View File

@ -0,0 +1,110 @@
import { eventTarget, EVENTS } from '@cornerstonejs/core';
import * as cornerstoneTools from '@cornerstonejs/tools';
/**
* Initializes a handler for web worker progress events.
* Tracks active worker tasks and shows notifications for their progress.
*
* @param uiNotificationService - The UI notification service for showing progress notifications
*/
export function initializeWebWorkerProgressHandler(uiNotificationService: any) {
// Use a single map to track all active worker tasks
const activeWorkerTasks = new Map();
// Create a normalized task key that doesn't include the random ID
// This helps us identify and deduplicate the same type of task
const getNormalizedTaskKey = (type: string) => {
return `worker-task-${type.toLowerCase().replace(/\s+/g, '-')}`;
};
eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
let normalizedKey: string | undefined;
let shouldCleanup = false;
try {
const { progress, type, id } = detail;
// Skip notifications for compute statistics
if (type === cornerstoneTools.Enums.WorkerTypes.COMPUTE_STATISTICS) {
return;
}
normalizedKey = getNormalizedTaskKey(type);
if (progress === 0) {
// Check if we're already tracking a task of this type
if (!activeWorkerTasks.has(normalizedKey)) {
const progressPromise = new Promise((resolve, reject) => {
try {
activeWorkerTasks.set(normalizedKey, {
resolve,
reject,
originalId: id,
type,
});
} catch (error) {
console.error(`Error setting active worker task for type "${type}":`, error);
reject(error);
throw error; // Re-throw to trigger outer catch and cleanup
}
});
try {
uiNotificationService.show({
id: normalizedKey, // Use the normalized key as ID for better deduplication
title: `${type}`,
message: `Computing...`,
autoClose: false,
allowDuplicates: false,
deduplicationInterval: 60000, // 60 seconds - prevent frequent notifications of same type
promise: progressPromise,
promiseMessages: {
loading: `Computing...`,
success: `Completed successfully`,
error: 'Web Worker failed',
},
});
} catch (error) {
console.error(`Error showing web worker notification for type "${type}":`, error);
shouldCleanup = true;
throw error;
}
} else {
// Already tracking this type of task, just let it continue
console.debug(`Already tracking a "${type}" task, skipping duplicate notification`);
}
}
// Task completed
else if (progress === 100) {
// Check if we have this task type in our tracking map
const taskData = activeWorkerTasks.get(normalizedKey);
if (taskData) {
// Resolve the promise to update the notification
const { resolve } = taskData;
resolve({ progress, type });
// Mark for cleanup
shouldCleanup = true;
console.debug(`Worker task "${type}" completed successfully`);
}
}
} catch (error) {
console.error(`Error in web worker progress handler for type "${detail?.type}":`, error);
shouldCleanup = true;
} finally {
// Clean up if needed
if (shouldCleanup && normalizedKey) {
try {
activeWorkerTasks.delete(normalizedKey);
} catch (cleanupError) {
console.error(
`Error cleaning up active worker task for type "${detail?.type}":`,
cleanupError
);
}
}
}
});
}

View File

@ -80,6 +80,7 @@ export default class ContextMenuController {
this.services.uiDialogService.hide('context-menu'); this.services.uiDialogService.hide('context-menu');
this.services.uiDialogService.show({ this.services.uiDialogService.show({
id: 'context-menu', id: 'context-menu',
showOverlay: false,
defaultPosition: ContextMenuController._getDefaultPosition( defaultPosition: ContextMenuController._getDefaultPosition(
defaultPointsPosition, defaultPointsPosition,
event?.detail || event, event?.detail || event,

View File

@ -37,6 +37,8 @@ import * as utils from './utils';
import { Toolbox } from './utils'; import { Toolbox } from './utils';
import MoreDropdownMenu from './Components/MoreDropdownMenu'; import MoreDropdownMenu from './Components/MoreDropdownMenu';
import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy'; import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy';
import { Toolbar } from './Toolbar/Toolbar';
const defaultExtension: Types.Extensions.Extension = { const defaultExtension: Types.Extensions.Extension = {
/** /**
* Only required property. Should be a unique value across all extensions. * Only required property. Should be a unique value across all extensions.
@ -103,4 +105,5 @@ export {
requestDisplaySetCreationForStudy, requestDisplaySetCreationForStudy,
callInputDialog, callInputDialog,
createReportDialogPrompt, createReportDialogPrompt,
Toolbar,
}; };

View File

@ -14,6 +14,8 @@ const serviceImplementation = {
console.warn('isEmpty() NOT IMPLEMENTED'); console.warn('isEmpty() NOT IMPLEMENTED');
return true; return true;
}, },
_updatePosition: (id: string, position: { x: number; y: number }) =>
console.warn('updatePosition() NOT IMPLEMENTED'),
_customComponent: null, _customComponent: null,
}; };
@ -63,6 +65,16 @@ class UIDialogService {
return serviceImplementation._isEmpty(); return serviceImplementation._isEmpty();
} }
/**
* Update the position of a specific dialog by id
*
* @param {string} id - The dialog id to update
* @param {{ x: number; y: number }} position - The new position
*/
updatePosition(id: string, position: { x: number; y: number }): void {
return serviceImplementation._updatePosition(id, position);
}
/** /**
* This provides flexibility in customizing the Modal's default component * This provides flexibility in customizing the Modal's default component
* *
@ -75,7 +87,14 @@ class UIDialogService {
/** /**
* Set the service implementation * Set the service implementation
*/ */
setServiceImplementation({ show, hide, hideAll, isEmpty, customComponent }: any): void { setServiceImplementation({
show,
hide,
hideAll,
isEmpty,
updatePosition,
customComponent,
}: any): void {
if (show) { if (show) {
serviceImplementation._show = show; serviceImplementation._show = show;
} }
@ -88,6 +107,9 @@ class UIDialogService {
if (isEmpty) { if (isEmpty) {
serviceImplementation._isEmpty = isEmpty; serviceImplementation._isEmpty = isEmpty;
} }
if (updatePosition) {
serviceImplementation._updatePosition = updatePosition;
}
if (customComponent) { if (customComponent) {
serviceImplementation._customComponent = customComponent; serviceImplementation._customComponent = customComponent;
} }

View File

@ -6,8 +6,8 @@ export const PowerOff = (props: IconProps) => (
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 28" viewBox="0 0 24 28"
aria-labelledby="title" aria-labelledby="title"
width="1em" width="28px"
height="1em" height="28px"
fill="currentColor" fill="currentColor"
{...props} {...props}
> >

View File

@ -67,9 +67,10 @@ function ViewportPane({
{/* Border overlay */} {/* Border overlay */}
<div <div
className={classNames('pointer-events-none absolute inset-0', { className={classNames('pointer-events-none absolute inset-0 rounded-md border', {
'border-highlight rounded-md border': isActive, 'border-highlight': isActive,
'group-hover:border-highlight/50 rounded-md border border-transparent': !isActive, 'group-hover/pane:border-highlight/50 border-transparent': !isActive,
'!border-secondary-light border-dashed': isHighlighted,
})} })}
/> />
</div> </div>

View File

@ -1,11 +1,20 @@
import React, { useState, createContext, useContext, useCallback, useEffect, useMemo } from 'react'; import React, {
import ManagedDialog, { ManagedDialogProps } from './ManagedDialog'; useState,
createContext,
useContext,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import ManagedDialog, { ManagedDialogProps, ManagedDialogRef } from './ManagedDialog';
interface DialogContextType { interface DialogContextType {
show: (options: ManagedDialogProps) => string; show: (options: ManagedDialogProps) => string;
hide: (id: string) => void; hide: (id: string) => void;
hideAll: () => void; hideAll: () => void;
isEmpty: () => boolean; isEmpty: () => boolean;
updatePosition: (id: string, position: { x: number; y: number }) => void;
} }
interface DialogService { interface DialogService {
@ -35,6 +44,7 @@ const DialogProvider: React.FC<DialogProviderProps> = ({
service = null, service = null,
}) => { }) => {
const [dialogs, setDialogs] = useState<(ManagedDialogProps & { id: string })[]>([]); const [dialogs, setDialogs] = useState<(ManagedDialogProps & { id: string })[]>([]);
const dialogRefs = useRef<Map<string, ManagedDialogRef>>(new Map());
const show = useCallback((options: ManagedDialogProps) => { const show = useCallback((options: ManagedDialogProps) => {
const id = options.id; const id = options.id;
@ -44,22 +54,32 @@ const DialogProvider: React.FC<DialogProviderProps> = ({
const hide = useCallback((id: string) => { const hide = useCallback((id: string) => {
setDialogs(prev => prev.filter(dialog => dialog.id !== id)); setDialogs(prev => prev.filter(dialog => dialog.id !== id));
dialogRefs.current.delete(id);
}, []); }, []);
const hideAll = useCallback(() => { const hideAll = useCallback(() => {
setDialogs([]); setDialogs([]);
dialogRefs.current.clear();
}, []); }, []);
const isEmpty = useCallback(() => dialogs.length === 0, [dialogs]); const isEmpty = useCallback(() => dialogs.length === 0, [dialogs]);
const updatePosition = useCallback((id: string, position: { x: number; y: number }) => {
const dialogRef = dialogRefs.current.get(id);
if (dialogRef) {
dialogRef.updatePosition(position);
}
}, []);
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
show, show,
hide, hide,
hideAll, hideAll,
isEmpty, isEmpty,
updatePosition,
}), }),
[show, hide, hideAll, isEmpty] [show, hide, hideAll, isEmpty, updatePosition]
); );
useEffect(() => { useEffect(() => {
@ -76,6 +96,11 @@ const DialogProvider: React.FC<DialogProviderProps> = ({
{dialogs.map(dialog => ( {dialogs.map(dialog => (
<RenderedDialog <RenderedDialog
key={dialog.id} key={dialog.id}
ref={(ref: ManagedDialogRef) => {
if (ref) {
dialogRefs.current.set(dialog.id, ref);
}
}}
onClose={hide} onClose={hide}
isOpen={true} isOpen={true}
{...dialog} {...dialog}

View File

@ -1,7 +1,12 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useState, useEffect, useImperativeHandle, forwardRef, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../components/Dialog/Dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../components/Dialog/Dialog';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
type Position = {
x: number;
y: number;
};
export interface ManagedDialogProps { export interface ManagedDialogProps {
id: string; id: string;
isOpen?: boolean; isOpen?: boolean;
@ -19,92 +24,134 @@ export interface ManagedDialogProps {
containerClassName?: string; containerClassName?: string;
} }
const ManagedDialog: React.FC<ManagedDialogProps> = ({ export interface ManagedDialogRef {
id, updatePosition: (position: Position) => void;
isOpen, }
title,
content: DialogContentComponent,
contentProps,
isDraggable,
shouldCloseOnEsc = false,
shouldCloseOnOverlayClick = false,
showOverlay = true,
defaultPosition,
onClose,
unstyled,
containerClassName,
}) => {
// When a default position is provided, the assumption is that the position
// is respected unless the position chosen results in the dialog being
// clipped off-screen (i.e. part of the dialog is rendered outside the browser
// window). When the dialog is clipped it will be repositioned about
// the default position such that it is no longer clipped. To avoid a flash
// during the reposition, we initially hide the dialog.
const [contentVisibility, setContentVisibility] = useState(
defaultPosition ? 'invisible' : 'visible'
);
// The callback to reposition an explicitly positioned dialog. Note that const _updatePosition = (
// if the dialog is larger than the window (in either dimension), the contentNode: HTMLElement,
// dialog will still be clipped in some manner. desiredPosition: { x: number; y: number },
const contentRef = useCallback( setCurrentPosition: (pt: Position) => void
contentNode => { ) => {
if (!contentNode) { if (!contentNode) {
return; return;
} }
const boundingClientRect = contentNode.getBoundingClientRect(); const boundingClientRect = contentNode.getBoundingClientRect();
if (boundingClientRect.bottom > window.innerHeight) { if (boundingClientRect.bottom > window.innerHeight) {
defaultPosition.y = defaultPosition.y - boundingClientRect.height; desiredPosition.y = desiredPosition.y - boundingClientRect.height;
} }
if (boundingClientRect.right > window.innerWidth) { if (boundingClientRect.right > window.innerWidth) {
defaultPosition.x = defaultPosition.x - boundingClientRect.width; desiredPosition.x = desiredPosition.x - boundingClientRect.width;
} }
setContentVisibility('visible'); setCurrentPosition(desiredPosition);
},
[defaultPosition]
);
return (
<Dialog
open={isOpen}
modal={false} // keep modal behavior off for independent windows
onOpenChange={open => {
if (!open) {
onClose(id);
}
}}
isDraggable={isDraggable}
shouldCloseOnEsc={shouldCloseOnEsc}
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
showOverlay={showOverlay}
>
<DialogContent
ref={contentRef}
className={cn(unstyled ? 'p-0' : '', containerClassName, contentVisibility)}
unstyled={unstyled}
style={{
...(defaultPosition
? {
position: 'fixed',
left: `${defaultPosition.x}px`,
top: `${defaultPosition.y}px`,
transform: 'translate(0, 0)',
margin: 0,
animation: 'none',
transition: 'none',
}
: {}),
}}
>
{!unstyled && <DialogHeader>{title && <DialogTitle>{title}</DialogTitle>}</DialogHeader>}
<DialogContentComponent
{...contentProps}
hide={() => onClose(id)}
/>
</DialogContent>
</Dialog>
);
}; };
const ManagedDialog = forwardRef<ManagedDialogRef, ManagedDialogProps>(
(
{
id,
isOpen,
title,
content: DialogContentComponent,
contentProps,
isDraggable,
shouldCloseOnEsc = false,
shouldCloseOnOverlayClick = false,
showOverlay = true,
defaultPosition,
onClose,
unstyled,
containerClassName,
},
ref
) => {
const [currentPosition, setCurrentPosition] = useState(defaultPosition);
const [contentNode, setContentNode] = useState<HTMLElement | null>(null);
useImperativeHandle(
ref,
() => ({
updatePosition: (position: Position) => {
_updatePosition(contentNode, position, setCurrentPosition);
},
}),
[]
);
useEffect(() => {
setCurrentPosition(defaultPosition);
}, [defaultPosition]);
// When a default position is provided, the assumption is that the position
// is respected unless the position chosen results in the dialog being
// clipped off-screen (i.e. part of the dialog is rendered outside the browser
// window). When the dialog is clipped it will be repositioned about
// the default position such that it is no longer clipped. To avoid a flash
// during the reposition, we initially hide the dialog.
const [contentVisibility, setContentVisibility] = useState(
defaultPosition ? 'invisible' : 'visible'
);
// The callback to reposition an explicitly positioned dialog. Note that
// if the dialog is larger than the window (in either dimension), the
// dialog will still be clipped in some manner.
const contentRef = useCallback(
contentNode => {
if (!contentNode) {
return;
}
setContentNode(contentNode);
_updatePosition(contentNode, defaultPosition, setCurrentPosition);
setContentVisibility('visible');
},
[defaultPosition]
);
return (
<Dialog
open={isOpen}
modal={false} // keep modal behavior off for independent windows
onOpenChange={open => {
if (!open) {
onClose(id);
}
}}
isDraggable={isDraggable}
shouldCloseOnEsc={shouldCloseOnEsc}
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
showOverlay={showOverlay}
>
<DialogContent
ref={contentRef}
className={cn(unstyled ? 'p-0' : '', containerClassName, contentVisibility)}
unstyled={unstyled}
style={{
...(currentPosition
? {
position: 'fixed',
left: `${currentPosition.x}px`,
top: `${currentPosition.y}px`,
transform: 'translate(0, 0)',
margin: 0,
animation: 'none',
transition: 'none',
}
: {}),
}}
>
{!unstyled && <DialogHeader>{title && <DialogTitle>{title}</DialogTitle>}</DialogHeader>}
<DialogContentComponent
{...contentProps}
hide={() => onClose(id)}
/>
</DialogContent>
</Dialog>
);
}
);
ManagedDialog.displayName = 'ManagedDialog';
export default ManagedDialog; export default ManagedDialog;

View File

@ -8,7 +8,14 @@ export default defineConfig({
workers: process.env.CI ? 6 : undefined, workers: process.env.CI ? 6 : undefined,
snapshotPathTemplate: './tests/screenshots{/projectName}/{testFilePath}/{arg}{ext}', snapshotPathTemplate: './tests/screenshots{/projectName}/{testFilePath}/{arg}{ext}',
outputDir: './tests/test-results', outputDir: './tests/test-results',
reporter: [[process.env.CI ? 'blob' : 'html', { outputFolder: './tests/playwright-report' }]], reporter: [
[
process.env.CI ? 'json' : 'html',
process.env.CI
? { outputFile: './tests/playwright-report.json' }
: { outputFolder: './tests/playwright-report' },
],
],
globalTimeout: 800_000, globalTimeout: 800_000,
timeout: 800_000, timeout: 800_000,
use: { use: {

View File

@ -27,8 +27,7 @@ test.describe('3D four up Test', async () => {
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
screenShotPaths.threeDFourUp.threeDFourUpDisplayedCorrectly, screenShotPaths.threeDFourUp.threeDFourUpDisplayedCorrectly
200
); );
}); });
}); });

View File

@ -22,11 +22,6 @@ test.describe('3D main Test', async () => {
.first() .first()
.click(); .click();
await attemptAction(() => reduce3DViewportSize(page), 10, 100); await attemptAction(() => reduce3DViewportSize(page), 10, 100);
await checkForScreenshot( await checkForScreenshot(page, page, screenShotPaths.threeDMain.threeDMainDisplayedCorrectly);
page,
page,
screenShotPaths.threeDMain.threeDMainDisplayedCorrectly,
200
);
}); });
}); });

View File

@ -22,11 +22,12 @@ test.describe('3D only Test', async () => {
.first() .first()
.click(); .click();
await attemptAction(() => reduce3DViewportSize(page), 10, 100); await attemptAction(() => reduce3DViewportSize(page), 10, 100);
await checkForScreenshot( // Use a 4 percent diff pixel ratio to account for slight color differences in the 3D viewport
await checkForScreenshot({
page, page,
page, locator: page,
screenShotPaths.threeDOnly.threeDOnlyDisplayedCorrectly, screenshotPath: screenShotPaths.threeDOnly.threeDOnlyDisplayedCorrectly,
200 maxDiffPixelRatio: 0.04,
); });
}); });
}); });

View File

@ -26,8 +26,7 @@ test.describe('3D primary Test', async () => {
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
screenShotPaths.threeDPrimary.threeDPrimaryDisplayedCorrectly, screenShotPaths.threeDPrimary.threeDPrimaryDisplayedCorrectly
200
); );
}); });
}); });

View File

@ -18,8 +18,7 @@ test.describe('Axial Primary Test', async () => {
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
screenShotPaths.axialPrimary.axialPrimaryDisplayedCorrectly, screenShotPaths.axialPrimary.axialPrimaryDisplayedCorrectly
200
); );
}); });
}); });

54
tests/ContextMenu.spec.ts Normal file
View File

@ -0,0 +1,54 @@
import { test } from 'playwright-test-coverage';
import {
visitStudy,
checkForScreenshot,
screenShotPaths,
simulateNormalizedClicksOnElement,
simulateNormalizedClickOnElement,
} from './utils';
test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5';
const mode = 'viewer';
await visitStudy(page, studyInstanceUID, mode, 2000);
});
test('should the context menu completely on screen and is not clipped for a point near the bottom edge of the screen', async ({
page,
}) => {
await page.getByTestId('MeasurementTools-split-button-secondary').click();
await page.locator('css=div[data-cy="Length"]').click();
const locator = page.getByTestId('viewport-pane').locator('canvas');
await simulateNormalizedClicksOnElement({
locator,
normalizedPoints: [
{
x: 0.45,
y: 0.98,
},
{
x: 0.55,
y: 0.98,
},
],
});
await page.getByTestId('prompt-begin-tracking-yes-btn').click();
await checkForScreenshot(page, page, screenShotPaths.contextMenu.preContextMenuNearBottomEdge);
await simulateNormalizedClickOnElement({
locator,
normalizedPoint: {
x: 0.55,
y: 0.98,
},
button: 'right',
});
await checkForScreenshot({
page,
locator: page,
screenshotPath: screenShotPaths.contextMenu.contextMenuNearBottomEdgeNotClipped,
});
});

View File

@ -3,7 +3,7 @@ import {
visitStudy, visitStudy,
checkForScreenshot, checkForScreenshot,
screenShotPaths, screenShotPaths,
initilizeMousePositionTracker, initializeMousePositionTracker,
getMousePosition, getMousePosition,
} from './utils/index.js'; } from './utils/index.js';
@ -42,7 +42,7 @@ test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785'; const studyInstanceUID = '1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785';
const mode = 'viewer'; const mode = 'viewer';
await visitStudy(page, studyInstanceUID, mode, 2000); await visitStudy(page, studyInstanceUID, mode, 2000);
await initilizeMousePositionTracker(page); await initializeMousePositionTracker(page);
}); });
test.describe('Crosshairs Test', async () => { test.describe('Crosshairs Test', async () => {
@ -105,8 +105,6 @@ test.describe('Crosshairs Test', async () => {
await page.getByTestId('study-browser-thumbnail').nth(1).dblclick(); await page.getByTestId('study-browser-thumbnail').nth(1).dblclick();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.crosshairs.crosshairsNewDisplayset); await checkForScreenshot(page, page, screenShotPaths.crosshairs.crosshairsNewDisplayset);
}); });
}); });

View File

@ -87,8 +87,6 @@ test('should hydrate in MPR correctly', async ({ page }) => {
await page.getByTestId('data-row').first().click(); await page.getByTestId('data-row').first().click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.jumpToMeasurementStack); await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.jumpToMeasurementStack);
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
@ -105,14 +103,11 @@ test('should hydrate in MPR correctly', async ({ page }) => {
await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.jumpInMPR); await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.jumpInMPR);
await page.locator(':text("S:3")').first().dblclick(); await page.locator(':text("S:3")').first().dblclick();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.changeSeriesInMPR); await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.changeSeriesInMPR);
await page.getByTestId('data-row').first().click(); await page.getByTestId('data-row').first().click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -15,7 +15,6 @@ test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay me
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('MPR').click(); await page.getByTestId('MPR').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
@ -32,7 +31,6 @@ test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay me
// Hide the overlay menu. // Hide the overlay menu.
await page.getByTestId('dataOverlayMenu-mpr-sagittal-btn').click(); await page.getByTestId('dataOverlayMenu-mpr-sagittal-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -15,7 +15,6 @@ test('should launch MPR with unhydrated SEG chosen from the data overlay menu',
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('MPR').click(); await page.getByTestId('MPR').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
@ -32,7 +31,6 @@ test('should launch MPR with unhydrated SEG chosen from the data overlay menu',
// Hide the overlay menu. // Hide the overlay menu.
await page.getByTestId('dataOverlayMenu-mpr-sagittal-btn').click(); await page.getByTestId('dataOverlayMenu-mpr-sagittal-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -19,7 +19,6 @@ test('should overlay an unhydrated RTSTRUCT over a display set that the RTSTRUCT
// Hide the overlay menu. // Hide the overlay menu.
await page.getByTestId('dataOverlayMenu-default-btn').click(); await page.getByTestId('dataOverlayMenu-default-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
@ -29,7 +28,6 @@ test('should overlay an unhydrated RTSTRUCT over a display set that the RTSTRUCT
// Navigate to the middle image of the default viewport. // Navigate to the middle image of the default viewport.
await press({ page, key: 'ArrowDown', nTimes: 23 }); await press({ page, key: 'ArrowDown', nTimes: 23 });
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -19,7 +19,6 @@ test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay me
// Hide the overlay menu. // Hide the overlay menu.
await page.getByTestId('dataOverlayMenu-default-btn').click(); await page.getByTestId('dataOverlayMenu-default-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
@ -29,7 +28,6 @@ test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay me
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('MPR').click(); await page.getByTestId('MPR').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -13,27 +13,19 @@ test('should hydrate an RTSTRUCT from MPR', async ({ page }) => {
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('MPR').click(); await page.getByTestId('MPR').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprBeforeRT); await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprBeforeRT);
await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); await page.getByTestId('study-browser-thumbnail-no-image').dblclick();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprAfterRT); await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprAfterRT);
await page.getByTestId('yes-hydrate-btn').click(); await page.getByTestId('yes-hydrate-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprAfterRTHydrated); await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprAfterRTHydrated);
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('Axial Primary').click(); await page.getByTestId('Axial Primary').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -13,13 +13,11 @@ test('should hydrate an RTSTRUCT and then launch MPR', async ({ page }) => {
await page.getByTestId('yes-hydrate-btn').click(); await page.getByTestId('yes-hydrate-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.rtHydrationThenMPR.rtPostHydration); await checkForScreenshot(page, page, screenShotPaths.rtHydrationThenMPR.rtPostHydration);
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('Axial Primary').click(); await page.getByTestId('Axial Primary').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -11,12 +11,10 @@ test('should launch MPR with unhydrated RTSTRUCT', async ({ page }) => {
await page.getByTestId('side-panel-header-right').click(); await page.getByTestId('side-panel-header-right').click();
await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); await page.getByTestId('study-browser-thumbnail-no-image').dblclick();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.rtNoHydrationThenMPR.rtNoHydrationPreMPR); await checkForScreenshot(page, page, screenShotPaths.rtNoHydrationThenMPR.rtNoHydrationPreMPR);
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('MPR').click(); await page.getByTestId('MPR').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.rtNoHydrationThenMPR.rtNoHydrationPostMPR); await checkForScreenshot(page, page, screenShotPaths.rtNoHydrationThenMPR.rtNoHydrationPostMPR);
}); });

View File

@ -19,7 +19,6 @@ test('should overlay an unhydrated SEG over a display set that the SEG does NOT
// Hide the overlay menu. // Hide the overlay menu.
await page.getByTestId('dataOverlayMenu-default-btn').click(); await page.getByTestId('dataOverlayMenu-default-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
@ -29,7 +28,6 @@ test('should overlay an unhydrated SEG over a display set that the SEG does NOT
// Navigate to the middle image of the default viewport. // Navigate to the middle image of the default viewport.
await press({ page, key: 'ArrowDown', nTimes: 9 }); await press({ page, key: 'ArrowDown', nTimes: 9 });
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -19,7 +19,6 @@ test('should launch MPR with unhydrated SEG chosen from the data overlay menu',
// Hide the overlay menu. // Hide the overlay menu.
await page.getByTestId('dataOverlayMenu-default-btn').click(); await page.getByTestId('dataOverlayMenu-default-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,
@ -29,7 +28,6 @@ test('should launch MPR with unhydrated SEG chosen from the data overlay menu',
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('MPR').click(); await page.getByTestId('MPR').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -13,27 +13,19 @@ test('should properly display MPR for MR', async ({ page }) => {
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('MPR').click(); await page.getByTestId('MPR').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprBeforeSEG); await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprBeforeSEG);
await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); await page.getByTestId('study-browser-thumbnail-no-image').dblclick();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSEG); await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSEG);
await page.getByTestId('yes-hydrate-btn').click(); await page.getByTestId('yes-hydrate-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSegHydrated); await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSegHydrated);
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('Axial Primary').click(); await page.getByTestId('Axial Primary').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -13,13 +13,11 @@ test('should properly display MPR for MR', async ({ page }) => {
await page.getByTestId('yes-hydrate-btn').click(); await page.getByTestId('yes-hydrate-btn').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segHydrationThenMPR.segPostHydration); await checkForScreenshot(page, page, screenShotPaths.segHydrationThenMPR.segPostHydration);
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('Axial Primary').click(); await page.getByTestId('Axial Primary').click();
await page.waitForTimeout(5000);
await checkForScreenshot( await checkForScreenshot(
page, page,
page, page,

View File

@ -11,12 +11,10 @@ test('should launch MPR with unhydrated SEG', async ({ page }) => {
await page.getByTestId('side-panel-header-right').click(); await page.getByTestId('side-panel-header-right').click();
await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); await page.getByTestId('study-browser-thumbnail-no-image').dblclick();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segNoHydrationThenMPR.segNoHydrationPreMPR); await checkForScreenshot(page, page, screenShotPaths.segNoHydrationThenMPR.segNoHydrationPreMPR);
await page.getByTestId('Layout').click(); await page.getByTestId('Layout').click();
await page.getByTestId('MPR').click(); await page.getByTestId('MPR').click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segNoHydrationThenMPR.segNoHydrationPostMPR); await checkForScreenshot(page, page, screenShotPaths.segNoHydrationThenMPR.segNoHydrationPostMPR);
}); });

View File

@ -57,6 +57,6 @@ test('should hydrate SR reports correctly', async ({ page }) => {
}); });
await page.getByTestId('data-row').first().click(); await page.getByTestId('data-row').first().click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.srHydration.srJumpToMeasurement); await checkForScreenshot(page, page, screenShotPaths.srHydration.srJumpToMeasurement);
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

View File

@ -1,28 +1,34 @@
import { expect } from 'playwright-test-coverage'; import { expect } from 'playwright-test-coverage';
import { Locator, Page } from 'playwright'; import { Locator, Page } from 'playwright';
/** type CheckForScreenshotProps = {
* @param page - The page to interact with page: Page;
* @param locator - The element to check for screenshot locator: Locator | Page;
* @param screenshotPath - The path to save the screenshot screenshotPath: string;
* @param attempts - The number of attempts to check for screenshot attempts?: number;
* @param delay - The delay between attempts delay?: number;
* @returns True if the screenshot matches, otherwise throws an error maxDiffPixelRatio?: number;
*/ threshold?: number;
const checkForScreenshot = async ( };
page: Page,
locator: Locator | Page, const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
screenshotPath: string, const {
attempts = 10, page,
delay = 100 locator,
) => { screenshotPath,
attempts = 10,
delay = 500,
maxDiffPixelRatio = 0.02,
threshold = 0.05,
} = props;
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
for (let i = 0; i < attempts; i++) { for (let i = 0; i < attempts; i++) {
try { try {
await expect(locator).toHaveScreenshot(screenshotPath, { await expect(locator).toHaveScreenshot(screenshotPath, {
// 4% tolerance for screenshot comparison maxDiffPixelRatio,
maxDiffPixelRatio: 0.04, threshold,
}); });
return true; return true;
} catch (error) { } catch (error) {
@ -38,4 +44,36 @@ const checkForScreenshot = async (
throw new Error('Screenshot comparison failed: loop exited without match or proper error'); throw new Error('Screenshot comparison failed: loop exited without match or proper error');
}; };
/**
* Checks if a screenshot of a specific element matches the expected screenshot.
* It retries the check for a specified number of attempts with a delay between each attempt.
* By default, the number of attempts is 10 and the delay is 500 milliseconds which results in a maximum wait time of 5 seconds.
* Instead of sleeping idle prior to calling this function, simply adjust the attempts and delay parameters to achieve the desired wait time.
* @param pageOrProps - The page to interact with or an object containing page and other properties
* @param locator - The element to check for screenshot
* @param screenshotPath - The path to save the screenshot
* @param attempts - The number of attempts to check for screenshot
* @param delay - The delay between attempts
* @returns True if the screenshot matches, otherwise throws an error
*/
const checkForScreenshot = async (
pageOrProps: Page | CheckForScreenshotProps,
locator?: Locator | Page,
screenshotPath?: string,
attempts?: number,
delay?: number
) => {
if (typeof pageOrProps === 'object' && 'page' in pageOrProps) {
return await _checkForScreenshot(pageOrProps as CheckForScreenshotProps);
} else {
return await _checkForScreenshot({
page: pageOrProps as Page,
locator,
screenshotPath,
attempts,
delay,
});
}
};
export { checkForScreenshot }; export { checkForScreenshot };

View File

@ -1,9 +1,13 @@
import { visitStudy } from './visitStudy'; import { visitStudy } from './visitStudy';
import { checkForScreenshot } from './checkForScreenshot'; import { checkForScreenshot } from './checkForScreenshot';
import { screenShotPaths } from './screenShotPaths'; import { screenShotPaths } from './screenShotPaths';
import { simulateClicksOnElement } from './simulateClicksOnElement'; import {
simulateClicksOnElement,
simulateNormalizedClickOnElement,
simulateNormalizedClicksOnElement,
} from './simulateClicksOnElement';
import { reduce3DViewportSize } from './reduce3DviewportSize'; import { reduce3DViewportSize } from './reduce3DviewportSize';
import { getMousePosition, initilizeMousePositionTracker } from './mouseUtils'; import { getMousePosition, initializeMousePositionTracker } from './mouseUtils';
import { getSUV } from './getSUV'; import { getSUV } from './getSUV';
import { getTMTVModalityUnit } from './getTMTVModalityUnit'; import { getTMTVModalityUnit } from './getTMTVModalityUnit';
import { clearAllAnnotations } from './clearAllAnnotations'; import { clearAllAnnotations } from './clearAllAnnotations';
@ -16,9 +20,11 @@ export {
checkForScreenshot, checkForScreenshot,
screenShotPaths, screenShotPaths,
simulateClicksOnElement, simulateClicksOnElement,
simulateNormalizedClickOnElement,
simulateNormalizedClicksOnElement,
reduce3DViewportSize, reduce3DViewportSize,
getMousePosition, getMousePosition,
initilizeMousePositionTracker, initializeMousePositionTracker,
getSUV, getSUV,
getTMTVModalityUnit, getTMTVModalityUnit,
clearAllAnnotations, clearAllAnnotations,

View File

@ -5,21 +5,21 @@ interface WindowWithMousePosition extends Window {
mouseY: number; mouseY: number;
} }
export const initilizeMousePositionTracker = async (page: Page) => { export const initializeMousePositionTracker = async (page: Page) => {
const window = await page.evaluateHandle("window") as any; const window = (await page.evaluateHandle('window')) as any;
await page.evaluate((window: WindowWithMousePosition) => { await page.evaluate((window: WindowWithMousePosition) => {
window.mouseX = 0; window.mouseX = 0;
window.mouseY = 0; window.mouseY = 0;
window.addEventListener("mousemove", (event) => { window.addEventListener('mousemove', event => {
window.mouseX = event.clientX; window.mouseX = event.clientX;
window.mouseY = event.clientY; window.mouseY = event.clientY;
}); });
}, window); }, window);
} };
export const getMousePosition = async (page: Page) => { export const getMousePosition = async (page: Page) => {
const window = await page.evaluateHandle("window") as any; const window = (await page.evaluateHandle('window')) as any;
return await page.evaluate((window: WindowWithMousePosition) => { return await page.evaluate((window: WindowWithMousePosition) => {
return { x: window.mouseX, y: window.mouseY }; return { x: window.mouseX, y: window.mouseY };
}, window); }, window);
} };

View File

@ -14,6 +14,10 @@ const screenShotPaths = {
cobbangle: { cobbangle: {
cobbangleDisplayedCorrectly: 'cobbangleDisplayedCorrectly.png', cobbangleDisplayedCorrectly: 'cobbangleDisplayedCorrectly.png',
}, },
contextMenu: {
preContextMenuNearBottomEdge: 'preContextMenuNearBottomEdge.png',
contextMenuNearBottomEdgeNotClipped: 'contextMenuNearBottomEdgeNotClipped.png',
},
ellipse: { ellipse: {
ellipseDisplayedCorrectly: 'ellipseDisplayedCorrectly.png', ellipseDisplayedCorrectly: 'ellipseDisplayedCorrectly.png',
}, },

View File

@ -17,3 +17,49 @@ export async function simulateClicksOnElement({
await locator.click({ delay: 100, position: { x, y } }); await locator.click({ delay: 100, position: { x, y } });
} }
} }
/**
* Simulates clicks on an element at a normalized point.
*
* @param locator - The locator to click on.
* @param normalizedPoint - The point with x and y coordinates, normalized to the element's bounding box.
* @param button - The mouse button to use for the click (default is 'left').
*/
export async function simulateNormalizedClickOnElement({
locator,
normalizedPoint,
button = 'left',
}: {
locator: Locator;
normalizedPoint: { x: number; y: number };
button?: 'left' | 'right' | 'middle';
}) {
const bBox = await locator.boundingBox();
const position = { x: normalizedPoint.x * bBox.width, y: normalizedPoint.y * bBox.height };
await locator.click({ delay: 100, position, button });
}
/**
* Simulates clicks on an element at normalized points.
*
* @param locator - The locator to click on.
* @param normalizedPoints - An array of points with x and y coordinates, normalized to the element's bounding box.
* @param button - The mouse button to use for the click (default is 'left').
*/
export async function simulateNormalizedClicksOnElement({
locator,
normalizedPoints,
button = 'left',
}: {
locator: Locator;
normalizedPoints: { x: number; y: number }[];
button?: 'left' | 'right' | 'middle';
}) {
for (const normalizedPoint of normalizedPoints) {
await simulateNormalizedClickOnElement({
locator,
normalizedPoint,
button,
});
}
}