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
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
--- /dev/null
+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
"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
+ },
+ },
];
--- /dev/null
+export async function login(page, password) {
+ await page.goto("/");
+ const input = page.locator("#connectPassword");
+ await input.fill(password);
+ await page.locator("#connectButton").click();
+}
--- /dev/null
+/** @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;
--- /dev/null
+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");
+});
--- /dev/null
+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});
+ });
+});
--- /dev/null
+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();
+});
--- /dev/null
+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();
+});
--- /dev/null
+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();
+ });
+});