From 401d6089fcf338c67c978a1b462f38c0693f1252 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 25 Oct 2026 15:16:50 -0400 Subject: [PATCH 1/3] test(e2e): stabilize readiness and reduce repeated setup --- .github/workflows/e2e.yml | 5 -- tests/pageobjects/volview.page.ts | 11 ++-- tests/rootHooks.ts | 10 +++- tests/specs/annotationTestUtils.ts | 32 +++------- tests/specs/dicom-dimension-mismatch.e2e.ts | 22 +------ tests/specs/layer-display-controls.e2e.ts | 2 +- .../specs/multicomponent-layer-display.e2e.ts | 12 +++- tests/specs/segment-group-download.e2e.ts | 36 ++++------- tests/specs/select-annotation-at-press.e2e.ts | 45 +++++++------- tests/specs/windowing-config.e2e.ts | 59 ++++--------------- wdio.chrome.conf.ts | 11 ++-- wdio.shared.conf.ts | 41 ++++++++++++- 12 files changed, 129 insertions(+), 157 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3f2d18f40..43312bf9c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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: diff --git a/tests/pageobjects/volview.page.ts b/tests/pageobjects/volview.page.ts index 8182c390d..673f710c9 100644 --- a/tests/pageobjects/volview.page.ts +++ b/tests/pageobjects/volview.page.ts @@ -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', diff --git a/tests/rootHooks.ts b/tests/rootHooks.ts index cd69811b4..3e537873d 100644 --- a/tests/rootHooks.ts +++ b/tests/rootHooks.ts @@ -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:']; @@ -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')}` diff --git a/tests/specs/annotationTestUtils.ts b/tests/specs/annotationTestUtils.ts index 143652b0d..0e16c44cf 100644 --- a/tests/specs/annotationTestUtils.ts +++ b/tests/specs/annotationTestUtils.ts @@ -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) }); @@ -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({ @@ -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]; diff --git a/tests/specs/dicom-dimension-mismatch.e2e.ts b/tests/specs/dicom-dimension-mismatch.e2e.ts index e0838dd62..016a503b8 100644 --- a/tests/specs/dicom-dimension-mismatch.e2e.ts +++ b/tests/specs/dicom-dimension-mismatch.e2e.ts @@ -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(); @@ -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 }; } diff --git a/tests/specs/layer-display-controls.e2e.ts b/tests/specs/layer-display-controls.e2e.ts index 79c57ef74..fdea89f97 100644 --- a/tests/specs/layer-display-controls.e2e.ts +++ b/tests/specs/layer-display-controls.e2e.ts @@ -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()) diff --git a/tests/specs/multicomponent-layer-display.e2e.ts b/tests/specs/multicomponent-layer-display.e2e.ts index 5850d1291..f1db105f4 100644 --- a/tests/specs/multicomponent-layer-display.e2e.ts +++ b/tests/specs/multicomponent-layer-display.e2e.ts @@ -233,10 +233,16 @@ 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')); + if (!rgba && type === 'unsigned char') { + await browser.keys('ArrowLeft'); + await expect(opacity).toHaveAttribute('aria-valuenow', '0.49'); + await browser.keys('ArrowRight'); + await expect(opacity).toHaveAttribute('aria-valuenow', '0.5'); + } + await browser.keys('Home'); await expect(opacity).toHaveAttribute('aria-valuenow', '0'); await expectImagePixels( [[128, 128, 128]], @@ -244,7 +250,7 @@ describe('Multicomponent image display controls', () => { '[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)); diff --git a/tests/specs/segment-group-download.e2e.ts b/tests/specs/segment-group-download.e2e.ts index 168292185..bdcf7f8d2 100644 --- a/tests/specs/segment-group-download.e2e.ts +++ b/tests/specs/segment-group-download.e2e.ts @@ -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; @@ -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(); @@ -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' + ); }); }); diff --git a/tests/specs/select-annotation-at-press.e2e.ts b/tests/specs/select-annotation-at-press.e2e.ts index a711f9b3f..4732f9d5f 100644 --- a/tests/specs/select-annotation-at-press.e2e.ts +++ b/tests/specs/select-annotation-at-press.e2e.ts @@ -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) => { @@ -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); @@ -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', () => { @@ -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(); @@ -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(', ')}` } ); }); } diff --git a/tests/specs/windowing-config.e2e.ts b/tests/specs/windowing-config.e2e.ts index a7393e168..01a18669f 100644 --- a/tests/specs/windowing-config.e2e.ts +++ b/tests/specs/windowing-config.e2e.ts @@ -3,6 +3,14 @@ import { openUrls } from './utils'; import { openConfigAndDataset } from './configTestUtils'; import { ONE_CT_SLICE_DICOM, MINIMAL_DICOM } from '../datasets'; +const expectWindowLevel = async (width: number, level: number) => { + const view = $('div[data-testid="vtk-view vtk-two-view"]'); + await volViewPage.waitForLoadingIndicator(view); + await expect(view.$('.view-annotations')).toHaveText( + expect.stringContaining(`W/L: ${width.toFixed(2)} / ${level.toFixed(2)}`) + ); +}; + describe('VolView windowing configuration', () => { it('should use runtime config window level over DICOM window level', async () => { const runtimeWindowLevel = { @@ -15,61 +23,16 @@ describe('VolView windowing configuration', () => { }; await openConfigAndDataset(config, 'windowing'); - - const view = await $('div[data-testid="vtk-view vtk-two-view"]'); - - await volViewPage.waitForLoadingIndicator(view); - - const viewAnnotations = await view.$('.view-annotations'); - const wlText = await viewAnnotations.getText(); - - const match = wlText.match(/W\/L:\s*([\d.]+)\s*\/\s*([\d.]+)/); - expect(match).not.toBeNull(); - - const displayedWidth = parseFloat(match![1]); - const displayedLevel = parseFloat(match![2]); - - expect(displayedWidth).toBe(runtimeWindowLevel.width); - expect(displayedLevel).toBe(runtimeWindowLevel.level); + await expectWindowLevel(runtimeWindowLevel.width, runtimeWindowLevel.level); }); it('should use DICOM window level when no runtime config is provided', async () => { await openUrls([ONE_CT_SLICE_DICOM]); - - const view = await $('div[data-testid="vtk-view vtk-two-view"]'); - - await volViewPage.waitForLoadingIndicator(view); - - const viewAnnotations = await view.$('.view-annotations'); - const wlText = await viewAnnotations.getText(); - - const match = wlText.match(/W\/L:\s*([\d.]+)\s*\/\s*([\d.]+)/); - expect(match).not.toBeNull(); - - const displayedWidth = parseFloat(match![1]); - const displayedLevel = parseFloat(match![2]); - - expect(displayedWidth).toBe(410); - expect(displayedLevel).toBe(70); + await expectWindowLevel(410, 70); }); it('should use auto windowing for DICOM without window/level metadata', async () => { await openUrls([MINIMAL_DICOM]); - - const view = await $('div[data-testid="vtk-view vtk-two-view"]'); - - await volViewPage.waitForLoadingIndicator(view); - - const viewAnnotations = await view.$('.view-annotations'); - const wlText = await viewAnnotations.getText(); - - const match = wlText.match(/W\/L:\s*([\d.]+)\s*\/\s*([\d.]+)/); - expect(match).not.toBeNull(); - - const displayedWidth = parseFloat(match![1]); - const displayedLevel = parseFloat(match![2]); - - expect(displayedWidth).toBe(1900); - expect(displayedLevel).toBe(1050); + await expectWindowLevel(1900, 1050); }); }); diff --git a/wdio.chrome.conf.ts b/wdio.chrome.conf.ts index 0a3408c87..0b98d3bfe 100644 --- a/wdio.chrome.conf.ts +++ b/wdio.chrome.conf.ts @@ -15,15 +15,12 @@ if (IS_CI) { } if (IS_LINUX_CI) { - chromeArgs.push( - '--headless=new', - '--use-gl=angle', - '--use-angle=swiftshader-webgl' - ); + chromeArgs.push('--use-gl=angle', '--use-angle=swiftshader-webgl'); } -if (!IS_CI && !process.env.HEADED) { - chromeArgs.push('--headless=new', '--enable-unsafe-swiftshader'); +if (IS_CI || !process.env.HEADED) { + chromeArgs.push('--headless=new'); + if (!IS_CI) chromeArgs.push('--enable-unsafe-swiftshader'); } export const config = { diff --git a/wdio.shared.conf.ts b/wdio.shared.conf.ts index e042de090..921cf2bff 100644 --- a/wdio.shared.conf.ts +++ b/wdio.shared.conf.ts @@ -30,6 +30,23 @@ export const TEMP_DIR = path.resolve(ROOT, TMP, 'runs', String(TEST_PORT)); const FIXTURES_DIR = 'tests/fixtures/'; export const FIXTURES = path.resolve(ROOT, FIXTURES_DIR); +let failureScreenshotCount = 0; + +export async function captureFailureScreenshot(title: string) { + try { + const name = title.replace(/[^a-zA-Z0-9.-]/g, '_').slice(0, 150); + failureScreenshotCount += 1; + await browser.saveScreenshot( + path.join( + TEMP_DIR, + `failure-${process.pid}-${failureScreenshotCount}-${name}.png` + ) + ); + } catch (error) { + console.warn('Could not capture the failure screenshot:', error); + } +} + // The static server and the browser's download directory both point into // TEMP_DIR, and Windows refuses to unlink a file while a handle is open, so give // their teardown a moment to catch up. @@ -152,7 +169,15 @@ export const config: Options.Testrunner = { 'cleanuptotal', ], framework: 'mocha', - reporters: ['spec', 'html-nice'], + reporters: [ + 'spec', + [ + 'html-nice', + { + outputDir: `${path.relative(ROOT, path.join(TEMP_DIR, 'reports'))}${path.sep}`, + }, + ], + ], mochaOpts: { ui: 'bdd', timeout: 90_000, @@ -200,6 +225,20 @@ export const config: Options.Testrunner = { await applyTestViewport(browser); }, + async afterTest(test, _context, { error }) { + if (error) { + await captureFailureScreenshot(`test-${test.parent}-${test.title}`); + } + }, + + async afterHook(hook, _context, { error }, hookName) { + if (error) { + await captureFailureScreenshot( + `hook-${hookName}-${hook.parent}-${hook.title}` + ); + } + }, + async afterCommand(commandName: string) { // After navigation, inject console interceptor to stringify errors if (commandName === 'navigateTo' || commandName === 'url') { From 1db394f95397a7c6ec83179722407b250b318b99 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 25 Oct 2026 16:36:09 -0400 Subject: [PATCH 2/3] test(e2e): wait for layout menus to finish closing --- tests/specs/slice-display-lifecycle.e2e.ts | 8 ++++---- tests/specs/utils.ts | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/specs/slice-display-lifecycle.e2e.ts b/tests/specs/slice-display-lifecycle.e2e.ts index c8ed3a8f8..87a7ec2b3 100644 --- a/tests/specs/slice-display-lifecycle.e2e.ts +++ b/tests/specs/slice-display-lifecycle.e2e.ts @@ -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); } diff --git a/tests/specs/utils.ts b/tests/specs/utils.ts index d4b040fed..eb52fd113 100644 --- a/tests/specs/utils.ts +++ b/tests/specs/utils.ts @@ -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); } From c4fe6277edd5017f2a55ecfc500b263a6fd34dbc Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 26 Oct 2026 10:04:12 -0400 Subject: [PATCH 3/3] test(e2e): keep opacity checks focused on rendering --- tests/specs/multicomponent-layer-display.e2e.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/specs/multicomponent-layer-display.e2e.ts b/tests/specs/multicomponent-layer-display.e2e.ts index f1db105f4..acfb9f43c 100644 --- a/tests/specs/multicomponent-layer-display.e2e.ts +++ b/tests/specs/multicomponent-layer-display.e2e.ts @@ -236,12 +236,6 @@ describe('Multicomponent image display controls', () => { await browser.keys(Array.from({ length: 5 }, () => 'PageDown')); await expect(opacity).toHaveAttribute('aria-valuenow', '0.5'); await expectImagePixels(colors(0.5)); - if (!rgba && type === 'unsigned char') { - await browser.keys('ArrowLeft'); - await expect(opacity).toHaveAttribute('aria-valuenow', '0.49'); - await browser.keys('ArrowRight'); - await expect(opacity).toHaveAttribute('aria-valuenow', '0.5'); - } await browser.keys('Home'); await expect(opacity).toHaveAttribute('aria-valuenow', '0'); await expectImagePixels(