From: Alexander Moisseev Date: Tue, 12 Aug 2025 16:09:07 +0000 (+0300) Subject: [Test] Add WebUI E2E workflow with Playwright X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=refs%2Fpull%2F5569%2Fhead;p=thirdparty%2Frspamd.git [Test] Add WebUI E2E workflow with Playwright Add a GitHub Actions workflow to run WebUI E2E tests with Playwright on legacy and latest browser versions against rspamd binaries built in the pipeline. --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6aa3f74bc..46fd669850 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,10 @@ jobs: with: image: ghcr.io/rspamd/rspamd-build-docker:centos-9 name: centos-9 + + webui-e2e-playwright: + needs: ubuntu + uses: ./.github/workflows/ci_webui_e2e_playwright.yml + with: + image: ghcr.io/rspamd/rspamd-build-docker:ubuntu-ci + name: ubuntu-ci diff --git a/.github/workflows/ci_rspamd.yml b/.github/workflows/ci_rspamd.yml index 0e2035c14c..fb0466cb79 100644 --- a/.github/workflows/ci_rspamd.yml +++ b/.github/workflows/ci_rspamd.yml @@ -92,3 +92,11 @@ jobs: name: rspamdlog-${{ inputs.name }} path: ${{ env.CONTAINER_WORKSPACE }}/build/robot-save retention-days: 1 + + - name: Upload built rspamd + if: inputs.name == 'ubuntu-ci' + uses: actions/upload-artifact@v4 + with: + name: rspamd-binary-${{ inputs.name }} + path: ${{ github.workspace }}/install + retention-days: 1 diff --git a/.github/workflows/ci_webui_e2e_playwright.yml b/.github/workflows/ci_webui_e2e_playwright.yml new file mode 100644 index 0000000000..2d32a52796 --- /dev/null +++ b/.github/workflows/ci_webui_e2e_playwright.yml @@ -0,0 +1,156 @@ +name: WebUI E2E (Playwright) + +on: + workflow_call: + inputs: + image: + required: true + type: string + name: + required: true + type: string + +concurrency: + group: webui-e2e-playwright-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + container: + image: ${{ inputs.image }} + options: --user root + strategy: + fail-fast: false + matrix: + include: + - version: 1.45.3 + label: legacy + - version: latest + label: latest + steps: + - name: Check out source code + uses: actions/checkout@v4 + with: + path: src + + - name: Define install prefix + run: echo "PREFIX=${GITHUB_WORKSPACE}/install" >> "$GITHUB_ENV" + + - name: Download rspamd binary from build job + uses: actions/download-artifact@v4 + with: + name: rspamd-binary-ubuntu-ci + path: ${{ env.PREFIX }} + + - name: Prepare rspamd configuration + run: | + mkdir -p ${PREFIX}/etc/rspamd/local.d + cp -r src/conf/* ${PREFIX}/etc/rspamd/ + echo 'static_dir = "${PREFIX}/share/rspamd/www";' > ${PREFIX}/etc/rspamd/local.d/worker-controller.inc + echo 'password = "$2$8y16z4benwtsemhhcsdtxc6zem1muuhj$pufmrdhm41s53eccisds6rxych3khq493jhqra8r1i3jto93dt7b";' >> ${PREFIX}/etc/rspamd/local.d/worker-controller.inc + echo 'enable_password = "$2$hkmgaqejragy47tfe18k7r8zf4wwfegt$jdrfna838b9f4mqu73q858t3zjpse1kw8mw7e6yeftabq1of1sry";' >> ${PREFIX}/etc/rspamd/local.d/worker-controller.inc + echo 'secure_ip = "0";' >> ${PREFIX}/etc/rspamd/local.d/worker-controller.inc + cat > ${PREFIX}/etc/rspamd/local.d/logging.inc << 'EOF' + type = "console"; + level = "error"; + EOF + # Disable multimap module to prevent hyperscan cache issues at runtime + echo 'enabled = false;' > ${PREFIX}/etc/rspamd/local.d/multimap.conf + # Disable redis dependent modules for WebUI tests + echo 'redis { enabled = false; }' > ${PREFIX}/etc/rspamd/local.d/modules.conf + chmod +x ${PREFIX}/bin/rspamd + mkdir -p /var/run/rspamd /var/lib/rspamd + chown $USER:$USER /var/run/rspamd /var/lib/rspamd + + - name: Start rspamd and wait for WebUI + run: | + ${PREFIX}/bin/rspamd -c ${PREFIX}/etc/rspamd/rspamd.conf --insecure & + # Initial delay before polling (in seconds) + initial_delay=5 + sleep "$initial_delay" + # Wait up to 60 seconds for WebUI to respond + max_retries=30 + for i in $(seq 1 "$max_retries"); do + http_code=$(wget -qO- --server-response http://localhost:11334/ping 2>&1 | awk '/^[[:space:]]*HTTP/ {print $2}' | tail -n1 || true) + if [ "$http_code" = "200" ]; then + elapsed=$(( initial_delay + (i - 1) * 2 )) + echo "Rspamd WebUI is up (HTTP 200) after $elapsed seconds" + break + elif [ -n "$http_code" ] && [ "$http_code" != "000" ]; then + echo "Unexpected HTTP code $http_code from /ping; failing" + exit 1 + fi + echo "Waiting for rspamd... ($i/$max_retries)" + sleep 2 + done + + if [ "$i" -eq "$max_retries" ] && [ "$http_code" != "200" ]; then + total_wait=$(( initial_delay + max_retries * 2 )) + echo "ERROR: rspamd WebUI did not become available after $total_wait seconds" + exit 1 + fi + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: .cache/ms-playwright-${{ matrix.label }} + key: browsers-${{ matrix.label }}-${{ runner.os }} + + - id: run-playwright + name: Run Playwright tests (${{ matrix.label }}) + env: + HOME: /root + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright-${{ matrix.label }} + run: | + set -e + mkdir -p test-${{ matrix.label }} + cp -r src/test/playwright test-${{ matrix.label }}/ + cd test-${{ matrix.label }}/playwright + + npm init -y --silent + echo "::group::Installing Playwright ${{ matrix.label }}" + npm install --no-save --silent @playwright/test@${{ matrix.version }} + npx playwright --version + npx playwright install --with-deps + echo "::endgroup::" + + echo "::group::Running tests (${{ matrix.label }})" + # Run tests; store Playwright artifacts (traces/videos) separately from HTML report + ARTIFACTS_DIR="$GITHUB_WORKSPACE/playwright-artifacts-${{ matrix.label }}" + HTML_OUT="$GITHUB_WORKSPACE/playwright-report-${{ matrix.label }}" + set +e + npx playwright test --output="$ARTIFACTS_DIR" + PW_STATUS=$? + set -e + REPORT_DIR="$PWD/playwright-report" + if [ -d "$REPORT_DIR" ]; then + mv "$REPORT_DIR" "$HTML_OUT" + fi + echo "report_path=$HTML_OUT" >> "$GITHUB_OUTPUT" + echo "::endgroup::" + exit $PW_STATUS + + - name: Upload Playwright reports (${{ matrix.label }}) + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ matrix.label }} + path: ${{ github.workspace }}/playwright-report-${{ matrix.label }} + if-no-files-found: ignore + + - name: Upload Playwright artifacts on failure (${{ matrix.label }}) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts-${{ matrix.label }} + path: ${{ github.workspace }}/playwright-artifacts-${{ matrix.label }} + if-no-files-found: ignore diff --git a/eslint.config.mjs b/eslint.config.mjs index bdd6ede481..810467f5af 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -84,4 +84,11 @@ export default [ "sort-keys": "error", }, }, + { + // Playwright E2E tests + files: ["test/playwright/tests/*.mjs"], + rules: { + "no-await-in-loop": "off", // Playwright operations in loops are often sequential and not independent + }, + }, ]; diff --git a/test/playwright/helpers/auth.mjs b/test/playwright/helpers/auth.mjs new file mode 100644 index 0000000000..aa5ba1addf --- /dev/null +++ b/test/playwright/helpers/auth.mjs @@ -0,0 +1,6 @@ +export async function login(page, password) { + await page.goto("/"); + const input = page.locator("#connectPassword"); + await input.fill(password); + await page.locator("#connectButton").click(); +} diff --git a/test/playwright/playwright.config.mjs b/test/playwright/playwright.config.mjs new file mode 100644 index 0000000000..6e3a4ab42f --- /dev/null +++ b/test/playwright/playwright.config.mjs @@ -0,0 +1,31 @@ +/** @type {import("@playwright/test").PlaywrightTestConfig} */ +const config = { + projects: [ + { + name: "firefox", + use: {browserName: "firefox"} + }, + { + name: "chromium", + use: {browserName: "chromium"} + }, + { + name: "webkit", + use: {browserName: "webkit"} + } + ], + reporter: [["html", {open: "never", outputFolder: "playwright-report"}]], + retries: 0, + testDir: "./tests", + timeout: 30000, + use: { + baseURL: "http://localhost:11334", + rspamdPasswords: { + enablePassword: "enable", + readOnlyPassword: "read-only", + }, + screenshot: "on-first-failure", + }, +}; + +export default config; diff --git a/test/playwright/tests/api.spec.mjs b/test/playwright/tests/api.spec.mjs new file mode 100644 index 0000000000..7cc5f0eefb --- /dev/null +++ b/test/playwright/tests/api.spec.mjs @@ -0,0 +1,10 @@ +import {expect, test} from "@playwright/test"; + +test("API /stat endpoint is available and returns version", async ({request}, testInfo) => { + const {readOnlyPassword} = testInfo.project.use.rspamdPasswords; + + const response = await request.get("/stat", {headers: {Password: readOnlyPassword}}); + expect(response.ok()).toBeTruthy(); + const data = await response.json(); + expect(data).toHaveProperty("version"); +}); diff --git a/test/playwright/tests/basic.spec.mjs b/test/playwright/tests/basic.spec.mjs new file mode 100644 index 0000000000..a0279bf9be --- /dev/null +++ b/test/playwright/tests/basic.spec.mjs @@ -0,0 +1,33 @@ +import {expect, test} from "@playwright/test"; +import {login} from "../helpers/auth.mjs"; + +test.describe("WebUI basic", () => { + test.beforeEach(async ({page}, testInfo) => { + const {readOnlyPassword} = testInfo.project.use.rspamdPasswords; + await login(page, readOnlyPassword); + }); + + test("Smoke: loads WebUI and shows main elements", async ({page}) => { + await expect(page).toHaveTitle(/Rspamd Web Interface/i); + // Wait for preloader to be hidden by JS when loading is complete + await expect(page.locator("#preloader")).toBeHidden({timeout: 30000}); + // Wait for main UI class to be removed by JS + await expect(page.locator("#mainUI")).not.toHaveClass("d-none", {timeout: 30000}); + await expect(page.locator("#mainUI")).toBeVisible(); + + await expect(page.locator("#navBar")).toBeVisible(); + await expect(page.locator("#tablist")).toBeVisible(); + await expect(page.locator(".tab-pane")).toHaveCount(7); + }); + + test("Shows no alert when backend returns non-AJAX error", async ({page}) => { + // Try to call a non-existent endpoint using browser fetch + await Promise.all([ + page.waitForResponse((resp) => resp.url().includes("/notfound") && !resp.ok()), + page.evaluate(() => fetch("/notfound")) + ]); + // WebUI shows alert-error only for errors handled via AJAX (common.query) + // If alert is not shown, the test should not fail + await expect(page.locator(".alert-error, .alert-modal.alert-error")).not.toBeVisible({timeout: 2000}); + }); +}); diff --git a/test/playwright/tests/config.spec.mjs b/test/playwright/tests/config.spec.mjs new file mode 100644 index 0000000000..3962e7a7bc --- /dev/null +++ b/test/playwright/tests/config.spec.mjs @@ -0,0 +1,99 @@ +import {expect, test} from "@playwright/test"; +import {login} from "../helpers/auth.mjs"; + +async function logAlertOnError(page, locator, fn) { + try { + await fn(); + } catch (e) { + const alertText = await locator.textContent(); + // eslint-disable-next-line no-console + console.log("[E2E] Alert error text:", alertText); + throw e; + } +} + +// Helper function for sequentially filling in fields +function fillSequentially(elements, values) { + return elements.reduce((promise, el, i) => promise.then(() => el.fill(values[i])), Promise.resolve()); +} + +test("Config page: always checks order error and valid save for actions", async ({page}, testInfo) => { + const {enablePassword} = testInfo.project.use.rspamdPasswords; + await login(page, enablePassword); + + await page.locator("#configuration_nav").click(); + await expect(page.locator("#actionsFormField")).toBeVisible({timeout: 10000}); + + function getInputs() { return page.locator("#actionsFormField input[data-id='action']"); } + const alert = page.locator(".alert-error, .alert-modal.alert-error"); + + const inputs = getInputs(); + const count = await inputs.count(); + expect(count).toBeGreaterThan(0); + await Promise.all( + Array.from({length: count}, (_, i) => expect(inputs.nth(i)).toBeVisible()) + ); + + // Save the original values + const values = await Promise.all(Array.from({length: count}, (_, i) => inputs.nth(i).inputValue())); + + // Determine only the fields actually available for input (not disabled, not readonly) + const fillableChecks = Array.from({length: count}, (_, i) => (async () => { + const input = inputs.nth(i); + const isDisabled = await input.isDisabled(); + const isReadOnly = await input.evaluate((el) => el.hasAttribute("readonly")); + return !isDisabled && !isReadOnly ? i : null; + })()); + const fillableIndices = (await Promise.all(fillableChecks)).filter((i) => i !== null); + + const fillableInputs = fillableIndices.map((i) => inputs.nth(i)); + + // 1. Correct order: strictly decreasing sequence + const correctOrder = fillableIndices.map((_, idx) => (idx * 10).toString()); + + await fillSequentially(fillableInputs, correctOrder); + + await page.locator("#saveActionsBtn").click(); + + await logAlertOnError(page, alert, async () => { + await expect(alert).not.toBeVisible({timeout: 2000}); + }); + + // Reload the configuration and make sure the new value has been saved + await page.locator("#refresh").click(); + await page.locator("#configuration_nav").click(); + + const reloadedInputs = getInputs(); + const reloadedCount = await reloadedInputs.count(); + + // Recalculate the fillable fields after reload + const reloadedFillableChecks = Array.from({length: reloadedCount}, (_, i) => (async () => { + const input = reloadedInputs.nth(i); + const isDisabled = await input.isDisabled(); + const isReadOnly = await input.evaluate((el) => el.hasAttribute("readonly")); + return !isDisabled && !isReadOnly ? i : null; + })()); + const reloadedFillableIndices = (await Promise.all(reloadedFillableChecks)).filter((i) => i !== null); + const reloadedFillableInputs = reloadedFillableIndices.map((i) => reloadedInputs.nth(i)); + + await Promise.all(reloadedFillableInputs.map((input) => expect(input).toBeVisible())); + + const saved = await Promise.all(reloadedFillableInputs.map((input) => input.inputValue())); + expect(saved).toEqual(correctOrder); + + // 2. Break the order: increasing sequence + const wrongOrder = reloadedFillableIndices.map((_, idx) => ((reloadedFillableIndices.length - idx) * 10).toString()); + + await fillSequentially(reloadedFillableInputs, wrongOrder); + + await page.locator("#saveActionsBtn").click(); + + await expect(alert).toBeVisible({timeout: 10000}); + const alertText = await alert.textContent(); + expect(alertText).toContain("Incorrect order of actions thresholds"); + + // Restore the original values + await fillSequentially(reloadedFillableInputs, values); + + await page.locator("#saveActionsBtn").click(); +}); diff --git a/test/playwright/tests/logs.spec.mjs b/test/playwright/tests/logs.spec.mjs new file mode 100644 index 0000000000..d127375386 --- /dev/null +++ b/test/playwright/tests/logs.spec.mjs @@ -0,0 +1,14 @@ +import {expect, test} from "@playwright/test"; +import {login} from "../helpers/auth.mjs"; + +test("Logs page displays recent errors and allows refresh", async ({page}, testInfo) => { + const {enablePassword} = testInfo.project.use.rspamdPasswords; + await login(page, enablePassword); + + await page.locator("#history_nav").click(); + await expect(page.locator("#errorsLog")).toBeVisible(); + const rowCount = await page.locator("#errorsLog tbody tr").count(); + expect(rowCount).toBeGreaterThan(0); + await page.locator("#updateErrors").click(); + await expect(page.locator("#errorsLog")).toBeVisible(); +}); diff --git a/test/playwright/tests/symbols.spec.mjs b/test/playwright/tests/symbols.spec.mjs new file mode 100644 index 0000000000..2a1cca74b5 --- /dev/null +++ b/test/playwright/tests/symbols.spec.mjs @@ -0,0 +1,77 @@ +import {expect, test} from "@playwright/test"; +import {login} from "../helpers/auth.mjs"; + +test.describe("Symbols", () => { + test.beforeEach(async ({page}, testInfo) => { + const {enablePassword} = testInfo.project.use.rspamdPasswords; + await login(page, enablePassword); + await page.locator("#symbols_nav").click(); + await expect(page.locator("#symbolsTable")).toBeVisible(); + // Ensure table data has been loaded before running tests + await expect(page.locator("#symbolsTable tbody tr").first()).toBeVisible(); + }); + + test("shows list and allows filtering by group", async ({page}) => { + // Check filtering by group (if selector exists) + const groupSelect = page.locator(".footable-filtering select.form-select").first(); + if (await groupSelect.count()) { + // Ensure there is at least one real group besides "Any group" + const optionCount = await groupSelect.evaluate((el) => el.options.length); + expect(optionCount).toBeGreaterThan(1); + + // Read target group's value and text BEFORE selection to avoid FooTable redraw races + const target = await groupSelect.evaluate((el) => { + const [, op] = Array.from(el.options); // first non-default option + return {text: op.text, value: op.value}; + }); + + const groupCells = page.locator("#symbolsTable tbody tr td.footable-first-visible"); + const beforeTexts = await groupCells.allTextContents(); + + await groupSelect.selectOption({value: target.value}); + const selectedGroup = target.text.toLowerCase(); + + // Wait until table content updates (using expect.poll with matcher) + await expect.poll(async () => { + const texts = await groupCells.allTextContents(); + return texts.join("|"); + }, {timeout: 5000}).not.toBe(beforeTexts.join("|")); + + const afterTexts = await groupCells.allTextContents(); + + // Validate that all visible rows belong to the selected group + for (const text of afterTexts) { + expect(text.toLowerCase()).toContain(selectedGroup); + } + } + }); + + test.describe.configure({mode: "serial"}); + test("edits score for the first symbol and saves", async ({page}) => { + const scoreInput = page.locator("#symbolsTable .scorebar").first(); + const scoreInputId = await scoreInput.evaluate((element) => element.id); + const oldValue = await scoreInput.inputValue(); + + // Try to change the score value for the first symbol + await scoreInput.fill((parseFloat(oldValue) + 0.01).toFixed(2)); + await scoreInput.blur(); + + // A save notification should appear + const saveAlert = page.locator("#save-alert"); + await expect(saveAlert).toBeVisible(); + + // Save changes + await saveAlert.getByRole("button", {exact: true, name: "Save"}).click(); + + // A success alert should appear (wait for any alert-success) + const alertSuccess = page.locator(".alert-success, .alert-modal.alert-success"); + await expect(alertSuccess).toBeVisible(); + + // Revert to the old value (clean up after the test) + await expect(alertSuccess).not.toBeVisible({timeout: 10000}); + const revertedScoreInput = page.locator("#" + scoreInputId); + await revertedScoreInput.fill(oldValue); + await revertedScoreInput.blur(); + await saveAlert.getByRole("button", {exact: true, name: "Save"}).click(); + }); +});