Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later

name: E2E Tests

on:
pull_request:
push:
branches:
- main
- master
- stable*

permissions:
contents: read

concurrency:
group: e2e-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
changes:
runs-on: ubuntu-latest-low
permissions:
contents: read
pull-requests: read

outputs:
src: ${{ steps.changes.outputs.src }}

steps:
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/e2e.yml'
- 'appinfo/**'
- 'lib/**'
- 'src/**'
- 'templates/**'
- 'e2e/**'
- 'playwright.config.ts'
- 'package.json'
- 'package-lock.json'

e2e-tests:
runs-on: ubuntu-latest

needs: [changes]
if: needs.changes.outputs.src != 'false'

name: Playwright E2E

steps:
- name: Checkout app
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
fallbackNode: '^24'
fallbackNpm: '^11.3'

- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ steps.versions.outputs.nodeVersion }}

- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install chromium --with-deps

- name: Build assets
run: npm run build

- name: Run E2E tests
run: npm run test:e2e

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30

summary:
permissions:
contents: none
runs-on: ubuntu-latest-low
needs: [changes, e2e-tests]

if: always()

name: e2e-summary

steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.e2e-tests.result != 'success' }}; then exit 1; fi
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ vendor/
/.php-cs-fixer.cache
/tests/.phpunit.result.cache
.DS_Store

# Playwright e2e test artifacts
/playwright-report/
/test-results/
/blob-report/
2 changes: 1 addition & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SPDX-PackageSupplier = "Nextcloud <info@nextcloud.com>"
SPDX-PackageDownloadLocation = "https://github.com/nextcloud/firstrunwizard"

[[annotations]]
path = [".gitattributes", ".github/issue_template.md", ".github/CODEOWNERS", ".editorconfig", "package-lock.json", "package.json", "composer.json", "composer.lock", "**/composer.json", "**/composer.lock", ".l10nignore", "cypress/tsconfig.json", "vendor-bin/**/composer.json", "vendor-bin/**/composer.lock", ".tx/config", "tsconfig.json", "krankerl.toml", ".npmignore", ".nextcloudignore"]
path = [".gitattributes", ".github/issue_template.md", ".github/CODEOWNERS", ".editorconfig", "package-lock.json", "package.json", "composer.json", "composer.lock", "**/composer.json", "**/composer.lock", ".l10nignore", "cypress/tsconfig.json", "e2e/tsconfig.json", "vendor-bin/**/composer.json", "vendor-bin/**/composer.lock", ".tx/config", "tsconfig.json", "krankerl.toml", ".npmignore", ".nextcloudignore"]
precedence = "aggregate"
SPDX-FileCopyrightText = "none"
SPDX-License-Identifier = "CC0-1.0"
Expand Down
106 changes: 106 additions & 0 deletions e2e/firstrunwizard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect } from '@playwright/test'
import { test } from './support/fixtures.ts'
import { setUserPreference } from './support/utils.ts'

test.describe('First Run Wizard', () => {
test('opens automatically on first login', async ({ page }) => {
// The fixture already logged in; the post-login redirect lands on the
// dashboard where the wizard is injected and opened automatically.
const wizard = page.locator('.first-run-wizard')
await expect(wizard).toBeVisible()

// For a brand-new user the intro animation (video) is shown first
await expect(wizard.locator('video')).toBeVisible()
})

test('opens when a new major version was shipped', async ({ page, user }) => {
// Simulate a user who last saw wizard version 2.0.0.
// The stored version is greater than "1" (so changelogOnly = true)
// but less than the current CHANGELOG_VERSION (33.0.0),
// so the wizard is injected and opened again.
await setUserPreference(user.userId, 'firstrunwizard', 'show', '2.0.0')

// Navigate to the dashboard with the updated preference so the page
// renders with changelogOnly=true.
await page.goto('/index.php/apps/dashboard/')

const wizard = page.locator('.first-run-wizard')
await expect(wizard).toBeVisible()

// The intro animation is always shown first; skip it to advance
// directly to the "What's new" page (changelog-only mode).
const skipButton = wizard.getByRole('button', { name: 'Skip' })
await expect(skipButton).toBeVisible({ timeout: 5_000 })
await skipButton.click()

// In changelog-only mode the wizard advances directly to the
// "What's new" page after the intro animation.
await expect(wizard).toContainText('New in Nextcloud Hub')
})

test('"About & What\'s new" menu entry reopens the wizard', async ({ page }) => {
// The wizard is already open from the post-login redirect to the dashboard.
const wizard = page.locator('.first-run-wizard')
await expect(wizard).toBeVisible()

// Skip the intro animation as soon as the Skip button appears (~2s)
const skipButton = wizard.getByRole('button', { name: 'Skip' })
await expect(skipButton).toBeVisible({ timeout: 5_000 })
await skipButton.click()

// Close the slideshow
const closeButton = wizard.getByRole('button', { name: 'Close' })
await expect(closeButton).toBeVisible()
await closeButton.click()
await expect(wizard).not.toBeVisible()

// Open the user settings menu to find the "About & What's new" entry
const userMenu = page.locator('[aria-controls="header-menu-user-menu"]')
await userMenu.click()

// Use the link role to avoid strict mode violation from the duplicate ID
// that Nextcloud renders on both the <li> and the inner <a> element
const aboutEntry = page.getByRole('link', { name: "About & What's new" })
await aboutEntry.click()

// The wizard should open again via the app-menu.ts handler
await expect(wizard).toBeVisible()
})

test('can be navigated and closed', async ({ page }) => {
// The wizard is already open from the post-login redirect to the dashboard.
const wizard = page.locator('.first-run-wizard')
await expect(wizard).toBeVisible()

// Skip the intro animation as soon as the Skip button appears (~2s)
const skipButton = wizard.getByRole('button', { name: 'Skip' })
await expect(skipButton).toBeVisible({ timeout: 5_000 })
await skipButton.click()

// The slideshow is now shown with the Close button always visible.
const closeButton = wizard.getByRole('button', { name: 'Close' })
await expect(closeButton).toBeVisible()

// Navigate through all pages by repeatedly clicking the last button
// (always the forward/primary navigation button) until the final
// page's "Get started!" button is reached.
const getStartedButton = wizard.getByRole('button', { name: 'Get started!' })

while (!(await getStartedButton.isVisible())) {
// The last button in DOM order is always the last navigation button
// in the button_wrapper (after the Close and Back buttons)
await wizard.getByRole('button').last().click()
}

// Clicking "Get started!" on the last page closes the wizard
await getStartedButton.click()

// The wizard should no longer be visible after closing
await expect(wizard).not.toBeVisible()
})
})
33 changes: 33 additions & 0 deletions e2e/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect } from '@playwright/test'
import { test } from './support/fixtures.ts'

