Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,8 @@ jobs:
with:
node-version: 22
cache: npm
- name: Install node-canvas deps (macos)
if: matrix.os != 'ubuntu-latest'
run: brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman
- name: Install dependencies
run: npm ci
env:
DETECT_CHROMEDRIVER_VERSION: true
- name: Cache test datasets
uses: actions/cache@v4
with:
Expand Down
11 changes: 7 additions & 4 deletions tests/pageobjects/volview.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,10 +451,13 @@ class VolViewPage extends Page {
timeout = DOWNLOAD_TIMEOUT
) {
await browser.waitUntil(
async () => {
const loadingIndicator = await view.$('.loading-indicator');
return !(await loadingIndicator.isDisplayed());
},
() =>
view.execute((element) => {
const container = element.closest(
'[data-testid="two-view-container"]'
);
return !!container && !container.querySelector('.loading-indicator');
}),
{
timeout,
timeoutMsg: 'Expected loading indicator to disappear',
Expand Down
10 changes: 9 additions & 1 deletion tests/rootHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// trouble a spec would otherwise sail past: a page that reaches the internet,
// or a WebGL error.

import { captureFailureScreenshot } from '../wdio.shared.conf';

const LOCAL_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
const NETWORK_PROTOCOLS = ['http:', 'https:', 'ws:', 'wss:'];

Expand Down Expand Up @@ -61,9 +63,15 @@ export const mochaHooks = {
await subscribe();
},

afterEach() {
async afterEach(this: Mocha.Context) {
const requested = externalRequests.splice(0);
const failed = webglErrors.splice(0);
if (requested.length || failed.length) {
// Root-hook plugins bypass WebdriverIO's afterHook callback.
await captureFailureScreenshot(
`root-afterEach-${this.currentTest?.fullTitle() ?? 'unknown test'}`
);
}
if (requested.length) {
throw new Error(
`The page requested non-local URLs:\n${requested.join('\n')}`
Expand Down
32 changes: 9 additions & 23 deletions tests/specs/annotationTestUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type ChainablePromiseElement } from 'webdriverio';
import AppPage from '../pageobjects/volview.page';
import { MINIMAL_DICOM } from '../datasets';
import { openUrls } from './utils';
import { openUrls, openVolViewPage } from './utils';

const pointerAt = (x: number, y: number) =>
browser.action('pointer').move({ x: Math.round(x), y: Math.round(y) });
Expand All @@ -20,14 +20,12 @@ export const rightClickAt = (x: number, y: number) =>
const HOVERING_MOUSE = 'hovering-mouse';
const hoveringMouse = () => browser.action('pointer', { id: HOVERING_MOUSE });

// A move with a duration is interpolated into a stream of pointer moves. A
// zero duration dispatches exactly one, which is what teleportTo relies on.
// A zero-duration move avoids interpolating intermediate pointer positions.
const INSTANT = 0;
const NUDGE_PX = 2;

// Two moves in one chain, so the one landing on (x, y) is never the first move
// after an idle period, which vtk.js reports as StartMouseMove and the widget
// manager ignores. The pick therefore runs at (x, y).
// Approach the target through two nearby positions while retaining the pointer
// for a subsequent press at the same coordinates.
export const nudgeTo = (x: number, y: number) =>
hoveringMouse()
.move({
Expand All @@ -38,30 +36,18 @@ export const nudgeTo = (x: number, y: number) =>
.move({ duration: INSTANT, x: Math.round(x), y: Math.round(y) })
.perform(true);

// vtk.js reports the first pointer move after ~200ms of stillness as
// StartMouseMove, which the widget manager does not subscribe to. A single move
// after that idle therefore relocates the pointer while leaving the widget
// manager's pick standing at the old position.
const IDLE_MS = 400;

export const teleportTo = async (x: number, y: number) => {
await browser.pause(IDLE_MS);
await hoveringMouse()
.move({ duration: INSTANT, x: Math.round(x), y: Math.round(y) })
.perform(true);
};

export const pressAtPointer = () => hoveringMouse().down().up().perform(true);

export const rightPressAtPointer = () =>
hoveringMouse().down({ button: 2 }).up({ button: 2 }).perform(true);

/**
* Loads the minimal DICOM and returns the axial view with the center of its
* canvas in page coordinates.
* Loads the minimal DICOM or a generated fixture and returns the axial view
* with the center of its canvas in page coordinates.
*/
export const setupTest = async () => {
await openUrls([MINIMAL_DICOM]);
export const setupTest = async (fileName?: string) => {
if (fileName) await openVolViewPage(fileName);
else await openUrls([MINIMAL_DICOM]);

const views2D = await AppPage.getViews2D();
const axialView = views2D[0];
Expand Down
22 changes: 1 addition & 21 deletions tests/specs/dicom-dimension-mismatch.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,26 +91,6 @@ async function waitForSeriesToSettle() {
);
}

// The badge only says "at least one error so far", so a count read as soon as
// it appears can miss a failure still on its way. Wait for it to hold steady.
async function waitForNotificationCountToSettle() {
let previous = await volViewPage.getNotificationsCount();
await browser.waitUntil(
async () => {
await browser.pause(500);
const current = await volViewPage.getNotificationsCount();
const held = current === previous;
previous = current;
return held;
},
{
timeout: 30000,
timeoutMsg: 'expected the notification count to stop changing',
}
);
return previous;
}

async function readErrorDetails() {
await volViewPage.notifications.click();

Expand All @@ -132,7 +112,7 @@ async function loadAndReadRejection(manifestName: string) {
await volViewPage.open(`?urls=[tmp/${manifestName}]`);
await volViewPage.waitForNotification();
await waitForSeriesToSettle();
const errorCount = await waitForNotificationCountToSettle();
const errorCount = await volViewPage.getNotificationsCount();
const text = await readErrorDetails();
return { text, errorCount };
}
Expand Down
2 changes: 1 addition & 1 deletion tests/specs/layer-display-controls.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ describe('Overlay display controls', () => {
);
await selectLayout('3D Only', 0, true);
await opacity.execute((element) => element.focus());
await browser.keys(Array.from({ length: 50 }, () => 'ArrowRight'));
await browser.keys(Array.from({ length: 5 }, () => 'PageUp'));
await expectDisplaySettings('-2.75', '100.25', '100.3');
expect(
await $$('.mapping-range-editor input').map((input) => input.getValue())
Expand Down
6 changes: 3 additions & 3 deletions tests/specs/multicomponent-layer-display.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,18 +233,18 @@ describe('Multicomponent image display controls', () => {
'[data-testid="layer-opacity-slider"] [role="slider"]'
);
await opacity.execute((element) => element.focus());
await browser.keys(Array.from({ length: 50 }, () => 'ArrowLeft'));
await browser.keys(Array.from({ length: 5 }, () => 'PageDown'));
await expect(opacity).toHaveAttribute('aria-valuenow', '0.5');
await expectImagePixels(colors(0.5));
await browser.keys(Array.from({ length: 50 }, () => 'ArrowLeft'));
await browser.keys('Home');
await expect(opacity).toHaveAttribute('aria-valuenow', '0');
await expectImagePixels(
[[128, 128, 128]],
3,
'[data-testid~="vtk-two-view"]',
foregroundColors
);
await browser.keys(Array.from({ length: 50 }, () => 'ArrowRight'));
await browser.keys(Array.from({ length: 5 }, () => 'PageUp'));
await expect(opacity).toHaveAttribute('aria-valuenow', '0.5');
await expectImagePixels(colors(0.5));

Expand Down
36 changes: 13 additions & 23 deletions tests/specs/segment-group-download.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,6 @@ const expectDirectSegmentDownload = async (
typedName: string,
expectedStem: string
) => {
await openUrls([PROSTATEX_DATASET]);
await openAnnotationSegments();
await paintOnViewedImage();

await volViewPage.clickSaveSegmentsButton();

const input = await volViewPage.saveSegmentsFilenameInput;
Expand All @@ -51,28 +47,11 @@ const expectDirectSegmentDownload = async (
await confirm.click();

await waitForFileExists(downloadedPath, SAVE_TIMEOUT);
await confirm.waitForDisplayed({ reverse: true });
};

describe('Segment download', () => {
it('sanitizes invalid characters for direct segment downloads', async () => {
await expectDirectSegmentDownload(
'Liver: left/right*?',
'Liver left right'
);
});

it('sanitizes reserved Windows names for direct segment downloads', async () => {
await expectDirectSegmentDownload('CON', 'CON_');
});

it('preserves valid names for direct segment downloads', async () => {
await expectDirectSegmentDownload(
'Prostate Segmentation',
'Prostate Segmentation'
);
});

it('names the download after the viewed image by default', async () => {
it('uses the image name by default and sanitizes custom download names', async () => {
await openUrls([PROSTATEX_DATASET]);
await openAnnotationSegments();
await paintOnViewedImage();
Expand All @@ -89,5 +68,16 @@ describe('Segment download', () => {
await confirm.click();

await waitForFileExists(downloadedPath, SAVE_TIMEOUT);
await confirm.waitForDisplayed({ reverse: true });

await expectDirectSegmentDownload(
'Liver: left/right*?',
'Liver left right'
);
await expectDirectSegmentDownload('CON', 'CON_');
await expectDirectSegmentDownload(
'Prostate Segmentation',
'Prostate Segmentation'
);
});
});
45 changes: 25 additions & 20 deletions tests/specs/select-annotation-at-press.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
nudgeTo,
pressAtPointer,
setupTest,
teleportTo,
waitForCircleCount,
} from './annotationTestUtils';
import { writeMetaImage } from './utils';

// BoundingRectangle.vue draws this around the selected annotation
const getSelectionRectCount = async (axialView: ChainablePromiseElement) => {
Expand Down Expand Up @@ -45,8 +45,8 @@ const hoverAndSelect = async (
}
);

const setupSelectedRectangle = async () => {
const context = await setupTest();
const setupSelectedRectangle = async (fileName?: string) => {
const context = await setupTest(fileName);
const { axialView, centerX, centerY } = context;
await AppPage.selectTool('mdi-vector-square');
await clickAt(centerX - 80, centerY - 80);
Expand All @@ -61,7 +61,7 @@ const setupSelectedRectangle = async () => {
);
const before = await bounds();
await hoverAndSelect(axialView, centerX - 80, centerY - 80);
return { ...context, bounds, before };
return { ...context, shape, bounds, before };
};

describe('Selection picks at the press position', () => {
Expand All @@ -70,25 +70,25 @@ describe('Selection picks at the press position', () => {
expect(await AppPage.getNotificationsCount()).toBe(0);
});

it('does not select an annotation the pointer left without a tracked move', async () => {
const { axialView, centerX, centerY, bounds, before } =
await setupSelectedRectangle();
it('deselects after changing slices without moving the pointer', async () => {
const { axialView, shape, bounds, before } = await setupSelectedRectangle(
writeMetaImage('selection-at-press.mha')
);

// Empty image area, well clear of both handles and the rectangle outline
await teleportTo(centerX + 140, centerY - 140);
await AppPage.advanceSliceAndWait();
await expect(shape.parentElement()).toHaveStyle({ display: 'none' });
await pressAtPointer();
await browser.keys('ArrowUp');
await expect(shape.parentElement()).not.toHaveStyle({ display: 'none' });

await waitForSelectionRectCount(
axialView,
0,
'Pressing on empty space should deselect, not act on the pick left behind at the handle'
'Pressing on an empty slice should deselect the annotation on the previous slice'
);
expect(await bounds()).toEqual(before);
});

// Control for the case above: same annotation, same press position, only the
// move onto empty space is one the widget manager tracks. Deselecting here
// shows the press does reach the view and that nothing is pickable there.
it('deselects when the move onto empty space is tracked', async () => {
const { axialView, centerX, centerY, bounds, before } =
await setupSelectedRectangle();
Expand Down Expand Up @@ -143,13 +143,18 @@ describe('Selection picks at the press position', () => {
2,
'Dragging should keep the same annotation'
);
expect(Number(await firstHandle.getAttribute('cx'))).toBeCloseTo(
Number(start[0]) + 35,
0
);
expect(Number(await firstHandle.getAttribute('cy'))).toBeCloseTo(
Number(start[1]) + 20,
0
const expected = [Number(start[0]) + 35, Number(start[1]) + 20];
await browser.waitUntil(
async () => {
const coordinates = await Promise.all(
['cx', 'cy'].map((axis) => firstHandle.getAttribute(axis))
);
return coordinates.every(
(coordinate, index) =>
Math.abs(Number(coordinate) - expected[index]) < 0.5
);
},
{ timeoutMsg: `Expected the dragged handle at ${expected.join(', ')}` }
);
});
}
Expand Down
8 changes: 4 additions & 4 deletions tests/specs/slice-display-lifecycle.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ const coloredPixelCount = (data: Buffer) =>

async function selectGrid(index: number, count: number) {
await volViewPage.layoutButton.click();
await $('.v-menu.v-overlay--active .v-overlay__content').waitForStable();
const cell = $$('.v-menu.v-overlay--active .grid-cell')[index].$(
'.grid-cell-box'
);
const cell = $(`.v-menu .grid-cell:nth-child(${index + 1}) .grid-cell-box`);
await cell.waitForDisplayed();
await cell.waitForStable();
await cell.moveTo();
await cell.waitForClickable();
await cell.click();
await cell.waitForDisplayed({ reverse: true });
await expect($$('.grid-item')).toBeElementsArrayOfSize(count);
}

Expand Down
1 change: 1 addition & 0 deletions tests/specs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export async function selectLayout(
const option = $(`.v-list-item-title=${name}`);
await option.waitForClickable();
await option.click();
await option.waitForDisplayed({ reverse: true });
await volViewPage.waitForViewCounts(count2D, has3D);
}

Expand Down
Loading
Loading