diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index a819385947434..0ee34e813de26 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -141,56 +141,10 @@ jobs: matrix: # Run multiple copies of the current job in parallel # Please increase the number or runners as your tests suite grows (0 based index for e2e tests) - containers: ['setup', '0', '1', '2', '3'] - # Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions + containers: ['0', '1', '2', '3'] # Always align this number with the total of e2e runners (max. index + 1) total-containers: [4] - services: - mysql: - # Only start mysql if we are running the setup tests - image: ${{matrix.containers == 'setup' && 'ghcr.io/nextcloud/continuous-integration-mysql-8.4:latest' || ''}} # zizmor: ignore[unpinned-images] - ports: - - '3306/tcp' - env: - MYSQL_ROOT_PASSWORD: rootpassword - MYSQL_USER: oc_autotest - MYSQL_PASSWORD: nextcloud - MYSQL_DATABASE: oc_autotest - options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 10 - - mariadb: - # Only start mariadb if we are running the setup tests - image: ${{matrix.containers == 'setup' && 'mariadb:11.4' || ''}} # zizmor: ignore[unpinned-images] - ports: - - '3306/tcp' - env: - MYSQL_ROOT_PASSWORD: rootpassword - MYSQL_USER: oc_autotest - MYSQL_PASSWORD: nextcloud - MYSQL_DATABASE: oc_autotest - options: --health-cmd="mariadb-admin ping" --health-interval 5s --health-timeout 2s --health-retries 5 - - postgres: - # Only start postgres if we are running the setup tests - image: ${{matrix.containers == 'setup' && 'ghcr.io/nextcloud/continuous-integration-postgres-17:latest' || ''}} # zizmor: ignore[unpinned-images] - ports: - - '5432/tcp' - env: - POSTGRES_USER: root - POSTGRES_PASSWORD: rootpassword - POSTGRES_DB: nextcloud - options: --mount type=tmpfs,destination=/var/lib/postgresql/data --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 - - oracle: - # Only start oracle if we are running the setup tests - image: ${{matrix.containers == 'setup' && 'ghcr.io/gvenzl/oracle-free:23' || ''}} # zizmor: ignore[unpinned-images] - ports: - - '1521' - env: - ORACLE_PASSWORD: oracle - options: --health-cmd healthcheck.sh --health-interval 20s --health-timeout 10s --health-retries 10 - name: runner ${{ matrix.containers }} steps: @@ -254,7 +208,6 @@ jobs: SPLIT: ${{ matrix.total-containers }} SPLIT_INDEX: ${{ matrix.containers == 'component' && 0 || matrix.containers }} SPLIT_RANDOM_SEED: ${{ github.run_id }} - SETUP_TESTING: ${{ matrix.containers == 'setup' && 'true' || '' }} - name: Upload snapshots and videos uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index a4f203b690ed7..aeadf828204db 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -147,10 +147,120 @@ jobs: path: blob-report retention-days: 1 + playwright-installer-tests: + needs: [gate, playwright-setup] + timeout-minutes: 30 + name: Playwright tests for installer + runs-on: ubuntu-latest + + # The installation-wizard tests exercise every supported database backend, so + # they need reachable database service containers. The Nextcloud container is + # joined to the GitHub Actions network at startup so it can resolve these by + # hostname (see tests/playwright/start-nextcloud-server.js). + services: + mysql: + image: mysql:9.7 # zizmor: ignore[unpinned-images] + ports: + - '3306/tcp' + env: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_USER: nextcloud + MYSQL_PASSWORD: nextcloud + MYSQL_DATABASE: nextcloud + options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 10 + + mariadb: + image: mariadb:12.3 # zizmor: ignore[unpinned-images] + ports: + - '3306/tcp' + env: + MARIADB_ROOT_PASSWORD: rootpassword + MARIADB_USER: nextcloud + MARIADB_PASSWORD: nextcloud + MARIADB_DATABASE: nextcloud + options: --health-cmd="mariadb-admin ping" --health-interval 5s --health-timeout 2s --health-retries 5 + + postgres: + image: postgres:18 # zizmor: ignore[unpinned-images] + ports: + - '5432/tcp' + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: rootpassword + POSTGRES_DB: nextcloud + options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 + + oracle: + image: ghcr.io/gvenzl/oracle-free:23 # zizmor: ignore[unpinned-images] + ports: + - '1521' + env: + ORACLE_PASSWORD: oracle + options: --health-cmd healthcheck.sh --health-interval 20s --health-timeout 10s --health-retries 10 + + steps: + - name: Restore context + id: cache + uses: buildjet/cache/restore@3e70d19e31d6a8030aeddf6ed8dbe601f94d09f4 # v4.0.2 + with: + key: playwright-context-${{ github.run_id }} + path: ./ + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + if: steps.cache.outputs.cache-hit != 'true' + with: + persist-credentials: false + submodules: true # for 3rdparty + + - name: Read package.json + if: steps.cache.outputs.cache-hit != 'true' + uses: nextcloud-libraries/parse-package-engines-action@122ae05d4257008180a514e1ddeb0c1b9d094bdd # v0.1.0 + id: versions + + - name: Set up node + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ steps.versions.outputs.node-version }} + + - name: Set up npm + if: steps.cache.outputs.cache-hit != 'true' + run: npm i -g 'npm@${{ steps.versions.outputs.package-manager-version }}' + + - name: Install dependencies and build + if: steps.cache.outputs.cache-hit != 'true' + run: | + npm ci + npm run build --if-present + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Run Playwright setup tests + run: npm run playwright:setup + + - name: Show logs + if: failure() + run: | + for id in $(docker ps -aq); do + docker container inspect "$id" --format '=== Logs for container {{.Name}} ===' + docker logs "$id" >> nextcloud.log + done + echo '=== Nextcloud server logs ===' + docker exec nextcloud-e2e-test-server_server cat data/nextcloud.log || true + + - name: Upload blob report to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: blob-report-setup + path: blob-report + retention-days: 1 + merge-reports: # Merge reports after playwright-tests, even if some shards have failed if: ${{ !cancelled() }} - needs: [gate, playwright-tests] + needs: [gate, playwright-tests, playwright-installer-tests] runs-on: ubuntu-latest-low steps: @@ -206,7 +316,7 @@ jobs: permissions: contents: none runs-on: ubuntu-latest-low - needs: [gate, playwright-tests] + needs: [gate, playwright-tests, playwright-installer-tests] if: always() @@ -214,4 +324,4 @@ jobs: steps: - name: Summary status - run: if ${{ needs.playwright-tests.result != 'success' }}; then exit 1; fi + run: if ${{ needs.playwright-tests.result != 'success' || needs.playwright-installer-tests.result != 'success' }}; then exit 1; fi diff --git a/cypress.config.ts b/cypress.config.ts index cec4bf5c8d6f7..29b83148220a3 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -4,12 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { configureNextcloud, docker, getContainer, getContainerName, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server' +import { configureNextcloud, getContainerName, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server' import { defineConfig } from 'cypress' import cypressSplit from 'cypress-split' import vitePreprocessor from 'cypress-vite' import { existsSync, rmSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { nodePolyfills } from 'vite-plugin-node-polyfills' @@ -116,15 +116,7 @@ export default defineConfig({ } }) - // Check if we are running the setup checks - if (process.env.SETUP_TESTING === 'true') { - console.log('Adding setup tests to specPattern ๐Ÿงฎ') - config.specPattern = [join(__dirname, 'cypress/e2e/core/setup.ts')] - console.log('โ””โ”€ Done') - } else { - // If we are not running the setup tests, we need to remove the setup tests from the specPattern - cypressSplit(on, config) - } + cypressSplit(on, config) const mounts = { '3rdparty': resolve(__dirname, './3rdparty'), @@ -163,8 +155,6 @@ export default defineConfig({ }) // Setting container's IP as base Url config.baseUrl = `http://localhost:${port}/index.php` - // if needed for the setup tests, connect to the actions network - await connectToActionsNetwork() // make sure not to write into apps but use a local apps folder runExec(['mkdir', 'apps-cypress']) runExec(['cp', 'cypress/fixtures/app.config.php', 'config']) @@ -185,26 +175,3 @@ export default defineConfig({ }, }, }) - -/** - * Connect the running test container to the GitHub Actions network - */ -async function connectToActionsNetwork() { - if (process.env.SETUP_TESTING !== 'true') { - console.log('โ”œโ”€ Not running setup tests, skipping actions network connection ๐ŸŒ') - return - } - - console.log('โ”œโ”€ Looking for github actions network... ๐Ÿ”') - const networks = await docker.listNetworks() - const network = networks.find((network) => network.Name.startsWith('github_network')) - if (!network) { - console.log('โ”‚ โ””โ”€ No actions network found โš ๏ธ') - return - } - - console.log('โ”‚ |โ”€ Found actions network: ' + network.Name) - await docker.getNetwork(network.Id) - .connect({ Container: getContainer().id }) - console.log('โ”‚ โ””โ”€ Connected to actions network ๐ŸŒ') -} diff --git a/cypress/e2e/core/setup.ts b/cypress/e2e/core/setup.ts deleted file mode 100644 index 90391dbf70192..0000000000000 --- a/cypress/e2e/core/setup.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { randomString } from '../../support/utils/randomString.ts' -import { handlePasswordConfirmation } from '../core-utils.ts' - -type RecommendedAppsMode = 'skip' | 'install-success' | 'install-failure' - -/** - * DO NOT RENAME THIS FILE to .cy.ts โš ๏ธ - * This is not following the pattern of the other files in this folder - * because it is manually added to the tests by the cypress config. - */ -describe('Can install Nextcloud', { testIsolation: true, retries: 0 }, () => { - beforeEach(() => { - // Move the config file and data folder - cy.runCommand('rm /var/www/html/config/config.php', { failOnNonZeroExit: false }) - cy.runCommand('rm /var/www/html/data/owncloud.db', { failOnNonZeroExit: false }) - }) - - it('Sqlite', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true }) - - sharedSetup() - }) - - it('Sqlite - Install recommended apps (success)', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true }) - - sharedSetup('install-success') - }) - - it('Sqlite - Install recommended apps (failure)', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="dbtype-sqlite"] input').check({ force: true }) - - sharedSetup('install-failure') - }) - - it('MySQL', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').check({ force: true }) - - // Fill in the DB form - cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}oc_autotest') - cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}nextcloud') - cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}oc_autotest') - cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}mysql:3306') - - sharedSetup() - }) - - it('MariaDB', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').check({ force: true }) - - // Fill in the DB form - cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}oc_autotest') - cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}nextcloud') - cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}oc_autotest') - cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}mariadb:3306') - - sharedSetup() - }) - - it('PostgreSQL', () => { - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-pgsql"] input').check({ force: true }) - - // Fill in the DB form - cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}root') - cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}rootpassword') - cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}nextcloud') - cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}postgres:5432') - - sharedSetup() - }) - - it('Oracle', () => { - Cypress.config('pageLoadTimeout', 200000) - cy.runCommand('cp /var/www/html/tests/databases-all-config.php /var/www/html/config/config.php') - cy.visit('/') - cy.get('[data-cy-setup-form]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminlogin"]').should('be.visible') - cy.get('[data-cy-setup-form-field="adminpass"]').should('be.visible') - cy.get('[data-cy-setup-form-field="directory"]').should('have.value', '/var/www/html/data') - - // Select the SQLite database - cy.get('[data-cy-setup-form-field="dbtype-oci"] input').check({ force: true }) - - // Fill in the DB form - cy.get('[data-cy-setup-form-field="dbuser"]').type('{selectAll}system') - cy.get('[data-cy-setup-form-field="dbpass"]').type('{selectAll}oracle') - cy.get('[data-cy-setup-form-field="dbname"]').type('{selectAll}FREE') - cy.get('[data-cy-setup-form-field="dbhost"]').type('{selectAll}oracle:1521') - - sharedSetup() - }) -}) - -/** - * Shared admin setup function for the Nextcloud setup - * - * @param mode How to handle the recommended apps screen at the end of the - * install assistant: skip it, exercise the install button with a - * stubbed success response, or stub a failure response. - */ -function sharedSetup(mode: RecommendedAppsMode = 'skip') { - const randAdmin = 'admin-' + randomString(10) - - // Mock the app store listing. The recommended-apps view fetches the apps via - // the appstore OCS API (`GET โ€ฆ/apps/appstore/api/v1/apps`), so the fixture - // must be OCS-shaped (`ocs.data`). Keep this in sync with the fixture, which - // currently exposes two recommended apps (calendar, contacts). - cy.intercept('GET', '**/apps/appstore/api/v1/apps', { fixture: 'appstore/apps.json' }) - - // Fill in the form - cy.get('[data-cy-setup-form-field="adminlogin"]').type(randAdmin) - cy.get('[data-cy-setup-form-field="adminpass"]').type(randAdmin) - - // Nothing more to do on sqlite, let's continue - cy.get('[data-cy-setup-form-submit]').click() - - // Wait for the setup to finish - cy.location('pathname', { timeout: 10000 }) - .should('include', '/core/apps/recommended') - - // See the apps setup - cy.get('[data-cy-setup-recommended-apps]') - .should('be.visible') - .within(() => { - cy.findByRole('heading', { name: 'Recommended apps' }) - .should('be.visible') - cy.findByRole('button', { name: 'Skip' }) - .should('be.visible') - cy.findByRole('button', { name: 'Install recommended apps' }) - .should('be.visible') - }) - - if (mode === 'skip') { - // Skip the setup apps - cy.get('[data-cy-setup-recommended-apps-skip]').click() - - // Go to files - cy.visit('/apps/files/') - cy.get('[data-cy-files-content]').should('be.visible') - return - } - - // The recommended apps are installed one after another, each via a single - // OCS enable request (`POST โ€ฆ/apps/appstore/api/v1/apps/enable`). Stub it so - // we exercise the frontend flow without hitting the real app store. - cy.intercept('POST', '**/apps/appstore/api/v1/apps/enable', mode === 'install-success' - ? { statusCode: 200, body: { ocs: { meta: { status: 'ok', statuscode: 200, message: 'OK' }, data: { update_required: false } } } } - : { statusCode: 500, body: { ocs: { meta: { status: 'failure', statuscode: 500, message: 'Forced failure' }, data: [] } } }).as('enableApp') - - cy.get('[data-cy-setup-recommended-apps-install]').click() - - // Each app is enabled with a strict password confirmation, so one dialog is - // shown per app (there is no longer a single bulk request). Confirm every - // dialog and assert each enable request carries a Basic auth header. - // Keep RECOMMENDED_APP_COUNT in sync with the appstore/apps.json fixture. - const RECOMMENDED_APP_COUNT = 2 - for (let i = 0; i < RECOMMENDED_APP_COUNT; i++) { - cy.findByRole('dialog', { name: 'Authentication required' }) - .should('be.visible') - handlePasswordConfirmation(randAdmin) - cy.wait('@enableApp') - .its('request.headers.authorization') - .should('match', /^Basic /) - } - - // The frontend no longer redirects after installing; it stays on the - // recommended-apps page and reflects the per-app result inline. - cy.location('pathname').should('include', '/core/apps/recommended') - if (mode === 'install-success') { - cy.get('[data-cy-setup-recommended-apps]') - .should('contain.text', 'App already installed') - } else { - cy.get('[data-cy-setup-recommended-apps]') - .should('contain.text', 'App download or installation failed') - } -} diff --git a/package.json b/package.json index 2d1f3cbcfa682..d517ed6f7e313 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "lint": "eslint --suppressions-location build/eslint-baseline.json --no-error-on-unmatched-pattern ./cypress ./tests/playwright", "postlint": "build/demi.sh lint", "lint:fix": "concurrently 'npm run lint -- --fix' 'build/demi.sh lint:fix'", - "playwright": "playwright test", + "playwright": "playwright test --project=default --project=admin-settings", "playwright:install": "playwright install chromium-headless-shell", + "playwright:setup": "playwright test --project=setup", "sass": "sass --style compressed --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)", "sass:icons": "node build/icons.mjs", "sass:watch": "sass --watch --load-path core/css core/css/ $(for cssdir in $(find apps -mindepth 2 -maxdepth 2 -name \"css\"); do if ! $(git check-ignore -q $cssdir); then printf \"$cssdir \"; fi; done)", diff --git a/playwright.config.ts b/playwright.config.ts index bf88178fb95b4..ef6258fa715e0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,6 +17,18 @@ export default defineConfig({ trace: 'on-first-retry', }, projects: [ + { + // Installation-wizard tests. They un-install and re-install the shared + // server, so they must never run alongside other tests + name: 'setup', + fullyParallel: false, + workers: 1, + grep: /@setup/, + use: { + ...devices['Desktop Chrome'], + }, + }, + { name: 'admin-settings', fullyParallel: false, @@ -28,8 +40,9 @@ export default defineConfig({ }, { - name: 'chrome', + name: 'default', testMatch: /\/(?!admin-settings)[^/]*\.spec\.ts$/, + grepInvert: /@setup/, use: { ...devices['Desktop Chrome'], }, diff --git a/tests/playwright/e2e/core/setup.spec.ts b/tests/playwright/e2e/core/setup.spec.ts new file mode 100644 index 0000000000000..813d833606232 --- /dev/null +++ b/tests/playwright/e2e/core/setup.spec.ts @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Page } from '@playwright/test' +import type { DatabaseConnection } from '../../support/sections/SetupPage.ts' + +import { runExec } from '@nextcloud/e2e-test-server/docker' +import { test as base, expect } from '@playwright/test' +import { SetupPage } from '../../support/sections/SetupPage.ts' + +/** + * Installation-wizard tests. They repeatedly UN-INSTALL the shared server (so it + * can be set up again from scratch) and the non-SQLite cases need reachable + * database containers, so they run only in the dedicated setup job โ€” + * isolated in the `setup` Playwright project. + * They are tagged `@setup` for selective runs. + */ +const test = base.extend<{ setupPage: SetupPage }>({ + setupPage: async ({ page }, use) => { + await use(new SetupPage(page)) + }, +}) + +/** How to handle the recommended-apps screen at the end of the wizard. */ +type RecommendedAppsMode = 'skip' | 'install-success' | 'install-failure' + +// The recommended-apps view fetches the listing from the appstore OCS API, so +// the mock must be OCS-shaped. Keep the app count in sync with the two entries. +const APPSTORE_APPS = { + ocs: { + meta: { status: 'ok', statuscode: 200, message: 'OK' }, + data: [ + { id: 'calendar', name: 'Calendar', isCompatible: true, active: false, installed: false, internal: false }, + { id: 'contacts', name: 'Contacts', isCompatible: true, active: false, installed: false, internal: false }, + ], + }, +} +const RECOMMENDED_APP_COUNT = APPSTORE_APPS.ocs.data.length + +const ENABLE_SUCCESS = { ocs: { meta: { status: 'ok', statuscode: 200, message: 'OK' }, data: { update_required: false } } } +const ENABLE_FAILURE = { ocs: { meta: { status: 'failure', statuscode: 500, message: 'Forced failure' }, data: [] } } + +const MYSQL: DatabaseConnection = { user: 'root', password: 'rootpassword', name: 'nextcloud', host: 'mysql:3306' } +const MARIADB: DatabaseConnection = { user: 'root', password: 'rootpassword', name: 'nextcloud', host: 'mariadb:3306' } +const POSTGRES: DatabaseConnection = { user: 'root', password: 'rootpassword', name: 'nextcloud', host: 'postgres:5432' } +const ORACLE: DatabaseConnection = { user: 'system', password: 'oracle', name: 'FREE', host: 'oracle:1521' } + +/** A unique administration account name (also used as the password). */ +function randomAdmin(): string { + return `admin-${crypto.randomUUID().slice(0, 10)}` +} + +/** + * Stub the appstore listing (always) and, for the install modes, the per-app + * enable request โ€” so the flow is exercised without hitting the real app store. + * Registered before the wizard submits, so the routes are live once the + * recommended-apps view mounts after the post-install redirect. + */ +async function mockAppstore(page: Page, mode: RecommendedAppsMode): Promise { + await page.route(/\/apps\/appstore\/api\/v1\/apps(\?.*)?$/, (route) => route.fulfill({ json: APPSTORE_APPS })) + + if (mode !== 'skip') { + await page.route(/\/apps\/appstore\/api\/v1\/apps\/enable/, (route) => route.fulfill(mode === 'install-success' + ? { status: 200, json: ENABLE_SUCCESS } + : { status: 500, json: ENABLE_FAILURE })) + } +} + +/** + * Drive the admin creation + submit, assert the recommended-apps screen, then + * either skip to the files app or install the recommended apps and assert the + * inline per-app result. + */ +async function completeSetup(page: Page, setupPage: SetupPage, mode: RecommendedAppsMode): Promise { + const admin = randomAdmin() + await setupPage.install(admin, admin) + + await expect(setupPage.recommendedAppsHeading()).toBeVisible() + await expect(setupPage.skipButton()).toBeVisible() + await expect(setupPage.installRecommendedButton()).toBeVisible() + + if (mode === 'skip') { + await setupPage.skipButton().click() + await page.goto('apps/files/') + await expect(page.locator('[data-cy-files-content]')).toBeVisible() + return + } + + await setupPage.installRecommendedApps(admin, RECOMMENDED_APP_COUNT) + + // The frontend stays on the recommended-apps page and reflects each app's + // result inline (no redirect after installing). + await expect(page).toHaveURL(/\/core\/apps\/recommended/) + await expect(setupPage.recommendedApps()).toContainText(mode === 'install-success' ? 'App already installed' : 'App download or installation failed') +} + +test.describe('Nextcloud installation wizard', { tag: '@setup' }, () => { + test.beforeEach(async () => { + // Reset the instance to an uninstalled state so the wizard is served again + await runExec(['rm', '-f', 'config/config.php'], { failOnError: false }) + await runExec(['rm', '-f', 'data/owncloud.db'], { failOnError: false }) + }) + + test.describe('SQLite', { tag: '@db_sqlite' }, () => { + test('installs with SQLite', async ({ page, setupPage }) => { + await mockAppstore(page, 'skip') + await setupPage.open() + + await expect(setupPage.adminLoginField()).toBeVisible() + await expect(setupPage.adminPasswordField()).toBeVisible() + await expect(setupPage.dataFolderField()).toHaveValue('/var/www/html/data') + + await setupPage.selectDatabase('SQLite') + await completeSetup(page, setupPage, 'skip') + }) + + test('installs with SQLite and installs recommended apps (success)', async ({ page, setupPage }) => { + await mockAppstore(page, 'install-success') + await setupPage.open() + + await setupPage.selectDatabase('SQLite') + await completeSetup(page, setupPage, 'install-success') + }) + + test('installs with SQLite and reports failed recommended apps', async ({ page, setupPage }) => { + await mockAppstore(page, 'install-failure') + await setupPage.open() + + await setupPage.selectDatabase('SQLite') + await completeSetup(page, setupPage, 'install-failure') + }) + }) + + test('installs with MySQL', { tag: '@db_mysql' }, async ({ page, setupPage }) => { + await mockAppstore(page, 'skip') + await setupPage.open() + + await setupPage.selectDatabase('MySQL/MariaDB') + await setupPage.fillDatabaseConnection(MYSQL) + await completeSetup(page, setupPage, 'skip') + }) + + test('installs with MariaDB', { tag: '@db_mariadb' }, async ({ page, setupPage }) => { + await mockAppstore(page, 'skip') + await setupPage.open() + + await setupPage.selectDatabase('MySQL/MariaDB') + await setupPage.fillDatabaseConnection(MARIADB) + await completeSetup(page, setupPage, 'skip') + }) + + test('installs with PostgreSQL', { tag: '@db_postgres' }, async ({ page, setupPage }) => { + await mockAppstore(page, 'skip') + await setupPage.open() + + await setupPage.selectDatabase('PostgreSQL') + await setupPage.fillDatabaseConnection(POSTGRES) + await completeSetup(page, setupPage, 'skip') + }) + + test('installs with Oracle', { tag: '@db_oracle' }, async ({ page, setupPage }) => { + // Installing into Oracle (and the Oracle container itself) is slow + test.setTimeout(200_000) + // Oracle is only offered when the server allows all databases + await runExec(['cp', 'tests/databases-all-config.php', 'config/config.php']) + + await mockAppstore(page, 'skip') + await setupPage.open() + + await setupPage.selectDatabase('Oracle') + await setupPage.fillDatabaseConnection(ORACLE) + await completeSetup(page, setupPage, 'skip') + }) +}) diff --git a/tests/playwright/start-nextcloud-server.js b/tests/playwright/start-nextcloud-server.js index bf4c9d54ea3e8..66759442b99e9 100644 --- a/tests/playwright/start-nextcloud-server.js +++ b/tests/playwright/start-nextcloud-server.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { configureNextcloud, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server/docker' +import { configureNextcloud, docker, getContainer, runExec, runOcc, startNextcloud, stopNextcloud, waitOnNextcloud } from '@nextcloud/e2e-test-server/docker' import { existsSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -42,11 +42,13 @@ async function start() { forceRecreate: true, }) - await runExec(['mkdir', '-p', 'apps-cypress']) - await runExec(['cp', 'cypress/fixtures/app.config.php', 'config']) + // The installer (setup) tests need to reach the database service containers + // (mysql, mariadb, โ€ฆ) that CI exposes on the GitHub Actions network. Join it + // when present; a no-op locally and in the normal test job where it is absent. + await connectToActionsNetwork() await waitOnNextcloud(ip) - await configureNextcloud() + await configureNextcloud(process.env.PLAYWRIGHT_SETUP ? [] : ['viewer']) process.stdout.write('\nApply custom configuration for Playwright tests\n') await runExec(['php', '-r', '$db = new SQLite3("data/owncloud.db");$db->busyTimeout(5000);$db->exec("PRAGMA journal_mode = wal;");']) @@ -68,6 +70,22 @@ async function start() { process.stdout.write('โ””โ”€ Nextcloud container ready to run Playwright tests\n') } +/** + * Connect the Nextcloud container to the GitHub Actions bridge network (named + * `github_network*`) if it exists, so it can resolve the database service + * containers by hostname. Does nothing when the network is absent. + */ +async function connectToActionsNetwork() { + const networks = await docker.listNetworks() + const network = networks.find((n) => n.Name.startsWith('github_network')) + if (!network) { + return + } + + await docker.getNetwork(network.Id).connect({ Container: getContainer().id }) + process.stdout.write('โ”œโ”€ Connected to the GitHub Actions network for the setup tests\n') +} + async function stop() { process.stderr.write('Stopping Nextcloud serverโ€ฆ\n') await stopNextcloud() diff --git a/tests/playwright/support/sections/SetupPage.ts b/tests/playwright/support/sections/SetupPage.ts new file mode 100644 index 0000000000000..cf12d22c352c4 --- /dev/null +++ b/tests/playwright/support/sections/SetupPage.ts @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page } from '@playwright/test' + +import { expect } from '@playwright/test' + +/** + * The database engines offered by the installation wizard, keyed by their + * visible (accessible) radio label. `oci` (Oracle) is only offered when the + * server is configured to allow all databases. + */ +export type DatabaseName = 'SQLite' | 'MySQL/MariaDB' | 'PostgreSQL' | 'Oracle' + +/** Connection details for a non-SQLite database backend. */ +export interface DatabaseConnection { + user: string + password: string + name: string + host: string +} + +/** + * The Nextcloud installation wizard (`core/src/views/Setup.vue`) and the + * recommended-apps screen shown right after a successful install + * (`core/src/components/setup/RecommendedApps.vue`). + * + * Locators are role/label based; the two view containers have no accessible + * name, so they keep their product-owned `data-cy` hooks. + */ +export class SetupPage { + constructor(private readonly page: Page) {} + + /** Open the installation wizard (served at the instance root when uninstalled). */ + async open(): Promise { + await this.page.goto('/') + await this.form().waitFor({ state: 'visible' }) + } + + form(): Locator { + return this.page.locator('[data-cy-setup-form]') + } + + adminLoginField(): Locator { + return this.page.getByRole('textbox', { name: 'Administration account name' }) + } + + adminPasswordField(): Locator { + // A password has no textbox role, so address it by its label + return this.page.getByLabel('Administration account password') + } + + dataFolderField(): Locator { + return this.page.getByRole('textbox', { name: 'Data folder' }) + } + + databaseRadio(name: DatabaseName): Locator { + return this.page.getByRole('radio', { name }) + } + + submitButton(): Locator { + return this.page.getByRole('button', { name: 'Install' }) + } + + /** Select a database engine and confirm the radio actually toggled. */ + async selectDatabase(name: DatabaseName): Promise { + const radio = this.databaseRadio(name) + // The radio input is visually hidden inside NcCheckboxRadioSwitch, so force it + await radio.check({ force: true }) + await expect(radio).toBeChecked() + } + + /** Fill the connection fields for a non-SQLite database backend. */ + async fillDatabaseConnection(connection: DatabaseConnection): Promise { + await this.page.getByRole('textbox', { name: 'Database user' }).fill(connection.user) + await this.page.getByLabel('Database password').fill(connection.password) + await this.page.getByRole('textbox', { name: 'Database name' }).fill(connection.name) + await this.page.getByRole('textbox', { name: 'Database host' }).fill(connection.host) + } + + /** + * Fill the administration account and submit the wizard, then wait for the + * install to finish and land on the recommended-apps screen. + */ + async install(adminUser: string, adminPassword: string): Promise { + await this.adminLoginField().fill(adminUser) + await this.adminPasswordField().fill(adminPassword) + await this.submitButton().click() + + await this.page.waitForURL(/\/core\/apps\/recommended/) + await this.recommendedApps().waitFor({ state: 'visible' }) + } + + // --- Recommended apps screen ------------------------------------------- + + recommendedApps(): Locator { + return this.page.locator('[data-cy-setup-recommended-apps]') + } + + recommendedAppsHeading(): Locator { + return this.page.getByRole('heading', { name: 'Recommended apps' }) + } + + skipButton(): Locator { + return this.page.getByRole('button', { name: 'Skip' }) + } + + installRecommendedButton(): Locator { + return this.page.getByRole('button', { name: 'Install recommended apps' }) + } + + /** + * Install the recommended apps and confirm the per-app password dialog that + * `@nextcloud/password-confirmation` raises for each enable request. The + * enables fire together, so one dialog is shown after another (never stacked): + * confirm each, waiting for the prompt to be consumed โ€” the input clears for + * the next app, or the dialog closes after the last โ€” before looking for the + * next. Confirm up to `appCount`, stopping early if a fresh session needs none. + */ + async installRecommendedApps(password: string, appCount: number): Promise { + await this.installRecommendedButton().click() + + for (let i = 0; i < appCount; i++) { + const dialog = this.page.getByRole('dialog', { name: 'Authentication required' }) + try { + await dialog.waitFor({ state: 'visible', timeout: 10_000 }) + } catch { + break + } + + const input = dialog.locator('input[type="password"]') + await input.fill(password) + await dialog.getByRole('button', { name: 'Confirm' }).click() + + // Wait until this prompt is resolved before seeking the next one, so the + // same dialog is never confirmed twice. + await expect(input).not.toHaveValue(password, { timeout: 10_000 }).catch(() => {}) + } + } +}