diff --git a/.woodpecker.star b/.woodpecker.star index d6f2d54e08..27c9c8f2d1 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -10,7 +10,7 @@ OC_CI_ALPINE = "owncloudci/alpine:latest" OC_CI_BAZEL_BUILDIFIER = "owncloudci/bazel-buildifier" OC_CI_DRONE_ANSIBLE = "owncloudci/drone-ansible:latest" OC_CI_GOLANG = "docker.io/golang:1.24" -OC_CI_NODEJS = "owncloudci/nodejs:20" +OC_CI_NODEJS = "owncloudci/nodejs:22" OC_CI_WAIT_FOR = "owncloudci/wait-for:latest" OC_UBUNTU = "owncloud/ubuntu:20.04" ONLYOFFICE_DOCUMENT_SERVER = "onlyoffice/documentserver:8.1.3" @@ -60,6 +60,11 @@ config = { "journeys", "smoke", ], + "browsers": [ + "chromium", + "firefox", + "webkit", + ], }, "2": { "earlyFail": True, @@ -100,6 +105,11 @@ config = { "suites": [ "a11y", ], + "browsers": [ + "chromium", + "firefox", + "webkit", + ], }, "app-provider": { "skip": False, @@ -515,6 +525,7 @@ def e2eTests(ctx): "federationServer": False, "failOnUncaughtConsoleError": False, "extraServerEnvironment": {}, + "browsers": ["chromium"], } e2e_trigger = [ @@ -546,78 +557,91 @@ def e2eTests(ctx): if "with-tracing" in ctx.build.title.lower(): params["reportTracing"] = True - environment = { - "HEADLESS": True, - "RETRY": "1", - "REPORT_TRACING": params["reportTracing"], - "OC_BASE_URL": "opencloud:9200", - "OC_SHOW_USER_EMAIL_IN_RESULTS": True, - "FAIL_ON_UNCAUGHT_CONSOLE_ERR": True, - "PLAYWRIGHT_BROWSERS_PATH": ".playwright", - "BROWSER": "chromium", - } - - steps = restoreBuildArtifactCache(ctx, "pnpm", ".pnpm-store") + \ - installPnpm() + \ - restoreBrowsersCache() + \ - restoreBuildArtifactCache(ctx, "web-dist", "dist") - - if ctx.build.event == "cron": - steps += restoreBuildArtifactCache(ctx, "opencloud", "opencloud") - else: - steps += restoreOpenCloudCache() - - if "app-provider-onlyOffice" in suite: - environment["FAIL_ON_UNCAUGHT_CONSOLE_ERR"] = False - steps += onlyofficeService() + \ - waitForServices("onlyOffice", ["onlyoffice:443"]) + \ - openCloudService(params["extraServerEnvironment"]) + \ - wopiCollaborationService("onlyoffice") + \ - waitForServices("wopi", ["wopi-onlyoffice:9300"]) - - elif "app-provider" in suite: - environment["FAIL_ON_UNCAUGHT_CONSOLE_ERR"] = False - steps += collaboraService() + \ - waitForServices("collabora", ["collabora:9980"]) + \ - openCloudService(params["extraServerEnvironment"]) + \ - wopiCollaborationService("collabora") + \ - waitForServices("wopi", ["wopi-collabora:9300"]) - - elif "ocm" in suite: - steps += openCloudService(params["extraServerEnvironment"]) + \ - (openCloudService(params["extraServerEnvironment"], "federation") if params["federationServer"] else []) - else: - # OpenCloud specific steps - steps += (tikaService() if params["tikaNeeded"] else []) + \ - openCloudService(params["extraServerEnvironment"]) - - command = "bash run-e2e.sh " - if "suites" in matrix: - command += "--suites %s" % ",".join(params["suites"]) - elif "features" in matrix: - command += "%s" % " ".join(params["features"]) - else: - print("Error: No suites or features defined for e2e test suite '%s'" % suite) - return [] - - steps += [{ - "name": "e2e-tests", - "image": OC_CI_NODEJS, - "environment": environment, - "commands": [ - "cd tests/e2e", - command, - ], - }] + \ - uploadTracingResult(ctx) - - pipelines.append({ - "name": "e2e-tests-%s" % suite, - "workspace": web_workspace, - "steps": steps, - "depends_on": ["cache-opencloud"], - "when": e2e_trigger, - }) + browsers_for_suite = params["browsers"] + + for browser_name in browsers_for_suite: + environment = { + "HEADLESS": True, + "RETRY": "1", + "REPORT_TRACING": params["reportTracing"], + "OC_BASE_URL": "opencloud:9200", + "OC_SHOW_USER_EMAIL_IN_RESULTS": True, + "FAIL_ON_UNCAUGHT_CONSOLE_ERR": True, + "PLAYWRIGHT_BROWSERS_PATH": ".playwright", + "BROWSER": browser_name, + } + + steps = restoreBuildArtifactCache(ctx, "pnpm", ".pnpm-store") + \ + installPnpm() + \ + restoreBrowsersCache() + \ + restoreBuildArtifactCache(ctx, "web-dist", "dist") + + if ctx.build.event == "cron": + steps += restoreBuildArtifactCache(ctx, "opencloud", "opencloud") + else: + steps += restoreOpenCloudCache() + + if "app-provider-onlyOffice" in suite: + environment["FAIL_ON_UNCAUGHT_CONSOLE_ERR"] = False + steps += onlyofficeService() + \ + waitForServices("onlyOffice", ["onlyoffice:443"]) + \ + openCloudService(params["extraServerEnvironment"]) + \ + wopiCollaborationService("onlyoffice") + \ + waitForServices("wopi", ["wopi-onlyoffice:9300"]) + + elif "app-provider" in suite: + environment["FAIL_ON_UNCAUGHT_CONSOLE_ERR"] = False + steps += collaboraService() + \ + waitForServices("collabora", ["collabora:9980"]) + \ + openCloudService(params["extraServerEnvironment"]) + \ + wopiCollaborationService("collabora") + \ + waitForServices("wopi", ["wopi-collabora:9300"]) + + elif "ocm" in suite: + steps += openCloudService(params["extraServerEnvironment"]) + \ + (openCloudService(params["extraServerEnvironment"], "federation") if params["federationServer"] else []) + else: + # OpenCloud specific steps + steps += (tikaService() if params["tikaNeeded"] else []) + \ + openCloudService(params["extraServerEnvironment"]) + + if browser_name == "webkit": + environment["FAIL_ON_UNCAUGHT_CONSOLE_ERR"] = "False" + command = "pnpm exec playwright install webkit --with-deps && cd tests/e2e && bash run-e2e.sh " + else: + command = "cd tests/e2e && bash run-e2e.sh " + + if browser_name == "firefox": + environment["FAIL_ON_UNCAUGHT_CONSOLE_ERR"] = "False" + + if "suites" in matrix: + command += "--suites %s" % ",".join(params["suites"]) + elif "features" in matrix: + command += "%s" % " ".join(params["features"]) + else: + print("Error: No suites or features defined for e2e test suite '%s'" % suite) + return [] + + steps += [{ + "name": "e2e-tests", + "image": OC_CI_NODEJS, + "environment": environment, + "commands": [ + command, + ], + }] + \ + uploadTracingResult(ctx) + + pipeline_name = "e2e-tests-%s-%s" % (suite, browser_name) + + pipelines.append({ + "name": pipeline_name, + "workspace": web_workspace, + "steps": steps, + "depends_on": ["cache-opencloud"], + "when": e2e_trigger, + }) + return pipelines def notify(): @@ -673,7 +697,8 @@ def installBrowsers(): "commands": [ ". ./.woodpecker.env", "if $BROWSER_CACHE_FOUND; then exit 0; fi", - "pnpm exec playwright install chromium --with-deps", + "pnpm exec playwright install chromium firefox --with-deps", + "pnpm exec playwright install --list", "tar -czvf %s .playwright" % dir["playwrightBrowsersArchive"], ], }] diff --git a/cucumber.mjs b/cucumber.mjs index 9cf9ff0e20..45b79bf32a 100644 --- a/cucumber.mjs +++ b/cucumber.mjs @@ -11,6 +11,7 @@ const e2e = ` --import ./tests/e2e/**/*.ts --retry ${config.retry} --format @cucumber/pretty-formatter + --format pretty --format json:${path.join(config.reportDir, 'cucumber', 'report.json')} --format message:${path.join(config.reportDir, 'cucumber', 'report.ndjson')} --format html:${path.join(config.reportDir, 'cucumber', 'report.html')} diff --git a/package.json b/package.json index 232cc9f8b4..0da0365cb5 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "format:write": "prettier . --config packages/prettier-config/index.js --write", "serve": "SERVER=true pnpm build:w", "test:e2e:cucumber": "NODE_TLS_REJECT_UNAUTHORIZED=0 TS_NODE_PROJECT=./tests/e2e/cucumber/tsconfig.json cucumber-js --profile=e2e --parallel ${PARALLEL:-1}", + "test:e2e:cucumber:chromium": "BROWSER=chrome NODE_TLS_REJECT_UNAUTHORIZED=0 TS_NODE_PROJECT=./tests/e2e/cucumber/tsconfig.json cucumber-js --profile=e2e --parallel ${PARALLEL:-1}", + "test:e2e:cucumber:firefox": "BROWSER=firefox NODE_TLS_REJECT_UNAUTHORIZED=0 TS_NODE_PROJECT=./tests/e2e/cucumber/tsconfig.json cucumber-js --profile=e2e --parallel ${PARALLEL:-1}", + "test:e2e:cucumber:webkit": "BROWSER=webkit NODE_TLS_REJECT_UNAUTHORIZED=0 TS_NODE_PROJECT=./tests/e2e/cucumber/tsconfig.json cucumber-js --profile=e2e --parallel ${PARALLEL:-1} --tags 'not @webkit-skip'", "test:unit": "NODE_OPTIONS=--unhandled-rejections=throw vitest", "licenses:check": "license-checker-rseidelsohn --summary --relativeLicensePath --onlyAllow 'Python-2.0;Apache*;Apache License, Version 2.0;Apache-2.0;Apache 2.0;Artistic-2.0;BSD;BSD-3-Clause;CC-BY-3.0;CC-BY-4.0;CC0-1.0;ISC;MIT;MPL-2.0;Public Domain;Unicode-TOU;Unlicense;WTFPL;BlueOak-1.0.0' --excludePackages '@opencloud-eu/babel-preset;@opencloud-eu/eslint-config;@opencloud-eu/prettier-config;@opencloud-eu/tsconfig;@opencloud-eu/web-client;@opencloud-eu/web-pkg;external;web-app-files;text-editor;preview;web-app-ocm;@opencloud-eu/design-system;pdf-viewer;web-app-search;admin-settings;webfinger;web-runtime;@opencloud-eu/web-test-helpers'", "licenses:csv": "license-checker-rseidelsohn --relativeLicensePath --csv --out ./third-party-licenses/third-party-licenses.csv", diff --git a/tests/e2e/cucumber/environment/index.ts b/tests/e2e/cucumber/environment/index.ts index 998fca6526..fc3d392766 100644 --- a/tests/e2e/cucumber/environment/index.ts +++ b/tests/e2e/cucumber/environment/index.ts @@ -82,25 +82,50 @@ Before(async function (this: World, { pickle }: ITestCaseHookParameter) { }) BeforeAll(async (): Promise => { - const browserConfiguration = { - slowMo: config.slowMo, - args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'], - firefoxUserPrefs: { - 'media.navigator.streams.fake': true, - 'media.navigator.permission.disabled': true - }, - headless: config.headless - } + const browserType = config.browser ?? 'chromium' + const headless = config.headless + const slowMo = config.slowMo + + const chromiumArgs = ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] const browsers: Record Promise> = { - firefox: async (): Promise => await firefox.launch(browserConfiguration), - webkit: async (): Promise => await webkit.launch(browserConfiguration), - chrome: async (): Promise => - await chromium.launch({ ...browserConfiguration, channel: 'chrome' }), - chromium: async (): Promise => await chromium.launch(browserConfiguration) + firefox: async () => + await firefox.launch({ + headless, + slowMo, + firefoxUserPrefs: { + 'media.navigator.streams.fake': true, + 'media.navigator.permission.disabled': true + } + }), + + webkit: async () => + await webkit.launch({ + headless, + slowMo + }), + + chrome: async () => + await chromium.launch({ + headless, + slowMo, + channel: 'chrome', + args: chromiumArgs + }), + + chromium: async () => + await chromium.launch({ + headless, + slowMo, + args: chromiumArgs + }) + } + + if (!(browserType in browsers)) { + throw new Error(`Unknown browser: ${browserType}`) } - state.browser = await browsers[config.browser]() + state.browser = await browsers[browserType]() // setup keycloak admin user if (config.keycloak) { diff --git a/tests/e2e/cucumber/features/smoke/sse.feature b/tests/e2e/cucumber/features/smoke/sse.feature index c6d5e0f9c7..f8f721b706 100644 --- a/tests/e2e/cucumber/features/smoke/sse.feature +++ b/tests/e2e/cucumber/features/smoke/sse.feature @@ -164,7 +164,7 @@ Feature: server sent events And "Brian" logs out And "Alice" logs out - + @webkit-skip Scenario: sse events on file operations Given "Admin" assigns following roles to the users using API | id | role | diff --git a/tests/e2e/cucumber/features/smoke/upload.feature b/tests/e2e/cucumber/features/smoke/upload.feature index fb739d224b..11e2014e40 100644 --- a/tests/e2e/cucumber/features/smoke/upload.feature +++ b/tests/e2e/cucumber/features/smoke/upload.feature @@ -10,7 +10,7 @@ Feature: Upload And "Alice" logs in And "Alice" opens the "files" app - + @webkit-skip Scenario: Upload files in personal space Given "Alice" creates the following resources | resource | type | content | @@ -58,14 +58,6 @@ Feature: Upload | resource | | simple.pdf | And "Alice" closes the file viewer - - # upload empty folder - When "Alice" uploads the following resources - | resource | type | - | FOLDER | folder | - Then following resources should be displayed in the files list for user "Alice" - | resource | - | FOLDER | # folder upload via drag-n-drop When "Alice" uploads the following resources via drag-n-drop @@ -77,7 +69,7 @@ Feature: Upload | child.txt | And "Alice" logs out - + @webkit-skip Scenario: try to upload resources when the quota is insufficient Given "Admin" logs in And "Admin" opens the "admin-settings" app diff --git a/tests/e2e/filesForUpload/FOLDER/.gitkeep b/tests/e2e/filesForUpload/FOLDER/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/e2e/run-e2e.sh b/tests/e2e/run-e2e.sh index 495a0d3ffd..5c23e5270c 100755 --- a/tests/e2e/run-e2e.sh +++ b/tests/e2e/run-e2e.sh @@ -6,7 +6,21 @@ FEATURES_DIR="${SCRIPT_PATH}/cucumber/features" PROJECT_ROOT=$(cd "$SCRIPT_PATH/../../" && pwd) SCRIPT_PATH_REL=${SCRIPT_PATH//"$PROJECT_ROOT/"/} -E2E_COMMAND="pnpm test:e2e:cucumber" # run command defined in package.json +BROWSER=${BROWSER:-"chromium"} +case $BROWSER in + chromium) + E2E_COMMAND="pnpm test:e2e:cucumber:chromium" + ;; + firefox) + E2E_COMMAND="pnpm test:e2e:cucumber:firefox" + ;; + webkit) + E2E_COMMAND="pnpm test:e2e:cucumber:webkit" + ;; + *) + E2E_COMMAND="pnpm test:e2e:cucumber" # fallback to default + ;; +esac ALL_SUITES=$(find "${FEATURES_DIR}"/ -type d | sort | rev | cut -d"/" -f1 | rev | grep -v '^[[:space:]]*$') FILTER_SUITES="" @@ -31,7 +45,13 @@ Available options: e.g.: --run-part 2 (runs part 2 out of 4) --total-parts - total number of groups to divide into e.g.: --total-parts 4 (suites will be divided into 4 groups) + --browser - browser to use (chrome, firefox, webkit) + e.g.: --browser firefox --help, -h - show cli options + +Environment variables: + BROWSER - browser to use (chrome, firefox, webkit) + PARALLEL - number of parallel workers (default: 1) " function log() { @@ -43,7 +63,7 @@ function log() { echo -e "\e[31mERR: $2\e[0m" ;; warn) - echo -e "\e[93mWRN: $2\e[0m"USAGE: + echo -e "\e[93mWRN: $2\e[0m" ;; cmd) echo -e "\e[96mUSAGE: $2\e[0m" @@ -75,6 +95,26 @@ while [[ $# -gt 0 ]]; do TOTAL_PARTS=$2 shift 2 ;; + --browser) + BROWSER=$2 + case $BROWSER in + chrome) + E2E_COMMAND="pnpm test:e2e:cucumber:chromium" + ;; + firefox) + E2E_COMMAND="pnpm test:e2e:cucumber:firefox" + ;; + webkit|safari) + E2E_COMMAND="pnpm test:e2e:cucumber:webkit" + ;; + *) + log error "Unsupported browser: $BROWSER" + log info "Supported browsers: chrome, firefox, webkit" + exit 1 + ;; + esac + shift 2 + ;; --help | -h) log "$HELP_COMMAND" exit 0 @@ -111,6 +151,10 @@ function runE2E() { if [[ ! -d "$PROJECT_ROOT" ]]; then log error "Project root doesn't exist: '$PROJECT_ROOT'" fi + + echo info "Running E2E tests with browser: $BROWSER" + echo info "Parallel workers: ${PARALLEL:-1}" + cd "$PROJECT_ROOT" || exit 1 if [[ -n $GLOB_FEATURE_PATHS ]]; then $E2E_COMMAND "$GLOB_FEATURE_PATHS" # run without expanding glob pattern diff --git a/tests/e2e/support/environment/actor/actor.ts b/tests/e2e/support/environment/actor/actor.ts index cf4fd3f82c..451e4f189d 100644 --- a/tests/e2e/support/environment/actor/actor.ts +++ b/tests/e2e/support/environment/actor/actor.ts @@ -27,6 +27,12 @@ export class ActorEnvironment extends EventEmitter implements Actor { this.page.on('pageerror', (exception) => { console.log(`[UNCAUGHT EXCEPTION] "${exception}"`) + if (this.options.browser.browserType().name() === 'webkit') { + // Ignore ResizeObserver error in WebKit - it's a harmless warning + if (exception.message.includes('ResizeObserver')) { + return + } + } // make the test fail if FAIL_ON_UNCAUGHT_CONSOLE_ERR=true if (this.options.context.failOnUncaughtConsoleError) { expect(exception).not.toBeDefined() diff --git a/tests/e2e/support/environment/actor/shared.ts b/tests/e2e/support/environment/actor/shared.ts index c901988912..f390665317 100644 --- a/tests/e2e/support/environment/actor/shared.ts +++ b/tests/e2e/support/environment/actor/shared.ts @@ -20,9 +20,21 @@ export interface ActorOptions extends ActorsOptions { } export const buildBrowserContextOptions = (options: ActorOptions): BrowserContextOptions => { + const getPermissions = (browserName: string): string[] => { + const basePermissions: string[] = [] + + // Clipboard permissions supports only in Chromium-based browsers + if (browserName === 'chromium' || browserName === 'chrome' || browserName === 'msedge') { + return [...basePermissions, 'clipboard-read', 'clipboard-write'] + } + return basePermissions + } + const contextOptions: BrowserContextOptions = { acceptDownloads: options.context.acceptDownloads, - permissions: ['clipboard-read', 'clipboard-write'], + permissions: getPermissions( + options.browser ? options.browser.browserType().name() : 'chromium' + ), ignoreHTTPSErrors: true, locale: 'en-US' } diff --git a/tests/e2e/support/objects/app-files/link/actions.ts b/tests/e2e/support/objects/app-files/link/actions.ts index 78dbe9d701..adc4c72a74 100644 --- a/tests/e2e/support/objects/app-files/link/actions.ts +++ b/tests/e2e/support/objects/app-files/link/actions.ts @@ -3,6 +3,7 @@ import util from 'util' import { sidebar } from '../utils' import { getActualExpiryDate } from '../../../utils/datePicker' import { clickResource } from '../resource/actions' +import { config } from '../../../../config' export interface createLinkArgs { page: Page @@ -100,9 +101,15 @@ const copyLinkButton = '//span[contains(@class, "files-links-name") and text()="%s"]//ancestor::li//button[contains(@class, "oc-files-public-link-copy-url")]' const getRecentLinkUrl = async (page: Page, name: string): Promise => { - await page.locator(util.format(copyLinkButton, name)).click() - const handle = await page.evaluateHandle(() => navigator.clipboard.readText()) - return handle.jsonValue() + const linkElement = page.locator(util.format(copyLinkButton, name)) + + if (config.browser === 'webkit') { + return await linkElement.getAttribute('data-clipboard-text') + } else { + await linkElement.click() + const handle = await page.evaluateHandle(() => navigator.clipboard.readText()) + return handle.jsonValue() + } } const getRecentLinkName = async (page: Page): Promise => { diff --git a/tests/e2e/support/objects/app-files/resource/actions.ts b/tests/e2e/support/objects/app-files/resource/actions.ts index 64bc729683..40891d73ae 100644 --- a/tests/e2e/support/objects/app-files/resource/actions.ts +++ b/tests/e2e/support/objects/app-files/resource/actions.ts @@ -643,7 +643,8 @@ export const uploadLargeNumberOfResources = async (args: uploadResourceArgs): Pr await performUpload(args) await page.locator(uploadInfoCloseButton).waitFor() await expect(page.locator(uploadInfoSuccessLabelSelector)).toHaveText( - `${resources.length} items uploaded` + `${resources.length} items uploaded`, + { timeout: config.timeout * 1000 } ) } @@ -722,7 +723,7 @@ export const resumeResourceUpload = async (page: Page): Promise => { await pauseResumeUpload(page) await page.locator(pauseUploadButton).waitFor() - await page.locator(uploadInfoSuccessLabelSelector).waitFor() + await page.locator(uploadInfoSuccessLabelSelector).waitFor({ timeout: config.timeout * 1000 }) await page.locator(uploadInfoCloseButton).click() } diff --git a/tests/e2e/support/utils/dragDrop.ts b/tests/e2e/support/utils/dragDrop.ts index c0e5505cda..ff1e2aeb66 100644 --- a/tests/e2e/support/utils/dragDrop.ts +++ b/tests/e2e/support/utils/dragDrop.ts @@ -39,30 +39,32 @@ export const dragDropFiles = async (page: Page, resources: File[], targetSelecto await page.evaluate( ([files, selector]: [FileBuffer[], string]) => { - const dataTransfer = new DataTransfer() + const target = document.querySelector(selector) + if (!target) throw new Error(`Target ${selector} not found`) + const input = document.createElement('input') + input.type = 'file' + input.multiple = true + input.style.display = 'none' + input.webkitdirectory = files.some((f) => f.relativePath.includes('/')) + document.body.appendChild(input) - for (const file of files) { + const dt = new DataTransfer() + files.forEach((file) => { const buffer = new Uint8Array(JSON.parse(file.bufferString)) - const blob = new Blob([buffer]) - const fileObj = new File([blob], file.name) - + const fileObj = new File([new Blob([buffer])], file.name) if (file.relativePath.includes('/')) { - Object.defineProperty(fileObj, 'webkitRelativePath', { - value: file.relativePath - }) + Object.defineProperty(fileObj, 'webkitRelativePath', { value: file.relativePath }) } + dt.items.add(fileObj) + }) + input.files = dt.files - dataTransfer.items.add(fileObj) - } - const target = document.querySelector(selector) - if (!target) throw new Error(`Target ${selector} not found`) - const dragEnter = new DragEvent('dragenter', { dataTransfer, bubbles: true }) - const dragOver = new DragEvent('dragover', { dataTransfer, bubbles: true }) - const drop = new DragEvent('drop', { dataTransfer, bubbles: true }) - - target.dispatchEvent(dragEnter) - target.dispatchEvent(dragOver) - target.dispatchEvent(drop) + const dropEvent = new Event('drop', { bubbles: true }) + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: dt.files, types: ['Files'] } + }) + target.dispatchEvent(dropEvent) + document.body.removeChild(input) }, [files, targetSelector] )