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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ coverage/
test-results/
playwright-report/
playground/dist/

# Built by build:e2e-app, mounted into the test container
e2e-server/js/
6 changes: 6 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,9 @@ path = ["playground/public/**", "playground/server-default.css"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"

[[annotations]]
path = ["e2e-server/tests/fixtures/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"
24 changes: 24 additions & 0 deletions e2e-server/appinfo/info.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0"?>
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>viewer_e2e</id>
<name>Viewer end-to-end harness</name>
<summary>Loads this checkout of @nextcloud/viewer into a real server</summary>
<description><![CDATA[
Not a real app. It exists so the library's end-to-end tests can run against a
real Nextcloud rather than against static files, which is the only way to
cover the formats that depend on the server rendering a preview.
]]></description>
<version>1.0.0</version>
<licence>agpl</licence>
<author>Nextcloud GmbH and Nextcloud contributors</author>
<namespace>ViewerE2E</namespace>
<category>tools</category>
<dependencies>
<nextcloud min-version="30" max-version="40"/>
</dependencies>
</info>
50 changes: 50 additions & 0 deletions e2e-server/global-setup.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 { configureNextcloud, runOcc, startNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server/docker'
import { fileURLToPath } from 'node:url'

/**
* Bring up a Nextcloud with this checkout of the library loaded into it.
*
* The playground answers for itself, which is what makes it quick, and it
* is also why the formats that depend on the server rendering a preview
* cannot be covered there: there is no preview endpoint to ask. This
* starts a real server, mounts the harness app that loads the library,
* and hands the tests its address.
*/
export default async function globalSetup(): Promise<void> {
const app = fileURLToPath(new URL('.', import.meta.url))
const branch = process.env.NEXTCLOUD_BRANCH ?? 'master'

const ip = await startNextcloud(branch, app)
// The container answers before the install has finished, and occ says
// so rather than waiting
await waitOnNextcloud(ip)
await configureNextcloud(['viewer_e2e'], branch)

// The formats worth covering here are the ones a provider has to render,
// and every ImageMagick provider is off until an admin asks for it. An
// installation would not have these on; the point is to prove the
// library shows what the server produces once they are.
//
// One occ call per entry, because the setting is a list and occ takes a
// list an index at a time.
const providers = [
'OC\\Preview\\PNG',
'OC\\Preview\\JPEG',
'OC\\Preview\\GIF',
'OC\\Preview\\HEIC',
'OC\\Preview\\TIFF',
'OC\\Preview\\JP2',
]
for (const [index, provider] of providers.entries()) {
await runOcc(['config:system:set', 'enabledPreviewProviders', String(index), '--value', provider])
}

// Read in the workers rather than here: this runs after the config has
// been evaluated in the main process, and the workers are started after
// it, so they inherit the address and evaluate `use.baseURL` with it
process.env.PLAYWRIGHT_TEST_BASE_URL = `http://${ip}`
}
40 changes: 40 additions & 0 deletions e2e-server/lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ViewerE2E\AppInfo;

use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Util;

/**
* Puts this checkout of the library on the page of a real server.
*
* The script registers the handlers the same way a host app would, so the
* tests exercise the published entry point rather than a copy of the
* source wired up especially for them.
*/
class Application extends App implements IBootstrap {
public const APP_ID = 'viewer_e2e';

public function __construct() {
parent::__construct(self::APP_ID);
}

#[\Override]
public function register(IRegistrationContext $context): void {
}

#[\Override]
public function boot(IBootContext $context): void {
Util::addScript(self::APP_ID, self::APP_ID . '-main');
}
}
14 changes: 14 additions & 0 deletions e2e-server/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

// The built package rather than the source: the point of this harness is
// that what gets published works on a real server, and the source needs
// build-time defines that only the library's own config supplies.
import { registerDefaultHandlers } from '../../dist/index.mjs'

// What a host app does, and nothing more: the point of the harness is that
// the library behaves on a real server the way it does on the playground,
// so it must not be helped along here.
registerDefaultHandlers()
Binary file added e2e-server/tests/fixtures/picture.heic
Binary file not shown.
Binary file added e2e-server/tests/fixtures/picture.jp2
Binary file not shown.
Binary file added e2e-server/tests/fixtures/picture.tiff
Binary file not shown.
Binary file added e2e-server/tests/fixtures/plain.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
169 changes: 169 additions & 0 deletions e2e-server/tests/previews.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server'
import type { APIRequestContext, Page } from '@playwright/test'

import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
import { expect, test } from '@playwright/test'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'

/**
* The formats that only a server can show.
*
* Nothing decodes any of these in a browser, so the viewer has nothing to
* fall back on: either a provider rendered a preview or the frame is
* empty. That is why they cannot be covered against the playground, and
* why they are the ones worth paying for a container to test.
*/
const PREVIEW_FORMATS = [
{ file: 'picture.tiff', mime: 'image/tiff' },
{ file: 'picture.heic', mime: 'image/heic' },
{ file: 'picture.jp2', mime: 'image/jp2' },
]

/** The credentials a dav request carries */
function basic(user: User): string {
return 'Basic ' + Buffer.from(`${user.userId}:${user.password}`).toString('base64')
}

/**
* Sign the browser in.
*
* Through the form rather than the API, because what the tests need is a
* session the page carries, and because it is the way somebody arrives at
* a file in the first place.
*
* @param page the page
* @param user the user to sign in as
*/
async function signIn(page: Page, user: User): Promise<void> {
await page.goto('/login')
await page.locator('input[name="user"]').fill(user.userId)
await page.locator('input[name="password"]').fill(user.password)
await page.locator('form[name="login"] button[type="submit"]').click()
await page.waitForURL(/\/apps\//)
}

/**
* Put a file in the user's home.
*
* @param request the request context
* @param user the owner
* @param name the file name
* @param mime what to upload it as
*/
async function upload(request: APIRequestContext, user: User, name: string, mime: string): Promise<void> {
const body = readFileSync(fileURLToPath(new URL(`fixtures/${name}`, import.meta.url)))
const response = await request.put(`/remote.php/dav/files/${user.userId}/${name}`, {
data: body,
headers: {
'Content-Type': mime,
Authorization: basic(user),
},
})
expect(response.status(), `uploading ${name}`).toBeLessThan(300)
}

/**
* Open a file the way somebody would, by clicking it in the list.
*
* Not through `?openfile` on a cold load: the harness app's script is
* added by an app rather than by core, so it can register the action
* after the Files app has already read that query. On a real server the
* viewer is registered from core and arrives in time. Clicking is the
* path this suite is about anyway.
*
* @param page the page
* @param name the file to open
*/
async function openFromList(page: Page, name: string): Promise<void> {
await page.goto('/apps/files')
const row = page.locator(`[data-cy-files-list-row-name="${name}"]`)
await row.waitFor({ timeout: 30_000 })
await row.locator('[data-cy-files-list-row-name-link]').click()
}

/**
* Whether the server tells the page which preview providers it has.
*
* The library gates every format that needs a preview on this, so without
* it none of them can be offered however well the server renders them. It
* arrives with nextcloud/server#63954; until that lands there is nothing
* for these tests to find, and saying so is more use than failing.
*
* @param request the request context
* @param user a user to ask as
*/
async function reportsPreviewProviders(request: APIRequestContext, user: User): Promise<boolean> {
const response = await request.get('/ocs/v2.php/cloud/capabilities?format=json', {
headers: { Authorization: basic(user), 'OCS-APIRequest': 'true' },
})
const body = await response.json() as {
ocs?: { data?: { capabilities?: { core?: { previews?: { enabled_providers?: string[] } } } } }
}
return Array.isArray(body?.ocs?.data?.capabilities?.core?.previews?.enabled_providers)
}

test.describe('A real server', () => {
test('shows a file it rendered a preview for', async ({ page, request }) => {
// Nothing gated about a jpeg, so this is the one that proves the
// harness itself: a real upload, a real session, the library loaded
// from its built package by an app, and a picture on screen
const user = await createRandomUser()
await upload(request, user, 'plain.jpg', 'image/jpeg')

await signIn(page, user)
await openFromList(page, 'plain.jpg')

const image = page.locator('.viewer__modal img').first()
await expect(image).toBeVisible({ timeout: 30_000 })
await expect(async () => {
const shown = await image.evaluate((element: HTMLImageElement) => ({
complete: element.complete,
width: element.naturalWidth,
}))
expect(shown.complete).toBe(true)
expect(shown.width).toBeGreaterThan(0)
}).toPass({ timeout: 30_000 })
})
})

test.describe('Formats only the server can render', () => {
for (const { file, mime } of PREVIEW_FORMATS) {
test(`shows a ${mime} through the preview the server made`, async ({ page, request }) => {
const user = await createRandomUser()
test.skip(
!await reportsPreviewProviders(request, user),
'This server does not report its preview providers, so the library cannot offer a format that needs one (nextcloud/server#63954)',
)
await upload(request, user, file, mime)

await signIn(page, user)
await openFromList(page, file)

const image = page.locator('.viewer__modal img').first()
await expect(image).toBeVisible({ timeout: 30_000 })

// A cold render can take a while, so the assertion is what the
// element ends up holding rather than how fast it got there
await expect(async () => {
const shown = await image.evaluate((element: HTMLImageElement) => ({
complete: element.complete,
width: element.naturalWidth,
height: element.naturalHeight,
src: element.currentSrc,
}))
// Decoded, so the server really produced an image
expect(shown.complete).toBe(true)
expect(shown.width).toBeGreaterThan(0)
expect(shown.height).toBeGreaterThan(0)
// And it came from the preview endpoint, not from the file:
// the browser cannot read any of these formats itself
expect(shown.src).toContain('/core/preview')
}).toPass({ timeout: 30_000 })
})
}
})
28 changes: 28 additions & 0 deletions e2e-server/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createAppConfig } from '@nextcloud/vite-config'
import { fileURLToPath } from 'node:url'

/**
* The harness app's bundle.
*
* Built into the app directory itself, because that directory is what gets
* bind mounted into the container: anything left outside it never reaches
* the server.
*/
export default createAppConfig({
main: fileURLToPath(new URL('src/main.ts', import.meta.url)),
}, {
appName: 'viewer_e2e',
config: {
root: fileURLToPath(new URL('.', import.meta.url)),
build: {
outDir: fileURLToPath(new URL('.', import.meta.url)),
emptyOutDir: false,
},
},
inlineCSS: true,
minify: false,
})
Loading
Loading