Add e2e and unit tests for explore, search, and marketing

This commit is contained in:
Georgios Jason Efstathiou
2026-07-15 15:42:22 +02:00
committed by Rūdolfs Ošiņš
parent 025e7254f9
commit c01e22ac87
6 changed files with 346 additions and 52 deletions
+79
View File
@@ -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();
});
+65
View File
@@ -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<string, unknown>).__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();
});
+34 -52
View File
@@ -69,15 +69,10 @@ test("unreachable node shows error with seed selector", async ({ page }) => {
page.getByText("Select a different node to continue."), page.getByText("Select a different node to continue."),
).toBeVisible(); ).toBeVisible();
// Shows the seed selector dropdown toggle. // Shows the seed selector toggle.
await expect( await expect(
page.getByRole("button", { name: "Toggle seed selector dropdown" }), page.getByRole("button", { name: "Seed selector" }),
).toBeVisible(); ).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 ({ 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(); await expect(page.getByText("Could not connect to")).toBeVisible();
// Open the seed selector and navigate to the working local node. // Open the seed selector and navigate to the working local node.
await page await page.getByRole("button", { name: "Seed selector" }).click();
.getByRole("button", { name: "Toggle seed selector dropdown" }) await page.getByRole("button", { name: "Add new" }).click();
.click(); await page.getByPlaceholder("seed.radicle.example").fill("localhost:8081");
await page.getByPlaceholder("seed.radicle.example").fill("localhost");
await page.getByPlaceholder("seed.radicle.example").press("Enter"); await page.getByPlaceholder("seed.radicle.example").press("Enter");
// Should navigate to the working node. // 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. // Seed selector is available to navigate away.
await expect( await expect(
page.getByRole("button", { name: "Toggle seed selector dropdown" }), page.getByRole("button", { name: "Seed selector" }),
).toBeVisible(); ).toBeVisible();
}); });
@@ -135,53 +129,41 @@ test("edit seed bookmarks", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page // The seed picker lives in the Settings dropdown now.
.getByRole("button", { name: "Toggle seed selector dropdown" }) const openSettings = () =>
.click(); page.getByRole("button", { name: "Settings" }).click();
await expect(page.getByPlaceholder("seed.radicle.example")).toHaveValue( await openSettings();
"localhost",
);
await expect(
page.getByRole("button", { name: "Default seeds can't be removed" }),
).toBeVisible();
await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1);
// 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").fill("seed.example.tld");
await page.getByPlaceholder("seed.radicle.example").press("Enter"); 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( await expect(
page.getByRole("button", { name: "Add bookmark" }), page.getByRole("button", { name: "Remove bookmark", exact: true }),
).toBeVisible(); ).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 await page
.getByRole("button", { name: "Toggle seed selector dropdown" }) .getByRole("button", { name: "Remove bookmark", exact: true })
.click(); .click();
await expect(
// After navigating to the seed it should not yet be added to the bookmarks. page.getByRole("button", { name: "Remove bookmark", exact: true }),
await expect(page.locator(".dropdown > .dropdown-item")).toHaveCount(1); ).not.toBeVisible();
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);
}); });
+56
View File
@@ -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();
});
+95
View File
@@ -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);
});
});
+17
View File
@@ -67,3 +67,20 @@ test("response error", async ({ page }) => {
mask: [page.locator(".command")], 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();
});