mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-10-31 04:02:03 +01:00
Merge pull request #376 from ipnet-mesh/test/e2e-ui-regression-coverage
test(e2e): expand Playwright coverage for UI regression protection
This commit is contained in:
20 files changed
+901
-31
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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+/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user