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