Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions __tests__/mimeCoverage.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
// 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<string, string> {
const source = readFileSync(resolve(root, 'playground/App.vue'), 'utf8')
const fixtures = new Map<string, string>()
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<string> {
const dir = resolve(root, 'e2e')
const opened = new Set<string>()
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([])
})
})
34 changes: 32 additions & 2 deletions build/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -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) {
Expand All @@ -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(/(?<!\bimport\s*\(\s*)["']([^"']+)["']/g)) {
const specifier = match[1]
Expand All @@ -66,10 +86,10 @@ function staticGraph(entry) {
}
}

return [...seen]
return { files: [...seen], packages: [...packages] }
}

const files = staticGraph(resolve(ENTRY))
const { files, packages } = staticGraph(resolve(ENTRY))
let raw = 0
let gzip = 0

Expand All @@ -82,8 +102,18 @@ for (const file of files.sort()) {
}
console.info(`\n ${String(raw).padStart(8)} total, ${(gzip / 1024).toFixed(1)} kB gzipped (budget ${(BUDGET_GZIP / 1024).toFixed(0)} kB)\n`)

console.info(` dependencies: ${packages.sort().join(', ') || 'none'}\n`)

const eager = files.map((file) => 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(', ')}`)
Expand Down
1 change: 1 addition & 0 deletions e2e/audio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const AUDIO = [
'sound.ogg',
'sound.webm',
'sound.m4a',
'sound.aac',
]

test.describe('Audio', () => {
Expand Down
2 changes: 2 additions & 0 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const MEDIA = [
'sound.ogg',
'sound.webm',
'sound.m4a',
'sound.aac',
'clip.webm',
]

test.describe('Viewer navigation', () => {
Expand Down
37 changes: 37 additions & 0 deletions e2e/video.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
}
})
2 changes: 1 addition & 1 deletion lib/models/audios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions lib/models/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/models/sheetmusic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]
Expand Down
2 changes: 1 addition & 1 deletion lib/models/videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions playground/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Binary file not shown.
Binary file not shown.
Loading