diff --git a/.gitignore b/.gitignore index 082f783..98c9541 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ Thumbs.db *.plslide *_files/ *.dzi +!frontend/e2e/fixtures/teaching-landmarks/landmarks_files/ +!frontend/e2e/fixtures/teaching-landmarks/landmarks_files/** # Runtime and packaging /runtime/ diff --git a/frontend/e2e/accessibility.pw.ts b/frontend/e2e/accessibility.pw.ts new file mode 100644 index 0000000..f092df1 --- /dev/null +++ b/frontend/e2e/accessibility.pw.ts @@ -0,0 +1,72 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test, type Page } from '@playwright/test' +import { captureBrowserErrors } from './browserErrors' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const dzi = '' +const slide = { + id: 'synthetic-responsive-slide', displayName: 'North-east synthetic landmarks.ome.tif', sourceBytes: 100, + format: 'OME_TIFF', status: 'READY', detail: 'Synthetic image ready', outputPath: '', sha256: 'synthetic', + selectedSeries: 0, width: 640, height: 320, downsample: 1, estimatedOutputBytes: 0, + projectedFileBytes: 0, projectedFileLowerBytes: 0, projectedFileUpperBytes: 0, + cropX: 0, cropY: 0, cropWidth: 640, cropHeight: 320, sourceFingerprint: 'synthetic-source', + configurationRevision: 'synthetic-configuration', currentArtifactRevision: '', approvedArtifactRevision: '', +} + +async function installFixture(page: Page) { + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()) + if (url.pathname === '/api/session') return route.fulfill({ json: {}, headers: { 'X-Forge-CSRF': 'fixture' } }) + if (url.pathname === '/api/datasets') return route.fulfill({ json: { datasets: [slide] }, headers: { ETag: 'synthetic-datasets' } }) + if (url.pathname === '/api/capabilities') return route.fulfill({ json: { + conversionRuntime: 'Synthetic fixture', derivativeRuntime: 'Synthetic fixture', vsiConversion: false, + dziGeneration: false, downsamples: [1, 2, 4], usableBytes: 1_000_000, effectiveCapacityBytes: 2_000_000, + } }) + if (url.pathname === '/api/features') return route.fulfill({ json: { features: [] } }) + if (url.pathname === '/api/viewer/connection') return route.fulfill({ json: { + connected: false, viewerUrl: '', deviceName: '', scopes: [], conversionMode: 'PREPARED_DZI_V2', + } }) + if (url.pathname.endsWith('/annotations')) return route.fulfill({ json: { annotations: [] } }) + if (url.pathname.endsWith('/artifacts')) return route.fulfill({ json: { currentRevision: '', approvedRevision: '', revisions: [] } }) + if (url.pathname === '/api/analysis/runs') return route.fulfill({ json: [] }) + if (url.pathname.endsWith('/slide.dzi')) return route.fulfill({ status: 200, contentType: 'application/xml', body: dzi, headers: { 'x-pathlab-preview-mode': 'direct' } }) + if (url.pathname.endsWith('/thumbnail')) return route.fulfill({ path: path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', '9', '0_0.png') }) + const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/) + if (tile) return route.fulfill({ path: path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', tile[1], `${tile[2]}_${tile[3]}.png`) }) + return route.fulfill({ status: 404, json: { detail: `No synthetic fixture for ${url.pathname}` } }) + }) +} + +for (const viewport of [{ width: 1440, height: 900, label: 'desktop' }, { width: 390, height: 844, label: 'phone' }]) { + test(`axe scans the populated Forge workspace at ${viewport.label} size`, async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + const expectNoBrowserErrors = captureBrowserErrors(page) + await installFixture(page) + await page.setViewportSize({ width: viewport.width, height: viewport.height }) + await page.goto('/assets/') + await expect(page.locator('.forge-viewer-header').getByText(slide.displayName, { exact: true })).toBeVisible() + if (viewport.label === 'phone') { + const header = page.locator('.forge-viewer-header') + const headerBox = await header.boundingBox() + const buttons = header.getByRole('button') + for (const button of await buttons.all()) { + await expect(button).toHaveAccessibleName(/.+/) + const box = await button.boundingBox() + expect(box).not.toBeNull() + expect(box!.x + box!.width).toBeLessThanOrEqual(headerBox!.x + headerBox!.width + 1) + expect(box!.height).toBeGreaterThanOrEqual(44) + } + } + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze() + const report = results.violations.map((violation) => ({ + id: violation.id, impact: violation.impact, description: violation.description, + help: violation.help, nodes: violation.nodes.map((node) => ({ target: node.target, summary: node.failureSummary })), + })) + await testInfo.attach(`axe-${viewport.label}.json`, { body: JSON.stringify(report, null, 2), contentType: 'application/json' }) + const severe = results.violations.filter((violation) => violation.impact === 'serious' || violation.impact === 'critical') + expect(severe, JSON.stringify(report, null, 2)).toEqual([]) + expectNoBrowserErrors() + }) +} diff --git a/frontend/e2e/browserErrors.ts b/frontend/e2e/browserErrors.ts new file mode 100644 index 0000000..aa2a00d --- /dev/null +++ b/frontend/e2e/browserErrors.ts @@ -0,0 +1,17 @@ +import { expect, type Page } from '@playwright/test' + +export function captureBrowserErrors(page: Page, isExpectedFailedResponse: (status: number, url: string) => boolean = () => false) { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(`uncaught: ${error.stack || error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error' && !message.text().startsWith('Failed to load resource:')) { + errors.push(`console.error: ${message.text()}`) + } + }) + page.on('response', (response) => { + if (response.status() >= 400 && !isExpectedFailedResponse(response.status(), response.url())) { + errors.push(`HTTP ${response.status()}: ${response.url()}`) + } + }) + return () => expect(errors, errors.join('\n')).toEqual([]) +} diff --git a/frontend/e2e/fixtures/TeachingPreviewHarness.tsx b/frontend/e2e/fixtures/TeachingPreviewHarness.tsx new file mode 100644 index 0000000..c250837 --- /dev/null +++ b/frontend/e2e/fixtures/TeachingPreviewHarness.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react' +import { createRoot } from 'react-dom/client' +import { TeachingSlidePreview } from '../../src/TeachingSlidePreview' +import type { TeachingAssociation } from '../../src/teachingAssociations' +import type OpenSeadragon from 'openseadragon' +import './teaching-preview.css' + +const association: TeachingAssociation = { + referenceId: 'synthetic:landmarks', + viewerSlideId: '', + datasetId: 'synthetic-dataset', + artifactRevision: 'synthetic-revision', + packageSha256: 'a'.repeat(64), + outputWidth: 640, + outputHeight: 320, +} + +declare global { interface Window { __teachingViewer?: OpenSeadragon.Viewer | null } } + +function TeachingPreviewHarness() { + const [location, setLocation] = useState<{ x: number; y: number }>() + const [ready, setReady] = useState(false) + return
+

Non-square teaching preview fixture

+ {ready ? 'Image ready' : 'Image loading'} + {location ? `${location.x.toFixed(5)},${location.y.toFixed(5)}` : 'No location selected'} + setReady(Boolean(pixels))} + onLocation={(x, y) => setLocation({ x, y })} selectedLocation={location} + onViewerReady={(viewer) => { window.__teachingViewer = viewer }} /> +
+} + +createRoot(document.getElementById('fixture')!).render() diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/0/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/0/0_0.png new file mode 100644 index 0000000..8480a92 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/0/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/1/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/1/0_0.png new file mode 100644 index 0000000..453ff3c Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/1/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/0_0.png new file mode 100644 index 0000000..718d874 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/0_1.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/0_1.png new file mode 100644 index 0000000..1af581e Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/0_1.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/1_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/1_0.png new file mode 100644 index 0000000..620aa6d Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/1_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/1_1.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/1_1.png new file mode 100644 index 0000000..9591bc4 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/1_1.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/2_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/2_0.png new file mode 100644 index 0000000..ebc3534 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/2_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/2_1.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/2_1.png new file mode 100644 index 0000000..813e36a Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/10/2_1.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/2/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/2/0_0.png new file mode 100644 index 0000000..4b17d17 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/2/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/3/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/3/0_0.png new file mode 100644 index 0000000..5ecd249 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/3/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/4/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/4/0_0.png new file mode 100644 index 0000000..14242d1 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/4/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/5/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/5/0_0.png new file mode 100644 index 0000000..d70de60 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/5/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/6/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/6/0_0.png new file mode 100644 index 0000000..6e4b14e Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/6/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/7/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/7/0_0.png new file mode 100644 index 0000000..968f27d Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/7/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/8/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/8/0_0.png new file mode 100644 index 0000000..d4abc90 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/8/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/9/0_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/9/0_0.png new file mode 100644 index 0000000..579e2d9 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/9/0_0.png differ diff --git a/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/9/1_0.png b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/9/1_0.png new file mode 100644 index 0000000..dc14c67 Binary files /dev/null and b/frontend/e2e/fixtures/teaching-landmarks/landmarks_files/9/1_0.png differ diff --git a/frontend/e2e/fixtures/teaching-preview.css b/frontend/e2e/fixtures/teaching-preview.css new file mode 100644 index 0000000..6acbdb4 --- /dev/null +++ b/frontend/e2e/fixtures/teaching-preview.css @@ -0,0 +1,2 @@ +body{margin:0;background:#f8f7f4;color:#202421;font:16px/1.4 system-ui,sans-serif}main{width:min(1120px,100%);margin:auto;padding:16px;box-sizing:border-box}h1{font-size:20px}output{display:block;margin:4px 0}section[aria-label="Exact offline teaching pixels"]{display:grid;grid-template-columns:repeat(3,minmax(100px,1fr));gap:8px}section[aria-label="Exact offline teaching pixels"]>div{grid-column:1/-1;height:min(62dvh,540px)!important;min-height:220px}section[aria-label="Exact offline teaching pixels"]>.teaching-slide-viewport{justify-self:start;width:min(400px,90vw)!important;height:min(400px,50dvh)!important;min-height:400px!important;aspect-ratio:1/1;background:#111!important}section[aria-label="Exact offline teaching pixels"]>button{min-height:44px} +span.teaching-location-marker{width:32px;height:32px;display:block;border:2px solid #fff;border-radius:50%;background:#b35439;box-shadow:0 0 0 2px #111;pointer-events:none} diff --git a/frontend/e2e/fixtures/teaching-preview.html b/frontend/e2e/fixtures/teaching-preview.html new file mode 100644 index 0000000..11585bc --- /dev/null +++ b/frontend/e2e/fixtures/teaching-preview.html @@ -0,0 +1,12 @@ + + + + + + Synthetic teaching preview + + +
+ + + diff --git a/frontend/e2e/responsive.pw.ts b/frontend/e2e/responsive.pw.ts new file mode 100644 index 0000000..3ef8f63 --- /dev/null +++ b/frontend/e2e/responsive.pw.ts @@ -0,0 +1,257 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test' +import { captureBrowserErrors } from './browserErrors' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const fixtureDzi = '' +const slide = { + id: 'synthetic-responsive-slide', displayName: 'North-east synthetic landmarks.ome.tif', sourceBytes: 100, + format: 'OME_TIFF', status: 'READY', detail: 'Synthetic image ready', outputPath: '', sha256: 'synthetic', + selectedSeries: 0, width: 640, height: 320, downsample: 1, estimatedOutputBytes: 0, + projectedFileBytes: 0, projectedFileLowerBytes: 0, projectedFileUpperBytes: 0, + cropX: 0, cropY: 0, cropWidth: 640, cropHeight: 320, sourceFingerprint: 'synthetic-source', + configurationRevision: 'synthetic-configuration', currentArtifactRevision: '', approvedArtifactRevision: '', +} + +async function installFixture(page: Page, testInfo?: TestInfo) { + const tileState = { active: 0, maxActive: 0, requests: 0 } + if (testInfo) { + await page.addInitScript(() => { + const originalAdd = EventTarget.prototype.addEventListener + const originalRemove = EventTarget.prototype.removeEventListener + const counts: Record = {} + const listeners = new WeakMap>>() + EventTarget.prototype.addEventListener = function (type, listener, options) { + if (this === window && listener) { + const capture = typeof options === 'boolean' ? options : Boolean(options?.capture) + const key = `${type}:${capture}` + let target = listeners.get(this) + if (!target) { target = new Map(); listeners.set(this, target) } + let set = target.get(key) + if (!set) { set = new Set(); target.set(key, set) } + if (!set.has(listener)) { set.add(listener); counts[type] = (counts[type] || 0) + 1 } + } + return originalAdd.call(this, type, listener, options) + } + EventTarget.prototype.removeEventListener = function (type, listener, options) { + if (this === window && listener) { + const capture = typeof options === 'boolean' ? options : Boolean(options?.capture) + const set = listeners.get(this)?.get(`${type}:${capture}`) + if (set?.delete(listener)) counts[type] = Math.max(0, (counts[type] || 0) - 1) + } + return originalRemove.call(this, type, listener, options) + } + Object.assign(window, { __activeWindowListeners: () => ({ ...counts }) }) + }) + } + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()) + if (url.pathname === '/api/session') return route.fulfill({ json: {}, headers: { 'X-Forge-CSRF': 'fixture' } }) + if (url.pathname === '/api/datasets') return route.fulfill({ json: { datasets: [slide] }, headers: { ETag: 'synthetic-datasets' } }) + if (url.pathname === '/api/capabilities') return route.fulfill({ json: { + conversionRuntime: 'Synthetic fixture', derivativeRuntime: 'Synthetic fixture', vsiConversion: false, + dziGeneration: false, downsamples: [1, 2, 4], usableBytes: 1_000_000, effectiveCapacityBytes: 2_000_000, + } }) + if (url.pathname === '/api/features') return route.fulfill({ json: { features: [] } }) + if (url.pathname === '/api/viewer/connection') return route.fulfill({ json: { + connected: false, viewerUrl: '', deviceName: '', scopes: [], conversionMode: 'PREPARED_DZI_V2', + } }) + if (url.pathname.endsWith('/annotations')) return route.fulfill({ json: { annotations: [] } }) + if (url.pathname.endsWith('/artifacts')) return route.fulfill({ json: { currentRevision: '', approvedRevision: '', revisions: [] } }) + if (url.pathname === '/api/analysis/runs') return route.fulfill({ json: [] }) + if (url.pathname.endsWith('/slide.dzi')) return route.fulfill({ status: 200, contentType: 'application/xml', body: fixtureDzi, headers: { 'x-pathlab-preview-mode': 'direct' } }) + if (url.pathname.endsWith('/thumbnail')) return route.fulfill({ path: path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', '9', '0_0.png') }) + const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/) + if (tile) { + tileState.active++ + tileState.requests++ + tileState.maxActive = Math.max(tileState.maxActive, tileState.active) + await new Promise((resolve) => setTimeout(resolve, 20)) + const tilePath = path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', tile[1], `${tile[2]}_${tile[3]}.png`) + try { return await route.fulfill({ path: tilePath }) } finally { tileState.active-- } + } + return route.fulfill({ status: 404, json: { detail: `No synthetic fixture for ${url.pathname}` } }) + }) + return tileState +} + +const fullChromiumViewports = [ + [320, 568], [390, 844], [568, 320], [667, 375], [768, 1024], [1024, 768], + [1280, 720], [1440, 900], [1920, 1080], [2560, 1440], [3440, 1440], [3840, 2160], + [599, 800], [600, 800], [601, 800], [959, 800], [960, 800], [961, 800], + [1199, 800], [1200, 800], [1201, 800], [320, 320], [390, 480], +] +const crossBrowserViewports = [[390, 844], [568, 320], [768, 1024], [1440, 900]] + +for (const [width, height] of fullChromiumViewports) { + test(`reflows without document overflow at ${width}x${height}`, async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium' && !crossBrowserViewports.some(([w, h]) => w === width && h === height)) + const fixtureTiles = await installFixture(page) + await page.setViewportSize({ width, height }) + const expectNoBrowserErrors = captureBrowserErrors(page) + await page.goto('/assets/') + await expect(page.locator('.forge-viewer-header').getByText('North-east synthetic landmarks.ome.tif', { exact: true })).toBeVisible() + if (testInfo.project.name.startsWith('chromium') && [[320, 568], [1280, 720]].some(([w, h]) => w === width && h === height)) { + await expect.poll(() => fixtureTiles.requests).toBeGreaterThan(0) + await expect(page.locator('.forge-preview-loading')).toBeHidden({ timeout: 20_000 }) + await page.screenshot({ path: testInfo.outputPath(`forge-${width}x${height}.png`) }) + } + const documentWidth = await page.evaluate(() => document.documentElement.scrollWidth) + expect(documentWidth).toBeLessThanOrEqual(width) + const layout = await page.evaluate(() => ({ + navigatorPosition: getComputedStyle(document.querySelector('.pathlab-local-navigator')!).position, + inspectorPosition: getComputedStyle(document.querySelector('.pathlab-viewer-inspector')!).position, + navigatorHidden: (document.querySelector('.pathlab-local-navigator') as HTMLElement).hidden, + inspectorHidden: (document.querySelector('.pathlab-viewer-inspector') as HTMLElement).hidden, + })) + expect(layout.navigatorPosition).toBe(width <= 960 ? 'fixed' : 'static') + expect(layout.inspectorPosition).toBe(width <= 1200 ? 'fixed' : 'relative') + expect(layout.navigatorHidden).toBe(width <= 960) + expect(layout.inspectorHidden).toBe(width <= 1200) + if (width <= 600) { + const header = page.locator('.forge-viewer-header') + const headerBox = await header.boundingBox() + const inspectorToggle = header.getByRole('button', { name: /slide inspector/ }) + await expect(inspectorToggle).toHaveAttribute('aria-label', /slide inspector/) + const toggleBox = await inspectorToggle.boundingBox() + expect(toggleBox).not.toBeNull() + expect(toggleBox!.x + toggleBox!.width).toBeLessThanOrEqual(headerBox!.x + headerBox!.width + 1) + expect(toggleBox!.height).toBeGreaterThanOrEqual(44) + } + expectNoBrowserErrors() + }) +} + +test('preserves panel preference, focus, and navigator edits through breakpoint changes', async ({ page }) => { + await installFixture(page) + await page.setViewportSize({ width: 900, height: 700 }) + await page.goto('/assets/') + const navigator = page.locator('.pathlab-local-navigator') + const inspector = page.locator('.pathlab-viewer-inspector') + const libraryTrigger = page.getByRole('button', { name: 'Local library' }) + await expect(libraryTrigger).toHaveAttribute('aria-expanded', 'false') + await libraryTrigger.click() + const search = navigator.getByPlaceholder('Search slides and folders') + await expect(navigator.locator(':focus')).toHaveCount(1) + await search.fill('landmarks') + await expect(search).toBeFocused() + await expect(navigator.getByRole('button', { name: 'Collapse slide library' })).toBeVisible() + const openInspector = page.getByRole('button', { name: 'Open slide inspector' }) + await openInspector.click() + await expect(navigator).toHaveAttribute('hidden', '') + await expect(inspector).not.toHaveAttribute('hidden', '') + await expect(inspector.locator(':focus')).toHaveCount(1) + await page.keyboard.press('Escape') + await expect(inspector).toHaveAttribute('hidden', '') + await expect(openInspector).toBeFocused() + + await libraryTrigger.click() + await expect(navigator).not.toHaveAttribute('hidden', '') + await page.setViewportSize({ width: 1000, height: 700 }) + await expect(navigator).not.toHaveAttribute('hidden', '') + await expect(inspector).toHaveAttribute('hidden', '') + await page.getByRole('button', { name: 'Open slide inspector' }).click() + await expect(inspector).not.toHaveAttribute('hidden', '') + await expect(navigator.getByPlaceholder('Search slides and folders')).toHaveValue('landmarks') + await inspector.getByRole('button', { name: 'Collapse slide inspector' }).click() + await expect(inspector).toHaveAttribute('hidden', '') + await page.setViewportSize({ width: 1201, height: 700 }) + await expect(inspector).not.toHaveAttribute('hidden', '') + await page.setViewportSize({ width: 1200, height: 700 }) + await expect(inspector).toHaveAttribute('hidden', '') + await expect(navigator).not.toHaveAttribute('hidden', '') + await page.setViewportSize({ width: 1201, height: 700 }) + await expect(inspector).not.toHaveAttribute('hidden', '') +}) + +test('keeps one viewer and bounded loader settings through twenty alternating resizes', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + const tiles = await installFixture(page, testInfo) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/assets/') + await expect.poll(() => tiles.requests).toBeGreaterThan(0) + await expect(page.locator('.forge-preview-loading')).toBeHidden({ timeout: 20_000 }) + const viewer = page.getByTestId('forge-osd') + const primaryViewer = viewer.locator(':scope > .openseadragon-container') + const overviewViewer = viewer.locator('.navigator .openseadragon-container') + await expect(primaryViewer).toHaveCount(1) + await expect(overviewViewer).toHaveCount(1) + await expect(viewer.locator('.openseadragon-container')).toHaveCount(2) + await expect(viewer).toHaveAttribute('data-image-loader-limit', '1') + await expect(viewer).toHaveAttribute('data-image-loader-budget', '2') + await expect(viewer).toHaveAttribute('data-max-tile-cache-count', '32') + await expect(viewer).toHaveAttribute('data-max-tile-cache-budget', '96') + await expect(viewer).toHaveAttribute('data-effective-primary-loader-jobs', '1') + await expect(viewer).toHaveAttribute('data-effective-overview-loader-jobs', '1') + await expect(viewer).toHaveAttribute('data-effective-primary-tile-cache', '32') + await expect(viewer).toHaveAttribute('data-effective-overview-tile-cache', '32') + const viewerInstance = await viewer.getAttribute('data-viewer-instance-id') + expect(viewerInstance).toBeTruthy() + const before = await page.evaluate(() => (window as typeof window & { __activeWindowListeners: () => Record }).__activeWindowListeners()) + for (let cycle = 0; cycle < 20; cycle++) { + await page.setViewportSize({ width: cycle % 2 ? 1440 : 390, height: cycle % 2 ? 900 : 844 }) + await expect(primaryViewer).toHaveCount(1) + await expect(overviewViewer).toHaveCount(1) + await expect(viewer.locator('.openseadragon-container')).toHaveCount(2) + } + await expect(primaryViewer).toHaveCount(1) + await expect(overviewViewer).toHaveCount(1) + await expect(viewer).toHaveAttribute('data-viewer-instance-id', viewerInstance!) + expect(tiles.active).toBe(0) + expect(tiles.maxActive).toBeLessThanOrEqual(2) + const after = await page.evaluate(() => (window as typeof window & { __activeWindowListeners: () => Record }).__activeWindowListeners()) + expect(after).toEqual(before) +}) + +test('keeps dark theme, reduced motion, forced colors, root-font enlargement emulation, and keyboard focus usable', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + await installFixture(page) + await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce', forcedColors: 'active' }) + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/assets/') + await page.addStyleTag({ content: 'html{font-size:200%!important} /* root-font enlargement emulation, not browser zoom */' }) + const settings = await page.evaluate(() => ({ + theme: document.documentElement.dataset.theme, + reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches, + forcedColors: matchMedia('(forced-colors: active)').matches, + width: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + transition: getComputedStyle(document.querySelector('.pathlab-local-navigator')!).transitionDuration, + })) + expect(settings).toMatchObject({ theme: 'dark', reducedMotion: true, forcedColors: true }) + expect(settings.width).toBeLessThanOrEqual(settings.viewportWidth) + expect(settings.transition).toBe('0s') + await page.getByRole('button', { name: 'Local library' }).focus() + await page.keyboard.press('Tab') + const focused = await page.evaluate(() => { + const active = document.activeElement as HTMLElement + return { label: active.getAttribute('aria-label'), outline: getComputedStyle(active).outlineStyle, border: getComputedStyle(active).borderStyle } + }) + expect(focused.label).toBe('Viewer library') + expect(focused.outline).toBe('solid') + expect(focused.border).toBe('solid') +}) + +test('supports touch, mouse, and keyboard while the phone navigator opens and closes', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium-touch') + await installFixture(page) + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/assets/') + expect(await page.evaluate(() => navigator.maxTouchPoints)).toBeGreaterThan(0) + const trigger = page.getByRole('button', { name: 'Local library' }) + const triggerBox = await trigger.boundingBox() + expect(triggerBox).not.toBeNull() + await page.touchscreen.tap(triggerBox!.x + triggerBox!.width / 2, triggerBox!.y + triggerBox!.height / 2) + const navigator = page.locator('#library-navigator') + await expect(navigator).not.toHaveAttribute('hidden', '') + await expect(navigator.locator(':focus')).toHaveCount(1) + const close = navigator.getByRole('button', { name: 'Collapse slide library' }) + const closeBox = await close.boundingBox() + expect(closeBox).not.toBeNull() + expect(closeBox!.height).toBeGreaterThanOrEqual(44) + await page.mouse.click(closeBox!.x + closeBox!.width / 2, closeBox!.y + closeBox!.height / 2) + await expect(navigator).toHaveAttribute('hidden', '') + await page.keyboard.press('Tab') + expect(await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.matches(':focus-visible'))).toBe(true) +}) diff --git a/frontend/e2e/teaching-landmarks.pw.ts b/frontend/e2e/teaching-landmarks.pw.ts new file mode 100644 index 0000000..075b4ee --- /dev/null +++ b/frontend/e2e/teaching-landmarks.pw.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test' +import { captureBrowserErrors } from './browserErrors' + +const dzi = '' + +test('renders non-square synthetic landmarks and records image coordinates through gutters, zoom, and resize', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + const expectNoBrowserErrors = captureBrowserErrors(page) + const browserErrors: string[] = [] + page.on('pageerror', (error) => browserErrors.push(error.stack || error.message)) + page.on('console', (message) => { if (message.type() === 'error') browserErrors.push(message.text()) }) + let tileRequests = 0 + await page.route('**/api/datasets/synthetic-dataset/artifacts/synthetic-revision/teaching-preview/**', async (route) => { + const url = new URL(route.request().url()) + if (url.pathname.endsWith('/slide.dzi')) return route.fulfill({ status: 200, contentType: 'application/xml', body: dzi }) + const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/) + if (!tile) return route.fulfill({ status: 404 }) + tileRequests++ + return route.fulfill({ path: `e2e/fixtures/teaching-landmarks/landmarks_files/${tile[1]}/${tile[2]}_${tile[3]}.png` }) + }) + await page.setViewportSize({ width: 1000, height: 900 }) + const response = await page.goto('/assets/e2e/fixtures/teaching-preview.html') + try { await expect(page.getByTestId('teaching-ready')).toHaveText('Image ready', { timeout: 20_000 }) } + catch (error) { + const diagnostics = { status: response?.status(), url: page.url(), browserErrors, tileRequests, body: await page.locator('body').innerText() } + console.log('TEACHING FIXTURE DIAGNOSTICS', JSON.stringify(diagnostics)) + await testInfo.attach('teaching-preview-diagnostics.json', { body: JSON.stringify(diagnostics, null, 2), contentType: 'application/json' }) + throw error + } + const viewport = page.locator('.teaching-slide-viewport') + await expect(viewport.locator('.openseadragon-container')).toHaveCount(1) + await expect.poll(() => tileRequests).toBeGreaterThan(0) + + const imageBounds = await page.evaluate(() => { + const viewer = window.__teachingViewer! + const area = document.querySelector('.teaching-slide-viewport')!.getBoundingClientRect() + const topLeft = viewer.viewport.pixelFromPoint(viewer.viewport.imageToViewportCoordinates(0, 0), true) + const bottomRight = viewer.viewport.pixelFromPoint(viewer.viewport.imageToViewportCoordinates(640, 320), true) + return { area: { left: area.left, top: area.top, right: area.right, bottom: area.bottom, width: area.width, height: area.height }, topLeft, bottomRight } + }) + const hasLetterbox = imageBounds.topLeft.y > imageBounds.area.top + 2 || imageBounds.bottomRight.y < imageBounds.area.bottom - 2 + || imageBounds.topLeft.x > imageBounds.area.left + 2 || imageBounds.bottomRight.x < imageBounds.area.right - 2 + expect(hasLetterbox).toBe(true) + const gutterPoint = imageBounds.topLeft.y > imageBounds.area.top + 2 + ? { x: (imageBounds.topLeft.x + imageBounds.bottomRight.x) / 2, y: imageBounds.area.top + 1 } + : { x: imageBounds.area.left + 1, y: (imageBounds.topLeft.y + imageBounds.bottomRight.y) / 2 } + await page.mouse.click(gutterPoint.x, gutterPoint.y) + await expect(page.getByTestId('teaching-location')).toHaveText('No location selected') + const expectLocationWithinOneSourcePixel = async (x: number, y: number) => { + const [actualX, actualY] = (await page.getByTestId('teaching-location').innerText()).split(',').map(Number) + expect(Math.abs(actualX - x)).toBeLessThanOrEqual(1 / 640) + expect(Math.abs(actualY - y)).toBeLessThanOrEqual(1 / 320) + } + + const clickImagePoint = async (x: number, y: number) => { + const point = await page.evaluate(({ x: imageX, y: imageY }) => { + const viewer = window.__teachingViewer! + const imagePoint = viewer.viewport.pixelFromPoint(viewer.viewport.imageToViewportCoordinates(imageX, imageY), true) + const rect = document.querySelector('.teaching-slide-viewport')!.getBoundingClientRect() + return { x: rect.left + imagePoint.x, y: rect.top + imagePoint.y } + }, { x, y }) + await page.mouse.click(point.x, point.y) + } + await clickImagePoint(160, 80) + await expectLocationWithinOneSourcePixel(0.25, 0.25) + const marker = page.getByTestId('teaching-location-marker') + await expect(marker).toBeVisible() + const markerBox = await marker.boundingBox() + const redLandmark = await page.evaluate(() => { + const viewer = window.__teachingViewer! + const p = viewer.viewport.pixelFromPoint(viewer.viewport.imageToViewportCoordinates(160, 80), true) + const rect = document.querySelector('.teaching-slide-viewport')!.getBoundingClientRect() + return { x: rect.left + p.x, y: rect.top + p.y } + }) + expect(Math.abs((markerBox!.x + markerBox!.width / 2) - redLandmark.x)).toBeLessThanOrEqual(1) + expect(Math.abs((markerBox!.y + markerBox!.height / 2) - redLandmark.y)).toBeLessThanOrEqual(1) + + await page.getByRole('button', { name: 'Zoom teaching slide in' }).click() + await page.waitForFunction(() => { + const viewport = window.__teachingViewer?.viewport + return Boolean(viewport && Math.abs(viewport.getZoom(true) - viewport.getZoom(false)) < 0.001) + }) + await clickImagePoint(480, 240) + await expectLocationWithinOneSourcePixel(0.75, 0.75) + await page.screenshot({ path: testInfo.outputPath('synthetic-landmarks-zoomed.png') }) + + await page.setViewportSize({ width: 390, height: 844 }) + await page.waitForFunction(() => { + const viewer = window.__teachingViewer + const element = document.querySelector('.teaching-slide-viewport') + const size = viewer?.viewport.getContainerSize() + return Boolean(element && size && size.x === element.clientWidth && size.y === element.clientHeight) + }) + await clickImagePoint(320, 160) + await expectLocationWithinOneSourcePixel(0.5, 0.5) + await page.setViewportSize({ width: 568, height: 320 }) + await page.waitForFunction(() => { + const viewer = window.__teachingViewer + const element = document.querySelector('.teaching-slide-viewport') + const size = viewer?.viewport.getContainerSize() + return Boolean(element && size && size.x === element.clientWidth && size.y === element.clientHeight) + }) + await clickImagePoint(160, 80) + await expectLocationWithinOneSourcePixel(0.25, 0.25) + expectNoBrowserErrors() +}) + +test('keeps synthetic landmark coordinates correct at DPR 1, 2, and 3', async ({ browser }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + + for (const deviceScaleFactor of [1, 2, 3]) { + const context = await browser.newContext({ + baseURL: testInfo.project.use.baseURL as string, + viewport: { width: 1000, height: 900 }, + deviceScaleFactor, + }) + try { + const page = await context.newPage() + const expectNoBrowserErrors = captureBrowserErrors(page) + let tileRequests = 0 + await page.route('**/api/datasets/synthetic-dataset/artifacts/synthetic-revision/teaching-preview/**', async (route) => { + const url = new URL(route.request().url()) + if (url.pathname.endsWith('/slide.dzi')) return route.fulfill({ status: 200, contentType: 'application/xml', body: dzi }) + const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/) + if (!tile) return route.fulfill({ status: 404 }) + tileRequests++ + return route.fulfill({ path: `e2e/fixtures/teaching-landmarks/landmarks_files/${tile[1]}/${tile[2]}_${tile[3]}.png` }) + }) + + await page.goto('/assets/e2e/fixtures/teaching-preview.html') + await expect(page.getByTestId('teaching-ready')).toHaveText('Image ready', { timeout: 20_000 }) + await expect.poll(() => tileRequests).toBeGreaterThan(0) + await expect.poll(() => page.evaluate(() => window.devicePixelRatio)).toBe(deviceScaleFactor) + + const point = await page.evaluate(() => { + const viewer = window.__teachingViewer! + const imagePoint = viewer.viewport.pixelFromPoint( + viewer.viewport.imageToViewportCoordinates(320, 160), + true, + ) + const rect = document.querySelector('.teaching-slide-viewport')!.getBoundingClientRect() + return { x: rect.left + imagePoint.x, y: rect.top + imagePoint.y } + }) + await page.mouse.click(point.x, point.y) + const [actualX, actualY] = (await page.getByTestId('teaching-location').innerText()).split(',').map(Number) + expect(Math.abs(actualX - 0.5)).toBeLessThanOrEqual(1 / 640) + expect(Math.abs(actualY - 0.5)).toBeLessThanOrEqual(1 / 320) + await page.screenshot({ path: testInfo.outputPath(`synthetic-landmarks-dpr-${deviceScaleFactor}.png`) }) + expectNoBrowserErrors() + } finally { + await context.close() + } + } +}) diff --git a/frontend/e2e/viewer-resource-budget.pw.ts b/frontend/e2e/viewer-resource-budget.pw.ts new file mode 100644 index 0000000..6745052 --- /dev/null +++ b/frontend/e2e/viewer-resource-budget.pw.ts @@ -0,0 +1,329 @@ +import { expect, test, type Page, type Route } from '@playwright/test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { captureBrowserErrors } from './browserErrors' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const datasetId = 'ca38d59a-08ce-44a2-aaf2-cb96bd147bdf' +const replacementDatasetId = 'b293c4f0-a2ba-4242-a81f-5a208a364cbb' +const artifactRevision = 'fcafc4bf-2350-470c-aa3a-ad5f5a0d9734' +const packageSha256 = 'a'.repeat(64) +const slideId = `local:${datasetId}` +const fixtureDzi = '' + +const slide = { + id: datasetId, displayName: 'Concurrent synthetic landmarks.ome.tif', sourceBytes: 100, + format: 'OME_TIFF', status: 'READY', detail: 'Synthetic image ready', outputPath: '', sha256: packageSha256, + selectedSeries: 0, width: 640, height: 320, downsample: 1, estimatedOutputBytes: 0, + projectedFileBytes: 0, projectedFileLowerBytes: 0, projectedFileUpperBytes: 0, + cropX: 0, cropY: 0, cropWidth: 640, cropHeight: 320, sourceFingerprint: 'synthetic-source', + configurationRevision: 'synthetic-configuration', currentArtifactRevision: '', approvedArtifactRevision: '', +} +const replacementSlide = { + ...slide, + id: replacementDatasetId, + displayName: 'Replacement synthetic landmarks.ome.tif', + sourceFingerprint: 'replacement-synthetic-source', + configurationRevision: 'replacement-synthetic-configuration', +} + +const studyDraft = { + id: 'concurrent-resource-draft', name: 'Concurrent resource draft', revision: 1, + associations: { teachingSlides: { [slideId]: { + referenceId: slideId, viewerSlideId: slideId, datasetId, artifactRevision, packageSha256, + configurationRevision: 'synthetic-configuration', sourceFingerprint: 'synthetic-source', outputWidth: 640, outputHeight: 320, + provenance: { series: 0, viewRevision: 'synthetic-view', crop: { x: 0, y: 0, width: 640, height: 320 }, downsample: 1 }, + } } }, + issues: [], previewChecksum: 'exact-synthetic-checksum', reviewedTaskIds: [], approvedChecksum: '', updatedAt: 1, + definition: { + schema: 'pathlab.study-pack/1', packKey: 'concurrent-resource', version: 1, title: 'Resource test', author: 'Synthetic fixture', + license: 'CC-BY-4.0', provenance: 'Synthetic Playwright fixture', revision: 'synthetic-r1', languages: ['en'], + slides: [{ viewerSlideId: slideId, sha256: packageSha256, displayName: 'Concurrent synthetic landmarks' }], + tasks: [{ id: 'spatial-landmark', type: 'spatial', slideId, prompt: 'Select the synthetic landmark.', targetX: .45, targetY: .4, + targetWidth: .1, targetHeight: .2, tolerance: .05, options: [], answerKey: '', hints: ['Synthetic hint'], explanation: 'Synthetic explanation.', sources: [] }], + }, +} + +type ResourceSnapshot = { + viewerCount: number; trackedViewerCount: number; activeLoaderJobs: number; queuedLoaderJobs: number + activeLoaders: number; scheduledLoaders: number; waitingLoaders: number; maxLoaderJobs: number; maxCacheTiles: number; cachedTiles: number + peaks: { activeLoaderJobs: number; queuedLoaderJobs: number; cachedTiles: number; viewers: number } + viewers: Array<{ id: string; role: string; retired: boolean; jobsInProgress: number; queuedJobs: number; runtimeJobLimit: number + cacheLimit: number; cachedTiles: number; bounds: { x: number; y: number; width: number; height: number } | null + center: { x: number; y: number } | null; zoom: number | null; container: { width: number; height: number } }> +} + +declare global { + interface Window { __forgeViewerResourceSnapshot?: () => ResourceSnapshot } +} + +async function installOverlapFixture(page: Page) { + const state = { activeTiles: 0, maxActiveTiles: 0, tileRequests: 0, teachingTileRequests: 0, replacementTileRequests: 0, releaseTiles: () => {} } + let release!: () => void + const tileGate = new Promise((resolve) => { release = resolve }) + state.releaseTiles = release + await page.route('**/api/**', async (route: Route) => { + const url = new URL(route.request().url()) + if (url.pathname === '/api/session') return route.fulfill({ json: {}, headers: { 'X-Forge-CSRF': 'synthetic' } }) + if (url.pathname === '/api/datasets') return route.fulfill({ json: { datasets: [slide, replacementSlide] }, headers: { ETag: 'resource-budget-fixture' } }) + if (url.pathname === '/api/capabilities') return route.fulfill({ json: { + conversionRuntime: 'Synthetic fixture', derivativeRuntime: 'Synthetic fixture', vsiConversion: false, + dziGeneration: false, downsamples: [1, 2, 4], usableBytes: 1_000_000, effectiveCapacityBytes: 2_000_000, + } }) + if (url.pathname === '/api/features') return route.fulfill({ json: { features: [] } }) + if (url.pathname === '/api/viewer/connection') return route.fulfill({ json: { + connected: false, viewerUrl: '', deviceName: '', scopes: [], conversionMode: 'PREPARED_DZI_V2', + } }) + if (url.pathname === '/api/viewer/sync') return route.fulfill({ json: { items: [], folders: [], conflicts: [] } }) + if (url.pathname === '/api/study/drafts') return route.fulfill({ json: [studyDraft] }) + if (url.pathname === '/api/analysis/runs') return route.fulfill({ json: [] }) + if (url.pathname.endsWith('/annotations')) return route.fulfill({ json: { annotations: [] } }) + if (url.pathname.endsWith('/artifacts')) return route.fulfill({ json: { currentRevision: '', approvedRevision: '', revisions: [] } }) + if (url.pathname.endsWith('/slide.dzi')) { + return route.fulfill({ status: 200, contentType: 'application/xml', body: fixtureDzi, headers: { 'x-pathlab-preview-mode': 'direct' } }) + } + if (url.pathname.endsWith('/thumbnail')) return route.fulfill({ path: path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', '9', '0_0.png') }) + const tile = url.pathname.match(/\/(\d+)\/(\d+)_(\d+)\.png$/) + if (tile) { + if (url.pathname.includes('/teaching-preview/')) state.teachingTileRequests++ + if (url.pathname.includes(replacementDatasetId)) state.replacementTileRequests++ + state.activeTiles++ + state.tileRequests++ + state.maxActiveTiles = Math.max(state.maxActiveTiles, state.activeTiles) + try { + await tileGate + const tilePath = path.join(here, 'fixtures', 'teaching-landmarks', 'landmarks_files', tile[1], `${tile[2]}_${tile[3]}.png`) + return await route.fulfill({ path: tilePath }) + } finally { + state.activeTiles-- + } + } + return route.fulfill({ status: 404, json: { detail: `No resource fixture for ${url.pathname}` } }) + }) + return state +} + +async function resourceSnapshot(page: Page) { + return page.evaluate(() => window.__forgeViewerResourceSnapshot?.() || null) +} + +async function waitForSettledLoaders(page: Page, fixture: Awaited>) { + let previousTileRequests = -1 + let stableSamples = 0 + await expect.poll(async () => { + const snapshot = await resourceSnapshot(page) + const idle = snapshot && snapshot.viewers.every((viewer) => viewer.jobsInProgress === 0) + && snapshot.activeLoaderJobs === 0 && snapshot.queuedLoaderJobs === 0 + && snapshot.waitingLoaders === 0 && snapshot.scheduledLoaders === 0 + if (!idle || fixture.tileRequests !== previousTileRequests) stableSamples = 0 + else stableSamples++ + previousTileRequests = fixture.tileRequests + return stableSamples >= 3 + }, { timeout: 20_000, intervals: [250] }).toBe(true) +} + +function expectResourceBounds(snapshot: ResourceSnapshot) { + expect(snapshot.viewerCount).toBeLessThanOrEqual(3) + expect(snapshot.activeLoaderJobs).toBeLessThanOrEqual(2) + expect(snapshot.activeLoaders).toBeLessThanOrEqual(2) + expect(snapshot.queuedLoaderJobs).toBeGreaterThanOrEqual(0) + expect(snapshot.maxLoaderJobs).toBe(2) + expect(snapshot.maxCacheTiles).toBe(96) + // OSD's maxImageCacheCount is an eviction target that can be exceeded while + // tiles are loading/drawing. Assert configured limits and report actual + // counts separately instead of treating that soft cap as a byte ceiling. + expect(snapshot.cachedTiles).toBeGreaterThanOrEqual(0) + const activeCacheOwners = snapshot.viewers.filter((viewer) => !viewer.retired) + expect(activeCacheOwners.reduce((total, viewer) => total + viewer.cacheLimit, 0)).toBeLessThanOrEqual(96) + expect(activeCacheOwners.every((viewer) => viewer.cacheLimit <= 32 && viewer.cachedTiles >= 0)).toBe(true) + expect(snapshot.peaks.activeLoaderJobs).toBeLessThanOrEqual(2) + expect(snapshot.peaks.cachedTiles).toBeGreaterThanOrEqual(0) +} + +test('bounds all concurrent workspace and Study preview viewers while resize preserves main slide view', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + test.setTimeout(90_000) + const fixture = await installOverlapFixture(page) + const expectNoBrowserErrors = captureBrowserErrors(page) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/assets/') + + const workspace = page.getByTestId('forge-osd') + const dialog = page.getByRole('dialog', { name: 'Study authoring workspace' }) + const teachingViewport = dialog.getByTestId('teaching-slide-viewport') + const mainCanvas = workspace.locator('.openseadragon-canvas').first() + await expect(page.locator('.forge-viewer-header').getByText(slide.displayName, { exact: true })).toBeVisible() + await expect.poll(() => fixture.activeTiles, { timeout: 15_000 }).toBe(2) + await expect.poll(async () => (await resourceSnapshot(page))?.viewerCount ?? 0).toBe(2) + + const canvasBox = await mainCanvas.boundingBox() + expect(canvasBox).not.toBeNull() + await page.mouse.move(canvasBox!.x + canvasBox!.width / 2, canvasBox!.y + canvasBox!.height / 2) + await page.mouse.wheel(0, -360) + await page.mouse.move(canvasBox!.x + canvasBox!.width / 2, canvasBox!.y + canvasBox!.height / 2) + await page.mouse.down() + await page.mouse.move(canvasBox!.x + canvasBox!.width / 2 + 54, canvasBox!.y + canvasBox!.height / 2 + 28, { steps: 5 }) + await page.mouse.up() + await page.waitForTimeout(250) + + const viewerInstance = await workspace.getAttribute('data-viewer-instance-id') + await page.getByRole('button', { name: 'Zoom in' }).click() + await expect.poll(async () => { + const snapshot = await resourceSnapshot(page) + return snapshot?.viewers.find((viewer) => viewer.role === 'workspace-primary')?.zoom || 0 + }).toBeGreaterThan(1.01) + const zoomedCanvasBox = await mainCanvas.boundingBox() + expect(zoomedCanvasBox).not.toBeNull() + await page.mouse.move(zoomedCanvasBox!.x + zoomedCanvasBox!.width / 2, zoomedCanvasBox!.y + zoomedCanvasBox!.height / 2) + await page.mouse.down() + await page.mouse.move(zoomedCanvasBox!.x + zoomedCanvasBox!.width / 2 + 54, zoomedCanvasBox!.y + zoomedCanvasBox!.height / 2 + 28, { steps: 5 }) + await page.mouse.up() + await page.waitForTimeout(250) + const before = await resourceSnapshot(page) as ResourceSnapshot | null + expect(before).not.toBeNull() + expectResourceBounds(before!) + const mainBefore = before!.viewers.find((viewer) => viewer.role === 'workspace-primary') + expect(mainBefore?.center).not.toBeNull() + expect(mainBefore?.zoom).toBeGreaterThan(0) + + try { + await page.getByRole('button', { name: 'Study Pack authoring' }).click() + const preview = dialog.getByRole('article', { name: 'Exact faculty preview' }) + await expect(preview).toBeVisible() + await preview.scrollIntoViewIfNeeded() + await expect(teachingViewport).toBeVisible() + await expect.poll(async () => { + const snapshot = await resourceSnapshot(page) + return snapshot?.viewers.find((viewer) => viewer.role === 'teaching-preview')?.center ? 1 : 0 + }).toBe(1) + const teachingZoomBefore = ((await resourceSnapshot(page)) as ResourceSnapshot).viewers.find((viewer) => viewer.role === 'teaching-preview')!.zoom! + await preview.getByRole('button', { name: 'Zoom teaching slide in' }).click() + await expect.poll(async () => (await resourceSnapshot(page))?.viewerCount ?? 0).toBe(3) + await expect.poll(async () => { + const snapshot = await resourceSnapshot(page) + return snapshot?.viewers.find((viewer) => viewer.role === 'teaching-preview')?.zoom ?? 0 + }).toBeGreaterThan(teachingZoomBefore + .05) + await expect.poll(async () => (await resourceSnapshot(page))?.waitingLoaders ?? 0).toBeGreaterThan(0) + const contended = await resourceSnapshot(page) as ResourceSnapshot + expectResourceBounds(contended) + expect(contended.activeLoaderJobs).toBe(2) + expect(contended.waitingLoaders).toBeGreaterThan(0) + expect(fixture.activeTiles).toBe(2) + expect(contended.viewers.map((viewer) => viewer.role).sort()).toEqual(['teaching-preview', 'workspace-overview', 'workspace-primary']) + expect(contended.viewers.reduce((total, viewer) => total + viewer.cacheLimit, 0)).toBe(96) + expect(contended.viewers.every((viewer) => viewer.cacheLimit === 32)).toBe(true) + expect(await page.locator('.openseadragon-container').count()).toBe(3) + + for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }]) { + await page.setViewportSize(viewport) + await expect(dialog).toBeVisible() + await expect(workspace).toHaveAttribute('data-viewer-instance-id', viewerInstance!) + const resized = await resourceSnapshot(page) as ResourceSnapshot + expectResourceBounds(resized) + const main = resized.viewers.find((viewer) => viewer.role === 'workspace-primary') + expect(main?.id).toBe(mainBefore!.id) + expect(main?.center?.x).toBeCloseTo(mainBefore!.center!.x, 3) + expect(main?.center?.y).toBeCloseTo(mainBefore!.center!.y, 3) + expect(main?.zoom).toBeGreaterThan(1) + expect(main?.bounds?.width).toBeGreaterThan(0) + expect(main?.bounds?.height).toBeGreaterThan(0) + const documentWidths = await page.evaluate(() => ({ + client: document.documentElement.clientWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + })) + expect(documentWidths.document).toBeLessThanOrEqual(documentWidths.client) + expect(documentWidths.body).toBeLessThanOrEqual(documentWidths.client) + const previewWidth = await teachingViewport.evaluate((element) => element.getBoundingClientRect().width) + expect(previewWidth).toBeLessThanOrEqual(viewport.width) + if (viewport.width === 1440) { + expect(main?.zoom).toBeCloseTo(mainBefore!.zoom!, 3) + expect(main?.bounds?.x).toBeCloseTo(mainBefore!.bounds!.x, 3) + expect(main?.bounds?.y).toBeCloseTo(mainBefore!.bounds!.y, 3) + expect(main?.bounds?.width).toBeCloseTo(mainBefore!.bounds!.width, 3) + expect(main?.bounds?.height).toBeCloseTo(mainBefore!.bounds!.height, 3) + } + expect(fixture.maxActiveTiles).toBeLessThanOrEqual(2) + } + } finally { + fixture.releaseTiles() + } + + await expect.poll(() => fixture.activeTiles, { timeout: 20_000 }).toBe(0) + await expect.poll(() => fixture.teachingTileRequests, { timeout: 20_000 }).toBeGreaterThan(0) + await expect(dialog.getByText('Exact associated teaching pixels loaded. Inspect every question location before reviewing.')).toBeVisible() + await waitForSettledLoaders(page, fixture) + const settled = await resourceSnapshot(page) as ResourceSnapshot + expectResourceBounds(settled) + expect(settled.activeLoaderJobs).toBe(0) + expect(settled.queuedLoaderJobs).toBe(0) + expect(settled.waitingLoaders).toBe(0) + expect(settled.scheduledLoaders).toBe(0) + expect(settled.peaks.activeLoaderJobs).toBeLessThanOrEqual(2) + console.log('OSD cache configuration and observed counts', JSON.stringify({ + configured: settled.viewers.map(({ role, cacheLimit }) => ({ role, cacheLimit })), + actualLoadedTiles: settled.viewers.map(({ role, cachedTiles }) => ({ role, cachedTiles })), + aggregateActualLoadedTiles: settled.cachedTiles, + peakAggregateActualLoadedTiles: settled.peaks.cachedTiles, + })) + expect(fixture.maxActiveTiles).toBeLessThanOrEqual(2) + expect(await page.locator('.openseadragon-container').count()).toBe(3) + expect(await workspace.getAttribute('data-viewer-instance-id')).toBe(viewerInstance) + await page.screenshot({ path: testInfo.outputPath('study-preview-loaded-landmarks-desktop.png') }) + await page.setViewportSize({ width: 390, height: 844 }) + await expect(dialog).toBeVisible() + const phoneWidths = await page.evaluate(() => ({ client: document.documentElement.clientWidth, document: document.documentElement.scrollWidth, body: document.body.scrollWidth })) + expect(phoneWidths.document).toBeLessThanOrEqual(phoneWidths.client) + expect(phoneWidths.body).toBeLessThanOrEqual(phoneWidths.client) + expect(await teachingViewport.evaluate((element) => element.getBoundingClientRect().width)).toBeLessThanOrEqual(390) + await page.screenshot({ path: testInfo.outputPath('study-preview-loaded-landmarks-phone.png') }) + expectNoBrowserErrors() +}) + +test('replaces the main slide while source tile requests are held and retires the old viewers cleanly', async ({ page }) => { + test.skip(test.info().project.name !== 'chromium') + test.setTimeout(60_000) + const fixture = await installOverlapFixture(page) + const expectNoBrowserErrors = captureBrowserErrors(page) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/assets/') + + const workspace = page.getByTestId('forge-osd') + await expect(page.locator('.forge-viewer-header').getByText(slide.displayName, { exact: true })).toBeVisible() + await expect.poll(() => fixture.activeTiles, { timeout: 15_000 }).toBe(2) + const initialInstanceId = await workspace.getAttribute('data-viewer-instance-id') + const initial = await resourceSnapshot(page) as ResourceSnapshot + expectResourceBounds(initial) + expect(initial.activeLoaderJobs).toBe(2) + expect(initial.viewers.map((viewer) => viewer.role).sort()).toEqual(['workspace-overview', 'workspace-primary']) + + await page.getByRole('button', { name: /Replacement synthetic landmarks\.ome\.tif/ }).click() + await expect(page.locator('.forge-viewer-header').getByText(replacementSlide.displayName, { exact: true })).toBeVisible() + await expect.poll(() => workspace.getAttribute('data-viewer-instance-id')).not.toBe(initialInstanceId) + const replacement = await resourceSnapshot(page) as ResourceSnapshot + expectResourceBounds(replacement) + expect(replacement.viewerCount).toBe(2) + expect(replacement.activeLoaderJobs).toBeLessThanOrEqual(2) + expect(replacement.viewers.filter((viewer) => !viewer.retired).map((viewer) => viewer.role).sort()).toEqual(['workspace-overview', 'workspace-primary']) + const retiringOldPair = replacement.viewers.filter((viewer) => viewer.retired) + expect(retiringOldPair).toHaveLength(2) + expect(retiringOldPair.every((viewer) => viewer.jobsInProgress > 0)).toBe(true) + expect(replacement.trackedViewerCount).toBe(4) + expect(await page.locator('.openseadragon-container').count()).toBe(2) + + fixture.releaseTiles() + await expect.poll(() => fixture.activeTiles, { timeout: 20_000 }).toBe(0) + await expect.poll(() => fixture.replacementTileRequests, { timeout: 20_000 }).toBeGreaterThan(0) + await expect.poll(async () => (await resourceSnapshot(page))?.trackedViewerCount ?? -1, { timeout: 20_000 }).toBe(2) + await waitForSettledLoaders(page, fixture) + const settled = await resourceSnapshot(page) as ResourceSnapshot + expectResourceBounds(settled) + expect(settled.activeLoaderJobs).toBe(0) + expect(settled.queuedLoaderJobs).toBe(0) + expect(settled.waitingLoaders).toBe(0) + expect(settled.scheduledLoaders).toBe(0) + expect(settled.viewers.every((viewer) => !viewer.retired)).toBe(true) + expect(await workspace.getAttribute('data-viewer-instance-id')).not.toBe(initialInstanceId) + expect(await page.locator('.openseadragon-container').count()).toBe(2) + expectNoBrowserErrors() +}) diff --git a/frontend/e2e/workspace-states.pw.ts b/frontend/e2e/workspace-states.pw.ts new file mode 100644 index 0000000..f3819f8 --- /dev/null +++ b/frontend/e2e/workspace-states.pw.ts @@ -0,0 +1,136 @@ +import { expect, test, type Page, type Route } from '@playwright/test' +import { captureBrowserErrors } from './browserErrors' + +type StateOptions = { + datasets?: unknown[] + connected?: boolean + scopes?: string[] + studyFailure?: boolean + featureFailureOnOpen?: boolean + holdFeatureLoadOnOpen?: boolean +} + +async function installWorkspaceState(page: Page, options: StateOptions = {}) { + const expectNoBrowserErrors = captureBrowserErrors(page, (status, responseUrl) => { + if (status !== 503) return false + const pathname = new URL(responseUrl).pathname + return Boolean(options.studyFailure && pathname === '/api/study/drafts') + || Boolean(options.featureFailureOnOpen && pathname === '/api/features') + }) + let featuresCalls = 0 + let releaseFeatureLoad: (() => void) | undefined + let signalBootstrapFeatures: (() => void) | undefined + const bootstrapFeatures = new Promise((resolve) => { signalBootstrapFeatures = resolve }) + await page.route('**/api/**', async (route: Route) => { + const url = new URL(route.request().url()) + if (url.pathname === '/api/session') return route.fulfill({ json: {}, headers: { 'X-Forge-CSRF': 'synthetic' } }) + if (url.pathname === '/api/datasets') return route.fulfill({ json: { datasets: options.datasets ?? [] }, headers: { ETag: 'workspace-state' } }) + if (url.pathname === '/api/capabilities') return route.fulfill({ json: { + conversionRuntime: 'Synthetic fixture', derivativeRuntime: 'Synthetic fixture', vsiConversion: false, + dziGeneration: false, downsamples: [1, 2, 4], usableBytes: 1_000_000, effectiveCapacityBytes: 2_000_000, + } }) + if (url.pathname === '/api/viewer/connection') return route.fulfill({ json: { + connected: options.connected ?? false, viewerUrl: options.connected ? 'https://viewer.synthetic.invalid' : '', + deviceName: options.connected ? 'Synthetic Viewer' : '', scopes: options.scopes ?? [], conversionMode: 'PREPARED_DZI_V2', + } }) + if (url.pathname === '/api/viewer/sync') return route.fulfill({ json: { items: [], folders: [], conflicts: [] } }) + if (url.pathname === '/api/features') { + featuresCalls++ + if (featuresCalls >= 2) signalBootstrapFeatures?.() + if (options.holdFeatureLoadOnOpen && featuresCalls === 3) { + await new Promise((resolve) => { releaseFeatureLoad = resolve }) + } + if (options.featureFailureOnOpen && featuresCalls === 3) { + return route.fulfill({ status: 503, json: { detail: 'Catalog temporarily unavailable.\nhttps://feature-catalog.synthetic.invalid/very/long/path/that/must-wrap-without-causing-horizontal-overflow' } }) + } + return route.fulfill({ json: { features: [] } }) + } + if (url.pathname === '/api/study/drafts') { + if (options.studyFailure) return route.fulfill({ status: 503, json: { detail: 'Draft service unavailable.\nRetry later at https://study.synthetic.invalid/recovery/with/a/long/unbroken/identifier-that-must-wrap-in-a-short-dialog' } }) + return route.fulfill({ json: [] }) + } + if (url.pathname === '/api/analysis/runs') return route.fulfill({ json: [] }) + if (url.pathname.endsWith('/annotations')) return route.fulfill({ json: { annotations: [] } }) + if (url.pathname.endsWith('/artifacts')) return route.fulfill({ json: { currentRevision: '', approvedRevision: '', revisions: [] } }) + return route.fulfill({ status: 404, json: { detail: `No workspace fixture for ${url.pathname}` } }) + }) + return { + expectNoBrowserErrors, + waitForBootstrapFeatures: () => bootstrapFeatures, + releaseFeatureLoad: () => releaseFeatureLoad?.(), + } +} + +test('shows the empty local and connected Viewer library states without overflowing a phone', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + const fixture = await installWorkspaceState(page, { connected: true, scopes: ['library:read'] }) + await page.setViewportSize({ width: 390, height: 480 }) + await page.goto('/assets/') + await page.getByRole('button', { name: 'Local library' }).click() + await expect(page.getByText('No local slides')).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + await page.screenshot({ path: testInfo.outputPath('empty-local-library-phone.png') }) + + await page.getByRole('button', { name: 'Viewer library' }).click() + await expect(page.getByText('No private Viewer slides.')).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + await page.screenshot({ path: testInfo.outputPath('empty-viewer-library-phone.png') }) + fixture.expectNoBrowserErrors() +}) + +test('keeps Study authoring input, error, and close actions reachable in a short viewport without write permission', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + const fixture = await installWorkspaceState(page, { connected: true, scopes: ['library:read'] }) + await page.setViewportSize({ width: 390, height: 480 }) + await page.goto('/assets/') + await page.getByRole('button', { name: 'Study Pack authoring' }).click() + const dialog = page.getByRole('dialog', { name: 'Study authoring workspace' }) + await expect(dialog.getByRole('heading', { name: 'Study Pack authoring' })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Refresh eligible teaching slides from Viewer' })).toHaveCount(0) + const draftName = dialog.getByLabel('Draft name') + await draftName.fill('Short viewport draft name stays in state') + await page.setViewportSize({ width: 390, height: 320 }) + await expect(draftName).toHaveValue('Short viewport draft name stays in state') + await expect(draftName).toBeFocused() + const box = await dialog.boundingBox() + expect(box).not.toBeNull() + expect(box!.y).toBeGreaterThanOrEqual(0) + expect(box!.y + box!.height).toBeLessThanOrEqual(320) + await dialog.getByRole('button', { name: 'Close Study authoring' }).scrollIntoViewIfNeeded() + await expect(dialog.getByRole('button', { name: 'Close Study authoring' })).toBeInViewport() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + await page.screenshot({ path: testInfo.outputPath('study-authoring-short-phone.png'), fullPage: true }) + fixture.expectNoBrowserErrors() +}) + +test('keeps Study draft errors and close control reachable at 320px height', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + const fixture = await installWorkspaceState(page, { studyFailure: true }) + await page.setViewportSize({ width: 390, height: 320 }) + await page.goto('/assets/') + await page.getByRole('button', { name: 'Study Pack authoring' }).click() + const dialog = page.getByRole('dialog', { name: 'Study authoring workspace' }) + await expect(dialog.getByRole('alert')).toContainText('Draft service unavailable.') + await expect(dialog.getByRole('button', { name: 'Retry loading drafts' })).toBeInViewport() + await dialog.getByRole('button', { name: 'Close Study authoring' }).scrollIntoViewIfNeeded() + await expect(dialog.getByRole('button', { name: 'Close Study authoring' })).toBeInViewport() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + fixture.expectNoBrowserErrors() +}) + +test('covers Feature Center loading, empty, and multiline catalog error states', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'chromium') + const fixture = await installWorkspaceState(page, { holdFeatureLoadOnOpen: true, featureFailureOnOpen: true }) + await page.setViewportSize({ width: 390, height: 480 }) + await page.goto('/assets/') + await fixture.waitForBootstrapFeatures() + await page.getByRole('button', { name: 'Feature Center' }).click() + const dialog = page.getByRole('dialog', { name: 'Feature Center' }) + await expect(dialog.getByRole('status')).toContainText('Checking installed features…') + fixture.releaseFeatureLoad() + await expect(page.getByText(/Catalog temporarily unavailable\./)).toBeVisible() + await expect(dialog.getByRole('status')).toContainText('No features are published.') + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + await page.screenshot({ path: testInfo.outputPath('feature-center-error-phone.png') }) + fixture.expectNoBrowserErrors() +}) diff --git a/frontend/package.json b/frontend/package.json index 6a99ed9..5451bc5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "tsc -b && vite build && node scripts/check-bundle.mjs", "dev": "vite --host 127.0.0.1", + "test:responsive": "playwright test", "test": "vitest run" }, "dependencies": { @@ -17,6 +18,8 @@ "uqr": "0.1.3" }, "devDependencies": { + "@axe-core/playwright": "4.10.2", + "@playwright/test": "1.61.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "^16.3.0", "@types/react": "^19.1.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..3e171a7 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,34 @@ +import { tmpdir } from 'node:os' +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +const outputDir = process.env.PATHLAB_FORGE_PLAYWRIGHT_OUTPUT + || path.join(tmpdir(), 'pathlab-forge-responsive-playwright') + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.pw.ts', + outputDir, + fullyParallel: true, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:5175', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + viewport: { width: 1280, height: 720 }, + colorScheme: 'light', + reducedMotion: 'no-preference', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'chromium-touch', use: { ...devices['Pixel 7'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], + webServer: { + command: 'pnpm exec vite --host 127.0.0.1 --port 5175 --strictPort', + url: 'http://127.0.0.1:5175/assets/', + reuseExistingServer: false, + timeout: 30_000, + }, +}) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1c54cd6..3b4e7dc 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -27,6 +27,12 @@ importers: specifier: 0.1.3 version: 0.1.3 devDependencies: + '@axe-core/playwright': + specifier: 4.10.2 + version: 4.10.2(playwright-core@1.61.1) + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 '@testing-library/jest-dom': specifier: 6.9.1 version: 6.9.1 @@ -63,6 +69,11 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@axe-core/playwright@4.10.2': + resolution: {integrity: sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -364,6 +375,11 @@ packages: react: '>= 16.8' react-dom: '>= 16.8' + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -618,6 +634,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + axe-core@4.10.3: + resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} + engines: {node: '>=4'} + baseline-browser-mapping@2.11.5: resolution: {integrity: sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==} engines: {node: '>=6.0.0'} @@ -721,6 +741,11 @@ packages: picomatch: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -833,6 +858,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.23: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} @@ -1094,6 +1129,11 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@axe-core/playwright@4.10.2(playwright-core@1.61.1)': + dependencies: + axe-core: 4.10.3 + playwright-core: 1.61.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1335,6 +1375,10 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.62.3': @@ -1550,6 +1594,8 @@ snapshots: assertion-error@2.0.1: {} + axe-core@4.10.3: {} + baseline-browser-mapping@2.11.5: {} browserslist@4.28.7: @@ -1651,6 +1697,9 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -1755,6 +1804,14 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.23: dependencies: nanoid: 3.3.16 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07494a6..8bb19ef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,7 +38,7 @@ import { Wrench, X, } from '@phosphor-icons/react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import type { FormEvent, ReactNode, Ref } from 'react' import type OpenSeadragon from 'openseadragon' import { renderSVG } from 'uqr' @@ -61,6 +61,7 @@ import { teachingAssociationFor, captureTeachingTarget } from './teachingAssocia import { BatchReports, type BatchSummary } from './BatchReports' import type { MaskChannel } from './AnalysisMaskOverlay' import { DIRECT_PREVIEW_VERSION } from './viewerConfig' +import { createResponsivePanelsState, isPanelDocked, isPanelOpen, responsivePanelsReducer, type ResponsivePanel } from './responsivePanels' const ACTIVE_STATUSES = new Set(['VERIFYING_SOURCE', 'INSPECTING', 'QUEUED', 'WAITING_RESOURCES', 'CONVERTING', 'OPTIMIZING_OME', 'VALIDATING', 'GENERATING_DZI', 'DZI_READY']) const CONVERSION_STATUSES = new Set(['CONVERTING', 'OPTIMIZING_OME', 'VALIDATING', 'GENERATING_DZI', 'DZI_READY']) @@ -68,7 +69,6 @@ const CANCELLABLE_STATUSES = new Set(['QUEUED', 'WAITING_RESOURCES', ...CONVERSI const QUEUEABLE_STATUSES = new Set(['READY', 'READY_TO_CONVERT', 'CONVERSION_READY', 'FAILED', 'CANCELLED']) const NO_ANNOTATIONS: AnnotationRecord[] = [] const DEFAULT_LOCAL_FOLDER = 'Unfiled' -const isCompactWorkspace = () => typeof window !== 'undefined' && window.innerWidth <= 1180 function viewerVisibility(slide?: Pick) { if (slide?.visibility) return slide.visibility === 'published' ? 'Published' : 'Private' @@ -90,10 +90,22 @@ export function App() { const [seriesByDataset, setSeriesByDataset] = useState>({}) const [artifactByDataset, setArtifactByDataset] = useState>({}) const [viewingRevisionByDataset, setViewingRevisionByDataset] = useState>({}) - const [navigatorOpen, setNavigatorOpen] = useState( - () => typeof window === 'undefined' || window.innerWidth > 960, - ) - const [inspectorOpen, setInspectorOpen] = useState(true) + const [panels, dispatchPanels] = useReducer(responsivePanelsReducer, typeof window === 'undefined' ? 1440 : window.innerWidth, createResponsivePanelsState) + const panelsRef = useRef(panels) + panelsRef.current = panels + const pendingResponsiveFocus = useRef(null) + const previousPanelVisibility = useRef<{ navigator: boolean; inspector: boolean } | null>(null) + const updatePanels = (action: Parameters[1]) => { + panelsRef.current = responsivePanelsReducer(panelsRef.current, action) + dispatchPanels(action) + } + const closePanel = (panel: ResponsivePanel) => { + const root = document.querySelector(panel === 'navigator' ? '#library-navigator' : '.pathlab-viewer-inspector') + if (root && document.activeElement instanceof HTMLElement && root.contains(document.activeElement)) pendingResponsiveFocus.current = panel + updatePanels({ type: 'close', panel }) + } + const navigatorOpen = isPanelOpen(panels, 'navigator') + const inspectorPreferenceOpen = isPanelOpen(panels, 'inspector') const [railExpanded, setRailExpanded] = useState(false) const [activeTool, setActiveTool] = useState('pan') const [selectedAnnotationId, setSelectedAnnotationId] = useState('') @@ -181,7 +193,7 @@ export function App() { const selected = datasets.find((item) => item.id === selectedId) ?? datasets[0] analysisContext.current = `${selected?.id}|${selected?.viewRevision}|${selected?.configurationRevision}` const overlayRun = analysisRuns.find((run) => run.id === analysisOverlay?.runId) - const inspectorVisible = inspectorOpen && Boolean(libraryMode === 'viewer' ? remoteLibrary.items.length : selected) + const inspectorVisible = inspectorPreferenceOpen && Boolean(libraryMode === 'viewer' ? remoteLibrary.items.length : selected) const selectedRemote = remoteLibrary.items.find((item) => item.id === selectedRemoteId) ?? remoteLibrary.items[0] const cropDraft = selected ? cropDrafts[selected.id] : undefined @@ -273,11 +285,74 @@ export function App() { }, [folderByDataset]) useEffect(() => { + const onResize = () => { + const before = panelsRef.current + const width = window.innerWidth + if (width === before.width) return + const next = responsivePanelsReducer(before, { type: 'resize', width }) + const active = document.activeElement + for (const panel of ['navigator', 'inspector'] as const) { + if (isPanelOpen(before, panel) && !isPanelOpen(next, panel) + && active instanceof HTMLElement && active.closest(panel === 'navigator' ? '#library-navigator' : '.pathlab-viewer-inspector')) { + pendingResponsiveFocus.current = panel + } + } + updatePanels({ type: 'resize', width }) + } + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + useEffect(() => { + const navigator = document.getElementById('library-navigator') const inspector = document.querySelector('.pathlab-viewer-inspector') - if (!inspector) return + if (!navigator || !inspector) return + navigator.inert = !navigatorOpen + navigator.setAttribute('aria-hidden', navigatorOpen ? 'false' : 'true') inspector.inert = !inspectorVisible inspector.setAttribute('aria-hidden', inspectorVisible ? 'false' : 'true') - }, [inspectorVisible]) + const previous = previousPanelVisibility.current ?? { navigator: navigatorOpen, inspector: inspectorVisible } + const focusInto = (root: HTMLElement) => { + root.setAttribute('tabindex', '-1') + const first = root.querySelector('button:not([disabled]), select:not([disabled]), textarea:not([disabled]), input:not([type="hidden"]):not([disabled]), [tabindex="0"]') + ;(first ?? root).focus({ preventScroll: true }) + } + if (navigatorOpen && !previous.navigator && !isPanelDocked('navigator', panels.width)) focusInto(navigator) + if (inspectorVisible && !previous.inspector && !isPanelDocked('inspector', panels.width)) focusInto(inspector) + const restore = pendingResponsiveFocus.current + if (restore && !isPanelOpen(panels, restore)) { + const replacement = restore === 'navigator' + ? document.querySelector(libraryMode === 'viewer' ? '[aria-label="Viewer library"]' : '[aria-label="Local library"]') + ?? document.querySelector('[aria-label="Local library"]') + : document.querySelector('.pathlab-viewer-stage button[aria-controls="forge-slide-inspector"]') + if (replacement && replacement.getClientRects().length) replacement.focus({ preventScroll: true }) + pendingResponsiveFocus.current = null + } + previousPanelVisibility.current = { navigator: navigatorOpen, inspector: inspectorVisible } + }, [navigatorOpen, inspectorVisible, panels.width, libraryMode]) + + useEffect(() => { + const surfaces: Array<{ panel: ResponsivePanel; element: HTMLElement | null; visible: boolean }> = [ + { panel: 'navigator', element: document.getElementById('library-navigator'), visible: navigatorOpen }, + { panel: 'inspector', element: document.querySelector('.pathlab-viewer-inspector'), visible: inspectorVisible }, + ] + const listeners = surfaces.flatMap(({ panel, element, visible }) => { + if (!element || !visible || isPanelDocked(panel, panels.width)) return [] + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + const modal = [...document.querySelectorAll('[role="dialog"]')] + .find((candidate) => candidate !== element && !candidate.contains(element) && candidate.getClientRects().length > 0) + if (modal) return + event.preventDefault() + event.stopPropagation() + if (document.activeElement instanceof HTMLElement && element.contains(document.activeElement)) pendingResponsiveFocus.current = panel + updatePanels({ type: 'close', panel }) + } + element.addEventListener('keydown', onKeyDown, true) + return [{ element, onKeyDown }] + }) + return () => listeners.forEach(({ element, onKeyDown }) => element.removeEventListener('keydown', onKeyDown, true)) + }, [navigatorOpen, inspectorVisible, panels.width]) const refresh = useCallback(async () => { try { @@ -545,7 +620,7 @@ export function App() { const syncViewer = async (revealLibrary = true) => { let epoch = remoteEpoch.current - if (revealLibrary) { setLibraryMode('viewer'); setNavigatorOpen(true); if (isCompactWorkspace()) setInspectorOpen(false) } + if (revealLibrary) { setLibraryMode('viewer'); updatePanels({ type: 'open', panel: 'navigator' }) } try { const next = await api.getViewerConnection() if (epoch !== remoteEpoch.current) return @@ -899,8 +974,7 @@ export function App() { onToggleExpanded={() => setRailExpanded((current) => !current)} onLocalLibrary={() => { setLibraryMode('local') - setNavigatorOpen(true) - if (isCompactWorkspace()) setInspectorOpen(false) + updatePanels({ type: 'open', panel: 'navigator' }) }} onViewerLibrary={() => void syncViewer()} onImport={() => { setImportError(''); setImportOpen(true) }} @@ -940,10 +1014,7 @@ export function App() { folderByDataset={folderByDataset} onSelect={(id) => { setSelectedId(id) - if (isCompactWorkspace()) { - setNavigatorOpen(false) - setInspectorOpen(true) - } + if (panels.width <= 1200) updatePanels({ type: 'open', panel: 'inspector' }) }} onChecked={setSelectedDatasetIds} onCreateFolder={(name) => setLocalFolders((current) => current.includes(name) ? current : [...current, name])} @@ -966,8 +1037,7 @@ export function App() { }} onResolveConflict={(id, field, resolution) => void remoteAction(() => api.resolveViewerConflict(id, field, resolution), true)} onCollapse={() => { - setNavigatorOpen(false) - window.requestAnimationFrame(() => navigatorButtonRef.current?.focus()) + closePanel('navigator') }} /> )} @@ -977,7 +1047,7 @@ export function App() { viewer={viewer} onViewer={setViewer} inspectorOpen={inspectorVisible} - onInspector={() => setInspectorOpen((current) => !current)} + onInspector={() => updatePanels({ type: 'toggle', panel: 'inspector' })} /> ) : ( { setSelectedAnnotationId(id); setActiveTool('select') }} onUpdateAnnotation={(id, geometry) => void updateLocalAnnotation(id, { geometry })} inspectorOpen={inspectorVisible} - onInspector={() => setInspectorOpen((current) => !current)} + onInspector={() => updatePanels({ type: 'toggle', panel: 'inspector' })} /> )} inspector={libraryMode === 'viewer' ? ( setInspectorOpen(false)} + onCollapse={() => closePanel('inspector')} onKeepOffline={(id) => void remoteAction(() => api.keepViewerSlideOffline(id))} onCancelOffline={(id) => void remoteAction(() => api.cancelViewerOfflineDownload(id))} onRemoveOffline={(id) => void remoteAction(() => api.removeViewerSlideOffline(id))} @@ -1036,7 +1106,7 @@ export function App() { setCropEditing(editing) if (editing) setActiveTool('pan') }} - onCollapse={() => setInspectorOpen(false)} + onCollapse={() => closePanel('inspector')} onInspect={inspect} onConfigure={updateConfiguration} onUpdateView={updateViewDefinition} @@ -1103,7 +1173,7 @@ export function App() { isError={Boolean(error)} onClearError={() => setError('')} onQueueReady={() => void queueReadySlides()} - onOpen={() => setInspectorOpen(false)} + onOpen={() => closePanel('inspector')} paused={capabilities?.queuePaused === true} onPause={() => { void api.setQueuePaused(capabilities?.queuePaused !== true).then((state) => { @@ -1223,9 +1293,9 @@ export function App() { slides={[...teachingSlides, ...datasets.map((dataset) => ({ viewerSlideId: `local:${dataset.id}`, displayName: `${dataset.displayName} (local draft)`, sha256: '' }))]} teachingArtifacts={Object.entries(artifactByDataset).flatMap(([datasetId, artifacts]) => artifacts.filter((artifact) => artifact.format === 'PREPARED_DZI_V2' && ['READY', 'APPROVED'].includes(artifact.status)).map((artifact) => ({ datasetId, artifactRevision: artifact.id, displayName: `${datasets.find((dataset) => dataset.id === datasetId)?.displayName || datasetId} · ${artifact.name}` })))} onAssociateTeachingSlide={(id, revision, referenceId, datasetId, artifactRevision) => rememberStudy(api.associateTeachingSlide(id, revision, referenceId, datasetId, artifactRevision))} - renderSlide={(slide, onLocation, onPixelsLoaded, draft) => { + renderSlide={(slide, onLocation, onPixelsLoaded, draft, selectedLocation) => { const association = teachingAssociationFor(slide, draft.associations) - return association && slide ? :

Select an exact local teaching artifact before reviewing these pixels.

+ return association && slide ? :

Select an exact local teaching artifact before reviewing these pixels.

}} onCaptureSpatial={async (slide, draft) => { const association = teachingAssociationFor(slide, draft.associations), roi = selectedAnnotations.find((annotation) => annotation.id === selectedAnnotationId) @@ -1348,7 +1418,7 @@ function ForgeProductRail({ return (