diff --git a/__tests__/mimeCoverage.spec.ts b/__tests__/mimeCoverage.spec.ts new file mode 100644 index 0000000..d6e7e2c --- /dev/null +++ b/__tests__/mimeCoverage.spec.ts @@ -0,0 +1,126 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { browserSupportedMimes as audioMimes } from '../lib/models/audios.ts' +import { browserSupportedMimes as imageMimes, previewSupportedMimes } from '../lib/models/images.ts' +import { supportedMimes as scoreMimes } from '../lib/models/sheetmusic.ts' +import { aliasedMimes, browserSupportedMimes as videoMimes } from '../lib/models/videos.ts' + +/** + * Every mime the handlers say they can open. + * + * A claim with nothing behind it is the failure this guards. The viewer + * offered `image/avif` for a while on servers that never produced one, and + * the audio handler claimed eleven types while a single fixture sat in the + * playground that no test ever opened. + */ +const CLAIMED = [ + ...imageMimes, + ...previewSupportedMimes, + ...videoMimes, + ...Object.keys(aliasedMimes), + ...audioMimes, + ...scoreMimes, +] + +/** + * What no playground test can cover, and why. + * + * Every entry here is a deliberate hole rather than a forgotten one. Take + * one out and the test below says so, which is the point: the list has to + * be argued for, not inherited. + */ +const NOT_COVERED_HERE: Record = { + // The playground has no previews endpoint, so these can only be opened + // against a real server. They are covered by the suite in e2e-server, + // where they currently skip for want of a capability that arrives with + // nextcloud/server#63954. + 'image/heic': 'needs a server-rendered preview', + 'image/heif': 'needs a server-rendered preview', + 'image/tiff': 'needs a server-rendered preview', + 'image/emf': 'needs a server-rendered preview', + 'image/x-xbitmap': 'needs a server-rendered preview', + 'image/jp2': 'needs a server-rendered preview', + + // Claimed so the handler takes the file, but no engine here decodes it, + // so a fixture would only record which browser ran the suite + 'audio/aacp': 'Chromium reports it cannot play this', + 'audio/vorbis': 'not a type any encoder writes; ogg carries vorbis', + 'video/mpeg': 'no engine decodes MPEG-1/2 video', + 'video/x-flv': 'no engine decodes Flash video', + 'video/quicktime': 'decoding depends on the codecs inside the container', + 'video/x-m4v': 'decoding depends on the codecs inside the container', + 'video/x-matroska': 'aliased to webm; decoding depends on the codecs inside', + 'video/ogg': 'Theora is not built into the engines the suite runs', +} + +/** The repository root, which is where vitest runs from */ +const root = process.cwd() + +/** The fixtures the playground serves, as name to mime */ +function playgroundFixtures(): Map { + const source = readFileSync(resolve(root, 'playground/App.vue'), 'utf8') + const fixtures = new Map() + for (const line of source.split('\n')) { + const match = /name: '([^']+)'.*mime: '([^']+)'/.exec(line) + if (match) { + fixtures.set(match[1]!, match[2]!) + } + } + return fixtures +} + +/** The fixture names the end-to-end specs actually open */ +function openedByTests(): Set { + const dir = resolve(root, 'e2e') + const opened = new Set() + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.spec.ts')) { + continue + } + const source = readFileSync(join(dir, entry), 'utf8') + for (const match of source.matchAll(/open\(\s*'([^']+)'/g)) { + opened.add(match[1]!) + } + // Table-driven specs list their fixtures rather than calling open + // with a literal, so take the file names they name as well + for (const match of source.matchAll(/'([\w.-]+\.(?:jpg|jpeg|png|gif|bmp|webp|ico|apng|avif|svg|tiff|heic|jp2|musicxml|mxl|mp3|mp4|wav|flac|ogg|webm|m4a|aac))'/g)) { + opened.add(match[1]!) + } + } + return opened +} + +describe('what the handlers claim', () => { + const fixtures = playgroundFixtures() + const opened = openedByTests() + + /** The mimes some fixture carries and some spec opens */ + const covered = new Set([...fixtures.entries()] + .filter(([name]) => opened.has(name)) + .map(([, mime]) => mime)) + + it.each(CLAIMED.filter((mime) => !(mime in NOT_COVERED_HERE)))( + '%s is opened by a test', + (mime) => { + expect(covered.has(mime), `no playground fixture with mime ${mime} is opened by any spec`).toBe(true) + }, + ) + + it('has a reason for each type it does not cover', () => { + // Guards the excuse list rather than the code: a mime that stopped + // being claimed should stop being excused, or the next person + // inherits a reason for something that no longer exists + const stale = Object.keys(NOT_COVERED_HERE).filter((mime) => !CLAIMED.includes(mime)) + expect(stale, 'excused but no longer claimed by any handler').toEqual([]) + }) + + it('does not excuse a type that is covered anyway', () => { + const excused = Object.keys(NOT_COVERED_HERE).filter((mime) => covered.has(mime)) + expect(excused, 'excused, but a test does open one').toEqual([]) + }) +}) diff --git a/build/check-bundle-size.mjs b/build/check-bundle-size.mjs index 9106d06..961f47f 100644 --- a/build/check-bundle-size.mjs +++ b/build/check-bundle-size.mjs @@ -27,6 +27,16 @@ const BUDGET_GZIP = 10 * 1024 /** Chunks that must only ever be reached through a dynamic import */ const MUST_BE_LAZY = ['mount', 'Images', 'Videos', 'Audios', 'ImageEditor', 'usePlyrPlayer', 'translations'] +/** + * Packages a page must not pay for unless it opens something needing them. + * + * The chunk names above only catch this library's own code. These are + * dependencies, which leave the bundle as bare specifiers and so never + * appear there: the canvas library and the editor built on it, and the + * score renderer, which is two megabytes by itself. + */ +const MUST_BE_LAZY_PACKAGES = ['konva', 'opensheetmusicdisplay', '@nextcloud/image-editor'] + const ENTRY = 'dist/index.mjs' /** @@ -39,6 +49,8 @@ const ENTRY = 'dist/index.mjs' */ function staticGraph(entry) { const seen = new Set() + /** Dependencies reached without a dynamic import */ + const packages = new Set() const queue = [entry] while (queue.length > 0) { @@ -49,6 +61,14 @@ function staticGraph(entry) { seen.add(file) const source = readFileSync(file, 'utf8') + + // A dependency leaves the bundle as a bare specifier, so it is never + // one of the chunks walked below. Matched on the whole statement + // rather than on the quotes, because every string has quotes. + for (const statement of source.matchAll(/(?:^|[\s;}])(?:import|export)\s+(?:[^'";]*?\sfrom\s+)?["']([^."'][^"']*)["']/gm)) { + packages.add(statement[1]) + } + // `import x from "./y"` and `export … from "./y"`, but never `import("./y")` for (const match of source.matchAll(/(? file.replace(/.*\/([^/]+)\.mjs$/, '$1')) const leaked = MUST_BE_LAZY.filter((chunk) => eager.includes(chunk)) +const leakedPackages = MUST_BE_LAZY_PACKAGES.filter( + (name) => packages.some((used) => used === name || used.startsWith(`${name}/`)), +) + +if (leakedPackages.length > 0) { + console.error(`These are only needed once a file is open, and something imports them at the top level: ${leakedPackages.join(', ')}`) + process.exit(1) +} if (leaked.length > 0) { console.error(`These are meant to load only when a file is opened, and something imports them at the top level: ${leaked.join(', ')}`) diff --git a/e2e/audio.spec.ts b/e2e/audio.spec.ts index 70a4350..1267bf4 100644 --- a/e2e/audio.spec.ts +++ b/e2e/audio.spec.ts @@ -22,6 +22,7 @@ const AUDIO = [ 'sound.ogg', 'sound.webm', 'sound.m4a', + 'sound.aac', ] test.describe('Audio', () => { diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index f66b29d..6f2d4cb 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -34,6 +34,8 @@ const MEDIA = [ 'sound.ogg', 'sound.webm', 'sound.m4a', + 'sound.aac', + 'clip.webm', ] test.describe('Viewer navigation', () => { diff --git a/e2e/video.spec.ts b/e2e/video.spec.ts new file mode 100644 index 0000000..0f39d15 --- /dev/null +++ b/e2e/video.spec.ts @@ -0,0 +1,37 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { expect, test } from '@playwright/test' +import { ViewerPage } from './support/viewer.ts' + +/** + * The video the handler claims and both engines can decode. + * + * The rest of what it claims is listed as uncovered, with reasons, in + * `__tests__/mimeCoverage.spec.ts`: containers whose contents decide + * whether anything can play them, and codecs no engine here ships. + */ +const VIDEO = ['video.mp4', 'clip.webm'] + +test.describe('Video', () => { + for (const file of VIDEO) { + test(`plays ${file}`, async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open(file) + await viewer.waitForOpen() + + const video = viewer.container.locator('video').first() + await expect(async () => { + const state = await video.evaluate((element: HTMLVideoElement) => ({ + readyState: element.readyState, + width: element.videoWidth, + error: element.error?.code ?? null, + })) + expect(state.error).toBeNull() + expect(state.readyState).toBeGreaterThan(0) + expect(state.width).toBeGreaterThan(0) + }).toPass({ timeout: 15_000 }) + }) + } +}) diff --git a/lib/models/audios.ts b/lib/models/audios.ts index 2056099..a1bf9ea 100644 --- a/lib/models/audios.ts +++ b/lib/models/audios.ts @@ -10,7 +10,7 @@ import { logger } from '../services/logger.ts' import { defineCustomElementOnce } from '../utils/customElements.ts' import { t } from '../utils/l10n.ts' -const browserSupportedMimes = [ +export const browserSupportedMimes = [ 'audio/aac', 'audio/aacp', 'audio/flac', diff --git a/lib/models/images.ts b/lib/models/images.ts index 716dcae..5682d0d 100644 --- a/lib/models/images.ts +++ b/lib/models/images.ts @@ -24,7 +24,7 @@ const enabledPreviewProviders = (getCapabilities() as PreviewCapabilities).core? * Those mimes needs a proper preview to be displayed * if they are not enabled on the server, let's not activate them. */ -const previewSupportedMimes = [ +export const previewSupportedMimes = [ 'image/heic', 'image/heif', // No browser decodes JPEG 2000, and libgd cannot either, so this one @@ -41,7 +41,7 @@ const previewSupportedMimes = [ * Since we fallback to the source image if there is no * preview, we can always include them. */ -const browserSupportedMimes = [ +export const browserSupportedMimes = [ 'image/apng', // Decoded natively by every engine the viewer runs in, so it needs no // preview: there is no provider for it either, and waiting for one diff --git a/lib/models/sheetmusic.ts b/lib/models/sheetmusic.ts index daebae2..9c89212 100644 --- a/lib/models/sheetmusic.ts +++ b/lib/models/sheetmusic.ts @@ -21,7 +21,7 @@ import { t } from '../utils/l10n.ts' * unrecognised file falls back to would have handed this handler far more * than sheet music. */ -const supportedMimes = [ +export const supportedMimes = [ 'application/vnd.recordare.musicxml', 'application/vnd.recordare.musicxml+xml', ] diff --git a/lib/models/videos.ts b/lib/models/videos.ts index 9e9180f..7f4cbd2 100644 --- a/lib/models/videos.ts +++ b/lib/models/videos.ts @@ -10,7 +10,7 @@ import { logger } from '../services/logger.ts' import { defineCustomElementOnce } from '../utils/customElements.ts' import { t } from '../utils/l10n.ts' -const browserSupportedMimes = [ +export const browserSupportedMimes = [ 'video/mpeg', 'video/ogg', 'video/webm', diff --git a/playground/App.vue b/playground/App.vue index da8a0e2..059edcf 100644 --- a/playground/App.vue +++ b/playground/App.vue @@ -62,6 +62,8 @@ const fixtures: Fixture[] = [ { name: 'sound.ogg', mime: 'audio/ogg' }, { name: 'sound.webm', mime: 'audio/webm' }, { name: 'sound.m4a', mime: 'audio/mp4' }, + { name: 'sound.aac', mime: 'audio/aac' }, + { name: 'clip.webm', mime: 'video/webm' }, ] /** Where the fixtures are served from, shaped like a WebDAV path */ diff --git a/playground/public/remote.php/dav/files/playground/clip.webm b/playground/public/remote.php/dav/files/playground/clip.webm new file mode 100644 index 0000000..4cafdaf Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/clip.webm differ diff --git a/playground/public/remote.php/dav/files/playground/sound.aac b/playground/public/remote.php/dav/files/playground/sound.aac new file mode 100644 index 0000000..6b0c410 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/sound.aac differ