diff --git a/.gitignore b/.gitignore index fd5ab2e47b..f37fc0b6d3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,6 @@ cypress/screenshots/ .vscode/ *.code-workspace +## Local dev environment +.playwright-mcp/ cypress/downloads/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..230ae129f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ + +# Agent notes for richdocuments + +Nextcloud Office: the Nextcloud app that embeds Collabora Online. PHP backend in `lib/`, Vue 3 frontend in `src/` built into `js/` with webpack. + +## Checks to run after a change + +```sh +npm ci && composer install # once +npm run build # or `npm run dev` for an unminified build +npm run lint +composer run cs:check +composer run psalm +``` + +PHPUnit (`composer run test:unit`) and the Cypress e2e suite need a full server setup and run in CI. + +## Local dev environment + +`npm run dev:server` starts a throwaway Nextcloud (server `master`, override with `BRANCH=stable35`) with this checkout mounted, plus a Collabora container. It needs Docker and Composer (it installs the PHP dependencies without dev packages into a separate folder for the container, so your own `vendor/` keeps the dev tools) and takes a few minutes on first start; later runs reuse the running Nextcloud container. Stop both with `npm run dev:server:stop`. + +- Nextcloud: http://localhost:8081, login `admin` / `admin` +- Collabora: http://localhost:9980 + +The checkout is mounted read-only into the container, so build the frontend locally (`npm run dev` or `npm run watch`) and reload the page. Static files are served with `Cache-Control: no-cache`, so a reload always picks up the new build. PHP changes apply on the next request. Nextcloud runs with `debug` enabled. + +Upload a test document over WebDAV: + +```sh +curl -u admin:admin -T tests/data/form.odt http://localhost:8081/remote.php/dav/files/admin/form.odt +``` + +Run occ commands in the container (named after the checkout folder, `docker ps` shows it): + +```sh +docker exec -u www-data nextcloud-e2e-test-server_ php occ richdocuments:activate-config +``` + +## Verifying changes in the browser + +Use the Playwright MCP against the dev environment to confirm a frontend change actually works: + +1. Log in at http://localhost:8081/index.php/login. +2. Open a document from the Files app, e.g. `http://localhost:8081/index.php/apps/files/files/?dir=/&openfile=true`. +3. Check the console messages for errors and take a screenshot. + +Things to know: + +- The document is rendered inside Collabora's cross-origin iframe. Its text is not visible to `wait_for`; wait a fixed time (around 20 seconds) and use a screenshot instead. Controls inside the iframe, like "Close document", are reachable through the snapshot. +- The editor is mounted by `@nextcloud/viewer` as the `` custom element inside the viewer modal. `browser_evaluate` on that element is the quickest way to debug layout. +- Collabora calls back to Nextcloud through `host.docker.internal:8081`, not the browser's `localhost:8081`, so the settings iframe on the personal settings page logs `postMessage` origin warnings. Those come from the dev setup. +- Playwright MCP writes snapshots and screenshots to `.playwright-mcp/` (gitignored). diff --git a/package.json b/package.json index fc9ab1e8b1..0a15f4de77 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "lint:fix": "eslint --ext .js,.vue,.ts,.tsx src --fix", "lint:cypress": "eslint --ext .js cypress", "stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css css/*.scss", - "stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css css/*.scss --fix" + "stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css css/*.scss --fix", + "dev:server": "node scripts/dev-server.mjs start", + "dev:server:stop": "node scripts/dev-server.mjs stop" }, "dependencies": { "@nextcloud/auth": "^2.6.0", diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs new file mode 100644 index 0000000000..d33786408c --- /dev/null +++ b/scripts/dev-server.mjs @@ -0,0 +1,100 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Starts a throwaway Nextcloud with this checkout mounted, plus Collabora. +// Used for local browser testing and as the target of the Cypress e2e tests. +// +// node scripts/dev-server.mjs start (BRANCH=master by default) +// node scripts/dev-server.mjs stop + +import { execFileSync } from 'node:child_process' +import { copyFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { configureNextcloud, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server/docker' + +const NEXTCLOUD_PORT = 8081 +const COLLABORA_PORT = 9980 +const COLLABORA_CONTAINER = 'richdocuments-dev-collabora' +const PRODUCTION_COMPOSER = join(tmpdir(), 'richdocuments-dev-composer') +const COLLABORA_IMAGE = process.env.COLLABORA_IMAGE ?? 'collabora/code:latest' +// How the containers reach each other through the host's published ports +const DOCKER_HOST = process.env.DOCKER_HOST_ADDRESS ?? (process.platform === 'linux' ? '172.17.0.1' : 'host.docker.internal') + +const nextcloudUrl = `http://localhost:${NEXTCLOUD_PORT}` +const internalNextcloudUrl = `http://${DOCKER_HOST}:${NEXTCLOUD_PORT}` +const collaboraUrl = `http://localhost:${COLLABORA_PORT}` +const internalCollaboraUrl = `http://${DOCKER_HOST}:${COLLABORA_PORT}` + +const docker = (...args) => execFileSync('docker', args, { stdio: 'inherit' }) + +function startCollabora() { + execFileSync('docker', ['rm', '-f', COLLABORA_CONTAINER], { stdio: 'ignore' }) + docker('run', '-d', '--name', COLLABORA_CONTAINER, + '-p', `${COLLABORA_PORT}:9980`, + '--cap-add', 'SYS_ADMIN', '--cap-add', 'SYS_CHROOT', + '-e', 'extra_params=--o:ssl.enable=false --o:home_mode.enable=true', + '-e', `aliasgroup1=${internalNextcloudUrl}`, + // Discovery reports this host to the browser, whichever host Nextcloud used to fetch it + '-e', `server_name=localhost:${COLLABORA_PORT}`, + COLLABORA_IMAGE) +} + +async function waitOnCollabora() { + for (let attempt = 0; attempt < 60; attempt++) { + try { + if ((await fetch(`${collaboraUrl}/hosting/discovery`)).ok) { + return + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + throw new Error(`Collabora did not come up on ${collaboraUrl}`) +} + +async function start() { + startCollabora() + // The server gets a vendor without dev dependencies, whose OCP stubs would shadow its own classes. + // Built next to a copy of the composer files so the autoloader paths stay relative to the app. + mkdirSync(PRODUCTION_COMPOSER, { recursive: true }) + for (const file of ['composer.json', 'composer.lock']) { + copyFileSync(file, join(PRODUCTION_COMPOSER, file)) + } + execFileSync('composer', ['install', '--no-dev', '--no-interaction', '--quiet', '--working-dir', PRODUCTION_COMPOSER], { stdio: 'inherit' }) + const mounts = { 'apps-writable/richdocuments/vendor': join(PRODUCTION_COMPOSER, 'vendor') } + await startNextcloud(process.env.BRANCH ?? 'master', true, { exposePort: NEXTCLOUD_PORT, mounts }) + await waitOnNextcloud('localhost:' + NEXTCLOUD_PORT) + await configureNextcloud(['files_pdfviewer']) + + const occ = (...args) => runOcc(args, { verbose: true }) + await occ('config:system:set', 'trusted_domains', '1', '--value', 'localhost') + await occ('config:system:set', 'trusted_domains', '2', '--value', DOCKER_HOST) + await occ('config:system:set', 'allow_local_remote_servers', '--value', 'true', '--type', 'bool') + await occ('config:system:set', 'debug', '--value', 'true', '--type', 'bool') + await occ('app:enable', '--force', 'richdocuments') + await waitOnCollabora() + await occ('richdocuments:activate-config', '--wopi-url', internalCollaboraUrl, '--callback-url', internalNextcloudUrl) + + // Let a rebuild show up on reload, and drop the web server's APCu copy of discovery + await runExec(['sed', '-i', 's/Header set Cache-Control "max-age=15778463[^"]*"/Header set Cache-Control "no-cache"/', '/var/www/html/.htaccess'], { user: 'root' }) + await runExec(['apache2ctl', 'graceful'], { user: 'root', failOnError: false }) + + console.log(`\nReady: ${nextcloudUrl} (admin / admin)\nRebuild with "npm run watch", then reload the page.`) +} + +async function stop() { + execFileSync('docker', ['rm', '-f', COLLABORA_CONTAINER], { stdio: 'ignore' }) + await stopNextcloud() +} + +const command = process.argv[2] +if (command === 'start') { + await start() +} else if (command === 'stop') { + await stop() +} else { + console.error('Usage: node scripts/dev-server.mjs start|stop') + process.exit(1) +}