diff --git a/AGENTS.md b/AGENTS.md index eaf7025..a397e31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/e2e/seed_data.py b/e2e/seed_data.py index a65bf6e..55070d6 100644 --- a/e2e/seed_data.py +++ b/e2e/seed_data.py @@ -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() diff --git a/e2e/tests/auth-gating.spec.ts b/e2e/tests/auth-gating.spec.ts new file mode 100644 index 0000000..8e0e443 --- /dev/null +++ b/e2e/tests/auth-gating.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/channels.spec.ts b/e2e/tests/channels.spec.ts new file mode 100644 index 0000000..e7ce228 --- /dev/null +++ b/e2e/tests/channels.spec.ts @@ -0,0 +1,144 @@ +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); + // 6 seeded channels + the created one - the deletion = 6. + await expect(page.getByTestId("channel-card")).toHaveCount(6); + }); + + 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+/); + }); +}); diff --git a/e2e/tests/empty-states.spec.ts b/e2e/tests/empty-states.spec.ts new file mode 100644 index 0000000..46006d4 --- /dev/null +++ b/e2e/tests/empty-states.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; + +test.describe("empty states", () => { + // The empty text renders twice (mobile EmptyState + desktop EmptyRow), so + // scope every assertion to the desktop table cell. + test("nodes search with no matches shows the empty state", async ({ page }) => { + await page.goto("/nodes?search=NoMatchToBeFound"); + await expect( + page.getByRole("cell", { name: "No nodes found", exact: true }), + ).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.getByRole("cell", { name: "No adverts found", exact: true }), + ).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.getByRole("cell", { name: "No messages found", exact: true }), + ).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.getByRole("cell", { name: "No packets found", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("list-row")).toHaveCount(0); + }); +}); diff --git a/e2e/tests/mobile.spec.ts b/e2e/tests/mobile.spec.ts new file mode 100644 index 0000000..99aa205 --- /dev/null +++ b/e2e/tests/mobile.spec.ts @@ -0,0 +1,43 @@ +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"); + // The desktop table (and its list-rows) is hidden at mobile widths; the + // mobile cards and sort select render instead. + await expect(page.getByTestId("mobile-sort-select")).toBeVisible(); + await expect( + page.getByText("Hello from the e2e mesh").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"); + }); +}); diff --git a/e2e/tests/node-detail.spec.ts b/e2e/tests/node-detail.spec.ts new file mode 100644 index 0000000..ac4a733 --- /dev/null +++ b/e2e/tests/node-detail.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/not-found.spec.ts b/e2e/tests/not-found.spec.ts new file mode 100644 index 0000000..874b96c --- /dev/null +++ b/e2e/tests/not-found.spec.ts @@ -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(); + }); +}); diff --git a/e2e/tests/packets-detail.spec.ts b/e2e/tests/packets-detail.spec.ts new file mode 100644 index 0000000..a1d0e72 --- /dev/null +++ b/e2e/tests/packets-detail.spec.ts @@ -0,0 +1,36 @@ +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(packet.event_type as string, { 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(); + }); +}); diff --git a/e2e/tests/packets.spec.ts b/e2e/tests/packets.spec.ts index d74f72a..2fed755 100644 --- a/e2e/tests/packets.spec.ts +++ b/e2e/tests/packets.spec.ts @@ -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"); diff --git a/e2e/tests/pagination.spec.ts b/e2e/tests/pagination.spec.ts new file mode 100644 index 0000000..1ac1863 --- /dev/null +++ b/e2e/tests/pagination.spec.ts @@ -0,0 +1,60 @@ +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.getByRole("cell", { name: "No packets found", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("list-row")).toHaveCount(0); + }); +}); diff --git a/e2e/tests/routes.spec.ts b/e2e/tests/routes.spec.ts index 64d52c7..b70735b 100644 --- a/e2e/tests/routes.spec.ts +++ b/e2e/tests/routes.spec.ts @@ -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, }) => { diff --git a/e2e/tests/sorting.spec.ts b/e2e/tests/sorting.spec.ts new file mode 100644 index 0000000..b2c5575 --- /dev/null +++ b/e2e/tests/sorting.spec.ts @@ -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"); + }); +}); diff --git a/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx index 3a387dd..7779405 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/MeshQrCode.tsx @@ -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) { return ( -
+
{config.oidc_enabled && !config.system_maintenance && }
-
+
    diff --git a/src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx b/src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx index 005aec7..15634a1 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx @@ -38,7 +38,12 @@ export function Pagination({ for (let p = 1; p <= totalPages; p++) { if (p === page) { pageNumbers.push( - , ); @@ -48,7 +53,13 @@ export function Pagination({ (p >= page - 2 && p <= page + 2) ) { pageNumbers.push( - + {p} , ); @@ -67,23 +78,39 @@ export function Pagination({ return (
    -
    +
    {page > 1 ? ( - + {t("common.previous")} ) : ( - )} {pageNumbers} {page < totalPages ? ( - + {t("common.next")} ) : ( - )} diff --git a/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx b/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx index 28e5bbb..9f19470 100644 --- a/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx @@ -57,10 +57,18 @@ export function SortableTableHeader({ const url = buildSortUrl(basePath, params, sortKey, nextOrder); + const ariaSort = + currentSort !== sortKey + ? "none" + : currentOrder === "asc" + ? "ascending" + : "descending"; + return ( - + e.stopPropagation()} > @@ -105,6 +113,7 @@ export function MobileSortSelect({
    {t("common.sort_by")} setName(e.target.value)} @@ -193,6 +198,7 @@ function ChannelModal({ setKeyHex(e.target.value)} @@ -208,6 +214,7 @@ function ChannelModal({ {t("channels.visibility_label")} setEnabled(e.target.checked)} @@ -231,13 +239,19 @@ function ChannelModal({
    -
    @@ -521,7 +527,7 @@ export function NodeDetailPage() { {tags.map((tag) => ( - + {tag.key} @@ -534,12 +540,14 @@ export function NodeDetailPage() {
    @@ -823,11 +840,12 @@ export function NodeDetailPage() { if (!editSaving) setEditTag(null); }} > - +
    setEditValue(e.target.value)} @@ -839,6 +857,7 @@ export function NodeDetailPage() {