Skip to content

Commit

Permalink
feat: Make docker integration available for other projects
Browse files Browse the repository at this point in the history
* Provide the docker helpers as `@nextcloud/cypress/docker` import.
* Allow installing custom required apps for testing
  * Handle `text` app which is not in the appstore but also not bundled
* Allow to autodetect current app, or set one, and bind mount the directory
  for testing the app

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
  • Loading branch information
susnux committed Feb 11, 2023
1 parent d692ccf commit 0c2f19d
Show file tree
Hide file tree
Showing 6 changed files with 9,419 additions and 6,799 deletions.
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,41 @@ describe('UploadPicker rendering', () => {
cy.getNc(UploadPickerInput).should('exist')
})
})
```
```

## Starting Nextcloud Docker container

It is possible to automatically start a docker container providing a Nextcloud instance for testing.
Therefor adjust your `cypress.config.ts` (or `.js`):

```js
import { configureNextcloud, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/cypress/docker'

export default defineConfig({
// ...
e2e: {
// other configuration

setupNodeEvents(on, config) {
// Remove container after run
on('after:run', () => {
stopNextcloud()
})

// starting Nextcloud testing container with specified server branch
return startNextcloud(process.env.BRANCH)
.then((ip) => {
// Setting container's IP as base Url
config.baseUrl = `http://${ip}/index.php`
return ip
})
.then(waitOnNextcloud)
// configure Nextcloud, also install and enable the `viewer` app
.then(() => configureNextcloud(['viewer']))
.then(() => {
return config
})
},
},
})
```
4 changes: 2 additions & 2 deletions cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
process.env.NODE_ENV = 'development'
process.env.npm_package_name = 'nextcloud-cypress'

import { configureNextcloud, startNextcloud, stopNextcloud, waitOnNextcloud } from './cypress/dockerNode'
import { configureNextcloud, startNextcloud, stopNextcloud, waitOnNextcloud } from './lib/docker'
import { defineConfig } from 'cypress'
import webpackConfig from '@nextcloud/webpack-vue-config'
import webpackRules from '@nextcloud/webpack-vue-config/rules'
Expand Down Expand Up @@ -52,7 +52,7 @@ export default defineConfig({
return ip
})
.then(waitOnNextcloud)
.then(configureNextcloud)
.then(() => configureNextcloud())
.then(() => {
return config
})
Expand Down
141 changes: 112 additions & 29 deletions cypress/dockerNode.ts → lib/docker.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable no-console */
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
Expand All @@ -23,19 +24,66 @@
import Docker from 'dockerode'
import waitOn from 'wait-on'

import { join, resolve, sep } from 'path'
import { existsSync, readFileSync } from 'fs'
import { XMLParser } from 'fast-xml-parser'

export const docker = new Docker()

const CONTAINER_NAME = 'nextcloud-cypress-tests'
const SERVER_IMAGE = 'ghcr.io/nextcloud/continuous-integration-shallow-server'

const TEXT_APP_GIT = 'https://github.com/nextcloud/text.git'

/**
* Start the testing container
*
* @param branch server branch to use
* @param mountApp bind mount app within server (`true` for autodetect, `false` to disable, or a string to force a path)
*/
export const startNextcloud = async function (branch: string = 'master'): Promise<any> {
export const startNextcloud = async function(branch = 'master', mountApp: boolean|string = true): Promise<any> {
let appPath = mountApp === true ? process.cwd() : mountApp
let appId: string|undefined
let appVersion: string|undefined
if (appPath) {
console.log('Mounting app directory')
while (appPath) {
const appInfoPath = resolve(join(appPath, 'appinfo', 'info.xml'))
if (existsSync(appInfoPath)) {
const parser = new XMLParser()
const xmlDoc = parser.parse(readFileSync(appInfoPath))
appId = xmlDoc.info.id
appVersion = xmlDoc.info.version
console.log(`└─ Found ${appId} version ${appVersion}`)
break
} else {
// skip if root is reached or manual directory was set
if (appPath === sep || typeof mountApp === 'string') {
console.log('└─ No appinfo found')
appPath = false
break
}
appPath = join(appPath, '..')
}
}
}

try {
// Pulling images
console.log('Pulling images...')
await docker.pull(SERVER_IMAGE)
console.log('\nPulling images...')
// https://github.com/apocas/dockerode/issues/357
await new Promise((resolve, reject): any => docker.pull(SERVER_IMAGE, {}, (error, stream) => {
if (!stream) reject(error)

docker.modem.followProgress(stream, (err, output) => {
if (!err) {
resolve(true)
return
}
reject(err)
})
}))
console.log('└─ Done')

// Getting latest image
console.log('\nChecking running containers... 🔍')
Expand All @@ -46,22 +94,22 @@ export const startNextcloud = async function (branch: string = 'master'): Promis
const oldContainer = docker.getContainer(CONTAINER_NAME)
const oldContainerData = await oldContainer.inspect()
if (oldContainerData.State.Running) {
console.log(`├─ Existing running container found`)
console.log('├─ Existing running container found')
if (localImage[0].Id !== oldContainerData.Image) {
console.log(`└─ But running container is outdated, replacing...`)
console.log('└─ But running container is outdated, replacing...')
} else {
// Get container's IP
console.log(`├─ Reusing that container`)
let ip = await getContainerIP(oldContainer)
console.log('├─ Reusing that container')
const ip = await getContainerIP(oldContainer)
return ip
}
} else {
console.log(`└─ None found!`)
console.log('└─ None found!')
}
// Forcing any remnants to be removed just in case
await oldContainer.remove({ force: true })
} catch (error) {
console.log(`└─ None found!`)
console.log('└─ None found!')
}

// Starting container
Expand All @@ -71,16 +119,19 @@ export const startNextcloud = async function (branch: string = 'master'): Promis
Image: SERVER_IMAGE,
name: CONTAINER_NAME,
Env: [`BRANCH=${branch}`],
HostConfig: {
Binds: appPath !== false ? [`${appPath}:/var/www/html/apps/${appId}`] : undefined,
},
})
await container.start()

// Get container's IP
let ip = await getContainerIP(container)
const ip = await getContainerIP(container)

console.log(`├─ Nextcloud container's IP is ${ip} 🌏`)
return ip
} catch (err) {
console.log(`└─ Unable to start the container 🛑`)
console.log('└─ Unable to start the container 🛑')
console.log(err)
stopNextcloud()
throw new Error('Unable to start the container')
Expand All @@ -89,8 +140,10 @@ export const startNextcloud = async function (branch: string = 'master'): Promis

/**
* Configure Nextcloud
*
* @param {string[]} apps List of default apps to install (default is ['viewer'])
*/
export const configureNextcloud = async function () {
export const configureNextcloud = async function(apps = ['viewer']) {
console.log('\nConfiguring nextcloud...')
const container = docker.getContainer(CONTAINER_NAME)
await runExec(container, ['php', 'occ', '--version'], true)
Expand All @@ -102,8 +155,31 @@ export const configureNextcloud = async function () {
await runExec(container, ['php', 'occ', 'config:system:set', 'force_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'enforce_theme', '--value', 'light'], true)

// Enable the app and give status
await runExec(container, ['php', 'occ', 'app:enable', '--force', 'viewer'], true)
// Build app list
const json = await runExec(container, ['php', 'occ', 'app:list', '--output', 'json'], false)
// fix dockerode bug returning invalid leading characters
const applist = JSON.parse(json.substring(json.indexOf('{')))

// Enable apps and give status
for (const app of apps) {
if (app in applist.enabled) {
console.log(`├─ ${app} version ${applist.enabled[app]} already installed and enabled`)
} else if (app in applist.disabled) {
// built in
await runExec(container, ['php', 'occ', 'app:enable', '--force', app], true)
} else {
if (app === 'text') {
// text is vendored but not within the server package
await runExec(container, ['apt', 'update'], false, 'root')
await runExec(container, ['apt-get', '-y', 'install', 'git'], false, 'root')
await runExec(container, ['git', 'clone', '--depth=1', TEXT_APP_GIT, 'apps/text'], true)
await runExec(container, ['php', 'occ', 'app:enable', '--force', app], true)
} else {
// try appstore
await runExec(container, ['php', 'occ', 'app:install', '--force', app], true)
}
}
}
// await runExec(container, ['php', 'occ', 'app:list'], true)

console.log('└─ Nextcloud is now ready to use 🎉')
Expand All @@ -112,7 +188,7 @@ export const configureNextcloud = async function () {
/**
* Force stop the testing container
*/
export const stopNextcloud = async function () {
export const stopNextcloud = async function() {
try {
const container = docker.getContainer(CONTAINER_NAME)
console.log('Stopping Nextcloud container...')
Expand All @@ -125,16 +201,18 @@ export const stopNextcloud = async function () {

/**
* Get the testing container's IP
*
* @param container
*/
export const getContainerIP = async function (
export const getContainerIP = async function(
container = docker.getContainer(CONTAINER_NAME)
): Promise<string> {
let ip = ''
let tries = 0
while (ip === '' && tries < 10) {
tries++

await container.inspect(function (err, data) {
await container.inspect(function(err, data) {
ip = data?.NetworkSettings?.IPAddress || ''
})

Expand All @@ -153,40 +231,45 @@ export const getContainerIP = async function (
// Until we can properly configure the baseUrl retry intervals,
// We need to make sure the server is already running before cypress
// https://github.com/cypress-io/cypress/issues/22676
export const waitOnNextcloud = async function (ip: string) {
export const waitOnNextcloud = async function(ip: string) {
console.log('├─ Waiting for Nextcloud to be ready... ⏳')
await waitOn({ resources: [`http://${ip}/index.php`] })
console.log('└─ Done')
}

const runExec = async function (
const runExec = async function(
container: Docker.Container,
command: string[],
verbose: boolean = false
verbose = false,
user = 'www-data'
) {
const exec = await container.exec({
Cmd: command,
AttachStdout: true,
AttachStderr: true,
User: 'www-data',
User: user,
})

return new Promise((resolve, reject) => {
return new Promise<string>((resolve, reject) => {
exec.start({}, (err, stream) => {
if (stream) {
stream.setEncoding('utf-8')
stream.on('data', str => {
if (verbose && str.trim() !== '') {
console.log(`├─ ${str.trim().replace(/\n/gi, '\n├─ ')}`)
const data = [] as string[]
stream.setEncoding('utf8')
stream.on('data', (str) => {
data.push(str)
const printable = str.replace(/\p{C}/gu, '').trim()
if (verbose && printable !== '') {
console.log(`├─ ${printable.replace(/\n/gi, '\n├─ ')}`)
}
})
stream.on('end', resolve)
stream.on('end', () => resolve(data.join('')))
} else {
reject()
}
})
})
}

const sleep = function (milliseconds: number) {
const sleep = function(milliseconds: number) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}

Loading

0 comments on commit 0c2f19d

Please sign in to comment.