diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000000..d979566b380 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT + +name: Playwright Tests +on: + pull_request: + branches: [main] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - name: Checkout app + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Check composer.json + id: check_composer + uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v2 + with: + files: 'composer.json' + + - name: Install composer dependencies + if: steps.check_composer.outputs.files_exists == 'true' + run: composer install --no-dev + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^20' + fallbackNpm: '^10' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.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 node dependencies & build app + run: | + npm ci + TESTING=true npm run build --if-present + + - name: Install Playwright Browsers + run: npx playwright install chromium --only-shell + + - name: Run Playwright tests + run: npx playwright test + + - uses: actions/upload-artifact@v5 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index f6556e46dbb..30663e36afa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,9 @@ cypress/snapshots/actual cypress/snapshots/diff cypress/videos/ cypress/downloads/ +/playwright-report/ .php-cs-fixer.cache +/test-results/ /tests/clover.xml /tests/.phpunit.result.cache dist/ diff --git a/package-lock.json b/package-lock.json index dfdc98221c7..02e0115f08a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/prettier-config": "^1.2.0", "@nextcloud/vite-config": "^1.7.2", + "@playwright/test": "^1.56.1", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue2": "^2.3.4", "@vitest/coverage-v8": "^4.0.3", @@ -4250,6 +4251,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.5", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", @@ -16073,6 +16090,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/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/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -23656,6 +23720,15 @@ "integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==", "dev": true }, + "@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "requires": { + "playwright": "1.56.1" + } + }, "@popperjs/core": { "version": "2.11.5", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", @@ -31823,6 +31896,31 @@ "pathe": "^2.0.1" } }, + "playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.56.1" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true + }, "pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 49268c059d3..1bf5bb6cbea 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,13 @@ "scripts": { "build": "NODE_ENV=production NODE_OPTIONS='--max-old-space-size=4096' vite --mode production build", "dev": "NODE_ENV=development NODE_OPTIONS='--max-old-space-size=4096' vite --mode development build", - "lint": "tsc && ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.ts,.vue src cypress", - "lint:fix": "tsc && ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.ts,.vue src cypress --fix", + "lint": "tsc && ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.ts,.vue src cypress playwright", + "lint:fix": "tsc && ESLINT_USE_FLAT_CONFIG=false eslint --ext .js,.ts,.vue src cypress playwright --fix", "prettier": "prettier --check .", "prettier:change": "git diff HEAD --name-only | xargs prettier --write --no-error-on-unmatched-pattern", "prettier:fix": "prettier --write .", "serve": "BASE=${BASE:-/apps/text} NODE_ENV=development vite --mode development serve --host", + "start:nextcloud": "node playwright/start-nextcloud-server.mjs", "test": "NODE_ENV=test vitest run", "test:coverage": "NODE_ENV=test vitest run --coverage", "test:cypress": "cd cypress && ./runLocal.sh run", @@ -116,6 +117,7 @@ "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/prettier-config": "^1.2.0", "@nextcloud/vite-config": "^1.7.2", + "@playwright/test": "^1.56.1", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue2": "^2.3.4", "@vitest/coverage-v8": "^4.0.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000000..f1703309101 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineConfig, devices } from '@playwright/test' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './playwright', + + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'github' : 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('./')`. */ + baseURL: process.env.baseURL ?? 'http://localhost:8089/index.php/', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + projects: [ + // Our global setup to configure the Nextcloud docker container + { + name: 'setup', + testMatch: /setup\.ts$/, + }, + + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + dependencies: ['setup'], + }, + ], + + webServer: { + // Starts the Nextcloud docker container + command: 'npm run start:nextcloud', + reuseExistingServer: !process.env.CI, + url: 'http://127.0.0.1:8089', + stderr: 'pipe', + stdout: 'pipe', + timeout: 5 * 60 * 1000, // max. 5 minutes for creating the container + }, +}) diff --git a/playwright/e2e/offline.spec.ts b/playwright/e2e/offline.spec.ts new file mode 100644 index 00000000000..d3a34a395c3 --- /dev/null +++ b/playwright/e2e/offline.spec.ts @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type CDPSession, expect, mergeTests } from '@playwright/test' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as uploadFileTest } from '../support/fixtures/upload-file' + +const test = mergeTests(randomUserTest, uploadFileTest) + +const setOnline = async (client: CDPSession, online: boolean): Promise => { + if (online) { + await client.send('Network.emulateNetworkConditions', { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + await client.send('Network.disable') + } else { + await client.send('Network.enable') + await client.send('Network.emulateNetworkConditions', { + offline: true, + latency: 0, + downloadThroughput: 0, + uploadThroughput: 0, + }) + } +} + +test.beforeEach(async ({ page, file }) => { + await page.goto(`f/${file.fileId}`) +}) + +test.describe('Offline', () => { + test('Offline state indicator', async ({ context, page }) => { + await expect(page.locator('.session-list')).toBeVisible() + await expect(page.locator('.offline-state')).not.toBeVisible() + + const client = await context.newCDPSession(page) + await setOnline(client, false) + + await expect(page.locator('.session-list')).not.toBeVisible() + await expect(page.locator('.offline-state')).toBeVisible() + + await setOnline(client, true) + }) + + test('Disabled upload and link file when offline', async ({ context, page }) => { + await page.locator('[data-text-action-entry="insert-link"]').click() + await expect( + page.locator('[data-text-action-entry="insert-link-file"] button'), + ).toBeEnabled() + await page.locator('[data-text-action-entry="insert-link"]').click() + await expect( + page.locator('[data-text-action-entry="insert-attachment"] button'), + ).toBeEnabled() + + const client = await context.newCDPSession(page) + await setOnline(client, false) + + await page.locator('[data-text-action-entry="insert-link"]').click() + await expect( + page.locator('[data-text-action-entry="insert-link-file"] button'), + ).toBeDisabled() + await page.locator('[data-text-action-entry="insert-link"]').click() + await expect( + page.locator('[data-text-action-entry="insert-attachment"] button'), + ).toBeDisabled() + + await setOnline(client, true) + }) +}) diff --git a/playwright/start-nextcloud-server.mjs b/playwright/start-nextcloud-server.mjs new file mode 100644 index 00000000000..2719c52ea3c --- /dev/null +++ b/playwright/start-nextcloud-server.mjs @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { startNextcloud, stopNextcloud } from '@nextcloud/e2e-test-server/docker' +import { readFileSync } from 'fs' + +const start = async () => { + return await startNextcloud(getBranch(), true, { + exposePort: 8089, + }) +} + +const getBranch = () => { + try { + const appinfo = readFileSync('appinfo/info.xml').toString() + const maxVersion = appinfo.match( + //, + )?.[1] + return maxVersion ? `stable${maxVersion}` : undefined + } catch (err) { + if (err.code === 'ENOENT') { + console.warn('No appinfo/info.xml found. Using default server banch.') + } + } +} + +// Start the Nextcloud docker container +await start() +// Listen for process to exit (tests done) and shut down the docker container +process.on('beforeExit', (code) => { + stopNextcloud() +}) + +// Idle to wait for shutdown +while (true) { + await new Promise((resolve) => setTimeout(resolve, 5000)) +} diff --git a/playwright/support/fixtures/random-user.ts b/playwright/support/fixtures/random-user.ts new file mode 100644 index 00000000000..464bd3f4257 --- /dev/null +++ b/playwright/support/fixtures/random-user.ts @@ -0,0 +1,48 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { type User } from '@nextcloud/e2e-test-server' + +interface RandomUserFixture { + user: User + requestToken: string +} + +/** + * This test fixture ensures a new random user is created and used for the test (current page) + */ +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + user: async ({ }, use) => { + const user = await createRandomUser() + await use(user) + }, + requestToken: async ({ page }, use) => { + // Navigate to get the page context and extract request token + await page.goto('/') + + // Get the request token from the page context + const token = await page.evaluate(() => { + // @ts-expect-error - OC is a global variable + return window.OC?.requestToken || '' + }) + + await use(token) + }, + page: async ({ browser, baseURL, user }, use) => { + // Important: make sure we authenticate in a clean environment by unsetting storage state. + const page = await browser.newPage({ + storageState: undefined, + baseURL, + }) + + await login(page.request, user) + + await use(page) + await page.close() + }, +}) diff --git a/playwright/support/fixtures/upload-file.ts b/playwright/support/fixtures/upload-file.ts new file mode 100644 index 00000000000..e14755d3632 --- /dev/null +++ b/playwright/support/fixtures/upload-file.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' + +interface UploadMdFixture { + requestToken?: string + file: { + fileName: string + fileId: number + } +} + +/** + * This test fixture uploads the empty.md file to the user's root directory + * Note: This fixture requires the page to be authenticated (e.g., by merging with random-user fixture) + */ +export const test = base.extend({ + file: async ({ page, requestToken }, use) => { + const fileName = 'empty.md' + const fileContent = '' + + if (!requestToken) { + throw new Error('requestToken is required. Make sure to merge with random-user fixture.') + } + + // Upload file via WebDAV using page.request with requesttoken header + const response = await page.request.put( + `/remote.php/webdav/${fileName}`, + { + data: fileContent, + headers: { + 'Content-Type': 'text/markdown', + 'requesttoken': requestToken, + }, + }, + ) + + if (!response.ok()) { + throw new Error(`Failed to upload file: ${response.status()} ${response.statusText()}`) + } + + // Extract file ID from response headers + const ocFileId = response.headers()['oc-fileid'] + const fileId = ocFileId ? Number(ocFileId.split('oc')?.[0]) : 0 + + await use({ fileName, fileId }) + }, +}) diff --git a/playwright/support/setup.ts b/playwright/support/setup.ts new file mode 100644 index 00000000000..6d188b440a4 --- /dev/null +++ b/playwright/support/setup.ts @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { configureNextcloud } from '@nextcloud/e2e-test-server' +import { test as setup } from '@playwright/test' + +/** + * We use this to ensure Nextcloud is configured correctly before running our tests + * + * This can not be done in the webserver startup process, + * as that only checks for the URL to be accessible which happens already before everything is configured. + */ +setup('Configure Nextcloud', async () => { + const appsToInstall = ['text', 'viewer'] + await configureNextcloud(appsToInstall) +}) diff --git a/src/components/Menu/ActionInsertLink.vue b/src/components/Menu/ActionInsertLink.vue index 9cc21ab31f5..c1bbd09fde0 100644 --- a/src/components/Menu/ActionInsertLink.vue +++ b/src/components/Menu/ActionInsertLink.vue @@ -36,6 +36,7 @@