mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-10-16 04:26:37 +02:00
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:
1 parent
9c709731df
commit
d7013703c1
19 files changed
+1344
-15
No files matched your search
@@ -17,5 +17,7 @@ node_modules/
|
||||
.DS_Store
|
||||
__pycache__
|
||||
web/dev-dist/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
venv/
|
||||
cmd/key-file.yaml
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3,3 +3,5 @@ dist/
|
||||
public/static/langs/
|
||||
src/app/emojis.js
|
||||
src/app/emojisMapped.js
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)}`;
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Generated
+64
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user