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
50 changes: 50 additions & 0 deletions e2e/audio.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
}
})
54 changes: 36 additions & 18 deletions e2e/formats.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
}
})
47 changes: 38 additions & 9 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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])
})
})
53 changes: 53 additions & 0 deletions e2e/svg.spec.ts
Original file line number Diff line number Diff line change
@@ -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('<circle')
expect(svg).toContain('Sanitiser test drawing')

// What an svg can be used to smuggle does not.
//
// Worth knowing what this does and does not prove: the viewer shows
// an svg through an img element, and a browser will not run script
// in one of those whatever it contains. So the sanitiser is not what
// stops this file executing here, and a test asserting that nothing
// ran passes just as well with the sanitiser taken out. What it does
// is keep the markup from reaching anywhere it would run, which is
// what these assertions check, and they do fail without it.
expect(svg).not.toContain('<script')
expect(svg).not.toContain('onload')
expect(svg).not.toContain('onerror')
expect(svg).not.toContain('__svgScriptRan')
})
})
15 changes: 15 additions & 0 deletions playground/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,25 @@ const fixtures: Fixture[] = [
{ name: 'photo.avif', mime: 'image/avif', editable: true },
{ name: 'score.musicxml', mime: 'application/vnd.recordare.musicxml+xml' },
{ name: 'score.mxl', mime: 'application/vnd.recordare.musicxml' },
{ name: 'picture.png', mime: 'image/png' },
{ name: 'picture.bmp', mime: 'image/bmp' },
{ name: 'picture.webp', mime: 'image/webp' },
{ name: 'picture.ico', mime: 'image/x-icon' },
{ name: 'picture.apng', mime: 'image/apng' },
{ name: 'drawing.svg', mime: 'image/svg+xml' },
{ name: 'animation.gif', mime: 'image/gif' },
{ name: 'protected.jpg', mime: 'image/jpeg', noDownload: true },
{ name: 'video.mp4', mime: 'video/mp4' },
{ name: 'audio.mp3', mime: 'audio/mpeg' },
// The same WAV under each of the three names a server may give it,
// which is what nextcloud-libraries/nextcloud-viewer#45 was about
{ name: 'sound.wav', mime: 'audio/wav' },
{ name: 'sound-xwav.wav', mime: 'audio/x-wav' },
{ name: 'sound-vnd.wav', mime: 'audio/vnd.wave' },
{ name: 'sound.flac', mime: 'audio/flac' },
{ name: 'sound.ogg', mime: 'audio/ogg' },
{ name: 'sound.webm', mime: 'audio/webm' },
{ name: 'sound.m4a', mime: 'audio/mp4' },
]

/** Where the fixtures are served from, shaped like a WebDAV path */
Expand Down
19 changes: 19 additions & 0 deletions playground/public/remote.php/dav/files/playground/drawing.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading