diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0bab9029919..6df54440463 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -8,6 +8,16 @@ module.exports = { extends: [ '@nextcloud', ], + overrides: [ + { + files: ['**/*.ts'], + rules: { + // Do not err out on constructors with parameter properties only. + 'no-useless-constructor': 'off', + '@typescript-eslint/no-useless-constructor': 'error', + }, + }, + ], rules: { '@typescript-eslint/no-unused-vars': ['off'], 'import/no-unresolved': [1, { ignore: ['\\.svg\\?raw$'] }], 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..be23e66cf85 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/ @@ -16,3 +18,7 @@ webpack-stats.json # js folder, to be updated wth --force /js + +# Playwright snapshots - only keep the ci version in git +playwright/e2e/*-snapshots/** +!playwright/e2e/*-snapshots/*-on-ci-** diff --git a/REUSE.toml b/REUSE.toml index b49b9590c76..80b2fce7035 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -30,7 +30,7 @@ SPDX-FileCopyrightText = "Nils Adermann, Jordi Boggiano" SPDX-License-Identifier = "MIT" [[annotations]] -path = ["cypress/fixtures/**", "cypress/snapshots/**", "src/tests/fixtures/**", "src/tests/**/__snapshots__/**"] +path = ["cypress/fixtures/**", "cypress/snapshots/**", "playwright/support/fixtures/files/**", "playwright/e2e/*-snapshots/**", "src/tests/fixtures/**", "src/tests/**/__snapshots__/**"] precedence = "aggregate" SPDX-FileCopyrightText = "2019-2025 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/cypress/e2e/SmartPicker.spec.js b/cypress/e2e/SmartPicker.spec.js deleted file mode 100644 index 8026c660ba3..00000000000 --- a/cypress/e2e/SmartPicker.spec.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { initUserAndFiles, randUser } from '../utils/index.js' - -const currentUser = randUser() - -const fileName = 'empty.md' - -describe('Smart picker', () => { - before(() => { - initUserAndFiles(currentUser, 'empty.md') - }) - - beforeEach(() => { - cy.login(currentUser) - cy.visit('/apps/files') - }) - - it('Type and see the smart picker', () => { - cy.isolateTest({ - sourceFile: fileName, - }) - cy.openFile(fileName, { force: true }) - - cy.getContent() - .type('/') - - cy.get('.tippy-box .suggestion-list').children().should(($children) => { - const entries = $children.find('.suggestion-list__item').map((i, el) => el.innerText).get() - expect(entries.length).to.be.greaterThan(0) - expect('To-Do list').to.be.oneOf(entries) - expect('Table').to.be.oneOf(entries) - }) - - cy.getContent() - .click({ force: true }) - - cy.getContent() - .type('Heading{enter}Hello World{enter}') - - cy.getContent() - .find('h1') - .should('contain.text', 'Hello World') - }) - - it('Insert a link with the smart picker', () => { - cy.isolateTest({ - sourceFile: fileName, - }) - cy.openFile(fileName, { force: true }) - - cy.getContent() - .type('/Any') - - cy.get('.tippy-box .suggestion-list').children().should(($children) => { - const entries = $children.find('.suggestion-list__item').map((i, el) => el.innerText).get() - expect(entries.length).to.be.eq(1) - expect('Any link').to.be.oneOf(entries) - }) - - cy.getContent() - .click({ force: true }) - - cy.getContent() - .type('{enter}') - - cy.get('.reference-picker-modal--content') - .should('be.visible') - - cy.get('.reference-picker input') - .type('https://github.com') - - cy.get('.reference-widget') - .should('be.visible') - .should('contain.text', 'GitHub') - - cy.get('.reference-picker input') - .type('{enter}') - - cy.getContent() - .find('a') - .should('contain.text', 'https://github.com') - - }) -}) diff --git a/cypress/e2e/mobile.spec.js b/cypress/e2e/mobile.spec.js deleted file mode 100644 index 940bcd7ae21..00000000000 --- a/cypress/e2e/mobile.spec.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { randUser } from '../utils/index.js' - -const user = randUser() - -const getRemainItem = (name) => { - cy.getActionEntry('remain').click() - return cy.get('.v-popper__wrapper .open').getActionEntry(name) -} - -describe('Mobile actions', { - // moto g4 - viewportWidth: 360, - viewportHeight: 640, -}, () => { - before(() => { - cy.createUser(user) - }) - - beforeEach(function() { - cy.login(user) - cy.visit('/apps/files').then(() => { - // isolate tests - each happens in its own folder - const retry = cy.state('test').currentRetry() - const folderName = retry - ? `${Cypress.currentTest.title} (${retry})` - : Cypress.currentTest.title - cy.createFolder(folderName) - cy.uploadFile('test.md', 'text/markdown', `${encodeURIComponent(folderName)}/text.md`) - - cy.visit(`apps/files?dir=/${encodeURIComponent(folderName)}`) - cy.openFile('text.md', { force: true }) - }) - }) - - it('formatting modal help', () => { - getRemainItem('formatting-help').click() - - cy.get('[data-text-el="formatting-help"]').should('be.visible') - cy.get('[data-text-el="formatting-help"]').find('button.modal-container__close').click() - cy.get('[data-text-el="formatting-help"]').should('not.exist') - }) -}) diff --git a/cypress/e2e/print.spec.js b/cypress/e2e/print.spec.js deleted file mode 100644 index 236f6ef899b..00000000000 --- a/cypress/e2e/print.spec.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { initUserAndFiles, randUser } from '../utils/index.js' -const user = randUser() - -describe('Open print.md and compare print view', function() { - before(function() { - initUserAndFiles(user, 'print.md') - }) - beforeEach(function() { - cy.login(user) - cy.visit('/apps/files') - }) - - it('Renders print view in viewer', function() { - cy.openFile('print.md') - cy.setCssMedia('print') - - cy.getEditor().should('be.visible') - cy.getContent() - // Ensure cursor is not displayed to prevent flaky tests (flashing input cursor) - .invoke('css', 'caret-color', 'transparent') - .get('h1:not(.hidden-visually)').should('contain', 'Print test') - .should('be.visible') - - cy.compareSnapshot('print view in viewer') - cy.setCssMedia('screen') - }) - - it('Renders print view in single-file share', function() { - cy.shareFile('/print.md', { edit: true }) - .then((token) => { - cy.logout() - cy.visit(`/s/${token}`) - cy.setCssMedia('print') - }) - .then(() => { - cy.getEditor().should('be.visible') - cy.getContent() - // Ensure cursor is not displayed to prevent flaky tests (flashing input cursor) - .invoke('css', 'caret-color', 'transparent') - .get('h1:not(.hidden-visually)').should('contain', 'Print test') - .should('be.visible') - - cy.compareSnapshot('print view in single-file share') - cy.setCssMedia('screen') - }) - }) -}) diff --git a/cypress/e2e/versions.spec.js b/cypress/e2e/versions.spec.js deleted file mode 100644 index 4c0350d8fb8..00000000000 --- a/cypress/e2e/versions.spec.js +++ /dev/null @@ -1,125 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { initUserAndFiles, randUser } from '../utils/index.js' - -const currentUser = randUser() - -const versionFileName = 'versioned.md' - -describe('Versions', () => { - before(() => { - initUserAndFiles(currentUser, 'empty.md') - }) - - beforeEach(() => { - cy.login(currentUser) - cy.visit('/apps/files') - }) - - it('View versions with close timestamps', () => { - cy.isolateTest().then(({ folderName, fileName }) => { - const fullPath = folderName + '/' + versionFileName - cy.createFile(fullPath, '# V1', 'text/markdown', { 'x-oc-mtime': 1691420501 }) - cy.createFile(fullPath, '# V2', 'text/markdown', { 'x-oc-mtime': 1691420521 }) - cy.createFile(fullPath, '# V3', 'text/markdown') - - cy.reloadFileList() - - cy.get('[data-cy-files-list-row-name="' + versionFileName + '"] [data-cy-files-list-row-mtime]').click() - cy.get('.app-sidebar-header').should('be.visible').should('contain', versionFileName) - cy.get('.app-sidebar-tabs__tab:contains("Versions")').click() - cy.get('[data-files-versions-versions-list] li a').should('have.length', 3) - - cy.get('[data-files-versions-versions-list] li a').eq(1).click() - cy.getContent() - .find('h1') - .should('contain.text', 'V2') - - cy.get('[data-files-versions-versions-list] li a').eq(2).click() - cy.getContent() - .find('h1') - .should('contain.text', 'V1') - - cy.get('[data-files-versions-versions-list] li a').eq(0).click() - cy.getContent() - .find('h1') - .should('contain.text', 'V3') - }) - }) - - it('View versions', () => { - cy.isolateTest().then(({ folderName, fileName }) => { - const fullPath = folderName + '/' + versionFileName - cy.createFile(fullPath, '# V1', 'text/markdown', { 'x-oc-mtime': 1691420521 }) - cy.createFile(fullPath, '# V2', 'text/markdown', { 'x-oc-mtime': 1691521521 }) - cy.createFile(fullPath, '# V3') - - cy.reloadFileList() - - cy.get('[data-cy-files-list-row-name="' + versionFileName + '"] [data-cy-files-list-row-mtime]').click() - - cy.get('.app-sidebar-header').should('be.visible').should('contain', versionFileName) - - cy.get('.app-sidebar-tabs__tab:contains("Versions")').click() - - cy.get('[data-files-versions-versions-list] li a').should('have.length', 3) - - cy.get('[data-files-versions-versions-list] li a').eq(1).click() - cy.getContent() - .find('h1') - .should('contain.text', 'V2') - - cy.get('[data-files-versions-versions-list] li a').eq(2).click() - cy.getContent() - .find('h1') - .should('contain.text', 'V1') - - cy.get('[data-files-versions-versions-list] li a').eq(0).click() - cy.getContent() - .find('h1') - .should('contain.text', 'V3') - - cy.getContent() - .type('Hello') - }) - }) - - it('Compare versions', () => { - cy.isolateTest().then(({ folderName, fileName }) => { - const fullPath = folderName + '/' + versionFileName - cy.createFile(fullPath, '# V1', 'text/markdown', { 'x-oc-mtime': 1691420521 }) - cy.createFile(fullPath, '# V2', 'text/markdown', { 'x-oc-mtime': 1691521521 }) - cy.createFile(fullPath, '# V3') - - cy.reloadFileList() - - cy.get('[data-cy-files-list-row-name="' + versionFileName + '"] [data-cy-files-list-row-mtime]').click() - - cy.get('.app-sidebar-header').should('be.visible').should('contain', versionFileName) - - cy.get('.app-sidebar-tabs__tab:contains("Versions")').click() - - cy.get('[data-files-versions-versions-list] li a').should('have.length', 3) - - cy.get('[data-files-versions-versions-list] li').eq(2) - .find('button.action-item__menutoggle').first().click({ force: true }) - - cy.get('.v-popper__inner') - .find('button') - .eq(1) - .should('contain', 'Compare to current version') - .click() - - cy.getContent() - .find('h1') - .should('contain.text', '#V1') - - cy.get('.viewer__content .viewer__file--active .ProseMirror') - .find('h1') - .should('contain.text', 'V3') - }) - }) -}) diff --git a/cypress/e2e/workspace.spec.js b/cypress/e2e/workspace.spec.js index d8a378ecc4c..2cb569c71af 100644 --- a/cypress/e2e/workspace.spec.js +++ b/cypress/e2e/workspace.spec.js @@ -30,59 +30,6 @@ describe('Workspace', function() { }) }) - it('Hides the workspace when switching to another folder', function() { - cy.uploadFile('test.md', 'text/markdown', `${this.testFolder}/README.md`) - cy.createFolder(`${this.testFolder}/subdirectory`) - cy.visitTestFolder() - cy.getFile('README.md') - cy.get('#rich-workspace .ProseMirror') - .should('contain', 'Hello world') - cy.openFolder('subdirectory') - cy.get('#rich-workspace') - .should('not.exist') - }) - - it('Hides the workspace when switching to another view', function() { - cy.uploadFile('test.md', 'text/markdown', `${this.testFolder}/README.md`) - cy.visitTestFolder() - cy.getFile('README.md') - cy.get('#rich-workspace .ProseMirror') - .should('contain', 'Hello world') - cy.get('a[href*="/apps/files/recent"]') - .click() - cy.get('#rich-workspace') - .should('not.exist') - }) - - it('adds a Readme.md', function() { - cy.visitTestFolder() - cy.createDescription() - openSidebar('Readme.md') - cy.get('#rich-workspace .text-editor .text-editor__wrapper') - .should('be.visible') - }) - - it('formats text', function() { - cy.visitTestFolder() - cy.openWorkspace() - const buttons = [ - ['bold', 'strong'], - ['italic', 'em'], - ['underline', 'u'], - ['strikethrough', 's'], - ] - cy.getContent().click() - buttons.forEach(([button, tag]) => testButtonUnselected(button, tag)) - // format is gone when text is gone - cy.getContent().type('Format me') - cy.getContent().find('s') - .should('not.exist') - cy.getContent() - .should('contain', 'Format me') - cy.getContent().type('{selectall}') - buttons.forEach(([button, tag]) => testButton(button, tag, 'Format me')) - }) - it('creates headings via submenu', function() { cy.visitTestFolder() cy.openWorkspace().type('Heading') @@ -296,11 +243,6 @@ describe('Workspace', function() { }) -const openSidebar = filename => { - cy.getFile(filename).find('.files-list__row-mtime').click() - cy.get('.app-sidebar-header').should('contain', filename) -} - /** * @param {string} button Name of the button to click. * @param {string} tag Html tag expected to be toggled. @@ -317,35 +259,3 @@ function testListButton(button, tag, content) { .should('have.class', 'is-active') .click() } - -/** - * @param {string} button Name of the button to click. - * @param {string} tag Html tag expected to be toggled. - * @param {string} content Content expected in the element. - */ -function testButton(button, tag, content) { - cy.getMenuEntry(button) - .should('not.have.class', 'is-active') - .click() - cy.getContent() - .find(`${tag}`) - .should('contain', content) - cy.getMenuEntry(button) - .should('have.class', 'is-active') - .click() -} - -/** - * - * @param {string} button Name of the button to click. - * @param {string} tag Html tag expected to be toggled. - */ -function testButtonUnselected(button, tag) { - cy.getMenuEntry(button) - .should('not.have.class', 'is-active') - .click() - cy.getContent().type('Format me') - cy.getContent().find(`${tag}`) - .should('contain', 'Format me') - cy.getContent().type('{selectall}{del}') -} diff --git a/cypress/snapshots/base/cypress/e2e/print.spec.js/print view in single-file share.png b/cypress/snapshots/base/cypress/e2e/print.spec.js/print view in single-file share.png deleted file mode 100644 index 5dfe7a98817..00000000000 Binary files a/cypress/snapshots/base/cypress/e2e/print.spec.js/print view in single-file share.png and /dev/null differ diff --git a/cypress/snapshots/base/cypress/e2e/print.spec.js/print view in viewer.png b/cypress/snapshots/base/cypress/e2e/print.spec.js/print view in viewer.png deleted file mode 100644 index 1890a446104..00000000000 Binary files a/cypress/snapshots/base/cypress/e2e/print.spec.js/print view in viewer.png and /dev/null differ diff --git a/cypress/support/component.js b/cypress/support/component.js deleted file mode 100644 index e244ef7abd7..00000000000 --- a/cypress/support/component.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import './commands.js' - -import { mount } from 'cypress/vue2' - -Cypress.Commands.add('mount', mount) diff --git a/package-lock.json b/package-lock.json index 13c94fb7990..69035b79424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,13 +95,14 @@ "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.1.1", "@nextcloud/vite-config": "^1.6.0", + "@playwright/test": "^1.58.1", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue2": "^2.3.4", "@vitest/coverage-v8": "^2.2.0-beta.2", "@vue/test-utils": "^1.3.0 <2", "@vue/tsconfig": "^0.5.1", "@vueuse/core": "^11.3.0", - "cypress": "^13.6.4", + "cypress": "^15.9.0", "cypress-split": "^1.24.23", "cypress-visual-regression": "^5.3.0", "cypress-vite": "^1.7.0", @@ -2167,16 +2168,6 @@ "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", "license": "Apache-2.0" }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", @@ -2248,9 +2239,9 @@ } }, "node_modules/@cypress/request": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz", - "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2267,7 +2258,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "6.14.0", + "qs": "~6.14.1", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", @@ -2295,6 +2286,7 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true, + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } @@ -4266,6 +4258,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.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", @@ -5830,6 +5838,13 @@ "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==" }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/toastify-js": { "version": "1.12.4", "resolved": "https://registry.npmjs.org/@types/toastify-js/-/toastify-js-1.12.4.tgz", @@ -7175,12 +7190,6 @@ "node": ">=8" } }, - "node_modules/async": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", - "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", - "dev": true - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -7216,15 +7225,17 @@ "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "*" } }, "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" }, "node_modules/axios": { "version": "1.12.2", @@ -7839,7 +7850,8 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/chai": { "version": "5.1.2", @@ -7924,15 +7936,6 @@ "node": ">= 16" } }, - "node_modules/check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/chevrotain": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", @@ -7994,10 +7997,20 @@ "license": "ISC" }, "node_modules/ci-info": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.2.tgz", - "integrity": "sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg==", - "dev": true + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } }, "node_modules/cipher-base": { "version": "1.0.6", @@ -8040,10 +8053,11 @@ } }, "node_modules/cli-table3": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", - "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.0" }, @@ -8051,7 +8065,7 @@ "node": "10.* || >= 12.*" }, "optionalDependencies": { - "@colors/colors": "1.5.0" + "colors": "1.4.0" } }, "node_modules/cli-truncate": { @@ -8126,6 +8140,17 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -8526,25 +8551,27 @@ "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==" }, "node_modules/cypress": { - "version": "13.6.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.4.tgz", - "integrity": "sha512-pYJjCfDYB+hoOoZuhysbbYhEmNW7DEDsqn+ToCLwuVowxUXppIWRr7qk4TVRIU471ksfzyZcH+mkoF0CQUKnpw==", + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.9.0.tgz", + "integrity": "sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { - "@cypress/request": "^3.0.0", + "@cypress/request": "^3.0.10", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", + "@types/tmp": "^0.2.3", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", - "buffer": "^5.6.0", + "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", - "check-more-types": "^2.24.0", + "ci-info": "^4.1.0", "cli-cursor": "^3.1.0", - "cli-table3": "~0.6.1", + "cli-table3": "0.6.1", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", @@ -8556,10 +8583,8 @@ "extract-zip": "2.0.1", "figures": "^3.2.0", "fs-extra": "^9.1.0", - "getos": "^3.2.1", - "is-ci": "^3.0.0", + "hasha": "5.2.2", "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", "listr2": "^3.8.3", "lodash": "^4.17.21", "log-symbols": "^4.0.0", @@ -8569,9 +8594,10 @@ "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", - "semver": "^7.5.3", "supports-color": "^8.1.1", - "tmp": "~0.2.1", + "systeminformation": "^5.27.14", + "tmp": "~0.2.4", + "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, @@ -8579,7 +8605,7 @@ "cypress": "bin/cypress" }, "engines": { - "node": "^16.0.0 || ^18.0.0 || >=20.0.0" + "node": "^20.1.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/cypress-split": { @@ -8646,19 +8672,6 @@ "vite": "^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/cypress/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/cytoscape": { "version": "3.30.2", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.30.2.tgz", @@ -11825,6 +11838,7 @@ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "*" } @@ -12057,15 +12071,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, - "dependencies": { - "async": "^3.2.0" - } - }, "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -12382,6 +12387,33 @@ "minimalistic-assert": "^1.0.1" } }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -12948,18 +12980,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -13305,7 +13325,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-unicode-supported": { "version": "0.1.0", @@ -13426,7 +13447,8 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -13685,7 +13707,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", @@ -13819,15 +13842,6 @@ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" }, - "node_modules/lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", - "dev": true, - "engines": { - "node": "> 0.8" - } - }, "node_modules/lib0": { "version": "0.2.117", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", @@ -15928,7 +15942,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -15986,6 +16001,53 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "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", @@ -16597,9 +16659,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -19047,6 +19109,33 @@ "optional": true, "peer": true }, + "node_modules/systeminformation": { + "version": "5.30.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.7.tgz", + "integrity": "sha512-33B/cftpaWdpvH+Ho9U1b08ss8GQuLxrWHelbJT1yw4M48Taj8W3ezcPuaLoIHZz5V6tVHuQPr5BprEfnBLBMw==", + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -19530,6 +19619,16 @@ "node": ">=6" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tributejs": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/tributejs/-/tributejs-5.1.3.tgz", @@ -19656,6 +19755,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" }, @@ -23133,13 +23233,6 @@ "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==" }, - "@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true - }, "@csstools/css-parser-algorithms": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", @@ -23164,9 +23257,9 @@ "requires": {} }, "@cypress/request": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz", - "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", "dev": true, "requires": { "aws-sign2": "~0.7.0", @@ -23182,7 +23275,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "6.14.0", + "qs": "~6.14.1", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", @@ -24500,6 +24593,15 @@ "dev": true, "optional": true }, + "@playwright/test": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "dev": true, + "requires": { + "playwright": "1.58.1" + } + }, "@popperjs/core": { "version": "2.11.5", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz", @@ -25515,6 +25617,12 @@ "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==" }, + "@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true + }, "@types/toastify-js": { "version": "1.12.4", "resolved": "https://registry.npmjs.org/@types/toastify-js/-/toastify-js-1.12.4.tgz", @@ -26418,12 +26526,6 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true }, - "async": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", - "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", - "dev": true - }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -26451,9 +26553,9 @@ "dev": true }, "aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "dev": true }, "axios": { @@ -26972,12 +27074,6 @@ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true }, - "check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", - "dev": true - }, "chevrotain": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", @@ -27022,9 +27118,9 @@ "dev": true }, "ci-info": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.2.tgz", - "integrity": "sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true }, "cipher-base": { @@ -27058,12 +27154,12 @@ } }, "cli-table3": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", - "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", "dev": true, "requires": { - "@colors/colors": "1.5.0", + "colors": "1.4.0", "string-width": "^4.2.0" } }, @@ -27121,6 +27217,13 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "optional": true + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -27448,24 +27551,25 @@ "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==" }, "cypress": { - "version": "13.6.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.4.tgz", - "integrity": "sha512-pYJjCfDYB+hoOoZuhysbbYhEmNW7DEDsqn+ToCLwuVowxUXppIWRr7qk4TVRIU471ksfzyZcH+mkoF0CQUKnpw==", + "version": "15.9.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.9.0.tgz", + "integrity": "sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==", "dev": true, "requires": { - "@cypress/request": "^3.0.0", + "@cypress/request": "^3.0.10", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", + "@types/tmp": "^0.2.3", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", - "buffer": "^5.6.0", + "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", - "check-more-types": "^2.24.0", + "ci-info": "^4.1.0", "cli-cursor": "^3.1.0", - "cli-table3": "~0.6.1", + "cli-table3": "0.6.1", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", @@ -27477,10 +27581,8 @@ "extract-zip": "2.0.1", "figures": "^3.2.0", "fs-extra": "^9.1.0", - "getos": "^3.2.1", - "is-ci": "^3.0.0", + "hasha": "5.2.2", "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", "listr2": "^3.8.3", "lodash": "^4.17.21", "log-symbols": "^4.0.0", @@ -27490,19 +27592,12 @@ "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", - "semver": "^7.5.3", "supports-color": "^8.1.1", - "tmp": "~0.2.1", + "systeminformation": "^5.27.14", + "tmp": "~0.2.4", + "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" - }, - "dependencies": { - "semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true - } } }, "cypress-split": { @@ -30003,15 +30098,6 @@ "resolve-pkg-maps": "^1.0.0" } }, - "getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, - "requires": { - "async": "^3.2.0" - } - }, "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -30245,6 +30331,24 @@ "minimalistic-assert": "^1.0.1" } }, + "hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, "hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -30656,15 +30760,6 @@ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true }, - "is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "requires": { - "ci-info": "^3.2.0" - } - }, "is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -31254,12 +31349,6 @@ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" }, - "lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", - "dev": true - }, "lib0": { "version": "0.2.117", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", @@ -32762,6 +32851,31 @@ } } }, + "playwright": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.58.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.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "dev": true + }, "pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -33241,9 +33355,9 @@ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==" }, "qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dev": true, "requires": { "side-channel": "^1.1.0" @@ -34966,6 +35080,12 @@ "optional": true, "peer": true }, + "systeminformation": { + "version": "5.30.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.7.tgz", + "integrity": "sha512-33B/cftpaWdpvH+Ho9U1b08ss8GQuLxrWHelbJT1yw4M48Taj8W3ezcPuaLoIHZz5V6tVHuQPr5BprEfnBLBMw==", + "dev": true + }, "tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -35333,6 +35453,12 @@ } } }, + "tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true + }, "tributejs": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/tributejs/-/tributejs-5.1.3.tgz", diff --git a/package.json b/package.json index b9d804214dc..edb1df7f27c 100644 --- a/package.json +++ b/package.json @@ -19,16 +19,17 @@ "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 --ext .js,.ts,.vue src cypress playwright", + "lint:fix": "tsc && eslint --ext .js,.ts,.vue src cypress playwright --fix", "serve": "BASE=${BASE:-/apps/text} NODE_ENV=development vite --mode development serve --host", "watch": "NODE_ENV=development NODE_OPTIONS='--max-old-space-size=8192' vite --mode development build --watch", - "lint": "tsc && eslint --ext .js,.ts,.vue src cypress", - "lint:fix": "tsc && eslint --ext .js,.ts,.vue src cypress --fix", + "start:nextcloud": "node playwright/start-nextcloud-server.mjs", "stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css css/*.scss", "stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css css/*.scss --fix", "test": "NODE_ENV=test vitest", + "test:coverage": "NODE_ENV=test vitest --coverage", "test:cypress": "cd cypress && ./runLocal.sh run", - "test:cypress:open": "cd cypress && ./runLocal.sh open", - "test:coverage": "NODE_ENV=test vitest --coverage" + "test:cypress:open": "cd cypress && ./runLocal.sh open" }, "browserslist": [ "extends @nextcloud/browserslist-config" @@ -124,13 +125,14 @@ "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.1.1", "@nextcloud/vite-config": "^1.6.0", + "@playwright/test": "^1.58.1", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue2": "^2.3.4", "@vitest/coverage-v8": "^2.2.0-beta.2", "@vue/test-utils": "^1.3.0 <2", "@vue/tsconfig": "^0.5.1", "@vueuse/core": "^11.3.0", - "cypress": "^13.6.4", + "cypress": "^15.9.0", "cypress-split": "^1.24.23", "cypress-visual-regression": "^5.3.0", "cypress-vite": "^1.7.0", 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/change-mime-type.spec.ts b/playwright/e2e/change-mime-type.spec.ts new file mode 100644 index 00000000000..63b3a462cd8 --- /dev/null +++ b/playwright/e2e/change-mime-type.spec.ts @@ -0,0 +1,46 @@ +/** + * 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 uploadFileTest } from '../support/fixtures/upload-file' + +const test = mergeTests(editorTest, uploadFileTest) + +test.beforeEach(async ({ open }) => { + await open() +}) + +test.describe('Changing mimetype from markdown to plaintext', () => { + test('resets the document session and indexed db', async ({ + editor, + file, + viewer, + }) => { + await editor.typeHeading('Hello world') + await viewer.close() + const plaintext = await file.move('test.txt') + await plaintext.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, + viewer, + }) => { + await editor.type('## Hello world') + await expect(editor.content).toHaveText('## Hello world') + await viewer.close() + const markdown = await file.move('test.md') + await markdown.open() + await expect(editor.getHeading({ name: 'Hello world' })).toBeVisible() + }) +}) diff --git a/playwright/e2e/folder-description/create.spec.ts b/playwright/e2e/folder-description/create.spec.ts new file mode 100644 index 00000000000..d3ca5fd0087 --- /dev/null +++ b/playwright/e2e/folder-description/create.spec.ts @@ -0,0 +1,27 @@ +/** + * 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' + +const test = mergeTests(editorTest, randomUserTest) + +test('from new menu', async ({ page, editor }) => { + await page.goto('/apps/files') + await page.getByRole('button', { name: 'New' }).click() + await page.getByRole('menuitem', { name: 'Add folder description' }).click() + await editor.content.click() + await editor.typeHeading('Hello world') + await expect(editor.getHeading({ name: 'Hello world' })).toBeVisible() + await expect( + page.locator('#rich-workspace .text-editor .text-editor__wrapper'), + ).toBeVisible() + await expect( + page + .locator('[data-cy-files-list-row-name="Readme.md"]') + .getByLabel('Actions'), + ).toBeVisible() +}) diff --git a/playwright/e2e/folder-description/navigate.spec.ts b/playwright/e2e/folder-description/navigate.spec.ts new file mode 100644 index 00000000000..2fbab570d7f --- /dev/null +++ b/playwright/e2e/folder-description/navigate.spec.ts @@ -0,0 +1,33 @@ +/** + * 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 folderDescriptionTest } from '../../support/fixtures/folder-description' +import { createFolder } from '../../support/fixtures/Node' + +const test = mergeTests(editorTest, folderDescriptionTest) + +test.use({ + fileContent: '# Folder description', +}) + +test('Shows Readme.md', async ({ open, editor }) => { + await open() + await expect(editor.getHeading({ name: 'Folder description' })).toBeVisible() +}) + +test('Hides in a different folder', async ({ editor, open, page, user }) => { + await createFolder({ name: 'Other folder', owner: user }) + await open() + await page.getByRole('button', { name: 'Other folder' }).click() + await expect(editor.getHeading({ name: 'Folder description' })).not.toBeVisible() +}) + +test('Hides in a different view', async ({ editor, open, page }) => { + await open() + await page.getByRole('link', { name: 'Recent' }).click() + await expect(editor.getHeading({ name: 'Folder description' })).not.toBeVisible() +}) diff --git a/playwright/e2e/format-text.spec.ts b/playwright/e2e/format-text.spec.ts new file mode 100644 index 00000000000..f71c6e3f35a --- /dev/null +++ b/playwright/e2e/format-text.spec.ts @@ -0,0 +1,43 @@ +/** + * 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 folderDescriptionTest } from '../support/fixtures/folder-description' +import { test as sharedFileTest } from '../support/fixtures/shared-file' +import { test as uploadFileTest } from '../support/fixtures/upload-file' + +const fileTest = mergeTests(editorTest, uploadFileTest) +const shareTest = mergeTests(editorTest, sharedFileTest) +const descriptionTest = mergeTests(editorTest, folderDescriptionTest) + +new Map([ + ['Own file', fileTest], + ['Shared file', shareTest], + ['Folder description', descriptionTest], +]).forEach((test, name) => { + test.describe(name, () => { + test('formats text', async ({ editor, open }) => { + await open() + const buttons = { + Bold: 'strong', + Italic: 'em', + Underline: 'u', + Strikethrough: 's', + } + await editor.type('Format me') + for (const [button] of Object.entries(buttons)) { + await expect(editor.getMenu(button)).not.toHaveClass('active') + } + await editor.content.press('Control+a') + for (const [button, tag] of Object.entries(buttons)) { + await editor.getMenu(button).click() + await expect(editor.el.locator(tag)).toContainText('Format me') + await editor.getMenu(button).click() + await expect(editor.el.locator(tag)).not.toBeVisible() + } + }) + }) +}) diff --git a/playwright/e2e/ime-input.spec.ts b/playwright/e2e/ime-input.spec.ts new file mode 100644 index 00000000000..a587df0d3b2 --- /dev/null +++ b/playwright/e2e/ime-input.spec.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Ideas taken from https://github.com/microsoft/playwright/issues/5777 and https://github.com/slab/quill/commit/0ea789f95fc4956287b3995f9495aafa367d4190 + +import { type Locator, expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor' +import { test as uploadFileTest } from '../support/fixtures/upload-file' + +const test = mergeTests(editorTest, uploadFileTest) + +test.beforeEach(async ({ open }) => { + await open() +}) + +let composingData = '' +const withKeyboardEvents = async ( + el: Locator, + key: string, + callback: () => Promise, +) => { + composingData += key + await el.dispatchEvent('keydown', { key }) + await callback() + await el.dispatchEvent('keyup', { key }) +} + +test('Input Chinese character via IME at beginning of paragraph works', async ({ + browserName, + editor, + page, +}) => { + test.skip( + browserName !== 'chromium', + 'IME testing is currently only implemented in Chromium API', + ) + + // Get developer tools API + const client = await page.context().newCDPSession(page) + + await editor.content.focus() + + await withKeyboardEvents(editor.content, 'w', async () => { + client.send('Input.imeSetComposition', { + selectionStart: composingData.length, + selectionEnd: composingData.length, + text: 'w', + }) + }) + await withKeyboardEvents(editor.content, 'o', async () => { + client.send('Input.imeSetComposition', { + selectionStart: composingData.length, + selectionEnd: composingData.length, + text: 'o', + }) + }) + await withKeyboardEvents(editor.content, 'Space', async () => { + client.send('Input.insertText', { + text: '我', + }) + }) + + await expect(editor.content).toHaveText('我') +}) diff --git a/playwright/e2e/mobile.spec.ts b/playwright/e2e/mobile.spec.ts new file mode 100644 index 00000000000..4e030421ef2 --- /dev/null +++ b/playwright/e2e/mobile.spec.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { devices, expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor' +import { test as uploadFileTest } from '../support/fixtures/upload-file' + +const test = mergeTests(editorTest, uploadFileTest) + +test.use({ + ...devices['Moto G4'], +}) + +test.beforeEach(async ({ open }) => { + await open() +}) + +test('Formatting help', async ({ editor }) => { + await editor.clickMenu('Remain', 'Formatting help') + await expect(editor.formattingHelp).toBeVisible() + await editor.formattingHelp.getByLabel('Close').click() + await expect(editor.formattingHelp).not.toBeVisible() +}) diff --git a/playwright/e2e/offline.spec.ts b/playwright/e2e/offline.spec.ts new file mode 100644 index 00000000000..3492a24e3ac --- /dev/null +++ b/playwright/e2e/offline.spec.ts @@ -0,0 +1,56 @@ +/** + * 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 offlineTest } from '../support/fixtures/offline' +import { test as uploadFileTest } from '../support/fixtures/upload-file' + +const test = mergeTests(editorTest, offlineTest, uploadFileTest) + +// As we switch on and off the network +// we cannot run tests in parallel. +test.describe.configure({ mode: 'serial' }) + +test.beforeEach(async ({ open }) => { + await open() +}) + +test('Offline state indicator', async ({ editor, setOffline }) => { + await expect(editor.sessionList).toBeVisible() + await expect(editor.offlineState).not.toBeVisible() + + await setOffline() + + await expect(editor.sessionList).not.toBeVisible() + await expect(editor.offlineState).toBeVisible() +}) + +test('Disabled upload and link file when offline', async ({ + editor, + setOffline, +}) => { + const linkToFile = editor.getMenuItem('Link to file or folder') + await editor.withOpenMenu('Insert link', () => expect(linkToFile).toBeEnabled()) + await expect(editor.getMenu('Insert attachment')).toBeEnabled() + + await setOffline() + + await editor.withOpenMenu('Insert link', () => expect(linkToFile).toBeDisabled()) + await expect(editor.getMenu('Insert attachment')).toBeDisabled() +}) + +test('typing offline and coming back online', async ({ + editor, + setOffline, + setOnline, +}) => { + await expect(editor.el).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/e2e/print.spec.ts b/playwright/e2e/print.spec.ts new file mode 100644 index 00000000000..01ff303236e --- /dev/null +++ b/playwright/e2e/print.spec.ts @@ -0,0 +1,27 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { hostname } from 'os' +import { test as editorTest } from '../support/fixtures/editor' +import { loadFixture } from '../support/fixtures/loadFixture' +import { test as sharedFileTest } from '../support/fixtures/shared-file' +import { test as uploadFileTest } from '../support/fixtures/upload-file' + +const fileTest = mergeTests(editorTest, uploadFileTest) +const shareTest = mergeTests(editorTest, sharedFileTest) +const host = process.env.CI ? 'ci' : hostname() + +new Map([ + [`Own file on ${host}`, fileTest], + [`Shared file on ${host}`, shareTest], +]).forEach((test, name) => { + test.use({ fileContent: loadFixture('print.md') }) + test(name, async ({ open, page }) => { + await open() + await page.emulateMedia({ media: 'print' }) + await expect(page).toHaveScreenshot({ fullPage: true }) + }) +}) diff --git a/playwright/e2e/print.spec.ts-snapshots/Own-file-on-ci-1-chromium-linux.png b/playwright/e2e/print.spec.ts-snapshots/Own-file-on-ci-1-chromium-linux.png new file mode 100644 index 00000000000..70286f2d051 Binary files /dev/null and b/playwright/e2e/print.spec.ts-snapshots/Own-file-on-ci-1-chromium-linux.png differ diff --git a/playwright/e2e/print.spec.ts-snapshots/Shared-file-on-ci-1-chromium-linux.png b/playwright/e2e/print.spec.ts-snapshots/Shared-file-on-ci-1-chromium-linux.png new file mode 100644 index 00000000000..70286f2d051 Binary files /dev/null and b/playwright/e2e/print.spec.ts-snapshots/Shared-file-on-ci-1-chromium-linux.png differ diff --git a/playwright/e2e/smart-picker.spec.ts b/playwright/e2e/smart-picker.spec.ts new file mode 100644 index 00000000000..707f1b5224e --- /dev/null +++ b/playwright/e2e/smart-picker.spec.ts @@ -0,0 +1,36 @@ +/** + * 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 uploadFileTest } from '../support/fixtures/upload-file' + +const test = mergeTests(editorTest, uploadFileTest) + +test.beforeEach(async ({ open }) => { + await open() +}) + +test('See top options', async ({ editor }) => { + await editor.type('/') + await expect(editor.getSuggestion('To-Do list')).toBeVisible() +}) + +test('Create heading', async ({ editor }) => { + await editor.type('/Heading') + await editor.content.press('Enter') + await editor.type('Hello world') + await editor.content.press('Enter') + await expect(editor.getHeading({ name: 'Hello world' })).toBeVisible() +}) + +test('Insert Link', async ({ editor }) => { + await editor.type('/Any') + await editor.getSuggestion('Any link').click() + await editor.referencePicker.fill('https://github.com') + await expect(editor.referenceWidget).toContainText('GitHub') + await editor.referencePicker.press('Enter') + await expect(editor.content.getByRole('link')).toContainText('github.com') +}) diff --git a/playwright/e2e/versions.spec.ts b/playwright/e2e/versions.spec.ts new file mode 100644 index 00000000000..594919c2036 --- /dev/null +++ b/playwright/e2e/versions.spec.ts @@ -0,0 +1,87 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests, type Locator } 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' +import type { EditorSection } from '../support/sections/EditorSection' + +const test = mergeTests(editorTest, randomUserTest, uploadFileTest) + +test.use({ + fileName: 'versions.md', + fileContent: '# V3', +}) + +test.describe('Versions with close timestamps', () => { + test.use({ + oldVersions: [ + [ + { content: '# V1', mtime: 1691420501 }, + { content: '# V2', mtime: 1691420521 }, + ], + { scope: 'test' }, + ], + }) + + test('Show up separately', async ({ editor, open, viewer }) => { + await open() + const versions = await viewer.openSidebarTab('Versions') + await checkVersions(versions, editor) + }) +}) + +test.describe('Versions with distant timestamps', () => { + test.use({ + oldVersions: [ + [ + { content: '# V1', mtime: 1691420501 }, + { content: '# V2', mtime: 1691424242 }, + ], + { scope: 'test' }, + ], + }) + + test('Show up', async ({ editor, open, viewer }) => { + await open() + const versions = await viewer.openSidebarTab('Versions') + await checkVersions(versions, editor) + }) + + test('Compare', async ({ open, page, viewer }) => { + await open() + const versions = await viewer.openSidebarTab('Versions') + await versions.getByRole('button').nth(2).click() + await page + .getByRole('menuitem', { name: 'Compare to current version' }) + .click() + const oldVersion = page.getByRole('textbox') + const current = page.locator( + '.viewer__content .viewer__file--active .ProseMirror', + ) + await expect(oldVersion.getByRole('heading', { name: 'V1' })).toBeVisible() + await expect(current.getByRole('heading', { name: 'V3' })).toBeVisible() + }) +}) + +/** + * Go through the different versions and check them in the editor + * + * Assumes 3 versions with content `# V1`, `# V2` and `# V3` + * @param versions Versions tab content + * @param editor editor section to inspect for headings + */ +async function checkVersions(versions: Locator, editor: EditorSection) { + expect(await versions.getByRole('link').count()).toBe(3) + // the oldest version is at the end of the versions list + await versions.getByRole('link').nth(2).click() + await expect(editor.getHeading({ name: 'V1' })).toBeVisible() + await versions.getByRole('link').nth(1).click() + await expect(editor.getHeading({ name: 'V2' })).toBeVisible() + // current version + await versions.getByRole('link').nth(0).click() + await expect(editor.getHeading({ name: 'V3' })).toBeVisible() +} 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/Node.ts b/playwright/support/fixtures/Node.ts new file mode 100644 index 00000000000..407b9b2b5c7 --- /dev/null +++ b/playwright/support/fixtures/Node.ts @@ -0,0 +1,125 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type Page } from '@playwright/test' +import type { User } from './User' + +/** + * Upload a file to the cloud. + * @param options options for the file upload + * @param options.name Name of the file + * @param options.content File content + * @param options.owner User who uploads the file + * @param options.mtime Last modified time of the file + * @return the file + */ +export async function uploadFile({ name, content = '', owner, mtime }: { + content?: string + name: string + owner: User + mtime?: number +}) { +// Upload file via WebDAV using page.request with requesttoken header + const headers = { + 'Content-Type': 'text/markdown', + ...(mtime ? { 'x-oc-mtime': mtime.toString() } : {}), + } + const response = await owner.request.put( + `/remote.php/webdav/${name}`, + { data: content, headers }, + ) + 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 id = ocFileId ? Number(ocFileId.split('oc')?.[0]) : 0 + return new Node({ id, name, page: owner.page }) +} + +/** + * Create a folder in the cloud. + * @param options options for the file upload + * @param options.name Name of the file + * @param options.owner User who uploads the file + * @return the folder + */ +export async function createFolder({ name, owner }: { + name: string + owner: User +}) { + const rootPath = `/remote.php/dav/files/${encodeURIComponent(owner.account.userId)}` + const dirPath = name.split('/').map(encodeURIComponent).join('/') + const response = await owner.request.fetch( + `${rootPath}/${dirPath}`, + { method: 'MKCOL' }, + ) + const id = parseInt(response.headers()['oc-fileid']) + return new Node({ id, name, page: owner.page }) +} + +const ocsHeaders = { + Accept: 'application/json, text/plain, */*', +} + +export class Node { + + public readonly id: number + public readonly name: string + private readonly page: Page + + constructor({ id, name, page }: { id: number, name: string, page: Page }) { + this.id = id + this.name = name + this.page = page + } + + async open() { + await this.page.goto(`f/${this.id}`) + await expect(this.page.getByLabel(this.name, { exact: true })) + .toBeVisible() + } + + async move(newName: string) { + await this.page.request.fetch( + `/remote.php/webdav/${this.name}`, + { + headers: { + Destination: `/remote.php/webdav/${newName}`, + }, + method: 'MOVE', + }) + return new Node({ ...this, page: this.page, name: newName }) + } + + async shareLink() { + // const shareType = window.OC?.Share?.SHARE_TYPE_LINK ?? 3 + const shareType = 3 + const path = `/${this.name}` + const response = await this.page.request.post( + '/ocs/v2.php/apps/files_sharing/api/v1/shares', + { + data: { shareType, path }, + headers: ocsHeaders, + }) + const { ocs } = await response.json() as { ocs: { data: { token: string, id: number } } } + return ocs.data + } + + async shareEditableLink() { + const { token, id } = await this.shareLink() + // Same permissions makeing the share editable in the UI would set + // 1 = read; 2 = write; 16 = share; + const permissions = 19 + await this.page.request.put( + `/ocs/v2.php/apps/files_sharing/api/v1/shares/${id}`, + { + data: { permissions }, + headers: ocsHeaders, + }) + return { token, id } + } + +} diff --git a/playwright/support/fixtures/User.ts b/playwright/support/fixtures/User.ts new file mode 100644 index 00000000000..f12fa2e504e --- /dev/null +++ b/playwright/support/fixtures/User.ts @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type Page } from '@playwright/test' +import type { User as Account } from '@nextcloud/e2e-test-server' +import { uploadFile } from './Node' + +export class User { + + constructor( + public readonly account: Account, + public readonly page: Page, + ) { + } + + get request() { + return this.page.request + } + + uploadFile(upload: Omit[0], 'owner'>) { + return uploadFile({ ...upload, owner: this }) + } + +} 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/cypress/fixtures/print.md b/playwright/support/fixtures/files/print.md similarity index 88% rename from cypress/fixtures/print.md rename to playwright/support/fixtures/files/print.md index 6291834724a..5134381f3de 100644 --- a/cypress/fixtures/print.md +++ b/playwright/support/fixtures/files/print.md @@ -13,6 +13,13 @@ - fifth - nineth + + +| very long table header one | header two | header three | +|--------------------------------------------------------------------|------------------------------------------|--------------------------------------------------------------| +| LoremipsumdolorsitametconsecteturadipiscingelitPraesentaugueturpis | Loremipsumdolorsitametconsecteturadipisc | LoremipsumdolorsitametconsecteturadipiscingelitPraesentaugue | +| abc | abcdedfhijklmnopqrstuv c | a | + Suspendisse a ullamcorper ex. Nullam id odio in ligula bibendum vehicula. Mauris in porttitor risus, ac consequat purus. Morbi consequat odio at lobortis venenatis. Aenean blandit justo et purus dignissim euismod. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec volutpat fringilla justo nec fringilla. Nulla a leo vel arcu fringilla auctor. Quisque aliquam leo velit, sit amet faucibus nisl tincidunt a. Ut eget porttitor metus. Sed erat tortor, mollis at arcu at, posuere interdum massa. Suspendisse vestibulum massa et diam ultrices pulvinar. Donec sagittis dui sed vehicula pellentesque. Etiam eu interdum mauris. - first @@ -83,4 +90,4 @@ Suspendisse a ullamcorper ex. Nullam id odio in ligula bibendum vehicula. Mauri - fifth - nineth - Suspendisse lacinia nulla scelerisque nisl egestas, ac eleifend ipsum lobortis. Vivamus ornare ultricies vestibulum. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse rhoncus rhoncus risus quis iaculis. Aliquam condimentum enim non pellentesque auctor. Fusce blandit vulputate tincidunt. In vehicula tortor eget metus congue feugiat. Suspendisse nulla nisi, feugiat in nibh eu, placerat semper odio. Maecenas in tincidunt velit, eget egestas ex. Sed ac imperdiet ante, eget placerat enim. Sed eu dapibus mauris. Morbi eros nisi, porta at ornare in, congue at tortor. Morbi porttitor dapibus velit sed ultricies. Etiam vestibulum dolor nulla, id vehicula massa sodales eget. Maecenas eu vulputate nulla, eu aliquet nunc. \ No newline at end of file + Suspendisse lacinia nulla scelerisque nisl egestas, ac eleifend ipsum lobortis. Vivamus ornare ultricies vestibulum. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse rhoncus rhoncus risus quis iaculis. Aliquam condimentum enim non pellentesque auctor. Fusce blandit vulputate tincidunt. In vehicula tortor eget metus congue feugiat. Suspendisse nulla nisi, feugiat in nibh eu, placerat semper odio. Maecenas in tincidunt velit, eget egestas ex. Sed ac imperdiet ante, eget placerat enim. Sed eu dapibus mauris. Morbi eros nisi, porta at ornare in, congue at tortor. Morbi porttitor dapibus velit sed ultricies. Etiam vestibulum dolor nulla, id vehicula massa sodales eget. Maecenas eu vulputate nulla, eu aliquet nunc. diff --git a/playwright/support/fixtures/folder-description.ts b/playwright/support/fixtures/folder-description.ts new file mode 100644 index 00000000000..9cd757facd4 --- /dev/null +++ b/playwright/support/fixtures/folder-description.ts @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from './upload-file' +import { expect } from '@playwright/test' + +export interface FolderDescriptionFixture { + fileName: string + open: () => Promise +} + +/** + * This test fixture creates a Readme.md in the user's root directory + * Note: You can use the config options of upload-file (`fileContent`, `oldVersions`) + * except for the `fileName`, which is hardcoded to `Readme.md`. + */ +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + fileName: ({ }, use) => use('Readme.md'), + + open: ({ file, page }, use) => use(async () => { + // Make sure file is initialized. + expect(file.name).toBe('Readme.md') + await page.goto('/apps/files') + }), + +}) diff --git a/playwright/support/fixtures/loadFixture.ts b/playwright/support/fixtures/loadFixture.ts new file mode 100644 index 00000000000..4538ee17498 --- /dev/null +++ b/playwright/support/fixtures/loadFixture.ts @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname } from 'path' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +/** + * Load a fixture file from playwright/support/fixtures/files directory + * @param filename Name of the file + */ +export function loadFixture(filename: string) { + return readFileSync(`${__dirname}/files/${filename}`, 'utf-8') +} diff --git a/playwright/support/fixtures/offline.ts b/playwright/support/fixtures/offline.ts new file mode 100644 index 00000000000..671b9990d03 --- /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 new file mode 100644 index 00000000000..a9d247ca8b5 --- /dev/null +++ b/playwright/support/fixtures/random-user.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 } from '@playwright/test' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import type { User as Account } from '@nextcloud/e2e-test-server' +import { User } from './User' + +export interface UserFixture { + account: Account + user: User +} + +/** + * 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 + account: async ({ }, use) => { + const account = await createRandomUser() + await use(account) + }, + page: async ({ account, browser, baseURL }, 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, account) + const tokenResponse = await page.request.get('./csrftoken', { + failOnStatusCode: true, + }) + const { token } = (await tokenResponse.json()) as { token: string } + page.context().setExtraHTTPHeaders({ requesttoken: token }) + + await use(page) + await page.close() + }, + user: async ({ account, page }, use) => { + const user = new User(account, page) + await use(user) + }, +}) diff --git a/playwright/support/fixtures/shared-file.ts b/playwright/support/fixtures/shared-file.ts new file mode 100644 index 00000000000..c1d8b8afd8e --- /dev/null +++ b/playwright/support/fixtures/shared-file.ts @@ -0,0 +1,78 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test as base } from './upload-file' +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { User } from './User' + +export interface SharedFileFixture { + owner: User + share: { token: string } +} + +/** + * This test fixture uploads the file to a user's root directory and shares it. + */ +export const test = base.extend({ + + // eslint-disable-next-line no-empty-pattern + owner: async ({ browser, baseURL }, use) => { + const account = await createRandomUser() + // 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, account) + const tokenResponse = await page.request.get('./csrftoken', { + failOnStatusCode: true, + }) + const { token } = (await tokenResponse.json()) as { token: string } + page.context().setExtraHTTPHeaders({ requesttoken: token }) + const user = new User(account, page) + await use(user) + }, + + file: async ({ fileContent, fileName, owner }, use) => { + const file = await owner.uploadFile({ name: fileName, content: fileContent }) + await use(file) + }, + + share: async ({ file }, use) => { + const { token } = await file.shareEditableLink() + await use({ token }) + }, + + page: async ({ baseURL, browser }, use) => { + // Important: make sure we use a clean environment by unsetting storage state. + const page = await browser.newPage({ + storageState: undefined, + baseURL, + }) + await use(page) + }, + + open: async ({ page, share }, use) => { + const open = async () => { + await page.goto(`s/${share.token}`) + await expect(page.getByRole('toolbar', { name: 'Formatting menu bar' })) + .toBeVisible() + } + await use(open) + }, + + close: async ({ page }, use) => { + const close = async () => { + await page.getByRole('button', { name: 'Close', exact: true }).click() + await page.waitForRequest(/close/) + await expect(page.getByRole('toolbar', { name: 'Formatting menu bar' })) + .not.toBeVisible() + } + await use(close) + }, + +}) diff --git a/playwright/support/fixtures/upload-file.ts b/playwright/support/fixtures/upload-file.ts new file mode 100644 index 00000000000..5147950ade5 --- /dev/null +++ b/playwright/support/fixtures/upload-file.ts @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test as base } from './random-user' +import { Node } from './Node' +import { ViewerSection } from '../sections/ViewerSection' + +export interface UploadFileFixture { + file: Node + fileName: string + fileContent: string + oldVersions: { content?: string, mtime: number }[] + open: () => Promise + close: () => Promise + viewer: ViewerSection +} + +/** + * 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({ + fileContent: ['', { option: true }], + fileName: ['empty.md', { option: true }], + oldVersions: [[], { option: true }], + + file: async ({ fileContent, fileName, oldVersions, user }, use) => { + const uploadVersion + = (opts: { content?: string, mtime?: number }) => + user.uploadFile({ name: fileName, ...opts }) + for (const version of oldVersions) { + await uploadVersion(version) + } + const file = await uploadVersion({ content: fileContent }) + await use(file) + }, + + open: ({ file }, use) => use(() => file.open()), + viewer: ({ fileName, page }, use) => use(new ViewerSection(fileName, page)), + +}) diff --git a/playwright/support/sections/EditorSection.ts b/playwright/support/sections/EditorSection.ts new file mode 100644 index 00000000000..fa388344f3c --- /dev/null +++ b/playwright/support/sections/EditorSection.ts @@ -0,0 +1,71 @@ +/** + * 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 el: Locator + public readonly content: Locator + public readonly formattingHelp: Locator + public readonly offlineState: Locator + public readonly referencePicker: Locator + public readonly referenceWidget: Locator + public readonly saveIndicator: Locator + public readonly sessionList: Locator + public readonly suggestions: Locator + + // eslint-disable-next-line no-useless-constructor + constructor(public readonly page: Page) { + this.el = this.page.locator('.editor').first() + this.content = this.el.getByRole('textbox') + this.formattingHelp = this.page.getByRole('dialog', { + name: 'Formatting and shortcuts', + }) + this.offlineState = this.el.locator('.offline-state') + this.referencePicker = this.page.locator('.reference-picker input') + this.referenceWidget = this.page.locator('.reference-widget') + this.saveIndicator = this.el.locator('.save-status') + this.sessionList = this.el.locator('.session-list') + this.suggestions = this.page.locator('.tippy-box .suggestion-list') + } + + 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.el.getByRole('button', { name }) + } + + public getMenuItem(name: string): Locator { + return this.el.getByRole('menuitem', { name }) + } + + public async clickMenu(menu: string, item: string): Promise { + await this.getMenu(menu).click() + await this.getMenuItem(item).click() + } + + 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 + } + + getHeading = (options: object = {}) => this.content.getByRole('heading', options) + getSuggestion = (name: string) => this.suggestions.getByText(name) + +} diff --git a/playwright/support/sections/ViewerSection.ts b/playwright/support/sections/ViewerSection.ts new file mode 100644 index 00000000000..e1d97a2ac44 --- /dev/null +++ b/playwright/support/sections/ViewerSection.ts @@ -0,0 +1,37 @@ +/** + * 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 ViewerSection { + + public readonly el: Locator + + constructor( + fileName: string, + public readonly page: Page, + ) { + this.el = this.page.getByRole('dialog', { name: fileName }) + } + + public async openSidebarTab(name: 'Versions') { + await this.clickAction('Open sidebar') + await this.page.getByRole('tab', { name }).click() + return this.page.getByRole('tabpanel', { name }) + } + + public async clickAction(name: string): Promise { + await this.el.getByLabel('Actions', { exact: true }).click() + await this.page.getByRole('menuitem', { name }).click() + } + + public async close(): Promise { + await this.page.getByRole('button', { name: 'Close', exact: true }).click() + await this.page.waitForRequest(/close/) + await expect(this.el).not.toBeVisible() + } + +} 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/css/print.scss b/src/css/print.scss index ff9677985fe..4a549d1a32e 100644 --- a/src/css/print.scss +++ b/src/css/print.scss @@ -88,6 +88,8 @@ // try no page breaks within tables or images break-inside: avoid-page; page-break-inside: avoid; + // no need to leave room for buttons + width: 100%; } // Add some borders below header and between columns diff --git a/vite.config.ts b/vite.config.ts index 32fa1376c20..33004f782c6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -71,6 +71,8 @@ const config = createAppConfig({ setupFiles: ['src/tests/setup.mjs'], environment: 'jsdom', globals: true, + include: ['src/tests/*.spec.[jt]s', 'src/tests/**/*.spec.[jt]s'], + exclude: ['**/node_modules/**', '**/.git/**', 'src/components/**'], server: { deps: { inline: [/@nextcloud.*/],