]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Test] Add WebUI E2E workflow with Playwright 5569/head
authorAlexander Moisseev <moiseev@mezonplus.ru>
Tue, 12 Aug 2025 16:09:07 +0000 (19:09 +0300)
committerAlexander Moisseev <moiseev@mezonplus.ru>
Tue, 12 Aug 2025 16:09:07 +0000 (19:09 +0300)
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.

.github/workflows/ci.yml
.github/workflows/ci_rspamd.yml
.github/workflows/ci_webui_e2e_playwright.yml [new file with mode: 0644]
eslint.config.mjs
test/playwright/helpers/auth.mjs [new file with mode: 0644]
test/playwright/playwright.config.mjs [new file with mode: 0644]
test/playwright/tests/api.spec.mjs [new file with mode: 0644]
test/playwright/tests/basic.spec.mjs [new file with mode: 0644]
test/playwright/tests/config.spec.mjs [new file with mode: 0644]
test/playwright/tests/logs.spec.mjs [new file with mode: 0644]
test/playwright/tests/symbols.spec.mjs [new file with mode: 0644]

index c6aa3f74bcd3b89f22b95f98ad277947c56c4414..46fd669850ab2732bb40db318570a21cd98f2748 100644 (file)
@@ -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
index 0e2035c14c111b27fa37b0a1cdb542def9a08a5d..fb0466cb79df78cb05ebd549ee49313098b3c0b8 100644 (file)
@@ -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 (file)
index 0000000..2d32a52
--- /dev/null
@@ -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
index bdd6ede4814f3eb37fcb86eef63d8a17934373c4..810467f5affec684d3529d550b8b870a30b2f2bf 100644 (file)
@@ -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 (file)
index 0000000..aa5ba1a
--- /dev/null
@@ -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 (file)
index 0000000..6e3a4ab
--- /dev/null
@@ -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 (file)
index 0000000..7cc5f0e
--- /dev/null
@@ -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 (file)
index 0000000..a0279bf
--- /dev/null
@@ -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 (file)
index 0000000..3962e7a
--- /dev/null
@@ -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 (file)
index 0000000..d127375
--- /dev/null
@@ -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 (file)
index 0000000..2a1cca7
--- /dev/null
@@ -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();
+    });
+});