diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index d979566b380..c7806ba7cf2 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -54,5 +54,5 @@ jobs: if: always() with: name: playwright-report - path: playwright-report/ + path: test-results/ retention-days: 30 diff --git a/playwright/e2e/change-mime-type.spec.ts b/playwright/e2e/change-mime-type.spec.ts new file mode 100644 index 00000000000..212c1bdae51 --- /dev/null +++ b/playwright/e2e/change-mime-type.spec.ts @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor' +import { test as randomUserTest } from '../support/fixtures/random-user' +import { test as uploadFileTest } from '../support/fixtures/upload-file' + +const test = mergeTests(editorTest, randomUserTest, uploadFileTest) + +test.beforeEach(async ({ file }) => { + await file.open() +}) + +test.describe('Changing mimetype from markdown to plaintext', () => { + test('resets the document session and indexed db', async ({ editor, file }) => { + await editor.typeHeading('Hello world') + await file.close() + await file.move('test.txt') + await file.open() + await expect(editor.content).toHaveText('## Hello world') + await expect(editor.getHeading()).not.toBeVisible() + }) +}) + +test.describe('Changing mimetype from plain to markdown', () => { + test.use({ fileName: 'empty.txt' }) + + test('resets the document session and indexed db', async ({ editor, file }) => { + await editor.type('## Hello world') + await expect(editor.content).toHaveText('## Hello world') + await file.close() + await file.move('test.md') + await file.open() + await expect(editor.getHeading({ name: 'Hello world' })).toBeVisible() + }) +}) diff --git a/playwright/e2e/offline.spec.ts b/playwright/e2e/offline.spec.ts index d3a34a395c3..cffb9660583 100644 --- a/playwright/e2e/offline.spec.ts +++ b/playwright/e2e/offline.spec.ts @@ -3,72 +3,55 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { type CDPSession, expect, mergeTests } from '@playwright/test' +import { expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor' +import { test as offlineTest } from '../support/fixtures/offline' import { test as randomUserTest } from '../support/fixtures/random-user' import { test as uploadFileTest } from '../support/fixtures/upload-file' -const test = mergeTests(randomUserTest, uploadFileTest) +const test = mergeTests(editorTest, offlineTest, 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, - }) - } -} +// As we switch on and off the network +// we cannot run tests in parallel. +test.describe.configure({ mode: 'serial' }) -test.beforeEach(async ({ page, file }) => { - await page.goto(`f/${file.fileId}`) +test.beforeEach(async ({ file }) => { + await file.open() }) -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() +test('Offline state indicator', async ({ editor, setOffline }) => { + await expect(editor.sessionList).toBeVisible() + await expect(editor.offlineState).not.toBeVisible() - const client = await context.newCDPSession(page) - await setOnline(client, false) + await setOffline() - await expect(page.locator('.session-list')).not.toBeVisible() - await expect(page.locator('.offline-state')).toBeVisible() - - await setOnline(client, true) - }) + await expect(editor.sessionList).not.toBeVisible() + await expect(editor.offlineState).toBeVisible() +}) - 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() +test('Disabled upload and link file when offline', async ({ + editor, + setOffline, +}) => { + const linkToFile = editor.getMenu('insert-link-file') + await editor.withOpenMenu('insert-link', () => expect(linkToFile).toBeEnabled()) + await expect(editor.getMenu('insert-attachment')).toBeEnabled() - const client = await context.newCDPSession(page) - await setOnline(client, false) + await setOffline() - 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 editor.withOpenMenu('insert-link', () => expect(linkToFile).toBeDisabled()) + await expect(editor.getMenu('insert-attachment')).toBeDisabled() +}) - await setOnline(client, true) - }) +test('typing offline and coming back online', async ({ + editor, + setOffline, + setOnline, +}) => { + await expect(editor.locator).toBeVisible() + await setOffline() + await editor.typeHeading('Hello world') + await setOnline() + await expect(editor.offlineState).not.toBeVisible() + await expect(editor.saveIndicator).toHaveAttribute('title', /Unsaved changes/) }) diff --git a/playwright/support/fixtures/File.ts b/playwright/support/fixtures/File.ts new file mode 100644 index 00000000000..cb14d4e441a --- /dev/null +++ b/playwright/support/fixtures/File.ts @@ -0,0 +1,69 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type Page } from '@playwright/test' + +export class File { + name: string + page: Page + requestToken: string + id?: number + + constructor(name: string, page: Page, requestToken: string) { + this.name = name + this.page = page + this.requestToken = requestToken + } + + async upload(fileContent: string) { + + // Upload file via WebDAV using page.request with requesttoken header + const response = await this.page.request.put( + `/remote.php/webdav/${this.name}`, + { + data: fileContent, + headers: { + 'Content-Type': 'text/markdown', + 'requesttoken': this.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 + this.id = fileId + } + + async open() { + await this.page.goto(`f/${this.id}`) + await expect(this.page.getByLabel(this.name, { exact: true })) + .toBeVisible() + } + + async close() { + await this.page.getByRole('button', { name: 'Close', exact: true }).click() + await this.page.waitForRequest(/close/) + await expect(this.page.getByLabel(this.name, { exact: true })) + .not.toBeVisible() + } + + async move(newName: string) { + await this.page.request.fetch( + `/remote.php/webdav/${this.name}`, + { + headers: { + Destination: `/remote.php/webdav/${newName}`, + 'requesttoken': this.requestToken, + }, + method: 'MOVE', + }) + this.name = newName + } +} diff --git a/playwright/support/fixtures/editor.ts b/playwright/support/fixtures/editor.ts new file mode 100644 index 00000000000..bb217bffa9a --- /dev/null +++ b/playwright/support/fixtures/editor.ts @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as baseTest } from '@playwright/test' +import { EditorSection } from '../sections/EditorSection' + +interface EditorFixture { + editor: EditorSection +} + +export const test = baseTest.extend({ + editor: async ({ page }, use) => { + const editor = new EditorSection(page) + await use(editor) + }, +}) diff --git a/playwright/support/fixtures/offline.ts b/playwright/support/fixtures/offline.ts new file mode 100644 index 00000000000..0163aa9673b --- /dev/null +++ b/playwright/support/fixtures/offline.ts @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base, type CDPSession } from '@playwright/test' + +interface OfflineFixture { + setOffline: () => Promise + setOnline: () => Promise +} + +const setClientOnline = async (client: CDPSession): Promise => { + await client.send('Network.emulateNetworkConditions', { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }) + await client.send('Network.disable') +} + +const setClientOffline = async (client: CDPSession): Promise => { + await client.send('Network.enable') + await client.send('Network.emulateNetworkConditions', { + offline: true, + latency: 0, + downloadThroughput: 0, + uploadThroughput: 0, + }) +} + +/** + * setOffline will turn the network off for the rest of the test and then on again. + */ +export const test = base.extend({ + setOffline: async ({ context, page }, use) => { + const client = await context.newCDPSession(page) + await use (() => setClientOffline(client)) + await setClientOnline(client) + }, + setOnline: async ({ context, page }, use) => { + const client = await context.newCDPSession(page) + await use (() => setClientOnline(client)) + }, +}) diff --git a/playwright/support/fixtures/random-user.ts b/playwright/support/fixtures/random-user.ts index 464bd3f4257..2ee147cadfe 100644 --- a/playwright/support/fixtures/random-user.ts +++ b/playwright/support/fixtures/random-user.ts @@ -9,7 +9,6 @@ import { type User } from '@nextcloud/e2e-test-server' interface RandomUserFixture { user: User - requestToken: string } /** @@ -21,18 +20,6 @@ export const test = base.extend({ 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({ diff --git a/playwright/support/fixtures/request-token.ts b/playwright/support/fixtures/request-token.ts new file mode 100644 index 00000000000..e2c9c37e811 --- /dev/null +++ b/playwright/support/fixtures/request-token.ts @@ -0,0 +1,24 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from '@playwright/test' + +export interface RequestTokenFixture { + requestToken: string +} + +/** + * This test fixture ensures a new random user is created and used for the test (current page) + */ +export const test = base.extend({ + requestToken: async ({ page }, use) => { + const tokenResponse = await page.request.get('./csrftoken', { + failOnStatusCode: true, + }) + const token = (await tokenResponse.json()).token + + await use(token) + }, +}) diff --git a/playwright/support/fixtures/upload-file.ts b/playwright/support/fixtures/upload-file.ts index e14755d3632..a9f578ff2c7 100644 --- a/playwright/support/fixtures/upload-file.ts +++ b/playwright/support/fixtures/upload-file.ts @@ -3,14 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { test as base } from '@playwright/test' +import { test as base } from './request-token' +import { File } from './File' interface UploadMdFixture { - requestToken?: string - file: { - fileName: string - fileId: number - } + file: File + fileName: string } /** @@ -18,34 +16,12 @@ interface UploadMdFixture { * 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' + file: async ({ page, requestToken, fileName }, use) => { + const file = new File(fileName, page, requestToken) 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 }) + await file.upload(fileContent) + await use(file) }, + fileName: ['empty.md', {option: true}], }) + diff --git a/playwright/support/sections/EditorSection.ts b/playwright/support/sections/EditorSection.ts new file mode 100644 index 00000000000..464d19f7889 --- /dev/null +++ b/playwright/support/sections/EditorSection.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2025 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' + +export class EditorSection { + public readonly locator: Locator + public readonly content: Locator + public readonly sessionList: Locator + public readonly offlineState: Locator + public readonly saveIndicator: Locator + + // eslint-disable-next-line no-useless-constructor + constructor(public readonly page: Page) { + this.locator = this.page.locator('.editor').first() + this.content = this.locator.getByRole('textbox') + this.sessionList = this.locator.locator('.session-list') + this.offlineState = this.locator.locator('.offline-state') + this.saveIndicator = this.locator.locator('.save-status') + } + + public async type(keys: string): Promise { + await this.content.pressSequentially(keys) + } + + public async typeHeading(name: string): Promise { + await this.type('## ') + await this.type(name) + await expect(this.getHeading({ name })).toBeVisible() + } + + public getMenu(name: string): Locator { + return this.locator.locator(`[data-text-action-entry="${name}"] button`) + } + + public async withOpenMenu( + name: string, + fn: () => Promise, + ): Promise { + await this.getMenu(name).click() // open the menu + await fn() + await this.getMenu(name).click() // close the menu + } + + public getHeading(options: object = {}): Locator { + return this.content.getByRole('heading', options) + } +}