test(e2e): expand Playwright coverage for UI regression protection

Grow the suite from 41 to 77 specs covering previously untested UI
surfaces: channels admin CRUD with QR codes and keyboard nav, node
detail adoption/tag editing with the role permission matrix, SPA 404
page, sortable tables with URL-synced sort state, pagination with
filter-preserving links, mobile viewport (hamburger nav, mobile cards,
sort select), anonymous/member auth gating, raw packet detail, and
empty states. Route cards now assert history chart, recent-match links
and owner profile links.

Supporting changes: purposeful data-testids on channels/node-detail/
pagination/sort headers (plus aria-sort) and mobile nav; seed_data adds
30 trace_data filler groups for pagination, channels at every
visibility tier incl. one disabled, and a pw-operator profile.
This commit is contained in:
Louis King committed 2026-08-30 12:27:48 +01:00
1 parent e67e0ec3ae
commit ec199e4e2b
20 files changed
+881 -31

No files matched your search

+6 -2
View File
@@ -129,12 +129,16 @@ Design notes when extending the suite:
- **Data is deterministic.** `e2e/seed_data.py` clears + recreates fixed rows
(nodes/observers with `area` tags, adverts, messages on channel idx 17 + the
"E2E General" custom channel, raw packets + path hops keyed to node prefixes,
a route + health, profiles + adoptions) using recent timestamps (7-day windows).
30 `trace_data` filler groups for pagination tests, channels at every
visibility tier (community/member/operator/admin + one disabled), a route +
health, profiles incl. `pw-operator` + adoptions) using recent timestamps
(7-day windows).
- **Single shared backend:** `workers: 1`, `fullyParallel: false`; routes/profile
specs are `describe.serial`. `WEB_AUTO_REFRESH_SECONDS=2` makes polling assertable.
- Selectors rely on purposeful `data-testid`s (theme/auto-refresh toggles, observer
area badges, path-hop badge + popover, route modal fields, nav/hero/member/list
rows) added to the React components.
rows, channel cards/modal, node adoption/tag forms, pagination, sort headers,
mobile nav) added to the React components.
## Database & Ops
+67 -5
View File
@@ -312,19 +312,72 @@ def seed_raw_packets(
return first_raw_packet_id
def seed_packet_filler(session: Session, nodes: dict[str, Node]) -> None:
"""Extra raw packet groups used by the pagination tests.
Uses the ``trace_data`` event type so the groups surface on /packets but
never on the dedicated /advertisements or /messages pages, and never
interfere with the event_type=advertisement filter assertions. The 30
filler groups (on top of the 10 seeded events) push /packets past one
page (limit 20). Timestamps stay older than every seeded event so the
newest groups keep their ordering.
"""
for i in range(30):
packet_hash = _hash(f"pf{i + 1:02d}")
event_hash = _event_hash(200 + i)
received_at = _ago(hours=3 + i * 0.25)
observer_pk = NORTH_1 if i % 2 == 0 else SOUTH_1
raw = RawPacket(
observer_node_id=nodes[observer_pk].id,
packet_hash=packet_hash,
event_hash=event_hash,
raw_hex=(packet_hash * 4)[:128],
packet_type=5,
payload_type=4,
event_type="trace_data",
channel_idx=None,
source_pubkey_prefix=ALPHA[:12],
route_type="flood",
path_len=3,
path_hash_bytes=2,
snr=7.0,
decoded={"e2e": True, "filler": i + 1},
received_at=received_at,
)
session.add(raw)
session.flush()
for position, node_hash in enumerate(PATH_HOPS):
session.add(
PacketPathHop(
raw_packet_id=raw.id,
position=position,
node_hash=node_hash,
packet_hash=packet_hash,
event_hash=event_hash,
received_at=received_at,
observer_node_id=nodes[observer_pk].id,
)
)
session.flush()
def seed_channels(session: Session) -> int:
keys = [
("E2E General", "00112233445566778899aabbccddeeff" * 2),
("E2E Ops", "ffeeddccbbaa99887766554433221100" * 2),
("E2E General", "00112233445566778899aabbccddeeff" * 2, "community", True),
("E2E Ops", "ffeeddccbbaa99887766554433221100" * 2, "community", True),
("E2E Members", "0f0e0d0c0b0a09080706050403020100" * 2, "member", True),
("E2E Crew", "102030405060708090a0b0c0d0e0f00" * 2, "operator", True),
("E2E Staff", "11223344556677889900aabbccddeeff" * 2, "admin", True),
("E2E Retired", "deadbeefdeadbeefdeadbeefdeadbeef" * 2, "community", False),
]
for name, key_hex in keys:
for name, key_hex, visibility, enabled in keys:
session.add(
Channel(
name=name,
key_hex=key_hex,
channel_hash=Channel.compute_channel_hash(key_hex),
visibility="community",
enabled=True,
visibility=visibility,
enabled=enabled,
)
)
session.flush()
@@ -458,6 +511,14 @@ def seed_profiles(session: Session, nodes: dict[str, Node]) -> None:
"Playwright member user",
None,
),
(
"pw-operator",
"PW Operator",
"E2EOPR",
"operator,member",
"Playwright operator user",
None,
),
("op-north", "Op North", "OPN1", "operator,member", "North operator", None),
("mem-south", "Mem South", "MEMS1", "member", "South member", None),
]
@@ -508,6 +569,7 @@ def main() -> None:
first_raw_packet_id = seed_raw_packets(
session, nodes, advert_events, message_events, custom_channel_idx
)
seed_packet_filler(session, nodes)
seed_routes(session, nodes, first_raw_packet_id)
seed_profiles(session, nodes)
session.commit()
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from "@playwright/test";
import { MEMBER_STATE } from "../utils/helpers";
test.describe("anonymous gating", () => {
test("home shows a login button and no user menu", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "Login", exact: true })).toBeVisible();
await expect(page.getByTestId("user-menu")).toHaveCount(0);
});
test("routes page hides every mutation affordance", async ({ page }) => {
await page.goto("/routes");
await expect(page.getByTestId("route-card").first()).toBeVisible();
await expect(page.getByTestId("add-route")).toHaveCount(0);
await expect(page.getByTestId("edit-route")).toHaveCount(0);
await expect(page.getByTestId("delete-route")).toHaveCount(0);
});
test("channels page hides admin buttons and restricted channels", async ({
page,
}) => {
await page.goto("/channels");
// Anonymous visitors only see community channels.
await expect(page.getByTestId("channel-card")).toHaveCount(3);
await expect(
page.locator('[data-testid="channel-card"][data-channel-name="E2E General"]'),
).toBeVisible();
await expect(
page.locator('[data-testid="channel-card"][data-channel-name="E2E Staff"]'),
).toHaveCount(0);
await expect(page.getByTestId("add-channel")).toHaveCount(0);
await expect(page.getByTestId("channel-edit")).toHaveCount(0);
await expect(page.getByTestId("channel-delete")).toHaveCount(0);
});
});
test.describe("member gating", () => {
test.use({ storageState: MEMBER_STATE });
test("members get no route mutation buttons", async ({ page }) => {
await page.goto("/routes");
await expect(page.getByTestId("route-card").first()).toBeVisible();
await expect(page.getByTestId("add-route")).toHaveCount(0);
await expect(page.getByTestId("edit-route")).toHaveCount(0);
await expect(page.getByTestId("delete-route")).toHaveCount(0);
await expect(page.getByTestId("routes-mine-toggle")).toHaveCount(0);
});
test("members see community and member channels without admin buttons", async ({
page,
}) => {
await page.goto("/channels");
await expect(page.getByTestId("channel-card")).toHaveCount(4);
await expect(
page.locator('[data-testid="channel-card"][data-channel-name="E2E Members"]'),
).toBeVisible();
await expect(
page.locator('[data-testid="channel-card"][data-channel-name="E2E Crew"]'),
).toHaveCount(0);
await expect(
page.locator('[data-testid="channel-card"][data-channel-name="E2E Staff"]'),
).toHaveCount(0);
await expect(page.getByTestId("add-channel")).toHaveCount(0);
await expect(page.getByTestId("channel-edit")).toHaveCount(0);
await expect(page.getByTestId("channel-delete")).toHaveCount(0);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { expect, test, type Page } from "@playwright/test";
import { ADMIN_STATE } from "../utils/helpers";
test.use({ storageState: ADMIN_STATE });
const CREATED_NAME = "E2E Created";
const CREATED_KEY = "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe";
function card(page: Page, name: string) {
return page.locator(
`[data-testid="channel-card"][data-channel-name="${name}"]`,
);
}
test.describe.serial("channels (admin)", () => {
test("seeded channels render grouped by visibility with QR codes", async ({
page,
}) => {
await page.goto("/channels");
await expect(page.getByTestId("channel-card")).toHaveCount(6);
await expect(page.getByTestId("add-channel")).toBeVisible();
for (const name of ["E2E General", "E2E Ops", "E2E Retired"]) {
await expect(card(page, name)).toBeVisible();
}
// Sections: community holds the two enabled channels plus the retired one.
await expect(
page.getByRole("heading", { name: "Community", exact: true }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Member", exact: true }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Operator", exact: true }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Admin", exact: true }),
).toBeVisible();
// Every seeded channel exposes its full key and a QR code to admins.
await expect(card(page, "E2E General").locator('[data-testid="channel-qr"] svg')).toBeVisible();
// Disabled channels are badged.
await expect(card(page, "E2E Retired").getByText("Disabled")).toBeVisible();
// Admin controls exist on every card.
await expect(page.getByTestId("channel-edit")).toHaveCount(6);
await expect(page.getByTestId("channel-delete")).toHaveCount(6);
});
test("adding a channel creates a card with a QR code", async ({ page }) => {
await page.goto("/channels");
await page.getByTestId("add-channel").click();
const modal = page.locator("dialog.modal-open");
await expect(modal).toBeVisible();
await expect(modal.locator("h3")).toHaveText("Add Channel");
await page.getByTestId("channel-name").fill(CREATED_NAME);
await page.getByTestId("channel-key").fill(CREATED_KEY);
await page.getByTestId("channel-visibility").selectOption("member");
await page.getByTestId("channel-save").click();
await expect(modal).toHaveCount(0);
const created = card(page, CREATED_NAME);
await expect(created).toBeVisible();
await expect(created.locator('[data-testid="channel-qr"] svg')).toBeVisible();
await expect(created).toContainText(CREATED_KEY);
// Cancelling the delete confirm keeps the channel.
await created.getByTestId("channel-delete").click();
const confirm = page.locator("dialog.modal-open");
await expect(
confirm.getByRole("heading", { name: "Delete Channel" }),
).toBeVisible();
await confirm.getByRole("button", { name: "Cancel" }).click();
await expect(confirm).toHaveCount(0);
await expect(created).toBeVisible();
});
test("editing a channel moves it between visibility sections", async ({
page,
}) => {
await page.goto("/channels");
const created = card(page, CREATED_NAME);
await created.getByTestId("channel-edit").click();
const modal = page.locator("dialog.modal-open");
await expect(modal.locator("h3")).toHaveText("Edit Channel");
// The name is locked and the key is not re-entered on edit.
await expect(page.getByTestId("channel-name")).toBeDisabled();
await expect(page.getByTestId("channel-name")).toHaveValue(CREATED_NAME);
await expect(page.getByTestId("channel-key")).toHaveCount(0);
await page.getByTestId("channel-visibility").selectOption("operator");
await page.getByTestId("channel-enabled").uncheck();
await page.getByTestId("channel-save").click();
await expect(modal).toHaveCount(0);
await expect(created).toBeVisible();
await expect(created.getByText("Disabled")).toBeVisible();
// It now sits under the Operator section.
const operatorSection = page.locator("h2", { hasText: "Operator" }).locator("..");
await expect(operatorSection.getByTestId("channel-card")).toHaveCount(2);
});
test("deleting a channel confirms and removes the card", async ({ page }) => {
await page.goto("/channels");
const created = card(page, CREATED_NAME);
await created.getByTestId("channel-delete").click();
const confirm = page.locator("dialog.modal-open");
await expect(
confirm.getByRole("heading", { name: "Delete Channel" }),
).toBeVisible();
await expect(
confirm.getByText(`Are you sure you want to delete channel ${CREATED_NAME}?`),
).toBeVisible();
await confirm.getByRole("button", { name: "Delete" }).click();
await expect(confirm).toHaveCount(0);
await expect(created).toHaveCount(0);
await expect(page.getByTestId("channel-card")).toHaveCount(5);
});
test("card click and Enter key navigate to the channel messages", async ({
page,
}) => {
await page.goto("/channels");
await card(page, "E2E General").click();
await expect(page).toHaveURL(/\/messages\?channel_idx=\d+/);
await page.goto("/channels");
const cardEl = card(page, "E2E Ops");
await cardEl.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/messages\?channel_idx=\d+/);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test";
test.describe("empty states", () => {
test("nodes search with no matches shows the empty state", async ({ page }) => {
await page.goto("/nodes?search=NoMatchToBeFound");
await expect(page.getByText("No nodes found")).toBeVisible();
await expect(page.getByTestId("list-row")).toHaveCount(0);
});
test("advertisements search with no matches shows the empty state", async ({
page,
}) => {
await page.goto("/advertisements?search=NoMatchToBeFound");
await expect(page.getByText("No adverts found")).toBeVisible();
await expect(page.getByTestId("list-row")).toHaveCount(0);
});
test("messages filtered to an unknown channel show the empty state", async ({
page,
}) => {
await page.goto("/messages?channel_idx=99999");
await expect(page.getByText("No messages found")).toBeVisible();
await expect(page.getByTestId("list-row")).toHaveCount(0);
});
test("packets search with no matches shows the empty state", async ({
page,
}) => {
await page.goto("/packets?search=NoMatchToBeFound");
await expect(page.getByText("No packets found")).toBeVisible();
await expect(page.getByTestId("list-row")).toHaveCount(0);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
test.use({ viewport: { width: 375, height: 667 } });
test.describe("mobile layout", () => {
test("hamburger menu opens and navigates", async ({ page }) => {
await page.goto("/");
const menu = page.getByTestId("mobile-nav-menu");
await expect(menu).not.toBeVisible();
await page.getByTestId("mobile-nav-toggle").click();
await expect(menu).toBeVisible();
await expect(menu.getByTestId("nav-link").first()).toBeVisible();
await menu.locator('[data-testid="nav-link"][data-nav-href="/messages"]').click();
await expect(page).toHaveURL("/messages");
await expect(page.getByTestId("list-row").first()).toBeVisible();
});
test("nodes list renders mobile cards with the sort select", async ({
page,
}) => {
await page.goto("/nodes");
// Desktop table is hidden on small screens; mobile cards render instead.
await expect(page.locator("table")).toBeHidden();
await expect(page.getByTestId("mobile-sort-select")).toBeVisible();
const sortSelect = page.getByTestId("mobile-sort-select");
await sortSelect.selectOption("name:asc");
await expect(page).toHaveURL(/sort=name/);
await expect(page).toHaveURL(/order=asc/);
const firstCard = page.locator("a.card").first();
await expect(firstCard).toContainText("Alpha Node");
});
});
+218
View File
@@ -0,0 +1,218 @@
import { expect, test } from "@playwright/test";
import { ADMIN_STATE, MEMBER_STATE, OPERATOR_STATE } from "../utils/helpers";
const ALPHA_KEY = "a1fa" + "0".repeat(60);
const BRAVO_KEY = "b2b0" + "0".repeat(60);
const CHARLIE_KEY = "c3c0" + "0".repeat(60);
const DELTA_KEY = "d4d0" + "0".repeat(60);
test.describe("node detail (anonymous)", () => {
test.use({ permissions: ["clipboard-read", "clipboard-write"] });
test("node with coordinates renders the mini-map and contact QR", async ({
page,
}) => {
await page.goto(`/nodes/${ALPHA_KEY}`);
await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
await expect(page.getByRole("heading", { name: "Alpha Node" })).toBeVisible();
await expect(page.getByTestId("node-mini-map")).toBeVisible();
await expect(page.getByTestId("contact-qr").locator("svg")).toBeVisible();
// The public key is copyable.
const copyable = page.locator('code[title="Click to copy"]').first();
await copyable.click();
await expect(page.getByText("Copied!").first()).toBeVisible();
});
test("node without coordinates falls back to the QR card", async ({
page,
}) => {
await page.goto(`/nodes/${DELTA_KEY}`);
await expect(page.getByTestId("node-mini-map")).toHaveCount(0);
await expect(page.getByTestId("contact-qr").locator("svg")).toBeVisible();
await expect(page.getByText("Scan to add as contact")).toBeVisible();
});
test("short prefix resolves and redirects to the full key", async ({
page,
}) => {
await page.goto("/n/a1fa");
await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`));
await expect(page.getByRole("heading", { name: "Alpha Node" })).toBeVisible();
await page.goto(`/nodes/${ALPHA_KEY.slice(0, 8)}`);
await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`));
});
test("unknown prefix shows the not-found state", async ({ page }) => {
await page.goto("/nodes/zzzz");
await expect(page.getByText("Node not found:")).toBeVisible();
await expect(page.getByTestId("adopt-button")).toHaveCount(0);
});
});
test.describe.serial("node tags (admin)", () => {
test.use({ storageState: ADMIN_STATE });
test("tags can be added, validated, edited and deleted", async ({ page }) => {
await page.goto(`/nodes/${DELTA_KEY}`);
// Add a tag.
await page.getByTestId("tag-key").fill("description");
await page.getByTestId("tag-value").fill("E2E marker");
await page.getByTestId("tag-add").click();
const row = page.locator('[data-testid="tag-row"][data-tag-key="description"]');
await expect(row).toBeVisible();
await expect(row).toContainText("E2E marker");
// Invalid number values are rejected client-side.
await page.getByTestId("tag-key").fill("score");
await page.getByTestId("tag-type").selectOption("number");
await page.getByTestId("tag-value").fill("not-a-number");
await page.getByTestId("tag-add").click();
await expect(page.getByText("Value must be a valid number")).toBeVisible();
await expect(
page.locator('[data-testid="tag-row"][data-tag-key="score"]'),
).toHaveCount(0);
// Edit the tag value.
await row.getByTestId("tag-edit").click();
const modal = page.getByTestId("tag-edit-modal");
await expect(modal).toBeVisible();
await expect(page.getByTestId("tag-edit-value")).toHaveValue("E2E marker");
await page.getByTestId("tag-edit-value").fill("E2E updated");
await page.getByTestId("tag-edit-save").click();
await expect(modal).toHaveCount(0);
await expect(row).toContainText("E2E updated");
// Cancelling the edit modal leaves the tag unchanged.
await row.getByTestId("tag-edit").click();
await expect(modal).toBeVisible();
await page.getByTestId("tag-edit-cancel").click();
await expect(modal).toHaveCount(0);
await expect(row).toContainText("E2E updated");
// Cancelling the delete confirm keeps the tag.
await row.getByTestId("tag-delete").click();
const confirm = page.locator("dialog.modal-open");
await expect(
confirm.getByRole("heading", { name: "Delete Tag" }),
).toBeVisible();
await confirm.getByRole("button", { name: "Cancel" }).click();
await expect(confirm).toHaveCount(0);
await expect(row).toBeVisible();
// Confirming the delete removes the tag.
await row.getByTestId("tag-delete").click();
await confirm.getByRole("button", { name: "Delete" }).click();
await expect(confirm).toHaveCount(0);
await expect(row).toHaveCount(0);
});
});
test.describe.serial("adoption (admin)", () => {
test.use({ storageState: ADMIN_STATE });
test("adopt shows ownership and release restores the unadopted state", async ({
page,
}) => {
await page.goto(`/nodes/${DELTA_KEY}`);
const adoption = page.getByTestId("adoption-card");
await expect(adoption).toBeVisible();
await expect(adoption.getByText("not been adopted")).toBeVisible();
await expect(page.getByTestId("adopt-button")).toBeVisible();
await page.getByTestId("adopt-button").click();
await expect(page).toHaveURL(/message=.*adopted/);
await expect(page.getByTestId("adoption-owner")).toHaveText("PW Admin");
await expect(page.getByTestId("release-button")).toBeVisible();
// The owner name links to the adopting profile.
await expect(page.getByTestId("adoption-owner")).toHaveAttribute(
"href",
/^\/profile\//,
);
// Cancelling the release confirm keeps the adoption.
await page.getByTestId("release-button").click();
const confirm = page.locator("dialog.modal-open");
await expect(
confirm.getByRole("heading", { name: "Release" }),
).toBeVisible();
await confirm.getByRole("button", { name: "Cancel" }).click();
await expect(confirm).toHaveCount(0);
await expect(page.getByTestId("adoption-owner")).toBeVisible();
// Confirming the release returns the node to the unadopted state.
await page.getByTestId("release-button").click();
await confirm.getByRole("button", { name: "Release" }).click();
await expect(page).toHaveURL(/message=.*released/);
await expect(page.getByTestId("adopt-button")).toBeVisible();
await expect(page.getByTestId("adoption-owner")).toHaveCount(0);
});
});
test.describe("node detail (member is read-only)", () => {
test.use({ storageState: MEMBER_STATE });
test("members cannot edit tags or adopt nodes", async ({ page }) => {
await page.goto(`/nodes/${DELTA_KEY}`);
await expect(page.getByTestId("tag-form")).toHaveCount(0);
await expect(page.getByTestId("tag-edit")).toHaveCount(0);
await expect(page.getByTestId("tag-delete")).toHaveCount(0);
await expect(page.getByTestId("adopt-button")).toHaveCount(0);
// Read-only tags (the seeded name tag) still render.
await expect(
page.locator("td.font-mono", { hasText: "name" }).first(),
).toBeVisible();
});
test("members see the owner of an adopted node without release rights", async ({
page,
}) => {
await page.goto(`/nodes/${BRAVO_KEY}`);
const adoption = page.getByTestId("adoption-card");
await expect(adoption).toBeVisible();
await expect(page.getByTestId("adoption-owner")).toHaveText("Mem South");
await expect(page.getByTestId("release-button")).toHaveCount(0);
});
});
test.describe.serial("adoption grants tag editing (operator)", () => {
test.use({ storageState: OPERATOR_STATE });
test("operators can edit tags only on nodes they adopt", async ({ page }) => {
await page.goto(`/nodes/${CHARLIE_KEY}`);
// Before adoption the operator is read-only.
await expect(page.getByTestId("tag-form")).toHaveCount(0);
await expect(page.getByTestId("adopt-button")).toBeVisible();
await page.getByTestId("adopt-button").click();
await expect(page.getByTestId("adoption-owner")).toHaveText("PW Operator");
await expect(page.getByTestId("tag-form")).toBeVisible();
// Tags added while adopted can be cleaned up again.
await page.getByTestId("tag-key").fill("opnote");
await page.getByTestId("tag-value").fill("checked by operator");
await page.getByTestId("tag-add").click();
const row = page.locator('[data-testid="tag-row"][data-tag-key="opnote"]');
await expect(row).toBeVisible();
await row.getByTestId("tag-delete").click();
const confirm = page.locator("dialog.modal-open");
await confirm.getByRole("button", { name: "Delete" }).click();
await expect(row).toHaveCount(0);
// Releasing the node removes the editing capability again.
await page.getByTestId("release-button").click();
await confirm.getByRole("button", { name: "Release" }).click();
await expect(page.getByTestId("adopt-button")).toBeVisible();
await expect(page.getByTestId("tag-form")).toHaveCount(0);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";
test.describe("spa 404 page", () => {
test("unknown routes render the not-found page with a way back", async ({
page,
}) => {
await page.goto("/does-not-exist");
const hero = page.getByTestId("not-found");
await expect(hero).toBeVisible();
await expect(hero.getByText("404")).toBeVisible();
await expect(
hero.getByRole("heading", { name: "Page not found" }),
).toBeVisible();
await expect(
hero.getByText("The page you're looking for doesn't exist or has been moved."),
).toBeVisible();
await page.getByTestId("go-home").click();
await expect(page).toHaveURL("/$");
});
test("unknown nested paths also land on the 404 page", async ({ page }) => {
await page.goto("/dashboard/nested/unknown");
await expect(page.getByTestId("not-found")).toBeVisible();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
test.describe("raw packet detail", () => {
test("renders fields, raw hex and decoded payload", async ({ page }) => {
const response = await page.request.get("/api/v1/packets?limit=1&order=desc");
expect(response.ok()).toBeTruthy();
const body = await response.json();
const packet = body.items[0];
expect(packet?.id).toBeTruthy();
await page.goto(`/packets/${packet.id}`);
await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
await expect(
page.getByRole("heading", { name: packet.packet_hash as string }),
).toBeVisible();
// Definition grid fields.
await expect(page.getByText("Packet Hash", { exact: true })).toBeVisible();
await expect(page.locator("code", { hasText: packet.packet_hash }).first()).toBeVisible();
await expect(page.getByText("advertisement", { exact: true }).first()).toBeVisible();
// Raw hex and decoded payload blocks.
await expect(page.getByText("Raw", { exact: true })).toBeVisible();
await expect(page.getByText("Decoded", { exact: true })).toBeVisible();
});
test("unknown packet id shows the not-found state", async ({ page }) => {
await page.goto(
"/packets/00000000-0000-4000-8000-000000000000",
);
await expect(page.getByText(/not found/i).first()).toBeVisible();
});
});
+2 -1
View File
@@ -8,7 +8,8 @@ test.describe("packets", () => {
test("filter options work", async ({ page }) => {
await page.goto("/packets");
await expectListLoaded(page);
await expect(page.getByTestId("list-row")).toHaveCount(10);
// 40 seeded groups (10 events + 30 trace_data fillers), page size 20.
await expect(page.getByTestId("list-row")).toHaveCount(20);
await openFilters(page);
await page.locator('select[name="event_type"]').selectOption("advertisement");
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from "@playwright/test";
import { expectListLoaded } from "../utils/helpers";
test.describe("pagination", () => {
test("packet groups paginate across pages", async ({ page }) => {
await page.goto("/packets");
await expectListLoaded(page);
// 40 seeded groups, page size 20.
await expect(page.getByTestId("list-row")).toHaveCount(20);
await expect(page.getByTestId("pagination")).toBeVisible();
await expect(
page.locator('[data-testid="pagination-page"][data-page="1"]'),
).toHaveClass(/btn-active/);
await expect(page.getByTestId("pagination-prev")).toBeDisabled();
await expect(page.getByTestId("pagination-next")).toBeEnabled();
await page
.locator('[data-testid="pagination-page"][data-page="2"]')
.click();
await expect(page).toHaveURL(/page=2/);
await expectListLoaded(page);
await expect(page.getByTestId("list-row")).toHaveCount(20);
await expect(
page.locator('[data-testid="pagination-page"][data-page="2"]'),
).toHaveClass(/btn-active/);
await expect(page.getByTestId("pagination-next")).toBeDisabled();
await expect(page.getByTestId("pagination-prev")).toBeEnabled();
await page.getByTestId("pagination-prev").click();
await expect(page).toHaveURL(/page=1/);
});
test("pagination links preserve active filters", async ({ page }) => {
await page.goto("/packets?event_type=trace_data");
await expectListLoaded(page);
// 30 filler groups -> two pages.
await expect(page.getByTestId("list-row")).toHaveCount(20);
const next = page.getByTestId("pagination-next");
await expect(next).toHaveAttribute(
"href",
/page=2/,
);
await expect(next).toHaveAttribute("href", /event_type=trace_data/);
await next.click();
await expect(page).toHaveURL(/page=2/);
await expect(page).toHaveURL(/event_type=trace_data/);
await expect(page.getByTestId("list-row")).toHaveCount(10);
});
test("out-of-range pages render the empty state", async ({ page }) => {
await page.goto("/packets?page=9");
await expect(page.getByText("No packets found")).toBeVisible();
await expect(page.getByTestId("list-row")).toHaveCount(0);
});
});
+29
View File
@@ -6,6 +6,35 @@ test.use({ storageState: ADMIN_STATE });
const ROUTE_LABEL = "E2E From \u2192 E2E To";
test.describe.serial("routes (admin)", () => {
test("seeded route cards render history, recent matches and owner", async ({
page,
}) => {
await page.goto("/routes");
const legacy = page.locator(
'[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]',
);
await expect(legacy).toBeVisible();
// History strip (chart.js canvas) with date labels ending in "Now".
await expect(legacy.locator("canvas").first()).toBeVisible();
await expect(legacy.getByText("Now", { exact: true })).toBeVisible();
// Recent match rows navigate into the packet group detail.
await legacy.getByText("Recent Packets").waitFor();
await legacy.locator("div.cursor-pointer").first().click();
await expect(page).toHaveURL(/\/packets\/hash\//);
// Operator-owned card links to the owner's profile.
await page.goto("/routes");
const op = page.locator(
'[data-testid="route-card"][data-route-label="Op North \u2192 Op South"]',
);
const owner = op.getByRole("link", { name: "PW Operator" });
await expect(owner).toBeVisible();
await expect(owner).toHaveAttribute("href", /^\/profile\//);
});
test("add route displays the modal and all options are persisted", async ({
page,
}) => {
+52
View File
@@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test";
import { expectListLoaded } from "../utils/helpers";
test.describe("sortable tables", () => {
test("nodes sort by name ascending then descending", async ({ page }) => {
await page.goto("/nodes");
await expectListLoaded(page);
await page.getByTestId("sort-header-name").click();
await expect(page).toHaveURL(/sort=name/);
await expect(page).toHaveURL(/order=asc/);
await expect(
page.locator('th:has([data-testid="sort-header-name"])'),
).toHaveAttribute("aria-sort", "ascending");
await expect(page.getByTestId("list-row").first()).toContainText("Alpha Node");
await expect(page.getByTestId("list-row").last()).toContainText("South Observer 2");
await page.getByTestId("sort-header-name").click();
await expect(page).toHaveURL(/order=desc/);
await expect(
page.locator('th:has([data-testid="sort-header-name"])'),
).toHaveAttribute("aria-sort", "descending");
await expect(page.getByTestId("list-row").first()).toContainText("South Observer 2");
});
test("messages sort by time oldest first then newest first", async ({
page,
}) => {
await page.goto("/messages");
await expectListLoaded(page);
// Default is time desc; the first click flips to ascending.
await page.getByTestId("sort-header-time").click();
await expect(page).toHaveURL(/sort=time/);
await expect(page).toHaveURL(/order=asc/);
await expect(page.getByTestId("list-row").first()).toContainText("Ops channel traffic");
await page.getByTestId("sort-header-time").click();
await expect(page).toHaveURL(/order=desc/);
await expect(page.getByTestId("list-row").first()).toContainText("Hello from the e2e mesh");
});
test("advertisements sort by node name", async ({ page }) => {
await page.goto("/advertisements");
await expectListLoaded(page);
await page.getByTestId("sort-header-node_name").click();
await expect(page).toHaveURL(/sort=node_name/);
await expect(page).toHaveURL(/order=asc/);
await expect(page.getByTestId("list-row").first()).toContainText("Alpha Node");
});
});
@@ -5,14 +5,15 @@ export function MeshQrCode({
size = 140,
level = "L",
className = "bg-white p-2 rounded-box",
...rest
}: {
value: string;
size?: number;
level?: "L" | "M" | "Q" | "H";
className?: string;
}) {
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={className}>
<div className={className} {...rest}>
<QRCode
value={value}
size={size}
@@ -42,7 +42,12 @@ export function Navbar() {
<ThemeToggle />
{config.oidc_enabled && !config.system_maintenance && <AuthSection />}
<div className="dropdown dropdown-end lg:hidden">
<div tabIndex={0} role="button" className="btn btn-ghost">
<div
tabIndex={0}
role="button"
data-testid="mobile-nav-toggle"
className="btn btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
@@ -60,6 +65,7 @@ export function Navbar() {
</div>
<ul
tabIndex={0}
data-testid="mobile-nav-menu"
className="dropdown-content menu z-50 p-2 shadow bg-base-100 rounded-box w-56 mt-3"
>
<MobileNav />
@@ -38,7 +38,12 @@ export function Pagination({
for (let p = 1; p <= totalPages; p++) {
if (p === page) {
pageNumbers.push(
<button key={p} className="join-item btn btn-sm btn-active">
<button
key={p}
data-testid="pagination-page"
data-page={p}
className="join-item btn btn-sm btn-active"
>
{p}
</button>,
);
@@ -48,7 +53,13 @@ export function Pagination({
(p >= page - 2 && p <= page + 2)
) {
pageNumbers.push(
<Link key={p} to={pageUrl(p)} className="join-item btn btn-sm">
<Link
key={p}
to={pageUrl(p)}
data-testid="pagination-page"
data-page={p}
className="join-item btn btn-sm"
>
{p}
</Link>,
);
@@ -67,23 +78,39 @@ export function Pagination({
return (
<div className="flex justify-center mt-6">
<div className="join">
<div className="join" data-testid="pagination">
{page > 1 ? (
<Link to={pageUrl(page - 1)} className="join-item btn btn-sm">
<Link
to={pageUrl(page - 1)}
data-testid="pagination-prev"
className="join-item btn btn-sm"
>
{t("common.previous")}
</Link>
) : (
<button className="join-item btn btn-sm btn-disabled" disabled>
<button
data-testid="pagination-prev"
className="join-item btn btn-sm btn-disabled"
disabled
>
{t("common.previous")}
</button>
)}
{pageNumbers}
{page < totalPages ? (
<Link to={pageUrl(page + 1)} className="join-item btn btn-sm">
<Link
to={pageUrl(page + 1)}
data-testid="pagination-next"
className="join-item btn btn-sm"
>
{t("common.next")}
</Link>
) : (
<button className="join-item btn btn-sm btn-disabled" disabled>
<button
data-testid="pagination-next"
className="join-item btn btn-sm btn-disabled"
disabled
>
{t("common.next")}
</button>
)}
@@ -57,10 +57,18 @@ export function SortableTableHeader({
const url = buildSortUrl(basePath, params, sortKey, nextOrder);
const ariaSort =
currentSort !== sortKey
? "none"
: currentOrder === "asc"
? "ascending"
: "descending";
return (
<th>
<th aria-sort={ariaSort}>
<Link
to={url}
data-testid={`sort-header-${sortKey}`}
className="link link-hover inline-flex items-center gap-1 no-underline"
onClick={(e) => e.stopPropagation()}
>
@@ -105,6 +113,7 @@ export function MobileSortSelect({
<div className="flex items-center gap-2">
<span className="text-xs opacity-60">{t("common.sort_by")}</span>
<select
data-testid="mobile-sort-select"
className="select select-sm flex-1"
value={currentValue}
onChange={handleChange}
@@ -78,6 +78,8 @@ function ChannelCard({
className="card bg-base-100 shadow-xl cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
role="button"
tabIndex={0}
data-testid="channel-card"
data-channel-name={channel.name}
onClick={() => onNavigate(channelIdx)}
onKeyDown={handleKeyDown}
>
@@ -104,6 +106,7 @@ function ChannelCard({
{isAdmin && (
<div className="flex gap-2 mt-2">
<button
data-testid="channel-edit"
className="btn btn-xs btn-outline"
onClick={(e) => {
e.stopPropagation();
@@ -113,6 +116,7 @@ function ChannelCard({
<IconEdit className="h-3 w-3" /> {t("common.edit")}
</button>
<button
data-testid="channel-delete"
className="btn btn-xs btn-outline btn-error"
onClick={(e) => {
e.stopPropagation();
@@ -124,7 +128,7 @@ function ChannelCard({
</div>
)}
</div>
<div className="flex-shrink-0 self-center">
<div className="flex-shrink-0 self-center" data-testid="channel-qr">
{channel.key_hex && <ChannelQrCode channel={channel} />}
</div>
</div>
@@ -178,6 +182,7 @@ function ChannelModal({
</label>
<input
type="text"
data-testid="channel-name"
className="input input-sm"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -193,6 +198,7 @@ function ChannelModal({
</label>
<input
type="text"
data-testid="channel-key"
className="input input-sm font-mono"
value={keyHex}
onChange={(e) => setKeyHex(e.target.value)}
@@ -208,6 +214,7 @@ function ChannelModal({
{t("channels.visibility_label")}
</label>
<select
data-testid="channel-visibility"
className="select select-sm"
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
@@ -221,6 +228,7 @@ function ChannelModal({
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
data-testid="channel-enabled"
className="checkbox checkbox-sm"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
@@ -231,13 +239,19 @@ function ChannelModal({
<div className="modal-action">
<button
type="button"
data-testid="channel-cancel"
className="btn btn-ghost"
onClick={onCancel}
disabled={saving}
>
{t("common.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
<button
type="submit"
data-testid="channel-save"
className="btn btn-primary"
disabled={saving}
>
{saving && (
<span className="loading loading-spinner loading-sm"></span>
)}
@@ -371,6 +385,7 @@ export function Channels() {
{isAdmin && (
<div className="flex justify-end mb-4">
<button
data-testid="add-channel"
className="btn btn-primary btn-sm"
onClick={() => setModal({ type: "add" })}
>
@@ -434,7 +434,7 @@ export function NodeDetailPage() {
(isOperator || isAdmin) &&
(adoptedBy.user_id === config.user.sub || isAdmin);
adoptionCard = (
<div className="card bg-base-100 shadow-xl h-full">
<div className="card bg-base-100 shadow-xl h-full" data-testid="adoption-card">
<div className="card-body">
<h2 className="card-title">{t("nodes.ownership")}</h2>
<div className="flex items-center justify-between">
@@ -442,6 +442,7 @@ export function NodeDetailPage() {
{t("nodes.adopted_by_prefix")}{" "}
<Link
to={`/profile/${adoptedBy.profile_id}`}
data-testid="adoption-owner"
className="link link-hover text-primary"
>
{ownerName}
@@ -449,6 +450,7 @@ export function NodeDetailPage() {
</p>
{canRelease && (
<button
data-testid="release-button"
className="btn btn-sm btn-outline btn-error"
onClick={() => setConfirmRelease(true)}
>
@@ -461,12 +463,16 @@ export function NodeDetailPage() {
);
} else if (isOperator || isAdmin) {
adoptionCard = (
<div className="card bg-base-100 shadow-xl h-full">
<div className="card bg-base-100 shadow-xl h-full" data-testid="adoption-card">
<div className="card-body">
<h2 className="card-title">{t("nodes.ownership")}</h2>
<p className="text-sm opacity-70">{t("nodes.not_adopted")}</p>
<div className="mt-2">
<button className="btn btn-sm btn-primary" onClick={handleAdopt}>
<button
data-testid="adopt-button"
className="btn btn-sm btn-primary"
onClick={handleAdopt}
>
{t("nodes.adopt")}
</button>
</div>
@@ -521,7 +527,7 @@ export function NodeDetailPage() {
</thead>
<tbody>
{tags.map((tag) => (
<tr key={tag.key}>
<tr key={tag.key} data-testid="tag-row" data-tag-key={tag.key}>
<td className="font-mono min-w-0 truncate max-w-[8rem]">
{tag.key}
</td>
@@ -534,12 +540,14 @@ export function NodeDetailPage() {
<td>
<div className="flex gap-1">
<button
data-testid="tag-edit"
className="btn btn-xs btn-ghost"
onClick={() => openEditTag(tag)}
>
<IconEdit className="h-4 w-4" />
</button>
<button
data-testid="tag-delete"
className="btn btn-xs btn-ghost"
onClick={() => setDeleteKey(tag.key)}
>
@@ -633,6 +641,7 @@ export function NodeDetailPage() {
<div
className="relative rounded-box overflow-hidden mb-6 shadow-xl"
style={{ height: 180 }}
data-testid="node-mini-map"
>
<div className="absolute inset-0 z-0">
<MapContainer
@@ -656,13 +665,14 @@ export function NodeDetailPage() {
<MeshQrCode
value={qrUrl}
className="bg-white p-2 rounded-box shadow-lg"
data-testid="contact-qr"
/>
</div>
</div>
) : (
<div className="card bg-base-100 shadow-xl mb-6">
<div className="card-body flex-row items-center gap-4">
<MeshQrCode value={qrUrl} />
<MeshQrCode value={qrUrl} data-testid="contact-qr" />
<p className="text-sm opacity-70">{t("nodes.scan_to_add")}</p>
</div>
</div>
@@ -766,11 +776,12 @@ export function NodeDetailPage() {
<h2 className="card-title">{t("entities.tags")}</h2>
{tagsTable}
{canEditTags && (
<form className="mt-4" onSubmit={handleAddTag}>
<form className="mt-4" data-testid="tag-form" onSubmit={handleAddTag}>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_1fr_auto_auto] gap-2 items-end">
<div className="fieldset">
<input
type="text"
data-testid="tag-key"
className="input input-sm w-full"
placeholder={t("common.key")}
required
@@ -781,6 +792,7 @@ export function NodeDetailPage() {
<div className="fieldset">
<input
type="text"
data-testid="tag-value"
className="input input-sm w-full"
placeholder={t("common.value")}
value={addValue}
@@ -791,6 +803,7 @@ export function NodeDetailPage() {
)}
</div>
<select
data-testid="tag-type"
className="select select-sm w-28"
value={addType}
onChange={(e) => setAddType(e.target.value)}
@@ -799,7 +812,11 @@ export function NodeDetailPage() {
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
<button type="submit" className="btn btn-sm btn-primary">
<button
type="submit"
data-testid="tag-add"
className="btn btn-sm btn-primary"
>
<IconPlus className="h-4 w-4" /> {t("common.add")}
</button>
</div>
@@ -823,11 +840,12 @@ export function NodeDetailPage() {
if (!editSaving) setEditTag(null);
}}
>
<form className="py-4" onSubmit={handleEditTag}>
<form className="py-4" data-testid="tag-edit-modal" onSubmit={handleEditTag}>
<div className="fieldset mb-4">
<label className="fieldset-label">{t("common.value")}</label>
<input
type="text"
data-testid="tag-edit-value"
className="input w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
@@ -839,6 +857,7 @@ export function NodeDetailPage() {
<div className="fieldset mb-4">
<label className="fieldset-label">{t("common.type")}</label>
<select
data-testid="tag-edit-type"
className="select w-full"
value={editType}
onChange={(e) => setEditType(e.target.value)}
@@ -851,6 +870,7 @@ export function NodeDetailPage() {
<div className="modal-action">
<button
type="button"
data-testid="tag-edit-cancel"
className="btn"
onClick={() => setEditTag(null)}
disabled={editSaving}
@@ -859,6 +879,7 @@ export function NodeDetailPage() {
</button>
<button
type="submit"
data-testid="tag-edit-save"
className="btn btn-primary"
disabled={editSaving}
>
@@ -8,7 +8,7 @@ export function NotFound() {
usePageTitle();
return (
<div className="hero min-h-[60vh]">
<div className="hero min-h-[60vh]" data-testid="not-found">
<div className="hero-content text-center">
<div className="max-w-md">
<div className="text-9xl font-bold text-primary opacity-20">404</div>
@@ -17,7 +17,7 @@ export function NotFound() {
</h1>
<p className="py-6 opacity-70">{t("not_found.description")}</p>
<div className="flex gap-4 justify-center">
<Link to="/" className="btn btn-primary">
<Link to="/" data-testid="go-home" className="btn btn-primary">
<IconHome className="h-5 w-5 mr-2" />
{t("common.go_home")}
</Link>