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:
parent
17ed63eb1d
commit
24e0263fb9
13
.github/workflows/playwright.yml
vendored
13
.github/workflows/playwright.yml
vendored
@ -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
|
||||||
|
|||||||
@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
110
extensions/cornerstone/src/utils/initWebWorkerProgressHandler.ts
Normal file
110
extensions/cornerstone/src/utils/initWebWorkerProgressHandler.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
54
tests/ContextMenu.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 |
@ -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 };
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user