mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Add e2e and unit tests for explore, search, and marketing
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user