Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5d6d9df
feat(screenshots): add Cypress screenshot automation infrastructure
miaulalala May 4, 2026
f6792f6
feat(screenshots): add proof-of-concept Files spec (12/12 passing)
miaulalala May 4, 2026
79a91fe
feat(screenshots): add MariaDB→SQLite converter and seed data injection
miaulalala May 4, 2026
ac2fd63
fix(screenshots): make seed DB compatible with NC 33
miaulalala May 4, 2026
a3f5395
refactor(screenshots): replace seed DB injection with OCS/WebDAV prov…
miaulalala May 5, 2026
258ab51
chore(screenshots): output to ~/Pictures/Screenshots/nextcloud-docs
miaulalala May 5, 2026
25b094f
feat(screenshots): provision realistic files for Christine user
miaulalala May 5, 2026
0eecbac
fix(screenshots): set realistic mtimes on provisioned files via X-OC-…
miaulalala May 5, 2026
e603cb4
feat(screenshots): compress screenshots with pngquant after each run
miaulalala May 5, 2026
0dc81d7
fixup! feat(screenshots): compress screenshots with pngquant after ea…
miaulalala May 5, 2026
8b3c4ab
fix(screenshots): crop breadcrumb screenshot to the breadcrumb element
miaulalala May 5, 2026
c16f20c
fix(screenshots): click Actions menu for files_page-3, no hover needed
miaulalala May 5, 2026
0419c59
fix(screenshots): enable files_versions app for details sidebar scree…
miaulalala May 5, 2026
b8ea8a1
fix(screenshots): remove focus ring from grid view button in files_pa…
miaulalala May 5, 2026
33a3b05
fix(screenshots): create file version so Versions tab appears in sidebar
miaulalala May 5, 2026
c5b5956
feat(screenshots): add files_page-9 — multi-file selection with bulk …
miaulalala May 5, 2026
7970db0
feat(screenshots): add files_sharing_status — sharing badge and link …
miaulalala May 5, 2026
dee3904
fixup! feat(screenshots): add files_sharing_status — sharing badge an…
miaulalala May 5, 2026
c1dcf9a
fixup! fixup! feat(screenshots): add files_sharing_status — sharing b…
miaulalala May 5, 2026
99c0782
feat(screenshots): add webinterface spec (login, dashboard, nav, prof…
miaulalala May 5, 2026
9dcdba0
feat(screenshots): add avatar provisioning for seeded users
miaulalala May 5, 2026
a2a0170
feat(screenshots): enable groupware apps and populate dashboard for c…
miaulalala May 5, 2026
b2f6080
fix(screenshots): bypass NC 33 browser compatibility check via CDP
miaulalala May 5, 2026
afb8d22
fix(screenshots): switch to Chromium browser and hide scrollbars
miaulalala May 5, 2026
f9b01a6
feat(screenshots): add Customize button element screenshot
miaulalala May 5, 2026
04d0030
feat(screenshots): add unified search modal screenshot
miaulalala May 5, 2026
af4e940
feat(screenshots): drop tasks widget to expose Customize button in da…
miaulalala May 5, 2026
96aafc5
feat(screenshots): add Talk specs and amara_w seed user
miaulalala May 5, 2026
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,11 @@ Pipfile.lock

# ack-grep
.ackrc

# Screenshot automation
node_modules/
cypress/snapshots/
cypress/videos/
cypress/downloads/
screenshot-inventory.json
cypress/fixtures/pdfs/
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20
19 changes: 19 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
all: html pdf

# Screenshot automation
# ---------------------
# Prerequisites: Node.js >=20, Docker (for the Nextcloud stable33 container)
# Optional: pngquant (for automatic PNG compression after sync)

screenshot-inventory:
python3 scripts/inventory.py

screenshots: screenshot-inventory
npm run screenshots
bash scripts/sync.sh

screenshots-dry: screenshot-inventory
npm run screenshots
bash scripts/sync.sh --dry-run

screenshot-install:
npm install

html: admin-manual-html user-manual-html developer-manual-html
pdf: admin-manual-pdf user-manual-pdf

Expand Down
150 changes: 150 additions & 0 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
configureNextcloud,
startNextcloud,
stopNextcloud,
waitOnNextcloud,
} from '@nextcloud/cypress/docker'
import { defineConfig } from 'cypress'
import { execSync } from 'child_process'
import { readFileSync } from 'fs'
import * as path from 'path'

// Port exposed to the host for the screenshot container.
const SCREENSHOT_PORT = 8093

// Apps to enable. Must be in @nextcloud/cypress VENDOR_APPS (activity, viewer,
// notifications, text) or bundled in the server image. Anything else triggers
// occ app:install which requires app-store access and is slow.
const CONTAINER_NAME = `nextcloud-cypress-tests_${path.basename(process.cwd())}`

function occ(cmd: string, env: Record<string, string> = {}): string {
const envFlags = Object.entries(env).map(([k, v]) => `-e ${k}=${v}`).join(' ')
return execSync(`docker exec -u www-data ${envFlags} ${CONTAINER_NAME} php /var/www/html/occ ${cmd}`, { encoding: 'utf8' })
}

const SCREENSHOT_APPS = [
'activity',
'calendar',
'comments',
'deck',
'files_versions',
'notes',
'notifications',
'spreed',
'tasks',
'viewer',
]

