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
28 changes: 28 additions & 0 deletions .github/workflows/e2e-datasets.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# A cache saved by a pull request run is only visible to that pull request.
# One saved on main is visible to all of them, so this keeps it there.
name: E2E dataset cache
on:
push:
branches:
- main
workflow_dispatch:

jobs:
warm:
runs-on: ubuntu-latest
name: Warm the dataset cache
steps:
- uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 22
# Keep in step with the cache step in e2e.yml.
- name: Cache test datasets
uses: actions/cache@v4
with:
path: .tmp/datasets
key: e2e-datasets-${{ hashFiles('tests/datasets.ts') }}
restore-keys: e2e-datasets-
- name: Download missing datasets
run: node tests/downloadDatasets.ts
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ jobs:
run: npm ci
env:
DETECT_CHROMEDRIVER_VERSION: true
- name: Cache test datasets
uses: actions/cache@v4
with:
path: .tmp/datasets
key: e2e-datasets-${{ hashFiles('tests/datasets.ts') }}
restore-keys: e2e-datasets-
- name: Install xvfb (linux)
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get install xvfb
Expand Down
53 changes: 53 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,38 @@ const featureBoundaries = (features) => {
];
};

// Files that still pass their own time limits. Remove a file once it is
// clean; never add one.
const E2E_TIMEOUT_DEBT = [
'tests/pageobjects/volview.page.ts',
'tests/specs/annotationTestUtils.ts',
'tests/specs/automatic-layering.e2e.ts',
'tests/specs/bug-report.e2e.ts',
'tests/specs/cine-drag-onto-slot.e2e.ts',
'tests/specs/cine-rendering.e2e.ts',
'tests/specs/cine-ruler-session.e2e.ts',
'tests/specs/crosshairs-multi-image.e2e.ts',
'tests/specs/delete-selected-annotation.e2e.ts',
'tests/specs/dicom-dimension-mismatch.e2e.ts',
'tests/specs/different-direction-labelmap.e2e.ts',
'tests/specs/imageCacheUtils.ts',
'tests/specs/layer-source-lifetime.e2e.ts',
'tests/specs/layers-and-rendering.e2e.ts',
'tests/specs/multi-series-load.e2e.ts',
'tests/specs/paint-tool-rendering.e2e.ts',
'tests/specs/polygon-nested-interaction.e2e.ts',
'tests/specs/sample-rendering.e2e.ts',
'tests/specs/save-large-labelmap.e2e.ts',
'tests/specs/seg-nrrd-export.e2e.ts',
'tests/specs/select-annotation-at-press.e2e.ts',
'tests/specs/select-tool-annotation-press.e2e.ts',
'tests/specs/session-large-uri-base.e2e.ts',
'tests/specs/sparse-manifest-prostate-rectangle.e2e.ts',
'tests/specs/sparse-manifest.e2e.ts',
'tests/specs/ultrasound-spacing.e2e.ts',
'tests/specs/vtk-interactor-lifecycle.e2e.ts',
];

