From 529e2122e616d6248d9d4a2b5d4ccc78a6ecfb2e Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 26 Mar 2026 19:49:56 -0400 Subject: [PATCH 1/3] fix: avoid Array.from OOM when saving large labelmaps VTK.js DataArray.getState() calls Array.from() on typed arrays, which OOMs for images >~180M voxels. Temporarily swap data arrays with empty before getState(), then inject the original TypedArrays into the state. Structured clone handles TypedArrays efficiently for worker transfer. Also surface save errors as user-visible notifications (SaveSession.vue had try/finally but no catch). --- src/components/SaveSession.vue | 6 + src/io/vtk/async.ts | 35 +++++- tests/specs/save-large-labelmap.e2e.ts | 165 +++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/specs/save-large-labelmap.e2e.ts diff --git a/src/components/SaveSession.vue b/src/components/SaveSession.vue index 291933cc9..da2bc91e5 100644 --- a/src/components/SaveSession.vue +++ b/src/components/SaveSession.vue @@ -36,6 +36,7 @@ import { saveAs } from 'file-saver'; import { onKeyDown } from '@vueuse/core'; import { serialize } from '../io/state-file/serialize'; +import { useMessageStore } from '../store/messages'; const DEFAULT_FILENAME = 'session.volview.zip'; @@ -58,6 +59,11 @@ export default defineComponent({ const blob = await serialize(); saveAs(blob, fileName.value); props.close(); + } catch (err) { + const messageStore = useMessageStore(); + messageStore.addError('Failed to save session', { + error: err instanceof Error ? err : new Error(String(err)), + }); } finally { saving.value = false; } diff --git a/src/io/vtk/async.ts b/src/io/vtk/async.ts index 9e21e2db6..0f95fc645 100644 --- a/src/io/vtk/async.ts +++ b/src/io/vtk/async.ts @@ -5,6 +5,39 @@ import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet'; import { vtkObject } from '@kitware/vtk.js/interfaces'; import { StateObject } from './common'; +// VTK.js DataArray.getState() calls Array.from() on typed arrays, +// which OOMs for large images (>~180M voxels). This helper temporarily +// swaps each array's data with empty before getState(), then injects +// the original TypedArrays into the resulting state. Structured clone +// (postMessage) handles TypedArrays efficiently, and vtk() +// reconstruction accepts them in DataArray.extend(). +const getStateWithTypedArrays = (dataSet: vtkDataSet) => { + const pointData = (dataSet as any).getPointData?.(); + const arrays: any[] = pointData?.getArrays?.() ?? []; + + const typedArrays = arrays.map((arr: any) => arr.getData()); + + // Swap to empty so Array.from runs on [] instead of huge TypedArray + arrays.forEach((arr: any) => arr.setData(new Uint8Array(0))); + + let state: any; + try { + state = dataSet.getState(); + } finally { + arrays.forEach((arr: any, i: number) => arr.setData(typedArrays[i])); + } + + // Inject original TypedArrays into the serialized state + state?.pointData?.arrays?.forEach((entry: any, i: number) => { + if (entry?.data) { + entry.data.values = typedArrays[i]; + entry.data.size = typedArrays[i].length; + } + }); + + return state; +}; + interface SuccessReadResult { status: 'success'; obj: StateObject; @@ -52,7 +85,7 @@ export const runAsyncVTKWriter = ); const worker = new PromiseWorker(asyncWorker); const result = (await worker.postMessage({ - obj: dataSet.getState(), + obj: getStateWithTypedArrays(dataSet), writerName, })) as WriteResult; asyncWorker.terminate(); diff --git a/tests/specs/save-large-labelmap.e2e.ts b/tests/specs/save-large-labelmap.e2e.ts new file mode 100644 index 000000000..6bd354da6 --- /dev/null +++ b/tests/specs/save-large-labelmap.e2e.ts @@ -0,0 +1,165 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; +import { volViewPage } from '../pageobjects/volview.page'; +import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; +import { writeManifestToFile } from './utils'; + +// 268M voxels — labelmap at this size triggers Array.from OOM +const DIM_X = 1024; +const DIM_Y = 1024; +const DIM_Z = 256; + +const writeBufferToFile = async (data: Buffer, fileName: string) => { + const filePath = path.join(TEMP_DIR, fileName); + await fs.promises.writeFile(filePath, data); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + return filePath; +}; + +// UInt8 base image — small compressed size, fast to load +const createUint8NiftiGz = () => { + const header = Buffer.alloc(352); + header.writeInt32LE(348, 0); + header.writeInt16LE(3, 40); + header.writeInt16LE(DIM_X, 42); + header.writeInt16LE(DIM_Y, 44); + header.writeInt16LE(DIM_Z, 46); + header.writeInt16LE(1, 48); + header.writeInt16LE(1, 50); + header.writeInt16LE(1, 52); + header.writeInt16LE(2, 70); // datatype: UINT8 + header.writeInt16LE(8, 72); // bitpix + header.writeFloatLE(1, 76); + header.writeFloatLE(1, 80); + header.writeFloatLE(1, 84); + header.writeFloatLE(1, 88); + header.writeFloatLE(352, 108); + header.writeFloatLE(1, 112); + header.writeInt16LE(1, 254); + header.writeFloatLE(1, 280); + header.writeFloatLE(0, 284); + header.writeFloatLE(0, 288); + header.writeFloatLE(0, 292); + header.writeFloatLE(0, 296); + header.writeFloatLE(1, 300); + header.writeFloatLE(0, 304); + header.writeFloatLE(0, 308); + header.writeFloatLE(0, 312); + header.writeFloatLE(0, 316); + header.writeFloatLE(1, 320); + header.writeFloatLE(0, 324); + header.write('n+1\0', 344, 'binary'); + + const imageData = Buffer.alloc(DIM_X * DIM_Y * DIM_Z); + return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 }); +}; + +const waitForFileExists = (filePath: string, timeout: number) => + new Promise((resolve, reject) => { + const dir = path.dirname(filePath); + const basename = path.basename(filePath); + + const watcher = fs.watch(dir, (eventType, filename) => { + if (eventType === 'rename' && filename === basename) { + clearTimeout(timerId); + watcher.close(); + resolve(); + } + }); + + const timerId = setTimeout(() => { + watcher.close(); + reject( + new Error(`File ${filePath} not created within ${timeout}ms timeout`) + ); + }, timeout); + + fs.access(filePath, fs.constants.R_OK, (err) => { + if (!err) { + clearTimeout(timerId); + watcher.close(); + resolve(); + } + }); + }); + +describe('Save large labelmap', function () { + this.timeout(180_000); + + it('saves session without error when labelmap exceeds 200M voxels', async () => { + const prefix = `save-large-${Date.now()}`; + const baseFileName = `${prefix}-u8.nii.gz`; + + await writeBufferToFile(createUint8NiftiGz(), baseFileName); + + const manifest = { resources: [{ url: `/tmp/${baseFileName}` }] }; + const manifestFileName = `${prefix}-manifest.json`; + await writeManifestToFile(manifest, manifestFileName); + + await volViewPage.open(`?urls=[tmp/${manifestFileName}]`); + await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); + + // Activate paint tool — creates a segment group + await volViewPage.activatePaint(); + + // Paint a stroke to allocate the labelmap + const views2D = await volViewPage.getViews2D(); + const canvas = await views2D[0].$('canvas'); + const location = await canvas.getLocation(); + const size = await canvas.getSize(); + const cx = Math.round(location.x + size.width / 2); + const cy = Math.round(location.y + size.height / 2); + + await browser + .action('pointer') + .move({ x: cx, y: cy }) + .down() + .move({ x: cx + 20, y: cy }) + .up() + .perform(); + + const notificationsBefore = await volViewPage.getNotificationsCount(); + + // Save session — before fix, this throws RangeError: Invalid array length + const sessionFileName = await volViewPage.saveSession(); + const downloadedPath = path.join(TEMP_DIR, sessionFileName); + + // Wait for either the file to appear (success) or notification (error) + const saveResult = await Promise.race([ + waitForFileExists(downloadedPath, 90_000).then(() => 'saved' as const), + browser + .waitUntil( + async () => { + const count = await volViewPage.getNotificationsCount(); + return count > notificationsBefore; + }, + { timeout: 90_000, interval: 1000 } + ) + .then(() => 'error' as const), + ]); + + if (saveResult === 'error') { + const errorDetails = await browser.execute(() => { + const app = document.querySelector('#app') as any; + const pinia = app?.__vue_app__?.config?.globalProperties?.$pinia; + if (!pinia) return 'no pinia'; + const store = pinia.state.value.message; + if (!store) return 'no message store'; + return store.msgList + .map((id: string) => { + const msg = store.byID[id]; + return `[${msg.type}] ${msg.title}: ${msg.options?.details?.slice(0, 300)}`; + }) + .join('\n'); + }); + throw new Error(`Save error:\n${errorDetails}`); + } + + const stat = fs.statSync(downloadedPath); + expect(stat.size).toBeGreaterThan(0); + }); +}); From 75fa6c9deee7460544fccef395892548304ead29 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 26 Mar 2026 20:14:33 -0400 Subject: [PATCH 2/3] fix: reset ITK-wasm worker before loading labelmaps in session restore The singleton ITK-wasm worker accumulates WASM heap across readImage calls. When loading a session with a large base image (~361MB) followed by a labelmap (~180MB), the heap can exceed 2GB. Emscripten's ccall returns pointers as signed i32, so pointers >2^31 wrap negative, causing RangeError. Reset the worker before deserializing segment groups to give each labelmap read a fresh WASM heap, preventing the overflow. --- src/io/import/processors/restoreStateFile.ts | 7 + src/io/itk/worker.ts | 8 + tests/specs/session-large-uri-base.e2e.ts | 169 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 tests/specs/session-large-uri-base.e2e.ts diff --git a/src/io/import/processors/restoreStateFile.ts b/src/io/import/processors/restoreStateFile.ts index ff332ddfb..c2c8889d9 100644 --- a/src/io/import/processors/restoreStateFile.ts +++ b/src/io/import/processors/restoreStateFile.ts @@ -20,6 +20,7 @@ import { useViewStore } from '@/src/store/views'; import { useViewConfigStore } from '@/src/store/view-configs'; import { migrateManifest } from '@/src/io/state-file/migrations'; import { useMessageStore } from '@/src/store/messages'; +import { resetWorker } from '@/src/io/itk/worker'; type LeafSource = | { type: 'uri'; uri: string; name: string; mime?: string } @@ -136,6 +137,12 @@ export async function completeStateFileRestore( useViewConfigStore().deserializeAll(manifest, stateIDToStoreID); + // Reset the ITK-wasm worker to free accumulated WASM heap from loading + // base images. Without this, loading large labelmaps on the same worker + // can push the heap past 2GB, causing signed pointer overflow in + // Emscripten's ccall (pointers > 2^31 wrap negative). + await resetWorker(); + const segmentGroupIDMap = await useSegmentGroupStore().deserialize( manifest, stateFiles, diff --git a/src/io/itk/worker.ts b/src/io/itk/worker.ts index 8db39de8a..098fb7c5b 100644 --- a/src/io/itk/worker.ts +++ b/src/io/itk/worker.ts @@ -33,6 +33,14 @@ export function getWorker() { return webWorker; } +export async function resetWorker() { + if (webWorker) { + webWorker.terminate(); + webWorker = null; + } + await ensureWorker(); +} + export function getDicomSeriesWorkerPool() { return readDicomSeriesWorkerPool; } diff --git a/tests/specs/session-large-uri-base.e2e.ts b/tests/specs/session-large-uri-base.e2e.ts new file mode 100644 index 000000000..ac08d19a2 --- /dev/null +++ b/tests/specs/session-large-uri-base.e2e.ts @@ -0,0 +1,169 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as zlib from 'node:zlib'; +import { cleanuptotal } from 'wdio-cleanuptotal-service'; +import { volViewPage } from '../pageobjects/volview.page'; +import { writeManifestToFile } from './utils'; +import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; + +const writeBufferToFile = async (data: Buffer, fileName: string) => { + const filePath = path.join(TEMP_DIR, fileName); + await fs.promises.writeFile(filePath, data); + cleanuptotal.addCleanup(async () => { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + }); + return filePath; +}; + +const createNiftiGz = ( + dimX: number, + dimY: number, + dimZ: number, + datatype: number, + bitpix: number +) => { + const bytesPerVoxel = bitpix / 8; + const header = Buffer.alloc(352); + + header.writeInt32LE(348, 0); + header.writeInt16LE(3, 40); + header.writeInt16LE(dimX, 42); + header.writeInt16LE(dimY, 44); + header.writeInt16LE(dimZ, 46); + header.writeInt16LE(1, 48); + header.writeInt16LE(1, 50); + header.writeInt16LE(1, 52); + header.writeInt16LE(datatype, 70); + header.writeInt16LE(bitpix, 72); + header.writeFloatLE(1, 76); + header.writeFloatLE(1, 80); + header.writeFloatLE(1, 84); + header.writeFloatLE(1, 88); + header.writeFloatLE(352, 108); + header.writeFloatLE(1, 112); + header.writeInt16LE(1, 254); + header.writeFloatLE(1, 280); + header.writeFloatLE(0, 284); + header.writeFloatLE(0, 288); + header.writeFloatLE(0, 292); + header.writeFloatLE(0, 296); + header.writeFloatLE(1, 300); + header.writeFloatLE(0, 304); + header.writeFloatLE(0, 308); + header.writeFloatLE(0, 312); + header.writeFloatLE(0, 316); + header.writeFloatLE(1, 320); + header.writeFloatLE(0, 324); + header.write('n+1\0', 344, 'binary'); + + const imageData = Buffer.alloc(dimX * dimY * dimZ * bytesPerVoxel); + return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 }); +}; + +/** + * Regression test for session restore with large base image + labelmap. + * + * Scenario: Int16 1024×1024×172 base image loaded via URI, with a UInt8 + * labelmap of the same dimensions. On the shared ITK-wasm worker, base + * image processing grew the WASM heap to ~1721MB. Loading the labelmap + * on the same worker pushed it past 2GB, causing signed pointer overflow + * in Emscripten's ccall (output pointers > 2^31 wrap negative → + * RangeError: Start offset -N is outside the bounds). + * + * Fix: reset the ITK-wasm worker before deserializing labelmaps to + * prevent heap accumulation. + */ +describe('Session with large URI base and labelmap', function () { + this.timeout(180_000); + + it('loads large base image with labelmap without errors', async () => { + const prefix = `session-large-${Date.now()}`; + const baseFileName = `${prefix}-base-i16.nii.gz`; + const labelmapFileName = `${prefix}-labelmap-u8.nii.gz`; + + // Int16 1024×1024×172 = 361MB raw + await writeBufferToFile( + createNiftiGz(1024, 1024, 172, 4, 16), + baseFileName + ); + // UInt8 labelmap same dimensions = 180MB raw + await writeBufferToFile( + createNiftiGz(1024, 1024, 172, 2, 8), + labelmapFileName + ); + + const manifest = { + version: '6.1.0', + dataSources: [ + { id: 0, type: 'uri', uri: `/tmp/${baseFileName}` }, + { id: 1, type: 'uri', uri: `/tmp/${labelmapFileName}` }, + ], + labelMaps: [ + { + id: 'seg-1', + dataSourceId: 1, + metadata: { + name: 'Annotation', + parentImage: '0', + segments: { + order: [1], + byValue: { + '1': { + value: 1, + name: 'Label 1', + color: [255, 0, 0, 255], + visible: true, + }, + }, + }, + }, + }, + ], + }; + + const manifestFileName = `${prefix}-manifest.volview.json`; + await writeManifestToFile(manifest, manifestFileName); + + const rangeErrors: string[] = []; + const onLogEntry = (logEntry: { text: string | null }) => { + const text = logEntry.text ?? ''; + if (text.includes('RangeError')) { + rangeErrors.push(text); + } + }; + browser.on('log.entryAdded', onLogEntry); + + try { + await volViewPage.open(`?urls=[tmp/${manifestFileName}]`); + await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); + + expect(rangeErrors).toEqual([]); + + const notifications = await volViewPage.getNotificationsCount(); + expect(notifications).toEqual(0); + + // Verify segment group loaded + const annotationsTab = await $( + 'button[data-testid="module-tab-Annotations"]' + ); + await annotationsTab.click(); + + const segmentGroupsTab = await $('button.v-tab*=Segment Groups'); + await segmentGroupsTab.waitForClickable(); + await segmentGroupsTab.click(); + + await browser.waitUntil( + async () => { + const segmentGroups = await $$('.segment-group-list .v-list-item'); + return (await segmentGroups.length) >= 1; + }, + { + timeout: DOWNLOAD_TIMEOUT, + timeoutMsg: 'Segment group not found after session restore', + } + ); + } finally { + browser.off('log.entryAdded', onLogEntry); + } + }); +}); From 5935437690a4e14e8418965fc94a6444fbd1332d Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 27 Mar 2026 10:13:34 -0400 Subject: [PATCH 3/3] test(e2e): reproduce WASM RangeError for large session restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite session-large-uri-base test to actually trigger the signed pointer overflow bug. Uses a ZIP-based session with a Float32 1024×1024×256 base image (1GB raw, pushes WASM heap past 2GB) and an embedded .nii.gz labelmap referenced via `path` in segmentGroups. Key changes: - ZIP session format with `path`-based labelmap triggers readImage() on the shared ITK-wasm worker (the old dataSourceId approach used cached data and never hit the bug path) - Float32 base image grows the WASM heap past 2GB so output pointers exceed 2^31 and wrap negative via Emscripten's signed i32 ccall - .nii.gz labelmap format is critical: .vti uses a separate JS reader that never touches the ITK-wasm worker - Wait for async labelmap deserialization via notification/segment-group detection (it completes after views render) Verified: fails with RangeError when resetWorker() is removed, passes when the fix is active. --- tests/specs/session-large-uri-base.e2e.ts | 148 +++++++++++++--------- 1 file changed, 87 insertions(+), 61 deletions(-) diff --git a/tests/specs/session-large-uri-base.e2e.ts b/tests/specs/session-large-uri-base.e2e.ts index ac08d19a2..65ea022fa 100644 --- a/tests/specs/session-large-uri-base.e2e.ts +++ b/tests/specs/session-large-uri-base.e2e.ts @@ -1,9 +1,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as zlib from 'node:zlib'; +import JSZip from 'jszip'; import { cleanuptotal } from 'wdio-cleanuptotal-service'; import { volViewPage } from '../pageobjects/volview.page'; -import { writeManifestToFile } from './utils'; import { DOWNLOAD_TIMEOUT, TEMP_DIR } from '../../wdio.shared.conf'; const writeBufferToFile = async (data: Buffer, fileName: string) => { @@ -60,69 +60,86 @@ const createNiftiGz = ( return zlib.gzipSync(Buffer.concat([header, imageData]), { level: 1 }); }; +const createSessionZip = async ( + baseFileName: string, + labelmapNiftiGz: Buffer +) => { + const manifest = { + version: '6.2.0', + dataSources: [ + { + id: 0, + type: 'uri', + uri: `/tmp/${baseFileName}`, + name: baseFileName, + }, + ], + datasets: [{ id: '0', dataSourceId: 0 }], + segmentGroups: [ + { + id: 'seg-1', + path: 'labels/seg-1.nii.gz', + metadata: { + name: 'Annotation', + parentImage: '0', + segments: { + order: [1], + byValue: { + '1': { + value: 1, + name: 'Label 1', + color: [255, 0, 0, 255], + visible: true, + }, + }, + }, + }, + }, + ], + }; + + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify(manifest, null, 2)); + zip.file('labels/seg-1.nii.gz', labelmapNiftiGz); + return zip.generateAsync({ type: 'nodebuffer', compression: 'STORE' }); +}; + /** - * Regression test for session restore with large base image + labelmap. + * Regression test for WASM signed pointer overflow during session restore. + * + * A .volview.zip session with a large Float32 URI-based base image and an + * embedded .nii.gz labelmap. The import pipeline loads the base image + * through the shared ITK-wasm worker, growing the WASM heap past 2GB. + * Then segmentGroupStore.deserialize() calls readImage() for the embedded + * .nii.gz labelmap on the same worker. + * + * The .nii.gz format is critical: .vti labelmaps use a separate JS + * reader and never touch the ITK-wasm worker. * - * Scenario: Int16 1024×1024×172 base image loaded via URI, with a UInt8 - * labelmap of the same dimensions. On the shared ITK-wasm worker, base - * image processing grew the WASM heap to ~1721MB. Loading the labelmap - * on the same worker pushed it past 2GB, causing signed pointer overflow - * in Emscripten's ccall (output pointers > 2^31 wrap negative → - * RangeError: Start offset -N is outside the bounds). + * Without resetting the worker, Emscripten's ccall returns output pointers + * as signed i32. When pointers exceed 2^31 they wrap negative, causing: + * RangeError: Start offset -N is outside the bounds of the buffer * - * Fix: reset the ITK-wasm worker before deserializing labelmaps to - * prevent heap accumulation. + * Fix: resetWorker() before deserializing labelmaps clears the heap. */ -describe('Session with large URI base and labelmap', function () { +describe('Session with large URI base and nii.gz labelmap', function () { this.timeout(180_000); - it('loads large base image with labelmap without errors', async () => { + it('loads session with large Float32 base and embedded nii.gz labelmap', async () => { const prefix = `session-large-${Date.now()}`; - const baseFileName = `${prefix}-base-i16.nii.gz`; - const labelmapFileName = `${prefix}-labelmap-u8.nii.gz`; + const baseFileName = `${prefix}-base-f32.nii.gz`; + const sessionFileName = `${prefix}-session.volview.zip`; - // Int16 1024×1024×172 = 361MB raw + // Float32 1024×1024×256 = 1GB raw — pushes WASM heap past 2GB await writeBufferToFile( - createNiftiGz(1024, 1024, 172, 4, 16), + createNiftiGz(1024, 1024, 256, 16, 32), baseFileName ); - // UInt8 labelmap same dimensions = 180MB raw - await writeBufferToFile( - createNiftiGz(1024, 1024, 172, 2, 8), - labelmapFileName - ); - - const manifest = { - version: '6.1.0', - dataSources: [ - { id: 0, type: 'uri', uri: `/tmp/${baseFileName}` }, - { id: 1, type: 'uri', uri: `/tmp/${labelmapFileName}` }, - ], - labelMaps: [ - { - id: 'seg-1', - dataSourceId: 1, - metadata: { - name: 'Annotation', - parentImage: '0', - segments: { - order: [1], - byValue: { - '1': { - value: 1, - name: 'Label 1', - color: [255, 0, 0, 255], - visible: true, - }, - }, - }, - }, - }, - ], - }; - const manifestFileName = `${prefix}-manifest.volview.json`; - await writeManifestToFile(manifest, manifestFileName); + // UInt8 labelmap same dimensions = 256MB raw, embedded in session ZIP + const labelmapNiftiGz = createNiftiGz(1024, 1024, 256, 2, 8); + const sessionZip = await createSessionZip(baseFileName, labelmapNiftiGz); + await writeBufferToFile(sessionZip, sessionFileName); const rangeErrors: string[] = []; const onLogEntry = (logEntry: { text: string | null }) => { @@ -134,15 +151,10 @@ describe('Session with large URI base and labelmap', function () { browser.on('log.entryAdded', onLogEntry); try { - await volViewPage.open(`?urls=[tmp/${manifestFileName}]`); + await volViewPage.open(`?urls=[tmp/${sessionFileName}]`); await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); - expect(rangeErrors).toEqual([]); - - const notifications = await volViewPage.getNotificationsCount(); - expect(notifications).toEqual(0); - - // Verify segment group loaded + // Open the segment groups panel so the list renders in the DOM const annotationsTab = await $( 'button[data-testid="module-tab-Annotations"]' ); @@ -152,16 +164,30 @@ describe('Session with large URI base and labelmap', function () { await segmentGroupsTab.waitForClickable(); await segmentGroupsTab.click(); + // Wait for the labelmap readImage to either succeed (segment group + // appears) or fail (RangeError in console OR error notification). + // The deserialization is async and finishes after views render. + const notifsBefore = await volViewPage.getNotificationsCount(); + await browser.waitUntil( async () => { + if (rangeErrors.length > 0) return true; + try { + const notifs = await volViewPage.getNotificationsCount(); + if (notifs > notifsBefore) return true; + } catch { + // badge may not exist yet + } const segmentGroups = await $$('.segment-group-list .v-list-item'); return (await segmentGroups.length) >= 1; }, { - timeout: DOWNLOAD_TIMEOUT, - timeoutMsg: 'Segment group not found after session restore', + timeout: DOWNLOAD_TIMEOUT * 3, + timeoutMsg: 'Labelmap load never completed or errored', } ); + + expect(rangeErrors).toEqual([]); } finally { browser.off('log.entryAdded', onLogEntry); }