export default defineConfig({
// 16:10, common laptop resolution — enough height for dashboard widgets + Customise button
viewportWidth: 1440,
viewportHeight: 900,

requestTimeout: 20000,
defaultCommandTimeout: 10000,

retries: {
runMode: 1,
openMode: 0,
},

video: false,

screenshotsFolder: `${process.env.HOME}/Pictures/Screenshots/nextcloud-docs`,
trashAssetsBeforeRuns: true,

e2e: {
async setupNodeEvents(on, config) {
on('before:browser:launch', (browser, launchOptions) => {
if (browser.family === 'chromium' && browser.name !== 'electron') {
launchOptions.preferences.default['browser.enable_spellchecking'] = false
// Force the window to the configured viewport size.
// Headless Chromium ignores viewportWidth/Height from config unless
// --window-size is passed explicitly.
launchOptions.args.push('--window-size=1440,987')
return launchOptions
}
if (browser.family === 'firefox') {
launchOptions.preferences['layout.spellcheckDefault'] = 0
return launchOptions
}
if (browser.name === 'electron') {
launchOptions.preferences.spellcheck = false
// Set the Electron window size — viewportWidth/Height alone isn't
// respected in headless Electron; the BrowserWindow must be sized explicitly.
launchOptions.preferences.width = 1440
launchOptions.preferences.height = 900
return launchOptions
}
})

on('task', {
occ: ({ cmd, env = {} }: { cmd: string, env?: Record<string, string> }) => occ(cmd, env),

// Upload a local file to WebDAV in Node.js to avoid Cypress IPC
// serialising Buffer objects as JSON (which breaks binary bodies).
async uploadFile({ src, dest, user, password, mtime }: { src: string, dest: string, user: string, password: string, mtime?: number }) {
const content = readFileSync(src)
const credentials = Buffer.from(`${user}:${password}`).toString('base64')
const url = `http://localhost:${SCREENSHOT_PORT}/remote.php/dav/files/${user}/${dest}`
const headers: Record<string, string> = { Authorization: `Basic ${credentials}` }
if (mtime) headers['X-OC-MTime'] = String(mtime)
const res = await fetch(url, { method: 'PUT', headers, body: content })
return res.status
},

async mkdavCol({ dest, user, password }: { dest: string, user: string, password: string }) {
const credentials = Buffer.from(`${user}:${password}`).toString('base64')
const url = `http://localhost:${SCREENSHOT_PORT}/remote.php/dav/files/${user}/${dest}`
const res = await fetch(url, {
method: 'MKCOL',
headers: { Authorization: `Basic ${credentials}` },
})
return res.status
},

async uploadAvatar({ src, user, password }: { src: string, user: string, password: string }) {
const content = readFileSync(src)
const credentials = Buffer.from(`${user}:${password}`).toString('base64')
const boundary = `----AvatarBoundary${Date.now()}`
const body = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files[]"; filename="avatar.png"\r\nContent-Type: image/png\r\n\r\n`),
content,
Buffer.from(`\r\n--${boundary}--\r\n`),
])
const res = await fetch(`http://localhost:${SCREENSHOT_PORT}/index.php/avatar`, {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'OCS-APIREQUEST': 'true',
},
body,
})
return res.status
},
})

on('after:run', async () => {
try {
const { default: pngquantBin } = await import('pngquant-bin')
execSync(
`find "${process.env.HOME}/Pictures/Screenshots/nextcloud-docs" -name '*.png'` +
` -exec "${pngquantBin}" --quality=70-85 --force --ext .png --strip {} \\;`,
{ stdio: 'inherit' },
)
} catch {
console.warn('pngquant failed — screenshots not compressed')
}
stopNextcloud()
})

await startNextcloud('stable33', false, { exposePort: SCREENSHOT_PORT })
config.baseUrl = `http://localhost:${SCREENSHOT_PORT}/index.php`
await waitOnNextcloud(`localhost:${SCREENSHOT_PORT}`)
await configureNextcloud(SCREENSHOT_APPS)

return config
},
},
})
53 changes: 53 additions & 0 deletions cypress/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

/**
* Take a named screenshot for documentation.
*
* Name should mirror the destination path relative to the manual root,
* e.g. 'user/files/sharing-dialog' → synced to user_manual/files/images/sharing-dialog.png
*
* The sync script (scripts/sync.sh) reads screenshot-inventory.json to map
* these names to their RST image directive targets.
*/
/** Inject CSS to strip focus outlines and scrollbars before capturing. */
function suppressFocusRings(): void {
cy.document().then((doc) => {
const style = doc.createElement('style')
style.setAttribute('data-doc-screenshot', '')
style.textContent = [
'*:focus, *:focus-visible { outline: none !important; }',
'::-webkit-scrollbar { display: none !important; }',
'* { scrollbar-width: none !important; }',
].join('\n')
doc.head.appendChild(style)
})
}

export function docScreenshot(name: string, options: Partial<Cypress.ScreenshotOptions> = {}): void {
suppressFocusRings()
// Let animations, loaders, and toasts settle before capturing
cy.wait(500)
cy.screenshot(name, {
capture: 'viewport',
overwrite: true,
...options,
})
}

/**
* Take a screenshot of a specific element only.
* Useful for dialogs, sidebars, or settings panels.
*/
export function docElementScreenshot(
selector: string,
name: string,
options: Partial<Cypress.ScreenshotOptions> = {},
): void {
suppressFocusRings()
cy.wait(500)
cy.get(selector).should('be.visible').screenshot(name, {
overwrite: true,
...options,
})
}
Loading
Loading