diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..dfa6ad7e --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,77 @@ +name: Playwright Tests + +on: + workflow_dispatch: + +env: + BASE_URL: https://localhost:8080 + +jobs: + test: + timeout-minutes: 60 + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install dependencies + run: cd php/tests && npm ci + + - name: Install Playwright Browsers + run: cd php/tests && npx playwright install --with-deps chromium + + - name: Start fresh development server + run: | + docker rm --force nextcloud-aio-{mastercontainer,apache,notify-push,nextcloud,redis,database,domaincheck,whiteboard,imaginary,talk,collabora,borgbackup} || true + docker volume rm nextcloud_aio_{mastercontainer,apache,database,database_dump,nextcloud,nextcloud_data,redis,backup_cache,elasticsearch} || true + docker pull nextcloud/all-in-one:develop + docker run \ + -d \ + --init \ + --name nextcloud-aio-mastercontainer \ + --restart always \ + --publish 8080:8080 \ + --volume nextcloud_aio_mastercontainer:/mnt/docker-aio-config \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ + --env SKIP_DOMAIN_VALIDATION=true \ + --env APACHE_PORT=11000 \ + nextcloud/all-in-one:develop + echo Waiting for 10 seconds for the development container to start ... + sleep 10 + + - name: Run Playwright tests for initial setup + run: cd php/tests && DEBUG=pw:api npx playwright test tests/initial-setup.spec.js + + - name: Start fresh development server + run: | + docker rm --force nextcloud-aio-{mastercontainer,apache,notify-push,nextcloud,redis,database,domaincheck,whiteboard,imaginary,talk,collabora,borgbackup} || true + docker volume rm nextcloud_aio_{mastercontainer,apache,database,database_dump,nextcloud,nextcloud_data,redis,backup_cache,elasticsearch} || true + docker run \ + -d \ + --init \ + --name nextcloud-aio-mastercontainer \ + --restart always \ + --publish 8080:8080 \ + --volume nextcloud_aio_mastercontainer:/mnt/docker-aio-config \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ + --env SKIP_DOMAIN_VALIDATION=false \ + --env APACHE_PORT=11000 \ + nextcloud/all-in-one:develop + echo Waiting for 10 seconds for the development container to start ... + sleep 10 + + - name: Run Playwright tests for backup restore + run: cd php/tests && DEBUG=pw:api npx playwright test tests/restore-instance.spec.js + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: php/tests/playwright-report/ + retention-days: 14 + overwrite: true diff --git a/php/tests/.gitignore b/php/tests/.gitignore new file mode 100644 index 00000000..58786aac --- /dev/null +++ b/php/tests/.gitignore @@ -0,0 +1,7 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/php/tests/package-lock.json b/php/tests/package-lock.json new file mode 100644 index 00000000..ea2b4296 --- /dev/null +++ b/php/tests/package-lock.json @@ -0,0 +1,97 @@ +{ + "name": "e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "e2e", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.51.1", + "@types/node": "^22.13.10" + } + }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.13.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", + "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/php/tests/package.json b/php/tests/package.json new file mode 100644 index 00000000..ebfa99ec --- /dev/null +++ b/php/tests/package.json @@ -0,0 +1,8 @@ +{ + "name": "nextcloud-aio-mastercontainer-tests", + "version": "1.0.0", + "license": "AGPL-3.0-or-later", + "devDependencies": { + "@playwright/test": "^1.51.1" + } +} diff --git a/php/tests/playwright.config.js b/php/tests/playwright.config.js new file mode 100644 index 00000000..191a7f59 --- /dev/null +++ b/php/tests/playwright.config.js @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: [ + ['list'], + ['html'], + ], + use: { + baseURL: process.env.BASE_URL ?? 'http://localhost:8080', + trace: 'on', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + ignoreHTTPSErrors: true, + }, + }, + ], +}) diff --git a/php/tests/tests/initial-setup.spec.js b/php/tests/tests/initial-setup.spec.js new file mode 100644 index 00000000..6e990767 --- /dev/null +++ b/php/tests/tests/initial-setup.spec.js @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; +import { writeFileSync } from 'node:fs' + +test('Initial setup', async ({ page: setupPage }) => { + test.setTimeout(10 * 60 * 1000) + + // Extract initial password + await setupPage.goto('./setup'); + const password = await setupPage.locator('#initial-password').innerText() + const containersPagePromise = setupPage.waitForEvent('popup'); + await setupPage.getByRole('link', { name: 'Open Nextcloud AIO login ↗' }).click(); + const containersPage = await containersPagePromise; + + // Log in and wait for redirect + await containersPage.locator('#master-password').click(); + await containersPage.locator('#master-password').fill(password); + await containersPage.getByRole('button', { name: 'Log in' }).click(); + await containersPage.waitForURL('./containers'); + + // Reject IP addresses + await containersPage.locator('#domain').click(); + await containersPage.locator('#domain').fill('1.1.1.1'); + await containersPage.getByRole('button', { name: 'Submit domain' }).click(); + await expect(containersPage.locator('body')).toContainText('Please enter a domain and not an IP-address!'); + + // Accept example.com (requires disabled domain validation) + await containersPage.locator('#domain').click(); + await containersPage.locator('#domain').fill('example.com'); + await containersPage.getByRole('button', { name: 'Submit domain' }).click(); + + // Disable all additional containers + await containersPage.locator('#talk').uncheck(); + await containersPage.getByRole('checkbox', { name: 'Whiteboard' }).uncheck(); + await containersPage.getByRole('checkbox', { name: 'Imaginary' }).uncheck(); + await containersPage.getByRole('checkbox', { name: 'Collabora' }).uncheck(); + await containersPage.getByRole('button', { name: 'Save changes' }).click(); + await expect(containersPage.locator('#talk')).not.toBeChecked() + await expect(containersPage.getByRole('checkbox', { name: 'Whiteboard' })).not.toBeChecked() + await expect(containersPage.getByRole('checkbox', { name: 'Imaginary' })).not.toBeChecked() + await expect(containersPage.getByRole('checkbox', { name: 'Collabora' })).not.toBeChecked() + + // Reject invalid time zones + await containersPage.locator('#timezone').click(); + await containersPage.locator('#timezone').fill('Invalid time zone'); + containersPage.once('dialog', dialog => { + console.log(`Dialog message: ${dialog.message()}`) + dialog.accept() + }); + await containersPage.getByRole('button', { name: 'Submit timezone' }).click(); + await expect(containersPage.locator('body')).toContainText('The entered timezone does not seem to be a valid timezone!') + + // Accept valid time zone + await containersPage.locator('#timezone').click(); + await containersPage.locator('#timezone').fill('Europe/Berlin'); + containersPage.once('dialog', dialog => { + console.log(`Dialog message: ${dialog.message()}`) + dialog.accept() + }); + await containersPage.getByRole('button', { name: 'Submit timezone' }).click(); + + // Start containers and wait for starting message + await containersPage.getByRole('button', { name: 'Download and start containers' }).click(); + await expect(containersPage.getByRole('link', { name: 'Open your Nextcloud ↗' })).toBeVisible({ timeout: 5 * 60 * 1000 }); + await expect(containersPage.getByRole('link', { name: 'Open your Nextcloud ↗' })).toHaveAttribute('href', 'https://example.com'); + + // Extract initial nextcloud password + await expect(containersPage.getByRole('main')).toContainText('Initial Nextcloud password:') + const initialNextcloudPassword = await containersPage.locator('#initial-nextcloud-password').innerText(); + + // Set backup location and create backup + const borgBackupLocation = `/mnt/test/aio-${Math.floor(Math.random() * 2147483647)}` + await containersPage.locator('#borg_backup_host_location').click(); + await containersPage.locator('#borg_backup_host_location').fill(borgBackupLocation); + await containersPage.getByRole('button', { name: 'Submit backup location' }).click(); + containersPage.once('dialog', dialog => { + console.log(`Dialog message: ${dialog.message()}`) + dialog.accept() + }); + await containersPage.getByRole('button', { name: 'Create backup' }).click(); + await expect(containersPage.getByRole('main')).toContainText('Backup container is currently running:', { timeout: 3 * 60 * 1000 }); + await expect(containersPage.getByRole('main')).toContainText('Last backup successful on', { timeout: 3 * 60 * 1000 }); + await containersPage.getByText('Click here to reveal all backup options').click(); + await expect(containersPage.locator('#borg-backup-password')).toBeVisible(); + const borgBackupPassword = await containersPage.locator('#borg-backup-password').innerText(); + + // Assert that all containers are stopped + await expect(containersPage.getByRole('button', { name: 'Start containers' })).toBeVisible(); + + // Save passwords for restore backup test + writeFileSync('test_data.json', JSON.stringify({ + initialNextcloudPassword, + borgBackupLocation, + borgBackupPassword, + })) +}); diff --git a/php/tests/tests/restore-instance.spec.js b/php/tests/tests/restore-instance.spec.js new file mode 100644 index 00000000..fef4ec01 --- /dev/null +++ b/php/tests/tests/restore-instance.spec.js @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +test('Restore instance', async ({ page: setupPage }) => { + test.setTimeout(10 * 60 * 1000) + + // Load passwords from previous test + const { + initialNextcloudPassword, + borgBackupLocation, + borgBackupPassword, + } = JSON.parse(readFileSync('test_data.json')) + + // Extract initial password + await setupPage.goto('./setup'); + const password = await setupPage.locator('#initial-password').innerText() + const containersPagePromise = setupPage.waitForEvent('popup'); + await setupPage.getByRole('link', { name: 'Open Nextcloud AIO login ↗' }).click(); + const containersPage = await containersPagePromise; + + // Log in and wait for redirect + await containersPage.locator('#master-password').click(); + await containersPage.locator('#master-password').fill(password); + await containersPage.getByRole('button', { name: 'Log in' }).click(); + await containersPage.waitForURL('./containers'); + + // Reject example.com (requires enabled domain validation) + await containersPage.locator('#domain').click(); + await containersPage.locator('#domain').fill('example.com'); + await containersPage.getByRole('button', { name: 'Submit domain' }).click(); + await expect(containersPage.locator('body')).toContainText('Domain does not point to this server or the reverse proxy is not configured correctly.'); + + // Reject invalid backup location + await containersPage.locator('#borg_restore_host_location').click(); + await containersPage.locator('#borg_restore_host_location').fill('/mnt/foobar'); + await containersPage.locator('#borg_restore_password').click(); + await containersPage.locator('#borg_restore_password').fill('foobar'); + await containersPage.getByRole('button', { name: 'Submit location and encryption password' }).click() + await containersPage.getByRole('button', { name: 'Test path and encryption' }).click(); + await expect(containersPage.getByRole('main')).toContainText('Last test failed!', { timeout: 60 * 1000 }); + + // Reject invalid backup password + await containersPage.locator('#borg_restore_host_location').click(); + await containersPage.locator('#borg_restore_host_location').fill('/mnt/backup'); + await containersPage.locator('#borg_restore_password').click(); + await containersPage.locator('#borg_restore_password').fill('foobar'); + await containersPage.getByRole('button', { name: 'Submit location and encryption password' }).click() + await containersPage.getByRole('button', { name: 'Test path and encryption' }).click(); + await expect(containersPage.getByRole('main')).toContainText('Last test failed!', { timeout: 60 * 1000 }); + + // Accept correct backup location and password + await containersPage.locator('#borg_restore_host_location').click(); + await containersPage.locator('#borg_restore_host_location').fill(borgBackupLocation); + await containersPage.locator('#borg_restore_password').click(); + await containersPage.locator('#borg_restore_password').fill(borgBackupPassword); + await containersPage.getByRole('button', { name: 'Submit location and encryption password' }).click() + await containersPage.getByRole('button', { name: 'Test path and encryption' }).click(); + + // Check integrity and restore backup + await containersPage.getByRole('button', { name: 'Check backup integrity' }).click(); + await expect(containersPage.getByRole('main')).toContainText('Last check successful!', { timeout: 5 * 60 * 1000 }); + await containersPage.getByRole('button', { name: 'Restore selected backup' }).click(); + await expect(containersPage.getByRole('main')).toContainText('Backup container is currently running:'); + + // Verify a successful backup restore + await expect(containersPage.getByRole('main')).toContainText('Last restore successful!', { timeout: 3 * 60 * 1000 }); + await expect(containersPage.getByRole('main')).toContainText('⚠️ Container updates are available. Click on Stop containers and Start and update containers to update them. You should consider creating a backup first.'); + containersPage.once('dialog', dialog => { + console.log(`Dialog message: ${dialog.message()}`) + dialog.accept() + }); + await containersPage.getByRole('button', { name: 'Start and update containers' }).click(); + await expect(containersPage.getByRole('link', { name: 'Open your Nextcloud ↗' })).toBeVisible({ timeout: 5 * 60 * 1000 }); + await expect(containersPage.getByRole('main')).toContainText(initialNextcloudPassword); + + // Verify that containers are all stopped + await containersPage.getByRole('button', { name: 'Stop containers' }).click(); + await expect(containersPage.getByRole('button', { name: 'Start containers' })).toBeVisible({ timeout: 60 * 1000 }); +}); \ No newline at end of file