diff --git a/.tx/config b/.tx/config index cacd073fa4f28..bc18d862ea8b3 100644 --- a/.tx/config +++ b/.tx/config @@ -205,3 +205,9 @@ file_filter = translationfiles//workflowengine.po source_file = translationfiles/templates/workflowengine.pot source_lang = en type = PO + +[o:nextcloud:p:nextcloud:r:viewer] +file_filter = translationfiles//viewer.po +source_file = translationfiles/templates/viewer.pot +source_lang = en +type = PO diff --git a/REUSE.toml b/REUSE.toml index 2904855498246..9c15d59740004 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -356,3 +356,4 @@ path = "core/img/libreoffice.svg" precedence = "aggregate" SPDX-FileCopyrightText = "2011-2026 The Document Foundation" SPDX-License-Identifier = "LicenseRef-TheDocumentFoundationTrademarks" + diff --git a/apps/files/lib/Controller/ViewController.php b/apps/files/lib/Controller/ViewController.php index 054bb106e150f..f53dc3d9c2713 100644 --- a/apps/files/lib/Controller/ViewController.php +++ b/apps/files/lib/Controller/ViewController.php @@ -17,7 +17,6 @@ use OCA\Files\Event\LoadSidebar; use OCA\Files\Service\UserConfig; use OCA\Files\Service\ViewConfig; -use OCA\Viewer\Event\LoadViewer; use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -198,9 +197,6 @@ public function index($dir = '', $view = '', $fileid = null) { $this->eventDispatcher->dispatchTyped(new LoadSidebar()); $this->eventDispatcher->dispatchTyped(new LoadSearchPlugins()); // Load Viewer scripts - if (class_exists(LoadViewer::class)) { - $this->eventDispatcher->dispatchTyped(new LoadViewer()); - } $this->initialState->provideInitialState('templates_enabled', true); $this->initialState->provideInitialState('templates_path', $this->templateManager->hasTemplateDirectory() ? $this->templateManager->getTemplatePath() : false); diff --git a/apps/files/src/actions/openLocallyAction.ts b/apps/files/src/actions/openLocallyAction.ts index 963df49e44442..667928ebda75b 100644 --- a/apps/files/src/actions/openLocallyAction.ts +++ b/apps/files/src/actions/openLocallyAction.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IFileAction } from '@nextcloud/files' +import type { ActionContextSingle, IFile, IFileAction, INode } from '@nextcloud/files' import LaptopSvg from '@mdi/svg/svg/laptop.svg?raw' import IconWeb from '@mdi/svg/svg/web.svg?raw' @@ -15,6 +15,7 @@ import { translate as t } from '@nextcloud/l10n' import { encodePath } from '@nextcloud/paths' import { generateOcsUrl } from '@nextcloud/router' import { isPublicShare } from '@nextcloud/sharing/public' +import { getViewer } from '@nextcloud/viewer' import { logger } from '../utils/logger.ts' import { isSyncable } from '../utils/permissions.ts' @@ -44,8 +45,8 @@ export const action: IFileAction = { return isSyncable(nodes[0]!) }, - async exec({ nodes }) { - await attemptOpenLocalClient(nodes[0].path) + async exec({ nodes, contents }) { + await attemptOpenLocalClient(nodes[0], contents) return null }, @@ -60,15 +61,16 @@ export const action: IFileAction = { * 2. Open online: The viewer is used to open the file. * 3. Close the dialog and nothing happens (abort). * - * @param path - The path to open + * @param node - The file to open + * @param contents - The folder the file is in, so the viewer can navigate */ -async function attemptOpenLocalClient(path: string) { - await openLocalClient(path) +async function attemptOpenLocalClient(node: INode, contents: ActionContextSingle['contents']) { + await openLocalClient(node.path) const result = await confirmLocalEditDialog() if (result === 'local') { - await openLocalClient(path) + await openLocalClient(node.path) } else if (result === 'online') { - window.OCA.Viewer.open({ path }) + await getViewer().open(contents as IFile[], node as IFile) } } diff --git a/apps/files_sharing/lib/DefaultPublicShareTemplateProvider.php b/apps/files_sharing/lib/DefaultPublicShareTemplateProvider.php index af755fc0eb444..7e2091d23cba4 100644 --- a/apps/files_sharing/lib/DefaultPublicShareTemplateProvider.php +++ b/apps/files_sharing/lib/DefaultPublicShareTemplateProvider.php @@ -11,7 +11,6 @@ use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\Files_Sharing\AppInfo\Application; use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; -use OCA\Viewer\Event\LoadViewer; use OCP\Accounts\IAccountManager; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\Template\ExternalShareMenuAction; @@ -118,11 +117,6 @@ public function renderPage(IShare $share, string $token, string $path): Template $isFileRequest = $attributes?->getAttribute('fileRequest', 'enabled') === true; $this->initialState->provideInitialState('isFileRequest', $isFileRequest); - // Load Viewer scripts - if (class_exists(LoadViewer::class)) { - $this->eventDispatcher->dispatchTyped(new LoadViewer()); - } - $this->initialState->provideInitialState('templates', $this->templateManager->listCreators()); // Allow external apps to register their scripts diff --git a/apps/files_versions/src/utils/versions.spec.ts b/apps/files_versions/src/utils/versions.spec.ts new file mode 100644 index 0000000000000..b80f9579d2554 --- /dev/null +++ b/apps/files_versions/src/utils/versions.spec.ts @@ -0,0 +1,99 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' +import type { Version } from './versions.ts' + +import { File, Permission } from '@nextcloud/files' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@nextcloud/auth', async (orig) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vitest importOriginal idiom + const actual = await orig() + return { + ...actual, + getCurrentUser: () => ({ uid: 'alice', displayName: 'Alice', isAdmin: false }), + } +}) + +const { versionToNode } = await import('./versions.ts') + +/** + * Build the file a version belongs to. + */ +function makeNode(): INode { + return new File({ + id: 42, + source: 'https://cloud.example.com/remote.php/dav/files/alice/holiday.jpg', + root: '/files/alice', + owner: 'alice', + mime: 'image/jpeg', + mtime: new Date('2025-01-22T11:20:00Z'), + }) +} + +/** + * Build a version of that file, as fetchVersions returns them. + * + * @param overrides - Fields to override on the version + */ +function makeVersion(overrides: Partial = {}): Version { + return { + fileId: '42', + label: '', + author: 'alice', + authorName: 'Alice', + filename: '/versions/alice/versions/42/1737542400', + basename: '22 January 2025, 11:20:00', + mime: 'image/jpeg', + etag: 'abc123', + size: 1024, + type: 'file', + mtime: 1737542400000, + permissions: 'R', + previewUrl: 'https://cloud.example.com/apps/files_versions/preview?file=/holiday.jpg&version=1737542400', + url: '/remote.php/dav/versions/alice/versions/42/1737542400', + source: 'https://cloud.example.com/remote.php/dav/versions/alice/versions/42/1737542400', + fileVersion: '1737542400', + ...overrides, + } as Version +} + +describe('versionToNode', () => { + let node: INode + + beforeEach(() => { + node = makeNode() + }) + + it('points at the version content rather than at its thumbnail', () => { + const version = versionToNode(makeVersion(), node) + + expect(version.source).toBe('https://cloud.example.com/remote.php/dav/versions/alice/versions/42/1737542400') + // The version preview is a 250px thumbnail meant for the list, so the + // viewer has to load the version itself + expect(version.attributes.hasPreview).toBe(false) + }) + + it('reads as the date it was taken, not as its id on the server', () => { + const version = versionToNode(makeVersion(), node) + + expect(version.basename).toBe('1737542400') + expect(version.displayname).toBe('22 January 2025, 11:20:00') + }) + + it('is read-only, whatever the file it belongs to allows', () => { + const version = versionToNode(makeVersion(), node) + + expect(version.permissions).toBe(Permission.READ) + }) + + it('takes the mime of the file when the version reports none', () => { + // The current version comes back with an empty mime + const version = versionToNode(makeVersion({ mime: '' }), node) + + expect(version.mime).toBe('image/jpeg') + }) +}) diff --git a/apps/files_versions/src/utils/versions.ts b/apps/files_versions/src/utils/versions.ts index 77995e85d7677..451de38eada99 100644 --- a/apps/files_versions/src/utils/versions.ts +++ b/apps/files_versions/src/utils/versions.ts @@ -8,6 +8,7 @@ import type { FileStat, ResponseDataDetailed } from 'webdav' import { getCurrentUser } from '@nextcloud/auth' import axios from '@nextcloud/axios' +import { File, Permission } from '@nextcloud/files' import { getClient } from '@nextcloud/files/dav' import { getCanonicalLocale } from '@nextcloud/l10n' import { encodePath, join } from '@nextcloud/paths' @@ -36,6 +37,39 @@ export interface Version { const client = getClient() +/** + * Build a node for a version, so that it can be handed to the viewer. + * + * The version content lives at its own dav endpoint, which is what the + * node points at: the preview a version has is a 250px thumbnail meant + * for this list, too small to show as the file itself. A version is + * named after its id on the server and reads as the date it was taken, + * so that is its display name. + * + * @param version - The version to build a node for + * @param node - The file the version belongs to + */ +export function versionToNode(version: Version, node: INode): File { + const owner = getCurrentUser()?.uid ?? null + + return new File({ + id: Number(version.fileId), + source: version.source, + root: `/versions/${owner}/versions/${version.fileId}`, + displayname: version.basename, + // The current version reports no mime of its own + mime: version.mime || node.mime || 'application/octet-stream', + size: version.size, + mtime: new Date(version.mtime), + owner, + permissions: Permission.READ, + attributes: { + etag: version.etag, + hasPreview: false, + }, + }) +} + /** * Get file versions for a given node * diff --git a/apps/files_versions/src/views/FilesVersionsSidebarTab.vue b/apps/files_versions/src/views/FilesVersionsSidebarTab.vue index ee4f11a3e8397..8c9cadddb29b8 100644 --- a/apps/files_versions/src/views/FilesVersionsSidebarTab.vue +++ b/apps/files_versions/src/views/FilesVersionsSidebarTab.vue @@ -41,12 +41,13 @@ diff --git a/apps/settings/lib/Settings/Personal/ServerDevNotice.php b/apps/settings/lib/Settings/Personal/ServerDevNotice.php index f4c4f04a33ba4..b0b6c7cf22047 100644 --- a/apps/settings/lib/Settings/Personal/ServerDevNotice.php +++ b/apps/settings/lib/Settings/Personal/ServerDevNotice.php @@ -7,10 +7,8 @@ namespace OCA\Settings\Settings\Personal; -use OCA\Viewer\Event\LoadViewer; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; -use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IRootFolder; use OCP\IURLGenerator; use OCP\IUserSession; @@ -22,7 +20,6 @@ class ServerDevNotice implements ISettings { public function __construct( private IRegistry $registry, - private IEventDispatcher $eventDispatcher, private IRootFolder $rootFolder, private IUserSession $userSession, private IInitialState $initialState, @@ -39,12 +36,9 @@ public function getForm(): TemplateResponse { $hasInitialState = false; - // If the Reasons to use Nextcloud.pdf file is here, let's init Viewer, also check that Viewer is there - if (class_exists(LoadViewer::class) && $userFolder->nodeExists('Reasons to use Nextcloud.pdf')) { - /** - * @psalm-suppress UndefinedClass, InvalidArgument - */ - $this->eventDispatcher->dispatch(LoadViewer::class, new LoadViewer()); + // The viewer loads itself; this only decides whether the notice has + // a file to point at + if ($userFolder->nodeExists('Reasons to use Nextcloud.pdf')) { $hasInitialState = true; } diff --git a/apps/settings/src/main-nextcloud-pdf.js b/apps/settings/src/main-nextcloud-pdf.js index 55d689c0f4a0e..339faa2da7d82 100644 --- a/apps/settings/src/main-nextcloud-pdf.js +++ b/apps/settings/src/main-nextcloud-pdf.js @@ -3,18 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav' import { loadState } from '@nextcloud/initial-state' +import { getViewer } from '@nextcloud/viewer' const hasPdf = loadState('settings', 'has-reasons-use-nextcloud-pdf') === true +const path = '/Reasons to use Nextcloud.pdf' + +/** + * Look the file up so the viewer can be handed a node. + */ +async function openInViewer() { + const { data } = await getClient().stat(getRootPath() + path, { + details: true, + data: getDefaultPropfind(), + }) + const node = resultToNode(data) + await getViewer().open([node], node) +} window.addEventListener('DOMContentLoaded', function() { const link = document.getElementById('open-reasons-use-nextcloud-pdf') if (link && hasPdf) { link.addEventListener('click', function(event) { event.preventDefault() - OCA.Viewer.open({ - path: '/Reasons to use Nextcloud.pdf', - }) + openInViewer() }) } }) diff --git a/build/frontend/vite.config.ts b/build/frontend/vite.config.ts index c292c3f371202..7cb4e616dbfda 100644 --- a/build/frontend/vite.config.ts +++ b/build/frontend/vite.config.ts @@ -10,6 +10,9 @@ const modules = { appstore: { main: resolve(import.meta.dirname, 'apps/appstore/src', 'main.ts'), }, + core: { + 'viewer-init': resolve(import.meta.dirname, '../../core/src', 'viewer-init.ts'), + }, comments: { 'comments-app': resolve(import.meta.dirname, 'apps/comments/src', 'comments-app.ts'), 'comments-tab': resolve(import.meta.dirname, 'apps/comments/src', 'files-sidebar.ts'), diff --git a/build/integration/features/provisioning-v1.feature b/build/integration/features/provisioning-v1.feature index cb9006474ed32..2730581534e74 100644 --- a/build/integration/features/provisioning-v1.feature +++ b/build/integration/features/provisioning-v1.feature @@ -658,7 +658,6 @@ Feature: provisioning | updatenotification | | user_ldap | | user_status | - | viewer | | workflowengine | | webhook_listeners | | weather_status | diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index b9990096345cd..0b5ce194b598d 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -2349,11 +2349,6 @@ - - - - - diff --git a/core/openapi-administration.json b/core/openapi-administration.json index fdb5aa9e523cd..050a817249faf 100644 --- a/core/openapi-administration.json +++ b/core/openapi-administration.json @@ -33,7 +33,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -52,6 +53,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/core/openapi-ex_app.json b/core/openapi-ex_app.json index 17dbd07fe1ae5..fc5b382108dd7 100644 --- a/core/openapi-ex_app.json +++ b/core/openapi-ex_app.json @@ -33,7 +33,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -52,6 +53,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/core/openapi-full.json b/core/openapi-full.json index 9324c67bc87e9..2f4bc1c13af84 100644 --- a/core/openapi-full.json +++ b/core/openapi-full.json @@ -99,7 +99,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -118,6 +119,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/core/openapi.json b/core/openapi.json index d6d2915708009..b798a827b9bdb 100644 --- a/core/openapi.json +++ b/core/openapi.json @@ -99,7 +99,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -118,6 +119,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/core/shipped.json b/core/shipped.json index 81f734cfeb2cd..a068fa8f61569 100644 --- a/core/shipped.json +++ b/core/shipped.json @@ -53,7 +53,6 @@ "updatenotification", "user_ldap", "user_status", - "viewer", "weather_status", "webhook_listeners", "workflowengine" @@ -102,7 +101,6 @@ "twofactor_totp", "updatenotification", "user_status", - "viewer", "weather_status", "webhook_listeners" ], @@ -120,7 +118,6 @@ "sharing", "theming", "twofactor_backupcodes", - "viewer", "workflowengine" ] } diff --git a/core/src/viewer-init.ts b/core/src/viewer-init.ts new file mode 100644 index 0000000000000..d22fb7acd2b09 --- /dev/null +++ b/core/src/viewer-init.ts @@ -0,0 +1,15 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { registerDefaultHandlers } from '@nextcloud/viewer' + +// Registers the handlers for images, video and audio, and offers this copy of +// the viewer to the page. Nothing of the viewer itself is loaded until a file +// is opened. +// +// This runs from an init script so those handlers, and the file actions they +// register, are in place before the Files list takes its first snapshot of the +// available actions. +registerDefaultHandlers() diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index fcfdd6ce50345..643b03dae144a 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2401,6 +2401,7 @@ 'OC\\Template\\JSCombiner' => $baseDir . '/lib/private/Template/JSCombiner.php', 'OC\\Template\\JSConfigHelper' => $baseDir . '/lib/private/Template/JSConfigHelper.php', 'OC\\Template\\JSResourceLocator' => $baseDir . '/lib/private/Template/JSResourceLocator.php', + 'OC\\Template\\LoadViewerListener' => $baseDir . '/lib/private/Template/LoadViewerListener.php', 'OC\\Template\\ResourceLocator' => $baseDir . '/lib/private/Template/ResourceLocator.php', 'OC\\Template\\ResourceNotFoundException' => $baseDir . '/lib/private/Template/ResourceNotFoundException.php', 'OC\\Template\\Template' => $baseDir . '/lib/private/Template/Template.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index fa6bc202aa4e8..39fdc554a8b00 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2442,6 +2442,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Template\\JSCombiner' => __DIR__ . '/../../..' . '/lib/private/Template/JSCombiner.php', 'OC\\Template\\JSConfigHelper' => __DIR__ . '/../../..' . '/lib/private/Template/JSConfigHelper.php', 'OC\\Template\\JSResourceLocator' => __DIR__ . '/../../..' . '/lib/private/Template/JSResourceLocator.php', + 'OC\\Template\\LoadViewerListener' => __DIR__ . '/../../..' . '/lib/private/Template/LoadViewerListener.php', 'OC\\Template\\ResourceLocator' => __DIR__ . '/../../..' . '/lib/private/Template/ResourceLocator.php', 'OC\\Template\\ResourceNotFoundException' => __DIR__ . '/../../..' . '/lib/private/Template/ResourceNotFoundException.php', 'OC\\Template\\Template' => __DIR__ . '/../../..' . '/lib/private/Template/Template.php', diff --git a/lib/private/OCS/CoreCapabilities.php b/lib/private/OCS/CoreCapabilities.php index 2bab5d96677a5..bb4315ef4a3ec 100644 --- a/lib/private/OCS/CoreCapabilities.php +++ b/lib/private/OCS/CoreCapabilities.php @@ -12,6 +12,7 @@ use OCP\Capabilities\ICapability; use OCP\IConfig; +use OCP\IPreview; use OCP\IURLGenerator; /** @@ -22,9 +23,11 @@ class CoreCapabilities implements ICapability { /** * @param IConfig $config + * @param IPreview $preview */ public function __construct( private IConfig $config, + private IPreview $preview, ) { } @@ -38,6 +41,9 @@ public function __construct( * reference-api: boolean, * reference-regex: string, * mod-rewrite-working: boolean, + * previews: array{ + * enabled_providers: list, + * }, * }, * } */ @@ -50,6 +56,13 @@ public function getCapabilities(): array { 'reference-api' => true, 'reference-regex' => IURLGenerator::URL_REGEX_NO_MODIFIERS, 'mod-rewrite-working' => $this->config->getSystemValueBool('htaccess.IgnoreFrontController') || getenv('front_controller_active') === 'true', + 'previews' => [ + // The mime patterns previews can be generated for. Empty + // when previews are off entirely. Clients use it to tell + // the formats a browser cannot show on its own but the + // server can render from the ones it can do neither of. + 'enabled_providers' => array_keys($this->preview->getProviders()), + ], ], ]; } diff --git a/lib/private/Repair/CleanUpAbandonedApps.php b/lib/private/Repair/CleanUpAbandonedApps.php index be08b210e5691..7eeac3e313008 100644 --- a/lib/private/Repair/CleanUpAbandonedApps.php +++ b/lib/private/Repair/CleanUpAbandonedApps.php @@ -13,7 +13,7 @@ use OCP\Migration\IRepairStep; class CleanUpAbandonedApps implements IRepairStep { - protected const ABANDONED_APPS = ['accessibility', 'files_videoplayer']; + protected const ABANDONED_APPS = ['accessibility', 'files_videoplayer', 'viewer']; public function __construct( private readonly IConfig $config, diff --git a/lib/private/Server.php b/lib/private/Server.php index c1aae75d9fb48..dd6e56c54045b 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -149,6 +149,7 @@ use OC\Talk\Broker; use OC\Teams\TeamManager; use OC\Template\JSCombiner; +use OC\Template\LoadViewerListener; use OC\Translation\TranslationManager; use OC\User\AvailabilityCoordinator; use OC\User\DisplayNameCache; @@ -163,6 +164,7 @@ use OCP\Accounts\IAccountManager; use OCP\Activity\IEventMerger; use OCP\App\IAppManager; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\AppFramework\Utility\IControllerMethodReflector; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Authentication\LoginCredentials\IStore; @@ -906,7 +908,7 @@ public function __construct( $this->registerService(CapabilitiesManager::class, function (ContainerInterface $c) { $manager = new CapabilitiesManager($c->get(LoggerInterface::class)); $manager->registerCapability(function () use ($c) { - return new CoreCapabilities($c->get(IConfig::class)); + return new CoreCapabilities($c->get(IConfig::class), $c->get(IPreview::class)); }); $manager->registerCapability(function () use ($c) { return $c->get(Capabilities::class); @@ -1189,6 +1191,7 @@ private function connectDispatcher(): void { $eventDispatcher->addServiceListener(PostLoginEvent::class, UserLoggedInListener::class); $eventDispatcher->addServiceListener(UserChangedEvent::class, UserChangedListener::class); $eventDispatcher->addServiceListener(BeforeUserDeletedEvent::class, BeforeUserDeletedListener::class); + $eventDispatcher->addServiceListener(BeforeTemplateRenderedEvent::class, LoadViewerListener::class); FilesMetadataManager::loadListeners($eventDispatcher); GenerateBlurhashMetadata::loadListeners($eventDispatcher); diff --git a/lib/private/Template/LoadViewerListener.php b/lib/private/Template/LoadViewerListener.php new file mode 100644 index 0000000000000..981b477065877 --- /dev/null +++ b/lib/private/Template/LoadViewerListener.php @@ -0,0 +1,33 @@ + + */ +class LoadViewerListener implements IEventListener { + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent)) { + return; + } + + Util::addInitScript('core', 'viewer-init'); + } +} diff --git a/openapi.json b/openapi.json index 1f862fa904b68..7827e05a45d31 100644 --- a/openapi.json +++ b/openapi.json @@ -149,7 +149,8 @@ "webdav-root", "reference-api", "reference-regex", - "mod-rewrite-working" + "mod-rewrite-working", + "previews" ], "properties": { "pollinterval": { @@ -168,6 +169,20 @@ "mod-rewrite-working": { "type": "boolean" }, + "previews": { + "type": "object", + "required": [ + "enabled_providers" + ], + "properties": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "user": { "type": "object", "required": [ diff --git a/package-lock.json b/package-lock.json index c62327a8617da..9b6a5c6505b3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^1.0.0-beta.4", + "@nextcloud/viewer": "^2.0.0-beta.7", "@nextcloud/vue": "^9.10.0", "@vueuse/core": "^14.1.0", "@vueuse/integrations": "^14.1.0", @@ -2020,6 +2021,24 @@ "@nextcloud/files": "^3.12.0" } }, + "node_modules/@nextcloud/image-editor": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@nextcloud/image-editor/-/image-editor-1.0.0-beta.2.tgz", + "integrity": "sha512-WSyi/3DhGmtBAti9WLHBt5YKyQq1+tdBYdNe/DMkGHbOKV96C2bpNXp02TJg5LjH55P8NOVbuiXO4tndWJRTow==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/l10n": "^3.4.0", + "konva": "~10.3.2" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "peerDependencies": { + "@nextcloud/dialogs": "^7.0.0", + "@nextcloud/vue": "^9.0.0", + "vue": "^3.5.0" + } + }, "node_modules/@nextcloud/initial-state": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nextcloud/initial-state/-/initial-state-3.0.0.tgz", @@ -2164,6 +2183,38 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, + "node_modules/@nextcloud/viewer": { + "version": "2.0.0-beta.7", + "resolved": "https://registry.npmjs.org/@nextcloud/viewer/-/viewer-2.0.0-beta.7.tgz", + "integrity": "sha512-cCr3YDkILfN/Iwio1q9zycby+gt6D8oaJ193JIzMxGtuyWL4ewxztVRloU33KebZ/ixf4tIdqKI7MKudQDZoxg==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@mdi/svg": "^7.4.47", + "@nextcloud/image-editor": "^1.0.0-beta.2", + "@nextcloud/logger": "^3.0.3", + "@skjnldsv/vue-plyr": "^7.5.0", + "debounce": "^3.0.0", + "dompurify": "^3.4.14", + "vue-material-design-icons": "^5.3.1", + "webdav": "^5.10.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "peerDependencies": { + "@nextcloud/axios": "^2.5.2", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/dialogs": "^7.5.0", + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/files": "^4.0.0", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/paths": "^3.1.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.4.0 || ^1.0.0-beta.2", + "@nextcloud/vue": "^9.10.0", + "vue": "^3.5.0" + } + }, "node_modules/@nextcloud/vite-config": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@nextcloud/vite-config/-/vite-config-2.5.4.tgz", @@ -3404,6 +3455,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@skjnldsv/vue-plyr": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@skjnldsv/vue-plyr/-/vue-plyr-7.5.0.tgz", + "integrity": "sha512-JmbKPe8Eh0yTP+7w4VYeqGhrUqDKM4qSsCn37CbADAvvwMvceBwYC0IBXil0RBtyzStxwXPtOj1oI//lQ5DeTw==", + "license": "MIT", + "dependencies": { + "plyr": "^3.7.8", + "vue": "^3.3.4" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0", + "npm": "^9.0.0 || ^10.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6436,6 +6501,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/custom-event-polyfill": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", + "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==", + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -9604,6 +9675,38 @@ "dev": true, "license": "MIT" }, + "node_modules/konva": { + "version": "10.3.3", + "resolved": "https://registry.npmjs.org/konva/-/konva-10.3.3.tgz", + "integrity": "sha512-j3Rrn504bYFwJ0zV0ZbSOQRsM9KMBLOpEq6xZuyIjbaKeFbBXwgiM5SA4IDq4nwM1l49GVKs66Mo+sl7+4G7PQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "peerDependencies": { + "canvas": "^3.0.0", + "skia-canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + }, + "skia-canvas": { + "optional": true + } + } + }, "node_modules/layerr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", @@ -9637,6 +9740,12 @@ "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", "license": "MIT" }, + "node_modules/loadjs": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz", + "integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==", + "license": "MIT" + }, "node_modules/local-pkg": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", @@ -11544,6 +11653,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/plyr": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz", + "integrity": "sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==", + "license": "MIT", + "dependencies": { + "core-js": "^3.45.1", + "custom-event-polyfill": "^1.0.7", + "loadjs": "^4.3.0", + "rangetouch": "^2.0.1", + "url-polyfill": "^1.1.13" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11957,6 +12079,12 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rangetouch": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz", + "integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==", + "license": "MIT" + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -14476,6 +14604,12 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", + "license": "MIT" + }, "node_modules/url/node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", diff --git a/package.json b/package.json index dac70883a84f0..ade5c234132c0 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@nextcloud/paths": "^3.1.0", "@nextcloud/router": "^3.1.0", "@nextcloud/sharing": "^1.0.0-beta.4", + "@nextcloud/viewer": "^2.0.0-beta.7", "@nextcloud/vue": "^9.10.0", "@vueuse/core": "^14.1.0", "@vueuse/integrations": "^14.1.0", diff --git a/playwright.config.ts b/playwright.config.ts index 4a7ccea10900b..47e9aa01f034b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,6 +14,10 @@ const BROWSWER_CONFIG_CHROME: DeviceDescriptor & { channel: string } = { : 'chromium', // locally use the default playwright chromium browser } +// Port of the Nextcloud container under test. Override it to run several +// checkouts in parallel, as each one needs its own server. +const NEXTCLOUD_PORT = process.env.NEXTCLOUD_PORT ?? '8042' + export default defineConfig({ testDir: './tests/playwright/e2e', fullyParallel: true, @@ -23,7 +27,7 @@ export default defineConfig({ timeout: process.env.CI ? 45_000 : undefined, // on CI allow 1.5x the default timeout to compensate for shared server resources reporter: process.env.CI ? [['blob'], ['dot'], ['github']] : 'html', use: { - baseURL: 'http://localhost:8042/index.php/', + baseURL: `http://localhost:${NEXTCLOUD_PORT}/index.php/`, trace: 'on-first-retry', }, projects: [ @@ -79,7 +83,7 @@ export default defineConfig({ webServer: { command: 'node tests/playwright/start-nextcloud-server.js', env: { - NEXTCLOUD_PORT: '8042', + NEXTCLOUD_PORT, }, stderr: 'pipe', stdout: 'pipe', diff --git a/tests/data/REUSE.toml b/tests/data/REUSE.toml index 3a19ce29f8384..2ace15bc9b815 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -122,3 +122,51 @@ path = "images/image.jpg" precedence = "aggregate" SPDX-FileCopyrightText = "2019 Tom Gainor " SPDX-License-Identifier = "LicenseRef-Unsplash" + +[[annotations]] +path = ["media/image.gif", "media/image.ico", "media/image.svg", "media/image-small.png", "media/image-apng.png"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2019 Nextcloud GmbH" +SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks" + +[[annotations]] +path = ["media/image1.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2019 Stephen Leonardi " +SPDX-License-Identifier = "LicenseRef-Unsplash" + +[[annotations]] +path = ["media/image2.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2019 Tom Gainor " +SPDX-License-Identifier = "LicenseRef-Unsplash" + +[[annotations]] +path = ["media/image4.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2019 Vincent Riszdorfer " +SPDX-License-Identifier = "LicenseRef-Unsplash" + +[[annotations]] +path = ["media/video.mkv", "media/video.ogv", "media/video.webm", "media/video1.mp4"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2012 AnRo0002 " +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = ["media/audio.mp3", "media/audio.ogg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2022 AC Krah " +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = ["media/image3.jpg"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2013 Wilfredor " +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = ["media/image.png", "media/image.webp"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2024 Master Unknown " +SPDX-License-Identifier = "LicenseRef-Unsplash" diff --git a/tests/data/media/audio.mp3 b/tests/data/media/audio.mp3 new file mode 100644 index 0000000000000..d71cb7e4f56ee Binary files /dev/null and b/tests/data/media/audio.mp3 differ diff --git a/tests/data/media/audio.ogg b/tests/data/media/audio.ogg new file mode 100644 index 0000000000000..661ba57eb726b Binary files /dev/null and b/tests/data/media/audio.ogg differ diff --git a/tests/data/media/image-apng.png b/tests/data/media/image-apng.png new file mode 100644 index 0000000000000..73d2f6b962a3e Binary files /dev/null and b/tests/data/media/image-apng.png differ diff --git a/tests/data/media/image-small.png b/tests/data/media/image-small.png new file mode 100644 index 0000000000000..a9128aa6afea5 Binary files /dev/null and b/tests/data/media/image-small.png differ diff --git a/tests/data/media/image.gif b/tests/data/media/image.gif new file mode 100644 index 0000000000000..4cb22be07b6e4 Binary files /dev/null and b/tests/data/media/image.gif differ diff --git a/tests/data/media/image.ico b/tests/data/media/image.ico new file mode 100644 index 0000000000000..b02c657782738 Binary files /dev/null and b/tests/data/media/image.ico differ diff --git a/tests/data/media/image.png b/tests/data/media/image.png new file mode 100644 index 0000000000000..205e6b3f70538 Binary files /dev/null and b/tests/data/media/image.png differ diff --git a/tests/data/media/image.svg b/tests/data/media/image.svg new file mode 100644 index 0000000000000..7008be01a195f --- /dev/null +++ b/tests/data/media/image.svg @@ -0,0 +1 @@ + diff --git a/tests/data/media/image.webp b/tests/data/media/image.webp new file mode 100644 index 0000000000000..8c9d8165c9cfe Binary files /dev/null and b/tests/data/media/image.webp differ diff --git a/tests/data/media/image1.jpg b/tests/data/media/image1.jpg new file mode 100644 index 0000000000000..e561b9e1594b6 Binary files /dev/null and b/tests/data/media/image1.jpg differ diff --git a/tests/data/media/image2.jpg b/tests/data/media/image2.jpg new file mode 100644 index 0000000000000..46dac8cc2835c Binary files /dev/null and b/tests/data/media/image2.jpg differ diff --git a/tests/data/media/image3.jpg b/tests/data/media/image3.jpg new file mode 100644 index 0000000000000..1feb689e477a6 Binary files /dev/null and b/tests/data/media/image3.jpg differ diff --git a/tests/data/media/image4.jpg b/tests/data/media/image4.jpg new file mode 100644 index 0000000000000..caf446d6c7e5d Binary files /dev/null and b/tests/data/media/image4.jpg differ diff --git a/tests/data/media/video.mkv b/tests/data/media/video.mkv new file mode 100644 index 0000000000000..11d6323919995 Binary files /dev/null and b/tests/data/media/video.mkv differ diff --git a/tests/data/media/video.ogv b/tests/data/media/video.ogv new file mode 100644 index 0000000000000..d86f0ccba0056 Binary files /dev/null and b/tests/data/media/video.ogv differ diff --git a/tests/data/media/video.webm b/tests/data/media/video.webm new file mode 100644 index 0000000000000..244b522743c87 Binary files /dev/null and b/tests/data/media/video.webm differ diff --git a/tests/data/media/video1.mp4 b/tests/data/media/video1.mp4 new file mode 100644 index 0000000000000..c94d7ab0715c7 Binary files /dev/null and b/tests/data/media/video1.mp4 differ diff --git a/tests/lib/App/AppManagerTest.php b/tests/lib/App/AppManagerTest.php index 26c5f9d60165c..9f6577cdb030f 100644 --- a/tests/lib/App/AppManagerTest.php +++ b/tests/lib/App/AppManagerTest.php @@ -564,7 +564,6 @@ public function testGetEnabledApps(): void { 'test3', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ]; $this->assertEquals($apps, $this->manager->getEnabledApps()); @@ -597,7 +596,6 @@ public function testGetAppsForUser(): void { 'test3', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ]; $this->assertEquals($enabled, $this->manager->getEnabledAppsForUser($user)); @@ -638,7 +636,6 @@ public function testGetAppsNeedingUpgrade(): void { 'sharing' => ['id' => 'sharing'], 'theming' => ['id' => 'theming'], 'twofactor_backupcodes' => ['id' => 'twofactor_backupcodes'], - 'viewer' => ['id' => 'viewer'], 'workflowengine' => ['id' => 'workflowengine'], 'oauth2' => ['id' => 'oauth2'], ]; @@ -703,7 +700,6 @@ public function testGetIncompatibleApps(): void { 'twofactor_backupcodes' => ['id' => 'twofactor_backupcodes'], 'workflowengine' => ['id' => 'workflowengine'], 'oauth2' => ['id' => 'oauth2'], - 'viewer' => ['id' => 'viewer'], ]; $manager->expects($this->any()) @@ -751,7 +747,6 @@ public function testGetEnabledAppsForGroup(): void { 'test3', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ]; $this->assertEquals($enabled, $this->manager->getEnabledAppsForGroup($group)); diff --git a/tests/lib/AppTest.php b/tests/lib/AppTest.php index 23cb10f0477e9..20349093c8c3e 100644 --- a/tests/lib/AppTest.php +++ b/tests/lib/AppTest.php @@ -109,7 +109,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], false @@ -135,7 +134,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], false @@ -162,7 +160,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], false @@ -189,7 +186,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], false, @@ -216,7 +212,6 @@ public static function appConfigValuesProvider(): array { 'sharing', 'theming', 'twofactor_backupcodes', - 'viewer', 'workflowengine', ], true, @@ -279,11 +274,11 @@ public function testEnabledAppsCache(): void { ); $apps = \OC_App::getEnabledApps(); - $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'viewer', 'workflowengine'], $apps); + $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'workflowengine'], $apps); // mock should not be called again here $apps = \OC_App::getEnabledApps(); - $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'viewer', 'workflowengine'], $apps); + $this->assertEquals(['files', 'app3', 'appstore', 'cloud_federation_api', 'dav', 'federatedfilesharing', 'lookup_server_connector', 'oauth2', 'profile', 'provisioning_api', 'settings', 'sharing', 'theming', 'twofactor_backupcodes', 'workflowengine'], $apps); $this->restoreAppConfig(); $userSession->setUser(null); diff --git a/tests/lib/OCS/CoreCapabilitiesTest.php b/tests/lib/OCS/CoreCapabilitiesTest.php new file mode 100644 index 0000000000000..75ed32bc93438 --- /dev/null +++ b/tests/lib/OCS/CoreCapabilitiesTest.php @@ -0,0 +1,54 @@ +config = $this->createMock(IConfig::class); + $this->preview = $this->createMock(IPreview::class); + $this->capabilities = new CoreCapabilities($this->config, $this->preview); + } + + public function testEnabledPreviewProvidersAreReported(): void { + $this->preview->method('getProviders') + ->willReturn([ + '/image\/jpeg/' => [], + '/image\/hei(f|c)/' => [], + ]); + + $capabilities = $this->capabilities->getCapabilities(); + + $this->assertSame( + ['/image\/jpeg/', '/image\/hei(f|c)/'], + $capabilities['core']['previews']['enabled_providers'], + ); + } + + public function testNoPreviewProvidersAreReportedWhenPreviewsAreOff(): void { + // The manager returns nothing at all when `enable_previews` is off + $this->preview->method('getProviders')->willReturn([]); + + $capabilities = $this->capabilities->getCapabilities(); + + $this->assertSame([], $capabilities['core']['previews']['enabled_providers']); + } +} diff --git a/tests/playwright/e2e/viewer/a11y.spec.ts b/tests/playwright/e2e/viewer/a11y.spec.ts new file mode 100644 index 0000000000000..e79691c3209f5 --- /dev/null +++ b/tests/playwright/e2e/viewer/a11y.spec.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +test.describe('Viewer accessibility', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await uploadMedia('image2.jpg', 'image2.jpg', 'image/jpeg') + await uploadMedia('video1.mp4', 'video1.mp4', 'video/mp4') + await filesListPage.open() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + }) + + test('exposes the viewer as a labelled dialog with accessible controls', async ({ page, openFile, viewerPage }) => { + await openFile('image2.jpg') + await viewerPage.waitForOpen() + + // The modal is a dialog labelled by the current file name. + const dialog = page.locator('.viewer__modal[role="dialog"]') + await expect(dialog).toBeVisible() + expect(await viewerPage.currentName()).toBe('image2.jpg') + + // Navigation and close controls have accessible names. + await expect(viewerPage.nextButton).toBeVisible() + await expect(viewerPage.previousButton).toBeVisible() + await expect(viewerPage.closeButton).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/close.spec.ts b/tests/playwright/e2e/viewer/actions/close.spec.ts new file mode 100644 index 0000000000000..cd903ac58d5a5 --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/close.spec.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer close on click outside', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + }) + + // Regression for nextcloud/viewer#2166: clicking outside the image closes it. + test('closes when clicking outside the media', async ({ openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.clickOutside() + + await viewerPage.waitForClosed() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/delete.spec.ts b/tests/playwright/e2e/viewer/actions/delete.spec.ts new file mode 100644 index 0000000000000..b5a8fc63991cd --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/delete.spec.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer delete action', () => { + // Deleting the shown file advances the viewer to the next file, then the + // previous one, then closes when nothing is left. + test('advances to the next file, then closes when the list is empty', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await uploadMedia('image2.jpg', 'image2.jpg', 'image/jpeg') + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + // Delete the first file → viewer moves to the second, staying open. + await viewerPage.runAction('Delete file') + await viewerPage.waitForOpen() + await expect(async () => { + expect(await viewerPage.currentName()).toBe('image2.jpg') + }).toPass() + + // Delete the only remaining file → viewer closes. + await viewerPage.runAction('Delete file') + await viewerPage.waitForClosed() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/download.spec.ts b/tests/playwright/e2e/viewer/actions/download.spec.ts new file mode 100644 index 0000000000000..e5e4ab487d0de --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/download.spec.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, sharingTest as test, uploadMediaFile } from '../../../support/fixtures/viewer-page.ts' +import { mkdir } from '../../../support/utils/dav.ts' +import { createShare, DOWNLOAD_DISABLED_ATTRIBUTE, waitForShare } from '../../../support/utils/sharing.ts' + +test.describe('Viewer download restrictions', () => { + // The owner shares a folder with the current user, forbidding download. The + // recipient must then get no download control in the viewer. + test('does not expose a download control when download is forbidden', async ({ page, user, owner, ownerRequest, filesListPage, viewerPage }) => { + await mkdir(ownerRequest, owner, '/Photos') + await uploadMediaFile(ownerRequest, owner, 'image1.jpg', '/Photos/image1.jpg', 'image/jpeg') + await createShare(ownerRequest, '/Photos', user.userId, { attributes: DOWNLOAD_DISABLED_ATTRIBUTE }) + await waitForShare(page.request, user, '', 'Photos') + + await filesListPage.open() + await filesListPage.getRowNameLinkForFile('Photos').click() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + + await filesListPage.getRowNameLinkForFile('image1.jpg').click() + await viewerPage.isVisible() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + // No download link nor download action is offered in the viewer. + await expect(viewerPage.modal.locator('a[download]')).toHaveCount(0) + await expect(viewerPage.modal.getByRole('button', { name: /download/i })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/edit.spec.ts b/tests/playwright/e2e/viewer/actions/edit.spec.ts new file mode 100644 index 0000000000000..5f955c70af2cb --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/edit.spec.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer image editor action', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + }) + + // The Edit action opens @nextcloud/image-editor; its Cancel closes it. + test('opens the image editor and closes it on cancel', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.runAction('Edit') + const editor = page.locator('.viewer__image-editor.image-editor') + await expect(editor).toBeVisible() + expect(page.url()).toContain('editing=true') + + await page.locator('[data-test="cancel"], [data-test="cancel-icon"]').first().click() + await expect(editor).toBeHidden() + expect(page.url()).not.toContain('editing=true') + }) + + // Saving overwrites the file and shows the edited image from its local blob, + // without a server refetch. + test('saves and shows the edited image without a refetch', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.runAction('Edit') + await expect(page.locator('.viewer__image-editor.image-editor')).toBeVisible() + + await page.locator('[data-test="save"]').click() + + await expect(page.locator('.viewer__image-editor.image-editor')).toBeHidden() + await expect(page.locator('oca-viewer-image img')).toHaveAttribute('src', /^blob:/) + }) + + // The `editing=true` URL param reopens the editor on refresh. + test('reopens the editor from an editing=true URL', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.runAction('Edit') + await expect(page.locator('.viewer__image-editor.image-editor')).toBeVisible() + + await page.reload() + await expect(page.locator('.viewer__image-editor.image-editor')).toBeVisible() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/menu.spec.ts b/tests/playwright/e2e/viewer/actions/menu.spec.ts new file mode 100644 index 0000000000000..198fa58c62efb --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/menu.spec.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer actions menu', () => { + // The viewer links its header menu to the Files actions. Actions rendered by + // a custom component (e.g. the sharing status) have no static label/icon and + // must be filtered out, otherwise they show up as blank menu entries. + test('does not render blank entries for custom-rendered file actions', async ({ page, filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + const toggle = viewerPage.actionsToggle() + await toggle.click() + + const menuItems = page.getByRole('menuitem') + await expect(menuItems.first()).toBeVisible() + + // Every menu entry must have a non-empty accessible label. + const labels = await menuItems.allInnerTexts() + expect(labels.length).toBeGreaterThan(0) + for (const label of labels) { + expect(label.trim()).not.toBe('') + } + + // The sharing-status action is custom-rendered and must be absent. + await expect(page.getByRole('menuitem', { name: /sharing/i })).toHaveCount(0) + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/rename.spec.ts b/tests/playwright/e2e/viewer/actions/rename.spec.ts new file mode 100644 index 0000000000000..751769294c721 --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/rename.spec.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer rename action', () => { + // The Files rename action edits the file-list row, which the viewer cannot + // host, so the viewer renames through its own dialog instead. + test('renames the current file from the viewer', async ({ page, filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.runAction('Rename') + + const dialog = page.getByRole('dialog', { name: 'Rename file' }) + await expect(dialog).toBeVisible() + const input = dialog.getByRole('textbox') + await input.fill('renamed.jpg') + await dialog.getByRole('button', { name: 'Rename' }).click() + + await expect(dialog).toBeHidden() + // The header reflects the new name. + await expect(async () => { + expect(await viewerPage.currentName()).toBe('renamed.jpg') + }).toPass() + }) +}) diff --git a/tests/playwright/e2e/viewer/actions/sidebar.spec.ts b/tests/playwright/e2e/viewer/actions/sidebar.spec.ts new file mode 100644 index 0000000000000..f2431c0232e41 --- /dev/null +++ b/tests/playwright/e2e/viewer/actions/sidebar.spec.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../../support/fixtures/viewer-page.ts' + +test.describe('Viewer sidebar action', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await filesListPage.open() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + }) + + test('opens the Files sidebar for the current file', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.openSidebar() + + const sidebar = page.locator('aside.app-sidebar') + await expect(sidebar).toBeVisible() + await expect(sidebar.locator('.app-sidebar-header__mainname')).toContainText('image1.jpg') + }) + + // The sidebar next to the viewer must fill the full height (the app header is + // hidden), like the pre-7.0.0 viewer did. + test('shows the sidebar full height next to the viewer', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.openSidebar() + + const sidebar = page.locator('aside.app-sidebar') + await expect(sidebar).toBeVisible() + + await expect(async () => { + const full = await page.evaluate(() => { + const el = document.querySelector('aside.app-sidebar')! + const header = document.querySelector('#header') + return document.body.classList.contains('viewer--sidebar-fullscreen') + && getComputedStyle(el).position === 'fixed' + && (!header || getComputedStyle(header).visibility === 'hidden') + && Math.round(el.getBoundingClientRect().height) === window.innerHeight + }) + expect(full).toBe(true) + }).toPass() + }) + + // Regression for nextcloud/viewer#658: opening the sidebar while the image is + // still loading must still show the sidebar (the header actions are available + // during loading). + test('opens the sidebar while the image is still loading', async ({ page, openFile, viewerPage }) => { + // The previews of the file list are held back as well, so the whole test + // runs against delayed responses and needs more than the default budget. + test.slow() + + // Hold the preview response so the viewer stays in its loading state long + // enough to interact with the header while loading. + await page.route('**/core/preview*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 3000)) + await route.continue() + }) + + await openFile('image1.jpg') + await expect(viewerPage.container).toBeVisible() + await expect(viewerPage.loading).toHaveCount(1) + + await viewerPage.openSidebar() + + const sidebar = page.locator('aside.app-sidebar') + await expect(sidebar).toBeVisible() + + // The image still finishes loading afterwards. Stop holding the previews + // back first: opening the sidebar resizes the viewer, which requests the + // preview again, and that request would be delayed as well. + await page.unroute('**/core/preview*') + await viewerPage.waitForOpen() + }) +}) diff --git a/tests/playwright/e2e/viewer/audios.spec.ts b/tests/playwright/e2e/viewer/audios.spec.ts new file mode 100644 index 0000000000000..9f6980f150e2e --- /dev/null +++ b/tests/playwright/e2e/viewer/audios.spec.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { AUDIO_MEDIA, createMediaViewerTests } from './media.ts' + +createMediaViewerTests(AUDIO_MEDIA) diff --git a/tests/playwright/e2e/viewer/compare.spec.ts b/tests/playwright/e2e/viewer/compare.spec.ts new file mode 100644 index 0000000000000..f820caab609e7 --- /dev/null +++ b/tests/playwright/e2e/viewer/compare.spec.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +test.describe('Viewer compare API', () => { + // The compare() API renders two files side by side. It has no Files-app UI + // (it is used programmatically, e.g. by files_versions), so we capture two + // real File nodes by opening them, then call the public viewer service. + test('renders two files side by side', async ({ page, filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await uploadMedia('image2.jpg', 'image2.jpg', 'image/jpeg') + await filesListPage.open() + + // Capture the File node passed to the viewer for each opened file. + await page.evaluate(() => { + const win = window as unknown as { + __capturedNodes: unknown[] + _nc_viewer_scope: Record unknown } }> + } + win.__capturedNodes = [] + const service = win._nc_viewer_scope.handlers_v1!.service + const original = service.open.bind(service) + service.open = (nodes: unknown, file: unknown, ...rest: unknown[]) => { + win.__capturedNodes.push(file) + return original(nodes, file, ...rest) + } + }) + + await openFile('image1.jpg') + await viewerPage.waitForOpen() + await viewerPage.close() + + await openFile('image2.jpg') + await viewerPage.waitForOpen() + await viewerPage.close() + + // Programmatically compare the two captured nodes. + await page.evaluate(async () => { + const win = window as unknown as { + __capturedNodes: unknown[] + _nc_viewer_scope: Record Promise } }> + } + await win._nc_viewer_scope.handlers_v1!.service.compare(win.__capturedNodes[0], win.__capturedNodes[1]) + }) + + const comparison = viewerPage.modal.locator('.viewer__comparison') + await expect(comparison).toBeVisible() + await expect(comparison.locator('oca-viewer-image')).toHaveCount(2) + }) +}) diff --git a/tests/playwright/e2e/viewer/error.spec.ts b/tests/playwright/e2e/viewer/error.spec.ts new file mode 100644 index 0000000000000..46343d7f38ea6 --- /dev/null +++ b/tests/playwright/e2e/viewer/error.spec.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test } from '../../support/fixtures/viewer-page.ts' + +test.describe('Viewer error handling', () => { + // A media file that fails to load must show an error, not stay stuck on the + // loading spinner (regression guard for the loading-gate / onError path). + test('shows an error when the media fails to load', async ({ page, filesListPage, uploadMedia, openFile, viewerPage }) => { + await uploadMedia('video1.mp4', 'video1.mp4', 'video/mp4') + + // Make every request for the video fail: the direct source and the E2EE + // fallback fetch both hit the dav endpoint for this file. + await page.route('**/video1.mp4', (route) => route.abort()) + await page.route('**/video1.mp4?**', (route) => route.abort()) + + await filesListPage.open() + await openFile('video1.mp4') + + await viewerPage.expectError() + }) +}) diff --git a/tests/playwright/e2e/viewer/history.spec.ts b/tests/playwright/e2e/viewer/history.spec.ts new file mode 100644 index 0000000000000..8a94c409aba89 --- /dev/null +++ b/tests/playwright/e2e/viewer/history.spec.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +test.describe('Viewer browser history', () => { + test.beforeEach(async ({ uploadMedia }) => { + await uploadMedia('image1.jpg', 'image1.jpg', 'image/jpeg') + await uploadMedia('image2.jpg', 'image2.jpg', 'image/jpeg') + }) + + test('reflects the open file in the URL and re-opens on refresh', async ({ page, filesListPage, openFile, viewerPage }) => { + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + // Opening pushes an openfile URL so a refresh re-triggers the viewer. + expect(page.url()).toContain('openfile=true') + + await page.reload() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + }) + + test('back and forward move between the shown files', async ({ page, filesListPage, openFile, viewerPage }) => { + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + const openUrl = page.url() + + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image2.jpg') + // Navigating pushed a new history entry. + expect(page.url()).not.toBe(openUrl) + + // Browser back returns to the first file, still inside the viewer. + await page.goBack() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + // Browser forward returns to the second file. + await page.goForward() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image2.jpg') + }) + + test('closing resets history so back does not re-open a file', async ({ page, filesListPage, openFile, viewerPage }) => { + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + await viewerPage.next() + await viewerPage.waitForOpen() + + await viewerPage.close() + // The openfile flag is gone from the URL… + expect(page.url()).not.toContain('openfile=true') + + // …and pressing back does not step back into the images. + await page.goBack() + await viewerPage.waitForClosed() + }) + + test('navigating out of the openfile range closes the viewer', async ({ page, filesListPage, openFile, viewerPage }) => { + await filesListPage.open() + await openFile('image1.jpg') + await viewerPage.waitForOpen() + + // A single back from the first file leaves the openfile range → close. + await page.goBack() + await viewerPage.waitForClosed() + }) +}) diff --git a/tests/playwright/e2e/viewer/images.spec.ts b/tests/playwright/e2e/viewer/images.spec.ts new file mode 100644 index 0000000000000..1e52424d7bb7c --- /dev/null +++ b/tests/playwright/e2e/viewer/images.spec.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createMediaViewerTests, IMAGE_MEDIA } from './media.ts' + +createMediaViewerTests(IMAGE_MEDIA) diff --git a/tests/playwright/e2e/viewer/large-folder.spec.ts b/tests/playwright/e2e/viewer/large-folder.spec.ts new file mode 100644 index 0000000000000..a637e0469d90b --- /dev/null +++ b/tests/playwright/e2e/viewer/large-folder.spec.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +// Zero-padded so the natural file-list order matches the numeric order. +const COUNT = 30 +const name = (index: number): string => `image-${String(index).padStart(3, '0')}.jpg` + +test.describe('Viewer in a large folder', () => { + // Regression for nextcloud/viewer#3015: opening a file in a directory with + // many files used to hang (O(n^2) sort). The viewer now trusts the caller's + // order, so opening and navigating stays responsive. + test('opens quickly and navigates in a folder with many files', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + await Promise.all(Array.from({ length: COUNT }, (_, index) => uploadMedia('image1.jpg', name(index), 'image/jpeg'))) + await filesListPage.open() + await expect(filesListPage.getRowForFile(name(0))).toBeVisible() + + const start = Date.now() + await openFile(name(0)) + await viewerPage.waitForOpen() + // The prev/next controls become available well within a sane budget. + expect(Date.now() - start).toBeLessThan(15000) + + expect(await viewerPage.currentName()).toBe(name(0)) + + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name(1)) + }) +}) diff --git a/tests/playwright/e2e/viewer/media.ts b/tests/playwright/e2e/viewer/media.ts new file mode 100644 index 0000000000000..8e410ca53c3ad --- /dev/null +++ b/tests/playwright/e2e/viewer/media.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { MediaKind } from '../../support/sections/ViewerPage.ts' + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +export interface MediaItem { + /** Fixture file name in `tests/data/media`. */ + fixture: string + /** Content type used when uploading the fixture. */ + mime: string + /** Media kind handled by the viewer. */ + kind: MediaKind + /** The handler custom element tag name. */ + handlerTag: string + /** + * If set, the standard tests are skipped with this reason. + * Used for codecs unsupported by Playwright's bundled Chromium. + */ + skip?: string +} + +/** Images ported from the Cypress image specs and the image mixin. */ +export const IMAGE_MEDIA: MediaItem[] = [ + { fixture: 'image1.jpg', mime: 'image/jpeg', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.png', mime: 'image/png', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.gif', mime: 'image/gif', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.webp', mime: 'image/webp', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.svg', mime: 'image/svg+xml', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image.ico', mime: 'image/x-icon', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image-apng.png', mime: 'image/png', kind: 'image', handlerTag: 'oca-viewer-image' }, + { fixture: 'image-small.png', mime: 'image/png', kind: 'image', handlerTag: 'oca-viewer-image' }, +] + +/** Videos ported from the Cypress video specs and the video mixin. */ +export const VIDEO_MEDIA: MediaItem[] = [ + { fixture: 'video1.mp4', mime: 'video/mp4', kind: 'video', handlerTag: 'oca-viewer-video' }, + { fixture: 'video.webm', mime: 'video/webm', kind: 'video', handlerTag: 'oca-viewer-video' }, + { fixture: 'video.mkv', mime: 'video/x-matroska', kind: 'video', handlerTag: 'oca-viewer-video' }, + { + fixture: 'video.ogv', + mime: 'video/ogg', + kind: 'video', + handlerTag: 'oca-viewer-video', + // Ogg/Theora is not supported by Playwright's bundled Chromium. + skip: 'Ogg/Theora codec is unsupported by Playwright bundled Chromium', + }, +] + +/** Audios ported from the Cypress audio specs and the audio mixin. */ +export const AUDIO_MEDIA: MediaItem[] = [ + { fixture: 'audio.mp3', mime: 'audio/mpeg', kind: 'audio', handlerTag: 'oca-viewer-audio' }, + { fixture: 'audio.ogg', mime: 'audio/ogg', kind: 'audio', handlerTag: 'oca-viewer-audio' }, +] + +/** + * Register the standard open → load → assert → close test for a media item. + * + * This is the Playwright port of the Cypress `image`, `video` and `audio` + * mixins: it uploads a single fixture, opens it from the files list, waits for + * the viewer to finish loading, asserts the correct handler is rendered and + * that a single file shows no navigation arrows, then closes the viewer. + * + * @param media - The media items to generate tests for + */ +export function createMediaViewerTests(media: MediaItem[]): void { + for (const item of media) { + test(`opens ${item.fixture} in the viewer`, async ({ filesListPage, viewerPage, uploadMedia, openFile }) => { + test.skip(Boolean(item.skip), item.skip ?? '') + + await uploadMedia(item.fixture, item.fixture, item.mime) + await filesListPage.open() + + await expect(filesListPage.getRowForFile(item.fixture)).toBeVisible() + + await openFile(item.fixture) + await viewerPage.waitForOpen() + + expect(await viewerPage.currentName()).toBe(item.fixture) + await viewerPage.expectHandler(item.kind) + + await viewerPage.close() + }) + } +} diff --git a/tests/playwright/e2e/viewer/navigation.spec.ts b/tests/playwright/e2e/viewer/navigation.spec.ts new file mode 100644 index 0000000000000..3bc7f967ff51c --- /dev/null +++ b/tests/playwright/e2e/viewer/navigation.spec.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +const IMAGES = ['image1.jpg', 'image2.jpg', 'image3.jpg', 'image4.jpg'] + +test.describe('Viewer navigation', () => { + test.beforeEach(async ({ filesListPage, uploadMedia }) => { + for (const image of IMAGES) { + await uploadMedia(image, image, 'image/jpeg') + } + await filesListPage.open() + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + }) + + test('navigates forward through the list and loops back to the first image', async ({ openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + // The list has several images: navigation arrows are shown. + await expect(viewerPage.nextButton).toBeVisible() + await expect(viewerPage.previousButton).toBeVisible() + + for (const image of ['image2.jpg', 'image3.jpg', 'image4.jpg']) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(image) + await viewerPage.expectHandler('image') + } + + // Looping from the last image back to the first. + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + }) + + test('navigates backward and loops from the first image to the last', async ({ openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + await viewerPage.previous() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image4.jpg') + }) + + test('closes the viewer when navigating back in the browser', async ({ page, openFile, viewerPage }) => { + await openFile('image1.jpg') + await viewerPage.isVisible() + + await page.goBack() + await expect(viewerPage.container).toBeHidden() + }) +}) diff --git a/tests/playwright/e2e/viewer/oddname.spec.ts b/tests/playwright/e2e/viewer/oddname.spec.ts new file mode 100644 index 0000000000000..bd49c543a9c94 --- /dev/null +++ b/tests/playwright/e2e/viewer/oddname.spec.ts @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { MediaKind } from '../../support/sections/ViewerPage.ts' + +import { expect, test } from '../../support/fixtures/viewer-page.ts' +import { FilesListPage } from '../../support/sections/FilesListPage.ts' +import { ViewerPage } from '../../support/sections/ViewerPage.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createLinkShare } from '../../support/utils/sharing.ts' + +/** + * Build a name aimed at breaking the viewer in case of escaping errors. + * + * @param realName - The original file name + */ +function naughtyFileName(realName: string): string { + const ext = realName.split('.').pop() ?? '' + return ( + '~⛰️ shot of a $[big} mountain`, ' + + "realy #1's " + + '" #_+="%2520%27%22%60%25%21%23 was this called ' + + realName + + 'in the' + + '☁️' + + '👩‍💻' + + '? :* .' + + ext.toUpperCase() + ) +} + +/** + * Build a folder name with special characters around a file name. + * + * @param realName - The original file name + */ +function naughtyFolderName(realName: string): string { + return 'Nextcloud "%27%22%60%25%21%23" >`⛰️<' + realName + "><` e*'rocks!#?#%~" +} + +interface OddCase { + fixture: string + mime: string + kind: MediaKind +} + +const CASES: OddCase[] = [ + { fixture: 'image.png', mime: 'image/png', kind: 'image' }, + { fixture: 'video1.mp4', mime: 'video/mp4', kind: 'video' }, + { fixture: 'audio.mp3', mime: 'audio/mpeg', kind: 'audio' }, +] + +for (const testCase of CASES) { + const placedName = naughtyFileName(testCase.fixture) + const folderName = naughtyFolderName(testCase.fixture) + + test.describe(`Odd file name (${testCase.fixture})`, () => { + test.beforeEach(async ({ page, user, filesListPage, uploadMedia }) => { + await mkdir(page.request, user, `/${folderName}`) + await uploadMedia(testCase.fixture, `/${folderName}/${placedName}`, testCase.mime) + await filesListPage.open() + }) + + test('opens a file with an odd name in the viewer', async ({ filesListPage, openFile, viewerPage }) => { + await openFile(folderName) + await expect(filesListPage.getRowForFile(placedName)).toBeVisible() + + await openFile(placedName) + await viewerPage.waitForOpen() + + expect(await viewerPage.currentName()).toBe(placedName) + await viewerPage.expectHandler(testCase.kind) + + await viewerPage.close() + }) + }) +} + +test.describe('Odd file name (image, sidebar and public share)', () => { + const fixture = 'image.png' + const placedName = naughtyFileName(fixture) + const folderName = naughtyFolderName(fixture) + + test.beforeEach(async ({ page, user, filesListPage, uploadMedia }) => { + await mkdir(page.request, user, `/${folderName}`) + await uploadMedia(fixture, `/${folderName}/${placedName}`, 'image/png') + await filesListPage.open() + }) + + test('opens the sidebar for an odd file name', async ({ page, openFile, viewerPage }) => { + await openFile(folderName) + await openFile(placedName) + await viewerPage.waitForOpen() + + await viewerPage.openSidebar() + + const sidebar = page.locator('aside.app-sidebar') + await expect(sidebar).toBeVisible() + await expect(sidebar.locator('.app-sidebar-header__mainname')).toContainText(placedName) + }) + + test('opens an odd file name from a public link share', async ({ browser, baseURL, playwright, user }) => { + // Seed and share as the owner through a clean basic-auth request context — + // browser session cookies would otherwise win over basic auth. + const ownerRequest = await playwright.request.newContext({ + baseURL, + httpCredentials: { username: user.userId, password: user.password, send: 'always' }, + }) + let share + try { + share = await createLinkShare(ownerRequest, `/${folderName}`) + } finally { + await ownerRequest.dispose() + } + + // Visit the share as an anonymous guest, in a fresh unauthenticated context. + const context = await browser.newContext({ storageState: undefined, baseURL }) + try { + const guestPage = await context.newPage() + await guestPage.goto(share.url) + + const filesListPage = new FilesListPage(guestPage) + const viewerPage = new ViewerPage(guestPage) + + await expect(filesListPage.getRowForFile(placedName)).toBeVisible() + + await filesListPage.getRowNameLinkForFile(placedName).click() + await viewerPage.waitForOpen() + + expect(await viewerPage.currentName()).toBe(placedName) + await viewerPage.expectHandler('image') + } finally { + await context.close() + } + }) +}) diff --git a/tests/playwright/e2e/viewer/sharing.spec.ts b/tests/playwright/e2e/viewer/sharing.spec.ts new file mode 100644 index 0000000000000..8fd9edf08f4d8 --- /dev/null +++ b/tests/playwright/e2e/viewer/sharing.spec.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, publicShareTest as test, uploadMediaFile } from '../../support/fixtures/viewer-page.ts' +import { mkdir } from '../../support/utils/dav.ts' +import { createLinkShare } from '../../support/utils/sharing.ts' + +test.describe('Viewer on public link shares', () => { + test('opens a single shared image in the viewer', async ({ user, ownerRequest, publicShare, viewerPage }) => { + await uploadMediaFile(ownerRequest, user, 'image1.jpg', '/image1.jpg', 'image/jpeg') + const share = await createLinkShare(ownerRequest, '/image1.jpg') + + // A single-file public share opens the viewer automatically. On a public + // share the node is exposed under the share token, so we only assert the + // correct handler renders, not the (token) file name. + await publicShare.open(share.url) + await viewerPage.waitForOpen() + + await viewerPage.expectHandler('image') + }) + + test('opens a single shared video in the viewer', async ({ user, ownerRequest, publicShare, viewerPage }) => { + await uploadMediaFile(ownerRequest, user, 'video1.mp4', '/video1.mp4', 'video/mp4') + const share = await createLinkShare(ownerRequest, '/video1.mp4') + + // A single-file public share opens the viewer automatically. + await publicShare.open(share.url) + await viewerPage.waitForOpen() + + await viewerPage.expectHandler('video') + // The public share serves the video from the public WebDAV endpoint. + await expect(viewerPage.mediaElement('video')) + .toHaveAttribute('src', new RegExp(`/public\\.php/dav/files/${share.token}`)) + }) + + test('navigates through a shared folder of images', async ({ user, ownerRequest, publicShare, filesListPage, viewerPage }) => { + await mkdir(ownerRequest, user, '/Photos') + const images = ['image1.jpg', 'image2.jpg', 'image3.jpg', 'image4.jpg'] + for (const image of images) { + await uploadMediaFile(ownerRequest, user, image, `/Photos/${image}`, 'image/jpeg') + } + const share = await createLinkShare(ownerRequest, '/Photos') + + await publicShare.open(share.url) + await expect(filesListPage.getRowForFile('image1.jpg')).toBeVisible() + + await filesListPage.getRowNameLinkForFile('image1.jpg').click() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('image1.jpg') + + await expect(viewerPage.nextButton).toBeVisible() + await expect(viewerPage.previousButton).toBeVisible() + + for (const image of ['image2.jpg', 'image3.jpg', 'image4.jpg']) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(image) + await viewerPage.expectHandler('image') + } + }) +}) diff --git a/tests/playwright/e2e/viewer/sort.spec.ts b/tests/playwright/e2e/viewer/sort.spec.ts new file mode 100644 index 0000000000000..87a8e361593a4 --- /dev/null +++ b/tests/playwright/e2e/viewer/sort.spec.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '../../support/fixtures/viewer-page.ts' + +// The viewer no longer sorts the list itself; it follows the order the Files +// app provides. These guard nextcloud/viewer#2596 (underscore ordering) and +// nextcloud/viewer#3027 (respect the active sort order). +test.describe('Viewer follows the files list order', () => { + test('steps through underscore-suffixed names in list order', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + // Files app natural order is name.jpg, name_1.jpg, name_2.jpg, name_3.jpg. + const names = ['name.jpg', 'name_1.jpg', 'name_2.jpg', 'name_3.jpg'] + for (const name of names) { + await uploadMedia('image1.jpg', name, 'image/jpeg') + } + await filesListPage.open() + + await openFile('name.jpg') + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe('name.jpg') + + for (const name of names.slice(1)) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name) + } + }) + + test('steps through a folder sorted by modification time, not by name', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + // Names ascend a, b, c while the modification times do not, so a + // viewer ordering the list itself by name shows a different file + const day = 24 * 60 * 60 + const now = Date.now() / 1000 + await uploadMedia('image1.jpg', 'a.jpg', 'image/jpeg', now - day) + await uploadMedia('image1.jpg', 'b.jpg', 'image/jpeg', now - day * 3) + await uploadMedia('image1.jpg', 'c.jpg', 'image/jpeg', now - day * 2) + await filesListPage.open() + + await filesListPage.sortByColumn('Modified') + await expect(filesListPage.getColumnHeader('Modified')).toHaveAttribute('aria-sort', 'ascending') + + // Whatever the list shows is what the viewer has to follow, so read + // the order rather than assuming which end welcome.txt lands on + const listed = (await filesListPage.getRowNames()).filter((name) => name.endsWith('.jpg')) + // The sort has to disagree with the name order, or this cannot tell + // a viewer following the list from one sorting by name itself + expect(listed).not.toEqual([...listed].sort()) + + await openFile(listed[0]!) + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(listed[0]) + + for (const name of listed.slice(1)) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name) + } + }) + + test('steps backwards through a descending sort', async ({ filesListPage, uploadMedia, openFile, viewerPage }) => { + for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) { + await uploadMedia('image1.jpg', name, 'image/jpeg') + } + await filesListPage.open() + + // Reversing the name order is the same list read the other way, so + // a viewer sorting by name on its own walks it backwards + await filesListPage.sortByColumn('Name') + await expect(filesListPage.getColumnHeader('Name')).toHaveAttribute('aria-sort', 'descending') + + const listed = (await filesListPage.getRowNames()).filter((name) => name.endsWith('.jpg')) + expect(listed).toEqual(['c.jpg', 'b.jpg', 'a.jpg']) + + await openFile(listed[0]!) + await viewerPage.waitForOpen() + + for (const name of listed.slice(1)) { + await viewerPage.next() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name) + } + + // And back up the same way + for (const name of [...listed].reverse().slice(1)) { + await viewerPage.previous() + await viewerPage.waitForOpen() + expect(await viewerPage.currentName()).toBe(name) + } + }) +}) diff --git a/tests/playwright/e2e/viewer/videos.spec.ts b/tests/playwright/e2e/viewer/videos.spec.ts new file mode 100644 index 0000000000000..b7f3c6fb93e7a --- /dev/null +++ b/tests/playwright/e2e/viewer/videos.spec.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createMediaViewerTests, VIDEO_MEDIA } from './media.ts' + +createMediaViewerTests(VIDEO_MEDIA) diff --git a/tests/playwright/start-nextcloud-server.js b/tests/playwright/start-nextcloud-server.js index e3ec044d9a57e..cb11f51ac6226 100644 --- a/tests/playwright/start-nextcloud-server.js +++ b/tests/playwright/start-nextcloud-server.js @@ -50,7 +50,9 @@ async function start() { } await waitOnNextcloud(ip) - await configureNextcloud(process.env.PLAYWRIGHT_SETUP ? [] : ['viewer']) + // The viewer used to be fetched from its own repository; it now ships with + // the server and is enabled on install, so no app has to be provisioned. + await configureNextcloud([]) if (process.env.PLAYWRIGHT_SETUP) { // When the apps folder is mounted, configureNextcloud writes an diff --git a/tests/playwright/support/fixtures/viewer-page.ts b/tests/playwright/support/fixtures/viewer-page.ts new file mode 100644 index 0000000000000..621038601954f --- /dev/null +++ b/tests/playwright/support/fixtures/viewer-page.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext } from '@playwright/test' + +import { mergeTests } from '@playwright/test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { ViewerPage } from '../sections/ViewerPage.ts' +import { uploadContent } from '../utils/dav.ts' +import { test as filesTest } from './files-page.ts' +import { test as sharingTestBase } from './files-sharing-page.ts' +import { test as publicShareTestBase } from './public-share-page.ts' + +/** + * The absolute path of a media fixture, from the shared `tests/data/media`. + * + * @param name - The fixture file name (e.g. `image1.jpg`) + */ +export function mediaFixturePath(name: string): string { + return fileURLToPath(new URL(`../../../data/media/${name}`, import.meta.url)) +} + +/** + * Upload a bundled media fixture to the user's WebDAV root. + * + * @param request - The request context to upload with (authenticated as `user`) + * @param user - The user owning the file + * @param fixture - The fixture file name to read from `tests/data/media` + * @param remoteName - The remote name/path relative to the user root (defaults to the fixture name) + * @param mimeType - The content type sent with the upload + * @param mtime - Modification time in seconds, to control the sort order + */ +export async function uploadMediaFile( + request: APIRequestContext, + user: User, + fixture: string, + remoteName: string = fixture, + mimeType: string = 'application/octet-stream', + mtime?: number, +): Promise { + const path = remoteName.startsWith('/') ? remoteName : `/${remoteName}` + await uploadContent(request, user, readFileSync(mediaFixturePath(fixture)), mimeType, path, mtime) +} + +interface ViewerFixtures { + /** Page object for the viewer modal. */ + viewerPage: ViewerPage + /** Upload a bundled media fixture to the logged-in user's root. */ + uploadMedia: (fixture: string, remoteName?: string, mimeType?: string, mtime?: number) => Promise + /** Open a file (or folder) by clicking its name link, triggering its default action. */ + openFile: (name: string) => Promise +} + +/** + * The Files fixtures (logged-in random `user`, `filesListPage`) plus the viewer + * page object and helpers to upload media and open files into the viewer. + */ +export const test = mergeTests(filesTest).extend({ + viewerPage: async ({ page }, use) => { + await use(new ViewerPage(page)) + }, + + uploadMedia: async ({ page, user }, use) => { + await use((fixture, remoteName, mimeType, mtime) => uploadMediaFile(page.request, user, fixture, remoteName, mimeType, mtime)) + }, + + openFile: async ({ filesListPage }, use) => { + await use((name) => filesListPage.getRowNameLinkForFile(name).click()) + }, +}) + +/** + * The public-share fixtures (guest `page`, share `owner` request) plus the + * viewer page object — for viewer behaviour on public link shares. + */ +export const publicShareTest = mergeTests(publicShareTestBase).extend<{ viewerPage: ViewerPage }>({ + viewerPage: async ({ page }, use) => { + await use(new ViewerPage(page)) + }, +}) + +/** + * The user-share fixtures (`page` logged in as the recipient `user`, `owner` + * request context to seed and share) plus the viewer page object — for viewer + * behaviour on files shared with the current user. + */ +export const sharingTest = mergeTests(sharingTestBase).extend<{ viewerPage: ViewerPage }>({ + viewerPage: async ({ page }, use) => { + await use(new ViewerPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/ViewerPage.ts b/tests/playwright/support/sections/ViewerPage.ts new file mode 100644 index 0000000000000..ebd910b30ff17 --- /dev/null +++ b/tests/playwright/support/sections/ViewerPage.ts @@ -0,0 +1,208 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' + +/** Media kinds handled by the viewer and their custom element tag / inner media tag. */ +export type MediaKind = 'image' | 'video' | 'audio' + +const HANDLER_TAG: Record = { + image: 'oca-viewer-image', + video: 'oca-viewer-video', + audio: 'oca-viewer-audio', +} + +const MEDIA_ELEMENT: Record = { + image: 'img', + video: 'video', + audio: 'audio', +} + +/** + * Page object for the viewer modal. + * + * The viewer mounts a `
` on the body, inside which an NcModal + * renders with the `viewer__modal` class. The active handler is rendered as a + * custom element (`oca-viewer-image`, `oca-viewer-video`, `oca-viewer-audio`). + */ +export class ViewerPage { + public readonly root: Locator + public readonly modal: Locator + public readonly container: Locator + public readonly content: Locator + public readonly headerName: Locator + public readonly loading: Locator + public readonly errorMessage: Locator + public readonly closeButton: Locator + public readonly nextButton: Locator + public readonly previousButton: Locator + + constructor(public readonly page: Page) { + // NcModal teleports its content to the document body, so the modal is not + // under the #viewer mount point — match it directly by its class + role. + this.root = page.locator('.viewer__modal') + this.modal = this.root + this.container = this.modal.locator('.modal-container') + this.content = this.modal.locator('.modal-container__content') + this.headerName = this.modal.locator('.modal-header__name') + this.loading = this.modal.locator('.viewer__loading') + this.errorMessage = this.modal.locator('.empty-content__name') + this.closeButton = this.modal.getByRole('button', { name: 'Close' }) + this.nextButton = this.modal.getByRole('button', { name: 'Next' }) + this.previousButton = this.modal.getByRole('button', { name: 'Previous' }) + } + + /** + * Click the modal content background (outside the media), which closes the + * viewer. Clicks near a corner so the centered media is never hit. + */ + public async clickOutside(): Promise { + await this.content.click({ position: { x: 8, y: 8 } }) + } + + /** + * Wait for the viewer modal to be gone. + */ + public async waitForClosed(): Promise { + await expect(this.container).toBeHidden() + } + + /** + * Assert the viewer shows an error message and is not stuck loading. + */ + public async expectError(): Promise { + await expect(this.errorMessage).toBeVisible() + await expect(this.loading).toHaveCount(0) + } + + /** + * The custom element for the given media kind. + * + * @param kind - The media kind + */ + public handlerTag(kind: MediaKind): Locator { + return this.container.locator(HANDLER_TAG[kind]) + } + + /** + * The inner media element (img/video/audio) for the given media kind. + * + * @param kind - The media kind + */ + public mediaElement(kind: MediaKind): Locator { + return this.handlerTag(kind).locator(MEDIA_ELEMENT[kind]) + } + + /** + * Assert the viewer modal is visible. + */ + public async isVisible(): Promise { + await expect(this.container).toBeVisible() + } + + /** + * Wait for the viewer to be open and done loading (spinner gone). + */ + public async waitForOpen(): Promise { + await expect(this.container).toBeVisible() + // Fetching and decoding the media is the slowest thing the viewer does, + // and the shared CI runners are slower at it than the default budget + // allows. The spinner going away is the signal, not how fast. + await expect(this.loading).toHaveCount(0, { timeout: 20_000 }) + } + + /** + * The basename of the file currently shown in the header. + */ + public async currentName(): Promise { + return (await this.headerName.textContent())?.trim() ?? '' + } + + /** + * Assert the active handler and its media element are visible for the given kind. + * + * @param kind - The media kind + */ + public async expectHandler(kind: MediaKind): Promise { + await expect(this.handlerTag(kind)).toBeVisible() + await expect(this.mediaElement(kind)).toHaveAttribute('src', /.+/) + } + + /** + * Navigate to the next file in the slideshow. + */ + public async next(): Promise { + await this.nextButton.click() + } + + /** + * Navigate to the previous file in the slideshow. + */ + public async previous(): Promise { + await this.previousButton.click() + } + + /** + * Close the viewer modal. + */ + public async close(): Promise { + await this.closeButton.click() + await expect(this.container).toBeHidden() + // Closing unwinds the history entries the viewer pushed, and the browser + // applies that asynchronously. Wait for it so a following assertion, or a + // following open, does not race the navigation still on its way. + await this.page.waitForURL((url) => !url.searchParams.has('openfile')) + } + + /** + * The header actions menu toggle, if the actions are collapsed into a menu. + */ + public actionsToggle(): Locator { + return this.modal.getByRole('button', { name: /Actions|Open actions menu/i }) + } + + /** + * Run a header action by its label, opening the actions menu if needed. + * + * @param name - The action label (or a matching regexp) + */ + public async runAction(name: string | RegExp): Promise { + const direct = this.modal.getByRole('button', { name }) + if (!(await direct.isVisible())) { + const toggle = this.actionsToggle() + if (await toggle.isVisible()) { + await toggle.click() + } + } + await this.page.getByRole('menuitem', { name }) + .or(direct) + .first() + .click() + } + + /** + * Open the Files sidebar from the viewer header actions. + * The action emits `viewer:sidebar:open`, which the Files app listens to. + */ + public async openSidebar(): Promise { + // The header actions collapse into a menu; the "Open sidebar" entry is a + // menuitem there. Open the menu first if the entry is not already shown. + const directButton = this.modal.getByRole('button', { name: 'Open sidebar' }) + const toggle = this.actionsToggle() + // Both checks below are instant, so wait for the header to render one of + // the two entry points first - it is still loading when opened from a + // slow preview. + await expect(directButton.or(toggle).first()).toBeVisible() + if (!(await directButton.isVisible())) { + await toggle.click() + } + await this.page.getByRole('menuitem', { name: 'Open sidebar' }) + .or(directButton) + .first() + .click() + } +}