From a078daf5e73ec96a0ea9f50f847baba1cc0df99f Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Mon, 21 Sep 2026 22:07:15 +0200 Subject: [PATCH] test: guard what the handlers claim and what the entry costs Two checks that keep a gap from reopening rather than testing anything new. The first reads the mime lists out of the handlers and asserts each one is opened by a test. A claim nobody exercises is how the audio handler came to offer eleven types with a fixture that no spec touched. Types that cannot be covered from the playground are listed with a reason, and the list is guarded both ways: an excuse for a type no longer claimed fails, and so does an excuse for a type a test does open. It found three things on its first run. video/webm and audio/aac were claimed and untested, so both have fixtures now. And image/apng was excused on the grounds that the server has no mapping for the extension, which is true and is a different concern: the playground proves the handler renders one, and whether an upload ever arrives as that mime belongs to the server suite. The second extends the size check to dependencies. It walked this library's own chunks and stopped at the package boundary, so konva or the score renderer imported at the top level would have passed it. Both have come close: the editor is behind an async component and the renderer behind an import inside its component, and neither arrangement was checked by anything. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- __tests__/mimeCoverage.spec.ts | 126 ++++++++++++++++++ build/check-bundle-size.mjs | 34 ++++- e2e/audio.spec.ts | 1 + e2e/navigation.spec.ts | 2 + e2e/video.spec.ts | 37 +++++ lib/models/audios.ts | 2 +- lib/models/images.ts | 4 +- lib/models/sheetmusic.ts | 2 +- lib/models/videos.ts | 2 +- playground/App.vue | 2 + .../remote.php/dav/files/playground/clip.webm | Bin 0 -> 6603 bytes .../remote.php/dav/files/playground/sound.aac | Bin 0 -> 4893 bytes 12 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 __tests__/mimeCoverage.spec.ts create mode 100644 e2e/video.spec.ts create mode 100644 playground/public/remote.php/dav/files/playground/clip.webm create mode 100644 playground/public/remote.php/dav/files/playground/sound.aac 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 0000000000000000000000000000000000000000..4cafdafc25dd63ade99cd620b07cc15726671a08 GIT binary patch literal 6603 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 Pu|WHKuW)>C78m{p>1<+5 literal 0 HcmV?d00001