Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Rewrite e2e tests to use the new hexagonal architecture, also removes fixtures
Sebastian Martinez committed 1 year ago
commit f17cd72da61e68b99821492e53186f8ce4d7bea2
parent a2afd124d09f6a884efc8ce3dc21b809fa1a3f9f
33 files changed +486 -824
modified package.json
@@ -15,7 +15,7 @@
    "check-js": "scripts/check-js",
    "check-rs": "scripts/check-rs",
    "test:unit": "TZ='UTC' vitest run",
-
    "test:e2e": "TZ='UTC' playwright test",
+
    "test:e2e": "TZ='UTC' cargo build --manifest-path ./crates/test-http-api/Cargo.toml && playwright test",
    "format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --write",
    "generate-types": "cargo test --manifest-path ./crates/radicle-types/Cargo.toml",
    "tauri": "npx tauri"
modified src/App.svelte
@@ -32,19 +32,21 @@

  onMount(async () => {
    try {
-
      profile = await invoke<Config>("startup");
+
      profile = await invoke<Config>("load_profile");
+
      await invoke("create_services");
    } catch (err) {
      startup.error = err as ErrorWrapper;
      return;
    }

    if (window.__TAURI_INTERNALS__) {
+
      await invoke("create_event_emitters");
      [unlistenEvents, unlistenNodeEvents, unlistenSyncStatus] =
        await createEventEmittersOnce();
    }

    try {
-
      await invoke("authenticate");
+
      await invoke("check_agent");
      void router.loadFromLocation();
      dynamicInterval(
        "auth",
modified src/components/Button.svelte
@@ -3,6 +3,7 @@

  interface Props {
    children: Snippet;
+
    ariaLabel?: string;
    variant: "primary" | "secondary" | "ghost" | "success" | "danger";
    onclick?: () => void;
    disabled?: boolean;
@@ -15,6 +16,7 @@
  const {
    children,
    variant,
+
    ariaLabel,
    onclick = undefined,
    disabled = false,
    active = false,
@@ -367,6 +369,7 @@
  style:cursor={!disabled ? "pointer" : "default"}
  class:disabled
  class:active
+
  aria-label={ariaLabel}
  class:flat-right={flatRight}
  class:flat-left={flatLeft}
  onclick={!disabled ? onclick : undefined}
modified src/components/Comment.svelte
@@ -20,6 +20,7 @@

  interface Props {
    actions?: Snippet;
+
    ariaLabels?: { edit: string };
    beforeTimestamp?: Snippet;
    id?: string;
    rid: string;
@@ -40,6 +41,7 @@
  /* eslint-disable prefer-const */
  let {
    actions,
+
    ariaLabels,
    beforeTimestamp,
    id,
    rid,
@@ -153,7 +155,10 @@
      <div class="header-right">
        {#if id && editComment}
          <div class="edit-buttons">
-
            <Icon name="pen" onclick={toggleEdit} />
+
            <Icon
+
              ariaLabel={ariaLabels?.edit}
+
              name="pen"
+
              onclick={toggleEdit} />
          </div>
        {/if}
        {#if id && reactions && reactOnComment}
modified src/components/CopyableId.svelte
@@ -2,7 +2,7 @@
  import type { ComponentProps } from "svelte";

  import debounce from "lodash/debounce";
-
  import { writeText } from "@tauri-apps/plugin-clipboard-manager";
+
  import { writeToClipboard } from "@app/lib/invoke";

  import Icon from "./Icon.svelte";

@@ -19,7 +19,7 @@
  }, 1000);

  async function copy() {
-
    await writeText(id);
+
    await writeToClipboard(id);
    icon = "checkmark";
    restoreIcon();
  }
modified src/components/ExtendedTextarea.svelte
@@ -183,11 +183,17 @@
  }

  function selectFiles() {
-
    void open({ multiple: true }).then(paths => {
-
      if (paths) {
-
        void attachEmbedsByPaths(paths);
-
      }
-
    });
+
    if (window.__TAURI_INTERNALS__) {
+
      void open({ multiple: true }).then(paths => {
+
        if (paths) {
+
          void attachEmbedsByPaths(paths);
+
        }
+
      });
+
    } else {
+
      console.warn(
+
        "Attaching files with file dialog isn't supported in the browser yet.",
+
      );
+
    }
  }

  function submitFn() {
modified src/components/Icon.svelte
@@ -7,6 +7,7 @@
    disabled?: boolean;
    styleDisplay?: string;
    styleVerticalAlign?: string;
+
    ariaLabel?: string;
    name:
      | "arrow-left"
      | "arrow-right"
@@ -70,6 +71,7 @@
  const {
    size = "16",
    onclick = undefined,
+
    ariaLabel = undefined,
    name,
    disabled = false,
    styleDisplay = "flex",
@@ -109,7 +111,7 @@
      onclick(e);
    }
  }}
-
  aria-label={`icon-${name}`}
+
  aria-label={ariaLabel ?? `icon-${name}`}
  width={size}
  height={size}
  fill="currentColor"
modified src/components/IssueStateBadge.svelte
@@ -12,6 +12,7 @@
</script>

<div
+
  aria-label="issue-state"
  class="global-counter txt-small"
  style:width="fit-content"
  style:color="var(--color-foreground-match-background)"
modified src/components/IssueStateButton.svelte
@@ -60,7 +60,11 @@
    popoverPositionTop="2.5rem"
    popoverPositionRight="0">
    {#snippet toggle(onclick)}
-
      <Button flatLeft {onclick} variant="secondary">
+
      <Button
+
        ariaLabel="toggle-issue-state"
+
        flatLeft
+
        {onclick}
+
        variant="secondary">
        <Icon name="chevron-down" />
      </Button>
    {/snippet}
modified src/components/PatchStateBadge.svelte
@@ -12,6 +12,7 @@
</script>

<div
+
  aria-label="patch-state"
  class="global-counter txt-small"
  style:width="fit-content"
  style:color="var(--color-foreground-match-background)"
modified src/components/PatchStateButton.svelte
@@ -61,7 +61,11 @@
    popoverPositionTop="2.5rem"
    popoverPositionRight="0">
    {#snippet toggle(onclick)}
-
      <Button flatLeft {onclick} variant="secondary">
+
      <Button
+
        ariaLabel="toggle-patch-state"
+
        flatLeft
+
        {onclick}
+
        variant="secondary">
        <Icon name="chevron-down" />
      </Button>
    {/snippet}
modified src/components/ReactionSelector.svelte
@@ -53,7 +53,7 @@
  {popoverPositionLeft}
  popoverPadding="0">
  {#snippet toggle(onclick)}
-
    <Icon name="face" {onclick} />
+
    <Icon ariaLabel="toggle-reaction-selector" name="face" {onclick} />
  {/snippet}
  {#snippet popover()}
    <Border variant="ghost">
@@ -63,6 +63,7 @@
            ({ emoji }) => emoji === reaction,
          )}
          <button
+
            aria-label={`reaction-selector-${reaction}`}
            use:twemoji={{ exclude: ["21a9"] }}
            class:active={Boolean(lookedUpReaction)}
            onclick={() =>
modified src/components/Revision.svelte
@@ -202,6 +202,7 @@
  <CommentComponent
    caption={revision.id === patchId ? "opened patch" : "created revision"}
    {rid}
+
    ariaLabels={{ edit: "edit-revision-description" }}
    id={revision.id}
    lastEdit={revision.description.length > 1
      ? revision.description.at(-1)
modified src/components/TextInput.svelte
@@ -21,6 +21,7 @@
    type?: string;
    valid?: boolean;
    value?: string;
+
    ariaLabel?: string;
  }

  /* eslint-disable prefer-const */
@@ -28,6 +29,7 @@
    autofocus = false,
    autoselect = false,
    disabled = false,
+
    ariaLabel,
    keyShortcuts,
    left,
    name,
@@ -112,6 +114,7 @@
    onblur={() => {
      focussed = false;
    }}
+
    aria-label={ariaLabel}
    bind:this={inputElement}
    {type}
    {name}
modified src/components/Thread.svelte
@@ -104,6 +104,7 @@
        <CommentComponent
          disallowEmptyBody
          {rid}
+
          ariaLabels={{ edit: "edit-reply-comment" }}
          lastEdit={reply.edits.length > 1 ? reply.edits.at(-1) : undefined}
          id={reply.id}
          author={reply.author}
@@ -147,6 +148,7 @@
    <CommentComponent
      disallowEmptyBody
      {rid}
+
      ariaLabels={{ edit: "edit-top-level-comment" }}
      id={root.id}
      lastEdit={root.edits.length > 1 ? root.edits.at(-1) : undefined}
      author={root.author}
@@ -159,7 +161,10 @@
      reactOnComment={reactOnComment && partial(reactOnComment, root.id)}>
      {#snippet actions()}
        {#if createReply}
-
          <Icon name="reply" onclick={toggleReply} />
+
          <Icon
+
            ariaLabel="create-top-level-reply"
+
            name="reply"
+
            onclick={toggleReply} />
        {/if}
      {/snippet}
    </CommentComponent>
modified src/lib/auth.svelte.ts
@@ -15,7 +15,7 @@ export async function checkAuth() {
      return;
    }
    lock = true;
-
    await invoke("authenticate", { passphrase: "" });
+
    await invoke("check_agent");
    dynamicInterval(
      "auth",
      checkAuth,
modified src/lib/invoke.ts
@@ -27,7 +27,8 @@ async function withTestBackend<T>(
  if (window.__TAURI_INTERNALS__) {
    return fn(cmd, args, options);
  } else {
-
    return fetch(`http://127.0.0.1:8081/${cmd}`, {
+
    const port = localStorage.getItem("TEST_HTTP_API_PORT") || "8081";
+
    return fetch(`http://127.0.0.1:${port}/${cmd}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(args),
modified src/views/booting/CreateIdentity.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
  import type { ErrorWrapper } from "@bindings/error/ErrorWrapper";

+
  import { SvelteMap } from "svelte/reactivity";
  import * as router from "@app/lib/router";
  import { createEventEmittersOnce } from "@app/lib/startup.svelte";
  import { invoke } from "@app/lib/invoke";
@@ -15,10 +16,7 @@
  let notMatchingPassphrases = $state<boolean>();
  let passphraseRepeat = $state("");
  let alias = $state("");
-
  const errors: { alias: ErrorWrapper[]; passphrase: ErrorWrapper[] } = {
-
    alias: [],
-
    passphrase: [],
-
  };
+
  const errors: SvelteMap<string, ErrorWrapper[]> = new SvelteMap();

  const validatePassphraseRepeat = debounce(() => {
    if (passphrase !== passphraseRepeat && passphraseRepeat.length !== 0) {
@@ -27,17 +25,33 @@
  }, 400);

  function validateInput(field: "alias" | "passphrase") {
-
    if (field === "alias" && alias.length === 0) {
-
      errors.alias.push({ code: "AliasError.EmptyAlias" });
-
    }
-
    if (field === "alias" && alias.length > 32) {
-
      errors.alias.push({ code: "AliasError.TooLongAlias" });
-
    }
-
    if (field === "alias" && alias.includes(" ")) {
-
      errors.alias.push({ code: "AliasError.InvalidAlias" });
+
    if (field === "alias") {
+
      const existingAliasErrors = errors.get("alias");
+
      if (alias.length === 0) {
+
        errors.set("alias", [
+
          ...(existingAliasErrors || []),
+
          { code: "AliasError.EmptyAlias" },
+
        ]);
+
      }
+
      if (alias.length > 32) {
+
        errors.set("alias", [
+
          ...(existingAliasErrors || []),
+
          { code: "AliasError.TooLongAlias" },
+
        ]);
+
      }
+
      if (alias.includes(" ")) {
+
        errors.set("alias", [
+
          ...(existingAliasErrors || []),
+
          { code: "AliasError.InvalidAlias" },
+
        ]);
+
      }
    }
    if (field === "passphrase" && passphrase.length === 0) {
-
      errors.passphrase.push({ code: "PassphraseError.InvalidPassphrase" });
+
      const existingPassphraseErrors = errors.get("passphrase");
+
      errors.set("alias", [
+
        ...(existingPassphraseErrors || []),
+
        { code: "PassphraseError.InvalidPassphrase" },
+
      ]);
    }
  }

@@ -56,23 +70,23 @@
    }
    try {
      await invoke("init", { passphrase, alias });
-
      await invoke("startup");
-
      await invoke("authenticate", { passphrase });
-
      // Clearing the passphrases from memory.
-
      passphrase = "";
-
      passphraseRepeat = "";
-

+
      await invoke("load_profile");
+
      await invoke("create_services");
      if (window.__TAURI_INTERNALS__) {
+
        await invoke("create_event_emitters");
        await createEventEmittersOnce();
      }
+
      // Clearing the passphrases from memory.
+
      passphrase = "";
+
      passphraseRepeat = "";

      void router.loadFromLocation();
    } catch (err) {
      const e = err as ErrorWrapper;
      if (e.code.startsWith("AliasError")) {
-
        errors.alias = [e];
+
        errors.set("alias", [e]);
      } else if (e.code.startsWith("PassphraseError")) {
-
        errors.passphrase = [e];
+
        errors.set("passphrase", [e]);
      }
      console.error(err);
    }
@@ -128,7 +142,7 @@
        autofocus
        onSubmit={handleKeydown}
        oninput={() => {
-
          errors.alias = [];
+
          errors.set("alias", []);
          if (alias.length > 0) {
            validateInput("alias");
          }
@@ -136,8 +150,8 @@
        placeholder="Enter desired alias"
        type="text"
        bind:value={alias}></TextInput>
-
      {#if errors.alias.some(e => e.code.startsWith("AliasError"))}
-
        {#each errors.alias as error}
+
      {#if errors.get("alias")?.some(e => e.code.startsWith("AliasError"))}
+
        {#each errors.get("alias") || [] as error}
          <div
            style="color: var(--color-foreground-red);"
            class="hint txt-small global-flex">
@@ -163,7 +177,7 @@
        <TextInput
          onSubmit={handleKeydown}
          oninput={() => {
-
            errors.passphrase = [];
+
            errors.set("passphrase", []);
            notMatchingPassphrases = false;
            if (passphrase.length > 0) {
              validateInput("passphrase");
@@ -172,8 +186,10 @@
          placeholder="Enter passphrase to protect your keys"
          type="password"
          bind:value={passphrase}></TextInput>
-
        {#if errors.passphrase.some(e => e.code.startsWith("PassphraseError"))}
-
          {#each errors.passphrase as error}
+
        {#if errors
+
          .get("passphrase")
+
          ?.some(e => e.code.startsWith("PassphraseError"))}
+
          {#each errors.get("passphrase") || [] as error}
            <div
              style="color: var(--color-foreground-red);"
              class="hint txt-small global-flex">
@@ -191,7 +207,7 @@
        <TextInput
          onSubmit={handleKeydown}
          oninput={() => {
-
            errors.passphrase = [];
+
            errors.set("passphase", []);
            notMatchingPassphrases = false;
            validatePassphraseRepeat();
          }}
modified src/views/repo/Issue.svelte
@@ -387,6 +387,7 @@
            {/if}
          </div>
          <TextInput
+
            placeholder="Issue title"
            valid={updatedTitle.trim().length > 0}
            bind:value={updatedTitle}
            autofocus
@@ -401,6 +402,7 @@
            }} />
          <div class="title-icons">
            <Icon
+
              ariaLabel="save-new-title"
              name="checkmark"
              onclick={async () => {
                if (updatedTitle.trim().length > 0) {
@@ -408,6 +410,7 @@
                }
              }} />
            <Icon
+
              ariaLabel="discard-new-title"
              name="cross"
              onclick={() => {
                updatedTitle = issue.title;
@@ -435,7 +438,10 @@
          </div>
          {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), issue.body.author.did, )}
            <div class="title-icons">
-
              <Icon name="pen" onclick={() => (editingTitle = !editingTitle)} />
+
              <Icon
+
                ariaLabel="edit-title"
+
                name="pen"
+
                onclick={() => (editingTitle = !editingTitle)} />
              <IssueStateButton issueState={issue.state} save={saveState} />
            </div>
          {/if}
modified src/views/repo/Patch.svelte
@@ -571,6 +571,7 @@
            <TextInput
              valid={updatedTitle.trim().length > 0}
              bind:value={updatedTitle}
+
              ariaLabel="patch-title"
              autofocus
              onSubmit={async () => {
                if (updatedTitle.trim().length > 0) {
@@ -583,6 +584,7 @@
              }} />
            <div class="title-icons">
              <Icon
+
                ariaLabel="save-new-title"
                name="checkmark"
                onclick={async () => {
                  if (updatedTitle.trim().length > 0) {
@@ -590,6 +592,7 @@
                  }
                }} />
              <Icon
+
                ariaLabel="discard-new-title"
                name="cross"
                onclick={() => {
                  updatedTitle = patch.title;
@@ -617,6 +620,7 @@
            {#if roles.isDelegateOrAuthor( config.publicKey, repo.delegates.map(delegate => delegate.did), patch.author.did, )}
              <div class="title-icons">
                <Icon
+
                  ariaLabel="edit-patch-title"
                  name="pen"
                  onclick={() => (editingTitle = !editingTitle)} />
                <PatchStateButton patchState={patch.state} save={saveState} />
modified tests/e2e/clipboard.spec.ts
@@ -1,29 +1,48 @@
import { chromium } from "playwright";

-
import { expect, markdownRid, test } from "@tests/support/fixtures.js";
-
import { formatRepositoryId } from "@app/lib/utils";
+
import { defaultHttpdPort, expect, test } from "@tests/support/fixtures.js";

// 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 () => {
+
test("copy to clipboard", async ({ peer }) => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  await context.grantPermissions(["clipboard-read", "clipboard-write"]);
  const page = await context.newPage();

-
  await page.goto("/repos");
+
  await page.addInitScript(
+
    port => localStorage.setItem("TEST_HTTP_API_PORT", port.toString()),
+
    peer.httpdBaseUrl?.port || defaultHttpdPort,
+
  );
+

+
  await page.goto("/");
+
  await page.getByPlaceholder("Enter desired alias").fill("palm");
+
  await page.getByPlaceholder("Enter passphrase to protect").fill("asdf");
+
  await page.getByPlaceholder("Repeat passphrase").fill("asdf");
+
  await page
+
    .getByRole("button", { name: "icon-seedling Create new identity" })
+
    .click();
+
  await expect(
+
    page.getByRole("button", {
+
      name: "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S icon-copy",
+
    }),
+
  ).toBeVisible();

  // Reset system clipboard to a known state.
  await page.evaluate<string>("navigator.clipboard.writeText('')");

  // Repo ID.
  {
-
    await page.getByText(formatRepositoryId(markdownRid)).click();
+
    await page
+
      .getByText("z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S")
+
      .click();
    const clipboardContent = await page.evaluate<string>(
      "navigator.clipboard.readText()",
    );
-
    expect(clipboardContent).toBe(markdownRid);
+
    expect(clipboardContent).toBe(
+
      "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S",
+
    );
  }

  // Clear the system clipboard contents so developers don't wonder why there's
added tests/e2e/issues.spec.ts
@@ -0,0 +1,108 @@
+
import { expect, playgroundRid, test } from "@tests/support/fixtures.js";
+

+
test("create and interact with issues", async ({
+
  authenticatedContext: page,
+
}) => {
+
  await test.step("create a new repo", async () => {
+
    await page
+
      .getByRole("button", { name: "Create a new repo", exact: true })
+
      .click();
+
    await page.getByPlaceholder("Name of your repo").fill("playground");
+
    await page.getByPlaceholder("Add description").fill("Lorem ipsum dolor");
+
    await page
+
      .getByRole("button", { name: "Create new repo", exact: true })
+
      .click();
+
    await page.getByRole("button", { name: "p playground" }).click();
+
    await expect(page.getByText(playgroundRid)).toBeVisible();
+
  });
+

+
  await test.step("create a new issue", async () => {
+
    await page.getByRole("button", { name: "icon-plus New" }).click();
+
    await page.getByPlaceholder("Title").fill("Add missing issue");
+
    await page.getByPlaceholder("Description").fill(
+
      `Lorem ipsum dolor sit amet, consetetur sadipscing elitr,
+
 sed diam nonumy eirmod tempor invidunt ut labore et dolore magna
+
 aliquyam erat, sed diam voluptua.`,
+
    );
+
    await page.getByRole("button", { name: "icon-checkmark Save" }).click();
+
    await expect(
+
      page.getByText("c5f47493484e4b1696bfa0bdad21ce2ae439e4f0"),
+
    ).toBeVisible();
+
  });
+

+
  await test.step("edit created issue", async () => {
+
    await page.getByLabel("edit-title").click();
+
    await page.getByPlaceholder("Issue title").fill("Add another issue");
+
    await page.getByLabel("save-new-title").click();
+
  });
+

+
  await test.step("change issue lifecycle", async () => {
+
    await page.getByText("Close as solved").click();
+
    await expect(page.getByText("Reopen")).toBeVisible();
+
    await page.getByLabel("toggle-issue-state").click();
+
    await page.getByRole("button", { name: "Reopen" }).last().click();
+
    await page.getByText("Reopen").first().click();
+
    await expect(
+
      page
+
        .getByLabel("issue-state", { exact: true })
+
        .filter({ hasText: "Open" }),
+
    ).toBeVisible();
+
  });
+

+
  await test.step("create a top level comment", async () => {
+
    await page.getByRole("button", { name: "icon-comment Comment" }).click();
+
    await expect(page.getByPlaceholder("Leave a comment")).toBeVisible();
+
    await page.getByPlaceholder("Leave a comment").fill("Lorem ipsum dolor.");
+
    await page.getByRole("button", { name: "icon-checkmark Comment" }).click();
+
    await expect(page.getByText("Lorem ipsum dolor.")).toBeVisible();
+
    await expect(
+
      page.getByRole("button", { name: "icon-checkmark Comment" }),
+
    ).toBeHidden();
+
  });
+

+
  await test.step("create a reply comment", async () => {
+
    await page.getByLabel("create-top-level-reply").click();
+
    await page
+
      .getByPlaceholder("Reply to comment")
+
      .fill("This is a reply comment");
+
    await page.getByRole("button", { name: "icon-checkmark Reply" }).click();
+
    await expect(
+
      page.getByRole("button", { name: "icon-checkmark Reply" }),
+
    ).toBeHidden();
+
  });
+

+
  await test.step("edit top level comment", async () => {
+
    await page.pause();
+
    await page.getByLabel("edit-top-level-comment").click();
+
    await page
+
      .getByPlaceholder("Leave a comment")
+
      .fill("Lorem ipsum dolor sit anem.");
+
    await page.getByRole("button", { name: "icon-checkmark Save" }).click();
+
    await expect(
+
      page.getByRole("button", { name: "icon-checkmark Save" }),
+
    ).toBeHidden();
+
  });
+

+
  await test.step("edit reply comment", async () => {
+
    await page.getByLabel("edit-reply-comment").click();
+
    await page
+
      .getByPlaceholder("Leave a comment")
+
      .last()
+
      .fill("This maybe is a reply comment.");
+
    await page.getByRole("button", { name: "icon-checkmark Save" }).click();
+
    await expect(
+
      page.getByRole("button", { name: "icon-checkmark Save" }),
+
    ).toBeHidden();
+
  });
+

+
  await test.step("react to a top level comment", async () => {
+
    await page.getByLabel("toggle-reaction-selector").nth(1).click();
+
    await page.getByLabel("reaction-selector-👍").click();
+
  });
+

+
  // This is not working yet, due to the reaction selector being hidden in the reply comment.
+
  // await test.step("react to a reply comment", async () => {
+
  //   await page.getByLabel("icon-face").nth(3).click();
+
  //   await page.getByRole("button", { name: "🎉", exact: true }).click();
+
  // });
+
});
added tests/e2e/onboarding.spec.ts
@@ -0,0 +1,37 @@
+
import { expect, test } from "@tests/support/fixtures.js";
+

+
test("create a new identity", async ({ page }) => {
+
  await page.goto("/");
+
  await page.getByPlaceholder("Enter desired alias").fill("palm");
+
  await page.getByPlaceholder("Enter passphrase to protect").fill("asdf");
+
  await page.getByPlaceholder("Repeat passphrase").fill("asdf");
+
  await page
+
    .getByRole("button", { name: "icon-seedling Create new identity" })
+
    .click();
+
  await expect(
+
    page.getByRole("button", {
+
      name: "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S icon-copy",
+
    }),
+
  ).toBeVisible();
+
});
+

+
test("validate new identity inputs", async ({ page }) => {
+
  await page.goto("/");
+
  await page.getByPlaceholder("Enter desired alias").fill("hello world");
+
  await expect(
+
    page.getByText("Alias cannot contain whitespace."),
+
  ).toBeVisible();
+

+
  await page.getByPlaceholder("Enter desired alias").fill("a".repeat(33));
+
  await expect(
+
    page.getByText("Alias is too long, make it less than 32 characters."),
+
  ).toBeVisible();
+

+
  await page.getByPlaceholder("Enter passphrase to protect").fill("asdf");
+
  await page.getByPlaceholder("Repeat passphrase").fill("asdfe");
+
  await expect(page.getByText("Passphrases don't match")).toBeVisible();
+

+
  await expect(
+
    page.getByRole("button", { name: "icon-seedling Create new identity" }),
+
  ).toHaveClass(/disabled/);
+
});
added tests/e2e/patches.spec.ts
@@ -0,0 +1,95 @@
+
import { expect, playgroundRid, test } from "@tests/support/fixtures.js";
+
import * as Fs from "node:fs/promises";
+
import * as Path from "node:path";
+

+
test("create and interact with patches", async ({
+
  authenticatedContext: page,
+
  peer,
+
}) => {
+
  const repoCheckout = Path.resolve(peer.checkoutPath, "playground");
+
  await test.step("create a new repo", async () => {
+
    await page
+
      .getByRole("button", { name: "Create a new repo", exact: true })
+
      .click();
+
    await page.getByPlaceholder("Name of your repo").fill("playground");
+
    await page.getByPlaceholder("Add description").fill("Lorem ipsum dolor");
+
    await page
+
      .getByRole("button", { name: "Create new repo", exact: true })
+
      .click();
+
    await page.getByRole("button", { name: "p playground" }).click();
+
    await expect(page.getByText(playgroundRid)).toBeVisible();
+
  });
+

+
  await test.step("create a patch", async () => {
+
    try {
+
      await peer.startNode();
+
      await peer.rad(["clone", playgroundRid], {
+
        cwd: Path.resolve(peer.checkoutPath),
+
      });
+
      await Fs.writeFile(Path.resolve(repoCheckout, "README.md"), "# README");
+
      await peer.git(["switch", "-c", "new-readme"], { cwd: repoCheckout });
+
      await peer.git(["add", "."], { cwd: repoCheckout });
+
      await peer.git(
+
        [
+
          "commit",
+
          "-m",
+
          "Add a README",
+
          "-m",
+
          "There was no README in this repo",
+
        ],
+
        { cwd: repoCheckout },
+
      );
+
      await peer.git(["push", "rad", "HEAD:refs/patches"], {
+
        cwd: repoCheckout,
+
      });
+
    } catch (err) {
+
      console.error("Unable to create a patch");
+
      console.error(err);
+
      process.exit(1);
+
    }
+
  });
+

+
  await test.step("navigate to patch", async () => {
+
    await page.getByRole("link", { name: "icon-patch Patches" }).click();
+
    await page.getByRole("button", { name: "icon-patch Add a README" }).click();
+
    await expect(page.getByText("Add a README").nth(1)).toBeVisible();
+
  });
+

+
  await test.step("edit patch title", async () => {
+
    await page.getByLabel("edit-patch-title").click();
+
    await page.getByLabel("patch-title").fill("Add the first README");
+
    await page.getByLabel("save-new-title").click();
+
    await expect(page.getByText("Add the first README").nth(1)).toBeVisible();
+
  });
+

+
  await test.step("edit first revision", async () => {
+
    await expect(
+
      page.getByText("There was no README in this repo"),
+
    ).toBeVisible();
+
    await page.getByLabel("edit-revision-description").click();
+
    await page
+
      .getByPlaceholder("Leave a comment")
+
      .fill("Now there will be a README");
+
    await page.getByRole("button", { name: "icon-checkmark Save" }).click();
+
  });
+

+
  await test.step("edit lifecycle patch", async () => {
+
    await page.getByLabel("toggle-patch-state").click();
+
    await page.getByRole("button", { name: "Archive", exact: true }).click();
+
    await page.getByRole("button", { name: "Archive" }).click();
+
    await expect(
+
      page
+
        .getByLabel("patch-state", { exact: true })
+
        .filter({ hasText: "Archived" }),
+
    ).toBeVisible();
+

+
    await page.getByRole("button", { name: "Reopen" }).click();
+
    await expect(
+
      page
+
        .getByLabel("patch-state", { exact: true })
+
        .filter({ hasText: "Open" }),
+
    ).toBeVisible();
+
  });
+

+
  await peer.stopNode().catch(() => console.error("Unable to stop the node"));
+
});
deleted tests/e2e/repo/issue.spec.ts
@@ -1,91 +0,0 @@
-
import { test, expect, cobRid } from "@tests/support/fixtures.js";
-

-
test("navigate single issue", async ({ page }) => {
-
  await page.goto(`/repos/${cobRid}/issues?status=all`);
-
  await page.getByText("This title has **markdown**").click();
-

-
  await expect(page).toHaveURL(/\/issues\/[0-9a-f]{40}/);
-
});
-

-
test("correct order of threads", async ({ page }) => {
-
  await page.goto("/repos");
-
  await page.getByRole("button", { name: "cobs" }).click();
-
  await page
-
    .getByRole("button", { name: "This title has **markdown**" })
-
    .click();
-
  const body = page.locator(".issue-body");
-
  await expect(body.getByText("This is a description")).toBeVisible();
-

-
  const topLevelComments = await page.locator(".comments").all();
-
  expect(topLevelComments).toHaveLength(2);
-

-
  const [first, second] = topLevelComments;
-
  await expect(first.getByText("This is a multiline comment")).toBeVisible();
-
  await expect(
-
    first.getByText("This is a reply, to a first comment"),
-
  ).toBeVisible();
-
  await expect(
-
    second.getByText("A root level comment after a reply, for margins sake."),
-
  ).toBeVisible();
-
});
-

-
test("creation of top level comments", async ({ page }) => {
-
  await page.goto("/repos");
-
  await page.getByRole("button", { name: "cobs" }).click();
-
  await page.getByRole("button", { name: "New" }).click();
-
  await page
-
    .getByPlaceholder("Title")
-
    .fill("Make sure that comment creation is working");
-
  await page
-
    .getByPlaceholder("Description")
-
    .fill(
-
      "It's important for us that the comment creation flow works as expected.",
-
    );
-
  await page.getByRole("button", { name: "icon-checkmark" }).click();
-
  await expect(
-
    page.getByText("Make sure that comment creation is working").last(),
-
  ).toBeVisible();
-
  await expect(
-
    page.getByRole("button", { name: "icon-issue Make sure that" }),
-
  ).toBeVisible();
-
  await expect(
-
    page
-
      .getByText(
-
        "It's important for us that the comment creation flow works as expected.",
-
      )
-
      .last(),
-
  ).toBeVisible();
-

-
  await page.getByRole("button", { name: "icon-comment Comment" }).click();
-
  await page
-
    .getByPlaceholder("Leave a comment")
-
    .fill("A top level comment by playwright");
-
  await page.getByRole("button", { name: "icon-checkmark" }).click();
-
  await expect(
-
    page.getByText("A top level comment by playwright"),
-
  ).toBeVisible();
-

-
  await page.getByLabel("icon-reply").first().click();
-
  await page
-
    .getByPlaceholder("Reply to comment")
-
    .fill(
-
      "A top level comment by playwright created by replying to the issue body",
-
    );
-
  await page.getByRole("button", { name: "icon-checkmark" }).click();
-
  await expect(
-
    page.getByText(
-
      "A top level comment by playwright created by replying to the issue body",
-
    ),
-
  ).toBeVisible();
-

-
  await page.getByLabel("icon-reply").click();
-
  await page
-
    .getByPlaceholder("Reply to comment")
-
    .fill("A reply comment by playwright replying to the first comment");
-
  await page.getByRole("button", { name: "icon-checkmark" }).click();
-
  await expect(
-
    page.getByText(
-
      "A reply comment by playwright replying to the first comment",
-
    ),
-
  ).toBeVisible();
-
});
deleted tests/e2e/repo/issues.spec.ts
@@ -1,8 +0,0 @@
-
import { test, cobRid, expect } from "@tests/support/fixtures.js";
-

-
test("navigate issues listing", async ({ page }) => {
-
  await page.goto(`/repos/${cobRid}/issues?show=all`);
-
  await page.getByRole("link", { name: "Closed" }).click();
-
  await expect(page.locator(".issue-teaser")).toHaveCount(2);
-
  await expect(page).toHaveURL(`/repos/${cobRid}/issues?status=closed`);
-
});
deleted tests/e2e/repos.spec.ts
@@ -1,12 +0,0 @@
-
import { expect, test } from "@tests/support/fixtures.js";
-

-
test("navigate to repo issues", async ({ page }) => {
-
  await page.goto("/repos");
-
  await page.getByRole("button", { name: "cobs" }).click();
-
  await page
-
    .getByRole("button", { name: "This title has **markdown**" })
-
    .click();
-
  await expect(
-
    page.getByText("This title has **markdown**").nth(1),
-
  ).toBeVisible();
-
});
modified tests/e2e/theme.spec.ts
@@ -1,14 +1,10 @@
import { test, expect } from "@tests/support/fixtures.js";

-
test("default theme", async ({ page }) => {
-
  await page.goto("/repos");
-

+
test("default theme", async ({ authenticatedContext: page }) => {
  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});

-
test("theme persistence", async ({ page }) => {
-
  await page.goto("/repos");
-
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
+
test("theme persistence", async ({ authenticatedContext: page }) => {
  await page.getByRole("button", { name: "Settings" }).click();

  await page
@@ -17,13 +13,13 @@ test("theme persistence", async ({ page }) => {
  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");

  await page.reload();
+
  // Making sure the page view has reloaded and we see some content.
+
  await expect(page.getByText("Repositories").nth(1)).toBeVisible();

  await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
});

-
test("change theme", async ({ page }) => {
-
  await page.goto("/repos");
-
  await expect(page.getByRole("button", { name: "markdown" })).toBeVisible();
+
test("change theme", async ({ authenticatedContext: page }) => {
  await page.getByRole("button", { name: "Settings" }).click();

  await page
deleted tests/fixtures/repos/markdown.tar.bz2
modified tests/support/fixtures.ts
@@ -1,30 +1,26 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { PeerManager, RadiclePeer } from "./peerManager.js";
import type * as Stream from "node:stream";
+
import type { Page } from "@playwright/test";

import * as Fs from "node:fs/promises";
import * as Path from "node:path";
+
import getPort from "get-port";
import { test as base, expect } from "@playwright/test";
-
import { execa } from "execa";

-
import * as issue from "@tests/support/cobs/issue.js";
import * as logLabel from "@tests/support/logPrefix.js";
-
import * as patch from "@tests/support/cobs/patch.js";
-
import { createOptions, supportDir, tmpDir } from "@tests/support/support.js";
import { createPeerManager } from "@tests/support/peerManager.js";
-
import { createRepo } from "@tests/support/repo.js";
-
import { formatOid } from "@app/lib/utils.js";
+
import { randomTag } from "@tests/support/support.js";

export { expect };

-
const fixturesDir = Path.resolve(supportDir, "..", "./fixtures");
-

export const test = base.extend<{
  // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
  forAllTests: void;
  stateDir: string;
  peerManager: PeerManager;
  peer: RadiclePeer;
+
  authenticatedContext: Page;
  outputLog: Stream.Writable;
}>({
  forAllTests: [
@@ -95,26 +91,53 @@ export const test = base.extend<{
    await logFile.close();
  },

+
  authenticatedContext: async ({ page }, use) => {
+
    await page.goto("/");
+
    await page.getByPlaceholder("Enter desired alias").fill("palm");
+
    await page.getByPlaceholder("Enter passphrase to protect").fill("asdf");
+
    await page.getByPlaceholder("Repeat passphrase").fill("asdf");
+
    await page
+
      .getByRole("button", { name: "icon-seedling Create new identity" })
+
      .click();
+
    await expect(
+
      page.getByRole("button", {
+
        name: "z6MktULudTtAsAhRegYPiZ6631RV3viv12qd4GQF8z1xB22S icon-copy",
+
      }),
+
    ).toBeVisible();
+

+
    await use(page);
+
  },
+

  peerManager: async ({ stateDir, outputLog }, use) => {
    const peerManager = await createPeerManager({
-
      dataDir: Path.resolve(Path.join(stateDir, "peers")),
+
      dataDir: Path.resolve(stateDir, "peers"),
      outputLog,
    });
    await use(peerManager);
    await peerManager.shutdown();
  },

-
  peer: async ({ peerManager }, use) => {
-
    const peer = await peerManager.createPeer({
-
      name: "httpd",
-
      gitOptions: gitOptions["bob"],
-
    });
+
  peer: [
+
    async ({ page, peerManager }, use) => {
+
      const peer = await peerManager.createPeer({
+
        id: randomTag(),
+
        gitOptions: gitOptions["alice"],
+
      });

-
    await peer.startNode();
-
    await peer.startHttpd();
+
      const port = await getPort();
+
      await page.addInitScript(
+
        port => localStorage.setItem("TEST_HTTP_API_PORT", port.toString()),
+
        port,
+
      );
+
      await peer.startSSHAgent();
+
      await peer.startHttpd(port);

-
    await use(peer);
-
  },
+
      await use(peer);
+

+
      await peer.stopSSHAgent();
+
    },
+
    { scope: "test", auto: true },
+
  ],

  // eslint-disable-next-line no-empty-pattern
  stateDir: async ({}, use, testInfo) => {
@@ -133,400 +156,29 @@ export const test = base.extend<{
});

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);
+
  if (!process.env.QUIET) {
+
    const output = text
+
      .split("\n")
+
      .map(line => `${label}${line}`)
+
      .join("\n");
+

+
    outputLog.write(`${output}\n`);
+
    if (!process.env.CI) {
+
      console.log(output);
+
    }
  }
}

-
export async function createCobsFixture(
-
  peerManager: PeerManager,
-
  peer: RadiclePeer,
-
) {
-
  await peer.rad(["follow", peer.nodeId, "--alias", "palm"]);
-
  await Fs.mkdir(Path.join(tmpDir, "repos", "cobs"), { recursive: true });
-
  const { repoFolder, rid, defaultBranch } = await createRepo(peer, {
-
    name: "cobs",
-
  });
-
  const eve = await peerManager.createPeer({
-
    name: "eve",
-
    gitOptions: gitOptions["eve"],
-
  });
-
  await eve.startNode({
-
    node: { ...defaultConfig.node, connect: [peer.address], alias: "eve" },
-
  });
-
  await eve.rad(["clone", rid], { cwd: eve.checkoutPath });
-

-
  const issueOne = await issue.create(
-
    peer,
-
    "This `title` has **markdown**",
-
    "This is a description\nWith some multiline text.",
-
    ["bug", "feature-request"],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["issue", "react", issueOne, "--emoji", "👍", "--to", issueOne],
-
    {
-
      cwd: repoFolder,
-
    },
-
  );
-
  await peer.rad(
-
    ["issue", "react", issueOne, "--emoji", "🎉", "--to", issueOne],
-
    {
-
      cwd: repoFolder,
-
    },
-
  );
-
  await peer.rad(
-
    ["issue", "assign", issueOne, "--add", `did:key:${peer.nodeId}`],
-
    createOptions(repoFolder, 1),
-
  );
-
  const { stdout: commentIssueOne } = await peer.rad(
-
    [
-
      "issue",
-
      "comment",
-
      issueOne,
-
      "--message",
-
      "This is a multiline comment\n\nWith some more text.",
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 2),
-
  );
-
  await peer.rad(
-
    ["issue", "react", issueOne, "--emoji", "🙏", "--to", commentIssueOne],
-
    {
-
      cwd: repoFolder,
-
    },
-
  );
-
  const { stdout: replyIssueOne } = await peer.rad(
-
    [
-
      "issue",
-
      "comment",
-
      issueOne,
-
      "--message",
-
      "This is a reply, to a first comment.",
-
      "--reply-to",
-
      commentIssueOne,
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 3),
-
  );
-
  await peer.rad(
-
    ["issue", "react", issueOne, "--emoji", "🚀", "--to", replyIssueOne],
-
    {
-
      cwd: repoFolder,
-
    },
-
  );
-
  await peer.rad(
-
    [
-
      "issue",
-
      "comment",
-
      issueOne,
-
      "--message",
-
      "A root level comment after a reply, for margins sake.",
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 4),
-
  );
-

-
  const issueTwo = await issue.create(
-
    peer,
-
    "A closed issue",
-
    "This issue has been closed\n\nsource: [link](https://radicle.xyz)",
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["issue", "state", issueTwo, "--closed"],
-
    createOptions(repoFolder, 1),
-
  );
-

-
  const issueThree = await issue.create(
-
    peer,
-
    "A solved issue",
-
    "This issue has been solved\n\n```js\nconsole.log('hello world')\nconsole.log(\"\")\n```",
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["issue", "state", issueThree, "--solved"],
-
    createOptions(repoFolder, 1),
-
  );
-

-
  const patchOne = await patch.create(
-
    peer,
-
    ["Add README", "This commit adds more information to the README"],
-
    "feature/add-readme",
-
    () => Fs.writeFile(Path.join(repoFolder, "README.md"), "# Cobs Repo"),
-
    ["Let's add a README", "This repo needed a README"],
-
    { cwd: repoFolder },
-
  );
-
  const { stdout: commentPatchOne } = await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "I'll review the patch",
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 1),
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "Thanks for that!",
-
      "--reply-to",
-
      commentPatchOne,
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 2),
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "Yeah no problem!",
-
      "--reply-to",
-
      commentPatchOne,
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 3),
-
  );
-
  const { stdout: commentTwo } = await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "Looking good so far",
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 4),
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "comment",
-
      patchOne,
-
      "--message",
-
      "Thanks again!",
-
      "--reply-to",
-
      commentTwo,
-
      "--quiet",
-
      "--no-announce",
-
    ],
-
    createOptions(repoFolder, 5),
-
  );
-
  await peer.rad(
-
    ["patch", "review", patchOne, "-m", "LGTM", "--accept"],
-
    createOptions(repoFolder, 6),
-
  );
-
  await patch.merge(
-
    peer,
-
    defaultBranch,
-
    "feature/add-readme",
-
    createOptions(repoFolder, 7),
-
  );
-

-
  const patchTwo = await patch.create(
-
    peer,
-
    ["Add subtitle to README"],
-
    "feature/add-more-text",
-
    () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Subtitle"),
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "review",
-
      patchTwo,
-
      "-m",
-
      "Not the README we are looking for",
-
      "--reject",
-
    ],
-
    createOptions(repoFolder, 1),
-
  );
-

-
  const patchThree = await patch.create(
-
    peer,
-
    [
-
      "Rewrite subtitle to README",
-
      "This was really necessary",
-
      "Blazingly fast",
-
    ],
-
    "feature/better-subtitle",
-
    () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Better?"),
-
    [
-
      "Taking another stab at the README",
-
      "This is a big improvement over the last one",
-
      "Hopefully **this** is the last time",
-
    ],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["patch", "label", patchThree, "--add", "documentation"],
-
    createOptions(repoFolder, 1),
-
  );
-
  await eve.rad(
-
    ["patch", "review", patchThree, "-m", "This looks better"],
-
    createOptions(repoFolder, 2),
-
  );
-
  await Fs.appendFile(
-
    Path.join(repoFolder, "README.md"),
-
    "\n\nHad to push a new revision",
-
  );
-
  await peer.git(["add", "."], { cwd: repoFolder });
-
  await peer.git(["commit", "-m", "Add more text"], { cwd: repoFolder });
-
  await peer.git(
-
    [
-
      "push",
-
      "-o",
-
      "patch.message=Most of the missing README text was caused by the git-daemon not having a writers block. It seems like using an RNG was not a good enough solution.",
-
      "-o",
-
      "patch.message=After this change, the README seem to be written correctly",
-
      "rad",
-
      "feature/better-subtitle",
-
    ],
-
    createOptions(repoFolder, 3),
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "review",
-
      patchThree,
-
      "-m",
-
      "No this doesn't look better",
-
      "--reject",
-
    ],
-
    createOptions(repoFolder, 2),
-
  );
-

-
  const patchFour = await patch.create(
-
    peer,
-
    ["This patch is going to be archived"],
-
    "feature/archived",
-
    () => Fs.writeFile(Path.join(repoFolder, "CONTRIBUTING.md"), "# Archived"),
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    [
-
      "patch",
-
      "review",
-
      patchFour,
-
      "-m",
-
      "No review due to patch being archived.",
-
    ],
-
    createOptions(repoFolder, 1),
-
  );
-
  await peer.rad(["patch", "archive", patchFour], createOptions(repoFolder, 2));
-

-
  const patchFive = await patch.create(
-
    peer,
-
    ["This patch is going to be reverted to draft"],
-
    "feature/draft",
-
    () => Fs.writeFile(Path.join(repoFolder, "LICENSE"), "Draft"),
-
    [],
-
    { cwd: repoFolder },
-
  );
-
  await peer.rad(
-
    ["patch", "ready", patchFive, "--undo"],
-
    createOptions(repoFolder, 1),
-
  );
-
}
-

-
export async function createMarkdownFixture(peer: RadiclePeer) {
-
  await Fs.mkdir(Path.join(tmpDir, "repos", "markdown"), { recursive: true });
-
  await execa("tar", [
-
    "-xf",
-
    Path.join(fixturesDir, "repos", "markdown.tar.bz2"),
-
    "-C",
-
    Path.join(tmpDir, "repos", "markdown"),
-
  ]);
-
  const { repoFolder } = await createRepo(peer, { name: "markdown" });
-
  await Fs.cp(Path.join(tmpDir, "repos", "markdown"), repoFolder, {
-
    recursive: true,
-
  });
-

-
  await peer.git(["add", "."], { cwd: repoFolder });
-
  const commitMessage = `Add Markdown cheat sheet
-

-
  Borrowed from [Adam Pritchard][ap].
-
  No modifications were made.
-

-
  [ap]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet`;
-
  await peer.git(["commit", "-m", commitMessage], {
-
    cwd: repoFolder,
-
  });
-
  await peer.git(["push", "rad"], { cwd: repoFolder });
-
  await issue.create(
-
    peer,
-
    "This `title` has **markdown**",
-
    'This is a description\n\nWith some multiline text.\n\n```\n23-11-06 10:19 ➜  radicle-jetbrains-plugin git:(main) rad id update --title "Godify jchrist" --description "where jchrist ascends to a god of this project" --delegate did:key:z6MkpaATbhkGbSMysNomYTFVvKG5bnNKYZ2cCamfoHzX9SnL --threshold 1\n\n✓ Identity revision 029837dde8f5c49704e50a19cd709473ac66a456 created\n```',
-
    ["bug", "feature-request"],
-
    { cwd: repoFolder },
-
  );
-
}
-

-
export const aliceMainHead = "7babd25a74eb3752ec24672b5edf0e7ecb4daf24";
-
export const aliceMainCommitMessage =
-
  "Verify that crate::DoubleColon::should_work()";
-
export const aliceMainCommitCount = 8;
-
export const aliceRemote =
-
  "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK";
-
export const shortAliceHead = formatOid(aliceMainHead);
-
export const bobRemote =
-
  "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5";
-
export const bobHead = "82f570ec909e77c7e1bb764f1429b1e01b1b4a90";
-
export const bobMainCommitCount = 9;
-
export const shortBobHead = formatOid(bobHead);
-
export const cobRid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe";
-
export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg";
-
export const shortNodeRemote = "z6MktU…1xB22S";
+
export const playgroundRid = "rad:z2GgNybAe2twGbs9ShVbxiRouJcoi";
export const defaultHttpdPort = 8081;
export const gitOptions = {
  alice: {
    GIT_AUTHOR_NAME: "Alice Liddell",
    GIT_AUTHOR_EMAIL: "alice@radicle.xyz",
-
    GIT_AUTHOR_DATE: "1727621093",
+
    GIT_AUTHOR_DATE: "1514817556",
    GIT_COMMITTER_NAME: "Alice Liddell",
    GIT_COMMITTER_EMAIL: "alice@radicle.xyz",
-
    GIT_COMMITTER_DATE: "1727621093",
-
  },
-
  bob: {
-
    GIT_AUTHOR_NAME: "Bob Belcher",
-
    GIT_AUTHOR_EMAIL: "bob@radicle.xyz",
-
    GIT_AUTHOR_DATE: "1727621093",
-
    GIT_COMMITTER_NAME: "Bob Belcher",
-
    GIT_COMMITTER_EMAIL: "bob@radicle.xyz",
-
    GIT_COMMITTER_DATE: "1730220293",
-
  },
-

-
  eve: {
-
    GIT_AUTHOR_NAME: "Eve Johnson",
-
    GIT_AUTHOR_EMAIL: "eve@radicle.xyz",
-
    GIT_AUTHOR_DATE: "1727621093",
-
    GIT_COMMITTER_NAME: "Eve Johnson",
-
    GIT_COMMITTER_EMAIL: "eve@radicle.xyz",
-
    GIT_COMMITTER_DATE: "1730220293",
+
    GIT_COMMITTER_DATE: "1514817556",
  },
};
export const defaultConfig: Config = {
modified tests/support/globalSetup.ts
@@ -1,19 +1,9 @@
-
import * as Fs from "node:fs";
import * as Path from "node:path";
import {
  assertBinariesInstalled,
  heartwoodRelease,
-
  removeWorkspace,
  tmpDir,
} from "@tests/support/support.js";
-
import {
-
  defaultConfig,
-
  createCobsFixture,
-
  createMarkdownFixture,
-
  defaultHttpdPort,
-
  gitOptions,
-
} from "@tests/support/fixtures.js";
-
import { createPeerManager } from "@tests/support/peerManager.js";

const heartwoodBinaryPath = Path.join(
  tmpDir,
@@ -24,7 +14,7 @@ const heartwoodBinaryPath = Path.join(

process.env.PATH = [heartwoodBinaryPath, process.env.PATH].join(Path.delimiter);

-
export default async function globalSetup(): Promise<() => void> {
+
export default async function globalSetup() {
  try {
    await assertBinariesInstalled("rad", heartwoodRelease, heartwoodBinaryPath);
  } catch (error) {
@@ -35,61 +25,4 @@ export default async function globalSetup(): Promise<() => void> {
    console.log("");
    process.exit(1);
  }
-

-
  if (!process.env.SKIP_FIXTURE_CREATION) {
-
    console.log(
-
      "Recreating static fixtures. Set SKIP_FIXTURE_CREATION to skip this",
-
    );
-
    await removeWorkspace();
-
  }
-

-
  const peerManager = await createPeerManager({
-
    dataDir: Path.resolve(tmpDir, "peers"),
-
    outputLog: Fs.createWriteStream(
-
      Path.resolve(tmpDir, "globalPeerManager.log"),
-
    )
-
      // Workaround for fixing MaxListenersExceededWarning.
-
      // Since every prefixOutput stream adds stream listeners that don't autoClose.
-
      // TODO: We still seem to have some descriptors left open when running vitest, which we should handle.
-
      .setMaxListeners(16),
-
  });
-

-
  const palm = await peerManager.createPeer({
-
    name: "palm",
-
    gitOptions: gitOptions["alice"],
-
  });
-

-
  if (!process.env.SKIP_FIXTURE_CREATION) {
-
    await palm.startNode({
-
      node: {
-
        ...defaultConfig.node,
-
        seedingPolicy: { default: "allow", scope: "all" },
-
        alias: "palm",
-
      },
-
    });
-
    await palm.startHttpd(defaultHttpdPort);
-

-
    try {
-
      console.log("Creating markdown fixture");
-
      await createMarkdownFixture(palm);
-
      console.log("Creating cobs fixture");
-
      await createCobsFixture(peerManager, palm);
-
      console.log("All fixtures created");
-
    } catch (error) {
-
      console.log("");
-
      console.log("Not able to create the required fixtures.");
-
      console.log("Make sure you are not using binaries compiled for release.");
-
      console.log("");
-
      console.log(error);
-
      console.log("");
-
      process.exit(1);
-
    }
-
    await palm.stopNode();
-
  } else {
-
    await palm.startHttpd(defaultHttpdPort);
-
  }
-

-
  return async () => {
-
    await peerManager.shutdown();
-
  };
}
modified tests/support/peerManager.ts
@@ -1,75 +1,28 @@
import type * as Execa from "execa";
+
import type { Config } from "@tests/support/fixtures";

import * as Fs from "node:fs/promises";
import * as Os from "node:os";
import * as Path from "node:path";
import * as Stream from "node:stream";
-
import * as Util from "node:util";
-
import * as readline from "node:readline/promises";
import getPort from "get-port";
-
import matches from "lodash/matches.js";
import waitOn from "wait-on";
-
import { defaultConfig, type Config } from "@tests/support/fixtures.js";
+
import { defaultConfig } from "@tests/support/fixtures";
import { execa } from "execa";
import { logPrefix } from "@tests/support/logPrefix.js";
-
import { randomTag } from "@tests/support/support.js";
-
import { sleep } from "@app/lib/sleep.js";
-

-
export type RefsUpdate =
-
  | { updated: { name: string; old: string; new: string } }
-
  | { created: { name: string; oid: string } }
-
  | { deleted: { name: string; oid: string } }
-
  | { skipped: { name: string; oid: string } };
-

-
export type NodeEvent =
-
  | {
-
      type: "refsFetched";
-
      remote: string;
-
      rid: string;
-
      updated: RefsUpdate[];
-
    }
-
  | {
-
      type: "refsSynced";
-
      remote: string;
-
      rid: string;
-
    }
-
  | {
-
      type: "seedDiscovered";
-
      rid: string;
-
      nid: string;
-
    }
-
  | {
-
      type: "seedDropped";
-
      nid: string;
-
      rid: string;
-
    }
-
  | {
-
      type: "peerConnected";
-
      nid: string;
-
    }
-
  | {
-
      type: "peerDisconnected";
-
      nid: string;
-
      reason: string;
-
    };
-

-
export interface RoutingEntry {
-
  nid: string;
-
  rid: string;
-
}

interface PeerManagerParams {
  dataPath: string;
  radSeed: string;
-
  // Name for easy identification. Used on file system and in logs.
-
  name: string;
+
  // Id for easy identification. Used on file system and in logs.
+
  id: string;
  gitOptions?: Record<string, string>;
  outputLog: Stream.Writable;
}

export interface PeerManager {
  createPeer(params: {
-
    name: string;
+
    id: string;
    gitOptions?: Record<string, string>;
  }): Promise<RadiclePeer>;
  /**
@@ -99,7 +52,7 @@ export async function createPeerManager(createParams: {
    async createPeer(params) {
      const peer = await RadiclePeer.create({
        dataPath: createParams.dataDir,
-
        name: params.name,
+
        id: params.id,
        gitOptions: params.gitOptions,
        radSeed: Array(64)
          .fill((peers.length + 1).toString())
@@ -139,99 +92,62 @@ export interface BaseUrl {

export class RadiclePeer {
  public checkoutPath: string;
-
  public nodeId: string;

+
  #sshAgentPid?: string;
+
  #sshAgentAuthSock?: string;
  #radSeed: string;
  #socket: string;
  #radHome: string;
-
  #eventRecords: NodeEvent[] = [];
  #outputLog: Stream.Writable;
  #gitOptions?: Record<string, string>;
  #listenSocketAddr?: string;
  #httpdBaseUrl?: BaseUrl;
  #nodeProcess?: SpawnResult;
-
  // Name for easy identification. Used on file system and in logs.
-
  #name: string;
+
  // Id for easy identification. Used on file system and in logs.
+
  #id: string;
  #childProcesses: SpawnResult[] = [];

  private constructor(props: {
    checkoutPath: string;
-
    nodeId: string;
    radSeed: string;
    socket: string;
    gitOptions?: Record<string, string>;
    radHome: string;
    logFile: Stream.Writable;
-
    name: string;
+
    id: string;
  }) {
    this.checkoutPath = props.checkoutPath;
-
    this.nodeId = props.nodeId;
    this.#gitOptions = props.gitOptions;
    this.#radSeed = props.radSeed;
    this.#socket = props.socket;
    this.#radHome = props.radHome;
    this.#outputLog = props.logFile;
-
    this.#name = props.name;
-
  }
-

-
  public async waitForEvent(searchEvent: NodeEvent, timeoutInMs: number) {
-
    const start = new Date().getTime();
-

-
    while (true) {
-
      if (this.#eventRecords.find(matches(searchEvent))) {
-
        return;
-
      }
-
      if (new Date().getTime() - start > timeoutInMs) {
-
        throw Error(
-
          `Timeout waiting for event on node ${this.#name} ${Util.inspect(
-
            searchEvent,
-
            { depth: null },
-
          )}`,
-
        );
-
      }
-
      await sleep(100);
-
    }
+
    this.#id = props.id;
  }

  public static async create({
    dataPath,
-
    name,
+
    id,
    gitOptions,
    radSeed: node,
    outputLog: logFile,
  }: PeerManagerParams): Promise<RadiclePeer> {
-
    const checkoutPath = Path.join(dataPath, name, "copy");
+
    const checkoutPath = Path.join(dataPath, id, "copy");
    await Fs.mkdir(checkoutPath, { recursive: true });
-
    const radHome = Path.join(dataPath, name, "home");
+
    const radHome = Path.join(dataPath, id, "home");
    await Fs.mkdir(radHome, { recursive: true });

-
    const socketDir = await Fs.mkdtemp(
-
      Path.join(Os.tmpdir(), `radicle-${randomTag()}`),
-
    );
+
    const socketDir = await Fs.mkdtemp(Path.join(Os.tmpdir(), `radicle-${id}`));
    const socket = Path.join(socketDir, "control.sock");

-
    /* eslint-disable @typescript-eslint/naming-convention */
-
    const env = {
-
      ...gitOptions,
-
      RAD_HOME: radHome,
-
      RAD_PASSPHRASE: "asdf",
-
      RAD_KEYGEN_SEED: node,
-
      RAD_SOCKET: socket,
-
    };
-
    /* eslint-enable @typescript-eslint/naming-convention */
-

-
    await execa("rad", ["auth", "--alias", name], { env });
-
    const { stdout: nodeId } = await execa("rad", ["self", "--nid"], { env });
-

    return new RadiclePeer({
      checkoutPath,
      gitOptions,
      radSeed: node,
      socket,
-
      nodeId,
      radHome,
      logFile,
-
      name,
+
      id,
    });
  }

@@ -242,14 +158,7 @@ export class RadiclePeer {
    await this.spawn("ssh-add", ["-d", `${this.#radHome}/keys/radicle.pub`]);
  }

-
  public async authenticate(): Promise<void> {
-
    await this.spawn("rad", ["auth"]);
-
  }
-

-
  public async startHttpd(port?: number): Promise<void> {
-
    if (!port) {
-
      port = await getPort();
-
    }
+
  public async startHttpd(port: number): Promise<void> {
    this.#httpdBaseUrl = {
      hostname: "127.0.0.1",
      port,
@@ -272,6 +181,34 @@ export class RadiclePeer {
    });
  }

+
  public async startSSHAgent() {
+
    const { stdout } = await this.spawn("ssh-agent", ["-s"]);
+
    const match = stdout.match(/SSH_AUTH_SOCK=([^;]+);.*SSH_AGENT_PID=(\d+)/s);
+
    if (match) {
+
      this.#sshAgentAuthSock = match[1];
+
      this.#sshAgentPid = match[2];
+
    } else {
+
      throw new Error("Could not start a new ssh-agent");
+
    }
+

+
    await waitOn({
+
      resources: [`socket:${this.#sshAgentAuthSock}`],
+
      timeout: 2000,
+
    });
+
  }
+

+
  public async stopSSHAgent() {
+
    if (this.#sshAgentPid) {
+
      process.kill(Number(this.#sshAgentPid), "SIGTERM");
+
    }
+

+
    await waitOn({
+
      resources: [`socket:${this.#sshAgentAuthSock}`],
+
      reverse: true,
+
      timeout: 2000,
+
    });
+
  }
+

  public async startNode(config: Partial<Config> = defaultConfig) {
    const listenPort = await getPort();
    this.#listenSocketAddr = `0.0.0.0:${listenPort}`;
@@ -295,28 +232,6 @@ export class RadiclePeer {
    if (!stdout) {
      throw new Error("Could not get stdout to track events");
    }
-

-
    readline
-
      .createInterface({
-
        input: stdout,
-
        terminal: false,
-
      })
-
      .on("line", line => {
-
        let event;
-
        try {
-
          event = JSON.parse(line);
-
        } catch {
-
          console.log("Error parsing event", line);
-
          return;
-
        }
-

-
        this.#eventRecords.push(event);
-
        for (const line of Util.inspect(event, { depth: null }).split("\n")) {
-
          this.#outputLog.write(
-
            `${logPrefix(`${this.#name} node events`)} ${line}\n`,
-
          );
-
        }
-
      });
  }

  public async stopNode() {
@@ -346,26 +261,6 @@ export class RadiclePeer {
      p.kill("SIGKILL");
    });
  }
-

-
  public get address(): string {
-
    if (!this.#listenSocketAddr) {
-
      throw new Error("Remote node has no listen addr yet");
-
    }
-
    return `${this.nodeId}@${this.#listenSocketAddr}`;
-
  }
-

-
  public uiUrl(): string {
-
    if (!this.#httpdBaseUrl) {
-
      throw new Error("No httpd service running");
-
    }
-

-
    return `/nodes/${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`;
-
  }
-

-
  public ridUrl(rid: string): string {
-
    return `/nodes/${this.httpdBaseUrl.hostname}:${this.httpdBaseUrl.port}/${rid}`;
-
  }
-

  public get httpdBaseUrl(): BaseUrl {
    if (!this.#httpdBaseUrl) {
      throw new Error("No httpd service running");
@@ -387,7 +282,7 @@ export class RadiclePeer {
    args: string[] = [],
    opts?: SpawnOptions,
  ): SpawnResult {
-
    const prefix = logPrefix(`${this.#name} ${cmd}`);
+
    const prefix = logPrefix(`${this.#id} ${cmd}`);
    const outputLog = this.#outputLog;

    function* logWithPrefix(line: unknown) {
@@ -408,6 +303,8 @@ export class RadiclePeer {
        RAD_LOCAL_TIME: "1671125284",
        RAD_KEYGEN_SEED: this.#radSeed,
        RAD_SOCKET: this.#socket,
+
        SSH_AUTH_SOCK: this.#sshAgentAuthSock,
+
        SSH_AGENT_PID: this.#sshAgentPid,
        ...opts?.env,
        ...this.#gitOptions,
      },
deleted tests/support/router.ts
@@ -1,29 +0,0 @@
-
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 page
-
    .getByRole("progressbar", { name: "Page loading" })
-
    .waitFor({ state: "hidden" });
-
  await expect(page).toHaveURL(beforeURL);
-
  await page.goForward();
-

-
  await page
-
    .getByRole("progressbar", { name: "Page loading" })
-
    .waitFor({ state: "hidden" });
-
  await expect(page).toHaveURL(currentURL);
-
};