Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ cypress/screenshots/
.vscode/
*.code-workspace

## Local dev environment
.playwright-mcp/
cypress/downloads/
55 changes: 55 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
# 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_<folder> 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/<fileid>?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 `<richdocuments-viewer>` 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).
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 100 additions & 0 deletions scripts/dev-server.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
Loading