Browse Source
[Test] Add WebUI E2E workflow with Playwright
[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
11 changed files with 448 additions and 0 deletions
-
7.github/workflows/ci.yml
-
8.github/workflows/ci_rspamd.yml
-
156.github/workflows/ci_webui_e2e_playwright.yml
-
7eslint.config.mjs
-
6test/playwright/helpers/auth.mjs
-
31test/playwright/playwright.config.mjs
-
10test/playwright/tests/api.spec.mjs
-
33test/playwright/tests/basic.spec.mjs
-
99test/playwright/tests/config.spec.mjs
-
14test/playwright/tests/logs.spec.mjs
-
77test/playwright/tests/symbols.spec.mjs
@ -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 |
@ -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(); |
||||
|
} |
@ -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; |
@ -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"); |
||||
|
}); |
@ -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}); |
||||
|
}); |
||||
|
}); |
@ -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(); |
||||
|
}); |
@ -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(); |
||||
|
}); |
@ -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(); |
||||
|
}); |
||||
|
}); |
Write
Preview
Loading…
Cancel
Save
Reference in new issue