Skip to content

Commit 9c8254a

Browse files
authored
test(vitest-browser): state-based integration suites for GenericViewport and tools (#2792)
1 parent f5b38d2 commit 9c8254a

24 files changed

Lines changed: 8919 additions & 0 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,7 @@ CLAUDE.md
7878
vtk-js/
7979
.ohif-downstream/
8080
auth.json
81+
82+
# Vitest browser mode run artifacts
83+
.vitest-attachments/
84+
tests/vitest-browser/__screenshots__/

tests/dicomImageLoaderWADOURI.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ import {
55
visitExample,
66
screenShotPaths,
77
waitForImageRendered,
8+
retryRemoteFixtures,
89
} from './utils/index';
910
import { dicomDimensions } from '../packages/dicomImageLoader/examples/dicomImageLoaderWADOURI/dicomDimensions';
1011

1112
test.beforeEach(async ({ page }) => {
13+
// Every image in this example is fetched over HTTP from
14+
// raw.githubusercontent.com, which intermittently rate-limits/drops requests
15+
// under the parallel workers on the self-hosted runner. Retry those fetches
16+
// with backoff so a single dropped response doesn't fail the image load.
17+
await retryRemoteFixtures(page);
1218
await visitExample(page, 'dicomImageLoaderWADOURI');
1319
});
1420

@@ -86,6 +92,11 @@ async function selectImageAndWaitForRender(page: Page, imagePath: string) {
8692
() => page.locator('#imageSelector').selectOption(imagePath),
8793
{
8894
expectedImageId: getExpectedWadoImageId(imagePath),
95+
// Larger budget than the 30s default: the large TG18 1k/2k images can
96+
// legitimately take a while to download+decode on the self-hosted
97+
// runner, and retryRemoteFixtures may add a few seconds of backoff on a
98+
// transient GitHub-raw failure.
99+
timeout: 60000,
89100
}
90101
);
91102

tests/genericViewport/genericDicomImageLoaderWADOURI.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createExampleUrl,
66
screenShotPaths,
77
waitForImageRendered,
8+
retryRemoteFixtures,
89
} from '../utils/index';
910
import { dicomDimensions } from '../../packages/dicomImageLoader/examples/dicomImageLoaderWADOURI/dicomDimensions';
1011

@@ -74,6 +75,11 @@ async function selectImageAndWaitForRender(page: Page, imagePath: string) {
7475
() => page.locator('#imageSelector').selectOption(imagePath),
7576
{
7677
expectedImageId: getExpectedWadoImageId(imagePath),
78+
// Larger budget than the 30s default: the large TG18 1k/2k images can
79+
// legitimately take a while to download+decode on the self-hosted
80+
// runner, and retryRemoteFixtures may add a few seconds of backoff on a
81+
// transient GitHub-raw failure.
82+
timeout: 60000,
7783
}
7884
);
7985

@@ -84,6 +90,11 @@ async function selectImageAndWaitForRender(page: Page, imagePath: string) {
8490
}
8591

8692
test.beforeEach(async ({ page }) => {
93+
// Every image in this example is fetched over HTTP from
94+
// raw.githubusercontent.com, which intermittently rate-limits/drops requests
95+
// under the parallel workers on the self-hosted runner. Retry those fetches
96+
// with backoff so a single dropped response doesn't fail the image load.
97+
await retryRemoteFixtures(page);
8798
const url = createExampleUrl(EXAMPLE + '.html');
8899
url.searchParams.set('type', 'next');
89100
await page.goto(url.toString());

tests/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export { createExampleUrl } from './createExampleUrl';
1212
export { getSegmentationActorClassNames } from './getSegmentationActorClassNames';
1313
export { expectGenericViewportRuntime } from './expectGenericViewportRuntime';
1414
export { waitForImageRendered } from './waitForImageRendered';
15+
export { retryRemoteFixtures } from './retryRemoteFixtures';
1516
export {
1617
setupRenderTracking,
1718
waitForViewportsRendered,

tests/utils/retryRemoteFixtures.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { APIResponse, Page, Route } from '@playwright/test';
2+
3+
const DEFAULT_HOSTS = [/raw\.githubusercontent\.com/];
4+
5+
interface RetryRemoteFixturesOptions {
6+
/** Total attempts (including the first) before giving up. Default 4. */
7+
attempts?: number;
8+
/** Host patterns to intercept. Default: raw.githubusercontent.com. */
9+
hosts?: RegExp[];
10+
/** Per-attempt fetch timeout in ms. Default 20000. */
11+
perAttemptTimeoutMs?: number;
12+
}
13+
14+
/**
15+
* DICOM fixtures for the loader examples are fetched over HTTP from
16+
* raw.githubusercontent.com — most images from the cornerstone3D repo, the
17+
* TG18 set from the external OHIF/viewer-testdata repo. Under the parallel
18+
* Playwright workers on the self-hosted runner, GitHub raw intermittently
19+
* rate-limits (429) or drops these requests. The loader issues a single
20+
* XMLHttpRequest per image with no retry (see
21+
* packages/dicomImageLoader/src/imageLoader/internal/xhrRequest.ts), so one
22+
* bad response fails the whole image load and surfaces as a flaky
23+
* `waitForImageRendered` timeout.
24+
*
25+
* Intercept those requests and retry them with exponential backoff from the
26+
* Node side via `route.fetch`, then replay the successful response to the
27+
* browser. Retries happen off the browser's single-shot XHR, the product code
28+
* is untouched, and the example's public URLs are left as-is so the deployed
29+
* docs demo is unaffected. Range requests are preserved because `route.fetch`
30+
* forwards the original request (headers included).
31+
*
32+
* Install in a spec's `beforeEach` BEFORE navigating to the example.
33+
*/
34+
export async function retryRemoteFixtures(
35+
page: Page,
36+
options: RetryRemoteFixturesOptions = {}
37+
): Promise<void> {
38+
const {
39+
attempts = 4,
40+
hosts = DEFAULT_HOSTS,
41+
perAttemptTimeoutMs = 20000,
42+
} = options;
43+
44+
await page.route(
45+
(url) => hosts.some((host) => host.test(url.href)),
46+
async (route: Route) => {
47+
let lastResponse: APIResponse | undefined;
48+
49+
for (let attempt = 0; attempt < attempts; attempt++) {
50+
try {
51+
const response = await route.fetch({ timeout: perAttemptTimeoutMs });
52+
const status = response.status();
53+
54+
// Only 429 and 5xx are transient; anything else (2xx, 3xx, 4xx
55+
// other than 429) is a real answer we should replay immediately.
56+
if (status !== 429 && status < 500) {
57+
await route.fulfill({ response });
58+
return;
59+
}
60+
61+
lastResponse = response;
62+
} catch {
63+
// Network error / timeout: fall through to backoff and retry.
64+
}
65+
66+
// Backoff after every failed attempt except when we are about to give
67+
// up, so the caller's render-gate budget is not spent needlessly.
68+
if (attempt < attempts - 1) {
69+
const backoffMs = 500 * 2 ** attempt;
70+
await new Promise((resolve) => setTimeout(resolve, backoffMs));
71+
}
72+
}
73+
74+
// Retries exhausted. Replay the last transient response if we have one,
75+
// otherwise let the request proceed so the real network error surfaces.
76+
if (lastResponse) {
77+
await route.fulfill({ response: lastResponse });
78+
} else {
79+
await route.continue();
80+
}
81+
}
82+
);
83+
}

0 commit comments

Comments
 (0)