Add Playwright end-to-end tests for the web app

Adds 56 Playwright tests in web/e2e/ that drive Chromium against a real
ntfy server: subscribing, publishing, notifications, signup/login/logout,
account settings, access tokens, reservations and languages. "make web-e2e"
builds the binary and runs them against a fresh local server; setting
NTFY_E2E_BASE_URL (plus an admin token and tier) runs them against an
existing server instead. docs/develop.md describes both.

The tests found two bugs, fixed here:

- Signup showed "Username is already taken" without the name, since it
  read the username from the error instead of the form.
- A poll that overlapped a WebSocket delivery could overwrite the stored
  message and drop its unread marker, or log a ConstraintError. Both
  write paths in SubscriptionManager now check and write in one
  transaction, and the poller skips messages that are already stored.
This commit is contained in:
binwiederhier committed 2026-10-07 17:26:35 +00:00
1 parent 9c709731df
commit d7013703c1
19 files changed
+1344 -15

No files matched your search

+2
View File
@@ -17,5 +17,7 @@ node_modules/
.DS_Store
__pycache__
web/dev-dist/
web/test-results/
web/playwright-report/
venv/
cmd/key-file.yaml
+7
View File
@@ -46,6 +46,7 @@ help:
@echo " make web-build - Actually build the web app"
@echo " make web-lint - Run eslint on the web app"
@echo " make web-test - Run vitest unit tests for the web app"
@echo " make web-e2e - Run Playwright e2e tests against a fresh local server"
@echo " make web-fmt - Run prettier on the web app"
@echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
@echo
@@ -171,6 +172,12 @@ web-lint:
web-test:
cd web && $(NPM) run test
# The e2e server must embed the real web app, so this builds the web app and a server binary
# (build/ntfy-e2e) first; see web/e2e/server.mjs for how it is started
web-e2e: web cli-deps-static-sites
CGO_ENABLED=1 go build -tags sqlite_omit_load_extension,osusergo,netgo -o build/ntfy-e2e .
cd web && npx playwright install chromium && $(NPM) run test:e2e
# Main server/client build
cli: cli-deps
+61
View File
@@ -279,6 +279,67 @@ Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-ser
2. Run the server (step 2 above)
3. Open <http://localhost/>
### Run the web app end-to-end tests
The web app has [Playwright](https://playwright.dev/) end-to-end tests in `web/e2e/`. They drive a real (headless) Chromium
against a real ntfy server, and cover subscribing, publishing, notifications, signup/login, account settings, access tokens,
reservations and languages. To build the web app and the server, and run all tests against a fresh local server, type:
``` shell
$ make web-e2e
...
56 passed
```
This builds the binary `build/ntfy-e2e` (with the web app embedded), downloads Chromium if needed, and starts the server
on `127.0.0.1:12586` with a throwaway database in `build/e2e/` (see `web/e2e/server.mjs`). The server is stopped when the
tests are done.
After `make web-e2e` has run once, you can run the tests directly from `web/`, e.g. a single spec file, or with a visible
browser. The binary is not rebuilt this way, so **run `make web-e2e` again after changing the web app or the server**:
``` shell
$ cd web
$ npx playwright test settings.spec.js # Only the tests in one file
$ npx playwright test -g "access token" # Only tests whose name matches
$ npx playwright test --headed # Watch the browser while the tests run
$ npx playwright test --ui # Interactive UI: pick tests, step through them
```
If a test fails, its trace is kept in `web/test-results/`. Open it with `npx playwright show-trace <path>/trace.zip` to see
every step with screenshots, the DOM, console logs and network requests. To get traces and an HTML report for **all**
tests, including the passing ones:
``` shell
$ npx playwright test --trace on --reporter=list,html
$ npx playwright show-report
```
#### Against an existing server
The tests can also run against an existing server (e.g. staging) instead of a local one. They create (and delete) their own
users and topics on that server, so **don't run them against production**. You'll need:
- an admin access token (e.g. `ntfy token add <admin-user>`), which is used to create and delete test users
- an existing tier that allows reservations (e.g. `ntfy tier add --reservation-limit 10 e2e`), which is assigned to the
test users that need a paid account
``` shell
$ cd web
$ NTFY_E2E_BASE_URL=https://staging.example.com \
NTFY_E2E_ADMIN_TOKEN=tk_... \
NTFY_E2E_TIER=e2e \
npx playwright test --workers 2
```
Tests for features the server has disabled (e.g. signup or reservations, as reported by `/v1/config`) are skipped. A few
things to keep in mind:
- Every run signs up one account through the web app, and ntfy only allows a few signups per IP per day, so you can run
the full suite about 6 times per day.
- All tests come from your IP, so request limits may kick in; use fewer workers (e.g. `--workers 2`) if you see `429`s.
- The reservation tests expect anonymous users to be allowed to publish and subscribe to topics that aren't reserved
(`auth-default-access: read-write`).
### Build the docs
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
+2
View File
@@ -3,3 +3,5 @@ dist/
public/static/langs/
src/app/emojis.js
src/app/emojisMapped.js
test-results/
playwright-report/
+126
View File
@@ -0,0 +1,126 @@
import { test, expect, login, signup } from "./fixtures.js";
import { password, unique } from "./env.js";
test.beforeEach(({ serverConfig }) => {
test.skip(!serverConfig.enable_login, "login disabled");
});
// Logout fades out and then navigates to /app; wait for that before moving on
const logout = async (page) => {
await page.getByRole("button", { name: "Profile" }).click();
await page.getByRole("menuitem", { name: "Logout" }).click();
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
};
const submitLogin = async (page, username, pass) => {
await page.goto("/login");
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).fill(pass);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
};
test.describe("signup", () => {
test.beforeEach(({ serverConfig }) => {
test.skip(!serverConfig.enable_signup, "signup disabled");
});
test("sign up, log out and log back in", async ({ page, users }) => {
const username = unique("user");
users.track(username);
await signup(page, username);
// Account page and profile menu show the new user
await page.goto("/account");
await expect(page.getByText(username).first()).toBeVisible();
await page.getByRole("button", { name: "Profile" }).click();
await expect(page.getByRole("menuitem", { name: username })).toBeVisible();
await page.keyboard.press("Escape");
await logout(page);
await login(page, username);
await page.goto("/account");
await expect(page.getByText(username).first()).toBeVisible();
});
test("taken username is rejected", async ({ page, users }) => {
const username = await users.create();
await page.goto("/signup");
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).fill(password);
await page.getByRole("textbox", { name: "Confirm password" }).fill(password);
await page.getByRole("button", { name: "Sign up" }).click();
await expect(page.getByText(`Username ${username} is already taken`)).toBeVisible();
});
test("sign up button needs matching passwords", async ({ page }) => {
await page.goto("/signup");
await page.getByRole("textbox", { name: "Username" }).fill(unique("user"));
await page.getByRole("textbox", { name: "Password", exact: true }).fill(password);
await page.getByRole("textbox", { name: "Confirm password" }).fill("something else");
await expect(page.getByRole("button", { name: "Sign up" })).toBeDisabled();
});
});
test("wrong password is rejected", async ({ page, users }) => {
const username = await users.create();
await submitLogin(page, username, "wrong-password");
await expect(page.getByText("Login failed: Invalid username/email or password")).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
});
test("logged out users are sent away from the account page", async ({ page }) => {
await page.goto("/account");
await expect(page).not.toHaveURL(/\/account$/);
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
});
test("change password", async ({ page, users }) => {
const username = await users.create();
await login(page, username);
await page.goto("/account");
await page.getByRole("button", { name: "Change your account password" }).click();
const dialog = page.getByRole("dialog", { name: "Change password" });
// Wrong current password first
await dialog.getByRole("textbox", { name: "Current password", exact: true }).fill("wrong-password");
await dialog.getByRole("textbox", { name: "New password", exact: true }).fill("new-secret-123");
await dialog.getByRole("textbox", { name: "Confirm password", exact: true }).fill("new-secret-123");
await dialog.getByRole("button", { name: "Change password" }).click();
await expect(dialog.getByText("Password incorrect")).toBeVisible();
await dialog.getByRole("textbox", { name: "Current password", exact: true }).fill(password);
await dialog.getByRole("button", { name: "Change password" }).click();
await expect(dialog).toBeHidden();
await logout(page);
await submitLogin(page, username, password);
await expect(page.getByText("Login failed: Invalid username/email or password")).toBeVisible();
await login(page, username, "new-secret-123");
});
test("delete account", async ({ page, request, users }) => {
const username = await users.create();
await login(page, username);
await page.goto("/account");
await page.getByRole("button", { name: "Delete account" }).click();
const dialog = page.getByRole("dialog", { name: "Delete account" });
await dialog.getByRole("textbox", { name: "Password", exact: true }).fill("wrong-password");
await dialog.getByRole("button", { name: "Permanently delete account" }).click();
await expect(dialog.getByText("Password incorrect")).toBeVisible();
await dialog.getByRole("textbox", { name: "Password", exact: true }).fill(password);
await dialog.getByRole("button", { name: "Permanently delete account" }).click();
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
const auth = Buffer.from(`${username}:${password}`).toString("base64");
expect((await request.post("/v1/account/token", { headers: { Authorization: `Basic ${auth}` } })).status()).toBe(401);
});
test("account page shows usage", async ({ page, users }) => {
await login(page, await users.create());
await page.goto("/account");
const usage = page.getByLabel("Usage", { exact: true });
await expect(usage.getByText("Published messages")).toBeVisible();
await expect(usage.getByText("Attachment storage")).toBeVisible();
});
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
// Shared by the config and the specs, so both agree on which server to test.
//
// By default, a throwaway local server is started from build/ntfy-e2e (see server.mjs and
// "make web-e2e"). To test an existing server instead, set NTFY_E2E_BASE_URL, plus
// NTFY_E2E_ADMIN_TOKEN (to create and delete test users) and NTFY_E2E_TIER (code of an
// existing tier with reservations).
const root = path.resolve(__dirname, "../..");
export const remote = !!process.env.NTFY_E2E_BASE_URL;
export const port = 12586;
export const baseURL = process.env.NTFY_E2E_BASE_URL || `http://127.0.0.1:${port}`;
export const binary = path.join(root, "build", "ntfy-e2e");
export const configFile = path.join(root, "build", "e2e", "server.yml");
export const localTier = "e2e";
export const password = "secret12345";
// Read lazily: for the local server, Playwright sets this once server.mjs reports it
export const adminToken = () => process.env.NTFY_E2E_ADMIN_TOKEN;
export const tier = () => (remote ? process.env.NTFY_E2E_TIER : localTier);
// Random suffix so specs never share topics or users, even across reruns.
export const unique = (prefix) => `${prefix}${Math.random().toString(36).slice(2, 10)}`;
+155
View File
@@ -0,0 +1,155 @@
import { test as base, expect } from "@playwright/test";
import { adminToken, baseURL, password, tier, unique } from "./env.js";
// Errors that are not bugs: headless Chromium has no notification backend, so
// showNotification() throws, even with the permission granted or before the worker is active.
const ignoredErrors = [/Failed to execute 'showNotification'/];
// Every spec fails on an uncaught exception or a console error, without each one
// having to remember to check. A missing import only throws on the path that uses it.
export const test = base.extend({
// A random client IP per test, so per-visitor rate limits start fresh (see server.mjs)
extraHTTPHeaders: async ({}, use) => {
const octet = () => Math.floor(Math.random() * 256);
await use({ "X-Forwarded-For": `10.${octet()}.${octet()}.${octet()}` });
},
page: async ({ page }, use) => {
const errors = [];
page.on("pageerror", (e) => errors.push(`uncaught: ${e.message}`));
page.on("console", (m) => {
// Non-2xx responses are logged as console errors; specs assert on statuses directly.
// Known ones: en-US.json 404 (i18n tries the region first), the 401 session check.
if (m.type() === "error" && !/^Failed to load resource/.test(m.text())) {
errors.push(m.text());
}
});
await use(page);
const unexpected = errors.filter((e) => !ignoredErrors.some((re) => re.test(e)));
expect(unexpected, `JavaScript errors on the page:\n ${unexpected.join("\n ")}`).toEqual([]);
},
// The server's /v1/config, so specs can skip features the server under test has disabled
serverConfig: [
async ({ playwright }, use) => {
const request = await playwright.request.newContext({ baseURL });
await use(await (await request.get("/v1/config")).json());
await request.dispose();
},
{ scope: "worker" },
],
// Admin API client, used to create test users without the 6/day signup limit
admin: [
async ({ playwright }, use) => {
const request = await playwright.request.newContext({ baseURL, extraHTTPHeaders: { Authorization: `Bearer ${adminToken()}` } });
await use(request);
await request.dispose();
},
{ scope: "worker" },
],
// Creates users via the admin API (optionally on the test tier) and deletes them after the
// test, including users that a spec signed up through the UI and handed to track()
users: async ({ admin }, use) => {
const created = [];
await use({
create: async ({ paid = false } = {}) => {
const username = unique("user");
const response = await admin.post("/v1/users", { data: { username, password, tier: paid ? tier() : "" } });
expect(response.ok(), `creating user ${username}: ${await response.text()}`).toBeTruthy();
created.push(username);
return username;
},
track: (username) => created.push(username),
});
for (const username of created) {
await admin.delete("/v1/users", { data: { username } });
}
},
});
// Resolves once the topic's WebSocket got the server's "open" event. The first connect has no
// "since", so anything published before that only shows up with the next poll, minutes later.
export const connected = (page, topic) =>
new Promise((resolve) => {
page.on("websocket", (ws) => {
if (new URL(ws.url()).pathname.endsWith(`/${topic}/ws`)) {
ws.on("framereceived", (frame) => {
if (JSON.parse(frame.payload).event === "open") resolve();
});
}
});
});
// Subscribes to a topic through the "Subscribe to topic" dialog and waits until it is live.
// It also waits for the poll that follows subscribing: a message arriving through both at once
// trips a check-then-add race in SubscriptionManager.addNotification (harmless, but logged).
export const subscribe = async (page, topic, { reserve = false } = {}) => {
const live = connected(page, topic);
const polled = page.waitForResponse((r) => new URL(r.url()).pathname.endsWith(`/${topic}/json`));
await page.getByRole("button", { name: "Subscribe to topic" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel(/topic name/i).fill(topic);
if (reserve) {
await dialog.getByRole("switch", { name: /reserve topic/i }).check();
}
await dialog.getByRole("button", { name: "Subscribe", exact: true }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("button", { name: topic, exact: true })).toBeVisible();
await Promise.all([live, polled]);
};
// Signs up a new user through /signup; the app logs them in and lands on /app.
export const signup = async (page, username) => {
await page.goto("/signup");
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).fill(password);
await page.getByRole("textbox", { name: "Confirm password" }).fill(password);
await page.getByRole("button", { name: "Sign up" }).click();
await expect(page.getByRole("button", { name: "Subscribe to topic" })).toBeVisible();
};
// Logs in through /login and waits for the reload into the app root (the account's language
// may not be English, so don't wait for any text).
export const login = async (page, username, pass = password) => {
await page.goto("/login");
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).fill(pass);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).not.toHaveURL(/\/login$/);
await expect(page.getByRole("navigation").first()).toBeVisible();
};
// Logs a user in via the API and returns a bearer token, for setup and for checking server state.
export const apiToken = async (request, username, pass = password) => {
const auth = Buffer.from(`${username}:${pass}`).toString("base64");
const response = await request.post("/v1/account/token", { headers: { Authorization: `Basic ${auth}` } });
expect(response.ok(), `login of ${username}: ${await response.text()}`).toBeTruthy();
return (await response.json()).token;
};
// GET /v1/account as the given token's user.
export const account = async (request, token) =>
(await request.get("/v1/account", { headers: { Authorization: `Bearer ${token}` } })).json();
// Picks an option in a settings select. These comboboxes have no accessible name (MUI only
// forwards labelId/inputProps to the combobox), so find them by their Pref row title.
export const selectPref = async (page, title, option) => {
await page
.getByRole("row")
.filter({ has: page.getByRole("cell", { name: title }) })
.getByRole("combobox")
.click();
await page.getByRole("option", { name: option, exact: true }).click();
};
// Opens the subscription menu from the sidebar topic row (its icon button has no name).
export const openTopicMenu = async (page, topic) => {
const row = page.getByRole("button", { name: topic, exact: true });
await row.hover();
await row.getByRole("button").click();
};
export { expect };
+174
View File
@@ -0,0 +1,174 @@
import { test, expect, openTopicMenu, subscribe } from "./fixtures.js";
import { baseURL, unique } from "./env.js";
// A 1x1 PNG, so the attachment is rendered as an image
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64");
let topic;
test.beforeEach(async ({ page }) => {
// Fake timers (time still flows), so a test can jump to the poller's next 5-minute run. The
// poller's startup sweep (after 2s) is fired right away, so it can't race live deliveries:
// a message it stores first is never marked unread (see addNotifications).
await page.clock.install();
topic = unique("notif");
await page.goto("/app");
await page.clock.runFor(2000);
await subscribe(page, topic);
});
test("message published via HTTP appears live", async ({ page, request }) => {
const response = await request.post(`/${topic}`, {
headers: { Title: "HTTP title", Tags: "warning,backup", Priority: "5" },
data: "sent over HTTP",
});
expect(response.ok()).toBeTruthy();
const item = page.getByRole("listitem", { name: "Notification" });
await expect(item.getByText("sent over HTTP")).toBeVisible();
await expect(item.getByText("HTTP title")).toBeVisible(); // prefixed with the warning emoji
await expect(item.getByText("Tags: backup")).toBeVisible();
await expect(item.getByRole("img", { name: "Priority 5" })).toBeVisible();
});
test("markdown message renders", async ({ page, request }) => {
await request.post(`/${topic}`, { headers: { "Content-Type": "text/markdown" }, data: "**bold md** and _italic_" });
await expect(page.locator("strong", { hasText: "bold md" })).toBeVisible();
await expect(page.locator("em", { hasText: "italic" })).toBeVisible();
});
test("file attachment shows name and buttons", async ({ page, request }) => {
const response = await request.put(`/${topic}`, { headers: { Filename: "report.bin" }, data: Buffer.alloc(3000, 7) });
expect(response.ok()).toBeTruthy();
const item = page.getByRole("listitem", { name: "Notification" });
await expect(item.getByText("report.bin").first()).toBeVisible();
await expect(item.getByRole("button", { name: "Copy attachment URL to clipboard" })).toBeVisible();
await expect(item.getByRole("button", { name: /^Go to http/ })).toBeVisible();
});
test("image attachment opens in a lightbox", async ({ page, request }) => {
await request.put(`/${topic}`, { headers: { Filename: "dot.png" }, data: png });
await page.getByRole("img", { name: "Attachment image" }).click();
// The lightbox shows a second copy, and hides the rest of the page from the accessibility tree
await expect(page.locator('img[alt="Attachment image"]')).toHaveCount(2);
await expect(page.getByRole("img", { name: "Attachment image" })).toHaveCount(1);
await page.keyboard.press("Escape");
await expect(page.locator('img[alt="Attachment image"]')).toHaveCount(1);
});
test("click URL shows link buttons", async ({ page, request }) => {
await request.post(`/${topic}`, { headers: { Click: "https://example.com/clicked" }, data: "with a click URL" });
const item = page.getByRole("listitem", { name: "Notification" });
// Buttons are named by their tooltips
await expect(item.getByRole("button", { name: "Go to https://example.com/clicked" })).toHaveText("Open link");
await expect(item.getByRole("button", { name: "Copy link URL to clipboard" })).toHaveText("Copy link");
});
test("http action button sends the request", async ({ page, request }) => {
// The action publishes to a second topic, so its effect is observable on the server
const target = unique("target");
const url = `${baseURL}/${target}`;
await request.post("/", {
data: {
topic,
message: "with actions",
actions: [
{ action: "view", label: "Open site", url: "https://example.com" },
{ action: "http", label: "Ping", url, method: "POST", body: "pinged by action" },
],
},
});
const item = page.getByRole("listitem", { name: "Notification" });
await expect(item.getByRole("button", { name: "Go to https://example.com" })).toHaveText("Open site");
await item.getByRole("button", { name: `Send HTTP POST to ${url}` }).click();
await expect(item.getByRole("button", { name: `Send HTTP POST to ${url}` })).toHaveText(/Ping ✔/);
await expect.poll(async () => (await request.get(`/${target}/json?poll=1`)).text()).toContain("pinged by action");
});
test("delete a single notification", async ({ page, request }) => {
await request.post(`/${topic}`, { data: "keep me" });
await request.post(`/${topic}`, { data: "delete me" });
const items = page.getByRole("listitem", { name: "Notification" });
await expect(items).toHaveCount(2);
await items.filter({ hasText: "delete me" }).getByRole("button", { name: "Delete" }).click();
await expect(items).toHaveCount(1);
await expect(page.getByText("keep me")).toBeVisible();
});
test("clear all notifications", async ({ page, request }) => {
await request.post(`/${topic}`, { data: "to be cleared" });
await expect(page.getByText("to be cleared")).toBeVisible();
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Clear all notifications" }).click();
await expect(page.getByText("to be cleared")).toHaveCount(0);
await expect(page.getByText("You haven't received any notifications for this topic yet.")).toBeVisible();
});
test("unread messages are counted until the topic is opened", async ({ page, request }) => {
// Look at another topic, so messages to the first one stay unread
const other = unique("other");
await subscribe(page, other);
await request.post(`/${topic}`, { data: "unread one" });
await request.post(`/${topic}`, { data: "unread two" });
const row = page.getByRole("button", { name: topic, exact: true });
await expect(row).toContainText("2");
await expect(page).toHaveTitle("(2) ntfy");
await row.click();
await expect(page.getByRole("listitem", { name: "Notification" })).toHaveCount(2);
await expect(page).toHaveTitle("ntfy");
});
test("mark a notification as read", async ({ page, request }) => {
const other = unique("other");
await subscribe(page, other);
await request.post(`/${topic}`, { data: "mark me" });
await expect(page).toHaveTitle("(1) ntfy");
// The all-notifications view shows it without marking it read
await page.getByRole("button", { name: "All notifications" }).click();
const item = page.getByRole("listitem", { name: "Notification" }).filter({ hasText: "mark me" });
await item.getByRole("button", { name: "Mark as read" }).click();
await expect(item.getByRole("button", { name: "Mark as read" })).toHaveCount(0);
await expect(page).toHaveTitle("ntfy");
});
test("a poll racing the WebSocket keeps the message unread", async ({ page, request }) => {
// A poll that was already in flight when the WebSocket delivered a message returns it a second
// time; storing that copy must not reset its unread marker. Hold the poll to force the overlap.
const other = unique("other");
await subscribe(page, other);
let release;
const held = new Promise((resolve) => {
release = resolve;
});
await page.route(`**/${topic}/json?poll=1*`, async (route) => {
await held;
await route.continue();
});
await page.clock.fastForward("05:00");
await request.post(`/${topic}`, { data: "delivered twice" });
await expect(page).toHaveTitle("(1) ntfy");
const polled = page.waitForResponse((r) => new URL(r.url()).pathname.endsWith(`/${topic}/json`));
release();
expect(await (await polled).text()).toContain("delivered twice");
await page.waitForTimeout(1000); // Let the poller store what it fetched
await expect(page).toHaveTitle("(1) ntfy");
});
test("all notifications view shows every topic", async ({ page, request }) => {
const other = unique("other");
await subscribe(page, other);
await request.post(`/${topic}`, { data: "first topic message" });
await request.post(`/${other}`, { data: "second topic message" });
await page.getByRole("button", { name: "All notifications" }).click();
await expect(page.getByText("first topic message")).toBeVisible();
await expect(page.getByText("second topic message")).toBeVisible();
});
test("notifications survive a reload", async ({ page, request }) => {
await request.post(`/${topic}`, { data: "still here" });
await expect(page.getByText("still here")).toBeVisible();
await page.reload();
await expect(page.getByText("still here")).toBeVisible();
});
+157
View File
@@ -0,0 +1,157 @@
import { test, expect, subscribe } from "./fixtures.js";
import { unique } from "./env.js";
let topic;
// The last message on the topic, as stored by the server
const lastMessage = async (request, t = topic) => {
const lines = (await (await request.get(`/${t}/json?poll=1`)).text()).trim().split("\n");
return JSON.parse(lines.pop());
};
const openDialog = async (page) => {
await page.getByRole("button", { name: "Show publish dialog" }).click();
return page.getByRole("dialog");
};
test.beforeEach(async ({ page }) => {
topic = unique("pub");
await page.goto("/app");
await subscribe(page, topic);
});
test("publish from the message bar", async ({ page, request }) => {
const input = page.getByPlaceholder("Type a message here");
await input.fill("hello from the message bar");
await input.press("Enter");
await expect(page.getByText("hello from the message bar")).toBeVisible();
await expect(input).toHaveValue("");
await input.fill("sent with the button");
await page.getByRole("button", { name: "Publish message" }).click();
await expect(page.getByText("sent with the button")).toBeVisible();
expect((await lastMessage(request)).message).toBe("sent with the button");
});
test("publish dialog with title, tags and priority", async ({ page, request }) => {
const dialog = await openDialog(page);
await dialog.getByRole("textbox", { name: "Title" }).fill("Dialog title");
await dialog.getByRole("textbox", { name: "Message" }).fill("from the publish dialog");
await dialog.getByRole("textbox", { name: "Tags" }).fill("warning, srv1");
await dialog.getByRole("combobox", { name: "Priority" }).click();
await page.getByRole("option", { name: "Priority 5" }).click();
await dialog.getByRole("button", { name: "Send" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByText("from the publish dialog")).toBeVisible();
expect(await lastMessage(request)).toMatchObject({
title: "Dialog title",
message: "from the publish dialog",
priority: 5,
tags: ["warning", "srv1"],
});
});
test("emoji picker adds a tag", async ({ page, request }) => {
const dialog = await openDialog(page);
await dialog.getByRole("textbox", { name: "Message" }).fill("with an emoji");
await dialog.getByRole("button", { name: "Pick emoji" }).click();
await page.getByRole("searchbox", { name: "Search emoji" }).fill("tada");
await page
.getByLabel(/\(tada\)$/)
.first()
.click();
await expect(dialog.getByRole("textbox", { name: "Tags" })).toHaveValue("tada");
await dialog.getByRole("button", { name: "Send" }).click();
await expect(dialog).toBeHidden();
expect((await lastMessage(request)).tags).toEqual(["tada"]);
});
test("markdown, click URL and attachment URL", async ({ page, request }) => {
const dialog = await openDialog(page);
await dialog.getByRole("textbox", { name: "Message" }).fill("**bold** text");
await dialog.getByRole("checkbox", { name: "Format as Markdown" }).check();
await dialog.getByRole("button", { name: "Click URL" }).click();
await dialog.getByRole("textbox", { name: "Click URL" }).fill("https://example.com/click");
await dialog.getByRole("button", { name: "Attach file by URL" }).click();
await dialog.getByRole("textbox", { name: "Attachment URL" }).fill("https://example.com/files/app.apk");
await expect(dialog.getByRole("textbox", { name: "Filename" })).toHaveValue("app.apk");
await dialog.getByRole("button", { name: "Send" }).click();
await expect(dialog).toBeHidden();
await expect(page.locator("strong", { hasText: "bold" })).toBeVisible();
expect(await lastMessage(request)).toMatchObject({
content_type: "text/markdown",
click: "https://example.com/click",
attachment: { name: "app.apk", url: "https://example.com/files/app.apk" },
});
});
test("attach a local file", async ({ page, request }) => {
const dialog = await openDialog(page);
const chooser = page.waitForEvent("filechooser");
await dialog.getByRole("button", { name: "Attach local file" }).click();
await (await chooser).setFiles({ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("some notes") });
await expect(dialog.getByRole("textbox", { name: "Attachment filename" })).toHaveValue("notes.txt");
await dialog.getByRole("textbox", { name: "Message" }).fill("file attached");
await dialog.getByRole("button", { name: "Send" }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole("listitem", { name: "Notification" }).getByText("notes.txt")).toBeVisible();
const message = await lastMessage(request);
expect(message).toMatchObject({ message: "file attached", attachment: { name: "notes.txt", size: 10 } });
expect(await (await request.get(message.attachment.url)).text()).toBe("some notes");
});
test("publish another keeps the dialog open", async ({ page, request }) => {
const dialog = await openDialog(page);
await dialog.getByRole("checkbox", { name: "Publish another" }).check();
await dialog.getByRole("textbox", { name: "Message" }).fill("first of many");
await dialog.getByRole("button", { name: "Send" }).click();
await expect(dialog.getByText("Notification published")).toBeVisible();
await expect(dialog).toBeVisible();
expect((await lastMessage(request)).message).toBe("first of many");
});
test("publish to another topic from the sidebar", async ({ page, request }) => {
// The dialog is preset to the selected topic until "Change topic" is clicked
const other = unique("pubother");
await page.getByRole("button", { name: "Publish notification" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Change topic" }).click();
await dialog.getByRole("textbox", { name: "Topic name" }).fill(other);
await dialog.getByRole("textbox", { name: "Message" }).fill("to an unsubscribed topic");
await dialog.getByRole("button", { name: "Send" }).click();
await expect(dialog).toBeHidden();
expect((await lastMessage(request, other)).message).toBe("to an unsubscribed topic");
});
test.describe("delayed delivery", () => {
// Natural-language delays ("tomorrow 10am") must resolve in the browser's timezone, not the server's
test.use({ timezoneId: "America/New_York" });
test("uses the browser timezone", async ({ page, request }) => {
const dialog = await openDialog(page);
await dialog.getByRole("textbox", { name: "Message", exact: true }).fill("scheduled message");
await dialog.getByRole("button", { name: "Delay delivery", exact: true }).click();
await dialog.getByLabel("Delay", { exact: true }).fill("tomorrow 10am");
const expected = await page.evaluate(() => {
const d = new Date();
d.setDate(d.getDate() + 1);
d.setHours(10, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
});
const published = page.waitForResponse((r) => r.request().method() === "PUT" && r.url().includes(`/${topic}?`));
await dialog.getByRole("button", { name: "Send", exact: true }).click();
const response = await published;
expect(response.status()).toBe(200);
expect(new URL(response.url()).searchParams.get("timezone")).toBe("America/New_York");
const message = await response.json();
expect(message.time).toBe(expected);
await expect(dialog).toBeHidden();
// Scheduled, so only visible when asking for scheduled messages
const scheduled = await (await request.get(`/${topic}/json?poll=1&scheduled=1`)).text();
expect(scheduled).toContain(message.id);
const delivered = await (await request.get(`/${topic}/json?poll=1`)).text();
expect(delivered).not.toContain(message.id);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { test, expect, account, apiToken, login, openTopicMenu, subscribe } from "./fixtures.js";
import { unique } from "./env.js";
test.beforeEach(({ serverConfig }) => {
test.skip(!serverConfig.enable_reservations, "reservations disabled");
});
// Anonymous publish/subscribe status codes for a topic; default access is read-write, so only
// the reservation can deny anything
const anonymous = async (request, topic) => ({
publish: (await request.post(`/${topic}`, { data: "anonymous" })).status(),
subscribe: (await request.get(`/${topic}/json?poll=1`)).status(),
});
test("reserve a topic while subscribing", async ({ page, request, users }) => {
await login(page, await users.create({ paid: true }));
const topic = unique("reserved");
await subscribe(page, topic, { reserve: true });
await expect.poll(() => anonymous(request, topic)).toEqual({ publish: 403, subscribe: 403 });
});
test("reserve switch is disabled without a tier", async ({ page, users }) => {
await login(page, await users.create());
await page.getByRole("button", { name: "Subscribe to topic" }).click();
await expect(page.getByRole("dialog").getByRole("switch", { name: /reserve topic/i })).toBeDisabled();
});
test("add, edit and delete a reservation in settings", async ({ page, request, users }) => {
const username = await users.create({ paid: true });
const topic = unique("reserved");
await login(page, username);
await page.goto("/settings");
// Add as "everyone can subscribe"
await page.getByRole("button", { name: "Add reserved topic" }).click();
let dialog = page.getByRole("dialog", { name: "Reserve topic" });
await dialog.getByRole("textbox", { name: "Topic", exact: true }).fill(topic);
await dialog.getByRole("combobox").click();
await page.getByRole("option", { name: "I can publish and subscribe, everyone can subscribe" }).click();
await dialog.getByRole("button", { name: "Add" }).click();
const table = page.getByRole("table", { name: "Reserved topics table" });
const row = table.getByRole("row").filter({ hasText: topic });
await expect(row).toContainText("I can publish and subscribe, everyone can subscribe");
await expect.poll(() => anonymous(request, topic)).toEqual({ publish: 403, subscribe: 200 });
// Not subscribed yet; the chip subscribes
await row.getByText("Not subscribed").click();
await expect(page.getByRole("button", { name: topic, exact: true })).toBeVisible();
// Edit to "everyone can publish and subscribe"
await page.goto("/settings");
await row.getByRole("button", { name: "Edit topic access" }).click();
dialog = page.getByRole("dialog", { name: "Edit reserved topic" });
await dialog.getByRole("combobox").click();
await page.getByRole("option", { name: "Everyone can publish and subscribe" }).click();
await dialog.getByRole("button", { name: "Save" }).click();
await expect(row).toContainText("Everyone can publish and subscribe");
await expect.poll(() => anonymous(request, topic)).toEqual({ publish: 200, subscribe: 200 });
// Delete, along with the cached messages
await row.getByRole("button", { name: "Reset topic access" }).click();
dialog = page.getByRole("dialog", { name: "Delete topic reservation" });
await dialog.getByRole("combobox").click();
await page.getByRole("option", { name: "Delete cached messages and attachments" }).click();
await dialog.getByRole("button", { name: "Delete reservation" }).click();
await expect(row).toHaveCount(0);
const token = await apiToken(request, username);
expect((await account(request, token)).reservations ?? []).toEqual([]);
await expect.poll(async () => (await (await request.get(`/${topic}/json?poll=1`)).text()).trim()).toBe("");
});
test("reserve and unreserve from the subscription menu", async ({ page, request, users }) => {
await login(page, await users.create({ paid: true }));
const topic = unique("reserved");
await subscribe(page, topic);
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Reserve topic" }).click();
const dialog = page.getByRole("dialog", { name: "Reserve topic" });
await dialog.getByRole("button", { name: "Add" }).click();
await expect(dialog).toBeHidden();
await expect.poll(() => anonymous(request, topic)).toEqual({ publish: 403, subscribe: 403 });
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Remove reservation" }).click();
await page.getByRole("dialog", { name: "Delete topic reservation" }).getByRole("button", { name: "Delete reservation" }).click();
await expect.poll(() => anonymous(request, topic)).toEqual({ publish: 200, subscribe: 200 });
});
test("an already reserved topic cannot be taken", async ({ page, request, users }) => {
const owner = await users.create({ paid: true });
const topic = unique("taken");
const token = await apiToken(request, owner);
await request.post("/v1/account/reservation", { headers: { Authorization: `Bearer ${token}` }, data: { topic, everyone: "read-write" } });
await login(page, await users.create({ paid: true }));
await page.goto("/settings");
await page.getByRole("button", { name: "Add reserved topic" }).click();
const dialog = page.getByRole("dialog", { name: "Reserve topic" });
await dialog.getByRole("textbox", { name: "Topic", exact: true }).fill(topic);
await dialog.getByRole("button", { name: "Add" }).click();
await expect(dialog.getByText("Topic already reserved")).toBeVisible();
});
+60
View File
@@ -0,0 +1,60 @@
import { execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
// Starts a throwaway ntfy server for the e2e run; Playwright's webServer launches this
// script (with the paths from env.js) and kills it when the run is over. State is wiped
// on every start.
const [binary, configFile, port, tier] = process.argv.slice(2);
const runDir = path.dirname(configFile);
const baseURL = `http://127.0.0.1:${port}`;
if (!fs.existsSync(binary)) {
console.error(`${binary} not found; run "make web-e2e" to build it`);
process.exit(1);
}
fs.rmSync(runDir, { recursive: true, force: true });
fs.mkdirSync(runDir, { recursive: true });
// behind-proxy lets every test pose as its own visitor via X-Forwarded-For (see fixtures.js),
// so per-IP limits like the 6/day account creation limit don't leak between tests
const config = {
"base-url": baseURL,
"listen-http": `127.0.0.1:${port}`,
"cache-file": path.join(runDir, "cache.db"),
"attachment-cache-dir": path.join(runDir, "attachments"),
"auth-file": path.join(runDir, "user.db"),
"auth-default-access": "read-write",
"enable-signup": true,
"enable-login": true,
"enable-reservations": true,
"behind-proxy": true,
"log-file": path.join(runDir, "ntfy.log"),
};
fs.writeFileSync(
configFile,
Object.entries(config)
.map(([k, v]) => `${k}: ${v}\n`)
.join(""),
);
const server = spawn(binary, ["serve", "--config", configFile], { stdio: "inherit" });
server.on("exit", (code) => process.exit(code ?? 1));
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => server.kill(signal));
}
// Wait for the server to create its database; the CLI refuses to run before that
while (!(await fetch(`${baseURL}/v1/health`).catch(() => null))?.ok) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Provision what a remote server is expected to already have: an admin token (specs create
// their users through the admin API) and a tier with reservations
const ntfy = (args, env = {}) => execFileSync(binary, args, { env: { ...process.env, ...env, NTFY_CONFIG_FILE: configFile } }).toString();
ntfy(["user", "add", "--role=admin", "e2eadmin"], { NTFY_PASSWORD: "e2eadmin-password" });
const token = ntfy(["token", "add", "e2eadmin"]).match(/token (tk_\w+) created/)[1];
ntfy(["tier", "add", "--name", "E2E", "--reservation-limit", "10", "--message-limit", "10000", tier]);
// Playwright waits for this line and exports the token as NTFY_E2E_ADMIN_TOKEN (see config)
console.log(`ntfy e2e server ready, admin token ${token}`);
+139
View File
@@ -0,0 +1,139 @@
import { test, expect, account, apiToken, login, selectPref, subscribe } from "./fixtures.js";
import { baseURL, unique } from "./env.js";
test("settings page renders", async ({ page }) => {
await page.goto("/settings");
for (const heading of ["Notifications", "Manage users", "Appearance"]) {
await expect(page.getByRole("heading", { name: heading })).toBeVisible();
}
});
test("notification prefs persist across reloads", async ({ page }) => {
await page.goto("/settings");
await selectPref(page, "Notification sound", "Pop");
await selectPref(page, "Minimum priority", "High priority and higher");
await selectPref(page, "Delete notifications", "After one day");
await page.reload();
await expect(page.getByText("Notifications play the Pop sound when they arrive")).toBeVisible();
await expect(page.getByText("Show notifications if priority is 4 (high) or above")).toBeVisible();
await expect(page.getByText("Notifications are auto-deleted after one day")).toBeVisible();
});
test("notification prefs sync to the account", async ({ page, request, users }) => {
const username = await users.create();
await login(page, username);
await page.goto("/settings");
await selectPref(page, "Notification sound", "No sound");
await selectPref(page, "Minimum priority", "Only max priority");
await selectPref(page, "Delete notifications", "Never");
const token = await apiToken(request, username);
await expect.poll(async () => (await account(request, token)).notification).toEqual({ sound: "none", min_priority: 5, delete_after: 0 });
});
test("theme", async ({ page }) => {
await page.goto("/settings");
await selectPref(page, "Theme", "Dark mode");
await expect(page.locator("html")).toHaveClass(/dark/);
await page.reload();
await expect(page.locator("html")).toHaveClass(/dark/);
await selectPref(page, "Theme", "Light mode");
await expect(page.locator("html")).not.toHaveClass(/dark/);
});
test("date format applies to notifications", async ({ page, request }) => {
const topic = unique("date");
await page.goto("/app");
await subscribe(page, topic);
await request.post(`/${topic}`, { data: "dated message" });
await page.getByRole("button", { name: "Settings" }).click();
await selectPref(page, "Date format", /^ISO 8601/);
await page.getByRole("button", { name: topic, exact: true }).click();
await expect(page.getByRole("listitem", { name: "Notification" })).toContainText(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
});
test.describe("language", () => {
test("switch to German and back", async ({ page }) => {
await page.goto("/settings");
const langFile = page.waitForResponse((r) => r.url().endsWith("/static/langs/de.json"));
await selectPref(page, /^Language/, "Deutsch");
expect((await langFile).ok()).toBeTruthy();
await expect(page.getByRole("heading", { name: "Darstellung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Thema abonnieren" })).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "de");
await page.reload();
await expect(page.getByRole("heading", { name: "Darstellung" })).toBeVisible();
await selectPref(page, /^Sprache/, "English");
await expect(page.getByRole("heading", { name: "Appearance" })).toBeVisible();
});
test("right-to-left language", async ({ page }) => {
await page.goto("/settings");
await selectPref(page, /^Language/, "العربية");
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
});
test("language syncs to the account", async ({ page, browser, request, users }) => {
const username = await users.create();
await login(page, username);
await page.goto("/settings");
await selectPref(page, /^Language/, "Français");
await expect(page.getByRole("heading", { name: "Apparence" })).toBeVisible();
const token = await apiToken(request, username);
await expect.poll(async () => (await account(request, token)).language).toBe("fr");
// Another browser picks it up after login
const context = await browser.newContext({ baseURL });
const other = await context.newPage();
await login(other, username);
await other.goto("/settings");
await expect(other.getByRole("heading", { name: "Apparence" })).toBeVisible();
await context.close();
});
});
test.describe("manage users", () => {
test("add, edit and delete a user", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("button", { name: "Add user" }).click();
let dialog = page.getByRole("dialog", { name: "Add user" });
await dialog.getByRole("textbox", { name: "Service URL, e.g. https://ntfy.sh", exact: true }).fill("not a url");
await expect(dialog.getByText("Invalid URL format. Must start with http:// or https://")).toBeVisible();
await dialog.getByRole("textbox", { name: "Service URL, e.g. https://ntfy.sh", exact: true }).fill("https://ntfy.example.com");
await dialog.getByRole("textbox", { name: "Username, e.g. phil", exact: true }).fill("alice");
await dialog.getByRole("textbox", { name: "Password", exact: true }).fill("alice-password");
await dialog.getByRole("button", { name: "Add" }).click();
const table = page.getByRole("table", { name: "Users table" });
const row = table.getByRole("row").filter({ hasText: "https://ntfy.example.com" });
await expect(row).toContainText("alice");
await page.reload();
await row.getByRole("button", { name: "Edit user" }).click();
dialog = page.getByRole("dialog", { name: "Edit user" });
await dialog.getByRole("textbox", { name: "Username, e.g. phil", exact: true }).fill("bob");
await dialog.getByRole("textbox", { name: "Password", exact: true }).fill("bob-password");
await dialog.getByRole("button", { name: "Save" }).click();
await expect(row).toContainText("bob");
await row.getByRole("button", { name: "Delete user" }).click();
await expect(row).toHaveCount(0);
});
test("duplicate service URL is rejected", async ({ page }) => {
await page.goto("/settings");
for (const attempt of [1, 2]) {
await page.getByRole("button", { name: "Add user" }).click();
const dialog = page.getByRole("dialog", { name: "Add user" });
await dialog.getByRole("textbox", { name: "Service URL, e.g. https://ntfy.sh", exact: true }).fill("https://dup.example.com");
if (attempt === 2) {
await expect(dialog.getByText("A user for this service URL already exists")).toBeVisible();
break;
}
await dialog.getByRole("textbox", { name: "Username, e.g. phil", exact: true }).fill("alice");
await dialog.getByRole("textbox", { name: "Password", exact: true }).fill("alice-password");
await dialog.getByRole("button", { name: "Add" }).click();
await expect(dialog).toBeHidden();
}
});
});
+148
View File
@@ -0,0 +1,148 @@
import { test, expect, account, apiToken, connected, login, openTopicMenu, subscribe } from "./fixtures.js";
import { baseURL, password, port, remote, unique } from "./env.js";
test("subscribe through the dialog", async ({ page }) => {
const topic = unique("sub");
await page.goto("/app");
await subscribe(page, topic);
await expect(page.getByText("You haven't received any notifications for this topic yet.")).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${topic}$`));
});
test("generate a topic name", async ({ page }) => {
await page.goto("/app");
await page.getByRole("button", { name: "Subscribe to topic" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Generate name" }).click();
const topic = await dialog.getByLabel(/topic name/i).inputValue();
expect(topic).toMatch(/^\w{16}$/);
await dialog.getByRole("button", { name: "Subscribe", exact: true }).click();
await expect(page.getByRole("button", { name: topic, exact: true })).toBeVisible();
});
test("visiting a topic URL subscribes to it", async ({ page }) => {
const topic = unique("visit");
await page.goto(`/${topic}`);
await expect(page.getByRole("button", { name: topic, exact: true })).toBeVisible();
});
test("subscribe to a topic on another server", async ({ page, request }) => {
// localhost is a different base URL for the same local server, so it counts as "another server"
test.skip(remote, "needs a second hostname for the server");
const topic = unique("other");
const otherServer = `http://localhost:${port}`;
await page.goto("/app");
await page.getByRole("button", { name: "Subscribe to topic" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel(/topic name/i).fill(topic);
await dialog.getByRole("switch", { name: "Use another server" }).check();
await dialog.getByRole("combobox").fill(otherServer);
const live = connected(page, topic);
await dialog.getByRole("button", { name: "Subscribe", exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/localhost:${port}/${topic}$`));
await live;
await request.post(`/${topic}`, { data: "via the other server" });
await expect(page.getByText("via the other server")).toBeVisible();
});
test("protected topic asks for a login", async ({ page, request, users, serverConfig }) => {
test.skip(!serverConfig.enable_reservations, "reservations disabled");
const owner = await users.create({ paid: true });
const token = await apiToken(request, owner);
const topic = unique("protected");
const auth = { Authorization: `Bearer ${token}` };
expect((await request.post("/v1/account/reservation", { headers: auth, data: { topic, everyone: "deny-all" } })).ok()).toBeTruthy();
// Anonymous subscribe hits the login-required page; the owner's credentials get through
await page.goto("/app");
await page.getByRole("button", { name: "Subscribe to topic" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel(/topic name/i).fill(topic);
await dialog.getByRole("button", { name: "Subscribe", exact: true }).click();
await expect(dialog.getByText("Login required")).toBeVisible();
await dialog.getByRole("textbox", { name: "Username, e.g. phil", exact: true }).fill(owner);
await dialog.getByLabel("Password", { exact: true }).fill(password);
const live = connected(page, topic);
await dialog.getByRole("button", { name: "Login" }).click();
await expect(page.getByRole("button", { name: topic, exact: true })).toBeVisible();
await live;
// Stored credentials are used for the live connection, and listed under Settings
await request.post(`/${topic}`, { headers: auth, data: "for the owner only" });
await expect(page.getByText("for the owner only")).toBeVisible();
await page.getByRole("button", { name: "Settings" }).click();
await expect(page.getByRole("table", { name: "Users table" }).getByText(owner)).toBeVisible();
});
test.describe("subscription menu", () => {
let topic;
test.beforeEach(async ({ page }) => {
topic = unique("menu");
await page.goto("/app");
await subscribe(page, topic);
});
test("change display name", async ({ page }) => {
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Change display name" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: "Display name" }).fill("My alerts");
await dialog.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("button", { name: "My alerts", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: topic, exact: true })).toHaveCount(0);
});
test("mute and unmute", async ({ page }) => {
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Mute notifications" }).click();
const muted = page.getByRole("button", { name: topic, exact: true }).getByLabel("Notifications muted").first();
await expect(muted).toBeVisible();
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Unmute notifications" }).click();
await expect(muted).toHaveCount(0);
});
test("send test notification", async ({ page }) => {
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Send test notification" }).click();
await expect(page.getByRole("listitem", { name: "Notification" })).toHaveCount(1);
});
test("unsubscribe", async ({ page }) => {
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Unsubscribe" }).click();
await expect(page.getByRole("button", { name: topic, exact: true })).toHaveCount(0);
await page.reload();
await expect(page.getByText("It looks like you don't have any subscriptions yet.")).toBeVisible();
});
});
test("subscriptions and display names sync to the account", async ({ page, browser, request, users }) => {
const username = await users.create();
const topic = unique("sync");
await login(page, username);
await subscribe(page, topic);
await openTopicMenu(page, topic);
await page.getByRole("menuitem", { name: "Change display name" }).click();
await page.getByRole("dialog").getByRole("textbox", { name: "Display name" }).fill("Synced name");
await page.getByRole("dialog").getByRole("button", { name: "Save" }).click();
const token = await apiToken(request, username);
await expect
.poll(async () => (await account(request, token)).subscriptions?.map((s) => [s.topic, s.display_name]))
.toEqual([[topic, "Synced name"]]);
// A second browser picks the subscription up on login
const context = await browser.newContext({ baseURL });
const other = await context.newPage();
await login(other, username);
await expect(other.getByRole("button", { name: "Synced name", exact: true })).toBeVisible();
// Unsubscribing removes it from the account
await openTopicMenu(page, "Synced name");
await page.getByRole("menuitem", { name: "Unsubscribe" }).click();
await expect.poll(async () => (await account(request, token)).subscriptions ?? []).toEqual([]);
await context.close();
});
+62
View File
@@ -0,0 +1,62 @@
import { test, expect, account, apiToken, login } from "./fixtures.js";
import { unique } from "./env.js";
test.beforeEach(({ serverConfig }) => {
test.skip(!serverConfig.enable_login, "login disabled");
});
test("create, edit and delete an access token", async ({ page, request, users }) => {
const username = await users.create();
const sessionToken = await apiToken(request, username);
await login(page, username);
await page.goto("/account");
const table = page.getByRole("table", { name: "Access tokens" });
await expect(table.getByText("Current browser session")).toBeVisible();
// Create
await page.getByRole("button", { name: "Create access token" }).click();
let dialog = page.getByRole("dialog", { name: "Create access token" });
await dialog.getByRole("textbox", { name: "Label, e.g. Radarr notifications", exact: true }).fill("Radarr");
await dialog.getByRole("combobox").click();
await page.getByRole("option", { name: "Token expires in 3 days" }).click();
await dialog.getByRole("button", { name: "Create token" }).click();
await expect(dialog).toBeHidden();
const row = table.getByRole("row").filter({ hasText: "Radarr" });
await expect(row).toBeVisible();
// The new token is real: it can publish as the user
const created = (await account(request, sessionToken)).tokens.find((t) => t.label === "Radarr");
const inThreeDays = Date.now() / 1000 + 3 * 24 * 3600;
expect(Math.abs(created.expires - inThreeDays)).toBeLessThan(60);
const topic = unique("token");
const publish = () => request.post(`/${topic}`, { headers: { Authorization: `Bearer ${created.token}` }, data: "via token" });
expect((await publish()).status()).toBe(200);
// Edit the label and make it never expire
await row.getByRole("button", { name: "Edit access token" }).click();
dialog = page.getByRole("dialog", { name: "Edit access token" });
await dialog.getByRole("textbox", { name: "Label, e.g. Radarr notifications", exact: true }).fill("Sonarr");
await dialog.getByRole("combobox").click();
await page.getByRole("option", { name: "Token never expires" }).click();
await dialog.getByRole("button", { name: "Update token" }).click();
await expect(table.getByRole("row").filter({ hasText: "Sonarr" }).getByText("Never expires")).toBeVisible();
// Delete; the token stops working
await table.getByRole("row").filter({ hasText: "Sonarr" }).getByRole("button", { name: "Delete access token" }).click();
dialog = page.getByRole("dialog", { name: "Delete access token" });
await dialog.getByRole("button", { name: "Permanently delete token" }).click();
await expect(table.getByRole("row").filter({ hasText: "Sonarr" })).toHaveCount(0);
expect((await publish()).status()).toBe(401);
});
test("current session token cannot be deleted", async ({ page, users }) => {
await login(page, await users.create());
await page.goto("/account");
// Its edit and delete buttons are unnamed, but sit under an explanatory tooltip
const row = page.getByRole("table", { name: "Access tokens" }).getByRole("row").filter({ hasText: "Current browser session" });
const buttons = row.getByLabel("Cannot edit or delete current session token").getByRole("button");
await expect(buttons).toHaveCount(2);
for (const button of await buttons.all()) {
await expect(button).toBeDisabled();
}
});
+64
View File
@@ -28,6 +28,7 @@
"stylis-plugin-rtl": "^2.1.1"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^8.41.0",
"eslint-config-airbnb": "^19.0.4",
@@ -2232,6 +2233,22 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -7486,6 +7503,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+3 -1
View File
@@ -10,7 +10,8 @@
"format:check": "prettier . --check",
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@emotion/cache": "^11.11.0",
@@ -33,6 +34,7 @@
"stylis-plugin-rtl": "^2.1.1"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^8.41.0",
"eslint-config-airbnb": "^19.0.4",
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from "@playwright/test";
import { baseURL, binary, configFile, localTier, port, remote } from "./e2e/env.js";
// End-to-end tests drive a real browser against a real ntfy server, started from
// e2e/server.mjs with a fresh database, or an existing one (see e2e/env.js). Run with
// "make web-e2e".
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
timeout: 30_000,
expect: { timeout: 10_000 },
reporter: [["list"]],
use: {
...devices["Desktop Chrome"],
baseURL,
permissions: ["notifications"],
trace: "retain-on-failure",
},
webServer: remote
? undefined
: {
command: `node e2e/server.mjs ${binary} ${configFile} ${port} ${localTier}`,
wait: { stdout: /admin token (?<ntfy_e2e_admin_token>tk_\w+)/ },
reuseExistingServer: false,
stdout: "ignore",
},
});
+29 -13
View File
@@ -190,20 +190,29 @@ export class SubscriptionManager {
/** Adds notification, or returns false if it already exists */
async addNotification(subscriptionId, notification) {
const exists = await this.db.notifications.get(notification.id);
if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {
if (notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {
return false;
}
try {
// Note: Service worker (sw.js) and addNotifications() duplicates this logic,
// so if you change it here, change it there too.
// Add notification to database
await this.db.notifications.add({
...messageWithSequenceId(notification),
subscriptionId,
new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
// Add notification to database; check and add in one transaction, so a poll storing the
// same message concurrently (see addNotifications) can't slip in between
const added = await this.db.transaction("rw", this.db.notifications, async () => {
if (await this.db.notifications.get(notification.id)) {
return false;
}
await this.db.notifications.add({
...messageWithSequenceId(notification),
subscriptionId,
new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
});
return true;
});
if (!added) {
return false;
}
// FIXME consider put() for double tab
// Update subscription last message id (for ?since=... queries)
@@ -216,14 +225,21 @@ export class SubscriptionManager {
return true;
}
/** Adds/replaces notifications, will not throw if they exist */
/** Adds notifications, skipping ones that already exist; will not throw if they exist */
async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications.map((notification) => ({
...messageWithSequenceId(notification),
subscriptionId,
}));
// Skip notifications that are already stored (e.g. delivered via WebSocket while this poll
// was in flight), so overwriting them doesn't drop their "new" marker
await this.db.transaction("rw", this.db.notifications, async () => {
const existing = await this.db.notifications.bulkGet(notifications.map((n) => n.id));
const notificationsWithSubscriptionId = notifications
.filter((_, i) => !existing[i])
.map((notification) => ({
...messageWithSequenceId(notification),
subscriptionId,
}));
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
});
const lastNotificationId = notifications.at(-1).id;
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
await this.db.subscriptions.update(subscriptionId, {
last: lastNotificationId,
});
+1 -1
View File
@@ -35,7 +35,7 @@ const Signup = () => {
} catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e);
if (e instanceof UserExistsError) {
setError(t("signup_error_username_taken", { username: e.username }));
setError(t("signup_error_username_taken", { username: user.username }));
} else if (e instanceof AccountActionLimitReachedError) {
setError(t("signup_error_creation_limit_reached"));
} else {