diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..cf3a3b7 --- /dev/null +++ b/.npmignore @@ -0,0 +1,20 @@ +# Source files (only ship compiled dist/) +src/ +tsconfig.json +tsconfig.lib.json +vite.config.ts + +# Development files +.git/ +.github/ +node_modules/ +docs/ +public/ + +# Web app specific (PoC only) +index.html +src/style.css +src/app.ts + +# Build artifacts from web app +*.map diff --git a/README.md b/README.md index 2c190be..8015a63 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,78 @@ Current OME-NGFF (i.e. OME-Zarr) tools tend to support different aspects of the Each tool could optionally publish a "capability manifest" which describes the tool's implementd capabilities with regards to the current and former NGFF Specifications. This manifest could simply live in the tool's Github repo, to be updated whenever relevant changes are made to the code. This manifest can then be interpreted computationally by any platform that wants to launch OME-NGFF tools (OMERO, BFF, Fileglancer, etc.) +## Library Usage + +This package can be used as a library to determine which OME-Zarr viewers are compatible with a given dataset. + +### Installation + +```bash +npm install @bioimagetools/capability-manifest +``` + +### Usage + +```typescript +import { + initialize, + getCompatibleViewers, + type OmeZarrMetadata +} from '@bioimagetools/capability-manifest'; + +// Initialize once at application startup +await initialize(); + +// For each dataset, pass pre-parsed metadata +const metadata: OmeZarrMetadata = { + version: '0.4', + axes: [ + { name: 'z', type: 'space' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ], + // ... other metadata +}; + +// Get list of compatible viewer names +const viewers = getCompatibleViewers(metadata); +// Returns: ['Avivator', 'Neuroglancer'] +``` + +### API + +#### `initialize(): Promise` + +Loads all viewer capability manifests. Must be called once at application startup before using other functions. + +Throws an error if no manifests can be loaded. + +#### `getCompatibleViewers(metadata: OmeZarrMetadata): string[]` + +Returns array of viewer names that are compatible with the given dataset metadata. + +- **Parameters:** + - `metadata`: Pre-parsed OME-Zarr metadata object (use ome-zarr.js or similar to parse from Zarr stores) + +- **Returns:** Array of viewer names (e.g., `['Avivator', 'Neuroglancer']`) + +- **Throws:** Error if library not initialized + +#### `getCompatibleViewersWithDetails(metadata: OmeZarrMetadata): Array<{name: string, validation: ValidationResult}>` + +Returns detailed compatibility information including validation errors and warnings for each compatible viewer. + +Useful for debugging or displaying why certain viewers work/don't work. + +### Types + +The library exports TypeScript types for all data structures: + +- `OmeZarrMetadata` - Structure of OME-Zarr metadata +- `ViewerManifest` - Structure of viewer capability manifests +- `ValidationResult` - Validation outcome with errors/warnings +- `ValidationError`, `ValidationWarning` - Detailed validation messages + ## Manifest Specification (DRAFT) | Attribute | Description | diff --git a/index.html b/index.html index ce05a61..db4fb18 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,6 @@

OME-NGFF Tool Capabilities

