aboutsummaryrefslogtreecommitdiffstats
path: root/test/playwright
diff options
context:
space:
mode:
Diffstat (limited to 'test/playwright')
-rw-r--r--test/playwright/helpers/auth.mjs6
-rw-r--r--test/playwright/playwright.config.mjs31
-rw-r--r--test/playwright/tests/api.spec.mjs10
-rw-r--r--test/playwright/tests/basic.spec.mjs45
-rw-r--r--test/playwright/tests/config.spec.mjs99
-rw-r--r--test/playwright/tests/logs.spec.mjs14
-rw-r--r--test/playwright/tests/symbols.spec.mjs77
7 files changed, 282 insertions, 0 deletions
diff --git a/test/playwright/helpers/auth.mjs b/test/playwright/helpers/auth.mjs
new file mode 100644
index 000000000..aa5ba1add
--- /dev/null
+++ b/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();
+}
diff --git a/test/playwright/playwright.config.mjs b/test/playwright/playwright.config.mjs
new file mode 100644
index 000000000..6e3a4ab42
--- /dev/null
+++ b/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;
diff --git a/test/playwright/tests/api.spec.mjs b/test/playwright/tests/api.spec.mjs
new file mode 100644
index 000000000..7cc5f0eef
--- /dev/null
+++ b/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");
+});
diff --git a/test/playwright/tests/basic.spec.mjs b/test/playwright/tests/basic.spec.mjs
new file mode 100644
index 000000000..139940574
--- /dev/null
+++ b/test/playwright/tests/basic.spec.mjs
@@ -0,0 +1,45 @@
+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("Browser version info", async ({page, browserName}, testInfo) => {
+ const browserVersion = await page.context().browser().version();
+
+ testInfo.annotations.push({
+ type: "Browser info",
+ description: `Browser version: ${browserName} ${browserVersion}`,
+ });
+
+ // eslint-disable-next-line no-console
+ console.log(`Browser (${browserName}) version: ${browserVersion}`);
+ });
+
+ 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
index 000000000..3962e7a7b
--- /dev/null
+++ b/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();
+});
diff --git a/test/playwright/tests/logs.spec.mjs b/test/playwright/tests/logs.spec.mjs
new file mode 100644
index 000000000..d12737538
--- /dev/null
+++ b/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();
+});
diff --git a/test/playwright/tests/symbols.spec.mjs b/test/playwright/tests/symbols.spec.mjs
new file mode 100644
index 000000000..2a1cca74b
--- /dev/null
+++ b/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();
+ });
+});