From 5f1a6245114be2f262ba241711bc740260b45602 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:26:26 -0500 Subject: [PATCH 01/46] feat: add viewers configuration template --- docs/viewers.config.yaml.template | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/viewers.config.yaml.template diff --git a/docs/viewers.config.yaml.template b/docs/viewers.config.yaml.template new file mode 100644 index 00000000..3883399e --- /dev/null +++ b/docs/viewers.config.yaml.template @@ -0,0 +1,51 @@ +# Fileglancer OME-Zarr Viewers Configuration +# +# This file defines which OME-Zarr viewers are available in your Fileglancer deployment. +# The @bioimagetools/capability-manifest library is used to determine compatibility +# for viewers that have a capability manifest. +# +# To use this file: +# 1. Copy this template to the project root: cp docs/viewers.config.yaml.template viewers.config.yaml +# 2. Uncommented viewers will be shown in your deployment +# 3. Check the values provided for each viewer - see guidelines below. +# +# For viewers with capability manifests, you must provide: +# - name: must match name value in capability manifest +# Optionally: +# - url: to override the template url in the capability manifest +# - logo: to override the default logo at {name}.png +# - label: Custom tooltip text (defaults to "View in {Name}") +# +# For viewers without capability manifests, you must provide: +# - name: Viewer identifier +# - url: URL template (use `{dataLink}` placeholder for dataset URL) +# - ome_zarr_versions: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) +# Optionally: +# - logo: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) +# - label: Custom tooltip text (defaults to "View in {Name}") + +viewers: + # OME-Zarr viewers with capability manifests + - name: neuroglancer + + - name: avivator + # Optional: Override the viewer URL from the capability manifest + # In this example, override to use Janelia's custom deployment + url: "https://janeliascicomp.github.io/viv/" + + # # OME-Zarr viewers without capability manifests + # # Example: + # OME-Zarr Validator + # Logo will automatically resolve to @/assets/validator.png + - name: validator + url: "https://ome.github.io/ome-ngff-validator/?source={dataLink}" + ome_zarr_versions: [0.4, 0.5] + label: "View in OME-Zarr Validator" + + # # Example: + # # Vol-E - Allen Cell Explorer 3D viewer + # # Logo will automatically resolve to @/assets/vole.png + - name: vole + url: "https://volumeviewer.allencell.org/viewer?url={dataLink}" + ome_zarr_versions: [0.4] + label: "View in Vol-E" From d0bf67c86b535d9ec054d27b4bd2811ed66933fb Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:28:08 -0500 Subject: [PATCH 02/46] feat: add viewer configuration types and YAML parser --- frontend/.gitignore | 1 + frontend/src/config/viewerLogos.ts | 34 +++++++++ frontend/src/config/viewersConfig.ts | 110 +++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/src/config/viewerLogos.ts create mode 100644 frontend/src/config/viewersConfig.ts diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..c979e0f1 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +src/config/viewers.config.yaml diff --git a/frontend/src/config/viewerLogos.ts b/frontend/src/config/viewerLogos.ts new file mode 100644 index 00000000..33ed1cd5 --- /dev/null +++ b/frontend/src/config/viewerLogos.ts @@ -0,0 +1,34 @@ +import fallback_logo from "@/assets/error_icon_gradient.png"; + +/** + * Fallback logo for viewers without a specified logo + */ +export const FALLBACK_LOGO = fallback_logo; + +/** + * Get logo path for a viewer + * Logo resolution order: + * 1. If customLogoPath is provided, use that from @/assets/ + * 2. If not, try to load @/assets/{viewerName}.png + * 3. If not found, use fallback logo + * + * @param viewerName - Name of the viewer (case-insensitive) + * @param customLogoPath - Optional custom logo filename from config (e.g., "my-logo.png") + * @returns Logo path to use + */ +export function getViewerLogo( + viewerName: string, + customLogoPath?: string, +): string { + const logoFileName = customLogoPath || `${viewerName.toLowerCase()}.png`; + + try { + // Try to dynamically import the logo from assets + // This will be resolved at build time by Vite + const logo = new URL(`../assets/${logoFileName}`, import.meta.url).href; + return logo; + } catch (error) { + // If logo not found, return fallback + return FALLBACK_LOGO; + } +} diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts new file mode 100644 index 00000000..0d9e2bc3 --- /dev/null +++ b/frontend/src/config/viewersConfig.ts @@ -0,0 +1,110 @@ +import yaml from "js-yaml"; + +/** + * Viewer entry from viewers.config.yaml + */ +export interface ViewerConfigEntry { + name: string; + url?: string; + label?: string; + logo?: string; + ome_zarr_versions?: number[]; +} + +/** + * Structure of viewers.config.yaml + */ +export interface ViewersConfigYaml { + viewers: ViewerConfigEntry[]; +} + +/** + * Parse and validate viewers configuration YAML + * @param yamlContent - The YAML content to parse + * @param viewersWithManifests - Array of viewer names that have capability manifests (from initializeViewerManifests) + */ +export function parseViewersConfig( + yamlContent: string, + viewersWithManifests: string[] = [], +): ViewersConfigYaml { + let parsed: unknown; + + try { + parsed = yaml.load(yamlContent); + } catch (error) { + throw new Error( + `Failed to parse viewers configuration YAML: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("Configuration must be an object"); + } + + const config = parsed as Record; + + if (!Array.isArray(config.viewers)) { + throw new Error('Configuration must have a "viewers" array'); + } + + // Normalize viewer names for comparison (case-insensitive) + const normalizedManifestViewers = viewersWithManifests.map((name) => + name.toLowerCase(), + ); + + // Validate each viewer entry + for (const viewer of config.viewers) { + if (!viewer || typeof viewer !== "object") { + throw new Error("Each viewer must be an object"); + } + + const v = viewer as Record; + + if (typeof v.name !== "string") { + throw new Error('Each viewer must have a "name" field (string)'); + } + + // Check if this viewer has a capability manifest + const hasManifest = normalizedManifestViewers.includes( + v.name.toLowerCase(), + ); + + // If this viewer doesn't have a capability manifest, require additional fields + if (!hasManifest) { + if (typeof v.url !== "string") { + throw new Error( + `Viewer "${v.name}" does not have a capability manifest and must specify "url"`, + ); + } + if ( + !Array.isArray(v.ome_zarr_versions) || + v.ome_zarr_versions.length === 0 + ) { + throw new Error( + `Viewer "${v.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)`, + ); + } + } + + // Validate optional fields if present + if (v.url !== undefined && typeof v.url !== "string") { + throw new Error(`Viewer "${v.name}": "url" must be a string`); + } + if (v.label !== undefined && typeof v.label !== "string") { + throw new Error(`Viewer "${v.name}": "label" must be a string`); + } + if (v.logo !== undefined && typeof v.logo !== "string") { + throw new Error(`Viewer "${v.name}": "logo" must be a string`); + } + if ( + v.ome_zarr_versions !== undefined && + !Array.isArray(v.ome_zarr_versions) + ) { + throw new Error( + `Viewer "${v.name}": "ome_zarr_versions" must be an array`, + ); + } + } + + return config as ViewersConfigYaml; +} From 66204864f5cab863d18139116a826521df50c313 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:29:10 -0500 Subject: [PATCH 03/46] feat: add ViewersContext for dynamic viewer configuration --- frontend/src/contexts/ViewersContext.tsx | 234 +++++++++++++++++++++++ frontend/src/layouts/MainLayout.tsx | 41 ++-- 2 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 frontend/src/contexts/ViewersContext.tsx diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx new file mode 100644 index 00000000..cbe0bd14 --- /dev/null +++ b/frontend/src/contexts/ViewersContext.tsx @@ -0,0 +1,234 @@ +import { + createContext, + useContext, + useState, + useEffect, + type ReactNode, +} from "react"; +import { + initializeViewerManifests, + getCompatibleViewers as getCompatibleViewersFromManifest, + type ViewerManifest, + type OmeZarrMetadata, +} from "@bioimagetools/capability-manifest"; +import { default as log } from "@/logger"; +import { + parseViewersConfig, + type ViewerConfigEntry, +} from "@/config/viewersConfig"; +import { getViewerLogo } from "@/config/viewerLogos"; + +/** + * Validated viewer with all necessary information + */ +export interface ValidViewer { + /** Internal key for this viewer (normalized name) */ + key: string; + /** Display name */ + displayName: string; + /** URL template (may contain {dataLink} placeholder) */ + urlTemplate: string; + /** Logo path */ + logoPath: string; + /** Tooltip/alt text label */ + label: string; + /** Associated capability manifest (if available) */ + manifest?: ViewerManifest; + /** Supported OME-Zarr versions (for viewers without manifests) */ + supportedVersions?: number[]; +} + +interface ViewersContextType { + validViewers: ValidViewer[]; + isInitialized: boolean; + error: string | null; + getCompatibleViewers: (metadata: OmeZarrMetadata) => ValidViewer[]; +} + +const ViewersContext = createContext(undefined); + +/** + * Load viewers configuration from build-time config file + * @param viewersWithManifests - Array of viewer names that have capability manifests + */ +async function loadViewersConfig( + viewersWithManifests: string[], +): Promise { + let configYaml: string; + + try { + // Try to dynamically import the config file + // This will be resolved at build time by Vite + const module = await import("@/config/viewers.config.yaml?raw"); + configYaml = module.default; + log.info( + "Using custom viewers configuration from src/config/viewers.config.yaml", + ); + } catch (error) { + log.info( + "No custom viewers.config.yaml found, using default configuration (neuroglancer only)", + ); + // Return default configuration + return [{ name: "neuroglancer" }]; + } + + try { + const config = parseViewersConfig(configYaml, viewersWithManifests); + return config.viewers; + } catch (error) { + log.error("Error parsing viewers configuration:", error); + throw error; + } +} + +/** + * Normalize viewer name to a valid key + */ +function normalizeViewerName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +export function ViewersProvider({ children }: { children: ReactNode }) { + const [validViewers, setValidViewers] = useState([]); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(null); + const [manifests, setManifests] = useState([]); + + useEffect(() => { + async function initialize() { + try { + log.info("Initializing viewers configuration..."); + + // Load capability manifests + let loadedManifests: ViewerManifest[] = []; + try { + loadedManifests = await initializeViewerManifests(); + setManifests(loadedManifests); + log.info( + `Loaded ${loadedManifests.length} viewer capability manifests`, + ); + } catch (manifestError) { + log.warn("Failed to load capability manifests:", manifestError); + } + + const viewersWithManifests = loadedManifests.map((m) => m.viewer.name); + + // Load viewer config entries + const configEntries = await loadViewersConfig(viewersWithManifests); + log.info(`Loaded configuration for ${configEntries.length} viewers`); + + const validated: ValidViewer[] = []; + + // Map through viewer config entries to validate + for (const entry of configEntries) { + const key = normalizeViewerName(entry.name); + const manifest = loadedManifests.find( + (m) => normalizeViewerName(m.viewer.name) === key, + ); + + let urlTemplate: string | undefined = entry.url; + let shouldInclude = true; + let skipReason = ""; + + if (manifest) { + if (!urlTemplate) { + // Use manifest template URL if no override + urlTemplate = manifest.viewer.template_url; + } + + if (!urlTemplate) { + shouldInclude = false; + skipReason = `has capability manifest but no template_url and no URL override in config`; + } + } else { + // No capability manifest + if (!urlTemplate) { + shouldInclude = false; + skipReason = `does not have a capability manifest and no URL provided in config`; + } + } + + if (!shouldInclude) { + log.warn(`Viewer "${entry.name}" excluded: ${skipReason}`); + continue; + } + + // Create valid viewer entry + const displayName = + entry.name.charAt(0).toUpperCase() + entry.name.slice(1); + const label = entry.label || `View in ${displayName}`; + const logoPath = getViewerLogo(entry.name, entry.logo); + + validated.push({ + key, + displayName, + urlTemplate: urlTemplate!, + logoPath, + label, + manifest, + supportedVersions: entry.ome_zarr_versions, + }); + + log.info(`Viewer "${entry.name}" registered successfully`); + } + + if (validated.length === 0) { + throw new Error( + "No valid viewers configured. Check viewers.config.yaml or console for errors.", + ); + } + + setValidViewers(validated); + setIsInitialized(true); + log.info( + `Viewers initialization complete: ${validated.length} viewers available`, + ); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + log.error("Failed to initialize viewers:", errorMessage); + setError(errorMessage); + setIsInitialized(true); // Still mark as initialized to prevent hanging + } + } + + initialize(); + }, []); + + const getCompatibleViewers = (metadata: OmeZarrMetadata): ValidViewer[] => { + if (!isInitialized || !metadata) { + return []; + } + + return validViewers.filter((viewer) => { + if (viewer.manifest) { + const compatibleNames = getCompatibleViewersFromManifest(metadata); + return compatibleNames.includes(viewer.manifest.viewer.name); + } else { + // Manual version check for viewers without manifests + const zarrVersion = metadata.version + ? parseFloat(metadata.version) + : null; + if (zarrVersion === null || !viewer.supportedVersions) { + return false; + } + return viewer.supportedVersions.includes(zarrVersion); + } + }); + }; + + return ( + + {children} + + ); +} + +export function useViewersContext() { + const context = useContext(ViewersContext); + if (!context) { + throw new Error("useViewersContext must be used within ViewersProvider"); + } + return context; +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 88d0b33c..09306c41 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -18,6 +18,7 @@ import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { NotificationProvider } from '@/contexts/NotificationsContext'; import { ServerHealthProvider } from '@/contexts/ServerHealthContext'; +import { ViewersProvider } from '@/contexts/ViewersContext'; import FileglancerNavbar from '@/components/ui/Navbar/Navbar'; import Notifications from '@/components/ui/Notifications/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; @@ -64,25 +65,27 @@ export const MainLayout = () => { return ( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + ); }; From 24e030e2fcb5b9f57462e09d1b3781102b77d244 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:34:06 -0500 Subject: [PATCH 04/46] refactor: make OpenWithToolUrls dynamic using Record type --- frontend/package-lock.json | 20 ++++++++++++++++++-- frontend/package.json | 3 +++ frontend/src/config/viewersConfig.ts | 2 +- frontend/src/queries/zarrQueries.ts | 10 +++++----- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c322152a..d79efbeb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,12 +9,15 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { + "@bioimagetools/capability-manifest": "^0.2.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", + "@types/js-yaml": "^4.0.9", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", + "js-yaml": "^4.1.1", "loglevel": "^1.9.2", "npm-run-all2": "^7.0.2", "ome-zarr.js": "^0.0.17", @@ -398,6 +401,15 @@ "node": ">=18" } }, + "node_modules/@bioimagetools/capability-manifest": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.2.0.tgz", + "integrity": "sha512-eZa4DmOCxbxS6BN8aHOqPNrfCP/zWMigvVwCY3aNTlLTsG3ZQ2Ap/TJetY7qnagNl3sIXYHiHbKDhBYNee5rDw==", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -1825,6 +1837,12 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2437,7 +2455,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -5863,7 +5880,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/frontend/package.json b/frontend/package.json index 12a3c0ba..6810c720 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,12 +30,15 @@ "test": "vitest" }, "dependencies": { + "@bioimagetools/capability-manifest": "^0.2.0", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", + "@types/js-yaml": "^4.0.9", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.21", + "js-yaml": "^4.1.1", "loglevel": "^1.9.2", "npm-run-all2": "^7.0.2", "ome-zarr.js": "^0.0.17", diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 0d9e2bc3..a7bd2135 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -106,5 +106,5 @@ export function parseViewersConfig( } } - return config as ViewersConfigYaml; + return config as unknown as ViewersConfigYaml; } diff --git a/frontend/src/queries/zarrQueries.ts b/frontend/src/queries/zarrQueries.ts index 67f71de2..5efef7b9 100644 --- a/frontend/src/queries/zarrQueries.ts +++ b/frontend/src/queries/zarrQueries.ts @@ -12,11 +12,11 @@ import { FileOrFolder } from '@/shared.types'; export type OpenWithToolUrls = { copy: string; - validator: string | null; - neuroglancer: string; - vole: string | null; - avivator: string | null; -}; +} & Record; + +// The 'copy' key is always present, all other keys are viewer-specific +// null means the viewer is incompatible with this dataset +// empty string means the viewer is compatible but no data URL is available yet export type ZarrMetadata = Metadata | null; From 9f5d1ebf20bb9cca785837b7a4b3e8ace7a7ec46 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:40:32 -0500 Subject: [PATCH 05/46] docs: add comment explaining type assertion in parseViewersConfig --- frontend/src/config/viewersConfig.ts | 39 +++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index a7bd2135..6237c913 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -1,4 +1,4 @@ -import yaml from "js-yaml"; +import yaml from 'js-yaml'; /** * Viewer entry from viewers.config.yaml @@ -25,7 +25,7 @@ export interface ViewersConfigYaml { */ export function parseViewersConfig( yamlContent: string, - viewersWithManifests: string[] = [], + viewersWithManifests: string[] = [] ): ViewersConfigYaml { let parsed: unknown; @@ -33,12 +33,12 @@ export function parseViewersConfig( parsed = yaml.load(yamlContent); } catch (error) { throw new Error( - `Failed to parse viewers configuration YAML: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to parse viewers configuration YAML: ${error instanceof Error ? error.message : 'Unknown error'}` ); } - if (!parsed || typeof parsed !== "object") { - throw new Error("Configuration must be an object"); + if (!parsed || typeof parsed !== 'object') { + throw new Error('Configuration must be an object'); } const config = parsed as Record; @@ -48,32 +48,32 @@ export function parseViewersConfig( } // Normalize viewer names for comparison (case-insensitive) - const normalizedManifestViewers = viewersWithManifests.map((name) => - name.toLowerCase(), + const normalizedManifestViewers = viewersWithManifests.map(name => + name.toLowerCase() ); // Validate each viewer entry for (const viewer of config.viewers) { - if (!viewer || typeof viewer !== "object") { - throw new Error("Each viewer must be an object"); + if (!viewer || typeof viewer !== 'object') { + throw new Error('Each viewer must be an object'); } const v = viewer as Record; - if (typeof v.name !== "string") { + if (typeof v.name !== 'string') { throw new Error('Each viewer must have a "name" field (string)'); } // Check if this viewer has a capability manifest const hasManifest = normalizedManifestViewers.includes( - v.name.toLowerCase(), + v.name.toLowerCase() ); // If this viewer doesn't have a capability manifest, require additional fields if (!hasManifest) { - if (typeof v.url !== "string") { + if (typeof v.url !== 'string') { throw new Error( - `Viewer "${v.name}" does not have a capability manifest and must specify "url"`, + `Viewer "${v.name}" does not have a capability manifest and must specify "url"` ); } if ( @@ -81,19 +81,19 @@ export function parseViewersConfig( v.ome_zarr_versions.length === 0 ) { throw new Error( - `Viewer "${v.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)`, + `Viewer "${v.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)` ); } } // Validate optional fields if present - if (v.url !== undefined && typeof v.url !== "string") { + if (v.url !== undefined && typeof v.url !== 'string') { throw new Error(`Viewer "${v.name}": "url" must be a string`); } - if (v.label !== undefined && typeof v.label !== "string") { + if (v.label !== undefined && typeof v.label !== 'string') { throw new Error(`Viewer "${v.name}": "label" must be a string`); } - if (v.logo !== undefined && typeof v.logo !== "string") { + if (v.logo !== undefined && typeof v.logo !== 'string') { throw new Error(`Viewer "${v.name}": "logo" must be a string`); } if ( @@ -101,10 +101,13 @@ export function parseViewersConfig( !Array.isArray(v.ome_zarr_versions) ) { throw new Error( - `Viewer "${v.name}": "ome_zarr_versions" must be an array`, + `Viewer "${v.name}": "ome_zarr_versions" must be an array` ); } } + // Type assertion is safe here because we've performed comprehensive runtime validation above. + // TypeScript sees 'config' as Record but our validation ensures it matches + // ViewersConfigYaml structure. The intermediate 'unknown' cast is required for type compatibility. return config as unknown as ViewersConfigYaml; } From 28173eafdbc478dccbeb8a933d29ece679a9f6ba Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:42:28 -0500 Subject: [PATCH 06/46] refactor: use ViewersContext in useZarrMetadata for dynamic viewer URLs --- frontend/src/hooks/useZarrMetadata.ts | 180 +++++++++++++++----------- 1 file changed, 101 insertions(+), 79 deletions(-) diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index 3df57288..d009c0a0 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -4,6 +4,7 @@ import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; import { useZarrMetadataQuery, useOmeZarrThumbnailQuery @@ -31,6 +32,11 @@ export default function useZarrMetadata() { disableHeuristicalLayerTypeDetection, useLegacyMultichannelApproach } = usePreferencesContext(); + const { + validViewers, + isInitialized: viewersInitialized, + getCompatibleViewers + } = useViewersContext(); // Fetch Zarr metadata const zarrMetadataQuery = useZarrMetadataQuery({ @@ -88,103 +94,116 @@ export default function useZarrMetadata() { }, [thumbnailSrc, disableHeuristicalLayerTypeDetection]); const openWithToolUrls = useMemo(() => { - if (!metadata) { + if (!metadata || !viewersInitialized) { return null; } - const validatorBaseUrl = 'https://ome.github.io/ome-ngff-validator/'; - const neuroglancerBaseUrl = 'https://neuroglancer-demo.appspot.com/#!'; - const voleBaseUrl = 'https://volumeviewer.allencell.org/viewer'; - const avivatorBaseUrl = 'https://janeliascicomp.github.io/viv/'; const url = externalDataUrlQuery.data || proxiedPathByFspAndPathQuery.data?.url; + const openWithToolUrls = { copy: url || '' } as OpenWithToolUrls; - // Determine which tools should be available based on metadata type - if (metadata?.multiscale) { - // OME-Zarr - all urls for v2; no avivator for v3 - if (url) { - if (effectiveZarrVersion === 2) { - openWithToolUrls.avivator = buildUrl(avivatorBaseUrl, null, { - image_url: url - }); + // Get compatible viewers for this dataset + let compatibleViewers = validViewers; + + // If we have metadata, use capability checking to filter + if (metadata) { + // Convert our metadata to OmeZarrMetadata format for capability checking + const omeZarrMetadata = { + version: effectiveZarrVersion === 3 ? '0.5' : '0.4', + axes: metadata.multiscale?.axes, + multiscales: metadata.multiscale ? [metadata.multiscale] : undefined, + omero: metadata.omero, + labels: metadata.labels + } as any; // Type assertion needed due to internal type differences + + compatibleViewers = getCompatibleViewers(omeZarrMetadata); + } + + // Create a Set for lookup of compatible viewer keys + // Needed to mark incompatible but valid (as defined by the viewer config) viewers as null in openWithToolUrls + const compatibleKeys = new Set(compatibleViewers.map(v => v.key)); + + for (const viewer of validViewers) { + if (!compatibleKeys.has(viewer.key)) { + openWithToolUrls[viewer.key] = null; + } + } + + // For compatible viewers, generate URLs + for (const viewer of compatibleViewers) { + if (!url) { + // Compatible but no data URL yet - show as available (empty string) + openWithToolUrls[viewer.key] = ''; + continue; + } + + // Generate the viewer URL + let viewerUrl = viewer.urlTemplate; + + // Special handling for Neuroglancer to maintain existing state generation logic + if (viewer.key === 'neuroglancer') { + const neuroglancerBaseUrl = viewer.urlTemplate; + + if (metadata?.multiscale) { + // OME-Zarr with multiscales + if (disableNeuroglancerStateGeneration) { + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType) { + try { + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForOmeZarr( + url, + effectiveZarrVersion, + layerType, + metadata.multiscale, + metadata.arr, + metadata.labels, + metadata.omero, + useLegacyMultichannelApproach + ); + } catch (error) { + log.error( + 'Error generating Neuroglancer state for OME-Zarr:', + error + ); + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } + } } else { - openWithToolUrls.avivator = null; - } - // Populate with actual URLs when proxied path is available - openWithToolUrls.validator = buildUrl(validatorBaseUrl, null, { - source: url - }); - openWithToolUrls.vole = buildUrl(voleBaseUrl, null, { - url - }); - if (disableNeuroglancerStateGeneration) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - try { - openWithToolUrls.neuroglancer = + // Non-OME Zarr array + if (disableNeuroglancerStateGeneration) { + viewerUrl = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType) { + viewerUrl = neuroglancerBaseUrl + - generateNeuroglancerStateForOmeZarr( + generateNeuroglancerStateForZarrArray( url, effectiveZarrVersion, - layerType, - metadata.multiscale, - metadata.arr, - metadata.labels, - metadata.omero, - useLegacyMultichannelApproach + layerType ); - } catch (error) { - log.error( - 'Error generating Neuroglancer state for OME-Zarr:', - error - ); - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); } } } else { - // No proxied URL - show all tools as available but empty - openWithToolUrls.validator = ''; - openWithToolUrls.vole = ''; - // if this is a zarr version 2, then set the url to blank which will show - // the icon before a data link has been generated. Setting it to null for - // all other versions, eg zarr v3 means the icon will not be present before - // a data link is generated. - openWithToolUrls.avivator = effectiveZarrVersion === 2 ? '' : null; - openWithToolUrls.neuroglancer = ''; - } - } else { - // Non-OME Zarr - only Neuroglancer available - if (url) { - openWithToolUrls.validator = null; - openWithToolUrls.vole = null; - openWithToolUrls.avivator = null; - if (disableNeuroglancerStateGeneration) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - openWithToolUrls.neuroglancer = - neuroglancerBaseUrl + - generateNeuroglancerStateForZarrArray( - url, - effectiveZarrVersion, - layerType - ); + // For other viewers, replace {dataLink} placeholder if present + if (viewerUrl.includes('{dataLink}')) { + viewerUrl = viewerUrl.replace(/{dataLink}/g, encodeURIComponent(url)); + } else { + // If no placeholder, use buildUrl with 'url' query param + viewerUrl = buildUrl(viewerUrl, null, { url }); } - } else { - // No proxied URL - only show Neuroglancer as available but empty - openWithToolUrls.validator = null; - openWithToolUrls.vole = null; - openWithToolUrls.avivator = null; - openWithToolUrls.neuroglancer = ''; } + + openWithToolUrls[viewer.key] = viewerUrl; } return openWithToolUrls; @@ -195,7 +214,10 @@ export default function useZarrMetadata() { disableNeuroglancerStateGeneration, useLegacyMultichannelApproach, layerType, - effectiveZarrVersion + effectiveZarrVersion, + validViewers, + viewersInitialized, + getCompatibleViewers ]); return { From 3fd00d7ec2f3dd6a7252c7b225097927bcd2570a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:43:09 -0500 Subject: [PATCH 07/46] refactor: use ViewersContext in DataToolLinks for dynamic viewer icons --- .../ui/BrowsePage/DataToolLinks.tsx | 141 +++++------------- 1 file changed, 39 insertions(+), 102 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index b4e34d0c..d5c8b4f7 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -1,13 +1,12 @@ import { Button, ButtonGroup, Typography } from '@material-tailwind/react'; import { Link } from 'react-router'; - -import neuroglancer_logo from '@/assets/neuroglancer.png'; -import validator_logo from '@/assets/ome-ngff-validator.png'; -import volE_logo from '@/assets/aics_website-3d-cell-viewer.png'; -import avivator_logo from '@/assets/vizarr_logo.png'; import copy_logo from '@/assets/copy-link-64.png'; -import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; +import type { + OpenWithToolUrls, + PendingToolKey +} from '@/hooks/useZarrMetadata'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; +import { useViewersContext } from '@/contexts/ViewersContext'; export default function DataToolLinks({ onToolClick, @@ -20,6 +19,8 @@ export default function DataToolLinks({ readonly title: string; readonly urls: OpenWithToolUrls | null; }) { + const { validViewers } = useViewersContext(); + const tooltipTriggerClasses = 'rounded-sm m-0 p-0 transform active:scale-90 transition-transform duration-75'; @@ -33,106 +34,42 @@ export default function DataToolLinks({ {title} - {urls.neuroglancer !== null ? ( - - { - e.preventDefault(); - await onToolClick('neuroglancer'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.neuroglancer} - > - Neuroglancer logo - - - ) : null} + {validViewers.map(viewer => { + const url = urls[viewer.key]; - {urls.vole !== null ? ( - - { - e.preventDefault(); - await onToolClick('vole'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.vole} - > - Vol-E logo - - - ) : null} - - {urls.avivator !== null ? ( - - { - e.preventDefault(); - await onToolClick('avivator'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.avivator} - > - Avivator logo - - - ) : null} + // null means incompatible, don't show + if (url === null) { + return null; + } - {urls.validator !== null ? ( - - { - e.preventDefault(); - await onToolClick('validator'); - }} - rel="noopener noreferrer" - target="_blank" - to={urls.validator} + return ( + - OME-Zarr Validator logo - - - ) : null} + { + e.preventDefault(); + await onToolClick(viewer.key as PendingToolKey); + }} + rel="noopener noreferrer" + target="_blank" + to={url} + > + {viewer.label} + + + ); + })} + {/* Copy URL tool - always available when there's a data URL */} Date: Mon, 2 Feb 2026 11:59:26 -0500 Subject: [PATCH 08/46] chore: prettier formatting --- .../ui/BrowsePage/DataToolLinks.tsx | 7 +-- frontend/src/config/viewerLogos.ts | 5 +- frontend/src/contexts/ViewersContext.tsx | 57 ++++++++++--------- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index d5c8b4f7..978939bf 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -1,10 +1,7 @@ import { Button, ButtonGroup, Typography } from '@material-tailwind/react'; import { Link } from 'react-router'; import copy_logo from '@/assets/copy-link-64.png'; -import type { - OpenWithToolUrls, - PendingToolKey -} from '@/hooks/useZarrMetadata'; +import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; import { useViewersContext } from '@/contexts/ViewersContext'; @@ -44,8 +41,8 @@ export default function DataToolLinks({ return ( (undefined); * @param viewersWithManifests - Array of viewer names that have capability manifests */ async function loadViewersConfig( - viewersWithManifests: string[], + viewersWithManifests: string[] ): Promise { let configYaml: string; try { // Try to dynamically import the config file // This will be resolved at build time by Vite - const module = await import("@/config/viewers.config.yaml?raw"); + const module = await import('@/config/viewers.config.yaml?raw'); configYaml = module.default; log.info( - "Using custom viewers configuration from src/config/viewers.config.yaml", + 'Using custom viewers configuration from src/config/viewers.config.yaml' ); } catch (error) { log.info( - "No custom viewers.config.yaml found, using default configuration (neuroglancer only)", + 'No custom viewers.config.yaml found, using default configuration (neuroglancer only)' ); // Return default configuration - return [{ name: "neuroglancer" }]; + return [{ name: 'neuroglancer' }]; } try { const config = parseViewersConfig(configYaml, viewersWithManifests); return config.viewers; } catch (error) { - log.error("Error parsing viewers configuration:", error); + log.error('Error parsing viewers configuration:', error); throw error; } } @@ -85,7 +85,7 @@ async function loadViewersConfig( * Normalize viewer name to a valid key */ function normalizeViewerName(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9]/g, ""); + return name.toLowerCase().replace(/[^a-z0-9]/g, ''); } export function ViewersProvider({ children }: { children: ReactNode }) { @@ -97,7 +97,7 @@ export function ViewersProvider({ children }: { children: ReactNode }) { useEffect(() => { async function initialize() { try { - log.info("Initializing viewers configuration..."); + log.info('Initializing viewers configuration...'); // Load capability manifests let loadedManifests: ViewerManifest[] = []; @@ -105,13 +105,13 @@ export function ViewersProvider({ children }: { children: ReactNode }) { loadedManifests = await initializeViewerManifests(); setManifests(loadedManifests); log.info( - `Loaded ${loadedManifests.length} viewer capability manifests`, + `Loaded ${loadedManifests.length} viewer capability manifests` ); } catch (manifestError) { - log.warn("Failed to load capability manifests:", manifestError); + log.warn('Failed to load capability manifests:', manifestError); } - const viewersWithManifests = loadedManifests.map((m) => m.viewer.name); + const viewersWithManifests = loadedManifests.map(m => m.viewer.name); // Load viewer config entries const configEntries = await loadViewersConfig(viewersWithManifests); @@ -123,12 +123,12 @@ export function ViewersProvider({ children }: { children: ReactNode }) { for (const entry of configEntries) { const key = normalizeViewerName(entry.name); const manifest = loadedManifests.find( - (m) => normalizeViewerName(m.viewer.name) === key, + m => normalizeViewerName(m.viewer.name) === key ); let urlTemplate: string | undefined = entry.url; let shouldInclude = true; - let skipReason = ""; + let skipReason = ''; if (manifest) { if (!urlTemplate) { @@ -166,7 +166,7 @@ export function ViewersProvider({ children }: { children: ReactNode }) { logoPath, label, manifest, - supportedVersions: entry.ome_zarr_versions, + supportedVersions: entry.ome_zarr_versions }); log.info(`Viewer "${entry.name}" registered successfully`); @@ -174,18 +174,19 @@ export function ViewersProvider({ children }: { children: ReactNode }) { if (validated.length === 0) { throw new Error( - "No valid viewers configured. Check viewers.config.yaml or console for errors.", + 'No valid viewers configured. Check viewers.config.yaml or console for errors.' ); } setValidViewers(validated); setIsInitialized(true); log.info( - `Viewers initialization complete: ${validated.length} viewers available`, + `Viewers initialization complete: ${validated.length} viewers available` ); } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Unknown error"; - log.error("Failed to initialize viewers:", errorMessage); + const errorMessage = + err instanceof Error ? err.message : 'Unknown error'; + log.error('Failed to initialize viewers:', errorMessage); setError(errorMessage); setIsInitialized(true); // Still mark as initialized to prevent hanging } @@ -199,7 +200,7 @@ export function ViewersProvider({ children }: { children: ReactNode }) { return []; } - return validViewers.filter((viewer) => { + return validViewers.filter(viewer => { if (viewer.manifest) { const compatibleNames = getCompatibleViewersFromManifest(metadata); return compatibleNames.includes(viewer.manifest.viewer.name); @@ -228,7 +229,7 @@ export function ViewersProvider({ children }: { children: ReactNode }) { export function useViewersContext() { const context = useContext(ViewersContext); if (!context) { - throw new Error("useViewersContext must be used within ViewersProvider"); + throw new Error('useViewersContext must be used within ViewersProvider'); } return context; } From ecf1b625b5d1902e6da4c94f2b5b546014c460d7 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 11:59:46 -0500 Subject: [PATCH 09/46] chore: update logo names; add fallback logo --- .../src/assets/{vizarr_logo.png => avivator.png} | Bin frontend/src/assets/fallback_logo.png | Bin 0 -> 8198 bytes .../{ome-ngff-validator.png => validator.png} | Bin ...{aics_website-3d-cell-viewer.png => vole.png} | Bin 4 files changed, 0 insertions(+), 0 deletions(-) rename frontend/src/assets/{vizarr_logo.png => avivator.png} (100%) create mode 100644 frontend/src/assets/fallback_logo.png rename frontend/src/assets/{ome-ngff-validator.png => validator.png} (100%) rename frontend/src/assets/{aics_website-3d-cell-viewer.png => vole.png} (100%) diff --git a/frontend/src/assets/vizarr_logo.png b/frontend/src/assets/avivator.png similarity index 100% rename from frontend/src/assets/vizarr_logo.png rename to frontend/src/assets/avivator.png diff --git a/frontend/src/assets/fallback_logo.png b/frontend/src/assets/fallback_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f636cabbcebaf46d99572dd5c77da3abe3d99525 GIT binary patch literal 8198 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/ome-ngff-validator.png b/frontend/src/assets/validator.png similarity index 100% rename from frontend/src/assets/ome-ngff-validator.png rename to frontend/src/assets/validator.png diff --git a/frontend/src/assets/aics_website-3d-cell-viewer.png b/frontend/src/assets/vole.png similarity index 100% rename from frontend/src/assets/aics_website-3d-cell-viewer.png rename to frontend/src/assets/vole.png From 7698348060a96f3ca814673a76ca8536057b6fed Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:05:26 -0500 Subject: [PATCH 10/46] docs: add viewers configuration guide --- docs/Development.md | 19 +++++ docs/ViewersConfiguration.md | 150 +++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 docs/ViewersConfiguration.md diff --git a/docs/Development.md b/docs/Development.md index f39aef1e..0d82c3f3 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -66,6 +66,25 @@ file_share_mounts: Instead of using the `file_share_mounts` setting, you can configure file share paths in the database. This is useful for production deployments where you want centralized management of file share paths. To use the paths in the database, set `file_share_mounts: []`. See [fileglancer-janelia](https://github.com/JaneliaSciComp/fileglancer-janelia) for an example of populating the file share paths in the database, using a private wiki source. +### Viewers Configuration + +Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers.config.yaml`. This allows you to customize which viewers are available in your deployment and configure custom viewer URLs. + +**Quick Setup:** + +1. Copy the template to the config directory: + ```bash + cp docs/viewers.config.yaml.template frontend/src/config/viewers.config.yaml + ``` + +2. Edit `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs + +3. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` + +**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. + +For detailed configuration options, examples, and documentation on adding custom viewers, see [ViewersConfiguration.md](ViewersConfiguration.md). + ### Running with SSL/HTTPS (Secure Mode) By default, `pixi run dev-launch` runs the server in insecure HTTP mode on port 7878. This is suitable for most local development scenarios. diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md new file mode 100644 index 00000000..6fd7f0cb --- /dev/null +++ b/docs/ViewersConfiguration.md @@ -0,0 +1,150 @@ +# Viewers Configuration Guide + +Fileglancer supports dynamic configuration of OME-Zarr viewers. This allows administrators to customize which viewers are available in their deployment and configure custom viewer URLs. + +## Overview + +The viewer system uses: + +- **viewers.config.yaml**: User configuration file defining available viewers +- **@bioimagetools/capability-manifest**: Library for automatic compatibility detection +- **ViewersContext**: React context providing viewer information to the application + +## Quick Start + +1. Copy the template to the config directory: + +```bash +cp docs/viewers.config.yaml.template frontend/src/config/viewers.config.yaml +``` + +2. Edit `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs + +3. Build the application - configuration is bundled at build time + +## Configuration File Location + +Place `viewers.config.yaml` in `frontend/src/config/` directory. + +**Important:** This file is bundled at build time. Changes require rebuilding the application. + +If no configuration file exists, Fileglancer defaults to Neuroglancer only. + +## Viewer Types + +### Viewers with Capability Manifests (Recommended) + +These viewers have metadata describing their capabilities, allowing automatic compatibility detection. For example, Neuroglancer and Avivator. For these viewers, you only need to specify the name. URL and compatibility are handled automatically. + +### Custom Viewers + +For viewers without capability manifests, you must provide: + +- `name`: Viewer identifier +- `url`: URL template (use `{dataLink}` placeholder for dataset URL) +- `ome_zarr_versions`: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) + +Optionally: + +- `logo`: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) +- `label`: Custom tooltip text (defaults to "View in {Name}") + +## Configuration Examples + +### Enable default viewers + +```yaml +viewers: + - name: neuroglancer + - name: avivator +``` + +### Override viewer URL + +```yaml +viewers: + - name: avivator + url: "https://my-avivator-instance.example.com/?image_url={dataLink}" +``` + +### Add custom viewer (with convention-based logo) + +```yaml +viewers: + - name: my-viewer + url: "https://viewer.example.com/?data={dataLink}" + ome_zarr_versions: [0.4, 0.5] + # Logo will automatically resolve to @/assets/my-viewer.png + label: "Open in My Viewer" +``` + +### Add custom viewer (with explicit logo) + +```yaml +viewers: + - name: my-viewer + url: "https://viewer.example.com/?data={dataLink}" + ome_zarr_versions: [0.4, 0.5] + logo: "custom-logo.png" # Use @/assets/custom-logo.png + label: "Open in My Viewer" +``` + +## Adding Custom Viewer Logos + +Logo resolution follows this order: + +1. **Custom logo specified**: If you provide a `logo` field in the config, it will be used +2. **Convention-based**: If no logo is specified, the system looks for `@/assets/{name}.png` +3. **Fallback**: If neither exists, uses `@/assets/fallback_logo.png` + +### Examples: + +**Using the naming convention (recommended):** + +```yaml +viewers: + - name: my-viewer + # Logo will automatically resolve to @/assets/my-viewer.png +``` + +Just add `frontend/src/assets/my-viewer.png` - no config needed! + +**Using a custom logo filename:** + +```yaml +viewers: + - name: my-viewer + logo: "custom-logo.png" # Will use @/assets/custom-logo.png +``` + +## How Compatibility Works + +### For Viewers with Manifests + +The @bioimagetools/capability-manifest library checks: + +- OME-Zarr version support +- Axis types and configurations +- Compression codecs +- Special features (labels, HCS plates, etc.) + +### For Custom Viewers + +Simple version matching: + +- Dataset version is compared against `ome_zarr_versions` list +- Viewer is shown only if version matches + +## Development + +When developing with custom configurations: + +1. Create/edit `frontend/src/config/viewers.config.yaml` +2. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` +3. Check console for initialization messages + +**Note:** The config file is gitignored to allow per-deployment customization without committing changes. + +## Copy URL Tool + +The "Copy data URL" tool is always available when a data URL exists, regardless of viewer configuration. From 69ae1b67068ab63dc4314fda90ca5a2b6c0e4c5c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:39:22 -0500 Subject: [PATCH 11/46] fix: require OME metadata for custom viewers without manifests - Custom viewers (validator, vol-e) now check for multiscales - Ensures viewers only display for OME-Zarr datasets, not plain Zarr arrays - Update DataToolLinks alt text to use displayName for E2E test compatibility --- frontend/src/components/ui/BrowsePage/DataToolLinks.tsx | 2 +- frontend/src/contexts/ViewersContext.tsx | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index 978939bf..68f4b409 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -57,7 +57,7 @@ export default function DataToolLinks({ to={url} > {viewer.label} diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index db828fc6..51a43176 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -206,12 +206,20 @@ export function ViewersProvider({ children }: { children: ReactNode }) { return compatibleNames.includes(viewer.manifest.viewer.name); } else { // Manual version check for viewers without manifests + // Custom viewers require both correct ome-zarr version AND OME metadata (multiscales) const zarrVersion = metadata.version ? parseFloat(metadata.version) : null; if (zarrVersion === null || !viewer.supportedVersions) { return false; } + + // Check if dataset has OME metadata (multiscales array) + const hasOmeMetadata = metadata.multiscales && metadata.multiscales.length > 0; + if (!hasOmeMetadata) { + return false; + } + return viewer.supportedVersions.includes(zarrVersion); } }); From 3e15ae7274290fe9cd31a07c25b8aea74301d616 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:53:29 -0500 Subject: [PATCH 12/46] fix: update locators to match new defaults --- .../ui-tests/tests/load-zarr-files.spec.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/ui-tests/tests/load-zarr-files.spec.ts b/frontend/ui-tests/tests/load-zarr-files.spec.ts index deaeb46e..a3ca9bd9 100644 --- a/frontend/ui-tests/tests/load-zarr-files.spec.ts +++ b/frontend/ui-tests/tests/load-zarr-files.spec.ts @@ -33,9 +33,9 @@ test.describe('Zarr File Type Representation', () => { await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) + page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toHaveCount(0); + await expect(page.getByRole('img', { name: /vole logo/i })).toHaveCount(0); }); test('Zarr V3 OME-Zarr should show all viewers except avivator', async ({ @@ -52,13 +52,13 @@ test.describe('Zarr File Type Representation', () => { await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) + page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toBeVisible(); + await expect(page.getByRole('img', { name: /vole logo/i })).toBeVisible(); await expect( - page.getByRole('link', { name: 'OME-Zarr Validator logo' }) + page.getByRole('img', { name: /validator logo/i }) ).toBeVisible(); - await expect(page.getByRole('link', { name: 'Avivator logo' })).toHaveCount( + await expect(page.getByRole('img', { name: /avivator logo/i })).toHaveCount( 0 ); }); @@ -77,9 +77,9 @@ test.describe('Zarr File Type Representation', () => { await expect(page.getByText('.zarray')).toBeVisible({ timeout: 10000 }); await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) + page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toHaveCount(0); + await expect(page.getByRole('img', { name: /vole logo/i })).toHaveCount(0); }); test('Zarr V2 OME-Zarr should display all viewers including avivator', async ({ @@ -96,14 +96,14 @@ test.describe('Zarr File Type Representation', () => { await expect(page.getByText('.zattrs')).toBeVisible({ timeout: 10000 }); await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) + page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('link', { name: 'Vol-E logo' })).toBeVisible(); + await expect(page.getByRole('img', { name: /vole logo/i })).toBeVisible(); await expect( - page.getByRole('link', { name: 'OME-Zarr Validator logo' }) + page.getByRole('img', { name: /validator logo/i }) ).toBeVisible(); await expect( - page.getByRole('link', { name: 'Avivator logo' }) + page.getByRole('img', { name: /avivator logo/i }) ).toBeVisible(); }); From 4292cdfa509d04eb956665de05648f43a471d51f Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:53:59 -0500 Subject: [PATCH 13/46] efactor: use committed config file instead of template --- docs/Development.md | 11 +++----- docs/ViewersConfiguration.md | 18 ++++--------- frontend/.gitignore | 1 - .../src/config/viewers.config.yaml | 25 +++++++------------ 4 files changed, 17 insertions(+), 38 deletions(-) delete mode 100644 frontend/.gitignore rename docs/viewers.config.yaml.template => frontend/src/config/viewers.config.yaml (65%) diff --git a/docs/Development.md b/docs/Development.md index 0d82c3f3..742948e3 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -72,16 +72,11 @@ Fileglancer supports dynamic configuration of OME-Zarr viewers through `viewers. **Quick Setup:** -1. Copy the template to the config directory: - ```bash - cp docs/viewers.config.yaml.template frontend/src/config/viewers.config.yaml - ``` +1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs -2. Edit `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs +2. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` -3. Rebuild the application: `pixi run node-build` or use watch mode in development: `pixi run dev-watch` - -**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. +**Note:** The configuration file is bundled at build time, so changes require rebuilding the application. The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. For detailed configuration options, examples, and documentation on adding custom viewers, see [ViewersConfiguration.md](ViewersConfiguration.md). diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 6fd7f0cb..324b29b4 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -12,23 +12,17 @@ The viewer system uses: ## Quick Start -1. Copy the template to the config directory: +1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs -```bash -cp docs/viewers.config.yaml.template frontend/src/config/viewers.config.yaml -``` - -2. Edit `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs - -3. Build the application - configuration is bundled at build time +2. Build the application - configuration is bundled at build time ## Configuration File Location -Place `viewers.config.yaml` in `frontend/src/config/` directory. +The configuration file is located at `frontend/src/config/viewers.config.yaml`. **Important:** This file is bundled at build time. Changes require rebuilding the application. -If no configuration file exists, Fileglancer defaults to Neuroglancer only. +The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. You can modify this file to add, remove, or customize viewers for your deployment. ## Viewer Types @@ -139,12 +133,10 @@ Simple version matching: When developing with custom configurations: -1. Create/edit `frontend/src/config/viewers.config.yaml` +1. Edit `frontend/src/config/viewers.config.yaml` 2. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` 3. Check console for initialization messages -**Note:** The config file is gitignored to allow per-deployment customization without committing changes. - ## Copy URL Tool The "Copy data URL" tool is always available when a data URL exists, regardless of viewer configuration. diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index c979e0f1..00000000 --- a/frontend/.gitignore +++ /dev/null @@ -1 +0,0 @@ -src/config/viewers.config.yaml diff --git a/docs/viewers.config.yaml.template b/frontend/src/config/viewers.config.yaml similarity index 65% rename from docs/viewers.config.yaml.template rename to frontend/src/config/viewers.config.yaml index 3883399e..6beb3e7b 100644 --- a/docs/viewers.config.yaml.template +++ b/frontend/src/config/viewers.config.yaml @@ -4,11 +4,6 @@ # The @bioimagetools/capability-manifest library is used to determine compatibility # for viewers that have a capability manifest. # -# To use this file: -# 1. Copy this template to the project root: cp docs/viewers.config.yaml.template viewers.config.yaml -# 2. Uncommented viewers will be shown in your deployment -# 3. Check the values provided for each viewer - see guidelines below. -# # For viewers with capability manifests, you must provide: # - name: must match name value in capability manifest # Optionally: @@ -31,21 +26,19 @@ viewers: - name: avivator # Optional: Override the viewer URL from the capability manifest # In this example, override to use Janelia's custom deployment - url: "https://janeliascicomp.github.io/viv/" + url: 'https://janeliascicomp.github.io/viv/' - # # OME-Zarr viewers without capability manifests - # # Example: + # OME-Zarr viewers without capability manifests # OME-Zarr Validator # Logo will automatically resolve to @/assets/validator.png - name: validator - url: "https://ome.github.io/ome-ngff-validator/?source={dataLink}" + url: 'https://ome.github.io/ome-ngff-validator/?source={dataLink}' ome_zarr_versions: [0.4, 0.5] - label: "View in OME-Zarr Validator" + label: 'View in OME-Zarr Validator' - # # Example: - # # Vol-E - Allen Cell Explorer 3D viewer - # # Logo will automatically resolve to @/assets/vole.png + # Vol-E - Allen Cell Explorer 3D viewer + # Logo will automatically resolve to @/assets/vole.png - name: vole - url: "https://volumeviewer.allencell.org/viewer?url={dataLink}" - ome_zarr_versions: [0.4] - label: "View in Vol-E" + url: 'https://volumeviewer.allencell.org/viewer?url={dataLink}' + ome_zarr_versions: [0.4, 0.5] + label: 'View in Vol-E' From d545e2810100848f37ceabd83be0919f9d1029ce Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:54:37 -0500 Subject: [PATCH 14/46] docs: add viewers configuration to CLAUDE.md --- CLAUDE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index fcd4de6a..8f1f444d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,6 +169,20 @@ Key settings: - `db_url`: Database connection string - SSL certificates for HTTPS mode +## Viewers Configuration + +Fileglancer supports dynamic viewer configuration through `viewers.config.yaml`. + +- **Configuration file**: `frontend/src/config/viewers.config.yaml` +- **Documentation**: See `docs/ViewersConfiguration.md` + +To customize viewers: + +1. Edit `frontend/src/config/viewers.config.yaml` +2. Rebuild application: `pixi run node-build` + +The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. The config file is bundled at build time. + ## Pixi Environments - `default`: Standard development From d5c24e9d1b76f7aea3dc7900f0bd062c9d313214 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:54:57 -0500 Subject: [PATCH 15/46] refactor: remove unused keys from N5OpenWithToolUrls type --- frontend/src/hooks/useN5Metadata.ts | 5 +---- frontend/src/queries/n5Queries.ts | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/src/hooks/useN5Metadata.ts b/frontend/src/hooks/useN5Metadata.ts index f432c1ce..1187d0a9 100644 --- a/frontend/src/hooks/useN5Metadata.ts +++ b/frontend/src/hooks/useN5Metadata.ts @@ -73,10 +73,7 @@ export default function useN5Metadata() { const toolUrls: N5OpenWithToolUrls = { copy: url || '', - neuroglancer: '', - validator: null, - vole: null, - avivator: null + neuroglancer: '' }; if (url) { diff --git a/frontend/src/queries/n5Queries.ts b/frontend/src/queries/n5Queries.ts index 3f15ba6a..699e9ab4 100644 --- a/frontend/src/queries/n5Queries.ts +++ b/frontend/src/queries/n5Queries.ts @@ -49,9 +49,6 @@ export type N5Metadata = { export type N5OpenWithToolUrls = { copy: string; neuroglancer: string; - validator: null; - vole: null; - avivator: null; }; type N5MetadataQueryParams = { From e6e1cd451c5482aa442ef25410f8b8ae10645533 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 12:58:11 -0500 Subject: [PATCH 16/46] perf: memoize getCompatibleViewers function Add useCallback to memoize the getCompatibleViewers function in ViewersContext to prevent unnecessary recalculations and improve performance when filtering compatible viewers based on metadata. --- frontend/src/contexts/ViewersContext.tsx | 57 +++++++++++++----------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 51a43176..aba34745 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -3,6 +3,7 @@ import { useContext, useState, useEffect, + useCallback, type ReactNode } from 'react'; import { @@ -195,35 +196,39 @@ export function ViewersProvider({ children }: { children: ReactNode }) { initialize(); }, []); - const getCompatibleViewers = (metadata: OmeZarrMetadata): ValidViewer[] => { - if (!isInitialized || !metadata) { - return []; - } + const getCompatibleViewers = useCallback( + (metadata: OmeZarrMetadata): ValidViewer[] => { + if (!isInitialized || !metadata) { + return []; + } - return validViewers.filter(viewer => { - if (viewer.manifest) { - const compatibleNames = getCompatibleViewersFromManifest(metadata); - return compatibleNames.includes(viewer.manifest.viewer.name); - } else { - // Manual version check for viewers without manifests - // Custom viewers require both correct ome-zarr version AND OME metadata (multiscales) - const zarrVersion = metadata.version - ? parseFloat(metadata.version) - : null; - if (zarrVersion === null || !viewer.supportedVersions) { - return false; - } + return validViewers.filter(viewer => { + if (viewer.manifest) { + const compatibleNames = getCompatibleViewersFromManifest(metadata); + return compatibleNames.includes(viewer.manifest.viewer.name); + } else { + // Manual version check for viewers without manifests + // Custom viewers require both correct ome-zarr version AND OME metadata (multiscales) + const zarrVersion = metadata.version + ? parseFloat(metadata.version) + : null; + if (zarrVersion === null || !viewer.supportedVersions) { + return false; + } - // Check if dataset has OME metadata (multiscales array) - const hasOmeMetadata = metadata.multiscales && metadata.multiscales.length > 0; - if (!hasOmeMetadata) { - return false; - } + // Check if dataset has OME metadata (multiscales array) + const hasOmeMetadata = + metadata.multiscales && metadata.multiscales.length > 0; + if (!hasOmeMetadata) { + return false; + } - return viewer.supportedVersions.includes(zarrVersion); - } - }); - }; + return viewer.supportedVersions.includes(zarrVersion); + } + }); + }, + [validViewers, isInitialized] + ); return ( Date: Mon, 2 Feb 2026 13:01:05 -0500 Subject: [PATCH 17/46] feat: improve error handling in viewers initialization Add more detailed error logging and ensure graceful degradation when: - Capability manifests fail to load - Viewer configuration parsing fails - No valid viewers are configured The application will continue with an empty viewer list and clear console messages to help users troubleshoot configuration issues. --- frontend/src/contexts/ViewersContext.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index aba34745..7e5e5e7f 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -110,6 +110,9 @@ export function ViewersProvider({ children }: { children: ReactNode }) { ); } catch (manifestError) { log.warn('Failed to load capability manifests:', manifestError); + log.warn( + 'Continuing without capability manifests. Only custom viewers with explicit configuration will be available.' + ); } const viewersWithManifests = loadedManifests.map(m => m.viewer.name); @@ -188,7 +191,11 @@ export function ViewersProvider({ children }: { children: ReactNode }) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; log.error('Failed to initialize viewers:', errorMessage); + log.error( + 'Application will continue with no viewers available. Check viewers.config.yaml for errors.' + ); setError(errorMessage); + setValidViewers([]); // Ensure empty viewer list on error setIsInitialized(true); // Still mark as initialized to prevent hanging } } From 58d163d7ab4dcf8560f6659057c3a6592c09fde5 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 15:17:00 -0500 Subject: [PATCH 18/46] chore: fix eslint warnings --- frontend/src/contexts/ViewersContext.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 7e5e5e7f..a2abc4e1 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -65,6 +65,7 @@ async function loadViewersConfig( log.info( 'Using custom viewers configuration from src/config/viewers.config.yaml' ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { log.info( 'No custom viewers.config.yaml found, using default configuration (neuroglancer only)' @@ -89,11 +90,14 @@ function normalizeViewerName(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, ''); } -export function ViewersProvider({ children }: { children: ReactNode }) { +export function ViewersProvider({ + children +}: { + readonly children: ReactNode; +}) { const [validViewers, setValidViewers] = useState([]); const [isInitialized, setIsInitialized] = useState(false); const [error, setError] = useState(null); - const [manifests, setManifests] = useState([]); useEffect(() => { async function initialize() { @@ -104,7 +108,6 @@ export function ViewersProvider({ children }: { children: ReactNode }) { let loadedManifests: ViewerManifest[] = []; try { loadedManifests = await initializeViewerManifests(); - setManifests(loadedManifests); log.info( `Loaded ${loadedManifests.length} viewer capability manifests` ); From 5ae87aeaa6331c3d8977eed1d87704101fbd65aa Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 16:03:51 -0500 Subject: [PATCH 19/46] fix: add validation for ome-zarr verison numbers --- frontend/src/config/viewersConfig.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 6237c913..4507e9c6 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -18,6 +18,11 @@ export interface ViewersConfigYaml { viewers: ViewerConfigEntry[]; } +/** + * Valid OME-Zarr versions supported by the application + */ +const VALID_OME_ZARR_VERSIONS = [0.4, 0.5]; + /** * Parse and validate viewers configuration YAML * @param yamlContent - The YAML content to parse @@ -104,6 +109,20 @@ export function parseViewersConfig( `Viewer "${v.name}": "ome_zarr_versions" must be an array` ); } + + // Validate ome_zarr_versions values if present + if ( + v.ome_zarr_versions !== undefined && + Array.isArray(v.ome_zarr_versions) + ) { + for (const version of v.ome_zarr_versions) { + if (!VALID_OME_ZARR_VERSIONS.includes(version)) { + throw new Error( + `Viewer "${v.name}": invalid ome_zarr_version "${version}". Valid versions are: ${VALID_OME_ZARR_VERSIONS.join(', ')}` + ); + } + } + } } // Type assertion is safe here because we've performed comprehensive runtime validation above. From 4b6db2e2f76e33bcbcae742a2ffbdf7acc2322a3 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 16:04:10 -0500 Subject: [PATCH 20/46] tests: unit tests for the viewer config parsing func --- .../__tests__/unitTests/viewersConfig.test.ts | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 frontend/src/__tests__/unitTests/viewersConfig.test.ts diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts new file mode 100644 index 00000000..815c3140 --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -0,0 +1,515 @@ +import { describe, it, expect } from 'vitest'; +import { parseViewersConfig } from '@/config/viewersConfig'; + +describe('parseViewersConfig', () => { + describe('Valid configurations', () => { + it('should parse valid config with viewers that have manifests', () => { + const yaml = ` +viewers: + - name: neuroglancer + - name: avivator +`; + const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].name).toBe('neuroglancer'); + expect(result.viewers[1].name).toBe('avivator'); + }); + + it('should parse config with custom viewer with all required fields', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [0.4, 0.5] +`; + const result = parseViewersConfig(yaml, []); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].name).toBe('custom-viewer'); + expect(result.viewers[0].url).toBe('https://example.com/{dataLink}'); + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + }); + + it('should parse config with optional fields', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [0.4] + label: Custom Viewer Label + logo: custom-logo.png +`; + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].label).toBe('Custom Viewer Label'); + expect(result.viewers[0].logo).toBe('custom-logo.png'); + }); + + it('should allow viewer with manifest to override url', () => { + const yaml = ` +viewers: + - name: neuroglancer + url: https://custom-neuroglancer.com/{dataLink} +`; + const result = parseViewersConfig(yaml, ['neuroglancer']); + + expect(result.viewers[0].url).toBe( + 'https://custom-neuroglancer.com/{dataLink}' + ); + }); + + it('should parse mixed config with manifest and non-manifest viewers', () => { + const yaml = ` +viewers: + - name: neuroglancer + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [0.4] + - name: avivator +`; + const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + + expect(result.viewers).toHaveLength(3); + expect(result.viewers[0].name).toBe('neuroglancer'); + expect(result.viewers[1].name).toBe('custom-viewer'); + expect(result.viewers[2].name).toBe('avivator'); + }); + }); + + describe('Invalid YAML syntax', () => { + it('should throw error for malformed YAML', () => { + const invalidYaml = 'viewers:\n - name: test\n invalid: [[['; + + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Failed to parse viewers configuration YAML/ + ); + }); + + it('should throw error for invalid YAML structure', () => { + const invalidYaml = 'this is not valid yaml [[{]}'; + + // js-yaml parses this as a string, which then fails the object check + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Configuration must be an object/ + ); + }); + + it('should throw error for non-object YAML', () => { + const invalidYaml = 'just a string'; + + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Configuration must be an object/ + ); + }); + + it('should throw error for empty YAML', () => { + const invalidYaml = ''; + + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Configuration must be an object/ + ); + }); + }); + + describe('Missing required fields', () => { + it('should throw error when viewers array is missing', () => { + const yaml = ` +name: some-config +other_field: value +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Configuration must have a "viewers" array/ + ); + }); + + it('should throw error when viewers is not an array', () => { + const yaml = ` +viewers: not-an-array +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Configuration must have a "viewers" array/ + ); + }); + + it('should throw error when viewer is not an object', () => { + const yaml = ` +viewers: + - just-a-string +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Each viewer must be an object/ + ); + }); + + it('should throw error when viewer lacks name field', () => { + const yaml = ` +viewers: + - url: https://example.com + ome_zarr_versions: [0.4] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Each viewer must have a "name" field \(string\)/ + ); + }); + + it('should throw error when viewer name is not a string', () => { + const yaml = ` +viewers: + - name: 123 + url: https://example.com + ome_zarr_versions: [0.4] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Each viewer must have a "name" field \(string\)/ + ); + }); + + it('should throw error when custom viewer (no manifest) lacks url', () => { + const yaml = ` +viewers: + - name: custom-viewer + ome_zarr_versions: [0.4] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "url"/ + ); + }); + + it('should throw error when custom viewer (no manifest) lacks ome_zarr_versions', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + ); + }); + + it('should throw error when custom viewer has empty ome_zarr_versions array', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + ); + }); + }); + + describe('Invalid field types', () => { + it('should throw error when url is not a string (for custom viewer)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: 123 + ome_zarr_versions: [0.4] +`; + + // The required field check happens first, so if url is wrong type, + // it's caught by the "must specify url" check + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "url"/ + ); + }); + + it('should throw error when url override is not a string (for manifest viewer)', () => { + const yaml = ` +viewers: + - name: neuroglancer + url: 123 +`; + + expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( + /Viewer "neuroglancer": "url" must be a string/ + ); + }); + + it('should throw error when label is not a string', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.4] + label: 123 +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer": "label" must be a string/ + ); + }); + + it('should throw error when logo is not a string', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.4] + logo: 123 +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer": "logo" must be a string/ + ); + }); + + it('should throw error when ome_zarr_versions is not an array (for custom viewer)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: "not-an-array" +`; + + // The required field check happens first and checks if it's an array + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + ); + }); + + it('should throw error when ome_zarr_versions override is not an array (for manifest viewer)', () => { + const yaml = ` +viewers: + - name: neuroglancer + ome_zarr_versions: "not-an-array" +`; + + expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( + /Viewer "neuroglancer": "ome_zarr_versions" must be an array/ + ); + }); + }); + + describe('OME-Zarr version validation', () => { + it('should accept valid ome_zarr_versions (0.4 and 0.5)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.4, 0.5] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + }); + + it('should throw error for invalid ome_zarr_version (0.3)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.3] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + ); + }); + + it('should throw error for invalid ome_zarr_version (1.0)', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [1.0] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /invalid ome_zarr_version "1". Valid versions are: 0.4, 0.5/ + ); + }); + + it('should throw error when mixing valid and invalid versions', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.3, 0.4, 0.5] +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + ); + }); + + it('should throw error for invalid version in manifest viewer override', () => { + const yaml = ` +viewers: + - name: neuroglancer + ome_zarr_versions: [0.3] +`; + + expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( + /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + ); + }); + + it('should accept only 0.4', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.4] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4]); + }); + + it('should accept only 0.5', () => { + const yaml = ` +viewers: + - name: custom-viewer + url: https://example.com + ome_zarr_versions: [0.5] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.5]); + }); + }); + + describe('Case sensitivity and normalization', () => { + it('should handle case-insensitive manifest matching', () => { + const yaml = ` +viewers: + - name: Neuroglancer + - name: AVIVATOR +`; + + // Manifest names are lowercase + const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].name).toBe('Neuroglancer'); + expect(result.viewers[1].name).toBe('AVIVATOR'); + }); + + it('should match manifests case-insensitively for mixed case', () => { + const yaml = ` +viewers: + - name: NeuroGlancer +`; + + // Should recognize this has a manifest (neuroglancer) + const result = parseViewersConfig(yaml, ['neuroglancer']); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].name).toBe('NeuroGlancer'); + // Should not require url or ome_zarr_versions since it has a manifest + }); + }); + + describe('Edge cases', () => { + it('should handle empty viewers array', () => { + const yaml = ` +viewers: [] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers).toHaveLength(0); + }); + + it('should handle viewer with only name (has manifest)', () => { + const yaml = ` +viewers: + - name: neuroglancer +`; + + const result = parseViewersConfig(yaml, ['neuroglancer']); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0]).toEqual({ name: 'neuroglancer' }); + }); + + it('should preserve all valid fields in parsed output', () => { + const yaml = ` +viewers: + - name: custom + url: https://example.com + label: Custom Label + logo: custom.png + ome_zarr_versions: [0.4, 0.5] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0]).toEqual({ + name: 'custom', + url: 'https://example.com', + label: 'Custom Label', + logo: 'custom.png', + ome_zarr_versions: [0.4, 0.5] + }); + }); + + it('should handle multiple valid ome_zarr_versions', () => { + const yaml = ` +viewers: + - name: custom + url: https://example.com + ome_zarr_versions: [0.4, 0.5] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + }); + + it('should handle single ome_zarr_version in array', () => { + const yaml = ` +viewers: + - name: custom + url: https://example.com + ome_zarr_versions: [0.4] +`; + + const result = parseViewersConfig(yaml, []); + + expect(result.viewers[0].ome_zarr_versions).toEqual([0.4]); + }); + }); + + describe('Default parameter behavior', () => { + it('should use empty array as default for viewersWithManifests', () => { + const yaml = ` +viewers: + - name: custom + url: https://example.com + ome_zarr_versions: [0.4] +`; + + // Not passing second parameter + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].name).toBe('custom'); + }); + + it('should treat viewer as non-manifest when viewersWithManifests is empty', () => { + const yaml = ` +viewers: + - name: neuroglancer +`; + + // Even though neuroglancer typically has a manifest, + // if not in the list, it should require url and versions + expect(() => parseViewersConfig(yaml, [])).toThrow(/must specify "url"/); + }); + }); +}); From fef30cdb18487339461c0ee8b49899d8f27b7500 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 2 Feb 2026 16:04:32 -0500 Subject: [PATCH 21/46] tests: component tests for DataToolLinks --- .../componentTests/DataToolLinks.test.tsx | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/DataToolLinks.test.tsx diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx new file mode 100644 index 00000000..90cd8257 --- /dev/null +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { render, screen } from '@/__tests__/test-utils'; +import DataToolLinks from '@/components/ui/BrowsePage/DataToolLinks'; +import type { OpenWithToolUrls, PendingToolKey } from '@/hooks/useZarrMetadata'; +import { ViewersProvider } from '@/contexts/ViewersContext'; + +// Mock logger to capture console warnings +const mockLogger = vi.hoisted(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() +})); + +vi.mock('@/logger', () => ({ + default: mockLogger +})); + +// Mock capability manifest to avoid network requests in tests +vi.mock('@bioimagetools/capability-manifest', () => ({ + initializeViewerManifests: vi.fn(async () => [ + { + viewer: { + name: 'neuroglancer', + template_url: 'https://neuroglancer.com/#{dataLink}' + } + }, + { + viewer: { + name: 'avivator', + template_url: 'https://avivator.com/?url={dataLink}' + } + } + ]), + getCompatibleViewers: vi.fn(() => ['neuroglancer', 'avivator']) +})); + +const mockOpenWithToolUrls: OpenWithToolUrls = { + copy: 'http://localhost:3000/test/copy/url', + validator: 'http://localhost:3000/test/validator/url', + neuroglancer: 'http://localhost:3000/test/neuroglancer/url', + vole: 'http://localhost:3000/test/vole/url', + avivator: 'http://localhost:3000/test/avivator/url' +}; + +// Helper component to wrap DataToolLinks with ViewersProvider +function TestDataToolLinksComponent({ + urls = mockOpenWithToolUrls, + onToolClick = vi.fn() +}: { + urls?: OpenWithToolUrls | null; + onToolClick?: (toolKey: PendingToolKey) => Promise; +}) { + return ( + + + + ); +} + +// Wrapper function for rendering with proper route context +function renderDataToolLinks( + urls?: OpenWithToolUrls | null, + onToolClick?: (toolKey: PendingToolKey) => Promise +) { + return render( + , + { initialEntries: ['/browse/test_fsp/test_file'] } + ); +} + +describe('DataToolLinks - Error Scenarios', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Invalid YAML syntax', () => { + it('should log error when YAML parsing fails in ViewersContext', async () => { + // This test verifies that the ViewersContext logs errors appropriately + // The actual YAML parsing error is tested in the ViewersContext initialization + + // Import the parseViewersConfig function to test it directly + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const invalidYaml = 'viewers:\n - name: test\n invalid: [[['; + + expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + /Failed to parse viewers configuration YAML/ + ); + }); + + it('should still render when ViewersContext fails to initialize', async () => { + // When ViewersContext fails to initialize, it sets error state + // and logs to console. The component should still render but with empty viewers. + renderDataToolLinks(); + + await waitFor( + () => { + // The component should still be initialized (to prevent hanging) + // but viewers may be empty if there was an error + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + + describe('Missing required fields', () => { + it('should throw error when custom viewer lacks required url field', async () => { + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const configMissingUrl = ` +viewers: + - name: custom-viewer + # Missing url for viewer without manifest +`; + + expect(() => parseViewersConfig(configMissingUrl, [])).toThrow( + /does not have a capability manifest and must specify "url"/ + ); + }); + + it('should throw error when custom viewer lacks ome_zarr_versions', async () => { + const { parseViewersConfig } = await import('@/config/viewersConfig'); + + const configMissingVersions = ` +viewers: + - name: custom-viewer + url: https://example.com + # Missing ome_zarr_versions for viewer without manifest +`; + + expect(() => parseViewersConfig(configMissingVersions, [])).toThrow( + /does not have a capability manifest and must specify "ome_zarr_versions"/ + ); + }); + }); +}); + +describe('DataToolLinks - Edge Cases', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Fallback logo handling', () => { + it('should handle viewers with custom logos', async () => { + // Test that getViewerLogo returns appropriate path + const { getViewerLogo } = await import('@/config/viewerLogos'); + + // Test with known viewer + const neuroglancerLogo = getViewerLogo('neuroglancer'); + expect(neuroglancerLogo).toBeTruthy(); + + // Test with custom logo path + const customLogo = getViewerLogo('custom-viewer', 'custom-logo.png'); + expect(customLogo).toBeTruthy(); + }); + + it('should handle viewers with known logos', async () => { + // Test that viewers with known logo files render correctly + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Check that images are rendered + const images = screen.getAllByRole('img'); + + // Check for neuroglancer logo (known viewer with logo) + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'Neuroglancer logo' + ); + expect(neuroglancerLogo).toBeTruthy(); + expect(neuroglancerLogo?.getAttribute('src')).toContain('neuroglancer'); + + // Check for avivator logo (known viewer with logo) + const avivatorLogo = images.find( + img => img.getAttribute('alt') === 'Avivator logo' + ); + expect(avivatorLogo).toBeTruthy(); + expect(avivatorLogo?.getAttribute('src')).toContain('avivator'); + }); + }); + + describe('Custom viewer without ome_zarr_versions', () => { + it('should exclude viewer URL when set to null in OpenWithToolUrls', async () => { + const urls: OpenWithToolUrls = { + copy: 'http://localhost:3000/copy', + neuroglancer: 'http://localhost:3000/neuroglancer', + customviewer: null // Custom viewer not compatible (explicitly null) + }; + + renderDataToolLinks(urls); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should have neuroglancer logo and copy icon + const images = screen.getAllByRole('img'); + expect(images.length).toBeGreaterThanOrEqual(2); + + // Check for neuroglancer logo + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'Neuroglancer logo' + ); + expect(neuroglancerLogo).toBeTruthy(); + + // Check for copy icon + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + }); + }); + + describe('Component behavior with null urls', () => { + it('should render nothing when urls is null', () => { + renderDataToolLinks(null); + + // Component should not render when urls is null + expect(screen.queryByText('Test Tools')).not.toBeInTheDocument(); + }); + }); +}); + +describe('DataToolLinks - Expected Behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('Component behavior with valid viewers', () => { + it('should render valid viewer icons and copy icon', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Should render copy icon at minimum + const images = screen.getAllByRole('img'); + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + + // Should also have viewer logos + expect(images.length).toBeGreaterThan(1); + }); + + it('should call onToolClick when copy icon is clicked', async () => { + const onToolClick = vi.fn(async () => {}); + renderDataToolLinks(undefined, onToolClick); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Click the copy icon (always present) + const images = screen.getAllByRole('img'); + const copyIcon = images.find( + img => img.getAttribute('alt') === 'Copy URL icon' + ); + expect(copyIcon).toBeTruthy(); + + const copyButton = copyIcon!.closest('button'); + expect(copyButton).toBeTruthy(); + + copyButton!.click(); + + await waitFor(() => { + expect(onToolClick).toHaveBeenCalledWith('copy'); + }); + }); + + it('should render multiple viewer logos when URLs are provided', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + const images = screen.getAllByRole('img'); + + // Should have neuroglancer, avivator, and copy icons at minimum + expect(images.length).toBeGreaterThanOrEqual(3); + + // Verify specific logos are present + const neuroglancerLogo = images.find( + img => img.getAttribute('alt') === 'Neuroglancer logo' + ); + const avivatorLogo = images.find( + img => img.getAttribute('alt') === 'Avivator logo' + ); + + expect(neuroglancerLogo).toBeTruthy(); + expect(avivatorLogo).toBeTruthy(); + }); + }); + + describe('Tooltip behavior', () => { + it('should show "Copy data URL" tooltip by default', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // The copy button should have the correct aria-label + const copyButton = screen.getByLabelText('Copy data URL'); + expect(copyButton).toBeInTheDocument(); + }); + + it('should show viewer tooltip labels', async () => { + renderDataToolLinks(); + + await waitFor( + () => { + expect(screen.getByText('Test Tools')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Viewer buttons should have correct aria-labels from their config + const neuroglancerButton = screen.getByLabelText('View in Neuroglancer'); + expect(neuroglancerButton).toBeInTheDocument(); + + const avivatorButton = screen.getByLabelText('View in Avivator'); + expect(avivatorButton).toBeInTheDocument(); + }); + }); +}); From d2a4e840181c78cd3855c02248b7d0e0672d2ea0 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 10:10:11 -0500 Subject: [PATCH 22/46] fix: add ViewersProvider to unit test setup for zarr tests --- frontend/src/__tests__/test-utils.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/src/__tests__/test-utils.tsx b/frontend/src/__tests__/test-utils.tsx index a54d0a43..f3bcce0c 100644 --- a/frontend/src/__tests__/test-utils.tsx +++ b/frontend/src/__tests__/test-utils.tsx @@ -15,6 +15,7 @@ import { TicketProvider } from '@/contexts/TicketsContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; import { ServerHealthProvider } from '@/contexts/ServerHealthContext'; +import { ViewersProvider } from '@/contexts/ViewersContext'; import ErrorFallback from '@/components/ErrorFallback'; interface CustomRenderOptions extends Omit { @@ -40,13 +41,15 @@ const Browse = ({ children }: { children: ReactNode }) => { - - - - {children} - - - + + + + + {children} + + + + From b0e97a37975fc65a92f7adcf02d4867c379109e5 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 10:12:49 -0500 Subject: [PATCH 23/46] fix: change how logos urls are found - previously, if a logo path wasn't found, a 404 URL was still created, so it was never falling through to the fallback_logo. --- frontend/src/assets/fallback_logo.png | Bin 8198 -> 23569 bytes frontend/src/config/viewerLogos.ts | 34 +++++++++++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/frontend/src/assets/fallback_logo.png b/frontend/src/assets/fallback_logo.png index f636cabbcebaf46d99572dd5c77da3abe3d99525..da5fcc42a33889f495302c8948d7c5a3da4cce22 100644 GIT binary patch literal 23569 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�kwES{9sV8F&)~R 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 literal 8198 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 diff --git a/frontend/src/config/viewerLogos.ts b/frontend/src/config/viewerLogos.ts index e22fdb29..cbc13369 100644 --- a/frontend/src/config/viewerLogos.ts +++ b/frontend/src/config/viewerLogos.ts @@ -1,9 +1,21 @@ import fallback_logo from '@/assets/fallback_logo.png'; /** - * Fallback logo for viewers without a specified logo + * Map of all available logo files in the assets directory + * This is populated at build time by Vite's glob import */ -export const FALLBACK_LOGO = fallback_logo; +const LOGO_MODULES = import.meta.glob<{ default: string }>('@/assets/*.png', { + eager: true +}); + +/** + * Extract filename from glob import path + * Converts '/src/assets/neuroglancer.png' to 'neuroglancer.png' + */ +function extractFileName(path: string): string { + const parts = path.split('/'); + return parts[parts.length - 1]; +} /** * Get logo path for a viewer @@ -22,14 +34,14 @@ export function getViewerLogo( ): string { const logoFileName = customLogoPath || `${viewerName.toLowerCase()}.png`; - try { - // Try to dynamically import the logo from assets - // This will be resolved at build time by Vite - const logo = new URL(`../assets/${logoFileName}`, import.meta.url).href; - return logo; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (error) { - // If logo not found, return fallback - return FALLBACK_LOGO; + // Search through available logos + for (const [path, module] of Object.entries(LOGO_MODULES)) { + const fileName = extractFileName(path); + if (fileName === logoFileName) { + return module.default; + } } + + // If logo not found, return fallback + return fallback_logo; } From 8b2af824831018fed7b472c7a3ec8476b8c09713 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 10:13:41 -0500 Subject: [PATCH 24/46] tests: add unit tests for the getViewerLogo func - includes checking that a fallback_logo is used when no logo is found for a viewer --- .../componentTests/DataToolLinks.test.tsx | 19 +--- .../__tests__/unitTests/viewerLogos.test.ts | 100 ++++++++++++++++++ 2 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 frontend/src/__tests__/unitTests/viewerLogos.test.ts diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 90cd8257..9fc82e4c 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -147,22 +147,9 @@ describe('DataToolLinks - Edge Cases', () => { vi.clearAllMocks(); }); - describe('Fallback logo handling', () => { - it('should handle viewers with custom logos', async () => { - // Test that getViewerLogo returns appropriate path - const { getViewerLogo } = await import('@/config/viewerLogos'); - - // Test with known viewer - const neuroglancerLogo = getViewerLogo('neuroglancer'); - expect(neuroglancerLogo).toBeTruthy(); - - // Test with custom logo path - const customLogo = getViewerLogo('custom-viewer', 'custom-logo.png'); - expect(customLogo).toBeTruthy(); - }); - - it('should handle viewers with known logos', async () => { - // Test that viewers with known logo files render correctly + describe('Logo rendering in components', () => { + it('should render viewer logos in component', async () => { + // Test that viewers with known logo files render correctly in the component renderDataToolLinks(); await waitFor( diff --git a/frontend/src/__tests__/unitTests/viewerLogos.test.ts b/frontend/src/__tests__/unitTests/viewerLogos.test.ts new file mode 100644 index 00000000..60f491d6 --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewerLogos.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { getViewerLogo } from '@/config/viewerLogos'; +import fallback_logo from '@/assets/fallback_logo.png'; + +describe('getViewerLogo', () => { + describe('Existing logo files', () => { + it('should return logo path for viewer with existing logo file', () => { + const neuroglancerLogo = getViewerLogo('neuroglancer'); + expect(neuroglancerLogo).toBeTruthy(); + expect(neuroglancerLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for avivator', () => { + const avivatorLogo = getViewerLogo('avivator'); + expect(avivatorLogo).toBeTruthy(); + expect(avivatorLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for validator', () => { + const validatorLogo = getViewerLogo('validator'); + expect(validatorLogo).toBeTruthy(); + expect(validatorLogo).not.toBe(fallback_logo); + }); + + it('should return logo path for vole', () => { + const voleLogo = getViewerLogo('vole'); + expect(voleLogo).toBeTruthy(); + expect(voleLogo).not.toBe(fallback_logo); + }); + }); + + describe('Custom logo paths', () => { + it('should return logo when custom logo path exists', () => { + // Using an existing logo file as a custom path + const customLogo = getViewerLogo('any-name', 'neuroglancer.png'); + expect(customLogo).toBeTruthy(); + expect(customLogo).not.toBe(fallback_logo); + }); + + it('should return fallback when custom logo path does not exist', () => { + const nonExistentCustomLogo = getViewerLogo('test', 'nonexistent.png'); + expect(nonExistentCustomLogo).toBe(fallback_logo); + }); + }); + + describe('Fallback logo handling', () => { + it('should return fallback logo when viewer logo file does not exist', () => { + const nonExistentViewerLogo = getViewerLogo('nonexistent_viewer'); + expect(nonExistentViewerLogo).toBe(fallback_logo); + }); + + it('should return fallback logo for custom_viewer without logo file', () => { + const customViewerLogo = getViewerLogo('custom_viewer'); + expect(customViewerLogo).toBe(fallback_logo); + }); + + it('should return fallback logo for unknown viewer names', () => { + const unknownLogo = getViewerLogo('unknown_test_viewer_xyz'); + expect(unknownLogo).toBe(fallback_logo); + }); + }); + + describe('Case handling', () => { + it('should handle lowercase viewer names', () => { + const logo = getViewerLogo('neuroglancer'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + + it('should convert uppercase to lowercase for logo lookup', () => { + // getViewerLogo converts to lowercase, so 'NEUROGLANCER' -> 'neuroglancer.png' + const logo = getViewerLogo('NEUROGLANCER'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + + it('should handle mixed case viewer names', () => { + const logo = getViewerLogo('NeuroGlancer'); + expect(logo).toBeTruthy(); + expect(logo).not.toBe(fallback_logo); + }); + }); + + describe('Edge cases', () => { + it('should handle empty string viewer name', () => { + const emptyLogo = getViewerLogo(''); + expect(emptyLogo).toBe(fallback_logo); + }); + + it('should handle viewer names with special characters', () => { + const specialLogo = getViewerLogo('viewer-with-dashes'); + expect(specialLogo).toBe(fallback_logo); + }); + + it('should handle viewer names with underscores', () => { + const underscoreLogo = getViewerLogo('viewer_with_underscores'); + expect(underscoreLogo).toBe(fallback_logo); + }); + }); +}); \ No newline at end of file From 1ebfb3eae88c3d02a2d0d8774f67346c3ffc2483 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 10:14:20 -0500 Subject: [PATCH 25/46] chore: prettier formatting --- frontend/src/__tests__/unitTests/viewerLogos.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/__tests__/unitTests/viewerLogos.test.ts b/frontend/src/__tests__/unitTests/viewerLogos.test.ts index 60f491d6..2b0c8000 100644 --- a/frontend/src/__tests__/unitTests/viewerLogos.test.ts +++ b/frontend/src/__tests__/unitTests/viewerLogos.test.ts @@ -97,4 +97,4 @@ describe('getViewerLogo', () => { expect(underscoreLogo).toBe(fallback_logo); }); }); -}); \ No newline at end of file +}); From 5cacb679b9aabf5579b33e5b650608ad75e69431 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 12:49:59 -0500 Subject: [PATCH 26/46] refactor: move valid ome zarr versions to config file - this minimizes the number of files we need to edit to change anything related to the viewers --- frontend/src/config/viewers.config.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 6beb3e7b..756eccb8 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -4,6 +4,9 @@ # The @bioimagetools/capability-manifest library is used to determine compatibility # for viewers that have a capability manifest. # +# Global Configuration: +# - valid_ome_zarr_versions: Array of OME-Zarr versions supported by the application (e.g., `[0.4, 0.5]`) +# # For viewers with capability manifests, you must provide: # - name: must match name value in capability manifest # Optionally: @@ -19,6 +22,9 @@ # - logo: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) # - label: Custom tooltip text (defaults to "View in {Name}") +# Valid OME-Zarr versions supported by this application +valid_ome_zarr_versions: [0.4, 0.5] + viewers: # OME-Zarr viewers with capability manifests - name: neuroglancer From 7ec34eca68c24ad9abc6740eeecab11556429625 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 12:51:15 -0500 Subject: [PATCH 27/46] refactor: add zod for viewer config validation - still requires some custom validation related to the capability manifests --- frontend/package-lock.json | 12 +- frontend/package.json | 3 +- frontend/src/config/viewersConfig.ts | 177 ++++++++++++++++----------- 3 files changed, 119 insertions(+), 73 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d79efbeb..8f9e233f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,7 +34,8 @@ "react-syntax-highlighter": "^16.1.0", "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", - "zarrita": "^0.5.1" + "zarrita": "^0.5.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/css": "^0.8.1", @@ -11119,6 +11120,15 @@ "numcodecs": "^0.3.2" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6810c720..48c6c7be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,7 +55,8 @@ "react-syntax-highlighter": "^16.1.0", "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", - "zarrita": "^0.5.1" + "zarrita": "^0.5.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/css": "^0.8.1", diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 4507e9c6..fccabaaf 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -1,27 +1,72 @@ import yaml from 'js-yaml'; +import { z } from 'zod'; /** - * Viewer entry from viewers.config.yaml + * Zod schema for viewer entry from viewers.config.yaml */ -export interface ViewerConfigEntry { - name: string; - url?: string; - label?: string; - logo?: string; - ome_zarr_versions?: number[]; -} +const ViewerConfigEntrySchema = z.object( + { + name: z.string({ + message: 'Each viewer must have a "name" field (string)' + }), + url: z.string({ message: '"url" must be a string' }).optional(), + label: z.string({ message: '"label" must be a string' }).optional(), + logo: z.string({ message: '"logo" must be a string' }).optional(), + ome_zarr_versions: z + .array(z.number(), { message: '"ome_zarr_versions" must be an array' }) + .optional() + }, + { + error: iss => { + // When the viewer entry itself isn't an object + if (iss.code === 'invalid_type' && iss.expected === 'object') { + return 'Each viewer must have a "name" field (string)'; + } + // Return undefined to use default behavior for other errors + return undefined; + } + } +); /** - * Structure of viewers.config.yaml + * Zod schema for viewers.config.yaml structure */ -export interface ViewersConfigYaml { - viewers: ViewerConfigEntry[]; -} +const ViewersConfigYamlSchema = z.object( + { + valid_ome_zarr_versions: z + .array( + z.number({ + message: '"valid_ome_zarr_versions" must contain only numbers' + }), + { + message: + 'Configuration must have a "valid_ome_zarr_versions" field containing an array of numbers' + } + ) + .min(1, { + message: '"valid_ome_zarr_versions" must not be empty' + }), + viewers: z.array(ViewerConfigEntrySchema, { + message: + 'Configuration must have a "viewers" field containing an array of viewers' + }) + }, + { + error: iss => { + if (iss.code === 'invalid_type') { + return { + message: + 'Configuration must have "valid_ome_zarr_versions" and "viewers" fields' + }; + } + } + } +); -/** - * Valid OME-Zarr versions supported by the application - */ -const VALID_OME_ZARR_VERSIONS = [0.4, 0.5]; +// exported for use in ViewersContext +export type ViewerConfigEntry = z.infer; + +type ViewersConfigYaml = z.infer; /** * Parse and validate viewers configuration YAML @@ -42,91 +87,81 @@ export function parseViewersConfig( ); } - if (!parsed || typeof parsed !== 'object') { - throw new Error('Configuration must be an object'); - } + // First pass: validate basic structure with Zod + const baseValidation = ViewersConfigYamlSchema.safeParse(parsed); + + if (!baseValidation.success) { + // Extract the first error message with path context to extract viewer name if possible + const firstError = baseValidation.error.issues[0]; - const config = parsed as Record; + // Check if the error is nested within a specific viewer + if (firstError.path.length > 0 && firstError.path[0] === 'viewers') { + // Extract viewer index from path (e.g., ['viewers', 0, 'ome_zarr_versions']) + const viewerIndex = firstError.path[1]; + + if ( + typeof viewerIndex === 'number' && + parsed && + typeof parsed === 'object' + ) { + const configData = parsed as { viewers?: unknown[] }; + const viewer = configData.viewers?.[viewerIndex]; + + // Try to get viewer name if it exists + if (viewer && typeof viewer === 'object' && 'name' in viewer) { + const viewerName = (viewer as { name: unknown }).name; + if (typeof viewerName === 'string') { + throw new Error(`Viewer "${viewerName}": ${firstError.message}`); + } + } + } + } - if (!Array.isArray(config.viewers)) { - throw new Error('Configuration must have a "viewers" array'); + // Fallback to original error message + throw new Error(firstError.message); } + const config = baseValidation.data; + // Normalize viewer names for comparison (case-insensitive) const normalizedManifestViewers = viewersWithManifests.map(name => name.toLowerCase() ); - // Validate each viewer entry - for (const viewer of config.viewers) { - if (!viewer || typeof viewer !== 'object') { - throw new Error('Each viewer must be an object'); - } - - const v = viewer as Record; - - if (typeof v.name !== 'string') { - throw new Error('Each viewer must have a "name" field (string)'); - } + // Second pass: validate manifest-dependent requirements and cross-field constraints + for (let i = 0; i < config.viewers.length; i++) { + const viewer = config.viewers[i]; // Check if this viewer has a capability manifest const hasManifest = normalizedManifestViewers.includes( - v.name.toLowerCase() + viewer.name.toLowerCase() ); // If this viewer doesn't have a capability manifest, require additional fields if (!hasManifest) { - if (typeof v.url !== 'string') { + if (!viewer.url) { throw new Error( - `Viewer "${v.name}" does not have a capability manifest and must specify "url"` + `Viewer "${viewer.name}" does not have a capability manifest and must specify "url"` ); } - if ( - !Array.isArray(v.ome_zarr_versions) || - v.ome_zarr_versions.length === 0 - ) { + if (!viewer.ome_zarr_versions || viewer.ome_zarr_versions.length === 0) { throw new Error( - `Viewer "${v.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)` + `Viewer "${viewer.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)` ); } } - // Validate optional fields if present - if (v.url !== undefined && typeof v.url !== 'string') { - throw new Error(`Viewer "${v.name}": "url" must be a string`); - } - if (v.label !== undefined && typeof v.label !== 'string') { - throw new Error(`Viewer "${v.name}": "label" must be a string`); - } - if (v.logo !== undefined && typeof v.logo !== 'string') { - throw new Error(`Viewer "${v.name}": "logo" must be a string`); - } - if ( - v.ome_zarr_versions !== undefined && - !Array.isArray(v.ome_zarr_versions) - ) { - throw new Error( - `Viewer "${v.name}": "ome_zarr_versions" must be an array` - ); - } - // Validate ome_zarr_versions values if present - if ( - v.ome_zarr_versions !== undefined && - Array.isArray(v.ome_zarr_versions) - ) { - for (const version of v.ome_zarr_versions) { - if (!VALID_OME_ZARR_VERSIONS.includes(version)) { + if (viewer.ome_zarr_versions) { + for (const version of viewer.ome_zarr_versions) { + if (!config.valid_ome_zarr_versions.includes(version)) { throw new Error( - `Viewer "${v.name}": invalid ome_zarr_version "${version}". Valid versions are: ${VALID_OME_ZARR_VERSIONS.join(', ')}` + `Viewer "${viewer.name}": invalid ome_zarr_version "${version}". Valid versions are: ${config.valid_ome_zarr_versions.join(', ')}` ); } } } } - // Type assertion is safe here because we've performed comprehensive runtime validation above. - // TypeScript sees 'config' as Record but our validation ensures it matches - // ViewersConfigYaml structure. The intermediate 'unknown' cast is required for type compatibility. - return config as unknown as ViewersConfigYaml; + return config; } From 30f0853f91c4b2df69ab12a99f1864a2a5eeb616 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 12:51:55 -0500 Subject: [PATCH 28/46] refactor: update tests for ome zarr version in config; zod error msgs --- .../componentTests/DataToolLinks.test.tsx | 2 + .../__tests__/unitTests/viewersConfig.test.ts | 117 ++++++++++++++++-- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index 9fc82e4c..b3d0d8a1 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -115,6 +115,7 @@ describe('DataToolLinks - Error Scenarios', () => { const { parseViewersConfig } = await import('@/config/viewersConfig'); const configMissingUrl = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer # Missing url for viewer without manifest @@ -129,6 +130,7 @@ viewers: const { parseViewersConfig } = await import('@/config/viewersConfig'); const configMissingVersions = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index 815c3140..fae881b1 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -5,19 +5,36 @@ describe('parseViewersConfig', () => { describe('Valid configurations', () => { it('should parse valid config with viewers that have manifests', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer - name: avivator `; const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + expect(result.valid_ome_zarr_versions).toEqual([0.4, 0.5]); expect(result.viewers).toHaveLength(2); expect(result.viewers[0].name).toBe('neuroglancer'); expect(result.viewers[1].name).toBe('avivator'); }); + it('should support custom valid_ome_zarr_versions', () => { + const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5, 0.6] +viewers: + - name: custom-viewer + url: https://example.com/{dataLink} + ome_zarr_versions: [0.6] +`; + const result = parseViewersConfig(yaml, []); + + expect(result.valid_ome_zarr_versions).toEqual([0.4, 0.5, 0.6]); + expect(result.viewers[0].ome_zarr_versions).toEqual([0.6]); + }); + it('should parse config with custom viewer with all required fields', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com/{dataLink} @@ -33,6 +50,7 @@ viewers: it('should parse config with optional fields', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com/{dataLink} @@ -48,6 +66,7 @@ viewers: it('should allow viewer with manifest to override url', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer url: https://custom-neuroglancer.com/{dataLink} @@ -61,6 +80,7 @@ viewers: it('should parse mixed config with manifest and non-manifest viewers', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer - name: custom-viewer @@ -91,7 +111,7 @@ viewers: // js-yaml parses this as a string, which then fails the object check expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must be an object/ + /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ ); }); @@ -99,7 +119,7 @@ viewers: const invalidYaml = 'just a string'; expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must be an object/ + /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ ); }); @@ -107,46 +127,97 @@ viewers: const invalidYaml = ''; expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must be an object/ + /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ ); }); }); describe('Missing required fields', () => { + it('should throw error when valid_ome_zarr_versions is missing', () => { + const yaml = ` +viewers: + - name: neuroglancer +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Configuration must have a \"valid_ome_zarr_versions\" field containing an array of numbers/ + ); + }); + + it('should throw error when valid_ome_zarr_versions is not an array', () => { + const yaml = ` +valid_ome_zarr_versions: "not-an-array" +viewers: + - name: neuroglancer +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /Configuration must have a \"valid_ome_zarr_versions\" field containing an array of numbers/ + ); + }); + + it('should throw error when valid_ome_zarr_versions is empty', () => { + const yaml = ` +valid_ome_zarr_versions: [] +viewers: + - name: neuroglancer +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /"valid_ome_zarr_versions" must not be empty/ + ); + }); + + it('should throw error when valid_ome_zarr_versions contains non-numbers', () => { + const yaml = ` +valid_ome_zarr_versions: [0.4, "0.5"] +viewers: + - name: neuroglancer +`; + + expect(() => parseViewersConfig(yaml, [])).toThrow( + /"valid_ome_zarr_versions" must contain only numbers/ + ); + }); + it('should throw error when viewers array is missing', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] name: some-config other_field: value `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a "viewers" array/ + /Configuration must have a \"viewers\" field containing an array of viewers/ ); }); it('should throw error when viewers is not an array', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: not-an-array `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a "viewers" array/ + /Configuration must have a \"viewers\" field containing an array of viewers/ ); }); it('should throw error when viewer is not an object', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - just-a-string `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Each viewer must be an object/ + /Each viewer must have a "name" field \(string\)/ ); }); it('should throw error when viewer lacks name field', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - url: https://example.com ome_zarr_versions: [0.4] @@ -159,6 +230,7 @@ viewers: it('should throw error when viewer name is not a string', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: 123 url: https://example.com @@ -172,6 +244,7 @@ viewers: it('should throw error when custom viewer (no manifest) lacks url', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer ome_zarr_versions: [0.4] @@ -184,18 +257,20 @@ viewers: it('should throw error when custom viewer (no manifest) lacks ome_zarr_versions', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com/{dataLink} `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions" \(array of numbers\)/ ); }); it('should throw error when custom viewer has empty ome_zarr_versions array', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com/{dataLink} @@ -203,7 +278,7 @@ viewers: `; expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions" \(array of numbers\)/ ); }); }); @@ -211,6 +286,7 @@ viewers: describe('Invalid field types', () => { it('should throw error when url is not a string (for custom viewer)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: 123 @@ -220,12 +296,13 @@ viewers: // The required field check happens first, so if url is wrong type, // it's caught by the "must specify url" check expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "url"/ + /Viewer "custom-viewer": "url" must be a string/ ); }); it('should throw error when url override is not a string (for manifest viewer)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer url: 123 @@ -238,6 +315,7 @@ viewers: it('should throw error when label is not a string', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -252,6 +330,7 @@ viewers: it('should throw error when logo is not a string', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -266,6 +345,7 @@ viewers: it('should throw error when ome_zarr_versions is not an array (for custom viewer)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -274,12 +354,13 @@ viewers: // The required field check happens first and checks if it's an array expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions"/ + /Viewer "custom-viewer": "ome_zarr_versions" must be an array/ ); }); it('should throw error when ome_zarr_versions override is not an array (for manifest viewer)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer ome_zarr_versions: "not-an-array" @@ -294,6 +375,7 @@ viewers: describe('OME-Zarr version validation', () => { it('should accept valid ome_zarr_versions (0.4 and 0.5)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -307,6 +389,7 @@ viewers: it('should throw error for invalid ome_zarr_version (0.3)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -320,6 +403,7 @@ viewers: it('should throw error for invalid ome_zarr_version (1.0)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -333,6 +417,7 @@ viewers: it('should throw error when mixing valid and invalid versions', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -346,6 +431,7 @@ viewers: it('should throw error for invalid version in manifest viewer override', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer ome_zarr_versions: [0.3] @@ -358,6 +444,7 @@ viewers: it('should accept only 0.4', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -371,6 +458,7 @@ viewers: it('should accept only 0.5', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom-viewer url: https://example.com @@ -386,6 +474,7 @@ viewers: describe('Case sensitivity and normalization', () => { it('should handle case-insensitive manifest matching', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: Neuroglancer - name: AVIVATOR @@ -401,6 +490,7 @@ viewers: it('should match manifests case-insensitively for mixed case', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: NeuroGlancer `; @@ -417,6 +507,7 @@ viewers: describe('Edge cases', () => { it('should handle empty viewers array', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: [] `; @@ -427,6 +518,7 @@ viewers: [] it('should handle viewer with only name (has manifest)', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer `; @@ -439,6 +531,7 @@ viewers: it('should preserve all valid fields in parsed output', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom url: https://example.com @@ -460,6 +553,7 @@ viewers: it('should handle multiple valid ome_zarr_versions', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom url: https://example.com @@ -473,6 +567,7 @@ viewers: it('should handle single ome_zarr_version in array', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom url: https://example.com @@ -488,6 +583,7 @@ viewers: describe('Default parameter behavior', () => { it('should use empty array as default for viewersWithManifests', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: custom url: https://example.com @@ -503,6 +599,7 @@ viewers: it('should treat viewer as non-manifest when viewersWithManifests is empty', () => { const yaml = ` +valid_ome_zarr_versions: [0.4, 0.5] viewers: - name: neuroglancer `; From bc867c81ca1a15056aea601323bdf16a16fcb319 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 13:06:02 -0500 Subject: [PATCH 29/46] docs: add valid_ome_zarr_versions to viewer config docs --- docs/ViewersConfiguration.md | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 324b29b4..ae0d56e2 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -24,6 +24,29 @@ The configuration file is located at `frontend/src/config/viewers.config.yaml`. The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. You can modify this file to add, remove, or customize viewers for your deployment. +## Configuration Structure + +### Global Configuration + +At the top level of the YAML file, you must specify: + +- `valid_ome_zarr_versions`: Array of OME-Zarr versions supported by the application (e.g., `[0.4, 0.5]`) + - **Required field** - must be present and cannot be empty + - This defines which OME-Zarr versions are valid across all viewers + - Individual viewer `ome_zarr_versions` will be validated against this list + - **Default value**: `[0.4, 0.5]` (set in the default config file) + +### Example: + +```yaml +# Valid OME-Zarr versions supported by this application +valid_ome_zarr_versions: [0.4, 0.5] + +viewers: + - name: neuroglancer + # ... more viewers +``` + ## Viewer Types ### Viewers with Capability Manifests (Recommended) @@ -37,6 +60,7 @@ For viewers without capability manifests, you must provide: - `name`: Viewer identifier - `url`: URL template (use `{dataLink}` placeholder for dataset URL) - `ome_zarr_versions`: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) + - These values must be in the `valid_ome_zarr_versions` list Optionally: @@ -48,6 +72,8 @@ Optionally: ### Enable default viewers ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: neuroglancer - name: avivator @@ -56,6 +82,8 @@ viewers: ### Override viewer URL ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: avivator url: "https://my-avivator-instance.example.com/?image_url={dataLink}" @@ -64,6 +92,8 @@ viewers: ### Add custom viewer (with convention-based logo) ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: my-viewer url: "https://viewer.example.com/?data={dataLink}" @@ -75,6 +105,8 @@ viewers: ### Add custom viewer (with explicit logo) ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: my-viewer url: "https://viewer.example.com/?data={dataLink}" @@ -83,6 +115,19 @@ viewers: label: "Open in My Viewer" ``` +### Supporting additional OME-Zarr versions + +If you want to support additional OME-Zarr versions beyond 0.4 and 0.5: + +```yaml +valid_ome_zarr_versions: [0.4, 0.5, 0.6] + +viewers: + - name: my-viewer + url: "https://viewer.example.com/?data={dataLink}" + ome_zarr_versions: [0.5, 0.6] # Only supports newer versions +``` + ## Adding Custom Viewer Logos Logo resolution follows this order: @@ -96,6 +141,8 @@ Logo resolution follows this order: **Using the naming convention (recommended):** ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: my-viewer # Logo will automatically resolve to @/assets/my-viewer.png @@ -106,6 +153,8 @@ Just add `frontend/src/assets/my-viewer.png` - no config needed! **Using a custom logo filename:** ```yaml +valid_ome_zarr_versions: [0.4, 0.5] + viewers: - name: my-viewer logo: "custom-logo.png" # Will use @/assets/custom-logo.png From 83dd34f56caada1877823f2fbafd72129fd61099 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 3 Feb 2026 15:25:19 -0500 Subject: [PATCH 30/46] refactor: move metadata.multiscale check back to useZarrMetadata hook --- frontend/src/contexts/ViewersContext.tsx | 8 -- frontend/src/hooks/useZarrMetadata.ts | 114 +++++++++++++---------- 2 files changed, 67 insertions(+), 55 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index a2abc4e1..3478010b 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -218,7 +218,6 @@ export function ViewersProvider({ return compatibleNames.includes(viewer.manifest.viewer.name); } else { // Manual version check for viewers without manifests - // Custom viewers require both correct ome-zarr version AND OME metadata (multiscales) const zarrVersion = metadata.version ? parseFloat(metadata.version) : null; @@ -226,13 +225,6 @@ export function ViewersProvider({ return false; } - // Check if dataset has OME metadata (multiscales array) - const hasOmeMetadata = - metadata.multiscales && metadata.multiscales.length > 0; - if (!hasOmeMetadata) { - return false; - } - return viewer.supportedVersions.includes(zarrVersion); } }); diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index d009c0a0..744ca0d7 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -108,8 +108,8 @@ export default function useZarrMetadata() { // Get compatible viewers for this dataset let compatibleViewers = validViewers; - // If we have metadata, use capability checking to filter - if (metadata) { + // If we have multiscales metadata (OME-Zarr), use capability checking to filter + if (metadata?.multiscale) { // Convert our metadata to OmeZarrMetadata format for capability checking const omeZarrMetadata = { version: effectiveZarrVersion === 3 ? '0.5' : '0.4', @@ -120,35 +120,32 @@ export default function useZarrMetadata() { } as any; // Type assertion needed due to internal type differences compatibleViewers = getCompatibleViewers(omeZarrMetadata); - } - - // Create a Set for lookup of compatible viewer keys - // Needed to mark incompatible but valid (as defined by the viewer config) viewers as null in openWithToolUrls - const compatibleKeys = new Set(compatibleViewers.map(v => v.key)); - for (const viewer of validViewers) { - if (!compatibleKeys.has(viewer.key)) { - openWithToolUrls[viewer.key] = null; - } - } + // Create a Set for lookup of compatible viewer keys + // Needed to mark incompatible but valid (as defined by the viewer config) viewers as null in openWithToolUrls + const compatibleKeys = new Set(compatibleViewers.map(v => v.key)); - // For compatible viewers, generate URLs - for (const viewer of compatibleViewers) { - if (!url) { - // Compatible but no data URL yet - show as available (empty string) - openWithToolUrls[viewer.key] = ''; - continue; + for (const viewer of validViewers) { + if (!compatibleKeys.has(viewer.key)) { + openWithToolUrls[viewer.key] = null; + } } - // Generate the viewer URL - let viewerUrl = viewer.urlTemplate; + // For compatible viewers, generate URLs + for (const viewer of compatibleViewers) { + if (!url) { + // Compatible but no data URL yet - show as available (empty string) + openWithToolUrls[viewer.key] = ''; + continue; + } - // Special handling for Neuroglancer to maintain existing state generation logic - if (viewer.key === 'neuroglancer') { - const neuroglancerBaseUrl = viewer.urlTemplate; + // Generate the viewer URL + let viewerUrl = viewer.urlTemplate; - if (metadata?.multiscale) { - // OME-Zarr with multiscales + // Special handling for Neuroglancer to maintain existing state generation logic + if (viewer.key === 'neuroglancer') { + // Extract base URL from template (everything before #!) + const neuroglancerBaseUrl = viewer.urlTemplate.split('#!')[0] + '#!'; if (disableNeuroglancerStateGeneration) { viewerUrl = neuroglancerBaseUrl + @@ -178,34 +175,57 @@ export default function useZarrMetadata() { } } } else { - // Non-OME Zarr array - if (disableNeuroglancerStateGeneration) { - viewerUrl = - neuroglancerBaseUrl + - generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); - } else if (layerType) { - viewerUrl = - neuroglancerBaseUrl + - generateNeuroglancerStateForZarrArray( - url, - effectiveZarrVersion, - layerType - ); + // For other viewers, replace {dataLink} placeholder if present + if (viewerUrl.includes('{dataLink}')) { + viewerUrl = viewerUrl.replace( + /{dataLink}/g, + encodeURIComponent(url) + ); + } else { + // If no placeholder, use buildUrl with 'url' query param + viewerUrl = buildUrl(viewerUrl, null, { url }); } } - } else { - // For other viewers, replace {dataLink} placeholder if present - if (viewerUrl.includes('{dataLink}')) { - viewerUrl = viewerUrl.replace(/{dataLink}/g, encodeURIComponent(url)); + + openWithToolUrls[viewer.key] = viewerUrl; + } + } else { + // Non-OME Zarr - only Neuroglancer available + // Mark all non-Neuroglancer viewers as incompatible + for (const viewer of validViewers) { + if (viewer.key !== 'neuroglancer') { + openWithToolUrls[viewer.key] = null; } else { - // If no placeholder, use buildUrl with 'url' query param - viewerUrl = buildUrl(viewerUrl, null, { url }); + // Neuroglancer + if (url) { + // Extract base URL from template (everything before #!) + const neuroglancerBaseUrl = + viewer.urlTemplate.split('#!')[0] + '#!'; + if (disableNeuroglancerStateGeneration) { + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } else if (layerType) { + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForZarrArray( + url, + effectiveZarrVersion, + layerType + ); + } else { + // layerType not yet determined - use fallback + openWithToolUrls.neuroglancer = + neuroglancerBaseUrl + + generateNeuroglancerStateForDataURL(url, effectiveZarrVersion); + } + } else { + // No proxied URL - show Neuroglancer as available but empty + openWithToolUrls.neuroglancer = ''; + } } } - - openWithToolUrls[viewer.key] = viewerUrl; } - return openWithToolUrls; }, [ metadata, From da8dd86a93106b4788d961a22559e080aca68c4c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 21:39:26 +0000 Subject: [PATCH 31/46] feat: add capability manifest files for all viewers --- frontend/public/viewers/neuroglancer.yaml | 41 +++++++++++++++++++++++ frontend/public/viewers/validator.yaml | 41 +++++++++++++++++++++++ frontend/public/viewers/vizarr.yaml | 41 +++++++++++++++++++++++ frontend/public/viewers/vole.yaml | 41 +++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 frontend/public/viewers/neuroglancer.yaml create mode 100644 frontend/public/viewers/validator.yaml create mode 100644 frontend/public/viewers/vizarr.yaml create mode 100644 frontend/public/viewers/vole.yaml diff --git a/frontend/public/viewers/neuroglancer.yaml b/frontend/public/viewers/neuroglancer.yaml new file mode 100644 index 00000000..9b395a8e --- /dev/null +++ b/frontend/public/viewers/neuroglancer.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Neuroglancer" + version: "2.41.2" + repo: "https://github.com/google/neuroglancer" + template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: false diff --git a/frontend/public/viewers/validator.yaml b/frontend/public/viewers/validator.yaml new file mode 100644 index 00000000..58b15d59 --- /dev/null +++ b/frontend/public/viewers/validator.yaml @@ -0,0 +1,41 @@ +viewer: + name: "validator" + version: "1.0.0" + repo: "https://github.com/ome/ome-ngff-validator" + template_url: "https://ome.github.io/ome-ngff-validator/?source={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: [] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: true + + # Are HCS plates loaded when available? + hcs_plates: true + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/frontend/public/viewers/vizarr.yaml b/frontend/public/viewers/vizarr.yaml new file mode 100644 index 00000000..3098d390 --- /dev/null +++ b/frontend/public/viewers/vizarr.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Avivator" + version: "0.16.1" + repo: "https://github.com/hms-dbmi/viv" + template_url: "https://avivator.gehlenborglab.org/?image_url={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4] + + compression_codecs: ["blosc", "gzip"] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/frontend/public/viewers/vole.yaml b/frontend/public/viewers/vole.yaml new file mode 100644 index 00000000..e785144c --- /dev/null +++ b/frontend/public/viewers/vole.yaml @@ -0,0 +1,41 @@ +viewer: + name: "vole" + version: "1.0.0" + repo: "https://github.com/allen-cell-animated/volume-viewer" + template_url: "https://volumeviewer.allencell.org/viewer?url={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: [] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: false From 10bafa7c5f1b6c09f51902d48546db75bdd72773 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 21:39:31 +0000 Subject: [PATCH 32/46] refactor: simplify viewers config to manifest_url entries --- frontend/src/config/viewers.config.yaml | 52 +++-------- frontend/src/config/viewersConfig.ts | 118 ++++++------------------ 2 files changed, 40 insertions(+), 130 deletions(-) diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index 756eccb8..d9b25f7b 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -1,50 +1,20 @@ # Fileglancer OME-Zarr Viewers Configuration # -# This file defines which OME-Zarr viewers are available in your Fileglancer deployment. -# The @bioimagetools/capability-manifest library is used to determine compatibility -# for viewers that have a capability manifest. -# -# Global Configuration: -# - valid_ome_zarr_versions: Array of OME-Zarr versions supported by the application (e.g., `[0.4, 0.5]`) -# -# For viewers with capability manifests, you must provide: -# - name: must match name value in capability manifest -# Optionally: -# - url: to override the template url in the capability manifest -# - logo: to override the default logo at {name}.png -# - label: Custom tooltip text (defaults to "View in {Name}") -# -# For viewers without capability manifests, you must provide: -# - name: Viewer identifier -# - url: URL template (use `{dataLink}` placeholder for dataset URL) -# - ome_zarr_versions: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) -# Optionally: -# - logo: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) -# - label: Custom tooltip text (defaults to "View in {Name}") - -# Valid OME-Zarr versions supported by this application -valid_ome_zarr_versions: [0.4, 0.5] +# Each viewer entry requires: +# - manifest_url: URL to a capability manifest YAML file +# Optional overrides: +# - instance_template_url: Override the viewer's template_url from the manifest +# - logo: Filename of logo in frontend/src/assets/ (defaults to {normalized_name}.png) +# - label: Custom tooltip text (defaults to "View in {Name}") viewers: - # OME-Zarr viewers with capability manifests - - name: neuroglancer + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml' - - name: avivator - # Optional: Override the viewer URL from the capability manifest - # In this example, override to use Janelia's custom deployment - url: 'https://janeliascicomp.github.io/viv/' + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml' + instance_template_url: 'https://janeliascicomp.github.io/viv/' - # OME-Zarr viewers without capability manifests - # OME-Zarr Validator - # Logo will automatically resolve to @/assets/validator.png - - name: validator - url: 'https://ome.github.io/ome-ngff-validator/?source={dataLink}' - ome_zarr_versions: [0.4, 0.5] + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/ome-zarr-validator.yaml' label: 'View in OME-Zarr Validator' - # Vol-E - Allen Cell Explorer 3D viewer - # Logo will automatically resolve to @/assets/vole.png - - name: vole - url: 'https://volumeviewer.allencell.org/viewer?url={dataLink}' - ome_zarr_versions: [0.4, 0.5] + - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vol-e.yaml' label: 'View in Vol-E' diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index fccabaaf..4b868609 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -6,23 +6,22 @@ import { z } from 'zod'; */ const ViewerConfigEntrySchema = z.object( { - name: z.string({ - message: 'Each viewer must have a "name" field (string)' - }), - url: z.string({ message: '"url" must be a string' }).optional(), + manifest_url: z + .string({ + message: 'Each viewer must have a "manifest_url" field (string)' + }) + .url({ message: '"manifest_url" must be a valid URL' }), + instance_template_url: z + .string({ message: '"instance_template_url" must be a string' }) + .optional(), label: z.string({ message: '"label" must be a string' }).optional(), - logo: z.string({ message: '"logo" must be a string' }).optional(), - ome_zarr_versions: z - .array(z.number(), { message: '"ome_zarr_versions" must be an array' }) - .optional() + logo: z.string({ message: '"logo" must be a string' }).optional() }, { error: iss => { - // When the viewer entry itself isn't an object if (iss.code === 'invalid_type' && iss.expected === 'object') { - return 'Each viewer must have a "name" field (string)'; + return 'Each viewer must be an object with a "manifest_url" field'; } - // Return undefined to use default behavior for other errors return undefined; } } @@ -33,30 +32,20 @@ const ViewerConfigEntrySchema = z.object( */ const ViewersConfigYamlSchema = z.object( { - valid_ome_zarr_versions: z - .array( - z.number({ - message: '"valid_ome_zarr_versions" must contain only numbers' - }), - { - message: - 'Configuration must have a "valid_ome_zarr_versions" field containing an array of numbers' - } - ) + viewers: z + .array(ViewerConfigEntrySchema, { + message: + 'Configuration must have a "viewers" field containing an array of viewers' + }) .min(1, { - message: '"valid_ome_zarr_versions" must not be empty' - }), - viewers: z.array(ViewerConfigEntrySchema, { - message: - 'Configuration must have a "viewers" field containing an array of viewers' - }) + message: '"viewers" must contain at least one viewer' + }) }, { error: iss => { if (iss.code === 'invalid_type') { return { - message: - 'Configuration must have "valid_ome_zarr_versions" and "viewers" fields' + message: 'Configuration must have a "viewers" field' }; } } @@ -71,12 +60,8 @@ type ViewersConfigYaml = z.infer; /** * Parse and validate viewers configuration YAML * @param yamlContent - The YAML content to parse - * @param viewersWithManifests - Array of viewer names that have capability manifests (from initializeViewerManifests) */ -export function parseViewersConfig( - yamlContent: string, - viewersWithManifests: string[] = [] -): ViewersConfigYaml { +export function parseViewersConfig(yamlContent: string): ViewersConfigYaml { let parsed: unknown; try { @@ -87,16 +72,13 @@ export function parseViewersConfig( ); } - // First pass: validate basic structure with Zod - const baseValidation = ViewersConfigYamlSchema.safeParse(parsed); + const result = ViewersConfigYamlSchema.safeParse(parsed); - if (!baseValidation.success) { - // Extract the first error message with path context to extract viewer name if possible - const firstError = baseValidation.error.issues[0]; + if (!result.success) { + const firstError = result.error.issues[0]; // Check if the error is nested within a specific viewer if (firstError.path.length > 0 && firstError.path[0] === 'viewers') { - // Extract viewer index from path (e.g., ['viewers', 0, 'ome_zarr_versions']) const viewerIndex = firstError.path[1]; if ( @@ -107,61 +89,19 @@ export function parseViewersConfig( const configData = parsed as { viewers?: unknown[] }; const viewer = configData.viewers?.[viewerIndex]; - // Try to get viewer name if it exists - if (viewer && typeof viewer === 'object' && 'name' in viewer) { - const viewerName = (viewer as { name: unknown }).name; - if (typeof viewerName === 'string') { - throw new Error(`Viewer "${viewerName}": ${firstError.message}`); + if (viewer && typeof viewer === 'object' && 'manifest_url' in viewer) { + const manifestUrl = (viewer as { manifest_url: unknown }).manifest_url; + if (typeof manifestUrl === 'string') { + throw new Error( + `Viewer "${manifestUrl}": ${firstError.message}` + ); } } } } - // Fallback to original error message throw new Error(firstError.message); } - const config = baseValidation.data; - - // Normalize viewer names for comparison (case-insensitive) - const normalizedManifestViewers = viewersWithManifests.map(name => - name.toLowerCase() - ); - - // Second pass: validate manifest-dependent requirements and cross-field constraints - for (let i = 0; i < config.viewers.length; i++) { - const viewer = config.viewers[i]; - - // Check if this viewer has a capability manifest - const hasManifest = normalizedManifestViewers.includes( - viewer.name.toLowerCase() - ); - - // If this viewer doesn't have a capability manifest, require additional fields - if (!hasManifest) { - if (!viewer.url) { - throw new Error( - `Viewer "${viewer.name}" does not have a capability manifest and must specify "url"` - ); - } - if (!viewer.ome_zarr_versions || viewer.ome_zarr_versions.length === 0) { - throw new Error( - `Viewer "${viewer.name}" does not have a capability manifest and must specify "ome_zarr_versions" (array of numbers)` - ); - } - } - - // Validate ome_zarr_versions values if present - if (viewer.ome_zarr_versions) { - for (const version of viewer.ome_zarr_versions) { - if (!config.valid_ome_zarr_versions.includes(version)) { - throw new Error( - `Viewer "${viewer.name}": invalid ome_zarr_version "${version}". Valid versions are: ${config.valid_ome_zarr_versions.join(', ')}` - ); - } - } - } - } - - return config; + return result.data; } From cd05c361075e2c826f13a73ecb99b56d91d43f8b Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 21:40:04 +0000 Subject: [PATCH 33/46] refactor: update ViewersContext for new capability-manifest API --- frontend/src/contexts/ViewersContext.tsx | 135 +++++++++-------------- 1 file changed, 55 insertions(+), 80 deletions(-) diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index 3478010b..2bbd6118 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -7,8 +7,8 @@ import { type ReactNode } from 'react'; import { - initializeViewerManifests, - getCompatibleViewers as getCompatibleViewersFromManifest, + loadManifestsFromUrls, + isCompatible, type ViewerManifest, type OmeZarrMetadata } from '@bioimagetools/capability-manifest'; @@ -33,10 +33,8 @@ export interface ValidViewer { logoPath: string; /** Tooltip/alt text label */ label: string; - /** Associated capability manifest (if available) */ - manifest?: ViewerManifest; - /** Supported OME-Zarr versions (for viewers without manifests) */ - supportedVersions?: number[]; + /** Associated capability manifest (required) */ + manifest: ViewerManifest; } interface ViewersContextType { @@ -50,32 +48,30 @@ const ViewersContext = createContext(undefined); /** * Load viewers configuration from build-time config file - * @param viewersWithManifests - Array of viewer names that have capability manifests */ -async function loadViewersConfig( - viewersWithManifests: string[] -): Promise { +async function loadViewersConfig(): Promise { let configYaml: string; try { - // Try to dynamically import the config file - // This will be resolved at build time by Vite const module = await import('@/config/viewers.config.yaml?raw'); configYaml = module.default; log.info( 'Using custom viewers configuration from src/config/viewers.config.yaml' ); - // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { log.info( - 'No custom viewers.config.yaml found, using default configuration (neuroglancer only)' + 'No custom viewers.config.yaml found, using default configuration' ); - // Return default configuration - return [{ name: 'neuroglancer' }]; + return [ + { + manifest_url: + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml' + } + ]; } try { - const config = parseViewersConfig(configYaml, viewersWithManifests); + const config = parseViewersConfig(configYaml); return config.viewers; } catch (error) { log.error('Error parsing viewers configuration:', error); @@ -104,79 +100,71 @@ export function ViewersProvider({ try { log.info('Initializing viewers configuration...'); + // Load viewer config entries + const configEntries = await loadViewersConfig(); + log.info(`Loaded configuration for ${configEntries.length} viewers`); + + // Extract manifest URLs + const manifestUrls = configEntries.map(entry => entry.manifest_url); + // Load capability manifests - let loadedManifests: ViewerManifest[] = []; + let manifestsMap: Map; try { - loadedManifests = await initializeViewerManifests(); - log.info( - `Loaded ${loadedManifests.length} viewer capability manifests` - ); + manifestsMap = await loadManifestsFromUrls(manifestUrls); + log.info(`Loaded ${manifestsMap.size} viewer capability manifests`); } catch (manifestError) { - log.warn('Failed to load capability manifests:', manifestError); - log.warn( - 'Continuing without capability manifests. Only custom viewers with explicit configuration will be available.' + log.error('Failed to load capability manifests:', manifestError); + throw new Error( + `Failed to load viewer manifests: ${manifestError instanceof Error ? manifestError.message : 'Unknown error'}` ); } - const viewersWithManifests = loadedManifests.map(m => m.viewer.name); - - // Load viewer config entries - const configEntries = await loadViewersConfig(viewersWithManifests); - log.info(`Loaded configuration for ${configEntries.length} viewers`); - const validated: ValidViewer[] = []; // Map through viewer config entries to validate for (const entry of configEntries) { - const key = normalizeViewerName(entry.name); - const manifest = loadedManifests.find( - m => normalizeViewerName(m.viewer.name) === key - ); + const manifest = manifestsMap.get(entry.manifest_url); - let urlTemplate: string | undefined = entry.url; - let shouldInclude = true; - let skipReason = ''; - - if (manifest) { - if (!urlTemplate) { - // Use manifest template URL if no override - urlTemplate = manifest.viewer.template_url; - } - - if (!urlTemplate) { - shouldInclude = false; - skipReason = `has capability manifest but no template_url and no URL override in config`; - } - } else { - // No capability manifest - if (!urlTemplate) { - shouldInclude = false; - skipReason = `does not have a capability manifest and no URL provided in config`; - } + if (!manifest) { + log.warn( + `Viewer manifest from "${entry.manifest_url}" failed to load, skipping` + ); + continue; } - if (!shouldInclude) { - log.warn(`Viewer "${entry.name}" excluded: ${skipReason}`); + // Determine URL template + const urlTemplate = + entry.instance_template_url ?? manifest.viewer.template_url; + + if (!urlTemplate) { + log.warn( + `Viewer "${manifest.viewer.name}" has no template_url in manifest and no instance_template_url override, skipping` + ); continue; } + // Replace {DATA_URL} with {dataLink} for consistency with existing code + const normalizedUrlTemplate = urlTemplate.replace( + /{DATA_URL}/g, + '{dataLink}' + ); + // Create valid viewer entry - const displayName = - entry.name.charAt(0).toUpperCase() + entry.name.slice(1); + const key = normalizeViewerName(manifest.viewer.name); + const displayName = manifest.viewer.name; const label = entry.label || `View in ${displayName}`; - const logoPath = getViewerLogo(entry.name, entry.logo); + const logoPath = getViewerLogo(manifest.viewer.name, entry.logo); validated.push({ key, displayName, - urlTemplate: urlTemplate!, + urlTemplate: normalizedUrlTemplate, logoPath, label, - manifest, - supportedVersions: entry.ome_zarr_versions + manifest }); - log.info(`Viewer "${entry.name}" registered successfully`); + log.info(`Viewer "${manifest.viewer.name}" registered successfully`); } if (validated.length === 0) { @@ -212,22 +200,9 @@ export function ViewersProvider({ return []; } - return validViewers.filter(viewer => { - if (viewer.manifest) { - const compatibleNames = getCompatibleViewersFromManifest(metadata); - return compatibleNames.includes(viewer.manifest.viewer.name); - } else { - // Manual version check for viewers without manifests - const zarrVersion = metadata.version - ? parseFloat(metadata.version) - : null; - if (zarrVersion === null || !viewer.supportedVersions) { - return false; - } - - return viewer.supportedVersions.includes(zarrVersion); - } - }); + return validViewers.filter(viewer => + isCompatible(viewer.manifest, metadata) + ); }, [validViewers, isInitialized] ); From d892ca13222bf0ddd5fff1bc4951609373c1eab4 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 22:25:59 +0000 Subject: [PATCH 34/46] test: rewrite viewersConfig unit tests for manifest_url API test: refine error message expectations in viewersConfig tests --- .../__tests__/unitTests/viewersConfig.test.ts | 577 +++++------------- 1 file changed, 159 insertions(+), 418 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index fae881b1..dc10324d 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -3,610 +3,351 @@ import { parseViewersConfig } from '@/config/viewersConfig'; describe('parseViewersConfig', () => { describe('Valid configurations', () => { - it('should parse valid config with viewers that have manifests', () => { + it('should parse config with single manifest_url viewer', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer - - name: avivator + - manifest_url: https://example.com/neuroglancer.yaml `; - const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); - - expect(result.valid_ome_zarr_versions).toEqual([0.4, 0.5]); - expect(result.viewers).toHaveLength(2); - expect(result.viewers[0].name).toBe('neuroglancer'); - expect(result.viewers[1].name).toBe('avivator'); - }); - - it('should support custom valid_ome_zarr_versions', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5, 0.6] -viewers: - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [0.6] -`; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); - expect(result.valid_ome_zarr_versions).toEqual([0.4, 0.5, 0.6]); - expect(result.viewers[0].ome_zarr_versions).toEqual([0.6]); + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/neuroglancer.yaml' + ); + expect(result.viewers[0].instance_template_url).toBeUndefined(); + expect(result.viewers[0].label).toBeUndefined(); + expect(result.viewers[0].logo).toBeUndefined(); }); - it('should parse config with custom viewer with all required fields', () => { + it('should parse config with multiple viewers', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [0.4, 0.5] + - manifest_url: https://example.com/neuroglancer.yaml + - manifest_url: https://example.com/avivator.yaml + - manifest_url: https://example.com/validator.yaml `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); - expect(result.viewers).toHaveLength(1); - expect(result.viewers[0].name).toBe('custom-viewer'); - expect(result.viewers[0].url).toBe('https://example.com/{dataLink}'); - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + expect(result.viewers).toHaveLength(3); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/neuroglancer.yaml' + ); + expect(result.viewers[1].manifest_url).toBe( + 'https://example.com/avivator.yaml' + ); + expect(result.viewers[2].manifest_url).toBe( + 'https://example.com/validator.yaml' + ); }); - it('should parse config with optional fields', () => { + it('should parse config with all optional fields', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [0.4] + - manifest_url: https://example.com/viewer.yaml + instance_template_url: https://example.com/viewer?url={dataLink} label: Custom Viewer Label logo: custom-logo.png `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/viewer.yaml' + ); + expect(result.viewers[0].instance_template_url).toBe( + 'https://example.com/viewer?url={dataLink}' + ); expect(result.viewers[0].label).toBe('Custom Viewer Label'); expect(result.viewers[0].logo).toBe('custom-logo.png'); }); - it('should allow viewer with manifest to override url', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: neuroglancer - url: https://custom-neuroglancer.com/{dataLink} -`; - const result = parseViewersConfig(yaml, ['neuroglancer']); - - expect(result.viewers[0].url).toBe( - 'https://custom-neuroglancer.com/{dataLink}' - ); - }); - - it('should parse mixed config with manifest and non-manifest viewers', () => { + it('should parse config with manifest_url only (no optional fields)', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [0.4] - - name: avivator + - manifest_url: https://example.com/simple-viewer.yaml `; - const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); + const result = parseViewersConfig(yaml); - expect(result.viewers).toHaveLength(3); - expect(result.viewers[0].name).toBe('neuroglancer'); - expect(result.viewers[1].name).toBe('custom-viewer'); - expect(result.viewers[2].name).toBe('avivator'); + expect(result.viewers).toHaveLength(1); + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/simple-viewer.yaml' + }); }); }); describe('Invalid YAML syntax', () => { it('should throw error for malformed YAML', () => { - const invalidYaml = 'viewers:\n - name: test\n invalid: [[['; + const invalidYaml = 'viewers:\n - manifest_url: test\n invalid: [[['; - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + expect(() => parseViewersConfig(invalidYaml)).toThrow( /Failed to parse viewers configuration YAML/ ); }); - it('should throw error for invalid YAML structure', () => { - const invalidYaml = 'this is not valid yaml [[{]}'; - - // js-yaml parses this as a string, which then fails the object check - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ - ); - }); - - it('should throw error for non-object YAML', () => { + it('should throw error for non-object YAML (string)', () => { const invalidYaml = 'just a string'; - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); - it('should throw error for empty YAML', () => { - const invalidYaml = ''; - - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( - /Configuration must have "valid_ome_zarr_versions" and "viewers" fields/ - ); - }); - }); - - describe('Missing required fields', () => { - it('should throw error when valid_ome_zarr_versions is missing', () => { - const yaml = ` -viewers: - - name: neuroglancer -`; + it('should throw error for non-object YAML (number)', () => { + const invalidYaml = '123'; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a \"valid_ome_zarr_versions\" field containing an array of numbers/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); - it('should throw error when valid_ome_zarr_versions is not an array', () => { - const yaml = ` -valid_ome_zarr_versions: "not-an-array" -viewers: - - name: neuroglancer -`; + it('should throw error for non-object YAML (array)', () => { + const invalidYaml = '[1, 2, 3]'; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a \"valid_ome_zarr_versions\" field containing an array of numbers/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); - it('should throw error when valid_ome_zarr_versions is empty', () => { - const yaml = ` -valid_ome_zarr_versions: [] -viewers: - - name: neuroglancer -`; + it('should throw error for empty YAML', () => { + const invalidYaml = ''; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /"valid_ome_zarr_versions" must not be empty/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); - it('should throw error when valid_ome_zarr_versions contains non-numbers', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, "0.5"] -viewers: - - name: neuroglancer -`; + it('should throw error for null YAML', () => { + const invalidYaml = 'null'; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /"valid_ome_zarr_versions" must contain only numbers/ + expect(() => parseViewersConfig(invalidYaml)).toThrow( + /Configuration must have a "viewers" field/ ); }); + }); + describe('Missing required fields', () => { it('should throw error when viewers array is missing', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] name: some-config other_field: value `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a \"viewers\" field containing an array of viewers/ - ); - }); - - it('should throw error when viewers is not an array', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: not-an-array -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Configuration must have a \"viewers\" field containing an array of viewers/ - ); - }); - - it('should throw error when viewer is not an object', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - just-a-string -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Each viewer must have a "name" field \(string\)/ - ); - }); - - it('should throw error when viewer lacks name field', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - url: https://example.com - ome_zarr_versions: [0.4] -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Each viewer must have a "name" field \(string\)/ - ); - }); - - it('should throw error when viewer name is not a string', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: 123 - url: https://example.com - ome_zarr_versions: [0.4] -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Each viewer must have a "name" field \(string\)/ - ); - }); - - it('should throw error when custom viewer (no manifest) lacks url', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - ome_zarr_versions: [0.4] -`; - - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "url"/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Configuration must have a "viewers" field containing an array/ ); }); - it('should throw error when custom viewer (no manifest) lacks ome_zarr_versions', () => { + it('should throw error when viewer is missing manifest_url', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com/{dataLink} + - label: Custom Label + logo: custom.png `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions" \(array of numbers\)/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must have a "manifest_url" field/ ); }); - it('should throw error when custom viewer has empty ome_zarr_versions array', () => { + it('should throw error when viewers array is empty', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com/{dataLink} - ome_zarr_versions: [] +viewers: [] `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer" does not have a capability manifest and must specify "ome_zarr_versions" \(array of numbers\)/ + expect(() => parseViewersConfig(yaml)).toThrow( + /"viewers" must contain at least one viewer/ ); }); }); describe('Invalid field types', () => { - it('should throw error when url is not a string (for custom viewer)', () => { + it('should throw error when manifest_url is not a string', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: 123 - ome_zarr_versions: [0.4] + - manifest_url: 123 `; - // The required field check happens first, so if url is wrong type, - // it's caught by the "must specify url" check - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer": "url" must be a string/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must have a "manifest_url" field/ ); }); - it('should throw error when url override is not a string (for manifest viewer)', () => { + it('should throw error when manifest_url is not a valid URL', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer - url: 123 + - manifest_url: not-a-valid-url `; - expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( - /Viewer "neuroglancer": "url" must be a string/ + expect(() => parseViewersConfig(yaml)).toThrow( + /"manifest_url" must be a valid URL/ ); }); it('should throw error when label is not a string', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.4] + - manifest_url: https://example.com/viewer.yaml label: 123 `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer": "label" must be a string/ - ); + expect(() => parseViewersConfig(yaml)).toThrow(/"label" must be a string/); }); it('should throw error when logo is not a string', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.4] + - manifest_url: https://example.com/viewer.yaml logo: 123 `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer": "logo" must be a string/ - ); - }); - - it('should throw error when ome_zarr_versions is not an array (for custom viewer)', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: "not-an-array" -`; - - // The required field check happens first and checks if it's an array - expect(() => parseViewersConfig(yaml, [])).toThrow( - /Viewer "custom-viewer": "ome_zarr_versions" must be an array/ - ); - }); - - it('should throw error when ome_zarr_versions override is not an array (for manifest viewer)', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: neuroglancer - ome_zarr_versions: "not-an-array" -`; - - expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( - /Viewer "neuroglancer": "ome_zarr_versions" must be an array/ - ); - }); - }); - - describe('OME-Zarr version validation', () => { - it('should accept valid ome_zarr_versions (0.4 and 0.5)', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.4, 0.5] -`; - - const result = parseViewersConfig(yaml, []); - - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + expect(() => parseViewersConfig(yaml)).toThrow(/"logo" must be a string/); }); - it('should throw error for invalid ome_zarr_version (0.3)', () => { + it('should throw error when instance_template_url is not a string', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.3] + - manifest_url: https://example.com/viewer.yaml + instance_template_url: 123 `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + expect(() => parseViewersConfig(yaml)).toThrow( + /"instance_template_url" must be a string/ ); }); - it('should throw error for invalid ome_zarr_version (1.0)', () => { + it('should throw error when viewer entry is not an object (string in array)', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [1.0] + - just-a-string `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /invalid ome_zarr_version "1". Valid versions are: 0.4, 0.5/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must be an object with a "manifest_url" field/ ); }); - it('should throw error when mixing valid and invalid versions', () => { + it('should throw error when viewer entry is not an object (number in array)', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.3, 0.4, 0.5] + - 123 `; - expect(() => parseViewersConfig(yaml, [])).toThrow( - /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Each viewer must be an object with a "manifest_url" field/ ); }); - it('should throw error for invalid version in manifest viewer override', () => { + it('should throw error when viewers is not an array', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: neuroglancer - ome_zarr_versions: [0.3] +viewers: not-an-array `; - expect(() => parseViewersConfig(yaml, ['neuroglancer'])).toThrow( - /invalid ome_zarr_version "0.3". Valid versions are: 0.4, 0.5/ + expect(() => parseViewersConfig(yaml)).toThrow( + /Configuration must have a "viewers" field containing an array/ ); }); - - it('should accept only 0.4', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.4] -`; - - const result = parseViewersConfig(yaml, []); - - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4]); - }); - - it('should accept only 0.5', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - ome_zarr_versions: [0.5] -`; - - const result = parseViewersConfig(yaml, []); - - expect(result.viewers[0].ome_zarr_versions).toEqual([0.5]); - }); - }); - - describe('Case sensitivity and normalization', () => { - it('should handle case-insensitive manifest matching', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: Neuroglancer - - name: AVIVATOR -`; - - // Manifest names are lowercase - const result = parseViewersConfig(yaml, ['neuroglancer', 'avivator']); - - expect(result.viewers).toHaveLength(2); - expect(result.viewers[0].name).toBe('Neuroglancer'); - expect(result.viewers[1].name).toBe('AVIVATOR'); - }); - - it('should match manifests case-insensitively for mixed case', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: NeuroGlancer -`; - - // Should recognize this has a manifest (neuroglancer) - const result = parseViewersConfig(yaml, ['neuroglancer']); - - expect(result.viewers).toHaveLength(1); - expect(result.viewers[0].name).toBe('NeuroGlancer'); - // Should not require url or ome_zarr_versions since it has a manifest - }); }); describe('Edge cases', () => { - it('should handle empty viewers array', () => { - const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: [] -`; - - const result = parseViewersConfig(yaml, []); - - expect(result.viewers).toHaveLength(0); - }); - - it('should handle viewer with only name (has manifest)', () => { + it('should handle single viewer with only manifest_url', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer + - manifest_url: https://example.com/viewer.yaml `; - const result = parseViewersConfig(yaml, ['neuroglancer']); + const result = parseViewersConfig(yaml); expect(result.viewers).toHaveLength(1); - expect(result.viewers[0]).toEqual({ name: 'neuroglancer' }); + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml' + }); }); - it('should preserve all valid fields in parsed output', () => { + it('should preserve all valid optional fields in output', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom - url: https://example.com + - manifest_url: https://example.com/viewer.yaml + instance_template_url: https://example.com/viewer?url={dataLink} label: Custom Label logo: custom.png - ome_zarr_versions: [0.4, 0.5] `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); expect(result.viewers[0]).toEqual({ - name: 'custom', - url: 'https://example.com', + manifest_url: 'https://example.com/viewer.yaml', + instance_template_url: 'https://example.com/viewer?url={dataLink}', label: 'Custom Label', - logo: 'custom.png', - ome_zarr_versions: [0.4, 0.5] + logo: 'custom.png' }); }); - it('should handle multiple valid ome_zarr_versions', () => { + it('should strip/ignore unknown fields', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom - url: https://example.com - ome_zarr_versions: [0.4, 0.5] + - manifest_url: https://example.com/viewer.yaml + unknown_field: some-value + another_unknown: 123 `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4, 0.5]); + // Zod should strip unknown fields + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml' + }); + expect(result.viewers[0]).not.toHaveProperty('unknown_field'); + expect(result.viewers[0]).not.toHaveProperty('another_unknown'); }); - it('should handle single ome_zarr_version in array', () => { + it('should accept http and https URLs', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom - url: https://example.com - ome_zarr_versions: [0.4] + - manifest_url: http://example.com/viewer.yaml + - manifest_url: https://example.com/viewer.yaml `; - const result = parseViewersConfig(yaml, []); + const result = parseViewersConfig(yaml); - expect(result.viewers[0].ome_zarr_versions).toEqual([0.4]); + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].manifest_url).toBe( + 'http://example.com/viewer.yaml' + ); + expect(result.viewers[1].manifest_url).toBe( + 'https://example.com/viewer.yaml' + ); }); - }); - describe('Default parameter behavior', () => { - it('should use empty array as default for viewersWithManifests', () => { + it('should handle URL with special characters', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: custom - url: https://example.com - ome_zarr_versions: [0.4] + - manifest_url: https://example.com/viewer-config_v2.yaml?version=1.0&format=yaml `; - // Not passing second parameter const result = parseViewersConfig(yaml); - expect(result.viewers).toHaveLength(1); - expect(result.viewers[0].name).toBe('custom'); + expect(result.viewers[0].manifest_url).toBe( + 'https://example.com/viewer-config_v2.yaml?version=1.0&format=yaml' + ); }); - it('should treat viewer as non-manifest when viewersWithManifests is empty', () => { + it('should handle empty optional strings', () => { const yaml = ` -valid_ome_zarr_versions: [0.4, 0.5] viewers: - - name: neuroglancer + - manifest_url: https://example.com/viewer.yaml + label: "" + logo: "" + instance_template_url: "" `; - // Even though neuroglancer typically has a manifest, - // if not in the list, it should require url and versions - expect(() => parseViewersConfig(yaml, [])).toThrow(/must specify "url"/); + const result = parseViewersConfig(yaml); + + expect(result.viewers[0]).toEqual({ + manifest_url: 'https://example.com/viewer.yaml', + label: '', + logo: '', + instance_template_url: '' + }); }); }); }); From d5261a590131e8896bedd00e816e210d6bb47a5b Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 22:25:59 +0000 Subject: [PATCH 35/46] test: rewrite DataToolLinks component tests for manifest_url API test: fix mock hoisting in DataToolLinks test test: simplify capability manifest mock structure test: update tests for new capability-manifest API test fix test fix --- .devcontainer/devcontainer.json | 3 +- frontend/package-lock.json | 28 ++-- frontend/package.json | 2 +- .../componentTests/DataToolLinks.test.tsx | 135 ++++++++++++------ 4 files changed, 113 insertions(+), 55 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 191fe3b7..f69ec723 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -32,7 +32,8 @@ }, "mounts": [ "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind", - "source=fileglancer-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume" + "source=fileglancer-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume", + "source=/groups/scicompsoft/home/truhlara/gh-repos/capability-manifest,target=/workspaces/capability-manifest,type=bind" ], "remoteEnv": { "NODE_OPTIONS": "--max-old-space-size=4096", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f9e233f..38203f28 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { - "@bioimagetools/capability-manifest": "^0.2.0", + "@bioimagetools/capability-manifest": "file:../../capability-manifest", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", @@ -69,6 +69,23 @@ "vitest": "^3.1.3" } }, + "../../capability-manifest": { + "name": "@bioimagetools/capability-manifest", + "version": "0.3.1", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^24.10.1", + "ome-zarr.js": "^0.0.17", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vitest": "^4.0.18", + "zarrita": "^0.5.4" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -403,13 +420,8 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.2.0.tgz", - "integrity": "sha512-eZa4DmOCxbxS6BN8aHOqPNrfCP/zWMigvVwCY3aNTlLTsG3ZQ2Ap/TJetY7qnagNl3sIXYHiHbKDhBYNee5rDw==", - "license": "ISC", - "dependencies": { - "js-yaml": "^4.1.1" - } + "resolved": "../../capability-manifest", + "link": true }, "node_modules/@emnapi/core": { "version": "1.7.1", diff --git a/frontend/package.json b/frontend/package.json index 48c6c7be..5afd2c80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test": "vitest" }, "dependencies": { - "@bioimagetools/capability-manifest": "^0.2.0", + "@bioimagetools/capability-manifest": "file:../../capability-manifest", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx index b3d0d8a1..0d27b364 100644 --- a/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx +++ b/frontend/src/__tests__/componentTests/DataToolLinks.test.tsx @@ -17,30 +17,19 @@ vi.mock('@/logger', () => ({ })); // Mock capability manifest to avoid network requests in tests -vi.mock('@bioimagetools/capability-manifest', () => ({ - initializeViewerManifests: vi.fn(async () => [ - { - viewer: { - name: 'neuroglancer', - template_url: 'https://neuroglancer.com/#{dataLink}' - } - }, - { - viewer: { - name: 'avivator', - template_url: 'https://avivator.com/?url={dataLink}' - } - } - ]), - getCompatibleViewers: vi.fn(() => ['neuroglancer', 'avivator']) +const mockCapabilityManifest = vi.hoisted(() => ({ + loadManifestsFromUrls: vi.fn(), + isCompatible: vi.fn() })); +vi.mock('@bioimagetools/capability-manifest', () => mockCapabilityManifest); + const mockOpenWithToolUrls: OpenWithToolUrls = { copy: 'http://localhost:3000/test/copy/url', validator: 'http://localhost:3000/test/validator/url', neuroglancer: 'http://localhost:3000/test/neuroglancer/url', vole: 'http://localhost:3000/test/vole/url', - avivator: 'http://localhost:3000/test/avivator/url' + vizarr: 'http://localhost:3000/test/vizarr/url' }; // Helper component to wrap DataToolLinks with ViewersProvider @@ -77,6 +66,10 @@ function renderDataToolLinks( describe('DataToolLinks - Error Scenarios', () => { beforeEach(() => { vi.clearAllMocks(); + + // Default mock: return empty Map (no manifests loaded) + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue(new Map()); + mockCapabilityManifest.isCompatible.mockReturnValue(false); }); describe('Invalid YAML syntax', () => { @@ -87,9 +80,9 @@ describe('DataToolLinks - Error Scenarios', () => { // Import the parseViewersConfig function to test it directly const { parseViewersConfig } = await import('@/config/viewersConfig'); - const invalidYaml = 'viewers:\n - name: test\n invalid: [[['; + const invalidYaml = 'viewers:\n - manifest_url: test\n invalid: [[['; - expect(() => parseViewersConfig(invalidYaml, [])).toThrow( + expect(() => parseViewersConfig(invalidYaml)).toThrow( /Failed to parse viewers configuration YAML/ ); }); @@ -111,34 +104,29 @@ describe('DataToolLinks - Error Scenarios', () => { }); describe('Missing required fields', () => { - it('should throw error when custom viewer lacks required url field', async () => { + it('should throw error when viewer lacks required manifest_url field', async () => { const { parseViewersConfig } = await import('@/config/viewersConfig'); - const configMissingUrl = ` -valid_ome_zarr_versions: [0.4, 0.5] + const configMissingManifestUrl = ` viewers: - - name: custom-viewer - # Missing url for viewer without manifest + - label: Custom Label + # Missing manifest_url `; - expect(() => parseViewersConfig(configMissingUrl, [])).toThrow( - /does not have a capability manifest and must specify "url"/ + expect(() => parseViewersConfig(configMissingManifestUrl)).toThrow( + /Each viewer must have a "manifest_url" field/ ); }); - it('should throw error when custom viewer lacks ome_zarr_versions', async () => { + it('should throw error when viewers array is empty', async () => { const { parseViewersConfig } = await import('@/config/viewersConfig'); - const configMissingVersions = ` -valid_ome_zarr_versions: [0.4, 0.5] -viewers: - - name: custom-viewer - url: https://example.com - # Missing ome_zarr_versions for viewer without manifest + const configEmptyViewers = ` +viewers: [] `; - expect(() => parseViewersConfig(configMissingVersions, [])).toThrow( - /does not have a capability manifest and must specify "ome_zarr_versions"/ + expect(() => parseViewersConfig(configEmptyViewers)).toThrow( + /"viewers" must contain at least one viewer/ ); }); }); @@ -147,6 +135,34 @@ viewers: describe('DataToolLinks - Edge Cases', () => { beforeEach(() => { vi.clearAllMocks(); + + // Mock loadManifestsFromUrls to return Map with manifests + // URLs must match those in viewers.config.yaml + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue( + new Map([ + [ + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml', + { + viewer: { + name: 'Neuroglancer', + template_url: 'https://neuroglancer.com/#!{DATA_URL}' + } + } + ], + [ + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml', + { + viewer: { + name: 'Avivator', + template_url: 'https://vizarr.com/?url={DATA_URL}' + } + } + ] + ]) + ); + + // Mock isCompatible to return true for all viewers + mockCapabilityManifest.isCompatible.mockReturnValue(true); }); describe('Logo rendering in components', () => { @@ -171,16 +187,16 @@ describe('DataToolLinks - Edge Cases', () => { expect(neuroglancerLogo).toBeTruthy(); expect(neuroglancerLogo?.getAttribute('src')).toContain('neuroglancer'); - // Check for avivator logo (known viewer with logo) - const avivatorLogo = images.find( + // Check for avivator logo (name for viewer in vizarr.yaml) + const vizarrLogo = images.find( img => img.getAttribute('alt') === 'Avivator logo' ); - expect(avivatorLogo).toBeTruthy(); - expect(avivatorLogo?.getAttribute('src')).toContain('avivator'); + expect(vizarrLogo).toBeTruthy(); + expect(vizarrLogo?.getAttribute('src')).toContain('avivator'); }); }); - describe('Custom viewer without ome_zarr_versions', () => { + describe('Custom viewer compatibility', () => { it('should exclude viewer URL when set to null in OpenWithToolUrls', async () => { const urls: OpenWithToolUrls = { copy: 'http://localhost:3000/copy', @@ -228,7 +244,36 @@ describe('DataToolLinks - Edge Cases', () => { describe('DataToolLinks - Expected Behavior', () => { beforeEach(() => { vi.clearAllMocks(); + + // Mock loadManifestsFromUrls to return Map with manifests + // URLs must match those in viewers.config.yaml + mockCapabilityManifest.loadManifestsFromUrls.mockResolvedValue( + new Map([ + [ + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml', + { + viewer: { + name: 'Neuroglancer', + template_url: 'https://neuroglancer.com/#!{DATA_URL}' + } + } + ], + [ + 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml', + { + viewer: { + name: 'Avivator', + template_url: 'https://vizarr.com/?url={DATA_URL}' + } + } + ] + ]) + ); + + // Mock isCompatible to return true for all viewers + mockCapabilityManifest.isCompatible.mockReturnValue(true); }); + describe('Component behavior with valid viewers', () => { it('should render valid viewer icons and copy icon', async () => { renderDataToolLinks(); @@ -291,19 +336,19 @@ describe('DataToolLinks - Expected Behavior', () => { const images = screen.getAllByRole('img'); - // Should have neuroglancer, avivator, and copy icons at minimum + // Should have neuroglancer, vizarr, and copy icons at minimum expect(images.length).toBeGreaterThanOrEqual(3); // Verify specific logos are present const neuroglancerLogo = images.find( img => img.getAttribute('alt') === 'Neuroglancer logo' ); - const avivatorLogo = images.find( + const vizarrLogo = images.find( img => img.getAttribute('alt') === 'Avivator logo' ); expect(neuroglancerLogo).toBeTruthy(); - expect(avivatorLogo).toBeTruthy(); + expect(vizarrLogo).toBeTruthy(); }); }); @@ -337,8 +382,8 @@ describe('DataToolLinks - Expected Behavior', () => { const neuroglancerButton = screen.getByLabelText('View in Neuroglancer'); expect(neuroglancerButton).toBeInTheDocument(); - const avivatorButton = screen.getByLabelText('View in Avivator'); - expect(avivatorButton).toBeInTheDocument(); + const vizarrButton = screen.getByLabelText('View in Avivator'); + expect(vizarrButton).toBeInTheDocument(); }); }); }); From 05957ba243f0262ae2f4c9b1b469f9efc2fcd0a6 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 21:48:18 +0000 Subject: [PATCH 36/46] docs: update viewer configuration docs for manifest-based system docs fix --- CLAUDE.md | 8 +- docs/ViewersConfiguration.md | 267 +++++++++++++++++++++-------------- 2 files changed, 169 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8f1f444d..ecfdf6fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,14 +171,16 @@ Key settings: ## Viewers Configuration -Fileglancer supports dynamic viewer configuration through `viewers.config.yaml`. +Fileglancer uses a manifest-based viewer configuration system. Each viewer is defined by a **capability manifest** (a YAML file describing the viewer's name, URL template, and capabilities). The config file lists manifest URLs and optional overrides. -- **Configuration file**: `frontend/src/config/viewers.config.yaml` +- **Configuration file**: `frontend/src/config/viewers.config.yaml` -- lists viewers by `manifest_url` with optional `instance_template_url`, `label`, and `logo` overrides +- **Manifest files**: `frontend/public/viewers/*.yaml` -- capability manifest YAML files defining each viewer's identity and supported features +- **Compatibility**: Handled by the `@bioimagetools/capability-manifest` library, which checks dataset metadata against manifest capabilities at runtime - **Documentation**: See `docs/ViewersConfiguration.md` To customize viewers: -1. Edit `frontend/src/config/viewers.config.yaml` +1. Edit `frontend/src/config/viewers.config.yaml` (add/remove `manifest_url` entries, override URLs or labels) 2. Rebuild application: `pixi run node-build` The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. The config file is bundled at build time. diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index ae0d56e2..d31111ef 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -1,190 +1,251 @@ # Viewers Configuration Guide -Fileglancer supports dynamic configuration of OME-Zarr viewers. This allows administrators to customize which viewers are available in their deployment and configure custom viewer URLs. +Fileglancer supports dynamic configuration of OME-Zarr viewers. This allows administrators to customize which viewers are available in their deployment, override viewer URLs, and control how compatibility is determined. ## Overview -The viewer system uses: +The viewer system is built on capability manifests: -- **viewers.config.yaml**: User configuration file defining available viewers -- **@bioimagetools/capability-manifest**: Library for automatic compatibility detection -- **ViewersContext**: React context providing viewer information to the application +- **`viewers.config.yaml`**: Configuration file listing viewers and their manifest URLs +- **Capability manifest files**: YAML files describing each viewer's name, URL template, and capabilities +- **`@bioimagetools/capability-manifest`**: Library that loads manifests and checks dataset compatibility +- **`ViewersContext`**: React context that provides viewer information to the application + +Each viewer is defined by a **capability manifest** hosted at a URL. The configuration file simply lists manifest URLs and optional overrides. At runtime, the manifests are fetched, and the `@bioimagetools/capability-manifest` library determines which viewers are compatible with a given dataset based on the manifest's declared capabilities. ## Quick Start -1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` to enable/disable viewers or customize URLs +1. Edit the configuration file at `frontend/src/config/viewers.config.yaml` +2. Rebuild the application: `pixi run node-build` -2. Build the application - configuration is bundled at build time +## Configuration File -## Configuration File Location +### Location -The configuration file is located at `frontend/src/config/viewers.config.yaml`. +`frontend/src/config/viewers.config.yaml` **Important:** This file is bundled at build time. Changes require rebuilding the application. -The default configuration includes Neuroglancer, Avivator, OME-Zarr Validator, and Vol-E viewers. You can modify this file to add, remove, or customize viewers for your deployment. +### Structure -## Configuration Structure +The configuration file has a single top-level key, `viewers`, containing a list of viewer entries. Each entry requires a `manifest_url` and supports optional overrides. -### Global Configuration +#### Viewer Entry Fields -At the top level of the YAML file, you must specify: +| Field | Required | Description | +| ----------------------- | -------- | -------------------------------------------------------------------------------- | +| `manifest_url` | Yes | URL to a capability manifest YAML file | +| `instance_template_url` | No | Override the viewer's `template_url` from the manifest | +| `label` | No | Custom tooltip text (defaults to "View in {Name}") | +| `logo` | No | Filename of logo in `frontend/src/assets/` (defaults to `{normalized_name}.png`) | -- `valid_ome_zarr_versions`: Array of OME-Zarr versions supported by the application (e.g., `[0.4, 0.5]`) - - **Required field** - must be present and cannot be empty - - This defines which OME-Zarr versions are valid across all viewers - - Individual viewer `ome_zarr_versions` will be validated against this list - - **Default value**: `[0.4, 0.5]` (set in the default config file) +### Default Configuration -### Example: +The default `viewers.config.yaml` configures four viewers: ```yaml -# Valid OME-Zarr versions supported by this application -valid_ome_zarr_versions: [0.4, 0.5] - viewers: - - name: neuroglancer - # ... more viewers + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" + instance_template_url: "https://janeliascicomp.github.io/viv/" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/validator.yaml" + label: "View in OME-Zarr Validator" + + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vole.yaml" + label: "View in Vol-E" ``` -## Viewer Types +## Capability Manifest Files + +Manifest files describe a viewer's identity and capabilities. The default manifests are stored in `frontend/public/viewers/` and are hosted via GitHub. You can host your own manifest files anywhere accessible via URL. -### Viewers with Capability Manifests (Recommended) +### Manifest Structure -These viewers have metadata describing their capabilities, allowing automatic compatibility detection. For example, Neuroglancer and Avivator. For these viewers, you only need to specify the name. URL and compatibility are handled automatically. +A manifest has two sections: `viewer` (identity) and `capabilities` (what the viewer supports). -### Custom Viewers +#### Example: `neuroglancer.yaml` -For viewers without capability manifests, you must provide: +```yaml +viewer: + name: "Neuroglancer" + version: "2.41.2" + repo: "https://github.com/google/neuroglancer" + template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} + +capabilities: + ome_zarr_versions: [0.4, 0.5] + compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] + rfcs_supported: [] + axes: true + scale: true + translation: true + channels: true + timepoints: true + labels: false + hcs_plates: false + bioformats2raw_layout: false + omero_metadata: false +``` -- `name`: Viewer identifier -- `url`: URL template (use `{dataLink}` placeholder for dataset URL) -- `ome_zarr_versions`: Array of supported OME-Zarr versions (e.g., `[0.4, 0.5]`) - - These values must be in the `valid_ome_zarr_versions` list +### Viewer Section -Optionally: +| Field | Description | +| -------------- | -------------------------------------------------------------- | +| `name` | Display name for the viewer | +| `version` | Viewer version | +| `repo` | Repository URL | +| `template_url` | URL template with `{DATA_URL}` placeholder for the dataset URL | -- `logo`: Filename of logo in `frontend/src/assets/` (defaults to `{name}.png` if not specified) -- `label`: Custom tooltip text (defaults to "View in {Name}") +### Capabilities Section -## Configuration Examples +| Field | Type | Description | +| ----------------------- | -------- | ------------------------------------------------------------ | +| `ome_zarr_versions` | number[] | Supported OME-Zarr specification versions | +| `compression_codecs` | string[] | Supported compression codecs (e.g., "blosc", "zstd", "gzip") | +| `rfcs_supported` | string[] | Additional RFCs supported | +| `axes` | boolean | Whether axis names and units are respected | +| `scale` | boolean | Whether scaling factors on multiscales are respected | +| `translation` | boolean | Whether translation factors on multiscales are respected | +| `channels` | boolean | Whether multiple channels are supported | +| `timepoints` | boolean | Whether multiple timepoints are supported | +| `labels` | boolean | Whether labels are loaded when available | +| `hcs_plates` | boolean | Whether HCS plates are loaded when available | +| `bioformats2raw_layout` | boolean | Whether bioformats2raw layout is handled | +| `omero_metadata` | boolean | Whether OMERO metadata is used (e.g., channel colors) | -### Enable default viewers +## URL Templates and `{DATA_URL}` Placeholder -```yaml -valid_ome_zarr_versions: [0.4, 0.5] +The `{DATA_URL}` placeholder in a manifest's `template_url` (or a config entry's `instance_template_url`) is replaced at runtime with the actual dataset URL. Internally, `{DATA_URL}` is normalized to `{dataLink}` for consistency with the rest of the application. + +For example, given this manifest `template_url`: -viewers: - - name: neuroglancer - - name: avivator +``` +https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} ``` -### Override viewer URL +When a user clicks the viewer link for a dataset at `https://example.com/data.zarr`, the final URL becomes: -```yaml -valid_ome_zarr_versions: [0.4, 0.5] +``` +https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"https://example.com/data.zarr","type":"image"}]} +``` + +## Configuration Examples +### Minimal: single viewer + +```yaml viewers: - - name: avivator - url: "https://my-avivator-instance.example.com/?image_url={dataLink}" + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml" ``` -### Add custom viewer (with convention-based logo) +### Override a viewer's URL -```yaml -valid_ome_zarr_versions: [0.4, 0.5] +Use `instance_template_url` to point to a custom deployment of a viewer while still using its manifest for capability matching: +```yaml viewers: - - name: my-viewer - url: "https://viewer.example.com/?data={dataLink}" - ome_zarr_versions: [0.4, 0.5] - # Logo will automatically resolve to @/assets/my-viewer.png - label: "Open in My Viewer" + - manifest_url: "https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml" + instance_template_url: "https://my-avivator-instance.example.com/?image_url={dataLink}" + logo: avivator.png ``` -### Add custom viewer (with explicit logo) +### Add a custom viewer + +To add a new viewer, create a capability manifest YAML file, host it at a URL, and reference it in the config: + +1. Create a manifest file (e.g., `my-viewer.yaml`): ```yaml -valid_ome_zarr_versions: [0.4, 0.5] +viewer: + name: "My Viewer" + version: "1.0.0" + repo: "https://github.com/example/my-viewer" + template_url: "https://viewer.example.com/?data={DATA_URL}" + +capabilities: + ome_zarr_versions: [0.4, 0.5] + compression_codecs: ["blosc", "gzip"] + rfcs_supported: [] + axes: true + scale: true + translation: true + channels: true + timepoints: false + labels: false + hcs_plates: false + bioformats2raw_layout: false + omero_metadata: false +``` + +2. Host the manifest at an accessible URL (e.g., in your own `frontend/public/viewers/` directory, on GitHub, or any web server). +3. Reference it in `viewers.config.yaml`: + +```yaml viewers: - - name: my-viewer - url: "https://viewer.example.com/?data={dataLink}" - ome_zarr_versions: [0.4, 0.5] - logo: "custom-logo.png" # Use @/assets/custom-logo.png + - manifest_url: "https://example.com/manifests/my-viewer.yaml" label: "Open in My Viewer" ``` -### Supporting additional OME-Zarr versions +4. Optionally, add a logo file at `frontend/src/assets/myviewer.png` (the normalized name, lowercase with non-alphanumeric characters removed). + +## How Compatibility Works -If you want to support additional OME-Zarr versions beyond 0.4 and 0.5: +The `@bioimagetools/capability-manifest` library handles all compatibility checking. When a user views an OME-Zarr dataset: -```yaml -valid_ome_zarr_versions: [0.4, 0.5, 0.6] +1. The application reads the dataset's metadata (OME-Zarr version, axes, codecs, etc.) +2. For each registered viewer, the library's `isCompatible()` function compares the dataset metadata against the manifest's declared capabilities +3. Only viewers whose capabilities match the dataset are shown to the user -viewers: - - name: my-viewer - url: "https://viewer.example.com/?data={dataLink}" - ome_zarr_versions: [0.5, 0.6] # Only supports newer versions -``` +This replaces the previous system where `valid_ome_zarr_versions` was a global config setting and custom viewers used simple version matching. Now all compatibility logic is driven by the detailed capabilities declared in each viewer's manifest. ## Adding Custom Viewer Logos Logo resolution follows this order: -1. **Custom logo specified**: If you provide a `logo` field in the config, it will be used -2. **Convention-based**: If no logo is specified, the system looks for `@/assets/{name}.png` -3. **Fallback**: If neither exists, uses `@/assets/fallback_logo.png` +1. **Custom logo specified**: If you provide a `logo` field in the config entry, that filename is looked up in `frontend/src/assets/` +2. **Convention-based**: If no `logo` is specified, the system looks for `frontend/src/assets/{normalized_name}.png`, where the normalized name is the viewer's name lowercased with non-alphanumeric characters removed +3. **Fallback**: If neither is found, `frontend/src/assets/fallback_logo.png` is used -### Examples: +### Examples **Using the naming convention (recommended):** ```yaml -valid_ome_zarr_versions: [0.4, 0.5] - viewers: - - name: my-viewer - # Logo will automatically resolve to @/assets/my-viewer.png + - manifest_url: "https://example.com/manifests/neuroglancer.yaml" + # Logo automatically resolves to @/assets/neuroglancer.png ``` -Just add `frontend/src/assets/my-viewer.png` - no config needed! +Just add `frontend/src/assets/neuroglancer.png` -- no config needed. **Using a custom logo filename:** ```yaml -valid_ome_zarr_versions: [0.4, 0.5] - viewers: - - name: my-viewer - logo: "custom-logo.png" # Will use @/assets/custom-logo.png + - manifest_url: "https://example.com/manifests/vizarr.yaml" + logo: "avivator.png" # Uses @/assets/avivator.png ``` -## How Compatibility Works - -### For Viewers with Manifests - -The @bioimagetools/capability-manifest library checks: - -- OME-Zarr version support -- Axis types and configurations -- Compression codecs -- Special features (labels, HCS plates, etc.) - -### For Custom Viewers - -Simple version matching: - -- Dataset version is compared against `ome_zarr_versions` list -- Viewer is shown only if version matches - ## Development When developing with custom configurations: 1. Edit `frontend/src/config/viewers.config.yaml` 2. Rebuild frontend: `pixi run node-build` or use watch mode: `pixi run dev-watch` -3. Check console for initialization messages +3. Check the browser console for viewer initialization messages + +### Validation + +The configuration is validated at build time using Zod schemas (see `frontend/src/config/viewersConfig.ts`). Validation enforces: + +- The `viewers` array must contain at least one entry +- Each entry must have a valid `manifest_url` (a properly formed URL) +- Optional fields (`instance_template_url`, `label`, `logo`) must be strings if present + +At runtime, manifests that fail to load are skipped with a warning. If a viewer has no `template_url` (neither from its manifest nor from `instance_template_url` in the config), it is also skipped. ## Copy URL Tool From 0fce05c62925197cb9c794055b4df3d15aa8de9f Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 17:32:48 -0500 Subject: [PATCH 37/46] chore: prettier formatting --- frontend/src/__tests__/unitTests/viewersConfig.test.ts | 4 +++- frontend/src/config/viewersConfig.ts | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index dc10324d..1b4c4f9e 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -190,7 +190,9 @@ viewers: label: 123 `; - expect(() => parseViewersConfig(yaml)).toThrow(/"label" must be a string/); + expect(() => parseViewersConfig(yaml)).toThrow( + /"label" must be a string/ + ); }); it('should throw error when logo is not a string', () => { diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index 4b868609..ce0b0b12 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -90,11 +90,10 @@ export function parseViewersConfig(yamlContent: string): ViewersConfigYaml { const viewer = configData.viewers?.[viewerIndex]; if (viewer && typeof viewer === 'object' && 'manifest_url' in viewer) { - const manifestUrl = (viewer as { manifest_url: unknown }).manifest_url; + const manifestUrl = (viewer as { manifest_url: unknown }) + .manifest_url; if (typeof manifestUrl === 'string') { - throw new Error( - `Viewer "${manifestUrl}": ${firstError.message}` - ); + throw new Error(`Viewer "${manifestUrl}": ${firstError.message}`); } } } From 4ab4fe65e4ece774dc9b53bea809d0345b6d00aa Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 9 Feb 2026 18:00:02 -0500 Subject: [PATCH 38/46] chore: bump capability-manifest version --- frontend/package-lock.json | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 38203f28..1bd326fa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { - "@bioimagetools/capability-manifest": "file:../../capability-manifest", + "@bioimagetools/capability-manifest": "^0.3.1", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/package.json b/frontend/package.json index 5afd2c80..0dc704e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test": "vitest" }, "dependencies": { - "@bioimagetools/capability-manifest": "file:../../capability-manifest", + "@bioimagetools/capability-manifest": "^0.3.1", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", From d7ea96851f713904dee059eec26e169af308ac42 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 09:30:19 -0500 Subject: [PATCH 39/46] fix: update capability-manifest in package-lock.json --- frontend/package-lock.json | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1bd326fa..ffcac8e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,23 +69,6 @@ "vitest": "^3.1.3" } }, - "../../capability-manifest": { - "name": "@bioimagetools/capability-manifest", - "version": "0.3.1", - "license": "ISC", - "dependencies": { - "js-yaml": "^4.1.1" - }, - "devDependencies": { - "@types/js-yaml": "^4.0.9", - "@types/node": "^24.10.1", - "ome-zarr.js": "^0.0.17", - "typescript": "^5.9.3", - "vite": "^7.2.2", - "vitest": "^4.0.18", - "zarrita": "^0.5.4" - } - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -420,8 +403,13 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "resolved": "../../capability-manifest", - "link": true + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.3.1.tgz", + "integrity": "sha512-ZSztIhNCETvdUXlERQ7Tqu3lQuOWmxExax4O4fq+J4IO1ze/JoVakKR+uWdaE6+IVpgd/i0U+fGbP5+m45likw==", + "license": "ISC", + "dependencies": { + "js-yaml": "^4.1.1" + } }, "node_modules/@emnapi/core": { "version": "1.7.1", From c1048035d1f018e5799ca22b6fce06cb9d179937 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 09:39:59 -0500 Subject: [PATCH 40/46] tests: fix vole to vol-e in expected text --- frontend/ui-tests/tests/load-zarr-files.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/ui-tests/tests/load-zarr-files.spec.ts b/frontend/ui-tests/tests/load-zarr-files.spec.ts index a3ca9bd9..6105e7e0 100644 --- a/frontend/ui-tests/tests/load-zarr-files.spec.ts +++ b/frontend/ui-tests/tests/load-zarr-files.spec.ts @@ -35,7 +35,7 @@ test.describe('Zarr File Type Representation', () => { await expect( page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vole logo/i })).toHaveCount(0); + await expect(page.getByRole('img', { name: /vol-e logo/i })).toHaveCount(0); }); test('Zarr V3 OME-Zarr should show all viewers except avivator', async ({ @@ -54,7 +54,7 @@ test.describe('Zarr File Type Representation', () => { await expect( page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vole logo/i })).toBeVisible(); + await expect(page.getByRole('img', { name: /vol-e logo/i })).toBeVisible(); await expect( page.getByRole('img', { name: /validator logo/i }) ).toBeVisible(); @@ -79,7 +79,7 @@ test.describe('Zarr File Type Representation', () => { await expect( page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vole logo/i })).toHaveCount(0); + await expect(page.getByRole('img', { name: /vol-e logo/i })).toHaveCount(0); }); test('Zarr V2 OME-Zarr should display all viewers including avivator', async ({ @@ -98,7 +98,7 @@ test.describe('Zarr File Type Representation', () => { await expect( page.getByRole('img', { name: /neuroglancer logo/i }) ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vole logo/i })).toBeVisible(); + await expect(page.getByRole('img', { name: /vol-e logo/i })).toBeVisible(); await expect( page.getByRole('img', { name: /validator logo/i }) ).toBeVisible(); From 0bd173100fc8618410da7b19bad760b77ab750b2 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 09:52:03 -0500 Subject: [PATCH 41/46] test: remove timeout to rely on longer global timeout value --- frontend/ui-tests/tests/data-link-operations.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index e81bd560..714dda3e 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -42,7 +42,7 @@ test.describe('Data Link Operations', () => { await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); await expect( page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible(); const dataLinkToggle = page.getByRole('checkbox', { name: /data link/i }); const confirmButton = page.getByRole('button', { From 2f86cabf4d02e191ed50df1742354b4947ba0ae6 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:13:59 -0500 Subject: [PATCH 42/46] chore: bump capability-manifest version --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ffcac8e2..0364c8ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "2.4.0", "license": "BSD-3-Clause", "dependencies": { - "@bioimagetools/capability-manifest": "^0.3.1", + "@bioimagetools/capability-manifest": "^0.3.3", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", @@ -403,9 +403,9 @@ } }, "node_modules/@bioimagetools/capability-manifest": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.3.1.tgz", - "integrity": "sha512-ZSztIhNCETvdUXlERQ7Tqu3lQuOWmxExax4O4fq+J4IO1ze/JoVakKR+uWdaE6+IVpgd/i0U+fGbP5+m45likw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@bioimagetools/capability-manifest/-/capability-manifest-0.3.3.tgz", + "integrity": "sha512-McaIsgGyrxRxdQbDmek8On7PeSFA47pYOrfSudvd0d+VtZXX0VCYzq4RmJswVT+h19Bi4b4vTIinhJE0ACsCwA==", "license": "ISC", "dependencies": { "js-yaml": "^4.1.1" diff --git a/frontend/package.json b/frontend/package.json index 0dc704e7..f7769b4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "test": "vitest" }, "dependencies": { - "@bioimagetools/capability-manifest": "^0.3.1", + "@bioimagetools/capability-manifest": "^0.3.3", "@material-tailwind/react": "^3.0.0-beta.24", "@tanstack/react-query": "^5.90.2", "@tanstack/react-table": "^8.21.3", From 9c629d284f27729a3932ff22dfb92088e0db6497 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:39:41 -0500 Subject: [PATCH 43/46] fix: use relative paths for viewer manifest URLs to fix CI failures --- .../__tests__/unitTests/viewersConfig.test.ts | 20 +++++++++++++++++-- frontend/src/config/viewers.config.yaml | 12 ++++++----- frontend/src/config/viewersConfig.ts | 5 ++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index 1b4c4f9e..45db020a 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -172,14 +172,14 @@ viewers: ); }); - it('should throw error when manifest_url is not a valid URL', () => { + it('should throw error when manifest_url is not a valid URL or absolute path', () => { const yaml = ` viewers: - manifest_url: not-a-valid-url `; expect(() => parseViewersConfig(yaml)).toThrow( - /"manifest_url" must be a valid URL/ + /"manifest_url" must be a valid URL or an absolute path starting with \// ); }); @@ -320,6 +320,22 @@ viewers: ); }); + it('should accept absolute paths starting with /', () => { + const yaml = ` +viewers: + - manifest_url: /viewers/neuroglancer.yaml + - manifest_url: /viewers/vizarr.yaml +`; + + const result = parseViewersConfig(yaml); + + expect(result.viewers).toHaveLength(2); + expect(result.viewers[0].manifest_url).toBe( + '/viewers/neuroglancer.yaml' + ); + expect(result.viewers[1].manifest_url).toBe('/viewers/vizarr.yaml'); + }); + it('should handle URL with special characters', () => { const yaml = ` viewers: diff --git a/frontend/src/config/viewers.config.yaml b/frontend/src/config/viewers.config.yaml index d9b25f7b..008f3c6a 100644 --- a/frontend/src/config/viewers.config.yaml +++ b/frontend/src/config/viewers.config.yaml @@ -1,20 +1,22 @@ # Fileglancer OME-Zarr Viewers Configuration # # Each viewer entry requires: -# - manifest_url: URL to a capability manifest YAML file +# - manifest_url: URL or absolute path to a capability manifest YAML file +# Use absolute paths (e.g. /viewers/neuroglancer.yaml) for manifests bundled +# in the public/ directory, or full URLs for externally hosted manifests. # Optional overrides: # - instance_template_url: Override the viewer's template_url from the manifest # - logo: Filename of logo in frontend/src/assets/ (defaults to {normalized_name}.png) # - label: Custom tooltip text (defaults to "View in {Name}") viewers: - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/neuroglancer.yaml' + - manifest_url: '/viewers/neuroglancer.yaml' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vizarr.yaml' + - manifest_url: '/viewers/vizarr.yaml' instance_template_url: 'https://janeliascicomp.github.io/viv/' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/ome-zarr-validator.yaml' + - manifest_url: '/viewers/validator.yaml' label: 'View in OME-Zarr Validator' - - manifest_url: 'https://raw.githubusercontent.com/JaneliaSciComp/fileglancer/main/frontend/public/viewers/vol-e.yaml' + - manifest_url: '/viewers/vole.yaml' label: 'View in Vol-E' diff --git a/frontend/src/config/viewersConfig.ts b/frontend/src/config/viewersConfig.ts index ce0b0b12..0f305a09 100644 --- a/frontend/src/config/viewersConfig.ts +++ b/frontend/src/config/viewersConfig.ts @@ -10,7 +10,10 @@ const ViewerConfigEntrySchema = z.object( .string({ message: 'Each viewer must have a "manifest_url" field (string)' }) - .url({ message: '"manifest_url" must be a valid URL' }), + .refine(val => val.startsWith('/') || URL.canParse(val), { + message: + '"manifest_url" must be a valid URL or an absolute path starting with /' + }), instance_template_url: z .string({ message: '"instance_template_url" must be a string' }) .optional(), From f4e7f515e33dada10ca08aab395cb4d535eb7c40 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:40:08 -0500 Subject: [PATCH 44/46] =?UTF-8?q?fix:=20capitalize=20viewer=20names=20in?= =?UTF-8?q?=20manifests=20(validator=20=E2=86=92=20Validator,=20vole=20?= =?UTF-8?q?=E2=86=92=20Vol-E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/public/viewers/validator.yaml | 8 ++++---- frontend/public/viewers/vole.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/public/viewers/validator.yaml b/frontend/public/viewers/validator.yaml index 58b15d59..8a3ee897 100644 --- a/frontend/public/viewers/validator.yaml +++ b/frontend/public/viewers/validator.yaml @@ -1,8 +1,8 @@ viewer: - name: "validator" - version: "1.0.0" - repo: "https://github.com/ome/ome-ngff-validator" - template_url: "https://ome.github.io/ome-ngff-validator/?source={DATA_URL}" + name: 'Validator' + version: '1.0.0' + repo: 'https://github.com/ome/ome-ngff-validator' + template_url: 'https://ome.github.io/ome-ngff-validator/?source={DATA_URL}' capabilities: # Enumeration of OME-Zarr versions that can be loaded diff --git a/frontend/public/viewers/vole.yaml b/frontend/public/viewers/vole.yaml index e785144c..fdd67803 100644 --- a/frontend/public/viewers/vole.yaml +++ b/frontend/public/viewers/vole.yaml @@ -1,8 +1,8 @@ viewer: - name: "vole" - version: "1.0.0" - repo: "https://github.com/allen-cell-animated/volume-viewer" - template_url: "https://volumeviewer.allencell.org/viewer?url={DATA_URL}" + name: 'Vol-E' + version: '1.0.0' + repo: 'https://github.com/allen-cell-animated/volume-viewer' + template_url: 'https://volumeviewer.allencell.org/viewer?url={DATA_URL}' capabilities: # Enumeration of OME-Zarr versions that can be loaded From 3080e8a19e81ae6d401ee0e83366cc04adbf72f0 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:40:20 -0500 Subject: [PATCH 45/46] fix: use viewer label for logo alt text and update test locators to match --- frontend/src/assets/{vole.png => vol-e.png} | Bin .../ui/BrowsePage/DataToolLinks.tsx | 2 +- .../tests/data-link-operations.spec.ts | 8 +--- .../ui-tests/tests/load-zarr-files.spec.ts | 40 ++++++------------ 4 files changed, 15 insertions(+), 35 deletions(-) rename frontend/src/assets/{vole.png => vol-e.png} (100%) diff --git a/frontend/src/assets/vole.png b/frontend/src/assets/vol-e.png similarity index 100% rename from frontend/src/assets/vole.png rename to frontend/src/assets/vol-e.png diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index 68f4b409..050ec566 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -57,7 +57,7 @@ export default function DataToolLinks({ to={url} > {`${viewer.displayName} diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index 714dda3e..46ae24fa 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -40,9 +40,7 @@ test.describe('Data Link Operations', () => { // Wait for zarr metadata to load await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('link', { name: 'Neuroglancer logo' }) - ).toBeVisible(); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); const dataLinkToggle = page.getByRole('checkbox', { name: /data link/i }); const confirmButton = page.getByRole('button', { @@ -53,9 +51,7 @@ test.describe('Data Link Operations', () => { }); await test.step('Turn on automatic data links via the data link dialog', async () => { - const neuroglancerLink = page.getByRole('link', { - name: 'Neuroglancer logo' - }); + const neuroglancerLink = page.getByAltText(/neuroglancer/i); await neuroglancerLink.click(); // Confirm the data link creation in the dialog diff --git a/frontend/ui-tests/tests/load-zarr-files.spec.ts b/frontend/ui-tests/tests/load-zarr-files.spec.ts index 6105e7e0..1ae7a036 100644 --- a/frontend/ui-tests/tests/load-zarr-files.spec.ts +++ b/frontend/ui-tests/tests/load-zarr-files.spec.ts @@ -32,10 +32,8 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load (zarr.json file present indicates loaded) await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('img', { name: /neuroglancer logo/i }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vol-e logo/i })).toHaveCount(0); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toHaveCount(0); }); test('Zarr V3 OME-Zarr should show all viewers except avivator', async ({ @@ -51,16 +49,10 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('zarr.json')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('img', { name: /neuroglancer logo/i }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vol-e logo/i })).toBeVisible(); - await expect( - page.getByRole('img', { name: /validator logo/i }) - ).toBeVisible(); - await expect(page.getByRole('img', { name: /avivator logo/i })).toHaveCount( - 0 - ); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toBeVisible(); + await expect(page.getByAltText(/validator/i)).toBeVisible(); + await expect(page.getByAltText(/avivator/i)).toHaveCount(0); }); test('Zarr V2 Array should show only neuroglancer', async ({ @@ -76,10 +68,8 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('.zarray')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('img', { name: /neuroglancer logo/i }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vol-e logo/i })).toHaveCount(0); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toHaveCount(0); }); test('Zarr V2 OME-Zarr should display all viewers including avivator', async ({ @@ -95,16 +85,10 @@ test.describe('Zarr File Type Representation', () => { // Wait for zarr metadata to load await expect(page.getByText('.zattrs')).toBeVisible({ timeout: 10000 }); - await expect( - page.getByRole('img', { name: /neuroglancer logo/i }) - ).toBeVisible({ timeout: 10000 }); - await expect(page.getByRole('img', { name: /vol-e logo/i })).toBeVisible(); - await expect( - page.getByRole('img', { name: /validator logo/i }) - ).toBeVisible(); - await expect( - page.getByRole('img', { name: /avivator logo/i }) - ).toBeVisible(); + await expect(page.getByAltText(/neuroglancer/i)).toBeVisible(); + await expect(page.getByAltText(/vol-e/i)).toBeVisible(); + await expect(page.getByAltText(/validator/i)).toBeVisible(); + await expect(page.getByAltText(/avivator/i)).toBeVisible(); }); test('Refresh button should update zarr metadata when .zattrs is modified', async ({ From 06c0ab1e23371c225c42cfd95f3719460b75c23a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 12 Feb 2026 11:40:54 -0500 Subject: [PATCH 46/46] chore: prettier formatting --- frontend/src/__tests__/unitTests/viewersConfig.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/__tests__/unitTests/viewersConfig.test.ts b/frontend/src/__tests__/unitTests/viewersConfig.test.ts index 45db020a..3277ccdb 100644 --- a/frontend/src/__tests__/unitTests/viewersConfig.test.ts +++ b/frontend/src/__tests__/unitTests/viewersConfig.test.ts @@ -330,9 +330,7 @@ viewers: const result = parseViewersConfig(yaml); expect(result.viewers).toHaveLength(2); - expect(result.viewers[0].manifest_url).toBe( - '/viewers/neuroglancer.yaml' - ); + expect(result.viewers[0].manifest_url).toBe('/viewers/neuroglancer.yaml'); expect(result.viewers[1].manifest_url).toBe('/viewers/vizarr.yaml'); });