{#if meta}
diff --git a/src/base/projects/View.svelte b/src/base/projects/View.svelte
index f91f39554..8ad197d29 100644
--- a/src/base/projects/View.svelte
+++ b/src/base/projects/View.svelte
@@ -48,13 +48,16 @@
activeRoute.params.route,
project.branches,
);
- router.updateProjectRoute({
- revision,
- path,
- line: activeRoute.params.line,
- hash: activeRoute.params.hash,
- route: undefined,
- });
+ router.updateProjectRoute(
+ {
+ revision,
+ path,
+ line: activeRoute.params.line,
+ hash: activeRoute.params.hash,
+ route: undefined,
+ },
+ { replace: true },
+ );
}
if (!activeRoute.params.revision) {
// We need a revision to fetch `getRoot`.
diff --git a/src/base/registrations/registrar.ts b/src/base/registrations/registrar.ts
index 8d2e0d90c..ea2fba8f9 100644
--- a/src/base/registrations/registrar.ts
+++ b/src/base/registrations/registrar.ts
@@ -60,10 +60,6 @@ export const state = writable
({ connection: State.Connecting });
window.registrarState = state;
-state.subscribe((s: Connection) => {
- console.debug("register.state", s);
-});
-
export async function getRegistration(
name: string,
wallet: Wallet,
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 000000000..7500c60a6
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,36 @@
+import configJson from "@app/config.json";
+
+export interface Config {
+ walletConnect: { bridge: string };
+ reactions: string[];
+ seeds: {
+ pinned: { host: string; emoji: string }[];
+ };
+ projects: {
+ pinned: {
+ name: string;
+ urn: string;
+ seed: string;
+ }[];
+ };
+}
+
+function getConfig(): Config {
+ if (window.VITEST) {
+ return {
+ walletConnect: { bridge: "" },
+ reactions: [],
+ seeds: {
+ pinned: [],
+ },
+ projects: { pinned: [] },
+ };
+ } else if (window.PLAYWRIGHT) {
+ return window.APP_CONFIG;
+ } else {
+ // In dev and production environments we use data from config.json.
+ return configJson;
+ }
+}
+
+export const config = getConfig();
diff --git a/src/e2eTestStubs.ts b/src/e2eTestStubs.ts
new file mode 100644
index 000000000..abc060225
--- /dev/null
+++ b/src/e2eTestStubs.ts
@@ -0,0 +1,8 @@
+import * as FakeTimers from "@sinonjs/fake-timers";
+
+if (typeof window.initializeTestStubs === "function") {
+ window.e2eTestStubs = {
+ FakeTimers: FakeTimers,
+ };
+ window.initializeTestStubs();
+}
diff --git a/src/global.d.ts b/src/global.d.ts
new file mode 100644
index 000000000..2dc53dea2
--- /dev/null
+++ b/src/global.d.ts
@@ -0,0 +1,32 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import type { Config } from "@app/config";
+import type { FakeTimers } from "@sinonjs/fake-timers";
+
+declare global {
+ interface Window {
+ // Defined in vite.config.ts and are available in all environments except
+ // production.
+ VITEST: boolean;
+ PLAYWRIGHT: boolean;
+ HASH_ROUTING: boolean;
+
+ // APP_CONFIG is set from within Playwright tests at runtime.
+ // To better understand how it works together, have a look at:
+ // tests/support/fixtures.ts
+ // src/config.ts
+ APP_CONFIG: Config;
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ initializeTestStubs: Function;
+ e2eTestStubs: {
+ FakeTimers: FakeTimers;
+ };
+
+ // Used in
+ // src/session.ts
+ // src/wallet.ts
+ ethereum: any;
+ registrarState: any;
+ }
+}
+
+export {};
diff --git a/src/index.ts b/src/index.ts
index 19ca82c02..a80163e73 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,15 +1,8 @@
+if (window.PLAYWRIGHT) import("./e2eTestStubs");
import App from "./App.svelte";
const app = new App({
target: document.body,
});
-declare global {
- namespace NodeJS {
- interface ProcessEnv {
- hashRouting: number;
- }
- }
-}
-
export default app;
diff --git a/src/router/index.ts b/src/router/index.ts
index 573cfd6ce..77efb6122 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -16,13 +16,15 @@ export const activeRouteStore: Readable = derived(
},
);
-export const base = process.env.hashRouting ? "./" : "/";
+export const base = window.HASH_ROUTING ? "./" : "/";
// Gets triggered when clicking on an anchor hash tag e.g.
// Allows the jump to a anchor hash
window.addEventListener("hashchange", e => {
- const url = new URL(e.newURL);
- updateProjectRoute({ hash: url.hash.substring(1) });
+ const route = pathToRoute(e.newURL);
+ if (route?.resource === "projects" && route.params.hash) {
+ updateProjectRoute({ hash: route.params.hash });
+ }
});
// Replaces history on any user interaction with forward and backwards buttons
@@ -62,12 +64,17 @@ export function projectLinkHref(
export function updateProjectRoute(
projectRouteParams: Partial,
+ opts: { replace: boolean } = { replace: false },
) {
const activeRoute = get(activeRouteStore);
if (activeRoute.resource === "projects") {
const updatedRoute = createProjectRoute(activeRoute, projectRouteParams);
- push(updatedRoute);
+ if (opts.replace) {
+ replace(updatedRoute);
+ } else {
+ push(updatedRoute);
+ }
} else {
throw new Error(
"Don't use project specific navigation outside of project views",
@@ -82,7 +89,7 @@ export const push = (newRoute: Route): void => {
// one subsequent pop() anyway.
historyStore.set([...history, newRoute].slice(-10));
- const path = process.env.hashRouting
+ const path = window.HASH_ROUTING
? "#" + routeToPath(newRoute)
: routeToPath(newRoute);
@@ -101,7 +108,7 @@ export const pop = (): void => {
export function replace(newRoute: Route): void {
historyStore.set([newRoute]);
- const path = process.env.hashRouting
+ const path = window.HASH_ROUTING
? "#" + routeToPath(newRoute)
: routeToPath(newRoute);
@@ -127,7 +134,7 @@ function pathToRoute(path: string): Route | null {
}
const url = new URL(path, window.origin);
- const segments = process.env.hashRouting
+ const segments = window.HASH_ROUTING
? url.hash.substring(2).split("#")[0].split("/") // Try to remove any additional hashes at the end of the URL.
: url.pathname.substring(1).split("/");
diff --git a/src/session.ts b/src/session.ts
index 8d6891765..830dd60ed 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -404,10 +404,6 @@ export async function connectSeed(seedSession: {
state.connectSeed(seedSession);
}
-state.subscribe(s => {
- console.debug("session.state", s);
-});
-
export async function approveSpender(
spender: string,
amount: BigNumber,
diff --git a/src/utils.ts b/src/utils.ts
index e6462b1dc..df3b33fe0 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -3,7 +3,6 @@ import type { Wallet } from "@app/wallet";
import type { marked } from "marked";
import * as cache from "@app/cache";
-import config from "@app/config.json";
import emojis from "@app/emojis";
import katex from "katex";
import md5 from "md5";
@@ -12,6 +11,7 @@ import { BigNumber } from "ethers";
import { ProfileType } from "@app/profile";
import { assert } from "@app/error";
import { base } from "@app/router";
+import { config } from "@app/config";
import { ethers } from "ethers";
import { getAddress, getResolver } from "@app/base/registrations/registrar";
import {
diff --git a/src/wallet.ts b/src/wallet.ts
index 60957eb5d..68e910338 100644
--- a/src/wallet.ts
+++ b/src/wallet.ts
@@ -10,7 +10,7 @@ import { capitalize } from "@app/utils";
import ethereumContractAbis from "@app/ethereum/contractAbis.json";
import homestead from "@app/ethereum/networks/homestead.json";
import goerli from "@app/ethereum/networks/goerli.json";
-import config from "@app/config.json";
+import { config } from "@app/config";
interface NetworkConfig {
name: string;
@@ -29,15 +29,6 @@ interface NetworkConfig {
alchemy: { key: string };
}
-declare global {
- interface Window {
- // eslint-disable-next-line @typescript-eslint/naming-convention
- Cypress: any;
- ethereum: any;
- registrarState: any;
- }
-}
-
export type WalletConnectState =
| { state: "close" }
| { state: "open"; uri: string; onClose: any };
@@ -200,12 +191,19 @@ function getProvider(
): ethers.providers.JsonRpcProvider {
if (metamask) {
return metamask;
- } else if (import.meta.env.PROD) {
+ } else if (
+ import.meta.env.PROD &&
+ window.location.host !== "localhost:4173"
+ ) {
return new ethers.providers.AlchemyWebSocketProvider(
networkConfig.name,
networkConfig.alchemy.key,
);
- } else if (import.meta.env.DEV) {
+ }
+ // Run the production smoke test with the ethers provider,
+ // because we block requests from localhost on Alchemy,
+ // which in turn throws an exception.
+ else if (import.meta.env.DEV || window.location.host === "localhost:4173") {
// The ethers defaultProvider doesn't include a `send` method, which breaks the `utils.getTokens` fn.
// Since Metamask nor WalletConnect provide an `alchemy_getTokenBalances` nor `alchemy_getTokenMetadata` endpoint,
// we can rely on not using `config.provider.send`.
diff --git a/tests/e2e/buildSmoke.spec.ts b/tests/e2e/buildSmoke.spec.ts
new file mode 100644
index 000000000..9e8b6720b
--- /dev/null
+++ b/tests/e2e/buildSmoke.spec.ts
@@ -0,0 +1,18 @@
+import { test, expect } from "@tests/support/fixtures.js";
+
+test("exceptions in production build", async ({ page, browserName }) => {
+ // It's enough to check this once.
+ if (browserName !== "chromium") {
+ test.skip();
+ }
+
+ await page.goto("/");
+ // Wait for scripts to finish executing, there might be exceptions that
+ // happen after the page has been painted.
+ await page.waitForTimeout(2000);
+ await expect(
+ page.locator(
+ "text=Radicle enables developers to securely collaborate on software over a peer-to-peer network built on Git.",
+ ),
+ ).toBeVisible();
+});
diff --git a/tests/e2e/clipboard.spec.ts b/tests/e2e/clipboard.spec.ts
new file mode 100644
index 000000000..031c8352c
--- /dev/null
+++ b/tests/e2e/clipboard.spec.ts
@@ -0,0 +1,87 @@
+import type { Page } from "@playwright/test";
+import { test, expect } from "@tests/support/fixtures.js";
+
+const sourceBrowsingFixture =
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o";
+
+async function expectClipboard(content: string, page: Page) {
+ const clipboardContent = await page.evaluate(
+ "navigator.clipboard.readText()",
+ );
+ expect(clipboardContent).toBe(content);
+}
+
+// We explicitly run all clipboard tests withing the context of a single test
+// so that we don't run into race conditions, because there is no way to isolate
+// the clipboard in Playwright yet.
+test("copy to clipboard", async ({ page, browserName, context }) => {
+ // These tests only work in Chromium, because other browsers don't support
+ // changing permissions.
+ if (browserName !== "chromium") {
+ test.skip();
+ }
+ context.grantPermissions(["clipboard-read", "clipboard-write"]);
+
+ await page.goto(sourceBrowsingFixture);
+
+ // Reset system clipboard to a known state.
+ await page.evaluate("navigator.clipboard.writeText('')");
+
+ // Project URN.
+ {
+ await page.locator(".urn > .clipboard").click();
+ const clipboardContent = await page.evaluate(
+ "navigator.clipboard.readText()",
+ );
+ expect(clipboardContent).toBe(
+ "rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o",
+ );
+ }
+
+ // `rad clone` URL.
+ {
+ await page.getByText("Clone").click();
+ await page.locator("text=rad clone rad://0.0.0.0/hnrkgd").hover();
+ await page
+ .locator(".clone-url-wrapper > span")
+ .first()
+ .locator(".clipboard")
+ .click();
+ await expectClipboard(
+ "rad clone rad://0.0.0.0/hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o",
+ page,
+ );
+ }
+
+ // `git clone` URL.
+ {
+ await page.getByText("Clone").click();
+ await page.locator("text=https://0.0.0.0/hnrkgd").hover();
+ await page
+ .locator(".clone-url-wrapper > span")
+ .last()
+ .locator(".clipboard")
+ .click();
+ await expectClipboard(
+ "https://0.0.0.0/hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o.git",
+ page,
+ );
+ }
+
+ await page.goto("/seeds/radicle.local");
+ // Seed address.
+ {
+ await page.locator(".clipboard").first().click();
+ await expectClipboard("0.0.0.0", page);
+
+ await page.locator(".clipboard").last().click();
+ await expectClipboard(
+ "hyb6i8oggc3mgra9siy8yuohhtz34r98pcybja97c9o789wpsg6nn4",
+ page,
+ );
+ }
+
+ // Clear the system clipboard contents so developers don't wonder why there's
+ // random stuff in their clipboard after running tests.
+ await page.evaluate("navigator.clipboard.writeText('')");
+});
diff --git a/tests/e2e/landingPage.spec.ts b/tests/e2e/landingPage.spec.ts
new file mode 100644
index 000000000..d4d2782f0
--- /dev/null
+++ b/tests/e2e/landingPage.spec.ts
@@ -0,0 +1,43 @@
+import { test, expect } from "@tests/support/fixtures.js";
+
+test.use({
+ customAppConfig: true,
+});
+
+test("show pinned projects", async ({ page }) => {
+ await page.addInitScript(() => {
+ window.APP_CONFIG = {
+ walletConnect: {
+ bridge: "https://radicle.bridge.walletconnect.org",
+ },
+ reactions: [],
+ seeds: {
+ pinned: [{ host: "0.0.0.0", emoji: "🚀" }],
+ },
+ projects: {
+ pinned: [
+ {
+ name: "source-browsing",
+ urn: "rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o",
+ seed: "0.0.0.0",
+ },
+ ],
+ },
+ };
+ });
+ await page.goto("/");
+ await expect(
+ page.locator("text=Explore projects on the Radicle network."),
+ ).toBeVisible();
+
+ // Shows pinned project name.
+ await expect(page.locator("text=source-browsing")).toBeVisible();
+ //
+ // Shows pinned project description.
+ await expect(
+ page.locator("text=Git repository for source browsing tests"),
+ ).toBeVisible();
+
+ // Shows latest commit.
+ await expect(page.locator("text=530aabd")).toBeVisible();
+});
diff --git a/tests/e2e/project.spec.ts b/tests/e2e/project.spec.ts
new file mode 100644
index 000000000..c143b256f
--- /dev/null
+++ b/tests/e2e/project.spec.ts
@@ -0,0 +1,339 @@
+import type { Page } from "@playwright/test";
+import { test, expect } from "@tests/support/fixtures.js";
+
+const sourceBrowsingFixture =
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o";
+
+async function expectCounts(
+ params: { commits: number; contributors: number },
+ page: Page,
+) {
+ await expect(page.locator('role=button[name="Commit count"]')).toContainText(
+ `${params.commits} commit(s)`,
+ );
+ await expect(
+ page.locator('role=button[name="Contributor count"]'),
+ ).toContainText(`${params.contributors} contributor(s)`);
+}
+
+test("navigate to project", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ // Header.
+ {
+ const name = page.locator("text=source-browsing");
+ const urn = page.locator(
+ "text=rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o",
+ );
+ const description = page.locator(
+ "text=Git repository for source browsing tests",
+ );
+
+ await expect(name).toBeVisible();
+ await expect(urn).toBeVisible();
+ await expect(description).toBeVisible();
+ }
+
+ // Project menu shows default selected branch and commit and contributor counts.
+ {
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "main 530aabd",
+ );
+ await expectCounts({ commits: 7, contributors: 1 }, page);
+ }
+
+ // Navigate to the project README.md by default.
+ await expect(page.locator(".file-name")).toContainText("README.md");
+
+ // Show a commit teaser.
+ await expect(page.locator("text=dd068e9 Add README.md")).toBeVisible();
+
+ // Show rendered README.md contents.
+ await expect(page.locator("text=Git test repository")).toBeVisible();
+});
+
+test("show source tree at specific revision", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+ await page.locator('role=button[name="Commit count"]').click();
+
+ await page
+ .locator(".commit-teaser", { hasText: "335dd6d" })
+ .getByTitle("Browse the repository at this point in the history")
+ .click();
+
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "335dd6dc89b535a4a31e9422c803199bb6b0a09a",
+ );
+ expect(page.locator(".source-tree")).toHaveText("bin/ src/");
+ await expectCounts({ commits: 2, contributors: 1 }, page);
+});
+
+test("source file highlighting", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ await page.getByText("src/").click();
+ await page.getByText("true.c").click();
+
+ await expect(page.getByText("return")).toHaveCSS(
+ "color",
+ "rgb(180, 142, 173)",
+ );
+});
+
+test("navigate deep file hierarchies", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ const sourceTree = page.locator(".source-tree");
+
+ await sourceTree.getByText("deep/").click();
+ await sourceTree.getByText("directory/").click();
+ await sourceTree.getByText("hierarchy/").click();
+ await sourceTree.getByText("is/").click();
+ await sourceTree.getByText("entirely/").click();
+ await sourceTree.getByText("possible/").click();
+ await sourceTree.getByText("in/").nth(1).click();
+ await sourceTree.getByText("git/").click();
+ await sourceTree.getByText("repositories/").click();
+ await sourceTree.getByText(".gitkeep").click();
+ await expect(
+ page.locator("text=0801ace Add a deeply nested directory tree"),
+ ).toBeVisible();
+
+ // After a page reload the tree browser is still expanded and we're still
+ // showing the .gitkeep file.
+ {
+ await page.reload();
+
+ const sourceTree = page.locator(".source-tree");
+
+ await expect(sourceTree.getByText("deep/")).toBeVisible();
+ await expect(sourceTree.getByText("directory/")).toBeVisible();
+ await expect(sourceTree.getByText("hierarchy/")).toBeVisible();
+ await expect(sourceTree.getByText("is/")).toBeVisible();
+ await expect(sourceTree.getByText("entirely/")).toBeVisible();
+ await expect(sourceTree.getByText("possible/")).toBeVisible();
+ await expect(sourceTree.getByText("in/").nth(1)).toBeVisible();
+ await expect(sourceTree.getByText("git/")).toBeVisible();
+ await expect(sourceTree.getByText("repositories/")).toBeVisible();
+ await expect(sourceTree.getByText(".gitkeep")).toBeVisible();
+
+ await expect(
+ page.locator("text=0801ace Add a deeply nested directory tree"),
+ ).toBeVisible();
+ }
+});
+
+test("files with special characters in the filename", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ const sourceTree = page.locator(".source-tree");
+ await sourceTree.getByText("special/").click();
+
+ await sourceTree.getByText("+plus+").click();
+ await expect(page.locator(".file-name")).toContainText("+plus");
+
+ await sourceTree.getByText("-dash-").click();
+ await expect(page.locator(".file-name")).toContainText("-dash-");
+
+ await sourceTree.getByText(":colon:").click();
+ await expect(page.locator(".file-name")).toContainText(":colon:");
+
+ await sourceTree.getByText(";semicolon;").click();
+ await expect(page.locator(".file-name")).toContainText(";semicolon;");
+
+ await sourceTree.getByText("@at@").click();
+ await expect(page.locator(".file-name")).toContainText("@at@");
+
+ await sourceTree.getByText("_underscore_").click();
+ await expect(page.locator(".file-name")).toContainText("_underscore_");
+
+ // TODO: fix these errors in `racdicle-client-services/http-api` for the
+ // following edge cases.
+ //
+ // await sourceTree.getByText("back\\slash").click();
+ // await expect(page.locator(".file-name")).toContainText("back\\slash");
+ // await sourceTree.getByText("qs?param1=value?param2=value2#hash").click();
+ // await expect(page.locator(".file-name")).toContainText(
+ // "qs?param1=value?param2=value2#hash",
+ // );
+
+ await sourceTree.getByText("spaces are okay").click();
+ await expect(page.locator(".file-name")).toContainText("spaces are okay");
+
+ await sourceTree.getByText("~tilde~").click();
+ await expect(page.locator(".file-name")).toContainText("~tilde~");
+
+ await sourceTree.getByText("👹👹👹").click();
+ await expect(page.locator(".file-name")).toContainText("👹👹👹");
+});
+
+test("binary files", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ await page.getByText("bin/").click();
+ await page.getByText("true").click();
+
+ await expect(page.locator("text=Binary content")).toBeVisible();
+});
+
+test("hidden files", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ await page.getByText(".hidden").click();
+
+ await expect(page.locator("text=I'm a hidden file.")).toBeVisible();
+});
+
+test("markdown files", async ({ page }) => {
+ await page.goto(`${sourceBrowsingFixture}/tree/main/markdown/cheatsheet.md`);
+
+ await expect(
+ page.locator("text=This is intended as a quick reference and showcase."),
+ ).toBeVisible();
+
+ // Switch between raw and rendered modes.
+ {
+ const rawButton = page.locator('role=button[name="Raw"]');
+
+ await rawButton.click();
+ await expect(rawButton).toHaveClass(/active/);
+ await expect(page.locator("text=##### Table of Contents")).toBeVisible();
+
+ await rawButton.click();
+ await expect(rawButton).not.toHaveClass("active");
+ }
+
+ // Internal links go to anchor.
+ {
+ await page.getByRole("link", { name: "YouTube Videos" }).click();
+ await expect(page).toHaveURL(
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/main/markdown/cheatsheet.md#videos",
+ );
+ }
+});
+
+test("peer and branch switching", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ // Alice's peer.
+ {
+ await page.getByTitle("Change peer").click();
+ await page.locator("text=alice").click();
+ await expect(page.getByTitle("Change peer")).toHaveText("alice delegate");
+ await expect(
+ page.locator("text=source-browsing / hyn1mj…qx7bun"),
+ ).toBeVisible();
+
+ // Default `main` branch.
+ {
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "main 530aabd",
+ );
+ await expectCounts({ commits: 7, contributors: 1 }, page);
+ }
+
+ // Feature branch with a slash in the name.
+ {
+ await page.getByTitle("Change branch").click();
+ await page.locator("text=feature/branch").click();
+
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "feature/branch d6318f7",
+ );
+ await expectCounts({ commits: 10, contributors: 1 }, page);
+ }
+
+ // Branch without a history or files in it.
+ {
+ await page.getByTitle("Change branch").click();
+ await page.locator("text=orphaned-branch").click();
+
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "orphaned-branch af3641c",
+ );
+ await expectCounts({ commits: 1, contributors: 1 }, page);
+
+ await expect(
+ page.locator("text=We couldn't find any files at this revision."),
+ ).toBeVisible();
+ }
+ }
+
+ // Reset the source browser by clicking the project title.
+ {
+ await page.locator("text=source-browsing").click();
+
+ await expect(page.getByTitle("Change peer")).not.toContainText("alice");
+ await expect(page.getByTitle("Change peer")).not.toContainText("bob");
+
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "main 530aabd",
+ );
+ await expect(page.locator("text=Git test repository")).toBeVisible();
+ }
+
+ // Bob's peer.
+ {
+ await page.getByTitle("Change peer").click();
+ await page.locator("text=bob").click();
+
+ await expect(page.getByTitle("Change peer")).toHaveText("bob");
+ await expect(page.getByTitle("Change peer")).not.toHaveText("delegate");
+
+ // Default `main` branch.
+ {
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "main 0be0f03",
+ );
+ await expectCounts({ commits: 8, contributors: 2 }, page);
+ await expect(page.locator("text=0be0f03 Update readme")).toBeVisible();
+ }
+ }
+});
+
+test("clone modal", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ await page.getByText("Clone").click();
+ await expect(
+ page.locator(
+ "text=rad clone rad://0.0.0.0/hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o",
+ ),
+ ).toBeVisible();
+ await expect(
+ page.locator(
+ "text=https://0.0.0.0/hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o.git",
+ ),
+ ).toBeVisible();
+});
+
+test("only one modal can be open at a time", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ await page.getByTitle("Change peer").click();
+ await page.locator("text=alice hyn1mj").click();
+
+ await page.getByText("Clone").click();
+ await expect(page.locator("text=Code font")).not.toBeVisible();
+ await expect(page.locator("text=Use the Radicle CLI")).toBeVisible();
+ await expect(page.locator("text=bob hyy1k6g")).not.toBeVisible();
+ await expect(page.locator("text=feature/branch")).not.toBeVisible();
+
+ await page.getByTitle("Change branch").click();
+ await expect(page.locator("text=Code font")).not.toBeVisible();
+ await expect(page.locator("text=Use the Radicle CLI")).not.toBeVisible();
+ await expect(page.locator("text=bob hyy1k6g")).not.toBeVisible();
+ await expect(page.locator("text=feature/branch")).toBeVisible();
+
+ await page.getByTitle("Change peer").click();
+ await expect(page.locator("text=Code font")).not.toBeVisible();
+ await expect(page.locator("text=Use the Radicle CLI")).not.toBeVisible();
+ await expect(page.locator("text=bob hyy1k6g")).toBeVisible();
+ await expect(page.locator("text=feature/branch")).not.toBeVisible();
+
+ page.locator('button[name="Settings"]').click();
+ await expect(page.locator("text=Code font")).toBeVisible();
+ await expect(page.locator("text=Use the Radicle CLI")).not.toBeVisible();
+ await expect(page.locator("text=bob hyy1k6g")).not.toBeVisible();
+ await expect(page.locator("text=feature/branch")).not.toBeVisible();
+});
diff --git a/tests/e2e/project/commit.spec.ts b/tests/e2e/project/commit.spec.ts
new file mode 100644
index 000000000..4f02a9034
--- /dev/null
+++ b/tests/e2e/project/commit.spec.ts
@@ -0,0 +1,98 @@
+import { test, expect } from "@tests/support/fixtures.js";
+
+const sourceBrowsingFixture =
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o";
+const modifiedFileFixture = `${sourceBrowsingFixture}/remotes/hyy1k6ggg45pi7ip7ksyn1wt1ob4w5zh1awtg4qu3cxmbh5mws8pj1/commits/0be0f0302269b362be0bfe72aa4843eceaac5e3f`;
+
+test("navigation from commit list", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+ await page.getByTitle("Change peer").click();
+ await page.locator("text=bob hyy1k6").click();
+ await page.locator('role=button[name="Commit count"]').click();
+
+ await page.locator("text=Update readme").click();
+ await expect(page).toHaveURL(modifiedFileFixture);
+});
+
+test("relative timestamps", async ({ page }) => {
+ page.addInitScript(() => {
+ window.initializeTestStubs = () => {
+ window.e2eTestStubs.FakeTimers.install({
+ now: new Date("November 24 2022 12:00:00").valueOf(),
+ shouldClearNativeTimers: true,
+ shouldAdvanceTime: false,
+ });
+ };
+ });
+ await page.goto(modifiedFileFixture);
+ await expect(
+ page.locator(".commit header >> text=bob committed 3 days ago"),
+ ).toBeVisible();
+});
+
+test("modified file", async ({ page }) => {
+ await page.goto(modifiedFileFixture);
+
+ // Commit header.
+ {
+ const header = page.locator(".commit header");
+ await expect(header.locator("text=Update readme")).toBeVisible();
+ await expect(header.locator("text=Verified")).toBeVisible();
+ await expect(
+ header.locator("text=0be0f0302269b362be0bfe72aa4843eceaac5e3f"),
+ ).toBeVisible();
+ }
+
+ // Diff header.
+ await expect(
+ page.locator("text=1 file(s) changed with 1 addition(s) and 4 deletion(s)"),
+ ).toBeVisible();
+
+ // Diff.
+ await expect(page.locator("text=- # Git test repository")).toBeVisible();
+ await expect(page.locator("text=+ Updated readme")).toBeVisible();
+});
+
+test("created file", async ({ page }) => {
+ await page.goto(
+ `${sourceBrowsingFixture}/remotes/hyn1mjueopwzrmb18c3zmgg8ei8qunn5wpg76ouymytfqkfxqx7bun/commits/d6318f7f3d9c15b8ac6dd52267c53220d00f0982`,
+ );
+ await expect(
+ page.locator("text=1 file(s) created with 9 addition(s) and 0 deletion(s)"),
+ ).toBeVisible();
+ await expect(page.locator("text=subconscious.txt created")).toBeVisible();
+});
+
+test("deleted file", async ({ page }) => {
+ await page.goto(
+ `${sourceBrowsingFixture}/remotes/hyn1mjueopwzrmb18c3zmgg8ei8qunn5wpg76ouymytfqkfxqx7bun/commits/cd13c2d9a8a930d64a82b6134b44d1b872e33662`,
+ );
+ await expect(
+ page.locator("text=1 file(s) deleted with 0 addition(s) and 1 deletion(s)"),
+ ).toBeVisible();
+ await expect(page.locator("text=.hidden deleted")).toBeVisible();
+});
+
+test("navigation to source tree at specific revision", async ({ page }) => {
+ await page.goto(
+ `${sourceBrowsingFixture}/commits/0801aceeab500033f8d608778218657bd626ef73`,
+ );
+
+ // Go to source tree at this revision.
+ await page.getByTitle("View file").click();
+ await expect(
+ page.locator("text=Add a deeply nested directory tree"),
+ ).toBeVisible();
+ await expect(page).toHaveURL(
+ `${sourceBrowsingFixture}/tree/0801aceeab500033f8d608778218657bd626ef73/deep/directory/hierarchy/is/entirely/possible/in/git/repositories/.gitkeep`,
+ );
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "0801aceeab500033f8d608778218657bd626ef73",
+ );
+ await expect(page.locator(".source-tree >> text=.gitkeep")).toBeVisible();
+ await expect(
+ page.locator(
+ "text=deep/directory/hierarchy/is/entirely/possible/in/git/repositories/",
+ ),
+ ).toBeVisible();
+});
diff --git a/tests/e2e/project/commits.spec.ts b/tests/e2e/project/commits.spec.ts
new file mode 100644
index 000000000..4b52af98e
--- /dev/null
+++ b/tests/e2e/project/commits.spec.ts
@@ -0,0 +1,129 @@
+import { test, expect } from "@tests/support/fixtures.js";
+
+const sourceBrowsingFixture =
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o";
+
+test("peer and branch switching", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+ await page.locator('role=button[name="Commit count"]').click();
+
+ // Alice's peer.
+ {
+ await page.getByTitle("Change peer").click();
+ await page.locator("text=alice hyn1mj").click();
+ await expect(page.getByTitle("Change peer")).toHaveText("alice delegate");
+
+ await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
+ await expect(
+ page.locator(".commit-group-headers .commit-teaser"),
+ ).toHaveCount(7);
+
+ const latestCommit = page.locator(".commit-teaser").first();
+ await expect(latestCommit).toContainText("Add Markdown cheat sheet");
+ await expect(latestCommit).toContainText("530aabd");
+
+ const earliestCommit = page.locator(".commit-teaser").last();
+ await expect(earliestCommit).toContainText(
+ "Initialize an empty git repository",
+ );
+ await expect(earliestCommit).toContainText("36d5bbe");
+
+ await page.getByTitle("Change branch").click();
+ await page.locator("text=feature/branch").click();
+
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "feature/branch d6318f7",
+ );
+ await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
+ await expect(page.locator(".commit-group-headers .commit")).toHaveCount(10);
+
+ await page.getByTitle("Change branch").click();
+ await page.locator("text=orphaned-branch").click();
+
+ await expect(page.getByTitle("Current branch")).toContainText(
+ "orphaned-branch af3641c",
+ );
+ await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
+ await expect(
+ page.locator(".commit-group-headers .commit-teaser"),
+ ).toHaveCount(1);
+ }
+
+ // Bob's peer.
+ {
+ await page.getByTitle("Change peer").click();
+ await page.locator("text=bob hyy1k6").click();
+ await expect(page.getByTitle("Change peer")).toHaveText("bob");
+
+ await expect(page.getByText("Monday, November 21, 2022")).toBeVisible();
+ await expect(
+ page.locator(".commit-group-headers").first().locator(".commit-teaser"),
+ ).toHaveCount(1);
+
+ await expect(page.getByText("Thursday, November 17, 2022")).toBeVisible();
+ await expect(
+ page.locator(".commit-group-headers").last().locator(".commit-teaser"),
+ ).toHaveCount(7);
+
+ await page.pause();
+ const latestCommit = page.locator(".commit-teaser").first();
+ await expect(latestCommit).toContainText("Update readme");
+ await expect(latestCommit).toContainText("0be0f03");
+
+ const earliestCommit = page.locator(".commit-teaser").last();
+ await expect(earliestCommit).toContainText(
+ "Initialize an empty git repository",
+ );
+ await expect(earliestCommit).toContainText("36d5bbe");
+ }
+});
+
+test("verified badge", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+ await page.locator('role=button[name="Commit count"]').click();
+
+ await page.getByTitle("Change peer").click();
+ await page.locator("text=bob hyy1k6").click();
+ await expect(page.getByTitle("Change peer")).toHaveText("bob");
+
+ await page.locator("text=Verified").hover();
+
+ await expect(
+ page.locator(
+ "text=This commit was signed with the committer's radicle key.",
+ ),
+ ).toBeVisible();
+ await expect(
+ page.locator(
+ "text=bob committed hyy1k6ggg45pi7ip7ksyn1wt1ob4w5zh1awtg4qu3cxmbh5mws8pj1",
+ ),
+ ).toBeVisible();
+});
+
+test("relative timestamps", async ({ page }) => {
+ page.addInitScript(() => {
+ window.initializeTestStubs = () => {
+ window.e2eTestStubs.FakeTimers.install({
+ now: new Date("November 24 2022 12:00:00").valueOf(),
+ shouldClearNativeTimers: true,
+ shouldAdvanceTime: false,
+ });
+ };
+ });
+
+ await page.goto(sourceBrowsingFixture);
+ await page.locator('role=button[name="Commit count"]').click();
+
+ await page.getByTitle("Change peer").click();
+ await page.locator("text=bob hyy1k6").click();
+ await expect(page.getByTitle("Change peer")).toHaveText("bob");
+
+ const latestCommit = page.locator(".commit-teaser").first();
+ await expect(latestCommit).toContainText("bob committed 3 days ago");
+ await expect(latestCommit).toContainText("0be0f03");
+
+ const earliestCommit = page.locator(".commit").last();
+ await expect(earliestCommit).toContainText(
+ "Alice Liddell committed 7 days ago",
+ );
+});
diff --git a/tests/e2e/router.hash.spec.ts b/tests/e2e/router.hash.spec.ts
new file mode 100644
index 000000000..9fb88eb19
--- /dev/null
+++ b/tests/e2e/router.hash.spec.ts
@@ -0,0 +1,137 @@
+import { test, expect } from "@tests/support/fixtures.js";
+import {
+ expectBackAndForwardNavigationWorks,
+ expectUrlPersistsReload,
+} from "@tests/support/router.js";
+
+test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => {
+ window.HASH_ROUTING = true;
+ });
+});
+
+test("navigate between landing and project page", async ({ page }) => {
+ await page.addInitScript(() => {
+ window.APP_CONFIG = {
+ walletConnect: {
+ bridge: "https://radicle.bridge.walletconnect.org",
+ },
+ reactions: [],
+ seeds: {
+ pinned: [{ host: "0.0.0.0", emoji: "🚀" }],
+ },
+ projects: {
+ pinned: [
+ {
+ name: "source-browsing",
+ urn: "rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o",
+ seed: "0.0.0.0",
+ },
+ ],
+ },
+ };
+ });
+
+ await page.goto("/#/");
+ await expect(page).toHaveURL("/#/");
+
+ await page.locator("text=source-browsing").click();
+ await expect(page).toHaveURL(
+ "/#/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/530aabdcc80397af254bc488b767169b92496e81",
+ );
+
+ await expectBackAndForwardNavigationWorks("/#/", page);
+ await expectUrlPersistsReload(page);
+});
+
+test("navigation between seed and project pages", async ({ page }) => {
+ await page.goto("/#/seeds/radicle.local");
+
+ const project = page.locator(".project");
+ await project.click();
+ await expect(page).toHaveURL(
+ "/#/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/530aabdcc80397af254bc488b767169b92496e81",
+ );
+
+ await expectBackAndForwardNavigationWorks("/#/seeds/radicle.local", page);
+ await expectUrlPersistsReload(page);
+
+ await page.locator('role=button[name="Seed"]').click();
+ await expect(page).toHaveURL("/#/seeds/0.0.0.0");
+});
+
+test.describe("project page navigation", () => {
+ test("navigation between commit history and single commit", async ({
+ page,
+ }) => {
+ const projectHistoryURL =
+ "/#/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/history/530aabdcc80397af254bc488b767169b92496e81";
+ await page.goto(projectHistoryURL);
+
+ await page.locator("text=Add Markdown cheat sheet").click();
+ await expect(page).toHaveURL(
+ "/#/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/commits/530aabdcc80397af254bc488b767169b92496e81",
+ );
+
+ await expectBackAndForwardNavigationWorks(projectHistoryURL, page);
+ await expectUrlPersistsReload(page);
+ });
+
+ test("navigate between tree and commit history", async ({ page }) => {
+ const projectTreeURL =
+ "/#/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/530aabdcc80397af254bc488b767169b92496e81";
+
+ await page.goto(projectTreeURL);
+ await expect(page).toHaveURL(projectTreeURL);
+
+ await page.locator('role=button[name="Commit count"]').click();
+ await expect(page).toHaveURL(
+ "/#/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/history/530aabdcc80397af254bc488b767169b92496e81",
+ );
+
+ await expectBackAndForwardNavigationWorks(projectTreeURL, page);
+ await expectUrlPersistsReload(page);
+ });
+
+ test("navigate project paths", async ({ page }) => {
+ const projectTreeURL =
+ "/#/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/530aabdcc80397af254bc488b767169b92496e81";
+
+ await page.goto(projectTreeURL);
+ await expect(page).toHaveURL(projectTreeURL);
+
+ await page.locator("text=.hidden").click();
+ await expect(page).toHaveURL(`${projectTreeURL}/.hidden`);
+
+ await page.locator("text=bin/").click();
+ await page.locator("text=true").click();
+ await expect(page).toHaveURL(`${projectTreeURL}/bin/true`);
+
+ await expectBackAndForwardNavigationWorks(
+ `${projectTreeURL}/.hidden`,
+ page,
+ );
+ await expectUrlPersistsReload(page);
+ });
+
+ test("navigate project paths with a selected peer", async ({ page }) => {
+ const projectTreeURL =
+ "/#/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/remotes/hyn1mjueopwzrmb18c3zmgg8ei8qunn5wpg76ouymytfqkfxqx7bun/tree";
+
+ await page.goto(projectTreeURL);
+ await expect(page).toHaveURL(projectTreeURL);
+
+ await page.locator("text=.hidden").click();
+ await expect(page).toHaveURL(`${projectTreeURL}/main/.hidden`);
+
+ await page.locator("text=bin/").click();
+ await page.locator("text=true").click();
+ await expect(page).toHaveURL(`${projectTreeURL}/main/bin/true`);
+
+ await expectBackAndForwardNavigationWorks(
+ `${projectTreeURL}/main/.hidden`,
+ page,
+ );
+ await expectUrlPersistsReload(page);
+ });
+});
diff --git a/tests/e2e/router.history.spec.ts b/tests/e2e/router.history.spec.ts
new file mode 100644
index 000000000..03db33675
--- /dev/null
+++ b/tests/e2e/router.history.spec.ts
@@ -0,0 +1,131 @@
+import { test, expect } from "@tests/support/fixtures.js";
+import {
+ expectBackAndForwardNavigationWorks,
+ expectUrlPersistsReload,
+} from "@tests/support/router.js";
+
+test("navigate between landing and project page", async ({ page }) => {
+ await page.addInitScript(() => {
+ window.APP_CONFIG = {
+ walletConnect: {
+ bridge: "https://radicle.bridge.walletconnect.org",
+ },
+ reactions: [],
+ seeds: {
+ pinned: [{ host: "0.0.0.0", emoji: "🚀" }],
+ },
+ projects: {
+ pinned: [
+ {
+ name: "source-browsing",
+ urn: "rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o",
+ seed: "0.0.0.0",
+ },
+ ],
+ },
+ };
+ });
+
+ await page.goto("/");
+ await expect(page).toHaveURL("/");
+
+ await page.locator("text=source-browsing").click();
+ await expect(page).toHaveURL(
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/530aabdcc80397af254bc488b767169b92496e81",
+ );
+
+ await expectBackAndForwardNavigationWorks("/", page);
+ await expectUrlPersistsReload(page);
+});
+
+test("navigation between seed and project pages", async ({ page }) => {
+ await page.goto("/seeds/radicle.local");
+
+ const project = page.locator(".project");
+ await project.click();
+ await expect(page).toHaveURL(
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/530aabdcc80397af254bc488b767169b92496e81",
+ );
+
+ await expectBackAndForwardNavigationWorks("/seeds/radicle.local", page);
+ await expectUrlPersistsReload(page);
+
+ await page.locator('role=button[name="Seed"]').click();
+ await expect(page).toHaveURL("/seeds/0.0.0.0");
+});
+
+test.describe("project page navigation", () => {
+ test("navigation between commit history and single commit", async ({
+ page,
+ }) => {
+ const projectHistoryURL =
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/history/530aabdcc80397af254bc488b767169b92496e81";
+ await page.goto(projectHistoryURL);
+
+ await page.locator("text=Add Markdown cheat sheet").click();
+ await expect(page).toHaveURL(
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/commits/530aabdcc80397af254bc488b767169b92496e81",
+ );
+
+ await expectBackAndForwardNavigationWorks(projectHistoryURL, page);
+ await expectUrlPersistsReload(page);
+ });
+
+ test("navigate between tree and commit history", async ({ page }) => {
+ const projectTreeURL =
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/530aabdcc80397af254bc488b767169b92496e81";
+
+ await page.goto(projectTreeURL);
+ await expect(page).toHaveURL(projectTreeURL);
+
+ await page.locator('role=button[name="Commit count"]').click();
+ await expect(page).toHaveURL(
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/history/530aabdcc80397af254bc488b767169b92496e81",
+ );
+
+ await expectBackAndForwardNavigationWorks(projectTreeURL, page);
+ await expectUrlPersistsReload(page);
+ });
+
+ test("navigate project paths", async ({ page }) => {
+ const projectTreeURL =
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/530aabdcc80397af254bc488b767169b92496e81";
+
+ await page.goto(projectTreeURL);
+ await expect(page).toHaveURL(projectTreeURL);
+
+ await page.locator("text=.hidden").click();
+ await expect(page).toHaveURL(`${projectTreeURL}/.hidden`);
+
+ await page.locator("text=bin/").click();
+ await page.locator("text=true").click();
+ await expect(page).toHaveURL(`${projectTreeURL}/bin/true`);
+
+ await expectBackAndForwardNavigationWorks(
+ `${projectTreeURL}/.hidden`,
+ page,
+ );
+ await expectUrlPersistsReload(page);
+ });
+
+ test("navigate project paths with a selected peer", async ({ page }) => {
+ const projectTreeURL =
+ "seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/remotes/hyn1mjueopwzrmb18c3zmgg8ei8qunn5wpg76ouymytfqkfxqx7bun/tree";
+
+ await page.goto(projectTreeURL);
+ await expect(page).toHaveURL(projectTreeURL);
+
+ await page.locator("text=.hidden").click();
+ await expect(page).toHaveURL(`${projectTreeURL}/main/.hidden`);
+
+ await page.locator("text=bin/").click();
+ await page.locator("text=true").click();
+ await expect(page).toHaveURL(`${projectTreeURL}/main/bin/true`);
+
+ await expectBackAndForwardNavigationWorks(
+ `${projectTreeURL}/main/.hidden`,
+ page,
+ );
+ await expectUrlPersistsReload(page);
+ });
+});
diff --git a/tests/e2e/search.spec.ts b/tests/e2e/search.spec.ts
new file mode 100644
index 000000000..8041cacb2
--- /dev/null
+++ b/tests/e2e/search.spec.ts
@@ -0,0 +1,31 @@
+import { test, expect } from "@tests/support/fixtures.js";
+
+test("navigate to existing project", async ({ page }) => {
+ await page.goto("/");
+ const searchInput = page.getByPlaceholder("Search a name or address…");
+ await searchInput.click();
+ await searchInput.fill("rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o");
+ await searchInput.press("Enter");
+
+ await expect(page).toHaveURL(
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree",
+ );
+ await expect(searchInput).not.toHaveValue(
+ "rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o",
+ );
+});
+
+test("navigate to a project that does not exist", async ({ page }) => {
+ await page.goto("/");
+ const searchInput = page.getByPlaceholder("Search a name or address…");
+ await searchInput.click();
+ await searchInput.fill("rad:git:hnrkn1ah5im83fwt4u3jfs5ndwpt9hrnm9wby");
+ await searchInput.press("Enter");
+
+ await page.waitForSelector(".search-bar.shaking");
+
+ await expect(page).toHaveURL("/");
+ await expect(searchInput).toHaveValue(
+ "rad:git:hnrkn1ah5im83fwt4u3jfs5ndwpt9hrnm9wby",
+ );
+});
diff --git a/tests/e2e/seed.spec.ts b/tests/e2e/seed.spec.ts
new file mode 100644
index 000000000..ad080d945
--- /dev/null
+++ b/tests/e2e/seed.spec.ts
@@ -0,0 +1,46 @@
+import { test, expect } from "@tests/support/fixtures.js";
+
+test("seed metadata", async ({ page }) => {
+ await page.goto("/seeds/radicle.local");
+
+ await expect(page.locator("header").getByText("radicle.local")).toBeVisible();
+ await expect(
+ page.locator(".title >> text=radicle.local").getByRole("img"),
+ ).toHaveAttribute("alt", "🚀");
+
+ await expect(page.getByRole("link", { name: "radicle.local" })).toBeVisible();
+ await expect(page.locator(".seed-address").getByRole("img")).toHaveAttribute(
+ "alt",
+ "🚀",
+ );
+ await expect(page.locator("text=hyb6i8…sg6nn4")).toBeVisible();
+ await expect(page.locator("text=8777")).toBeVisible();
+ await expect(page.locator("text=0.2.0")).toBeVisible();
+});
+
+test("seed projects", async ({ page }) => {
+ await page.goto("/seeds/radicle.local");
+ const project = page.locator(".project");
+
+ // Project metadata.
+ {
+ await expect(project.locator("text=source-browsing")).toBeVisible();
+ await expect(
+ project.locator("text=Git repository for source browsing tests"),
+ ).toBeVisible();
+ await expect(
+ project.locator("text=530aabdcc80397af254bc488b767169b92496e81"),
+ ).toBeVisible();
+ }
+
+ // Show project URN on hover.
+ {
+ await expect(
+ project.locator("text=rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o"),
+ ).not.toBeVisible();
+ await project.hover();
+ await expect(
+ project.locator("text=rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o"),
+ ).toBeVisible();
+ }
+});
diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts
new file mode 100644
index 000000000..cb257ef68
--- /dev/null
+++ b/tests/e2e/settings.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect } from "@tests/support/fixtures.js";
+
+const sourceBrowsingFixture =
+ "/seeds/0.0.0.0/rad:git:hnrkgd7sjt79k4j59ddh11ooxg18rk7soej8o/tree/main/src/true.c";
+
+test("default settings", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ // Default settings.
+ {
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-codefont",
+ "jetbrains",
+ );
+ }
+});
+
+test("settings persistance", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+ page.locator('button[name="Settings"]').click();
+
+ await page.locator(".theme .toggle").click();
+ await page.getByText("Code font").click();
+ await page.getByText("System").click();
+
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await expect(page.locator("html")).toHaveAttribute("data-codefont", "system");
+
+ page.reload();
+
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await expect(page.locator("html")).toHaveAttribute("data-codefont", "system");
+});
+
+test("change theme", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+ page.locator('button[name="Settings"]').click();
+
+ await page.locator(".theme .toggle").click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await expect(page.locator("body")).toHaveCSS(
+ "background-color",
+ "rgb(243, 246, 253)",
+ );
+ // Source highlighting reacts to theme change.
+ await expect(page.getByText("() {")).toHaveCSS("color", "rgb(79, 91, 102)");
+
+ await page.locator(".theme .toggle").click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ await expect(page.locator("body")).toHaveCSS(
+ "background-color",
+ "rgb(11, 19, 26)",
+ );
+ // Source highlighting reacts to theme change.
+ await expect(page.getByText("() {")).toHaveCSS("color", "rgb(192, 197, 206)");
+});
+
+test("change code font", async ({ page }) => {
+ await page.goto(sourceBrowsingFixture);
+
+ page.locator('button[name="Settings"]').click();
+ await page.getByText("Code font").click();
+
+ await page.getByText("System").click();
+ await expect(page.getByText("System")).toHaveClass(/active/);
+ await expect(page.locator("html")).toHaveAttribute("data-codefont", "system");
+
+ await page.getByText("JetBrains Mono").click();
+ await expect(page.getByText("JetBrains Mono")).toHaveClass(/active/);
+ await expect(page.locator("html")).toHaveAttribute(
+ "data-codefont",
+ "jetbrains",
+ );
+});
diff --git a/tests/fixtures/repos/source-browsing.tar.bz2 b/tests/fixtures/repos/source-browsing.tar.bz2
new file mode 100644
index 000000000..52606baad
Binary files /dev/null and b/tests/fixtures/repos/source-browsing.tar.bz2 differ
diff --git a/tests/fixtures/seeds/palm.tar.bz2 b/tests/fixtures/seeds/palm.tar.bz2
new file mode 100644
index 000000000..b77ccf37c
Binary files /dev/null and b/tests/fixtures/seeds/palm.tar.bz2 differ
diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts
new file mode 100644
index 000000000..45e78187c
--- /dev/null
+++ b/tests/support/fixtures.ts
@@ -0,0 +1,151 @@
+import type * as Stream from "node:stream";
+
+import * as Fs from "node:fs/promises";
+import * as Path from "node:path";
+import { test as base, expect } from "@playwright/test";
+
+import * as logLabel from "@tests/support/logLabel.js";
+
+export { expect };
+
+export const test = base.extend<{
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+ forAllTests: void;
+ customAppConfig: boolean;
+ stateDir: string;
+ outputLog: Stream.Writable;
+}>({
+ customAppConfig: [false, { option: true }],
+
+ forAllTests: [
+ async ({ customAppConfig, outputLog, page }, use) => {
+ const browserLabel = logLabel.make("browser");
+ page.on("console", msg => {
+ // Ignore common console logs that we don't care about.
+ if (
+ msg
+ .text()
+ .startsWith(
+ `Module "buffer" has been externalized for browser compatibility.`,
+ ) ||
+ msg.text().startsWith("[vite] connected.") ||
+ msg.text().startsWith("[vite] connecting...") ||
+ msg
+ .text()
+ .includes("Please make sure it wasn't preloaded for nothing.")
+ ) {
+ return;
+ }
+ log(msg.text(), browserLabel, outputLog);
+ });
+
+ page.on("pageerror", msg => {
+ expect(
+ false,
+ `Test failed because there was a console error in the app: ${msg}`,
+ ).toBeTruthy();
+ });
+
+ if (!customAppConfig) {
+ // Remember: `page.addInitScript()` is run in the browser which
+ // is completely isolated from the test environment, so we don't have
+ // access to any variables that we have in the test.
+ await page.addInitScript(() => {
+ window.APP_CONFIG = {
+ walletConnect: {
+ bridge: "https://radicle.bridge.walletconnect.org",
+ },
+ reactions: [],
+ seeds: {
+ pinned: [{ host: "0.0.0.0", emoji: "🚀" }],
+ },
+ projects: { pinned: [] },
+ };
+ });
+ }
+
+ const playwrightLabel = logLabel.make("playwright");
+ await page.route("**/*", route => {
+ if (
+ route.request().url().startsWith("http://127.0.0.1") ||
+ route.request().url().startsWith("http://localhost") ||
+ route.request().url().startsWith("http://0.0.0.0")
+ ) {
+ return route.continue();
+ } else if (
+ route
+ .request()
+ .url()
+ .startsWith("https://www.gravatar.com/avatar/") ||
+ route.request().url().endsWith(".png")
+ ) {
+ route.fulfill({
+ status: 200,
+ path: "./public/favicon.ico",
+ });
+ } else {
+ log(
+ `Aborted remote request: ${route.request().url()}`,
+ playwrightLabel,
+ outputLog,
+ );
+ return route.abort();
+ }
+ });
+
+ page.on("websocket", ws => {
+ log(`WebSocket opened: ${ws.url()}`, playwrightLabel, outputLog);
+ ws.on("framesent", event =>
+ log(
+ `WebSocket framesent: ${event.payload}`,
+ playwrightLabel,
+ outputLog,
+ ),
+ );
+ ws.on("framereceived", event =>
+ log(
+ `WebSocket framereceived: ${event.payload}`,
+ playwrightLabel,
+ outputLog,
+ ),
+ );
+ ws.on("close", () =>
+ log(`WebSocket closed`, playwrightLabel, outputLog),
+ );
+ });
+
+ await use();
+ },
+ { scope: "test", auto: true },
+ ],
+
+ outputLog: async ({ stateDir }, use) => {
+ const logFile = await Fs.open(Path.join(stateDir, "test.log"), "a");
+ await use(logFile.createWriteStream());
+ await logFile.close();
+ },
+
+ // eslint-disable-next-line no-empty-pattern
+ stateDir: async ({}, use, testInfo) => {
+ const stateDir = testInfo.outputDir;
+ await Fs.rm(stateDir, { recursive: true, force: true });
+ await Fs.mkdir(stateDir, { recursive: true });
+
+ await use(stateDir);
+ if (process.env.CI && testInfo.status === "passed") {
+ await Fs.rm(stateDir, { recursive: true });
+ }
+ },
+});
+
+function log(text: string, label: string, outputLog: Stream.Writable) {
+ const output = text
+ .split("\n")
+ .map(line => `${label}${line}`)
+ .join("\n");
+
+ outputLog.write(`${output}\n`);
+ if (!process.env.CI) {
+ console.log(output);
+ }
+}
diff --git a/tests/support/globalSetup.ts b/tests/support/globalSetup.ts
new file mode 100644
index 000000000..446890cd8
--- /dev/null
+++ b/tests/support/globalSetup.ts
@@ -0,0 +1,33 @@
+import type { FullConfig } from "@playwright/test";
+
+export default async function globalSetup(_config: FullConfig): Promise {
+ assertHttpApiRunning();
+}
+
+// Assert that the test http-api is running. If it is not running, throw an
+// error that explains how to run it.
+async function assertHttpApiRunning(): Promise {
+ const palmTestFixtureSeedId =
+ "hyb6i8oggc3mgra9siy8yuohhtz34r98pcybja97c9o789wpsg6nn4";
+
+ const notRunningMessage =
+ "The http-api server with test fixtures needs to be running.\n" +
+ "👉 You can start it with `./scripts/run-http-api-with-fixtures`\n";
+
+ let peerId: string | undefined = undefined;
+
+ try {
+ const response = await fetch("http://0.0.0.0:8777");
+ const data = await response.json();
+ peerId = data.peer.id;
+ } catch (err) {
+ console.error(err);
+ throw new Error(notRunningMessage);
+ }
+
+ if (peerId !== palmTestFixtureSeedId) {
+ const wrongSeedMessage =
+ "The server on port 8777 doesn't have the right fixtures.\n";
+ throw new Error(wrongSeedMessage + notRunningMessage);
+ }
+}
diff --git a/tests/support/logLabel.ts b/tests/support/logLabel.ts
new file mode 100644
index 000000000..d4f0a2672
--- /dev/null
+++ b/tests/support/logLabel.ts
@@ -0,0 +1,42 @@
+import type { ColorName } from "chalk";
+
+import chalk from "chalk";
+
+const PADDING_WIDTH = 12;
+
+// The order here is important, we want successive prefixes to have
+// high contrast.
+const availableColors: ColorName[] = [
+ "blue",
+ "yellowBright",
+ "greenBright",
+ "gray",
+ "green",
+ "blueBright",
+ "redBright",
+ "white",
+ "yellow",
+ "red",
+ "magenta",
+ "cyan",
+];
+
+const assignedColors: Record = {};
+
+export function make(label: string): string {
+ if (assignedColors[label] === undefined) {
+ const color = availableColors.pop();
+ if (!color) {
+ throw new Error("We're out of colors. 🤷");
+ }
+
+ assignedColors[label] = color;
+ }
+
+ // We reset colors at the beginning of each line to avoid styles from previous
+ // lines messing up prefix colors. This is noticable in rust stack traces
+ // where the `in` and `with` keywords have a white background color.
+ return chalk.reset[assignedColors[label]](
+ `${label.padEnd(PADDING_WIDTH)} | `,
+ );
+}
diff --git a/tests/support/router.ts b/tests/support/router.ts
new file mode 100644
index 000000000..fe0101c28
--- /dev/null
+++ b/tests/support/router.ts
@@ -0,0 +1,21 @@
+import type { Page } from "@playwright/test";
+import { expect } from "@tests/support/fixtures.js";
+
+// Reloads the current page and verifies that the URL stays correct
+export const expectUrlPersistsReload = async (page: Page) => {
+ const url = page.url();
+ await page.reload();
+ await expect(page).toHaveURL(url);
+};
+
+// Navigates back, checks the URL and navigates forward back to the initial page
+export const expectBackAndForwardNavigationWorks = async (
+ beforeURL: string,
+ page: Page,
+) => {
+ const currentURL = page.url();
+ await page.goBack();
+ await expect(page).toHaveURL(beforeURL);
+ await page.goForward();
+ await expect(page).toHaveURL(currentURL);
+};
diff --git a/tests/tmp/.gitkeep b/tests/tmp/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/cache.test.ts b/tests/unit/cache.test.ts
similarity index 100%
rename from src/cache.test.ts
rename to tests/unit/cache.test.ts
diff --git a/src/utils.test.ts b/tests/unit/utils.test.ts
similarity index 99%
rename from src/utils.test.ts
rename to tests/unit/utils.test.ts
index 7932d6780..ee8a1e9e7 100644
--- a/src/utils.test.ts
+++ b/tests/unit/utils.test.ts
@@ -2,7 +2,7 @@ import type { Wallet } from "@app/wallet";
import { BigNumber } from "ethers";
import { describe, expect, test } from "vitest";
-import * as utils from "./utils";
+import * as utils from "@app/utils";
describe("Conversions", () => {
test("toWei", () => {
diff --git a/tsconfig.json b/tsconfig.json
index fbaf728c0..69429cfcf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,11 +1,11 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
- "include": ["src", "cypress/support"],
+ "include": ["src", "tests"],
"exclude": ["node_modules/*"],
"compilerOptions": {
"target": "es2020",
"module": "es2020",
- "types": ["svelte", "vite/client", "cypress"],
+ "types": ["svelte", "vite/client"],
"sourceMap": true,
"baseUrl": "./",
"moduleResolution": "node",
@@ -18,7 +18,8 @@
"skipLibCheck": true,
"paths": {
"@public/*": ["./public/*"],
- "@app/*": ["./src/*"]
+ "@app/*": ["./src/*"],
+ "@tests/*": ["./tests/*"]
}
},
"noEmit": true
diff --git a/vite.config.ts b/vite.config.ts
index 759a88093..f827987ec 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,10 +1,27 @@
-///
-import type { UserConfig } from "vite";
+///
+
import path from "path";
import pluginRewriteAll from "vite-plugin-rewrite-all";
+import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
-const config: UserConfig = {
+function defineConstants() {
+ const constants = {
+ VITEST: process.env.VITEST !== undefined,
+ PLAYWRIGHT: process.env.PLAYWRIGHT_TEST_BASE_URL !== undefined,
+ };
+
+ // Don't overwrite HASH_ROUTING in Playwright tests, so we can control it
+ // from within the tests.
+ if (process.env.PLAYWRIGHT_TEST_BASE_URL !== undefined) {
+ return constants;
+ } else {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ return { ...constants, HASH_ROUTING: Boolean(process.env.HASH_ROUTING) };
+ }
+}
+
+export default defineConfig({
optimizeDeps: {
exclude: ["@pedrouid/environment", "@pedrouid/iso-crypto"],
},
@@ -14,7 +31,7 @@ const config: UserConfig = {
},
setupFiles: "./vitest/setupVitest",
environment: "happy-dom",
- include: ["**/*.test.ts"],
+ include: ["tests/unit/**/*.test.ts"],
reporters: "verbose",
},
plugins: [
@@ -37,13 +54,6 @@ const config: UserConfig = {
"@app": path.resolve("./src"),
},
},
- define: {
- "process.env": {
- // eslint-disable-next-line @typescript-eslint/naming-convention
- READABLE_STREAM: "disable",
- hashRouting: Boolean(process.env.HASH_ROUTING),
- },
- },
build: {
outDir: "build",
rollupOptions: {
@@ -58,11 +68,6 @@ const config: UserConfig = {
},
},
},
-};
-// For Vitest to work we need to unset READABLE_STREAM.
-if (process.env.VITEST || process.env.Cypress) {
- config.define = undefined;
-}
-
-export default config;
+ define: defineConstants(),
+});