export default tseslint.config(
{
ignores: [
Expand Down Expand Up @@ -142,6 +174,27 @@ export default tseslint.config(
'class-methods-use-this': 'off',
},
},
// A wait with its own time limit stands in for a state the app should
// publish, and fails on a slow machine. Wait on the state instead.
{
files: ['**/tests/specs/**/*.ts', '**/tests/pageobjects/**/*.ts'],
ignores: E2E_TIMEOUT_DEBT,
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property[key.name='timeout']",
message:
'Wait for a state the page publishes rather than passing a timeout.',
},
{
selector:
"CallExpression[callee.object.name='browser'][callee.property.name='pause']",
message: 'Wait for a state the page publishes rather than pausing.',
},
],
},
},
{
files: ['**/__tests__/*.{js,ts}', '**/tests/unit/**/*.spec.{js,ts}'],
languageOptions: {
Expand Down
8 changes: 0 additions & 8 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,6 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>VolView</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css"
/>
</head>
<body>
<noscript>
Expand Down
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@
"@commitlint/cli": "^21.0.0",
"@commitlint/config-conventional": "^21.0.0",
"@eslint/js": "^9.39.1",
"@fontsource/roboto": "^5.3.0",
"@itk-wasm/dicom": "^7.6.4",
"@itk-wasm/image-io": "1.6.1",
"@itk-wasm/morphological-contour-interpolation": "2.0.0",
"@kitware/vtk.js": "^37.0.4",
"@mdi/font": "^7.4.47",
"@netlify/edge-functions": "^3.0.2",
"@rollup/plugin-replace": "^6.0.3",
"@sentry/vite-plugin": "^4.6.1",
Expand Down
7 changes: 7 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import '@fontsource/roboto/100.css';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import '@fontsource/roboto/900.css';
import '@mdi/font/css/materialdesignicons.min.css';
import 'vue-toastification/dist/index.css';
import 'vuetify/lib/styles/main.css';
import '@/src/global.css';
Expand Down
105 changes: 105 additions & 0 deletions tests/datasets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Every file the suite fetches from the internet. The runner downloads them
// into the dataset cache before any spec starts, and specs read them from the
// local server at `/tmp/<name>`. CI keys its dataset cache on this file.
//
// The files are assets of one GitHub release. An asset can be replaced after
// it is published, so each download is checked against its hash. To change or
// add a file, publish a new release and point DATASET_RELEASE at it.
export const DATASET_RELEASE =
'https://github.com/Kitware/VolView/releases/download/test-data-1';

// `origin` is where the file was first published.
const DATASETS = {
ONE_CT_SLICE_DICOM: {
name: 'CT000085.dcm',
origin:
'https://data.kitware.com/api/v1/file/6566aa81c5a2b36857ad1783/download',
sha256: '8c9c3477a3bcf54477f7705c9f60ec2473699b8ac93a11f00332873c1dde6b9b',
},
MINIMAL_DICOM: {
name: 'minimal.dcm',
origin:
'https://data.kitware.com/api/v1/file/68e9807dbf0f869935e36481/download',
sha256: 'c0d05d47b16cbc2e1a913c5b1dc1b5c982eb8499f7674c2e5933a81d5ab878a1',
},
MINIMAL_501_SESSION: {
name: 'minimal-501-session.volview.zip',
origin:
'https://data.kitware.com/api/v1/file/693077d880eaefe49a4abb88/download',
sha256: 'e2dded0c7bcabae6611e4bcc7e0170e2902640624de60043aa6d363630844d7d',
},
PROSTATEX_DATASET: {
name: 'prostate.zip',
origin:
'https://data.kitware.com/api/v1/item/63527c7311dab8142820a338/download',
sha256: '2db4738cc063f80eaaeffd52b83271dca1e3543518a54240c01e5ba2d5629dd1',
},
PROSTATE_SEGMENT_GROUP: {
name: 'prostate-total.seg.nii.gz',
origin:
'https://data.kitware.com/api/v1/file/692f13ed80eaefe49a4abb72/download',
sha256: '30704eab70795d242f529c63c467867f7ac5256a8bf50af28a272bc6c866f394',
},
MRA_HEAD_NECK_DATASET: {
name: 'MRA-Head_and_Neck.zip',
origin:
'https://data.kitware.com/api/v1/item/6352a2b311dab8142820a33b/download',
sha256: 'd1d692f449e94294f1204040c43aa313b84fb4d6be9eb4c8558f78435126f875',
},
FETUS_DATASET: {
name: 'fetus.zip',
origin:
'https://data.kitware.com/api/v1/item/635679c311dab8142820a4f4/download',
sha256: '3cf89e3d24dfaaea61b006e797df6976a8b2e94a63637e34e16c3608d6e19464',
},
// Multiframe ultrasound DICOM from pydicom public test data.
// SequenceOfUltrasoundRegions: PhysicalDeltaX/Y = 0.05104970559 cm/pixel
// (unit code 3 = cm), so with US spacing fix the VTK spacing is ~0.5105 mm.
US_MULTIFRAME_DICOM: {
name: 'US_multiframe_30frames.dcm',
origin:
'https://data.kitware.com/api/v1/file/69e1630646ef98a20f563020/download',
sha256: '6fa3a087d3c631b43216a8abec8aac8d2d73751c5bf5885708d1150b09283f72',
},
// 8-frame echocardiogram from the BSD-licensed GDCM test corpus. Native
// Explicit VR LE, MONOCHROME2, retired Ultrasound Multi-frame SOP UID.
CINE_US_DATASET: {
name: 'US-MONO2-8-8x-echo.dcm',
origin:
'https://sourceforge.net/p/gdcm/gdcmdata/ci/master/tree/US-MONO2-8-8x-execho.dcm?format=raw',
sha256: '7d3f54806d0315c6cfc8b7371649a242b5ef8f31e0d20221971dd8087f2ff1ea',
},
// 120-frame RGB ultrasound cine encoded as JPEG Baseline. Exercises the async
// browser JPEG decode path used by common compressed ultrasound clips.
COLOR3D_JPEG_BASELINE_DICOM: {
name: 'color3d_jpeg_baseline.dcm',
origin:
'https://raw.githubusercontent.com/pydicom/pydicom-data/master/data_store/data/color3d_jpeg_baseline.dcm',
sha256: 'c8798b8abf8ae0a18e8c9952e7c7f75f3cc8465234b1b63f9e3ba3bebb9d5625',
},
CT_ELECTRODES: {
name: 'CT_Electrodes.nii.gz',
origin:
'https://raw.githubusercontent.com/neurolabusc/niivue-images/main/CT_Electrodes.nii.gz',
sha256: '0566942708ff451458d0fdb1f61c4611187bb5cc0efa516c8500f1ff3c142e57',
},
} as const;

export const {
ONE_CT_SLICE_DICOM,
MINIMAL_DICOM,
MINIMAL_501_SESSION,
PROSTATEX_DATASET,
PROSTATE_SEGMENT_GROUP,
MRA_HEAD_NECK_DATASET,
FETUS_DATASET,
US_MULTIFRAME_DICOM,
CINE_US_DATASET,
COLOR3D_JPEG_BASELINE_DICOM,
CT_ELECTRODES,
} = DATASETS;

// Everything declared above is downloaded.
export const TEST_DATASETS = Object.values(DATASETS);

export type TestDataset = (typeof TEST_DATASETS)[number];
64 changes: 64 additions & 0 deletions tests/downloadDatasets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import {
DATASET_RELEASE,
TEST_DATASETS,
type TestDataset,
} from './datasets.ts';
import { projectRoot } from './e2eTestUtils.ts';

// Node built-ins only, so CI can warm the dataset cache by running this file
// with node, without installing dependencies.

// Kept across runs, and across CI runs by the workflow cache.
export const DATASET_CACHE = path.resolve(projectRoot(), '.tmp/datasets');

const sha256 = (data: Buffer) =>
createHash('sha256').update(data).digest('hex');

async function fetchDataset(dataset: TestDataset) {
const url = `${DATASET_RELEASE}/${dataset.name}`;
const fail = (reason: string) =>
new Error(
`Could not download test dataset ${dataset.name} from ${url}: ${reason}`
);

const response = await fetch(url).catch((err: Error) => {
throw fail(err.message);
});
if (!response.ok) throw fail(`HTTP ${response.status}`);

const data = Buffer.from(await response.arrayBuffer());
const actual = sha256(data);
if (actual !== dataset.sha256) {
throw fail(`sha256 is ${actual}, expected ${dataset.sha256}`);
}
return data;
}

/**
* The only place the suite reaches the internet. One attempt per file: a slow
* or missing host stops here and never shows up as a failing spec.
*/
export async function downloadDatasets() {
fs.mkdirSync(DATASET_CACHE, { recursive: true });
await Promise.all(
TEST_DATASETS.map(async (dataset) => {
const savePath = path.join(DATASET_CACHE, dataset.name);
// A cached file is only good while it is the file this checkout names.
const cached =
fs.existsSync(savePath) &&
sha256(fs.readFileSync(savePath)) === dataset.sha256;
if (cached) return;
// Rename into place so an interrupted write never looks cached.
fs.writeFileSync(`${savePath}.part`, await fetchDataset(dataset));
fs.renameSync(`${savePath}.part`, savePath);
})
);
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
await downloadDatasets();
}
2 changes: 1 addition & 1 deletion tests/fixtures/layer-axial.5-0-1.volview.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion tests/fixtures/pre-multi-4up.5-0-1.volview.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"id": 2,
"type": "uri",
"name": "MRI-PROSTATEx-0004.zip",
"uri": "https://data.kitware.com/api/v1/item/63527c7311dab8142820a338/download"
"uri": "/tmp/prostate.zip"
},
{
"id": 3,
Expand Down
Loading
Loading