diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0c69487ee..12b108800 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -29,6 +29,19 @@ jobs: run: | export NODE_OPTIONS="--max_old_space_size=10192" 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 run: | bun nyc report --reporter=lcov --reporter=text diff --git a/extensions/cornerstone/src/hps/fourUp.ts b/extensions/cornerstone/src/hps/fourUp.ts index 9a72b049d..11facdf9a 100644 --- a/extensions/cornerstone/src/hps/fourUp.ts +++ b/extensions/cornerstone/src/hps/fourUp.ts @@ -64,7 +64,7 @@ export const fourUp = { customViewportProps: { hideOverlays: true, }, - syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP], + syncGroups: [HYDRATE_SEG_SYNC_GROUP], }, displaySets: [ { diff --git a/extensions/cornerstone/src/hps/mprAnd3DVolumeViewport.ts b/extensions/cornerstone/src/hps/mprAnd3DVolumeViewport.ts index 002600f27..c26a6d5ee 100644 --- a/extensions/cornerstone/src/hps/mprAnd3DVolumeViewport.ts +++ b/extensions/cornerstone/src/hps/mprAnd3DVolumeViewport.ts @@ -71,7 +71,7 @@ export const mprAnd3DVolumeViewport = { customViewportProps: { hideOverlays: true, }, - syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP], + syncGroups: [HYDRATE_SEG_SYNC_GROUP], }, displaySets: [ { diff --git a/extensions/cornerstone/src/hps/only3D.ts b/extensions/cornerstone/src/hps/only3D.ts index 6e5782ebe..36f3534ef 100644 --- a/extensions/cornerstone/src/hps/only3D.ts +++ b/extensions/cornerstone/src/hps/only3D.ts @@ -47,7 +47,7 @@ export const only3D = { orientation: 'coronal', customViewportProps: { hideOverlays: true, - syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP], + syncGroups: [HYDRATE_SEG_SYNC_GROUP], }, }, displaySets: [ diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 397ba63c7..8916de073 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -39,6 +39,7 @@ import { useLutPresentationStore } from './stores/useLutPresentationStore'; import { usePositionPresentationStore } from './stores/usePositionPresentationStore'; import { useSegmentationPresentationStore } from './stores/useSegmentationPresentationStore'; import { imageRetrieveMetadataProvider } from '@cornerstonejs/core/utilities'; +import { initializeWebWorkerProgressHandler } from './utils/initWebWorkerProgressHandler'; const { registerColormap } = csUtilities.colormap; @@ -307,76 +308,6 @@ export default async function init({ 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 * @param strategyFn - The image loading strategy function to wrap diff --git a/extensions/cornerstone/src/utils/initWebWorkerProgressHandler.ts b/extensions/cornerstone/src/utils/initWebWorkerProgressHandler.ts new file mode 100644 index 000000000..a57404fef --- /dev/null +++ b/extensions/cornerstone/src/utils/initWebWorkerProgressHandler.ts @@ -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 + ); + } + } + } + }); +} diff --git a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx index 183fd417d..2827790c0 100644 --- a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx +++ b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx @@ -80,6 +80,7 @@ export default class ContextMenuController { this.services.uiDialogService.hide('context-menu'); this.services.uiDialogService.show({ id: 'context-menu', + showOverlay: false, defaultPosition: ContextMenuController._getDefaultPosition( defaultPointsPosition, event?.detail || event, diff --git a/extensions/default/src/index.ts b/extensions/default/src/index.ts index f11d3d237..133d5878e 100644 --- a/extensions/default/src/index.ts +++ b/extensions/default/src/index.ts @@ -37,6 +37,8 @@ import * as utils from './utils'; import { Toolbox } from './utils'; import MoreDropdownMenu from './Components/MoreDropdownMenu'; import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy'; +import { Toolbar } from './Toolbar/Toolbar'; + const defaultExtension: Types.Extensions.Extension = { /** * Only required property. Should be a unique value across all extensions. @@ -103,4 +105,5 @@ export { requestDisplaySetCreationForStudy, callInputDialog, createReportDialogPrompt, + Toolbar, }; diff --git a/platform/core/src/services/UIDialogService/UIDialogService.ts b/platform/core/src/services/UIDialogService/UIDialogService.ts index 2f291d331..15f50c2e1 100644 --- a/platform/core/src/services/UIDialogService/UIDialogService.ts +++ b/platform/core/src/services/UIDialogService/UIDialogService.ts @@ -14,6 +14,8 @@ const serviceImplementation = { console.warn('isEmpty() NOT IMPLEMENTED'); return true; }, + _updatePosition: (id: string, position: { x: number; y: number }) => + console.warn('updatePosition() NOT IMPLEMENTED'), _customComponent: null, }; @@ -63,6 +65,16 @@ class UIDialogService { 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 * @@ -75,7 +87,14 @@ class UIDialogService { /** * Set the service implementation */ - setServiceImplementation({ show, hide, hideAll, isEmpty, customComponent }: any): void { + setServiceImplementation({ + show, + hide, + hideAll, + isEmpty, + updatePosition, + customComponent, + }: any): void { if (show) { serviceImplementation._show = show; } @@ -88,6 +107,9 @@ class UIDialogService { if (isEmpty) { serviceImplementation._isEmpty = isEmpty; } + if (updatePosition) { + serviceImplementation._updatePosition = updatePosition; + } if (customComponent) { serviceImplementation._customComponent = customComponent; } diff --git a/platform/docs/src/pages/components-list.tsx b/platform/docs/src/pages/components-list.tsx index 189e9f1a4..2ec48c375 100644 --- a/platform/docs/src/pages/components-list.tsx +++ b/platform/docs/src/pages/components-list.tsx @@ -139,4 +139,4 @@ export default function ComponentsList() { ); -} \ No newline at end of file +} diff --git a/platform/ui-next/src/components/Icons/Sources/PowerOff.tsx b/platform/ui-next/src/components/Icons/Sources/PowerOff.tsx index 4de9a9fff..a4996e021 100644 --- a/platform/ui-next/src/components/Icons/Sources/PowerOff.tsx +++ b/platform/ui-next/src/components/Icons/Sources/PowerOff.tsx @@ -6,8 +6,8 @@ export const PowerOff = (props: IconProps) => ( xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 28" aria-labelledby="title" - width="1em" - height="1em" + width="28px" + height="28px" fill="currentColor" {...props} > diff --git a/platform/ui-next/src/components/Viewport/ViewportPane.tsx b/platform/ui-next/src/components/Viewport/ViewportPane.tsx index e5c6f4b91..2610ad241 100644 --- a/platform/ui-next/src/components/Viewport/ViewportPane.tsx +++ b/platform/ui-next/src/components/Viewport/ViewportPane.tsx @@ -67,9 +67,10 @@ function ViewportPane({ {/* Border overlay */}
diff --git a/platform/ui-next/src/contextProviders/DialogProvider.tsx b/platform/ui-next/src/contextProviders/DialogProvider.tsx index 940aed9b5..442cd7284 100644 --- a/platform/ui-next/src/contextProviders/DialogProvider.tsx +++ b/platform/ui-next/src/contextProviders/DialogProvider.tsx @@ -1,11 +1,20 @@ -import React, { useState, createContext, useContext, useCallback, useEffect, useMemo } from 'react'; -import ManagedDialog, { ManagedDialogProps } from './ManagedDialog'; +import React, { + useState, + createContext, + useContext, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; +import ManagedDialog, { ManagedDialogProps, ManagedDialogRef } from './ManagedDialog'; interface DialogContextType { show: (options: ManagedDialogProps) => string; hide: (id: string) => void; hideAll: () => void; isEmpty: () => boolean; + updatePosition: (id: string, position: { x: number; y: number }) => void; } interface DialogService { @@ -35,6 +44,7 @@ const DialogProvider: React.FC = ({ service = null, }) => { const [dialogs, setDialogs] = useState<(ManagedDialogProps & { id: string })[]>([]); + const dialogRefs = useRef>(new Map()); const show = useCallback((options: ManagedDialogProps) => { const id = options.id; @@ -44,22 +54,32 @@ const DialogProvider: React.FC = ({ const hide = useCallback((id: string) => { setDialogs(prev => prev.filter(dialog => dialog.id !== id)); + dialogRefs.current.delete(id); }, []); const hideAll = useCallback(() => { setDialogs([]); + dialogRefs.current.clear(); }, []); 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( () => ({ show, hide, hideAll, isEmpty, + updatePosition, }), - [show, hide, hideAll, isEmpty] + [show, hide, hideAll, isEmpty, updatePosition] ); useEffect(() => { @@ -76,6 +96,11 @@ const DialogProvider: React.FC = ({ {dialogs.map(dialog => ( { + if (ref) { + dialogRefs.current.set(dialog.id, ref); + } + }} onClose={hide} isOpen={true} {...dialog} diff --git a/platform/ui-next/src/contextProviders/ManagedDialog.tsx b/platform/ui-next/src/contextProviders/ManagedDialog.tsx index 12885ffed..a6e668ac5 100644 --- a/platform/ui-next/src/contextProviders/ManagedDialog.tsx +++ b/platform/ui-next/src/contextProviders/ManagedDialog.tsx @@ -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 { cn } from '../lib/utils'; +type Position = { + x: number; + y: number; +}; + export interface ManagedDialogProps { id: string; isOpen?: boolean; @@ -19,92 +24,134 @@ export interface ManagedDialogProps { containerClassName?: string; } -const ManagedDialog: React.FC = ({ - id, - 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' - ); +export interface ManagedDialogRef { + updatePosition: (position: Position) => void; +} - // 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; - } +const _updatePosition = ( + contentNode: HTMLElement, + desiredPosition: { x: number; y: number }, + setCurrentPosition: (pt: Position) => void +) => { + if (!contentNode) { + return; + } - const boundingClientRect = contentNode.getBoundingClientRect(); - if (boundingClientRect.bottom > window.innerHeight) { - defaultPosition.y = defaultPosition.y - boundingClientRect.height; - } - if (boundingClientRect.right > window.innerWidth) { - defaultPosition.x = defaultPosition.x - boundingClientRect.width; - } - setContentVisibility('visible'); - }, - [defaultPosition] - ); - - return ( - { - if (!open) { - onClose(id); - } - }} - isDraggable={isDraggable} - shouldCloseOnEsc={shouldCloseOnEsc} - shouldCloseOnOverlayClick={shouldCloseOnOverlayClick} - showOverlay={showOverlay} - > - - {!unstyled && {title && {title}}} - onClose(id)} - /> - - - ); + const boundingClientRect = contentNode.getBoundingClientRect(); + if (boundingClientRect.bottom > window.innerHeight) { + desiredPosition.y = desiredPosition.y - boundingClientRect.height; + } + if (boundingClientRect.right > window.innerWidth) { + desiredPosition.x = desiredPosition.x - boundingClientRect.width; + } + setCurrentPosition(desiredPosition); }; +const ManagedDialog = forwardRef( + ( + { + 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(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 ( + { + if (!open) { + onClose(id); + } + }} + isDraggable={isDraggable} + shouldCloseOnEsc={shouldCloseOnEsc} + shouldCloseOnOverlayClick={shouldCloseOnOverlayClick} + showOverlay={showOverlay} + > + + {!unstyled && {title && {title}}} + onClose(id)} + /> + + + ); + } +); + +ManagedDialog.displayName = 'ManagedDialog'; + export default ManagedDialog; diff --git a/playwright.config.ts b/playwright.config.ts index 0975b89c0..008943039 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,14 @@ export default defineConfig({ workers: process.env.CI ? 6 : undefined, snapshotPathTemplate: './tests/screenshots{/projectName}/{testFilePath}/{arg}{ext}', 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, timeout: 800_000, use: { diff --git a/tests/3DFourUp.spec.ts b/tests/3DFourUp.spec.ts index efcfc82b1..75991fcb7 100644 --- a/tests/3DFourUp.spec.ts +++ b/tests/3DFourUp.spec.ts @@ -27,8 +27,7 @@ test.describe('3D four up Test', async () => { await checkForScreenshot( page, page, - screenShotPaths.threeDFourUp.threeDFourUpDisplayedCorrectly, - 200 + screenShotPaths.threeDFourUp.threeDFourUpDisplayedCorrectly ); }); }); diff --git a/tests/3DMain.spec.ts b/tests/3DMain.spec.ts index 580e6932f..56db76e2c 100644 --- a/tests/3DMain.spec.ts +++ b/tests/3DMain.spec.ts @@ -22,11 +22,6 @@ test.describe('3D main Test', async () => { .first() .click(); await attemptAction(() => reduce3DViewportSize(page), 10, 100); - await checkForScreenshot( - page, - page, - screenShotPaths.threeDMain.threeDMainDisplayedCorrectly, - 200 - ); + await checkForScreenshot(page, page, screenShotPaths.threeDMain.threeDMainDisplayedCorrectly); }); }); diff --git a/tests/3DOnly.spec.ts b/tests/3DOnly.spec.ts index 16b2f64d1..b3b890180 100644 --- a/tests/3DOnly.spec.ts +++ b/tests/3DOnly.spec.ts @@ -22,11 +22,12 @@ test.describe('3D only Test', async () => { .first() .click(); 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, - screenShotPaths.threeDOnly.threeDOnlyDisplayedCorrectly, - 200 - ); + locator: page, + screenshotPath: screenShotPaths.threeDOnly.threeDOnlyDisplayedCorrectly, + maxDiffPixelRatio: 0.04, + }); }); }); diff --git a/tests/3DPrimary.spec.ts b/tests/3DPrimary.spec.ts index 05d700b28..208ee08db 100644 --- a/tests/3DPrimary.spec.ts +++ b/tests/3DPrimary.spec.ts @@ -26,8 +26,7 @@ test.describe('3D primary Test', async () => { await checkForScreenshot( page, page, - screenShotPaths.threeDPrimary.threeDPrimaryDisplayedCorrectly, - 200 + screenShotPaths.threeDPrimary.threeDPrimaryDisplayedCorrectly ); }); }); diff --git a/tests/AxialPrimary.spec.ts b/tests/AxialPrimary.spec.ts index 91185dc32..a1dbc2cb0 100644 --- a/tests/AxialPrimary.spec.ts +++ b/tests/AxialPrimary.spec.ts @@ -18,8 +18,7 @@ test.describe('Axial Primary Test', async () => { await checkForScreenshot( page, page, - screenShotPaths.axialPrimary.axialPrimaryDisplayedCorrectly, - 200 + screenShotPaths.axialPrimary.axialPrimaryDisplayedCorrectly ); }); }); diff --git a/tests/ContextMenu.spec.ts b/tests/ContextMenu.spec.ts new file mode 100644 index 000000000..8a37f01d7 --- /dev/null +++ b/tests/ContextMenu.spec.ts @@ -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, + }); +}); diff --git a/tests/Crosshairs.spec.ts b/tests/Crosshairs.spec.ts index a64795140..432ad3a25 100644 --- a/tests/Crosshairs.spec.ts +++ b/tests/Crosshairs.spec.ts @@ -3,7 +3,7 @@ import { visitStudy, checkForScreenshot, screenShotPaths, - initilizeMousePositionTracker, + initializeMousePositionTracker, getMousePosition, } 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 mode = 'viewer'; await visitStudy(page, studyInstanceUID, mode, 2000); - await initilizeMousePositionTracker(page); + await initializeMousePositionTracker(page); }); 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.waitForTimeout(5000); - await checkForScreenshot(page, page, screenShotPaths.crosshairs.crosshairsNewDisplayset); }); }); diff --git a/tests/JumpToMeasurementMPR.spec.ts b/tests/JumpToMeasurementMPR.spec.ts index 4b78723f0..6b2c93f1b 100644 --- a/tests/JumpToMeasurementMPR.spec.ts +++ b/tests/JumpToMeasurementMPR.spec.ts @@ -87,8 +87,6 @@ test('should hydrate in MPR correctly', async ({ page }) => { await page.getByTestId('data-row').first().click(); - await page.waitForTimeout(5000); - await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.jumpToMeasurementStack); 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 page.locator(':text("S:3")').first().dblclick(); - await page.waitForTimeout(5000); await checkForScreenshot(page, page, screenShotPaths.jumpToMeasurementMPR.changeSeriesInMPR); await page.getByTestId('data-row').first().click(); - await page.waitForTimeout(5000); - await checkForScreenshot( page, page, diff --git a/tests/MPRThenRTOverlayNoHydration.spec.ts b/tests/MPRThenRTOverlayNoHydration.spec.ts index 633789dcf..8a03e0804 100644 --- a/tests/MPRThenRTOverlayNoHydration.spec.ts +++ b/tests/MPRThenRTOverlayNoHydration.spec.ts @@ -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('MPR').click(); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, @@ -32,7 +31,6 @@ test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay me // Hide the overlay menu. await page.getByTestId('dataOverlayMenu-mpr-sagittal-btn').click(); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, diff --git a/tests/MPRThenSEGOverlayNoHydration.spec.ts b/tests/MPRThenSEGOverlayNoHydration.spec.ts index 86c23c3e0..a970a1795 100644 --- a/tests/MPRThenSEGOverlayNoHydration.spec.ts +++ b/tests/MPRThenSEGOverlayNoHydration.spec.ts @@ -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('MPR').click(); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, @@ -32,7 +31,6 @@ test('should launch MPR with unhydrated SEG chosen from the data overlay menu', // Hide the overlay menu. await page.getByTestId('dataOverlayMenu-mpr-sagittal-btn').click(); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, diff --git a/tests/RTDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts b/tests/RTDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts index 64af43251..7754489d2 100644 --- a/tests/RTDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts +++ b/tests/RTDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts @@ -19,7 +19,6 @@ test('should overlay an unhydrated RTSTRUCT over a display set that the RTSTRUCT // Hide the overlay menu. await page.getByTestId('dataOverlayMenu-default-btn').click(); - await page.waitForTimeout(5000); await checkForScreenshot( 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. await press({ page, key: 'ArrowDown', nTimes: 23 }); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, diff --git a/tests/RTDataOverlayNoHydrationThenMPR.spec.ts b/tests/RTDataOverlayNoHydrationThenMPR.spec.ts index 389a57464..06ff28b5f 100644 --- a/tests/RTDataOverlayNoHydrationThenMPR.spec.ts +++ b/tests/RTDataOverlayNoHydrationThenMPR.spec.ts @@ -19,7 +19,6 @@ test('should launch MPR with unhydrated RTSTRUCT chosen from the data overlay me // Hide the overlay menu. await page.getByTestId('dataOverlayMenu-default-btn').click(); - await page.waitForTimeout(5000); await checkForScreenshot( 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('MPR').click(); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, diff --git a/tests/RTHydrationFromMPR.spec.ts b/tests/RTHydrationFromMPR.spec.ts index fae1ffadf..63b4b98c1 100644 --- a/tests/RTHydrationFromMPR.spec.ts +++ b/tests/RTHydrationFromMPR.spec.ts @@ -13,27 +13,19 @@ test('should hydrate an RTSTRUCT from MPR', async ({ page }) => { await page.getByTestId('Layout').click(); await page.getByTestId('MPR').click(); - await page.waitForTimeout(5000); - await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprBeforeRT); await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); - await page.waitForTimeout(5000); - await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprAfterRT); await page.getByTestId('yes-hydrate-btn').click(); - await page.waitForTimeout(5000); - await checkForScreenshot(page, page, screenShotPaths.rtHydrationFromMPR.mprAfterRTHydrated); await page.getByTestId('Layout').click(); await page.getByTestId('Axial Primary').click(); - await page.waitForTimeout(5000); - await checkForScreenshot( page, page, diff --git a/tests/RTHydrationThenMPR.spec.ts b/tests/RTHydrationThenMPR.spec.ts index 440a4d13c..c6e16d221 100644 --- a/tests/RTHydrationThenMPR.spec.ts +++ b/tests/RTHydrationThenMPR.spec.ts @@ -13,13 +13,11 @@ test('should hydrate an RTSTRUCT and then launch MPR', async ({ page }) => { await page.getByTestId('yes-hydrate-btn').click(); - await page.waitForTimeout(5000); await checkForScreenshot(page, page, screenShotPaths.rtHydrationThenMPR.rtPostHydration); await page.getByTestId('Layout').click(); await page.getByTestId('Axial Primary').click(); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, diff --git a/tests/RTNoHydrationThenMPR.spec.ts b/tests/RTNoHydrationThenMPR.spec.ts index 71006aead..3b34e66e8 100644 --- a/tests/RTNoHydrationThenMPR.spec.ts +++ b/tests/RTNoHydrationThenMPR.spec.ts @@ -11,12 +11,10 @@ test('should launch MPR with unhydrated RTSTRUCT', async ({ page }) => { await page.getByTestId('side-panel-header-right').click(); await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); - await page.waitForTimeout(5000); await checkForScreenshot(page, page, screenShotPaths.rtNoHydrationThenMPR.rtNoHydrationPreMPR); await page.getByTestId('Layout').click(); await page.getByTestId('MPR').click(); - await page.waitForTimeout(5000); await checkForScreenshot(page, page, screenShotPaths.rtNoHydrationThenMPR.rtNoHydrationPostMPR); }); diff --git a/tests/SEGDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts b/tests/SEGDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts index ce2b684a9..84cb879d9 100644 --- a/tests/SEGDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts +++ b/tests/SEGDataOverlayForUnreferencedDisplaySetNoHydration.spec.ts @@ -19,7 +19,6 @@ test('should overlay an unhydrated SEG over a display set that the SEG does NOT // Hide the overlay menu. await page.getByTestId('dataOverlayMenu-default-btn').click(); - await page.waitForTimeout(5000); await checkForScreenshot( 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. await press({ page, key: 'ArrowDown', nTimes: 9 }); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, diff --git a/tests/SEGDataOverlayNoHydrationThenMPR.spec.ts b/tests/SEGDataOverlayNoHydrationThenMPR.spec.ts index 6892d1ac9..56b407610 100644 --- a/tests/SEGDataOverlayNoHydrationThenMPR.spec.ts +++ b/tests/SEGDataOverlayNoHydrationThenMPR.spec.ts @@ -19,7 +19,6 @@ test('should launch MPR with unhydrated SEG chosen from the data overlay menu', // Hide the overlay menu. await page.getByTestId('dataOverlayMenu-default-btn').click(); - await page.waitForTimeout(5000); await checkForScreenshot( 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('MPR').click(); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, diff --git a/tests/SEGHydrationFromMPR.spec.ts b/tests/SEGHydrationFromMPR.spec.ts index 9a5cf97c4..da5b19d3b 100644 --- a/tests/SEGHydrationFromMPR.spec.ts +++ b/tests/SEGHydrationFromMPR.spec.ts @@ -13,27 +13,19 @@ test('should properly display MPR for MR', async ({ page }) => { await page.getByTestId('Layout').click(); await page.getByTestId('MPR').click(); - await page.waitForTimeout(5000); - await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprBeforeSEG); await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); - await page.waitForTimeout(5000); - await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSEG); await page.getByTestId('yes-hydrate-btn').click(); - await page.waitForTimeout(5000); - await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSegHydrated); await page.getByTestId('Layout').click(); await page.getByTestId('Axial Primary').click(); - await page.waitForTimeout(5000); - await checkForScreenshot( page, page, diff --git a/tests/SEGHydrationThenMPR.spec.ts b/tests/SEGHydrationThenMPR.spec.ts index 509c3981b..379993f19 100644 --- a/tests/SEGHydrationThenMPR.spec.ts +++ b/tests/SEGHydrationThenMPR.spec.ts @@ -13,13 +13,11 @@ test('should properly display MPR for MR', async ({ page }) => { await page.getByTestId('yes-hydrate-btn').click(); - await page.waitForTimeout(5000); await checkForScreenshot(page, page, screenShotPaths.segHydrationThenMPR.segPostHydration); await page.getByTestId('Layout').click(); await page.getByTestId('Axial Primary').click(); - await page.waitForTimeout(5000); await checkForScreenshot( page, page, diff --git a/tests/SEGNoHydrationThenMPR.spec.ts b/tests/SEGNoHydrationThenMPR.spec.ts index 83527557d..8cbadcf9b 100644 --- a/tests/SEGNoHydrationThenMPR.spec.ts +++ b/tests/SEGNoHydrationThenMPR.spec.ts @@ -11,12 +11,10 @@ test('should launch MPR with unhydrated SEG', async ({ page }) => { await page.getByTestId('side-panel-header-right').click(); await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); - await page.waitForTimeout(5000); await checkForScreenshot(page, page, screenShotPaths.segNoHydrationThenMPR.segNoHydrationPreMPR); await page.getByTestId('Layout').click(); await page.getByTestId('MPR').click(); - await page.waitForTimeout(5000); await checkForScreenshot(page, page, screenShotPaths.segNoHydrationThenMPR.segNoHydrationPostMPR); }); diff --git a/tests/SRHydration.spec.ts b/tests/SRHydration.spec.ts index 81c9c8cfe..dc2bc0c4d 100644 --- a/tests/SRHydration.spec.ts +++ b/tests/SRHydration.spec.ts @@ -57,6 +57,6 @@ test('should hydrate SR reports correctly', async ({ page }) => { }); await page.getByTestId('data-row').first().click(); - await page.waitForTimeout(5000); + await checkForScreenshot(page, page, screenShotPaths.srHydration.srJumpToMeasurement); }); diff --git a/tests/screenshots/chromium/3DOnly.spec.ts/threeDOnlyDisplayedCorrectly.png b/tests/screenshots/chromium/3DOnly.spec.ts/threeDOnlyDisplayedCorrectly.png index 1492a0e79..ef12e07e7 100644 Binary files a/tests/screenshots/chromium/3DOnly.spec.ts/threeDOnlyDisplayedCorrectly.png and b/tests/screenshots/chromium/3DOnly.spec.ts/threeDOnlyDisplayedCorrectly.png differ diff --git a/tests/screenshots/chromium/ContextMenu.spec.ts/contextMenuNearBottomEdgeNotClipped.png b/tests/screenshots/chromium/ContextMenu.spec.ts/contextMenuNearBottomEdgeNotClipped.png new file mode 100644 index 000000000..29d9d2580 Binary files /dev/null and b/tests/screenshots/chromium/ContextMenu.spec.ts/contextMenuNearBottomEdgeNotClipped.png differ diff --git a/tests/screenshots/chromium/ContextMenu.spec.ts/preContextMenuNearBottomEdge.png b/tests/screenshots/chromium/ContextMenu.spec.ts/preContextMenuNearBottomEdge.png new file mode 100644 index 000000000..a1c641439 Binary files /dev/null and b/tests/screenshots/chromium/ContextMenu.spec.ts/preContextMenuNearBottomEdge.png differ diff --git a/tests/utils/checkForScreenshot.ts b/tests/utils/checkForScreenshot.ts index f77967295..84d1caf7c 100644 --- a/tests/utils/checkForScreenshot.ts +++ b/tests/utils/checkForScreenshot.ts @@ -1,28 +1,34 @@ import { expect } from 'playwright-test-coverage'; import { Locator, Page } from 'playwright'; -/** - * @param page - The page to interact with - * @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 ( - page: Page, - locator: Locator | Page, - screenshotPath: string, - attempts = 10, - delay = 100 -) => { +type CheckForScreenshotProps = { + page: Page; + locator: Locator | Page; + screenshotPath: string; + attempts?: number; + delay?: number; + maxDiffPixelRatio?: number; + threshold?: number; +}; + +const _checkForScreenshot = async (props: CheckForScreenshotProps) => { + const { + page, + locator, + screenshotPath, + attempts = 10, + delay = 500, + maxDiffPixelRatio = 0.02, + threshold = 0.05, + } = props; + await page.waitForLoadState('networkidle'); for (let i = 0; i < attempts; i++) { try { await expect(locator).toHaveScreenshot(screenshotPath, { - // 4% tolerance for screenshot comparison - maxDiffPixelRatio: 0.04, + maxDiffPixelRatio, + threshold, }); return true; } catch (error) { @@ -38,4 +44,36 @@ const checkForScreenshot = async ( 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 }; diff --git a/tests/utils/index.ts b/tests/utils/index.ts index 4e2c04351..abdba595c 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -1,9 +1,13 @@ import { visitStudy } from './visitStudy'; import { checkForScreenshot } from './checkForScreenshot'; import { screenShotPaths } from './screenShotPaths'; -import { simulateClicksOnElement } from './simulateClicksOnElement'; +import { + simulateClicksOnElement, + simulateNormalizedClickOnElement, + simulateNormalizedClicksOnElement, +} from './simulateClicksOnElement'; import { reduce3DViewportSize } from './reduce3DviewportSize'; -import { getMousePosition, initilizeMousePositionTracker } from './mouseUtils'; +import { getMousePosition, initializeMousePositionTracker } from './mouseUtils'; import { getSUV } from './getSUV'; import { getTMTVModalityUnit } from './getTMTVModalityUnit'; import { clearAllAnnotations } from './clearAllAnnotations'; @@ -16,9 +20,11 @@ export { checkForScreenshot, screenShotPaths, simulateClicksOnElement, + simulateNormalizedClickOnElement, + simulateNormalizedClicksOnElement, reduce3DViewportSize, getMousePosition, - initilizeMousePositionTracker, + initializeMousePositionTracker, getSUV, getTMTVModalityUnit, clearAllAnnotations, diff --git a/tests/utils/mouseUtils.ts b/tests/utils/mouseUtils.ts index 0f1e90e22..d0ef6c86f 100644 --- a/tests/utils/mouseUtils.ts +++ b/tests/utils/mouseUtils.ts @@ -5,21 +5,21 @@ interface WindowWithMousePosition extends Window { mouseY: number; } -export const initilizeMousePositionTracker = async (page: Page) => { - const window = await page.evaluateHandle("window") as any; +export const initializeMousePositionTracker = async (page: Page) => { + const window = (await page.evaluateHandle('window')) as any; await page.evaluate((window: WindowWithMousePosition) => { window.mouseX = 0; window.mouseY = 0; - window.addEventListener("mousemove", (event) => { + window.addEventListener('mousemove', event => { window.mouseX = event.clientX; window.mouseY = event.clientY; }); }, window); -} +}; 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 { x: window.mouseX, y: window.mouseY }; }, window); -} +}; diff --git a/tests/utils/screenShotPaths.ts b/tests/utils/screenShotPaths.ts index 591836876..4fb83c1d0 100644 --- a/tests/utils/screenShotPaths.ts +++ b/tests/utils/screenShotPaths.ts @@ -14,6 +14,10 @@ const screenShotPaths = { cobbangle: { cobbangleDisplayedCorrectly: 'cobbangleDisplayedCorrectly.png', }, + contextMenu: { + preContextMenuNearBottomEdge: 'preContextMenuNearBottomEdge.png', + contextMenuNearBottomEdgeNotClipped: 'contextMenuNearBottomEdgeNotClipped.png', + }, ellipse: { ellipseDisplayedCorrectly: 'ellipseDisplayedCorrectly.png', }, diff --git a/tests/utils/simulateClicksOnElement.ts b/tests/utils/simulateClicksOnElement.ts index d1932faa0..c8dac6714 100644 --- a/tests/utils/simulateClicksOnElement.ts +++ b/tests/utils/simulateClicksOnElement.ts @@ -17,3 +17,49 @@ export async function simulateClicksOnElement({ 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, + }); + } +}