Browse Source

[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.
pull/5569/head
Alexander Moisseev 2 months ago
parent
commit
22046fed3f
  1. 7
      .github/workflows/ci.yml
  2. 8
      .github/workflows/ci_rspamd.yml
  3. 156
      .github/workflows/ci_webui_e2e_playwright.yml
  4. 7
      eslint.config.mjs
  5. 6
      test/playwright/helpers/auth.mjs
  6. 31
      test/playwright/playwright.config.mjs
  7. 10
      test/playwright/tests/api.spec.mjs
  8. 33
      test/playwright/tests/basic.spec.mjs
  9. 99
      test/playwright/tests/config.spec.mjs
  10. 14
      test/playwright/tests/logs.spec.mjs
  11. 77
      test/playwright/tests/symbols.spec.mjs

7
.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

8
.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

156
.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

7
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
},
},
];

6
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();
}

31
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;

10
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");
});

33
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});
});
});

99
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();
});

14
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();
});

77
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();
});
});
Loading…
Cancel
Save