From 0709ce4ba1da72151e6660e232fd05e9750edcd9 Mon Sep 17 00:00:00 2001 From: Ivan Date: Tue, 31 Mar 2026 03:00:23 +0300 Subject: [PATCH] feat(tests): add comprehensive Playwright E2E tests for navigation, shell, and smoke scenarios --- tests/e2e/navigation.spec.js | 114 +++++++++++++++++++++++++++++++++++ tests/e2e/shell.spec.js | 81 +++++++++++++++++++++++++ tests/e2e/smoke.spec.js | 51 ++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 tests/e2e/navigation.spec.js create mode 100644 tests/e2e/shell.spec.js create mode 100644 tests/e2e/smoke.spec.js diff --git a/tests/e2e/navigation.spec.js b/tests/e2e/navigation.spec.js new file mode 100644 index 0000000..36f5c82 --- /dev/null +++ b/tests/e2e/navigation.spec.js @@ -0,0 +1,114 @@ +const { test, expect } = require("@playwright/test"); +const { PALETTE_PLACEHOLDER, openCommandPalette, prepareE2eSession } = require("./helpers"); + +test.describe("Getting started (tutorial page)", () => { + test("tutorial route shows welcome copy", async ({ page }) => { + await page.goto("/#/tutorial"); + await expect(page).toHaveURL(/#\/tutorial/); + await expect(page.getByRole("heading", { name: /Welcome to\s*MeshChatX/i }).first()).toBeVisible({ + timeout: 30000, + }); + await expect( + page.getByText("The future of off-grid communication", { exact: false }).first(), + ).toBeVisible({ timeout: 10000 }); + }); +}); + +test.describe("Command palette", () => { + test("Ctrl+K opens palette with search field", async ({ page }) => { + await page.goto("/#/messages"); + await openCommandPalette(page); + await expect(page.getByPlaceholder(PALETTE_PLACEHOLDER)).toBeVisible(); + await expect(page.getByText("Navigate", { exact: true })).toBeVisible(); + }); + + test("palette navigates to Settings via filter and Enter", async ({ page }) => { + await page.goto("/#/messages"); + await openCommandPalette(page); + const input = page.getByPlaceholder(PALETTE_PLACEHOLDER); + await input.fill("Settings"); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/#\/settings/, { timeout: 15000 }); + await expect(page.getByText("Profile", { exact: true }).first()).toBeVisible({ timeout: 20000 }); + }); + + test("Escape closes command palette", async ({ page }) => { + await page.goto("/#/messages"); + await openCommandPalette(page); + const input = page.getByPlaceholder(PALETTE_PLACEHOLDER); + await page.keyboard.press("Escape"); + await expect(page.getByPlaceholder(PALETTE_PLACEHOLDER)).toBeHidden({ timeout: 5000 }); + }); + + test("palette Getting Started action opens tutorial modal", async ({ page }) => { + await page.goto("/#/messages"); + await openCommandPalette(page); + const input = page.getByPlaceholder(PALETTE_PLACEHOLDER); + await input.fill("Getting Started"); + await page.keyboard.press("Enter"); + await expect(page.getByRole("heading", { name: /Welcome to\s*MeshChatX/i }).first()).toBeVisible({ + timeout: 20000, + }); + }); +}); + +test.describe("Sidebar and keyboard navigation", () => { + test("sidebar links navigate across main sections", async ({ page, request }) => { + test.setTimeout(120000); + await prepareE2eSession(request); + await page.goto("/#/messages"); + await expect(page).toHaveURL(/#\/messages/); + + const sideNav = page.locator("ul.py-3"); + + await sideNav.locator('a[href*="#/nomadnetwork"]').click(); + await expect(page).toHaveURL(/#\/nomadnetwork/); + + await sideNav.locator('a[href*="#/map"]').click(); + await expect(page).toHaveURL(/#\/map/); + + await sideNav.locator('a[href*="#/tools"]').click(); + await expect(page).toHaveURL(/#\/tools/); + await expect(page.getByText("Power tools for operators", { exact: true })).toBeVisible({ + timeout: 20000, + }); + + await sideNav.locator('a[href*="#/settings"]').click(); + await expect(page).toHaveURL(/#\/settings/); + await expect(page.getByText("Profile", { exact: true }).first()).toBeVisible({ timeout: 20000 }); + + await sideNav.locator('a[href*="#/about"]').click(); + await expect(page).toHaveURL(/#\/about/); + await expect(page.getByText("MeshChatX", { exact: true }).first()).toBeVisible({ timeout: 20000 }); + }); + + test("Alt+1 jumps to Messages from another route", async ({ page }) => { + await page.goto("/#/contacts"); + await expect(page).toHaveURL(/#\/contacts/); + await page.keyboard.press("Alt+1"); + await expect(page).toHaveURL(/#\/messages/, { timeout: 15000 }); + }); + + test("Alt+S opens Settings", async ({ page }) => { + await page.goto("/#/map"); + await expect(page).toHaveURL(/#\/map/); + await page.keyboard.press("Alt+s"); + await expect(page).toHaveURL(/#\/settings/, { timeout: 15000 }); + }); +}); + +test.describe("Tools hub", () => { + test("tools index lists utilities heading", async ({ page }) => { + await page.goto("/#/tools"); + await expect(page.getByText("Utilities", { exact: true })).toBeVisible({ timeout: 20000 }); + await expect(page.getByPlaceholder("Search tools...", { exact: true })).toBeVisible(); + }); + + test("deep link to paper message tool", async ({ page }) => { + await page.goto("/#/tools/paper-message"); + await expect(page).toHaveURL(/#\/tools\/paper-message/); + await expect(page.getByRole("heading", { name: "Paper Message Generator", exact: true })).toBeVisible({ + timeout: 20000, + }); + }); +}); diff --git a/tests/e2e/shell.spec.js b/tests/e2e/shell.spec.js new file mode 100644 index 0000000..91751a5 --- /dev/null +++ b/tests/e2e/shell.spec.js @@ -0,0 +1,81 @@ +const { test, expect } = require("@playwright/test"); +const { prepareE2eSession } = require("./helpers"); + +function topChrome(page) { + return page.locator("div.sticky.top-0.z-\\[100\\]").first(); +} + +test.describe("Shell: sidebar, theme, notifications, call, search", () => { + test.beforeEach(async ({ request }) => { + await prepareE2eSession(request); + }); + + test("desktop sidebar collapse toggle changes width", async ({ page }) => { + await page.goto("/#/messages"); + const sidebar = page.locator("div.fixed.inset-y-0.left-0").filter({ has: page.locator("ul.py-3") }); + await expect(sidebar).toHaveClass(/w-80/); + await page.locator("div.hidden.sm\\:flex.justify-end.p-2.border-b button").click(); + await expect(sidebar).toHaveClass(/w-16/); + await page.locator("div.hidden.sm\\:flex.justify-end.p-2.border-b button").click(); + await expect(sidebar).toHaveClass(/w-80/); + }); + + test("header theme button toggles dark mode on root shell", async ({ page }) => { + await page.goto("/#/messages"); + const shell = page.locator("#app > div.h-screen.w-full.flex.flex-col").first(); + await expect(shell).toBeVisible({ timeout: 30000 }); + const themeBtn = page.getByTitle(/Switch to (light|dark) mode/); + await expect(themeBtn).toBeVisible({ timeout: 20000 }); + const initialDark = await shell.evaluate((el) => el.classList.contains("dark")); + await themeBtn.click(); + await expect.poll(async () => shell.evaluate((el) => el.classList.contains("dark"))).not.toBe(initialDark); + await themeBtn.click(); + await expect.poll(async () => shell.evaluate((el) => el.classList.contains("dark"))).toBe(initialDark); + }); + + test("notification bell opens panel and closes from header", async ({ page }) => { + await page.goto("/#/messages"); + await topChrome(page) + .locator("button") + .filter({ has: page.locator('svg[aria-label="bell"]') }) + .click(); + await expect(page.getByRole("heading", { name: "Notifications", exact: true })).toBeVisible({ + timeout: 15000, + }); + await expect(page.getByText("No new notifications", { exact: true })).toBeVisible({ timeout: 10000 }); + const panel = page.locator("div.fixed").filter({ hasText: "Notifications" }).first(); + await panel.locator('svg[aria-label="close"]').click(); + await expect(page.getByRole("heading", { name: "Notifications", exact: true })).toBeHidden({ + timeout: 5000, + }); + }); + + test("call route shows Phone tab", async ({ page }) => { + await page.goto("/#/call"); + await expect(page).toHaveURL(/#\/call/); + await expect(page.getByRole("button", { name: "Phone", exact: true })).toBeVisible({ timeout: 20000 }); + }); + + test("messages Announces tab search input accepts text", async ({ page }) => { + await page.goto("/#/messages"); + await page.locator("div.-mb-px.flex").getByText("Announces", { exact: true }).click(); + const input = page.getByPlaceholder(/Search \d+ recent announces/); + await expect(input).toBeVisible({ timeout: 15000 }); + await input.fill("e2e-filter"); + await expect(input).toHaveValue("e2e-filter"); + }); + + test("NomadNet favourites and announces search inputs accept text", async ({ page }) => { + await page.goto("/#/nomadnetwork"); + const favSearch = page.getByPlaceholder(/Search \d+ favourites/); + await expect(favSearch).toBeVisible({ timeout: 20000 }); + await favSearch.fill("fav-q"); + await expect(favSearch).toHaveValue("fav-q"); + + await page.locator("button.sidebar-tab").filter({ hasText: "Announces" }).click(); + const nodeSearch = page.getByPlaceholder(/Search \d+ recent announces/); + await expect(nodeSearch).toBeVisible({ timeout: 15000 }); + await nodeSearch.fill("node-q"); + await expect(nodeSearch).toHaveValue("node-q"); + }); +}); diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js new file mode 100644 index 0000000..401ac3e --- /dev/null +++ b/tests/e2e/smoke.spec.js @@ -0,0 +1,51 @@ +const { test, expect } = require("@playwright/test"); + +const E2E_BACKEND_PORT = process.env.E2E_BACKEND_PORT || "8000"; +const E2E_BACKEND_ORIGIN = `http://127.0.0.1:${E2E_BACKEND_PORT}`; + +test.describe("MeshChatX E2E (Vite + Python backend)", () => { + test("backend /api/v1/status is OK (via Vite proxy)", async ({ request }) => { + const res = await request.get("/api/v1/status"); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + expect(body.status).toBe("ok"); + }); + + test("backend /api/v1/app/info returns version JSON (direct backend)", async ({ request }) => { + const res = await request.get(`${E2E_BACKEND_ORIGIN}/api/v1/app/info`); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + expect(body.app_info).toBeDefined(); + expect(String(body.app_info.version).length).toBeGreaterThan(0); + }); + + test("document title, shell, and app name in header", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/Reticulum MeshChatX/); + await expect(page.getByText("Reticulum MeshChatX", { exact: true }).first()).toBeVisible({ + timeout: 30000, + }); + const root = page.locator("#app"); + await expect(root).toBeVisible(); + await expect(root.locator("div").first()).toBeVisible({ timeout: 30000 }); + }); + + test("about page shows MeshChatX and version", async ({ page }) => { + await page.goto("/#/about"); + await expect(page).toHaveURL(/#\/about/); + await expect(page.getByText("MeshChatX", { exact: true }).first()).toBeVisible({ timeout: 30000 }); + await expect(page.locator("#app")).toBeVisible(); + }); + + test("settings route loads profile section", async ({ page }) => { + await page.goto("/#/settings"); + await expect(page).toHaveURL(/#\/settings/); + await expect(page.getByText("Profile", { exact: true }).first()).toBeVisible({ timeout: 30000 }); + }); + + test("messages route is reachable", async ({ page }) => { + await page.goto("/#/messages"); + await expect(page).toHaveURL(/#\/messages/); + await expect(page.locator("#app")).toBeVisible(); + }); +});