diff --git a/AUTHORS.md b/AUTHORS.md index 01627bbc..7818f692 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -13,3 +13,4 @@ - Max - max-nextcloud - Pytal <24800714+Pytal@users.noreply.github.com> +- Tobias Knöppler diff --git a/cypress.config.ts b/cypress.config.ts index b3f72374..3f3be67a 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -7,9 +7,20 @@ process.env.NODE_ENV = 'development' process.env.npm_package_name = 'nextcloud-e2e-test-server' +import type { RunExecOptions, RunExecResult } from './lib/docker.ts' + import { defineConfig } from 'cypress' import vitePreprocessor from 'cypress-vite' -import { configureNextcloud, createSnapshot, setupUsers, startNextcloud, stopNextcloud, waitOnNextcloud } from './lib/docker.ts' +import { + configureNextcloud, + createSnapshot, + docker, + runExec as dockerRunExec, + setupUsers, + startNextcloud, + stopNextcloud, + waitOnNextcloud, +} from './lib/docker.ts' export default defineConfig({ projectId: 'h2z7r3', @@ -24,29 +35,31 @@ export default defineConfig({ // Disable session isolation testIsolation: false, - setupNodeEvents(on, config) { + async setupNodeEvents(on, config) { on('file:preprocessor', vitePreprocessor({ configFile: false })) // Remove container after run - on('after:run', () => { - stopNextcloud() + on('after:run', async () => { + await stopNextcloud() + await docker.getVolume('apps_writable').remove() + }) + + on('task', { + async runExec({ command, options }: { command: string | string[], options: Partial }): Promise { + return await dockerRunExec(command, options) + }, }) // Before the browser launches // starting Nextcloud testing container - return startNextcloud(process.env.BRANCH, false, { forceRecreate: true }) - .then((ip) => { - // Setting container's IP as base Url - config.baseUrl = `http://${ip}/index.php` - return ip - }) - .then((ip) => waitOnNextcloud(ip)) - .then(() => configureNextcloud()) - .then(() => setupUsers()) - .then(() => createSnapshot('init')) - .then(() => { - return config - }) + const ip = await startNextcloud(process.env.BRANCH, false, { forceRecreate: true, exposePort: 8086 }) + // Setting container's IP as base Url + config.baseUrl = `http://${ip}/index.php` + await waitOnNextcloud(ip) + await configureNextcloud() + await setupUsers() + await createSnapshot('init') + return config }, }, }) diff --git a/cypress/e2e/docker.cy.ts b/cypress/e2e/docker.cy.ts index 34d02741..171a6527 100644 --- a/cypress/e2e/docker.cy.ts +++ b/cypress/e2e/docker.cy.ts @@ -11,7 +11,7 @@ describe('Run command', function() { }) it('hands on the env', function() { - cy.runCommand('env', { env: { DATA: 'Hello' } }) + cy.runCommand('env', { env: ['DATA=Hello'] }) .its('stdout') .should('contain', 'DATA') .should('contain', 'Hello') diff --git a/lib/commands/docker.ts b/lib/commands/docker.ts index 0dde5d91..e0b5008c 100644 --- a/lib/commands/docker.ts +++ b/lib/commands/docker.ts @@ -3,16 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { basename } from '@nextcloud/paths' +import type { RunExecOptions, RunExecResult } from '../docker.ts' -/** - * - */ -function getContainerName(): Cypress.Chainable { - return cy.exec('pwd').then(({ stdout }) => { - const name = basename(stdout).replace(' ', '') - return cy.wrap(`nextcloud-e2e-test-server_${name}`) - }) +const defaultOptions = { + failOnError: true, + user: 'www-data', + verbose: false, } /** @@ -20,14 +16,16 @@ function getContainerName(): Cypress.Chainable { * @param command * @param options */ -export function runCommand(command: string, options?: Partial): Cypress.Chainable { +export function runCommand(command: string[] | string, options?: Partial): Cypress.Chainable { const env = Object.entries(options?.env ?? {}) - .map(([name, value]) => `-e '${name}=${value}'`) - .join(' ') + .map(([name, value]) => `${name}=${value}`) - return getContainerName() - .then((containerName) => { - // Wrapping command inside bash -c "..." to allow using '*'. - return cy.exec(`docker exec --user www-data --workdir /var/www/html ${env} ${containerName} bash -c "${command}"`, options) - }) + return cy.task('runExec', { + command, + options: { + ...defaultOptions, + ...options, + env, + }, + }) } diff --git a/lib/commands/occ.ts b/lib/commands/occ.ts index 77f70255..3bb09c35 100644 --- a/lib/commands/occ.ts +++ b/lib/commands/occ.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { RunExecOptions, RunExecResult } from '../docker.ts' + import { runCommand } from './docker.ts' /** @@ -10,6 +12,6 @@ import { runCommand } from './docker.ts' * @param command * @param options */ -export function runOccCommand(command: string, options?: Partial): Cypress.Chainable { - return runCommand(`php ./occ ${command}`, options) +export function runOccCommand(command: string, options?: Partial): Cypress.Chainable { + return runCommand(['php', 'occ', command], { verbose: true, ...options }) } diff --git a/lib/commands/state.ts b/lib/commands/state.ts index 0a64d2d0..0575ed09 100644 --- a/lib/commands/state.ts +++ b/lib/commands/state.ts @@ -11,14 +11,14 @@ import { runCommand } from './docker.ts' export function saveState(): Cypress.Chainable { const snapshot = Math.random().toString(36).substring(7) - runCommand(`rm /var/www/html/data-${snapshot}.tar`, { failOnNonZeroExit: false }) + runCommand(['rm', `/var/www/html/data-${snapshot}.tar`], { failOnError: false, verbose: true }) // The instance keeps writing into ./data while we archive it (e.g. the // nextcloud.log, caches, session files), so a file can change mid-read and // GNU tar exits 1 with "file changed as we read it". That is harmless for a // test-state snapshot: silence the warning and treat exit 1 (benign, // "some files differ") as success while still failing on a real error // (exit 2, fatal). - runCommand(`tar --warning=no-file-changed -cf /var/www/html/data-${snapshot}.tar ./data || [ $? -eq 1 ]`) + runCommand(['bash', '-c', `tar --warning=no-file-changed -cf /var/www/html/data-${snapshot}.tar ./data || [ $? -eq 1 ]`], { verbose: true }) cy.log(`Created snapshot ${snapshot}`) @@ -31,13 +31,12 @@ export function saveState(): Cypress.Chainable { * @param snapshot - The name of the snapshot to restore. If not provided, the default snapshot 'init' will be used. */ export function restoreState(snapshot: string = 'init') { - runCommand('rm -vfr ./data/*') - runCommand(`tar -xf '/var/www/html/data-${snapshot}.tar'`) - - // Any user sessions created between saveState() and restoreState() - // are not present in the database, but exist in the web server. - // Using them leads to unknown behavior, so we clear them all to prevent session errors. - Cypress.session.clearAllSavedSessions() - - cy.log(`Restored snapshot ${snapshot}`) + runCommand(['bash', '-c', 'rm -vfr ./data/*'], { verbose: true }).then(() => { + runCommand(['bash', '-c', `tar -xf '/var/www/html/data-${snapshot}.tar'`], { verbose: true }).then(() => { + // Any user sessions created between saveState() and restoreState() + // are not present in the database, but exist in the web server. + // Using them leads to unknown behavior, so we clear them all to prevent session errors. + Cypress.session.clearAllSavedSessions().then(() => cy.log(`Restored snapshot ${snapshot}`)) + }) + }) } diff --git a/lib/cypress.ts b/lib/cypress.ts index 6768071c..3ad44b51 100644 --- a/lib/cypress.ts +++ b/lib/cypress.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { RunExecOptions, RunExecResult } from './docker.ts' import type { Selector } from './selectors/index.ts' import { getNc, restoreState, runCommand, runOccCommand, saveState } from './commands/index.ts' @@ -109,12 +110,12 @@ declare global { * Run a command in the docker container * */ - runCommand(command: string, options?: Partial): Cypress.Chainable + runCommand(command: string, options?: Partial): Cypress.Chainable /** * Run an occ command */ - runOccCommand(command: string, options?: Partial): Cypress.Chainable + runOccCommand(command: string, options?: Partial): Cypress.Chainable } } } diff --git a/lib/docker.ts b/lib/docker.ts index 0a87953b..1cafa8c6 100644 --- a/lib/docker.ts +++ b/lib/docker.ts @@ -17,7 +17,7 @@ import { User } from './User.ts' const SERVER_IMAGE = 'ghcr.io/nextcloud/continuous-integration-shallow-server' -export const docker = new Docker() +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 let _containerName: string | null = null @@ -164,12 +164,6 @@ export async function startNextcloud(branch = 'master', mountApp: boolean | stri Image: SERVER_IMAGE, name: getContainerName(), Env: [`BRANCH=${branch}`, 'APCU=1'], - Volumes: { - apps_writable: { - Mountpoint: '/var/www/html/apps-writable', - Readonly: false, - }, - }, HostConfig: { Binds: mounts.length > 0 ? mounts : undefined, PortBindings, @@ -180,6 +174,11 @@ export async function startNextcloud(branch = 'master', mountApp: boolean | stri Source: '', Type: 'tmpfs', ReadOnly: false, + }, { + Target: '/var/www/html/apps-writable', + Source: 'apps_writable', + Type: 'volume', + ReadOnly: false, }], }, }) @@ -268,7 +267,7 @@ export async function configureNextcloud(apps = ['viewer'], vendoredBranch?: str console.log('├─ Using "apps-writable" folder for mounted apps') await runExec(['mkdir', '-p', '/var/www/html/apps-writable'], { container }) - await runExec(['chown', 'www-data:www-data', '/var/www/html/apps-writable'], { container }) + await runExec(['chown', 'www-data:www-data', '/var/www/html/apps-writable'], { container, user: 'root' }) const appsConfig = ` [ @@ -364,7 +363,7 @@ export async function stopNextcloud() { try { const container = getContainer() console.log('Stopping Nextcloud container…') - container.remove({ force: true }) + await container.remove({ force: true }) console.log('└─ Nextcloud container removed 🥀') } catch (err) { console.log(err) @@ -419,7 +418,7 @@ export async function waitOnNextcloud(ip: string) { console.log('└─ Done') } -interface RunExecOptions { +export interface RunExecOptions { /** * The container to run the command in. If not provided, the current container will be used. */ @@ -442,7 +441,7 @@ interface RunExecOptions { verbose: boolean } -type RunExecResult = { +export type RunExecResult = { stdout: string stderr: string exitCode: number diff --git a/package-lock.json b/package-lock.json index 121d3012..faa926cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@nextcloud/paths": "^3.1.0", - "dockerode": "^5.0.0", + "dockerode": "^5.0.1", "fast-xml-parser": "^5.2.2", "tar-stream": "^3.2.0", "wait-on": "^9.0.1" @@ -4000,6 +4000,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", + "license": "Apache-2.0", "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", diff --git a/playwright/start-nextcloud-server.mjs b/playwright/start-nextcloud-server.mjs index d5e41466..41685aab 100644 --- a/playwright/start-nextcloud-server.mjs +++ b/playwright/start-nextcloud-server.mjs @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { startNextcloud, stopNextcloud } from '@nextcloud/e2e-test-server/docker' +import { docker, startNextcloud, stopNextcloud } from '@nextcloud/e2e-test-server/docker' import { readFileSync } from 'fs' /** @@ -33,8 +33,9 @@ function getBranch() { // Start the Nextcloud docker container await start() // Listen for process to exit (tests done) and shut down the docker container -process.on('beforeExit', () => { - stopNextcloud() +process.on('beforeExit', async () => { + await stopNextcloud() + await docker.getVolume('apps_writable').remove() }) // Idle to wait for shutdown diff --git a/playwright/support/setup.ts b/playwright/support/setup.ts index 1c8475d9..887dad6f 100644 --- a/playwright/support/setup.ts +++ b/playwright/support/setup.ts @@ -13,6 +13,6 @@ import { test as setup } from '@playwright/test' * as that only checks for the URL to be accessible which happens already before everything is configured. */ setup('Configure Nextcloud', async () => { - const appsToInstall = [] + const appsToInstall: string[] = [] await configureNextcloud(appsToInstall) }) diff --git a/tests/docker.spec.ts b/tests/docker.spec.ts index 936d7655..7183d6c4 100644 --- a/tests/docker.spec.ts +++ b/tests/docker.spec.ts @@ -5,16 +5,19 @@ import * as expect from 'node:assert' import { after, before, describe, test } from 'node:test' -import { configureNextcloud, getContainer, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '../lib/docker.ts' +import { configureNextcloud, docker, getContainer, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '../lib/docker.ts' describe('Docker: Pre-installation of apps', async () => { before(async () => { - const ip = await startNextcloud('master', false, { forceRecreate: true }) + const ip = await startNextcloud('master', false, { forceRecreate: true, exposePort: 8088 }) await waitOnNextcloud(ip) await configureNextcloud(['viewer', 'text', 'forms']) }) - after(async () => await stopNextcloud()) + after(async () => { + await stopNextcloud() + await docker.getVolume('apps_writable').remove() + }) await test('Additional apps: Default mapping works', async () => { const container = getContainer() diff --git a/tests/runExec.spec.ts b/tests/runExec.spec.ts index 013c3e97..3a441df4 100644 --- a/tests/runExec.spec.ts +++ b/tests/runExec.spec.ts @@ -7,18 +7,21 @@ import type { Container } from 'dockerode' import assert from 'node:assert/strict' import { after, before, describe, test } from 'node:test' -import { getContainer, runExec, startNextcloud, stopNextcloud, waitOnNextcloud } from '../lib/docker.ts' +import { docker, getContainer, runExec, startNextcloud, stopNextcloud, waitOnNextcloud } from '../lib/docker.ts' describe('Docker: runExec', async () => { let container: Container before(async () => { - const ip = await startNextcloud('master', false) + const ip = await startNextcloud('master', false, { exposePort: 8087 }) await waitOnNextcloud(ip) container = getContainer() }) - after(async () => await stopNextcloud()) + after(async () => { + await stopNextcloud() + await docker.getVolume('apps_writable').remove() + }) await test('captures stdout of a command', async () => { const { stdout, stderr, exitCode } = await runExec(['echo', 'hello world'], { container })