test.describe('Settings page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/index.php/settings/user/sync-clients')
})

test('shows the sync clients section', async ({ page }) => {
// The SettingsClients section heading should be visible
await expect(
page.getByRole('heading', { name: 'Get the apps to sync your files' }),
).toBeVisible()
})

test('shows the connected apps section', async ({ page }) => {
// The SettingsApps section should be visible
const heading = page.getByRole('heading', { name: /Connect other apps to/i })
await expect(heading).toBeVisible()
})

test('shows the server address section', async ({ page }) => {
// The SettingsServer section should be visible
await expect(
page.getByRole('heading', { name: 'Server address' }),
).toBeVisible()
})
})
84 changes: 84 additions & 0 deletions e2e/start-nextcloud-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import {
configureNextcloud,
getContainerName,
startNextcloud,
stopNextcloud,
waitOnNextcloud,
} from '@nextcloud/e2e-test-server/docker'
import { readFileSync } from 'fs'
import { execSync } from 'node:child_process'

async function start() {
const appinfo = readFileSync('appinfo/info.xml').toString()
const maxVersion = appinfo.match(
/<nextcloud min-version="\d+" max-version="(\d\d+)" \/>/,
)?.[1]

let branch = 'master'
if (maxVersion) {
try {
const refs = execSync('git ls-remote --refs').toString('utf-8')
branch = refs.includes(`refs/heads/stable${maxVersion}`)
? `stable${maxVersion}`
: branch
} catch {
// If git command fails, fall back to 'master'
}
}

return await startNextcloud(branch, true, {
exposePort: 8089,
})
}

/**
* Patch the container's run.sh to remove SSL configuration so Apache starts
* on HTTP only. The Docker image's run.sh enables SSL but does not ship with
* a certificate, which causes Apache to fail to start. By removing the SSL
* directives we run on plain HTTP instead.
*
* This must be called after startNextcloud() (container is running) but before
* waitOnNextcloud() (Apache has not started yet), while initnc.sh is still
* setting up the Nextcloud instance.
*/
function makeHttpOnly() {
execSync(
`docker exec ${getContainerName()} `
+ `sed -i `
+ `-e '/a2enmod ssl/d' `
+ `-e '/a2ensite default-ssl/d' `
+ `-e '/a2enconf ssl-params/d' `
+ `-e '/apache2ctl configtest/d' `
+ `/usr/local/bin/run.sh`,
{ stdio: 'pipe', timeout: 30_000 },
)
}

async function stop() {
process.stderr.write('Stopping Nextcloud server…\n')
await stopNextcloud()
process.exit(0)
}

process.on('SIGTERM', stop)
process.on('SIGINT', stop)

// Start the Nextcloud docker container
const ip = await start()

// Patch run.sh to disable SSL so Apache starts on plain HTTP
makeHttpOnly()

await waitOnNextcloud(ip)
await configureNextcloud(['firstrunwizard'])

// Idle to keep the process alive until a SIGTERM/SIGINT signal is received
// (sent by Playwright's gracefulShutdown when tests finish)
while (true) {
await new Promise((resolve) => setTimeout(resolve, 5000))
}
38 changes: 38 additions & 0 deletions e2e/support/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
import type { User } from '@nextcloud/e2e-test-server'
import { test as baseTest } from '@playwright/test'

export type { User }

/**
* Extended test fixture that provides a logged-in random user for each test.
* The user is automatically created, authenticated via the Nextcloud login
* form, and deleted after the test.
*
* Using `auto: true` ensures every test in files that import this `test`
* object gets a fresh random-user session without having to explicitly
* request the `user` fixture.
*/
export const test = baseTest.extend<{ user: User }>({
user: [async ({ page }, use) => {
const user = await createRandomUser()

// Log in via the Nextcloud login form so session cookies are set in
// the browser page used by the test.
await page.goto('/index.php/login')
await page.locator('#user').fill(user.userId)
await page.locator('#password').fill(user.password)
await page.locator('[type=submit]').click()
await page.waitForURL('**/apps/**')

await use(user)

await runOcc(['user:delete', user.userId])
}, { auto: true }],
})
Loading
Loading