- + diff --git a/package-lock.json b/package-lock.json index 5aab77e..5fa48b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,24 @@ { - "name": "capability-manifest", - "version": "1.0.0", + "name": "@bioimagetools/capability-manifest", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "capability-manifest", - "version": "1.0.0", + "name": "@bioimagetools/capability-manifest", + "version": "0.1.0", "license": "ISC", "dependencies": { - "js-yaml": "^4.1.1", - "ome-zarr.js": "^0.0.17", - "zarrita": "^0.5.4" + "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" + "vite": "^7.2.2", + "vitest": "^4.0.18", + "zarrita": "^0.5.4" } }, "node_modules/@esbuild/aix-ppc64": { @@ -462,6 +463,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", @@ -770,6 +778,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -794,10 +827,122 @@ "undici-types": "~7.16.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@zarrita/storage": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.3.tgz", "integrity": "sha512-ZyCMYN3LuCNtKxro9876r/KyHyXV+ie2Bhk1qYsJR4Jp+sAjoVRRNNSJPsJxk64ZgFFezayO5S2hCu88/1Odwg==", + "dev": true, "license": "MIT", "dependencies": { "reference-spec-reader": "^0.2.0", @@ -810,6 +955,33 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -852,6 +1024,26 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -874,6 +1066,7 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, "license": "MIT" }, "node_modules/fsevents": { @@ -903,6 +1096,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -926,20 +1129,40 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "dev": true, "license": "MIT", "dependencies": { "fflate": "^0.8.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ome-zarr.js": { "version": "0.0.17", "resolved": "https://registry.npmjs.org/ome-zarr.js/-/ome-zarr.js-0.0.17.tgz", "integrity": "sha512-5t10obZtLX1Lwgo7kpqGdkPYJOAmRmTojCjhEiOlyDJ66zyyj7PCFN5CkUWsCQSvpWomZ9JwLsjEhv9rhJS4fQ==", + "dev": true, "license": "BSD", "dependencies": { "zarrita": "^0.5.0" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -993,6 +1216,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", + "dev": true, "license": "MIT" }, "node_modules/rollup": { @@ -1037,6 +1261,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1047,6 +1278,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1064,6 +1326,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1089,6 +1361,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", + "dev": true, "license": "MIT", "dependencies": { "uzip-module": "^1.0.2" @@ -1101,6 +1374,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", + "dev": true, "license": "MIT" }, "node_modules/vite": { @@ -1178,10 +1452,106 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zarrita": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.5.4.tgz", "integrity": "sha512-i88iN2+HqIQ+uiCEWLfhjbYNXAJD7IrM4h3lFwFclfqEOOhxp10amRWtqmgN5jbuy3+h0LwdyLVVzk4y9rTLgg==", + "dev": true, "license": "MIT", "dependencies": { "@zarrita/storage": "^0.1.3", diff --git a/package.json b/package.json index 081566c..41f34d4 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,57 @@ { - "name": "capability-manifest", - "version": "1.0.0", - "description": "During the **2025 OME-NGFF Workflows Hackathon**, participants discussed the potential need for a way to programmatically determine OME-NGFF tool capabilities. This repo is a place to experiment with schemas for capability manifests for OME-Zarr-compatible tools.", + "name": "@bioimagetools/capability-manifest", + "version": "0.1.0", + "description": "Library to determine OME-Zarr viewer compatibility based on capability manifests", "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "dev": "vite", "build": "tsc && vite build", + "build:lib": "tsc --project tsconfig.lib.json", "preview": "vite preview", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "repository": { "type": "git", "url": "git+https://github.com/BioImageTools/capability-manifest.git" }, - "keywords": [], + "keywords": [ + "ome-zarr", + "ome-ngff", + "viewer", + "compatibility" + ], "author": "", "license": "ISC", "bugs": { "url": "https://github.com/BioImageTools/capability-manifest/issues" }, "homepage": "https://github.com/BioImageTools/capability-manifest#readme", + "dependencies": { + "js-yaml": "^4.1.1" + }, "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.1", - "typescript": "^5.9.3", - "vite": "^7.2.2" - }, - "dependencies": { - "js-yaml": "^4.1.1", "ome-zarr.js": "^0.0.17", + "typescript": "^5.9.3", + "vite": "^7.2.2", + "vitest": "^4.0.18", "zarrita": "^0.5.4" } } diff --git a/src/main.ts b/src/app.ts similarity index 72% rename from src/main.ts rename to src/app.ts index 5fe9fdf..a07ba8c 100644 --- a/src/main.ts +++ b/src/app.ts @@ -4,13 +4,13 @@ import yaml from 'js-yaml'; import * as omezarr from 'ome-zarr.js'; import { FetchStore } from 'zarrita'; +import { initializeViewerManifests } from './index.js'; +import { validateViewer } from './validator.js'; import type { Schema, ViewerManifest, OmeZarrMetadata, - ValidationResult, - ValidationError, - ValidationWarning + ValidationResult } from './types'; import './style.css'; @@ -127,144 +127,6 @@ async function loadZarrMetadata(url: string): Promise { } } -// ============================================================================ -// VALIDATION -// ============================================================================ -function validateViewer(viewer: ViewerManifest, metadata: OmeZarrMetadata): ValidationResult { - const errors: ValidationError[] = []; - const warnings: ValidationWarning[] = []; - - console.log('='.repeat(80)); - console.log(`Validating viewer: ${viewer.viewer.name}`); - console.log('Metadata:', metadata); - console.log('Viewer capabilities:', viewer.capabilities); - - // Check version compatibility - this is critical - let dataVersion: number | null = null; - - // Try to extract version from metadata (could be in root or in multiscales) - if (metadata.version) { - dataVersion = parseFloat(metadata.version); - console.log(`Found version in root: ${dataVersion}`); - } else if (metadata.multiscales && metadata.multiscales.length > 0 && metadata.multiscales[0].version) { - dataVersion = parseFloat(metadata.multiscales[0].version); - console.log(`Found version in multiscales[0]: ${dataVersion}`); - } else { - console.log('No version found in metadata'); - } - - if (dataVersion !== null) { - console.log(`Data version: ${dataVersion}`); - console.log(`Viewer supports versions:`, viewer.capabilities.ome_zarr_versions); - - if (!viewer.capabilities.ome_zarr_versions || viewer.capabilities.ome_zarr_versions.length === 0) { - console.log('ERROR: Viewer does not specify OME-Zarr version support'); - errors.push({ - capability: 'ome_zarr_versions', - message: `Viewer does not specify OME-Zarr version support (data is v${dataVersion})`, - required: dataVersion, - found: [] - }); - } else if (!viewer.capabilities.ome_zarr_versions.includes(dataVersion)) { - console.log(`ERROR: Viewer does not support v${dataVersion}`); - errors.push({ - capability: 'ome_zarr_versions', - message: `Viewer does not support OME-Zarr v${dataVersion} (supports: ${viewer.capabilities.ome_zarr_versions.join(', ')})`, - required: dataVersion, - found: viewer.capabilities.ome_zarr_versions - }); - } else { - console.log(`OK: Viewer supports v${dataVersion}`); - } - } - - // Check compression codecs - if (metadata.compressor) { - const codec = metadata.compressor.id || metadata.compressor; - if (viewer.capabilities.compression_codecs && - !viewer.capabilities.compression_codecs.includes(codec)) { - errors.push({ - capability: 'compression_codecs', - message: `Viewer does not support compression codec: ${codec}`, - required: codec, - found: viewer.capabilities.compression_codecs - }); - } - } - - // Check axes support - if (metadata.axes && !viewer.capabilities.axes) { - warnings.push({ - capability: 'axes', - message: 'Dataset has axis metadata but viewer may not respect it' - }); - } - - // Check for multiple channels - const hasChannels = metadata.axes?.some(ax => ax.name === 'c' || ax.type === 'channel'); - if (hasChannels && !viewer.capabilities.channels) { - errors.push({ - capability: 'channels', - message: 'Dataset has multiple channels but viewer does not support them', - required: true, - found: false - }); - } - - // Check for timepoints - const hasTime = metadata.axes?.some(ax => ax.name === 't' || ax.type === 'time'); - if (hasTime && !viewer.capabilities.timepoints) { - errors.push({ - capability: 'timepoints', - message: 'Dataset has multiple timepoints but viewer does not support them', - required: true, - found: false - }); - } - - // Check for labels - if (metadata.labels && metadata.labels.length > 0 && !viewer.capabilities.labels) { - errors.push({ - capability: 'labels', - message: 'Dataset has labels but viewer does not support them', - required: true, - found: false - }); - } - - // Check for HCS plates - if (metadata.plate && !viewer.capabilities.hcs_plates) { - errors.push({ - capability: 'hcs_plates', - message: 'Dataset is an HCS plate but viewer does not support plates', - required: true, - found: false - }); - } - - // Check OMERO metadata - if (metadata.omero && !viewer.capabilities.omero_metadata) { - warnings.push({ - capability: 'omero_metadata', - message: 'Dataset has OMERO metadata but viewer may not use it' - }); - } - - const result = { - compatible: errors.length === 0, - errors, - warnings - }; - - console.log(`Validation result for ${viewer.viewer.name}:`, result); - console.log(` Compatible: ${result.compatible}`); - console.log(` Errors: ${result.errors.length}`); - console.log(` Warnings: ${result.warnings.length}`); - console.log('='.repeat(80)); - - return result; -} - // ============================================================================ // UI RENDERING // ============================================================================ @@ -489,7 +351,10 @@ async function main(): Promise { const loadingEl = document.getElementById('loading')!; const errorEl = document.getElementById('error')!; - // Load schema and viewers + // Initialize library (loads viewer manifests) + await initializeViewerManifests(); + + // Load schema and viewers separately for table display const [schema, viewers] = await Promise.all([ loadSchema(), loadViewers(VIEWER_FILES) @@ -529,7 +394,7 @@ async function main(): Promise { document.getElementById('capabilities-table')!.style.display = 'table'; } catch (error: any) { - console.error('Failed to initialize:', error); + console.error('Failed to initializeViewerManifests:', error); document.getElementById('loading')!.style.display = 'none'; const errorEl = document.getElementById('error')!; errorEl.textContent = `Error: ${error.message}`; diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..073c85e --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { OmeZarrMetadata } from './types.js'; + +// Sample YAML manifest for testing +const sampleManifestYaml = ` +viewer: + name: TestViewer + version: 1.0.0 +capabilities: + ome_zarr_versions: + - 0.4 + - 0.5 + compression_codecs: + - blosc + - gzip + channels: true + timepoints: true + labels: true + hcs_plates: false +`; + +const limitedManifestYaml = ` +viewer: + name: LimitedViewer + version: 1.0.0 +capabilities: + ome_zarr_versions: + - 0.4 + channels: false + timepoints: false +`; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +describe('Public API', () => { + beforeEach(() => { + mockFetch.mockReset(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Reset the cached manifests by re-importing the module + // We need to reset module state between tests + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initializeViewerManifests', () => { + it('loads manifests from the registry', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + + // Dynamically import to get fresh module state + const { initializeViewerManifests: init } = await import('./index.js'); + + await expect(init()).resolves.toBeUndefined(); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('throws error when no manifests can be loaded', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const { initializeViewerManifests: init } = await import('./index.js'); + + await expect(init()).rejects.toThrow('Failed to load any viewer manifests'); + }); + + it('throws error when all manifests fail to load', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found' + }); + + const { initializeViewerManifests: init } = await import('./index.js'); + + await expect(init()).rejects.toThrow('Failed to load any viewer manifests'); + }); + }); + + describe('getCompatibleViewers', () => { + it('throws error when library not initialized', async () => { + const { getCompatibleViewers: get } = await import('./index.js'); + + const metadata: OmeZarrMetadata = { version: '0.4' }; + + expect(() => get(metadata)).toThrow('Library not initialized'); + }); + + it('returns array of compatible viewer names', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + + const { initializeViewerManifests: init, getCompatibleViewers: get } = + await import('./index.js'); + + await init(); + + const metadata: OmeZarrMetadata = { + version: '0.4', + axes: [ + { name: 'z', type: 'space' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }; + + const viewers = get(metadata); + + expect(Array.isArray(viewers)).toBe(true); + expect(viewers.length).toBeGreaterThan(0); + expect(typeof viewers[0]).toBe('string'); + }); + + it('filters out incompatible viewers', async () => { + let callCount = 0; + mockFetch.mockImplementation(() => { + callCount++; + const yaml = callCount === 1 ? sampleManifestYaml : limitedManifestYaml; + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(yaml) + }); + }); + + const { initializeViewerManifests: init, getCompatibleViewers: get } = + await import('./index.js'); + + await init(); + + // Metadata with channels - LimitedViewer doesn't support channels + const metadata: OmeZarrMetadata = { + version: '0.4', + axes: [ + { name: 'c', type: 'channel' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }; + + const viewers = get(metadata); + + expect(viewers).toContain('TestViewer'); + expect(viewers).not.toContain('LimitedViewer'); + }); + + it('returns empty array when no viewers are compatible', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(limitedManifestYaml) + }); + + const { initializeViewerManifests: init, getCompatibleViewers: get } = + await import('./index.js'); + + await init(); + + // Metadata with version 0.5 - LimitedViewer only supports 0.4 + const metadata: OmeZarrMetadata = { + version: '0.5', + axes: [ + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }; + + const viewers = get(metadata); + + expect(viewers).toEqual([]); + }); + }); + + describe('getCompatibleViewersWithDetails', () => { + it('throws error when library not initialized', async () => { + const { getCompatibleViewersWithDetails: get } = await import('./index.js'); + + const metadata: OmeZarrMetadata = { version: '0.4' }; + + expect(() => get(metadata)).toThrow('Library not initialized'); + }); + + it('returns array with viewer name and validation details', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + + const { initializeViewerManifests: init, getCompatibleViewersWithDetails: get } = + await import('./index.js'); + + await init(); + + const metadata: OmeZarrMetadata = { + version: '0.4', + axes: [ + { name: 'z', type: 'space' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }; + + const results = get(metadata); + + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBeGreaterThan(0); + expect(results[0]).toHaveProperty('name'); + expect(results[0]).toHaveProperty('validation'); + expect(results[0].validation).toHaveProperty('compatible'); + expect(results[0].validation).toHaveProperty('errors'); + expect(results[0].validation).toHaveProperty('warnings'); + }); + + it('only returns compatible viewers with their validation details', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + + const { initializeViewerManifests: init, getCompatibleViewersWithDetails: get } = + await import('./index.js'); + + await init(); + + const metadata: OmeZarrMetadata = { + version: '0.4', + axes: [ + { name: 'z', type: 'space' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }; + + const results = get(metadata); + + // All returned results should be compatible + results.forEach((result) => { + expect(result.validation.compatible).toBe(true); + expect(result.validation.errors).toHaveLength(0); + }); + }); + + it('includes warnings in validation details', async () => { + const manifestWithLimitedSupport = ` +viewer: + name: PartialViewer + version: 1.0.0 +capabilities: + ome_zarr_versions: + - 0.4 + axes: false + omero_metadata: false +`; + + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(manifestWithLimitedSupport) + }); + + const { initializeViewerManifests: init, getCompatibleViewersWithDetails: get } = + await import('./index.js'); + + await init(); + + const metadata: OmeZarrMetadata = { + version: '0.4', + axes: [ + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ], + omero: { name: 'test' } + }; + + const results = get(metadata); + + // Should be compatible but with warnings + expect(results.length).toBeGreaterThan(0); + expect(results[0].validation.compatible).toBe(true); + expect(results[0].validation.warnings.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ad16094 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,92 @@ +/** + * Capability Manifest Library + * + * Main entry point for determining OME-Zarr viewer compatibility. + * + * Usage: + * import { initializeViewerManifests, getCompatibleViewers } from '@bioimagetools/capability-manifest'; + * + * // At application startup + * await initializeViewerManifests(); + * + * // For each dataset + * const viewers = getCompatibleViewers(metadata); + */ + +import { loadViewerManifests } from './loader.js'; +import { validateViewer, isCompatible } from './validator.js'; +import type { ViewerManifest, OmeZarrMetadata, ValidationResult } from './types.js'; + +// Cache loaded manifests after initialization +let cachedManifests: ViewerManifest[] | null = null; + +/** + * Initialize the library by loading all viewer manifests. + * Must be called once at application startup before using getCompatibleViewers. + * + * @throws Error if manifest loading fails completely + */ +export async function initializeViewerManifests(): Promise { + cachedManifests = await loadViewerManifests(); + + if (cachedManifests.length === 0) { + throw new Error( + '[capability-manifest] Failed to load any viewer manifests. Check network and manifest URLs.' + ); + } +} + +/** + * Get list of viewer names compatible with the given OME-Zarr metadata. + * + * @param metadata - Pre-parsed OME-Zarr metadata (from ome-zarr.js or similar) + * @returns Array of viewer names (e.g., ['Avivator', 'Neuroglancer']) + * @throws Error if library not initialized + */ +export function getCompatibleViewers(metadata: OmeZarrMetadata): string[] { + if (!cachedManifests) { + throw new Error( + '[capability-manifest] Library not initialized. Call initializeViewerManifests() before using getCompatibleViewers().' + ); + } + + return cachedManifests + .filter(viewer => isCompatible(viewer, metadata)) + .map(viewer => viewer.viewer.name); +} + +/** + * Get detailed compatibility information including validation errors and warnings. + * Useful for debugging or displaying why certain viewers are incompatible. + * + * @param metadata - Pre-parsed OME-Zarr metadata + * @returns Array of objects with viewer name and full validation results + * @throws Error if library not initialized + */ +export function getCompatibleViewersWithDetails( + metadata: OmeZarrMetadata +): Array<{ name: string; validation: ValidationResult }> { + if (!cachedManifests) { + throw new Error( + '[capability-manifest] Library not initialized. Call initializeViewerManifests() before using getCompatibleViewersWithDetails().' + ); + } + + return cachedManifests + .filter(viewer => isCompatible(viewer, metadata)) + .map(viewer => ({ + name: viewer.viewer.name, + validation: validateViewer(viewer, metadata) + })); +} + +// Re-export types for consumers +export type { + ViewerManifest, + OmeZarrMetadata, + ValidationResult, + ValidationError, + ValidationWarning, + AxisMetadata, + MultiscaleMetadata +} from './types.js'; diff --git a/src/loader.test.ts b/src/loader.test.ts new file mode 100644 index 0000000..eca8bed --- /dev/null +++ b/src/loader.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { loadViewerManifests } from './loader.js'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Sample YAML manifests for testing +const sampleManifestYaml = ` +viewer: + name: TestViewer + version: 1.0.0 +capabilities: + ome_zarr_versions: + - 0.4 + - 0.5 + compression_codecs: + - blosc + - gzip + channels: true + timepoints: true +`; + +const sampleManifest2Yaml = ` +viewer: + name: AnotherViewer + version: 2.0.0 +capabilities: + ome_zarr_versions: + - 0.4 + channels: false +`; + +describe('loadViewerManifests', () => { + beforeEach(() => { + mockFetch.mockReset(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads and parses YAML manifests from registry URLs', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + + const manifests = await loadViewerManifests(); + + expect(manifests.length).toBeGreaterThan(0); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('returns parsed ViewerManifest objects with correct structure', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + + const manifests = await loadViewerManifests(); + + expect(manifests[0]).toHaveProperty('viewer'); + expect(manifests[0]).toHaveProperty('capabilities'); + expect(manifests[0].viewer).toHaveProperty('name'); + expect(manifests[0].viewer).toHaveProperty('version'); + }); + + it('returns empty array and logs warning when all fetches fail', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const manifests = await loadViewerManifests(); + + expect(manifests).toEqual([]); + expect(console.warn).toHaveBeenCalled(); + }); + + it('returns only successful manifests when some fetches fail', async () => { + let callCount = 0; + mockFetch.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + } + return Promise.reject(new Error('Network error')); + }); + + const manifests = await loadViewerManifests(); + + expect(manifests.length).toBe(1); + expect(manifests[0].viewer.name).toBe('TestViewer'); + expect(console.warn).toHaveBeenCalled(); + }); + + it('handles HTTP error responses', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found' + }); + + const manifests = await loadViewerManifests(); + + expect(manifests).toEqual([]); + expect(console.warn).toHaveBeenCalled(); + }); + + it('fetches from all registry URLs', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + + await loadViewerManifests(); + + // Registry has 3 viewers: vizarr, neuroglancer, n5-ij + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('handles mixed success and failure responses', async () => { + let callCount = 0; + mockFetch.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + } else if (callCount === 2) { + return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }); + } else { + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(sampleManifest2Yaml) + }); + } + }); + + const manifests = await loadViewerManifests(); + + expect(manifests.length).toBe(2); + expect(console.warn).toHaveBeenCalled(); + }); + + it('parses capability values correctly from YAML', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve(sampleManifestYaml) + }); + + const manifests = await loadViewerManifests(); + + expect(manifests[0].capabilities.ome_zarr_versions).toEqual([0.4, 0.5]); + expect(manifests[0].capabilities.compression_codecs).toEqual(['blosc', 'gzip']); + expect(manifests[0].capabilities.channels).toBe(true); + expect(manifests[0].capabilities.timepoints).toBe(true); + }); +}); diff --git a/src/loader.ts b/src/loader.ts new file mode 100644 index 0000000..7c1cfca --- /dev/null +++ b/src/loader.ts @@ -0,0 +1,41 @@ +import yaml from 'js-yaml'; +import { VIEWER_REGISTRY } from './registry.js'; +import type { ViewerManifest } from './types.js'; + +/** + * Loads all viewer manifests from the registry. + * Uses Promise.allSettled to handle partial failures gracefully. + * + * @returns Array of successfully loaded viewer manifests + */ +export async function loadViewerManifests(): Promise { + const results = await Promise.allSettled( + VIEWER_REGISTRY.map(async (viewer) => { + const response = await fetch(viewer.manifestUrl); + if (!response.ok) { + throw new Error( + `Failed to fetch ${viewer.name} manifest: ${response.status} ${response.statusText}` + ); + } + const text = await response.text(); + const manifest = yaml.load(text) as ViewerManifest; + return manifest; + }) + ); + + // Extract successful results + const manifests = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map(r => r.value); + + // Log warnings for failures but don't crash + const failures = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[]; + if (failures.length > 0) { + console.warn( + `[capability-manifest] Failed to load ${failures.length} viewer manifest(s):` + ); + failures.forEach(f => console.warn(` - ${f.reason}`)); + } + + return manifests; +} diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..ab544db --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,24 @@ +/** + * Registry of supported OME-Zarr viewers and their capability manifest locations. + * + * CURRENT: Points to local manifests in this repo for proof-of-concept. + * TODO: In production, point to GitHub repos and assume manifest is at: + * {repo_url}/capability-manifest.yaml + * + * Example future entry: + * { + * name: 'vizarr', + * manifestUrl: 'https://raw.githubusercontent.com/hms-dbmi/viv/main/capability-manifest.yaml' + * } + */ + +const MANIFEST_BASE_URL = + "https://raw.githubusercontent.com/BioImageTools/capability-manifest/main/public/viewers/"; + +export const VIEWER_REGISTRY = [ + { name: 'vizarr', manifestUrl: `${MANIFEST_BASE_URL}vizarr.yaml` }, + { name: 'neuroglancer', manifestUrl: `${MANIFEST_BASE_URL}neuroglancer.yaml` }, + { name: 'n5-ij', manifestUrl: `${MANIFEST_BASE_URL}n5-ij.yaml` } +] as const; + +export type ViewerRegistryEntry = typeof VIEWER_REGISTRY[number]; diff --git a/src/validator.test.ts b/src/validator.test.ts new file mode 100644 index 0000000..d2c1164 --- /dev/null +++ b/src/validator.test.ts @@ -0,0 +1,480 @@ +import { describe, it, expect } from 'vitest'; +import { validateViewer, isCompatible } from './validator.js'; +import type { ViewerManifest, OmeZarrMetadata } from './types.js'; + +// Helper to create a minimal viewer manifest +function createViewer(capabilities: Partial = {}): ViewerManifest { + return { + viewer: { + name: 'TestViewer', + version: '1.0.0' + }, + capabilities: { + ome_zarr_versions: [0.4], + compression_codecs: ['blosc', 'gzip', 'zlib'], + axes: true, + scale: true, + translation: true, + channels: true, + timepoints: true, + labels: true, + hcs_plates: true, + omero_metadata: true, + ...capabilities + } + }; +} + +// Helper to create minimal OME-Zarr metadata +function createMetadata(overrides: Partial = {}): OmeZarrMetadata { + return { + version: '0.4', + axes: [ + { name: 'z', type: 'space' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ], + ...overrides + }; +} + +describe('validateViewer', () => { + describe('version compatibility', () => { + it('returns compatible when viewer supports the data version', () => { + const viewer = createViewer({ ome_zarr_versions: [0.4, 0.5] }); + const metadata = createMetadata({ version: '0.4' }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('returns error when viewer does not support the data version', () => { + const viewer = createViewer({ ome_zarr_versions: [0.4] }); + const metadata = createMetadata({ version: '0.5' }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('ome_zarr_versions'); + expect(result.errors[0].required).toBe(0.5); + expect(result.errors[0].found).toEqual([0.4]); + }); + + it('extracts version from multiscales when not at root level', () => { + const viewer = createViewer({ ome_zarr_versions: [0.4] }); + const metadata = createMetadata({ + version: undefined, + multiscales: [{ version: '0.4', datasets: [] }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('returns error when viewer has empty ome_zarr_versions array', () => { + const viewer = createViewer({ ome_zarr_versions: [] }); + const metadata = createMetadata({ version: '0.4' }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('does not specify OME-Zarr version support'); + }); + + it('returns error when viewer has undefined ome_zarr_versions', () => { + const viewer = createViewer({ ome_zarr_versions: undefined }); + const metadata = createMetadata({ version: '0.4' }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('does not specify OME-Zarr version support'); + }); + + it('returns error when metadata has no version', () => { + const viewer = createViewer({ ome_zarr_versions: [0.4] }); + const metadata = createMetadata({ version: undefined, multiscales: undefined }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('ome_zarr_versions'); + expect(result.errors[0].message).toContain('Metadata does not specify an OME-Zarr version'); + }); + }); + + describe('compression codecs', () => { + it('returns compatible when viewer supports the codec', () => { + const viewer = createViewer({ compression_codecs: ['blosc', 'gzip'] }); + const metadata = createMetadata({ compressor: { id: 'blosc' } }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + + it('returns error when viewer does not support the codec', () => { + const viewer = createViewer({ compression_codecs: ['blosc', 'gzip'] }); + const metadata = createMetadata({ compressor: { id: 'zstd' } }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('compression_codecs'); + expect(result.errors[0].required).toBe('zstd'); + }); + + it('handles compressor as plain string', () => { + const viewer = createViewer({ compression_codecs: ['blosc'] }); + const metadata = createMetadata({ compressor: 'zstd' }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors[0].required).toBe('zstd'); + }); + + it('skips codec check when metadata has no compressor', () => { + const viewer = createViewer({ compression_codecs: ['blosc'] }); + const metadata = createMetadata({ compressor: undefined }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + + it('returns warning when viewer has no codec list but data uses compression', () => { + const viewer = createViewer({ compression_codecs: undefined }); + const metadata = createMetadata({ compressor: { id: 'zstd' } }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].capability).toBe('compression_codecs'); + expect(result.warnings[0].message).toContain('zstd'); + expect(result.warnings[0].message).toContain('compatibility unknown'); + }); + }); + + describe('axes support', () => { + it('returns warning when data has axes but viewer does not support them', () => { + const viewer = createViewer({ axes: false }); + const metadata = createMetadata({ + axes: [{ name: 'x', type: 'space' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].capability).toBe('axes'); + }); + + it('no warning when viewer supports axes', () => { + const viewer = createViewer({ axes: true }); + const metadata = createMetadata({ + axes: [{ name: 'x', type: 'space' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.warnings.filter(w => w.capability === 'axes')).toHaveLength(0); + }); + }); + + describe('channels support', () => { + it('returns error when data has channels but viewer does not support them', () => { + const viewer = createViewer({ channels: false }); + const metadata = createMetadata({ + axes: [ + { name: 'c', type: 'channel' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('channels'); + }); + + it('detects channels by axis name "c"', () => { + const viewer = createViewer({ channels: false }); + const metadata = createMetadata({ + axes: [{ name: 'c' }, { name: 'y' }, { name: 'x' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.errors.some(e => e.capability === 'channels')).toBe(true); + }); + + it('detects channels by axis type "channel"', () => { + const viewer = createViewer({ channels: false }); + const metadata = createMetadata({ + axes: [{ name: 'ch', type: 'channel' }, { name: 'y' }, { name: 'x' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.errors.some(e => e.capability === 'channels')).toBe(true); + }); + + it('returns compatible when viewer supports channels', () => { + const viewer = createViewer({ channels: true }); + const metadata = createMetadata({ + axes: [{ name: 'c', type: 'channel' }, { name: 'y' }, { name: 'x' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + }); + + describe('timepoints support', () => { + it('returns error when data has timepoints but viewer does not support them', () => { + const viewer = createViewer({ timepoints: false }); + const metadata = createMetadata({ + axes: [ + { name: 't', type: 'time' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('timepoints'); + }); + + it('detects timepoints by axis name "t"', () => { + const viewer = createViewer({ timepoints: false }); + const metadata = createMetadata({ + axes: [{ name: 't' }, { name: 'y' }, { name: 'x' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.errors.some(e => e.capability === 'timepoints')).toBe(true); + }); + + it('detects timepoints by axis type "time"', () => { + const viewer = createViewer({ timepoints: false }); + const metadata = createMetadata({ + axes: [{ name: 'time', type: 'time' }, { name: 'y' }, { name: 'x' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.errors.some(e => e.capability === 'timepoints')).toBe(true); + }); + + it('returns compatible when viewer supports timepoints', () => { + const viewer = createViewer({ timepoints: true }); + const metadata = createMetadata({ + axes: [{ name: 't', type: 'time' }, { name: 'y' }, { name: 'x' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + }); + + describe('labels support', () => { + it('returns error when data has labels but viewer does not support them', () => { + const viewer = createViewer({ labels: false }); + const metadata = createMetadata({ + labels: ['nuclei', 'cells'] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('labels'); + }); + + it('returns compatible when viewer supports labels', () => { + const viewer = createViewer({ labels: true }); + const metadata = createMetadata({ + labels: ['nuclei', 'cells'] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + + it('skips label check when metadata has empty labels array', () => { + const viewer = createViewer({ labels: false }); + const metadata = createMetadata({ + labels: [] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + + it('skips label check when metadata has no labels', () => { + const viewer = createViewer({ labels: false }); + const metadata = createMetadata({ + labels: undefined + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + }); + + describe('HCS plates support', () => { + it('returns error when data is HCS plate but viewer does not support them', () => { + const viewer = createViewer({ hcs_plates: false }); + const metadata = createMetadata({ + plate: { wells: [], columns: [] } + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('hcs_plates'); + }); + + it('returns compatible when viewer supports HCS plates', () => { + const viewer = createViewer({ hcs_plates: true }); + const metadata = createMetadata({ + plate: { wells: [], columns: [] } + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + + it('skips HCS check when metadata has no plate', () => { + const viewer = createViewer({ hcs_plates: false }); + const metadata = createMetadata({ + plate: undefined + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + }); + }); + + describe('OMERO metadata support', () => { + it('returns warning when data has OMERO metadata but viewer does not support it', () => { + const viewer = createViewer({ omero_metadata: false }); + const metadata = createMetadata({ + omero: { name: 'test-image' } + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(true); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].capability).toBe('omero_metadata'); + }); + + it('no warning when viewer supports OMERO metadata', () => { + const viewer = createViewer({ omero_metadata: true }); + const metadata = createMetadata({ + omero: { name: 'test-image' } + }); + + const result = validateViewer(viewer, metadata); + + expect(result.warnings.filter(w => w.capability === 'omero_metadata')).toHaveLength(0); + }); + }); + + describe('multiple validation issues', () => { + it('collects multiple errors', () => { + const viewer = createViewer({ + ome_zarr_versions: [0.4], + channels: false, + timepoints: false + }); + const metadata = createMetadata({ + version: '0.5', + axes: [ + { name: 'c', type: 'channel' }, + { name: 't', type: 'time' }, + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors).toHaveLength(3); + }); + + it('collects both errors and warnings', () => { + const viewer = createViewer({ + channels: false, + axes: false, + omero_metadata: false + }); + const metadata = createMetadata({ + axes: [{ name: 'c', type: 'channel' }, { name: 'y' }, { name: 'x' }], + omero: { name: 'test' } + }); + + const result = validateViewer(viewer, metadata); + + expect(result.compatible).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.warnings.length).toBeGreaterThan(0); + }); + }); +}); + +describe('isCompatible', () => { + it('returns true when validation has no errors', () => { + const viewer = createViewer(); + const metadata = createMetadata(); + + const result = isCompatible(viewer, metadata); + + expect(result).toBe(true); + }); + + it('returns false when validation has errors', () => { + const viewer = createViewer({ ome_zarr_versions: [0.4] }); + const metadata = createMetadata({ version: '0.5' }); + + const result = isCompatible(viewer, metadata); + + expect(result).toBe(false); + }); + + it('returns true even when there are warnings', () => { + const viewer = createViewer({ omero_metadata: false }); + const metadata = createMetadata({ omero: { name: 'test' } }); + + const result = isCompatible(viewer, metadata); + + expect(result).toBe(true); + }); +}); diff --git a/src/validator.ts b/src/validator.ts new file mode 100644 index 0000000..b6ed143 --- /dev/null +++ b/src/validator.ts @@ -0,0 +1,162 @@ +import type { + ViewerManifest, + OmeZarrMetadata, + ValidationResult, + ValidationError, + ValidationWarning +} from './types.js'; + +/** + * Validates whether a viewer is compatible with a given OME-Zarr dataset. + * + * @param viewer - The viewer manifest to validate + * @param metadata - The OME-Zarr metadata from the dataset + * @returns Validation result with compatibility status, errors, and warnings + */ +export function validateViewer( + viewer: ViewerManifest, + metadata: OmeZarrMetadata +): ValidationResult { + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + + // Check version compatibility - this is critical + let dataVersion: number | null = null; + + // Try to extract version from metadata (could be in root or in multiscales) + if (metadata.version) { + dataVersion = parseFloat(metadata.version); + } else if ( + metadata.multiscales && + metadata.multiscales.length > 0 && + metadata.multiscales[0].version + ) { + dataVersion = parseFloat(metadata.multiscales[0].version); + } + + if (dataVersion === null) { + errors.push({ + capability: 'ome_zarr_versions', + message: 'Metadata does not specify an OME-Zarr version', + required: 'version', + found: null + }); + } else if ( + !viewer.capabilities.ome_zarr_versions || + viewer.capabilities.ome_zarr_versions.length === 0 + ) { + errors.push({ + capability: 'ome_zarr_versions', + message: `Viewer does not specify OME-Zarr version support (data is v${dataVersion})`, + required: dataVersion, + found: [] + }); + } else if (!viewer.capabilities.ome_zarr_versions.includes(dataVersion)) { + errors.push({ + capability: 'ome_zarr_versions', + message: `Viewer does not support OME-Zarr v${dataVersion} (supports: ${viewer.capabilities.ome_zarr_versions.join(', ')})`, + required: dataVersion, + found: viewer.capabilities.ome_zarr_versions + }); + } + + // Check compression codecs + if (metadata.compressor) { + const codec = metadata.compressor.id || metadata.compressor; + if (!viewer.capabilities.compression_codecs) { + // Viewer doesn't declare codec support - can't guarantee compatibility + warnings.push({ + capability: 'compression_codecs', + message: `Data uses codec '${codec}' but viewer doesn't declare codec support - compatibility unknown` + }); + } else if (!viewer.capabilities.compression_codecs.includes(codec)) { + errors.push({ + capability: 'compression_codecs', + message: `Viewer does not support compression codec: ${codec}`, + required: codec, + found: viewer.capabilities.compression_codecs + }); + } + } + + // Check axes support + if (metadata.axes && !viewer.capabilities.axes) { + warnings.push({ + capability: 'axes', + message: 'Dataset has axis metadata but viewer may not respect it' + }); + } + + // Check for multiple channels + const hasChannels = metadata.axes?.some( + ax => ax.name === 'c' || ax.type === 'channel' + ); + if (hasChannels && !viewer.capabilities.channels) { + errors.push({ + capability: 'channels', + message: 'Dataset has multiple channels but viewer does not support them', + required: true, + found: false + }); + } + + // Check for timepoints + const hasTime = metadata.axes?.some(ax => ax.name === 't' || ax.type === 'time'); + if (hasTime && !viewer.capabilities.timepoints) { + errors.push({ + capability: 'timepoints', + message: 'Dataset has multiple timepoints but viewer does not support them', + required: true, + found: false + }); + } + + // Check for labels + if (metadata.labels && metadata.labels.length > 0 && !viewer.capabilities.labels) { + errors.push({ + capability: 'labels', + message: 'Dataset has labels but viewer does not support them', + required: true, + found: false + }); + } + + // Check for HCS plates + if (metadata.plate && !viewer.capabilities.hcs_plates) { + errors.push({ + capability: 'hcs_plates', + message: 'Dataset is an HCS plate but viewer does not support plates', + required: true, + found: false + }); + } + + // Check OMERO metadata + if (metadata.omero && !viewer.capabilities.omero_metadata) { + warnings.push({ + capability: 'omero_metadata', + message: 'Dataset has OMERO metadata but viewer may not use it' + }); + } + + return { + compatible: errors.length === 0, + errors, + warnings + }; +} + +/** + * Helper function to check if a viewer is compatible with metadata. + * Convenience wrapper around validateViewer that returns only the boolean result. + * + * @param viewer - The viewer manifest to check + * @param metadata - The OME-Zarr metadata from the dataset + * @returns True if compatible (no errors), false otherwise + */ +export function isCompatible( + viewer: ViewerManifest, + metadata: OmeZarrMetadata +): boolean { + return validateViewer(viewer, metadata).compatible; +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..f0a311f --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "noEmit": false, + "allowImportingTsExtensions": false + }, + "include": [ + "src/index.ts", + "src/loader.ts", + "src/validator.ts", + "src/registry.ts", + "src/types.ts" + ], + "exclude": ["src/app.ts"] +}