diff --git a/e2e/audio.spec.ts b/e2e/audio.spec.ts new file mode 100644 index 0000000..70a4350 --- /dev/null +++ b/e2e/audio.spec.ts @@ -0,0 +1,50 @@ +/*! + * 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 audio the handler claims and both engines can decode. + * + * `audio/aacp` is claimed and left out: Chromium answers `no` to it, so a + * fixture would only record which engine is running. The three WAV names + * are all here because a server may send any of them for the same file, + * which is the whole of nextcloud-libraries/nextcloud-viewer#45. + */ +const AUDIO = [ + 'audio.mp3', + 'sound.wav', + 'sound-xwav.wav', + 'sound-vnd.wav', + 'sound.flac', + 'sound.ogg', + 'sound.webm', + 'sound.m4a', +] + +test.describe('Audio', () => { + for (const file of AUDIO) { + test(`plays ${file}`, async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open(file) + await viewer.waitForOpen() + + // Reaching metadata is the engine saying it understood the file. + // An element that merely exists proves only that the handler + // took the mime, which is the easy half. + const audio = viewer.container.locator('audio').first() + await expect(async () => { + const state = await audio.evaluate((element: HTMLAudioElement) => ({ + readyState: element.readyState, + duration: element.duration, + error: element.error?.code ?? null, + })) + expect(state.error).toBeNull() + expect(state.readyState).toBeGreaterThan(0) + expect(state.duration).toBeGreaterThan(0) + }).toPass({ timeout: 15_000 }) + }) + } +}) diff --git a/e2e/formats.spec.ts b/e2e/formats.spec.ts index fb0a0ca..0e997a8 100644 --- a/e2e/formats.spec.ts +++ b/e2e/formats.spec.ts @@ -5,24 +5,42 @@ import { expect, test } from '@playwright/test' import { ViewerPage } from './support/viewer.ts' +/** + * The formats the image handler says every browser can decode, and the + * size each fixture really is. + * + * Listing them is the point: the handler claims them, so something has to + * open one of each and find a decoded picture rather than an empty frame. + */ +const BROWSER_FORMATS = [ + { file: 'photo.avif', width: 320, height: 240 }, + { file: 'picture.png', width: 120, height: 90 }, + { file: 'picture.bmp', width: 120, height: 90 }, + { file: 'picture.webp', width: 120, height: 90 }, + { file: 'picture.ico', width: 32, height: 32 }, + { file: 'picture.apng', width: 120, height: 90 }, +] + test.describe('Formats the browser decodes itself', () => { - test('opens an AVIF and paints it', async ({ page }) => { - const viewer = new ViewerPage(page) - await viewer.open('photo.avif') - await viewer.waitForOpen() + for (const { file, width, height } of BROWSER_FORMATS) { + test(`opens ${file} and paints it`, async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open(file) + await viewer.waitForOpen() - // Listed as browser-supported, so no preview stands behind it: the - // engine either decodes the file or the viewer shows nothing. Asking - // the element for its intrinsic size is asking whether it decoded. - const image = viewer.container.locator('img').first() - await expect(image).toBeVisible() - await expect(async () => { - const decoded = await image.evaluate((element: HTMLImageElement) => ({ - complete: element.complete, - width: element.naturalWidth, - height: element.naturalHeight, - })) - expect(decoded).toEqual({ complete: true, width: 320, height: 240 }) - }).toPass({ timeout: 5000 }) - }) + // No preview stands behind these, so the engine either decoded + // the file or there is nothing on screen. Its intrinsic size is + // the answer to which. + const image = viewer.container.locator('img').first() + await expect(image).toBeVisible() + await expect(async () => { + const decoded = await image.evaluate((element: HTMLImageElement) => ({ + complete: element.complete, + width: element.naturalWidth, + height: element.naturalHeight, + })) + expect(decoded).toEqual({ complete: true, width, height }) + }).toPass({ timeout: 10_000 }) + }) + } }) diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index 928d728..f66b29d 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -7,7 +7,34 @@ import { ViewerPage } from './support/viewer.ts' // The order the playground lists them in, which is the order the viewer is // handed and the order it has to step through -const IMAGES = ['photo.jpg', 'gradient.jpg', 'portrait.jpg', 'photo.avif', 'animation.gif', 'protected.jpg'] +const IMAGES = [ + 'photo.jpg', + 'gradient.jpg', + 'portrait.jpg', + 'photo.avif', + 'picture.png', + 'picture.bmp', + 'picture.webp', + 'picture.ico', + 'picture.apng', + 'drawing.svg', + 'animation.gif', + 'protected.jpg', +] + +// The video and audio handlers share the 'media' group, so these page +// among themselves and never into the images +const MEDIA = [ + 'video.mp4', + 'audio.mp3', + 'sound.wav', + 'sound-xwav.wav', + 'sound-vnd.wav', + 'sound.flac', + 'sound.ogg', + 'sound.webm', + 'sound.m4a', +] test.describe('Viewer navigation', () => { test('steps through the list and loops around at both ends', async ({ page }) => { @@ -38,19 +65,21 @@ test.describe('Viewer navigation', () => { test('pages within the handler group and not across it', async ({ page }) => { const viewer = new ViewerPage(page) - // The video and audio handlers share the 'media' group, images are on - // their own, so opening a video pages through the media and stops there - await viewer.open('video.mp4') + // Images are on their own, so opening a video pages through the + // media and stops there + await viewer.open(MEDIA[0]!) await viewer.waitForOpen() - expect(await viewer.currentName()).toBe('video.mp4') + expect(await viewer.currentName()).toBe(MEDIA[0]) - await viewer.next() - await viewer.waitForOpen() - expect(await viewer.currentName()).toBe('audio.mp3') + for (const file of MEDIA.slice(1)) { + await viewer.next() + await viewer.waitForOpen() + expect(await viewer.currentName()).toBe(file) + } // Round the end of the media, rather than on into the images await viewer.next() await viewer.waitForOpen() - expect(await viewer.currentName()).toBe('video.mp4') + expect(await viewer.currentName()).toBe(MEDIA[0]) }) }) diff --git a/e2e/svg.spec.ts b/e2e/svg.spec.ts new file mode 100644 index 0000000..be414b1 --- /dev/null +++ b/e2e/svg.spec.ts @@ -0,0 +1,53 @@ +/*! + * 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 svg the viewer showed, decoded back out of the element. + * + * An svg is the one image the viewer does not hand to the element as it + * came: it is fetched, run through the sanitiser and given over as a data + * URL. Reading that URL back is reading what the sanitiser produced. + * + * @param src the element's src attribute + */ +function decode(src: string): string { + const encoded = src.replace(/^data:image\/svg\+xml;base64,/, '') + return Buffer.from(encoded, 'base64').toString('utf8') +} + +test.describe('SVG', () => { + test('shows the drawing without what was smuggled in it', async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open('drawing.svg') + await viewer.waitForOpen() + + const image = viewer.container.locator('img').first() + await expect(image).toBeVisible() + + const src = await image.getAttribute('src') + expect(src).toMatch(/^data:image\/svg\+xml;base64,/) + const svg = decode(src!) + + // The drawing survives + expect(svg).toContain(' + + + Sanitiser test drawing + + + + + + + diff --git a/playground/public/remote.php/dav/files/playground/picture.apng b/playground/public/remote.php/dav/files/playground/picture.apng new file mode 100644 index 0000000..5dbb7f9 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/picture.apng differ diff --git a/playground/public/remote.php/dav/files/playground/picture.bmp b/playground/public/remote.php/dav/files/playground/picture.bmp new file mode 100644 index 0000000..72f92e6 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/picture.bmp differ diff --git a/playground/public/remote.php/dav/files/playground/picture.ico b/playground/public/remote.php/dav/files/playground/picture.ico new file mode 100644 index 0000000..facedc7 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/picture.ico differ diff --git a/playground/public/remote.php/dav/files/playground/picture.png b/playground/public/remote.php/dav/files/playground/picture.png new file mode 100644 index 0000000..cefb198 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/picture.png differ diff --git a/playground/public/remote.php/dav/files/playground/picture.webp b/playground/public/remote.php/dav/files/playground/picture.webp new file mode 100644 index 0000000..c571546 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/picture.webp differ diff --git a/playground/public/remote.php/dav/files/playground/sound-vnd.wav b/playground/public/remote.php/dav/files/playground/sound-vnd.wav new file mode 100644 index 0000000..991589f Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/sound-vnd.wav differ diff --git a/playground/public/remote.php/dav/files/playground/sound-xwav.wav b/playground/public/remote.php/dav/files/playground/sound-xwav.wav new file mode 100644 index 0000000..991589f Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/sound-xwav.wav differ diff --git a/playground/public/remote.php/dav/files/playground/sound.flac b/playground/public/remote.php/dav/files/playground/sound.flac new file mode 100644 index 0000000..3054a59 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/sound.flac differ diff --git a/playground/public/remote.php/dav/files/playground/sound.m4a b/playground/public/remote.php/dav/files/playground/sound.m4a new file mode 100644 index 0000000..0452d62 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/sound.m4a differ diff --git a/playground/public/remote.php/dav/files/playground/sound.ogg b/playground/public/remote.php/dav/files/playground/sound.ogg new file mode 100644 index 0000000..f248fe5 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/sound.ogg differ diff --git a/playground/public/remote.php/dav/files/playground/sound.wav b/playground/public/remote.php/dav/files/playground/sound.wav new file mode 100644 index 0000000..991589f Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/sound.wav differ diff --git a/playground/public/remote.php/dav/files/playground/sound.webm b/playground/public/remote.php/dav/files/playground/sound.webm new file mode 100644 index 0000000..507174f Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/sound.webm differ