From c01e22ac8736dd824d7de9347df7321da97505d3 Mon Sep 17 00:00:00 2001 From: Georgios Jason Efstathiou Date: Fri, 10 Jul 2026 13:57:23 +0200 Subject: [PATCH] Add e2e and unit tests for explore, search, and marketing --- tests/e2e/explore.spec.ts | 79 ++++++++++++++++++++ tests/e2e/marketing.spec.ts | 65 ++++++++++++++++ tests/e2e/node.spec.ts | 86 +++++++++------------ tests/e2e/search.spec.ts | 56 ++++++++++++++ tests/unit/localStore.test.ts | 95 ++++++++++++++++++++++++ tests/visual/desktop/landingPage.spec.ts | 17 +++++ 6 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 tests/e2e/explore.spec.ts create mode 100644 tests/e2e/marketing.spec.ts create mode 100644 tests/e2e/search.spec.ts create mode 100644 tests/unit/localStore.test.ts diff --git a/tests/e2e/explore.spec.ts b/tests/e2e/explore.spec.ts new file mode 100644 index 000000000..6de19baf4 --- /dev/null +++ b/tests/e2e/explore.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "@tests/support/fixtures.js"; + +const unreachableSeed = { + hostname: "this.node.does.not.exist.xyz", + port: 8081, + scheme: "http", +}; + +test("explore repos page lists all repos and hides sort without a search backend", async ({ + page, +}) => { + await page.goto("/explore/repos"); + + // Breadcrumb and subtitle. Without a search backend the listing is forced + // to "All repos" (rid order). + await expect(page.getByRole("link", { name: "Explore" })).toBeVisible(); + await expect(page.getByText("All repos")).toBeVisible(); + + // The repo grid renders the seeded repos. + await expect( + page.locator(".repo-card", { hasText: "source-browsing" }).first(), + ).toBeVisible(); + + // The sort control is only offered when the seed exposes a search backend. + await expect(page.getByRole("button", { name: "Sort" })).toHaveCount(0); + + // The breadcrumb navigates back to the explore landing page. + await page.getByRole("link", { name: "Explore" }).click(); + await expect(page).toHaveURL("/explore"); +}); + +test("explore falls back to a healthy seed when the primary is unreachable", async ({ + page, +}) => { + // Make an unreachable seed the primary, then fail its requests instantly. + await page.addInitScript(seed => { + window.localStorage.setItem("selectedSeed", JSON.stringify(seed)); + }, unreachableSeed); + await page.route( + url => url.hostname === unreachableSeed.hostname, + route => route.abort(), + ); + + await page.goto("/explore", { waitUntil: "networkidle" }); + + // Failover lands on the reachable preferred seed and the page renders its + // repos, even though the configured primary seed is unreachable. + await expect(page.getByText("source-browsing").first()).toBeVisible(); +}); + +test("seed picker lives in the settings dropdown on explore", async ({ + page, +}) => { + await page.goto("/explore", { waitUntil: "networkidle" }); + + // The explore header no longer shows a seed picker directly. + await expect(page.getByRole("button", { name: "Seed selector" })).toHaveCount( + 0, + ); + + // It's reached through the settings dropdown as the "Explore seed" setting. + await page.getByRole("button", { name: "Settings" }).click(); + await expect(page.getByText("Explore seed")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Seed selector" }), + ).toContainText("localhost"); +}); + +test("explore shows an error when no seed can be reached", async ({ page }) => { + // Fail every request to the seed's httpd port so no candidate responds. + await page.route( + url => url.port === String(unreachableSeed.port), + route => route.abort(), + ); + + await page.goto("/explore", { waitUntil: "networkidle" }); + + await expect(page.getByText("Unable to reach any seed")).toBeVisible(); +}); diff --git a/tests/e2e/marketing.spec.ts b/tests/e2e/marketing.spec.ts new file mode 100644 index 000000000..2c6c15c33 --- /dev/null +++ b/tests/e2e/marketing.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@tests/support/fixtures.js"; + +// The marketing site is only served when a deployment opts in via +// `homepage: "landing"`. Tests set it through the runtime config channel +// (`window.__CONFIG__`) before the app boots. +function enableLandingMode(page: import("@playwright/test").Page) { + return page.addInitScript(() => { + (window as unknown as Record).__CONFIG__ = { + nodes: { homepage: "landing" }, + }; + }); +} + +test("landing page is served from / in landing mode", async ({ page }) => { + await enableLandingMode(page); + await page.goto("/", { waitUntil: "networkidle" }); + + await expect( + page.getByRole("heading", { name: /distributed code network/i }), + ).toBeVisible(); + // The marketing sidebar navigation is present. + await expect(page.getByRole("link", { name: "Install" })).toBeVisible(); +}); + +test("marketing sub-routes resolve in landing mode", async ({ page }) => { + await enableLandingMode(page); + + await page.goto("/install", { waitUntil: "networkidle" }); + await expect( + page.getByRole("heading", { name: /Get started with Radicle/i }), + ).toBeVisible(); + + // A markdown doc route renders its compiled content. + await page.goto("/faq", { waitUntil: "networkidle" }); + await expect(page.locator(".markdown-content")).toBeVisible(); +}); + +test("mobile navigation menu links between marketing pages", async ({ + page, +}) => { + await enableLandingMode(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + + // The left sidebar is hidden on mobile; navigation happens through the + // header's hamburger menu instead. + const menuButton = page.getByRole("button", { name: "Open navigation menu" }); + await expect(menuButton).toBeVisible(); + await menuButton.click(); + + const mobileNav = page.getByRole("navigation", { name: "Mobile navigation" }); + await mobileNav.getByRole("link", { name: /Learn/ }).click(); + + await expect(page).toHaveURL("/learn"); + await expect( + page.getByRole("heading", { name: /Learn Radicle/i }), + ).toBeVisible(); +}); + +test("marketing routes are not found without landing mode", async ({ + page, +}) => { + await page.goto("/install", { waitUntil: "networkidle" }); + await expect(page.getByText("Page not found")).toBeVisible(); +}); diff --git a/tests/e2e/node.spec.ts b/tests/e2e/node.spec.ts index e238fada4..0a6d362d7 100644 --- a/tests/e2e/node.spec.ts +++ b/tests/e2e/node.spec.ts @@ -69,15 +69,10 @@ test("unreachable node shows error with seed selector", async ({ page }) => { page.getByText("Select a different node to continue."), ).toBeVisible(); - // Shows the seed selector dropdown toggle. + // Shows the seed selector toggle. await expect( - page.getByRole("button", { name: "Toggle seed selector dropdown" }), + page.getByRole("button", { name: "Seed selector" }), ).toBeVisible(); - - // Bookmark button is not shown in compact mode. - await expect( - page.getByRole("button", { name: "Add bookmark" }), - ).not.toBeVisible(); }); test("seed selector on not-found page allows navigating to a working node", async ({ @@ -90,10 +85,9 @@ test("seed selector on not-found page allows navigating to a working node", asyn await expect(page.getByText("Could not connect to")).toBeVisible(); // Open the seed selector and navigate to the working local node. - await page - .getByRole("button", { name: "Toggle seed selector dropdown" }) - .click(); - await page.getByPlaceholder("seed.radicle.example").fill("localhost"); + await page.getByRole("button", { name: "Seed selector" }).click(); + await page.getByRole("button", { name: "Add new" }).click(); + await page.getByPlaceholder("seed.radicle.example").fill("localhost:8081"); await page.getByPlaceholder("seed.radicle.example").press("Enter"); // Should navigate to the working node. @@ -113,7 +107,7 @@ test("unreachable seed on repo page shows error with seed selector", async ({ // Seed selector is available to navigate away. await expect( - page.getByRole("button", { name: "Toggle seed selector dropdown" }), + page.getByRole("button", { name: "Seed selector" }), ).toBeVisible(); }); @@ -135,53 +129,41 @@ test("edit seed bookmarks", async ({ page }) => { await page.goto("/"); - await page - .getByRole("button", { name: "Toggle seed selector dropdown" }) - .click(); - await expect(page.getByPlaceholder("seed.radicle.example")).toHaveValue( - "localhost", - ); - await expect( - page.getByRole("button", { name: "Default seeds can't be removed" }), - ).toBeVisible(); - await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1); + // The seed picker lives in the Settings dropdown now. + const openSettings = () => + page.getByRole("button", { name: "Settings" }).click(); + await openSettings(); - // The input box is focussed, has the text selected and ready to be overwritten. + const seedSelector = page.getByRole("button", { name: "Seed selector" }); + + // Add a custom seed via the seed selector. + await seedSelector.click(); + await page.getByRole("button", { name: "Add new" }).click(); await page.getByPlaceholder("seed.radicle.example").fill("seed.example.tld"); await page.getByPlaceholder("seed.radicle.example").press("Enter"); - await expect(page).toHaveURL("/nodes/seed.example.tld"); + // On the explore page the URL is unchanged, but the active seed switches to + // the new custom seed, which is added to the bookmarked custom seeds. + await expect(seedSelector).toContainText("seed.example.tld"); + + await seedSelector.click(); await expect( - page.getByRole("button", { name: "Add bookmark" }), + page.getByRole("button", { name: "Remove bookmark", exact: true }), ).toBeVisible(); + // The bookmark persists across reloads. + await page.reload(); + await openSettings(); + await seedSelector.click(); + await expect( + page.getByRole("button", { name: "Remove bookmark", exact: true }), + ).toBeVisible(); + + // Removing the bookmark drops it from the custom seeds. await page - .getByRole("button", { name: "Toggle seed selector dropdown" }) + .getByRole("button", { name: "Remove bookmark", exact: true }) .click(); - - // After navigating to the seed it should not yet be added to the bookmarks. - await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1); - - await page.getByRole("button", { name: "Add bookmark" }).click(); - await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(2); - - // Test that new seed is persisted and opened when we go to the landing page. - await page.getByRole("link", { name: "Home" }).click(); - await expect(page.getByText("seed.example.tld").first()).toBeVisible(); - - // Test removing a bookmark. - await page - .getByRole("button", { name: "Toggle seed selector dropdown" }) - .click(); - await page.getByRole("button", { name: "Remove bookmark" }).nth(1).click(); - await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1); - - // Remove the bookmark from within the dropdown. - await page.getByRole("button", { name: "Add bookmark" }).click(); - await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(2); - await page - .getByRole("button", { name: "seed.example.tld" }) - .getByRole("button", { name: "Remove bookmark" }) - .click(); - await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1); + await expect( + page.getByRole("button", { name: "Remove bookmark", exact: true }), + ).not.toBeVisible(); }); diff --git a/tests/e2e/search.spec.ts b/tests/e2e/search.spec.ts new file mode 100644 index 000000000..bfcc9087c --- /dev/null +++ b/tests/e2e/search.spec.ts @@ -0,0 +1,56 @@ +import { expect, sourceBrowsingRid, test } from "@tests/support/fixtures.js"; + +// The header repo-search only renders when the seed reports a search backend +// (`/info` → `searchAvailable`). Force it on and stub the search endpoint so +// we can drive the result-selection flow. The stubbed result points at the +// real `source-browsing` fixture repo so navigating to it actually loads. +// Force the header search bar on by reporting a search backend from `/info`. +// The search itself is served by httpd's real storage-walk fallback, so the +// results (and the seed they resolve on) are genuine — no stubbing the rid. +async function enableSearch(page: import("@playwright/test").Page) { + await page.route("**/api/v1/info", async route => { + const response = await route.fetch(); + const json = await response.json(); + json.httpd.searchAvailable = true; + await route.fulfill({ response, json }); + }); +} + +test("selecting a search result with Enter navigates to the repo", async ({ + page, +}) => { + await enableSearch(page); + await page.goto("/explore"); + + const input = page.getByRole("textbox", { name: "Search repositories" }); + await expect(input).toBeVisible(); + await input.fill("source"); + + await expect( + page.locator(".dropdown").getByRole("link", { name: /source-browsing/ }), + ).toBeVisible(); + + // Highlight the first result and confirm with Enter. + await input.press("ArrowDown"); + await input.press("Enter"); + + await expect(page).toHaveURL(new RegExp(sourceBrowsingRid)); + await expect(page.getByRole("button", { name: "Clone" })).toBeVisible(); +}); + +test("clicking a search result navigates to the repo", async ({ page }) => { + await enableSearch(page); + await page.goto("/explore"); + + const input = page.getByRole("textbox", { name: "Search repositories" }); + await expect(input).toBeVisible(); + await input.fill("source"); + + await page + .locator(".dropdown") + .getByRole("link", { name: /source-browsing/ }) + .click(); + + await expect(page).toHaveURL(new RegExp(sourceBrowsingRid)); + await expect(page.getByRole("button", { name: "Clone" })).toBeVisible(); +}); diff --git a/tests/unit/localStore.test.ts b/tests/unit/localStore.test.ts new file mode 100644 index 000000000..7583fb06f --- /dev/null +++ b/tests/unit/localStore.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { get } from "svelte/store"; +import { z } from "zod"; + +import storedWritable from "@app/lib/localStore"; + +const numberSchema = z.number(); + +describe("storedWritable", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("uses initial value when localStorage is empty", () => { + const store = storedWritable("test-empty", numberSchema, 42); + expect(get(store)).toBe(42); + }); + + test("restores prior value from localStorage", () => { + localStorage.setItem("test-restore", "7"); + const store = storedWritable("test-restore", numberSchema, 0); + expect(get(store)).toBe(7); + }); + + test("persists set() to localStorage", () => { + const store = storedWritable("test-persist", numberSchema, 0); + store.set(5); + expect(localStorage.getItem("test-persist")).toBe("5"); + }); + + test("clear() resets the store and removes from localStorage", () => { + const store = storedWritable("test-clear", numberSchema, 0); + store.set(9); + store.clear(); + expect(get(store)).toBe(0); + expect(localStorage.getItem("test-clear")).toBeNull(); + }); + + test("falls back to initial value when stored data fails schema", () => { + localStorage.setItem("test-bad-schema", '"not a number"'); + const store = storedWritable("test-bad-schema", numberSchema, 3); + expect(get(store)).toBe(3); + }); + + test("falls back to initial value when stored data is invalid JSON", () => { + localStorage.setItem("test-bad-json", "{not json"); + const store = storedWritable("test-bad-json", numberSchema, 4); + expect(get(store)).toBe(4); + }); + + test("ignores localStorage.getItem errors and uses initial value", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("private mode"); + }); + const store = storedWritable("test-get-throws", numberSchema, 11); + expect(get(store)).toBe(11); + }); + + test("ignores localStorage.setItem errors but still updates in-memory store", () => { + const store = storedWritable("test-set-throws", numberSchema, 0); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + expect(() => store.set(99)).not.toThrow(); + expect(get(store)).toBe(99); + }); + + test("ignores localStorage.removeItem errors but still resets in-memory store", () => { + const store = storedWritable("test-remove-throws", numberSchema, 0); + store.set(5); + vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(() => store.clear()).not.toThrow(); + expect(get(store)).toBe(0); + }); + + test("update() persists to localStorage", () => { + const store = storedWritable("test-update", numberSchema, 1); + store.update(n => n + 10); + expect(get(store)).toBe(11); + expect(localStorage.getItem("test-update")).toBe("11"); + }); + + test("disableLocalStorage skips persistence", () => { + const store = storedWritable("test-disabled", numberSchema, 0, true); + store.set(7); + expect(localStorage.getItem("test-disabled")).toBeNull(); + expect(get(store)).toBe(7); + }); +}); diff --git a/tests/visual/desktop/landingPage.spec.ts b/tests/visual/desktop/landingPage.spec.ts index 2c3684391..b5cc5ae1e 100644 --- a/tests/visual/desktop/landingPage.spec.ts +++ b/tests/visual/desktop/landingPage.spec.ts @@ -67,3 +67,20 @@ test("response error", async ({ page }) => { mask: [page.locator(".command")], }); }); + +test("unable to reach any seed", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem( + "configuredPreferredSeeds", + JSON.stringify([{ hostname: "127.0.0.1", port: 8081, scheme: "http" }]), + ); + }); + // Fail every seed request so no candidate responds, exercising the + // failover dead-end on the explore page. + await page.route( + url => url.port === "8081", + route => route.abort(), + ); + await page.goto("/", { waitUntil: "networkidle" }); + await expect(page).toHaveScreenshot(); +});