diff --git a/README.md b/README.md index 6280d47c..a5d05b34 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,29 @@ export default defineConfig({ }) ``` +## Getting the server log + +The server's `data` directory is mounted as a tmpfs and the container is removed after the run, +so `data/nextcloud.log` is gone once the tests have finished. +To keep it, save it while the container still exists — either by passing `saveLogTo` to `stopNextcloud`, +or by setting the `NEXTCLOUD_E2E_LOG_FILE` environment variable (both take a path on your machine, relative paths are resolved from the current working directory): + +```js +import { stopNextcloud } from '@nextcloud/e2e-test-server' + +// Writes `data/nextcloud.log` to `cypress/logs/nextcloud.log`, then removes the container +await stopNextcloud({ saveLogTo: 'cypress/logs/nextcloud.log' }) +``` + +If you need the log during a run — e.g. to attach it to a failing test — use `getNextcloudLog()`, +which resolves with the log contents, or `saveNextcloudLog(path)` to write it out. + +```js +import { getNextcloudLog } from '@nextcloud/e2e-test-server' + +const log = await getNextcloudLog() +``` + ## Cypress commands You can import individual commands or all at once diff --git a/cypress.config.ts b/cypress.config.ts index 3f3be67a..5251686f 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -40,7 +40,8 @@ export default defineConfig({ // Remove container after run on('after:run', async () => { - await stopNextcloud() + // The data directory is a tmpfs, so grab the server log before the container goes away + await stopNextcloud({ saveLogTo: 'cypress/logs/nextcloud.log' }) await docker.getVolume('apps_writable').remove() }) diff --git a/lib/docker.ts b/lib/docker.ts index 4fc4921c..e278b51b 100644 --- a/lib/docker.ts +++ b/lib/docker.ts @@ -5,12 +5,14 @@ import type { Container } from 'dockerode' import type { Stream } from 'stream' +import type { Extract, Pack } from 'tar-stream' import Docker from 'dockerode' import { XMLParser } from 'fast-xml-parser' -import { existsSync, readFileSync } from 'fs' -import { basename, join, resolve, sep } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { basename, dirname, join, resolve, sep } from 'path' import { PassThrough } from 'stream' +import { pipeline } from 'stream/promises' import tarStreamer from 'tar-stream' import waitOn from 'wait-on' import { User } from './User.ts' @@ -23,6 +25,9 @@ const COMPOSER_PHAR = '/tmp/composer.phar' /** `COMPOSER_HOME` used inside the server container, must be writable by `www-data` */ const COMPOSER_HOME = '/tmp/composer-home' +/** Path of the server log inside the container, it lives on the tmpfs mounted data directory */ +const NEXTCLOUD_LOG = '/var/www/html/data/nextcloud.log' + export const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET ?? '/var/run/docker.sock' }) // Store the container name, different names are used to prevent conflicts when testing multiple apps locally @@ -291,7 +296,7 @@ export async function configureNextcloud(apps = ['viewer'], vendoredBranch?: str const stream = tarStreamer.pack() stream.entry({ name: 'apps.config.php' }, appsConfig) stream.finalize() - await container.putArchive(stream, { path: '/var/www/html/config' }) + await container.putArchive(asNodeStream(stream), { path: '/var/www/html/config' }) // Build app list, only now that "apps-writable" is a known apps path so that mounted apps show up const { stdout: json } = await runOcc(['app:list', '--output', 'json'], { container }) @@ -418,12 +423,85 @@ export async function restoreSnapshot(snapshot = 'init', container?: Container) console.log('└─ Done') } +/** + * Read the server log (`data/nextcloud.log`) from the container. + * + * The data directory is a tmpfs and the container is removed after the run, + * so the log has to be fetched while the container still exists. + * + * @param container Optional server container to use (defaults to current container) + * @return The log contents, or an empty string if the server has not written a log + */ +export async function getNextcloudLog(container?: Container): Promise { + container = container ?? getContainer() + + let archive: NodeJS.ReadableStream + try { + archive = await container.getArchive({ path: NEXTCLOUD_LOG }) + } catch { + // No log written (yet), or the container is already gone + return '' + } + + // `getArchive` always answers with a tar stream, containing the single log entry + const extract = tarStreamer.extract() + const chunks: Buffer[] = [] + extract.on('entry', (_header, stream, next) => { + stream.on('data', (chunk) => chunks.push(chunk as Buffer)) + stream.on('end', () => next()) + }) + await pipeline(archive, asNodeStream(extract)) + + return Buffer.concat(chunks).toString('utf8') +} + +/** + * Save the server log (`data/nextcloud.log`) from the container to a local file. + * + * Must be called before {@link stopNextcloud}, the log is lost with the container. + * + * @param targetPath Local path to write the log to (default 'nextcloud.log' in the current directory) + * @param container Optional server container to use (defaults to current container) + * @return Whether a log was found and written + */ +export async function saveNextcloudLog(targetPath = 'nextcloud.log', container?: Container): Promise { + const log = await getNextcloudLog(container) + if (log === '') { + console.log('└─ No server log found in the container') + return false + } + + const target = resolve(targetPath) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, log) + console.log(`└─ Server log saved to ${target} 📝`) + return true +} + +interface StopOptions { + /** + * Local path to save the server log (`data/nextcloud.log`) to before the container is removed. + * + * @default process.env.NEXTCLOUD_E2E_LOG_FILE (disabled if unset) + */ + saveLogTo?: string +} + /** * Force stop the testing container + * + * @param options Optional parameters to configure the container removal */ -export async function stopNextcloud() { +export async function stopNextcloud(options: StopOptions = {}) { try { const container = getContainer() + + const logTarget = options.saveLogTo ?? process.env.NEXTCLOUD_E2E_LOG_FILE + if (logTarget) { + console.log('\nSaving Nextcloud server log…') + await saveNextcloudLog(logTarget, container) + } + console.log('Stopping Nextcloud container…') await container.remove({ force: true }) console.log('└─ Nextcloud container removed 🥀') @@ -693,6 +771,24 @@ export function addUser(user: User, { container, env = [], verbose = false }: Pa ) } +/** + * Present a `tar-stream` stream as the Node.js stream its consumers expect. + * + * `tar-stream` is typed as the `streamx` streams it is built on. Those behave + * like Node.js streams at runtime, but are not structurally assignable to them, so both + * `dockerode` and `stream.pipeline` need the stream to be cast. + * + * @param stream The pack or extract stream to cast + */ +function asNodeStream(stream: Pack): NodeJS.ReadableStream +function asNodeStream(stream: Extract): NodeJS.WritableStream +/** + * @param stream The pack or extract stream to cast + */ +function asNodeStream(stream: Pack | Extract) { + return stream as unknown as NodeJS.ReadableStream & NodeJS.WritableStream +} + /** * Pauses execution for a specified number of milliseconds. * diff --git a/package-lock.json b/package-lock.json index 2a92455a..dba3c278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@nextcloud/paths": "^3.1.0", "dockerode": "^5.0.0", "fast-xml-parser": "^5.2.2", - "tar-stream": "^3.2.0", + "tar-stream": "^3.2.1", "wait-on": "^9.0.1" }, "devDependencies": { @@ -21,7 +21,6 @@ "@playwright/test": "^1.61.1", "@types/cypress": "^1.1.6", "@types/dockerode": "^4.0.1", - "@types/tar-stream": "^3.1.4", "@types/wait-on": "^5.3.4", "cypress": "^15.18.0", "cypress-vite": "^1.10.2", @@ -2099,16 +2098,6 @@ "@types/node": "*" } }, - "node_modules/@types/tar-stream": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.4.tgz", - "integrity": "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/tmp": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", @@ -10731,15 +10720,6 @@ "@types/node": "*" } }, - "@types/tar-stream": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-3.1.4.tgz", - "integrity": "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/tmp": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", diff --git a/package.json b/package.json index 83e5414f..5f098421 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@nextcloud/paths": "^3.1.0", "dockerode": "^5.0.0", "fast-xml-parser": "^5.2.2", - "tar-stream": "^3.2.0", + "tar-stream": "^3.2.1", "wait-on": "^9.0.1" }, "devDependencies": { @@ -91,7 +91,6 @@ "@playwright/test": "^1.61.1", "@types/cypress": "^1.1.6", "@types/dockerode": "^4.0.1", - "@types/tar-stream": "^3.1.4", "@types/wait-on": "^5.3.4", "cypress": "^15.18.0", "cypress-vite": "^1.10.2", diff --git a/playwright/start-nextcloud-server.mjs b/playwright/start-nextcloud-server.mjs index 41685aab..7bc44fac 100644 --- a/playwright/start-nextcloud-server.mjs +++ b/playwright/start-nextcloud-server.mjs @@ -34,7 +34,7 @@ function getBranch() { await start() // Listen for process to exit (tests done) and shut down the docker container process.on('beforeExit', async () => { - await stopNextcloud() + await stopNextcloud({ saveLogTo: 'playwright-report/nextcloud.log' }) await docker.getVolume('apps_writable').remove() })