Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add support for Heartwood
xphoniex committed 3 years ago
commit f72405a3e49d2370fc64263ebef883318004894d
parent adc159a71eeea09c82ebfcd74a2684126cbcacbe
36 files changed +564 -185
modified .github/workflows/check-e2e.yml
@@ -12,7 +12,7 @@ jobs:
    continue-on-error: true
    strategy:
      matrix:
-
        browser: [chromium, firefox, visual]
+
        browser: [chromium, firefox, visual, heartwood]
    timeout-minutes: 30
    runs-on: ubuntu-latest
    steps:
@@ -59,12 +59,19 @@ jobs:
        run: npx playwright install-deps

      - name: Start http-api test server
-
        run: ./scripts/run-http-api-with-fixtures --non-interactive --detach
+
        run: |
+
          if [ ${{ matrix.browser }} = "heartwood" ]; then
+
            ./scripts/run-httpd-with-fixtures --non-interactive --detach
+
          else
+
            ./scripts/run-http-api-with-fixtures --non-interactive --detach
+
          fi

      - name: Run Playwright tests
        run: |
          if [ ${{ matrix.browser }} = "visual" ] && [ ${{ github.ref }} = "refs/heads/master" ]; then
            npm run test:e2e -- --project ${{ matrix.browser }} --update-snapshots;
+
          elif [ ${{ matrix.browser }} = "heartwood" ]; then
+
            HEARTWOOD=true npm run test:e2e -- --project chromium;
          else
            npm run test:e2e -- --project ${{ matrix.browser }};
          fi
added scripts/create-seed-fixture-heartwood
@@ -0,0 +1,51 @@
+
#!/usr/bin/env bash
+
set -euo pipefail
+

+
PASSPHRASE=asdf
+
SEED="0.0.0.0:8080"
+

+
REPO_ROOT=$(git rev-parse --show-toplevel)
+
ID=$(echo $RANDOM | md5sum | head -c 8)
+
BASE_PATH=$REPO_ROOT/tests/tmp/create-seed-fixture-$ID
+

+
TEST_REPO_ARCHIVE=$REPO_ROOT/tests/fixtures/repos/source-browsing.tar.bz2
+
TEST_REPO_NAME=source-browsing
+
TEST_REPO_PATH=$BASE_PATH/repos/$TEST_REPO_NAME
+

+
PALM_RAD_HOME=$BASE_PATH/seeds/palm
+
ALICE_RAD_HOME=$BASE_PATH/peers/alice
+
ALICE_CHECKOUT=$BASE_PATH/checkout/alice
+
BOB_RAD_HOME=$BASE_PATH/peers/bob
+
BOB_CHECKOUT=$BASE_PATH/checkout/bob
+

+
mkdir -p $PALM_RAD_HOME
+
mkdir -p $ALICE_RAD_HOME
+
mkdir -p $ALICE_CHECKOUT
+
mkdir -p $BOB_RAD_HOME
+
mkdir -p $BOB_CHECKOUT
+
mkdir -p $TEST_REPO_PATH
+

+
tar -xf $TEST_REPO_ARCHIVE -C $TEST_REPO_PATH
+

+
RAD_HOME=$PALM_RAD_HOME RAD_PASSPHRASE=$PASSPHRASE rad auth
+

+
GIT_AUTHOR_NAME="Alice Liddell"
+
GIT_AUTHOR_EMAIL="alice@radicle.xyz"
+
GIT_COMMITTER_NAME="Alice Liddell"
+
GIT_COMMITTER_EMAIL="alice@radicle.xyz"
+

+
RAD_DEBUG=1 RAD_HOME=$ALICE_RAD_HOME RAD_PASSPHRASE=$PASSPHRASE rad auth
+

+
cd $ALICE_CHECKOUT
+

+
git clone $TEST_REPO_PATH
+
cd $TEST_REPO_NAME
+
git checkout feature/branch
+
git checkout orphaned-branch
+
git checkout main
+

+
RAD_HOME=$ALICE_RAD_HOME RAD_PASSPHRASE=$PASSPHRASE rad init --name "source-browsing" --description "Git repository for source browsing tests" --default-branch "main" --no-confirm
+
PROJECT_ID=$(RAD_HOME=$ALICE_RAD_HOME RAD_PASSPHRASE=$PASSPHRASE rad .)
+

+
cd $BASE_PATH
+
tar -cjf palm.tar.bz2 --exclude "post-receive" --exclude "pre-receive" -C $ALICE_RAD_HOME .
modified scripts/run-http-api-with-fixtures
@@ -10,8 +10,7 @@ PASSPHRASE=asdf
CONTAINER_NAME=radicle-http-api-with-fixtures
HTTP_API_BINARY=radicle-http-api

-
show_usage()
-
{
+
show_usage() {
  echo
  echo "Starts a http-api backend with test fixtures."
  echo
@@ -26,8 +25,7 @@ show_usage()
  echo
}

-
prompt_workspace_removal()
-
{
+
prompt_workspace_removal() {
  echo "This will irrevocably destroy the following directories:"
  echo
  echo $WORKSPACE
@@ -35,19 +33,18 @@ prompt_workspace_removal()

  read -r -p "Are you sure you want to continue? [yes/no]: " confirm
  case "$confirm" in
-
      [yY][eE][sS] )
-
          rm -rf $WORKSPACE
-
          echo "Done"
-
          ;;
-
      * )
-
          echo "Ok, I won't touch your data."
-
          exit
-
          ;;
+
    [yY][eE][sS])
+
      rm -rf $WORKSPACE
+
      echo "Done"
+
      ;;
+
    *)
+
      echo "Ok, I won't touch your data."
+
      exit
+
      ;;
  esac
}

-
prepare_workspace()
-
{
+
prepare_workspace() {
  echo
  echo "Unpacking fixture $FIXTURE"
  mkdir -p $WORKSPACE
@@ -94,13 +91,13 @@ DETACH=false

while [ $# -ne 0 ]; do
  case "$1" in
-
    --binary|-b)
+
    --binary | -b)
      BINARY=true
      ;;
-
    --detach|-d)
+
    --detach | -d)
      DETACH=true
      ;;
-
    --non-interactive|-n)
+
    --non-interactive | -n)
      NON_INTERACTIVE=true
      ;;
    *)
@@ -120,7 +117,6 @@ else
  prepare_workspace
fi

-

if [ "$BINARY" = true ]; then
  run_binary
else
added scripts/run-httpd-with-fixtures
@@ -0,0 +1,132 @@
+
#!/bin/sh
+
set -e
+

+
REV=1f55d7a32750b3e63c56aad00370411b90420a29
+

+
REPO_ROOT=$(git rev-parse --show-toplevel)
+
FIXTURE=$REPO_ROOT/tests/fixtures/seeds/palm-heartwood.tar.bz2
+
WORKSPACE=$REPO_ROOT/tests/tmp/palm
+
PASSPHRASE=asdf
+
CONTAINER_NAME=radicle-httpd-with-fixtures
+
HTTP_API_BINARY=radicle-httpd
+

+
show_usage() {
+
  echo
+
  echo "Starts a radicle-httpd backend with test fixtures."
+
  echo
+
  echo "USAGE:"
+
  echo "  run-httpd-with-fixtures [-b|d|h|n]"
+
  echo
+
  echo "OPTIONS:"
+
  echo "  -b --binary            Use a ${HTTP_API_BINARY} binary that is in PATH to avoid using Docker."
+
  echo "  -d --detach            Daemonize the docker process."
+
  echo "  -h --help              Print this Help."
+
  echo "  -n --non-interactive   Run in non-interactive mode, no user prompts."
+
  echo
+
}
+

+
prompt_workspace_removal() {
+
  echo "This will irrevocably destroy the following directories:"
+
  echo
+
  echo $WORKSPACE
+
  echo
+

+
  read -r -p "Are you sure you want to continue? [yes/no]: " confirm
+
  case "$confirm" in
+
    [yY][eE][sS])
+
      rm -rf $WORKSPACE
+
      echo "Done"
+
      ;;
+
    *)
+
      echo "Ok, I won't touch your data."
+
      exit
+
      ;;
+
  esac
+
}
+

+
prepare_workspace() {
+
  echo
+
  echo "Unpacking fixture $FIXTURE"
+
  mkdir -p $WORKSPACE
+
  tar -xf $FIXTURE -C $WORKSPACE
+
  if [ "$CI" = true ]; then
+
    sudo chown -R 0:0 $WORKSPACE
+
  fi
+
}
+

+
run_docker() {
+
  echo "Starting docker at container $CONTAINER_NAME at $REV"
+
  echo "  RAD_HOME=$WORKSPACE RAD_PASSPHRASE=$PASSPHRASE radicle-http"
+
  echo
+

+
  exec docker run \
+
    --init \
+
    --publish 8080:8080 \
+
    --rm \
+
    --name $CONTAINER_NAME \
+
    --volume $WORKSPACE:/app/radicle \
+
    "$@" \
+
    --env "RAD_HOME=/app/radicle" \
+
    --env "RAD_PASSPHRASE=$PASSPHRASE" \
+
    "gcr.io/radicle-services/radicle-httpd:$REV"
+
}
+

+
run_binary() {
+
  if ! [ -x "$(command -v $HTTP_API_BINARY)" ]; then
+
    echo
+
    echo "Couldn't find the $HTTP_API_BINARY binary in your PATH."
+
    echo "You can compile it from source:"
+
    echo "  👉 https://github.com/radicle-dev/heartwood"
+
    echo
+
    exit 1
+
  fi
+

+
  echo
+
  echo "Starting $HTTP_API_BINARY"
+
  echo "  RAD_HOME=$WORKSPACE $HTTP_API_BINARY"
+
  echo
+

+
  RAD_HOME=$WORKSPACE RAD_PASSPHRASE=$PASSPHRASE $HTTP_API_BINARY
+
}
+

+
BINARY=false
+
NON_INTERACTIVE=false
+
DETACH=false
+

+
while [ $# -ne 0 ]; do
+
  case "$1" in
+
    --binary | -b)
+
      BINARY=true
+
      ;;
+
    --detach | -d)
+
      DETACH=true
+
      ;;
+
    --non-interactive | -n)
+
      NON_INTERACTIVE=true
+
      ;;
+
    *)
+
      show_usage
+
      exit
+
      ;;
+
  esac
+

+
  shift
+
done
+

+
if [ "$NON_INTERACTIVE" = true ]; then
+
  rm -rf $WORKSPACE
+
  prepare_workspace
+
else
+
  prompt_workspace_removal
+
  prepare_workspace
+
fi
+

+
if [ "$BINARY" = true ]; then
+
  run_binary
+
else
+
  if [ "$DETACH" = true ]; then
+
    run_docker --detach
+
  else
+
    run_docker
+
  fi
+
fi
modified src/components/Authorship.svelte
@@ -6,7 +6,11 @@

  import Address from "@app/components/Address.svelte";
  import { Profile, ProfileType } from "@app/lib/profile";
-
  import { formatRadicleId, formatTimestamp } from "@app/lib/utils";
+
  import {
+
    formatRadicleId,
+
    formatSeedId,
+
    formatTimestamp,
+
  } from "@app/lib/utils";

  export let noAvatar = false;
  export let author: Author;
@@ -67,7 +71,7 @@
    </span>
  {:else}
    <span class="highlight">
-
      {formatRadicleId(author.id)}
+
      {window.HEARTWOOD ? formatSeedId(author.id) : formatRadicleId(author.id)}
    </span>
  {/if}
  <span class="caption">&nbsp;{caption}&nbsp;</span>
modified src/components/Form.svelte
@@ -20,17 +20,19 @@

  const validationExamples: Record<string, string> = {
    URL: "https://acme.xyz/",
-
    URN: "eip155:1:0xd1bb21bd5a432d2919c82bcefe1bc7f8cc9207d9",
+
    ID: "eip155:1:0xd1bb21bd5a432d2919c82bcefe1bc7f8cc9207d9",
    handle: "acme",
    id: "hydkkcf6k9be5fuszdhpqbctu3q3fuwagj874wx2puia8ti8coygh1",
-
    identity: "rad:git:hnrkqdpm9ub19oc8dccx44echy75hzfsezyio",
+
    identity: window.HEARTWOOD
+
      ? "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT"
+
      : "rad:git:hnrkqdpm9ub19oc8dccx44echy75hzfsezyio",
    domain: "seed.acme.xyz",
    address: "0x17a8c096733BD5F87aD43D7A2A4d1C42ab8A2A70",
  };

  const validationTypes: { [index: string]: RegExp } = {
    URL: /^(https:\/\/|http:\/\/|ipfs:\/\/)\S+/,
-
    URN: /^[a-z]+:[a-zA-Z0-9:-]{1,64}$/,
+
    ID: /^z[a-z]+:[a-zA-Z0-9:-]{1,64}$/,
    // Github
    //   Username may only contain alphanumeric characters or hyphens.
    //   Username cannot have multiple consecutive hyphens.
@@ -43,7 +45,9 @@
    handle: /^[a-zA-Z0-9-_]{1,39}$/,
    address: /^0x[a-zA-Z0-9]{40}$/,
    id: /^[a-z0-9]+$/,
-
    identity: /^rad:git:[a-z0-9]{37}$/,
+
    identity: window.HEARTWOOD
+
      ? /^rad:[a-z0-9]{28}$/
+
      : /^rad:git:[a-z0-9]{37}$/,
    domain: /^[^/:$!_;,@#]+\.[a-z]{2,}$/,
  };
</script>
modified src/global.d.ts
@@ -10,6 +10,8 @@ declare global {
    PLAYWRIGHT: boolean;
    HASH_ROUTING: boolean;

+
    HEARTWOOD: 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
modified src/lib/api.ts
@@ -13,7 +13,11 @@ export class Request {

  constructor(path: string, api: Host) {
    this.port = api.port || defaultSeedPort;
-
    this.base = api.host;
+
    if (window.HEARTWOOD) {
+
      this.base = api.host + "/api";
+
    } else {
+
      this.base = api.host;
+
    }
    this.path = path.startsWith("/") ? path.slice(1) : path;
    this.protocol = api.host === "0.0.0.0" ? "http://" : "https://";
  }
modified src/lib/diff.ts
@@ -89,6 +89,7 @@ export interface Diff {
  moved: string[];
  copied: string[];
  modified: FileDiff[];
+
  stats: DiffStats;
}

export interface DiffStats {
modified src/lib/issue.ts
@@ -12,7 +12,7 @@ export interface IIssue {
  author: Author;
  title: string;
  state: State;
-
  comment: Comment;
+
  comment: Comment; // TODO: Remove this after we have migrated to Heartwood.
  discussion: Thread[];
  tags: Tag[];
  timestamp: number;
@@ -32,7 +32,8 @@ export interface Comment<R = null> {
  body: string;
  reactions: Record<string, number>;
  timestamp: number;
-
  replies: R;
+
  replies: R; // TODO: Remove this after we have migrated to Heartwood.
+
  replyTo: R;
}

export type Thread = Comment<Comment[]>;
@@ -57,7 +58,7 @@ export class Issue {
  author: Author;
  title: string;
  state: State;
-
  comment: Comment;
+
  comment: Comment; // TODO: Remove this after we have migrated to Heartwood.
  discussion: Thread[];
  tags: Tag[];
  timestamp: number;
@@ -67,18 +68,28 @@ export class Issue {
    this.author = issue.author;
    this.title = issue.title;
    this.state = issue.state;
-
    this.comment = issue.comment;
+
    this.comment = issue.comment; // TODO: Remove this after we have migrated to Heartwood.
    this.discussion = issue.discussion;
    this.tags = issue.tags;
-
    this.timestamp = issue.timestamp;
+
    if (window.HEARTWOOD) {
+
      this.timestamp = issue.discussion[0].timestamp;
+
    } else {
+
      this.timestamp = issue.timestamp;
+
    }
  }

  // Counts the amount of comments and replies in a discussion
  countComments(): number {
-
    return this.discussion.reduce((acc, comment) => {
-
      if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 forathe comment in this loop.
-
      return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
-
    }, 0);
+
    if (window.HEARTWOOD) {
+
      return this.discussion.reduce(acc => {
+
        return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
+
      }, 0);
+
    } else {
+
      return this.discussion.reduce((acc, comment) => {
+
        if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 forathe comment in this loop.
+
        return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
+
      }, 0);
+
    }
  }

  static async getIssues(id: string, host: Host): Promise<Issue[]> {
modified src/lib/patch.ts
@@ -100,10 +100,16 @@ export class Patch implements IPatch {

  // Counts the amount of comments and replies in a discussion
  countComments(rev: number): number {
-
    return this.revisions[rev].discussion.reduce((acc, comment) => {
-
      if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 for each comment in this loop.
-
      return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
-
    }, 0);
+
    if (window.HEARTWOOD) {
+
      return this.revisions[rev].discussion.reduce(acc => {
+
        return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
+
      }, 0);
+
    } else {
+
      return this.revisions[rev].discussion.reduce((acc, comment) => {
+
        if (comment.replies) return acc + comment.replies.length + 1; // We add all replies and 1 for each comment in this loop.
+
        return acc + 1; // If there are no replies, we simply add 1 for the comment in this loop.
+
      }, 0);
+
    }
  }

  createTimeline(rev: number) {
modified src/lib/project.ts
@@ -12,6 +12,7 @@ export type Branches = { [key: string]: string };
export type MaybeBlob = Blob | undefined;
export type MaybeTree = Tree | undefined;

+
// TODO: Remove this after we have migrated to Heartwood.
export type Delegate =
  | {
      type: "indirect";
@@ -40,38 +41,43 @@ export interface ProjectInfo {
  name: string;
  description: string;
  defaultBranch: string;
-
  delegates: Delegate[];
-
  remotes: PeerId[];
+
  delegates: Delegate[]; // TODO: Remove this after we have migrated to Heartwood.
+
  remotes: PeerId[]; // TODO: Remove this after we have migrated to Heartwood.
  patches?: number;
  issues?: number;
}

export interface Tree {
  path: string;
-
  info: EntryInfo;
+
  info: EntryInfo; // TODO: Remove this after we have migrated to Heartwood.
  entries: Array<Entry>;
  stats: Stats;
+
  name: string;
+
  kind: Kind;
+
  lastCommit: CommitHeader;
}

+
// TODO: Remove "TREE" and "BLOB" after we have migrated to Heartwood.
+
type Kind = "tree" | "blob" | "TREE" | "BLOB";
+

export interface Stats {
  commits: number;
  contributors: number;
}

-
export enum ObjectType {
-
  Blob = "BLOB",
-
  Tree = "TREE",
-
}
-

+
// TODO: Remove this after we have migrated to Heartwood.
export interface EntryInfo {
  name: string;
-
  objectType: ObjectType;
+
  objectType: Kind;
  lastCommit: CommitHeader;
}

export interface Entry {
  path: string;
-
  info: EntryInfo;
+
  info: EntryInfo; // TODO: Remove this after we have migrated to Heartwood.
+
  name: string;
+
  kind: Kind;
+
  lastCommit: CommitHeader;
}

export interface Blob {
@@ -79,7 +85,10 @@ export interface Blob {
  html?: boolean;
  content: string;
  path: string;
-
  info: EntryInfo;
+
  info: EntryInfo; // TODO: Remove this after we have migrated to Heartwood.
+
  name: string;
+
  kind: Kind;
+
  lastCommit: CommitHeader;
}

export interface Remote {
@@ -144,8 +153,8 @@ export class Project implements ProjectInfo {
  name: string;
  description: string;
  defaultBranch: string;
-
  delegates: Delegate[];
-
  remotes: PeerId[];
+
  delegates: Delegate[]; // TODO: Remove this after we have migrated to Heartwood.
+
  remotes: PeerId[]; // TODO: Remove this after we have migrated to Heartwood.
  seed: Seed;
  peers: Peer[];
  branches: Branches;
@@ -167,8 +176,8 @@ export class Project implements ProjectInfo {
    this.name = info.name;
    this.description = info.description;
    this.defaultBranch = info.defaultBranch;
-
    this.delegates = info.delegates;
-
    this.remotes = info.remotes;
+
    this.delegates = info.delegates; // TODO: Remove this after we have migrated to Heartwood.
+
    this.remotes = info.remotes; // TODO: Remove this after we have migrated to Heartwood.
    this.seed = seed;
    this.peers = peers;
    this.branches = branches;
@@ -192,10 +201,14 @@ export class Project implements ProjectInfo {
  }

  static async getInfo(nameOrId: string, host: Host): Promise<ProjectInfo> {
-
    const result = await new Request(`projects/${nameOrId}`, host).get();
-
    result["id"] = result["urn"];
-
    delete result["urn"];
-
    return result;
+
    if (window.HEARTWOOD) {
+
      return await new Request(`projects/${nameOrId}`, host).get();
+
    } else {
+
      const result = await new Request(`projects/${nameOrId}`, host).get();
+
      result["id"] = result["urn"];
+
      delete result["urn"];
+
      return result;
+
    }
  }

  static async getProjects(
@@ -209,13 +222,17 @@ export class Project implements ProjectInfo {
      "per-page": opts?.perPage,
      page: opts?.page,
    };
-
    const results = await new Request("projects", host).get(params);
-
    results.forEach((result: { [x: string]: any }) => {
-
      result["id"] = result["urn"];
-
      delete result["urn"];
-
    });
-

-
    return results;
+
    if (window.HEARTWOOD) {
+
      return await new Request("projects", host).get(params);
+
    } else {
+
      const results = await new Request("projects", host).get(params);
+
      results.forEach((result: { [x: string]: any }) => {
+
        result["id"] = result["urn"];
+
        delete result["urn"];
+
      });
+

+
      return results;
+
    }
  }

  static async getDelegateProjects(
@@ -280,28 +297,31 @@ export class Project implements ProjectInfo {
      `projects/${this.id}/commits/${commit}`,
      this.seed.addr,
    ).get();
-
    result.stats["insertions"] = result.stats["additions"];
-
    delete result.stats["additions"];
-
    result.diff["added"] = result.diff["created"];
-
    delete result.diff["created"];
-

-
    for (const kind of ["added", "deleted", "modified"]) {
-
      for (const file of result.diff[kind]) {
-
        for (const hunk of file.diff.hunks) {
-
          for (const line of hunk.lines) {
-
            if (line["lineNumOld"]) {
-
              line["lineNoOld"] = line["lineNumOld"];
-
              delete line["lineNumOld"];
-
            }
-

-
            if (line["lineNumNew"]) {
-
              line["lineNoNew"] = line["lineNumNew"];
-
              delete line["lineNumNew"];
-
            }

-
            if (line["lineNum"]) {
-
              line["lineNo"] = line["lineNum"];
-
              delete line["lineNum"];
+
    if (!window.HEARTWOOD) {
+
      result.stats["insertions"] = result.stats["additions"];
+
      delete result.stats["additions"];
+
      result.diff["added"] = result.diff["created"];
+
      delete result.diff["created"];
+

+
      for (const kind of ["added", "deleted", "modified"]) {
+
        for (const file of result.diff[kind]) {
+
          for (const hunk of file.diff.hunks) {
+
            for (const line of hunk.lines) {
+
              if (line["lineNumOld"]) {
+
                line["lineNoOld"] = line["lineNumOld"];
+
                delete line["lineNumOld"];
+
              }
+

+
              if (line["lineNumNew"]) {
+
                line["lineNoNew"] = line["lineNumNew"];
+
                delete line["lineNumNew"];
+
              }
+

+
              if (line["lineNum"]) {
+
                line["lineNo"] = line["lineNum"];
+
                delete line["lineNum"];
+
              }
            }
          }
        }
@@ -364,12 +384,16 @@ export class Project implements ProjectInfo {
    const info = await Project.getInfo(id, seed.addr);
    id = isRadicleId(id) ? id : info.id;

-
    // Older versions of http-api don't include the ID.
-
    if (!info.id) info.id = id;
+
    let peers: Peer[] = [];

-
    const peers: Peer[] = info.delegates
-
      ? await Project.getRemotes(id, seed.addr)
-
      : [];
+
    if (window.HEARTWOOD) {
+
      peers = await Project.getRemotes(id, seed.addr);
+
    } else {
+
      // Older versions of http-api don't include the ID.
+
      if (!info.id) info.id = id;
+

+
      peers = info.delegates ? await Project.getRemotes(id, seed.addr) : [];
+
    }

    let remote: Remote = {
      heads: info.head ? { [info.defaultBranch]: info.head } : {},
modified src/lib/registrar.ts
@@ -90,18 +90,8 @@ export async function getRegistration(
    getText(resolver, "com.github"),
  ]);

-
  const [
-
    address,
-
    avatar,
-
    url,
-
    id,
-
    seedId,
-
    seedHost,
-
    seedGit,
-
    seedApi,
-
    twitter,
-
    github,
-
  ] = meta.filter(isFulfilled).map(r => (r.value ? r.value : undefined));
+
  const [address, avatar, url, id, seedId, seedHost, seedApi, twitter, github] =
+
    meta.filter(isFulfilled).map(r => (r.value ? r.value : undefined));

  const profile: EnsProfile = {
    name,
@@ -119,7 +109,6 @@ export async function getRegistration(
      profile.seed = new Seed({
        host: seedHost,
        id: seedId,
-
        git: seedGit,
        addr: seedApi,
      });
    } catch (e: any) {
@@ -157,10 +146,9 @@ export async function getSeed(
    return null;
  }

-
  const [id, host, git, api] = await Promise.all([
+
  const [id, host, api] = await Promise.all([
    getText(resolver, "eth.radicle.seed.id"),
    getText(resolver, "eth.radicle.seed.host"),
-
    getText(resolver, "eth.radicle.seed.git"),
    getText(resolver, "eth.radicle.seed.api"),
  ]);

@@ -170,7 +158,7 @@ export async function getSeed(
  }

  try {
-
    return new Seed({ host, id, git, addr: api });
+
    return new Seed({ host, id, addr: api });
  } catch (e: any) {
    console.debug(e, host, id);
    return new InvalidSeed(id, host);
modified src/lib/seed.ts
@@ -23,15 +23,16 @@ export class InvalidSeed {
  }
}

-
export const defaultSeedPort = 8777;
+
export const defaultSeedPort = window.HEARTWOOD ? 8080 : 8777;
export const defaultNodePort = 8776;
+
// TODO: Remove this after we have migrated to Heartwood.
export const defaultGitPort = 443;

export class Seed {
  valid = true as const;

  addr: { host: string; port: number | null };
-
  git: { host: string; port: number | null };
+
  git: { host: string; port: number | null }; // TODO: Remove this after we have migrated to Heartwood.
  node: { host: string; id: string; port: number };

  version?: string;
@@ -40,17 +41,21 @@ export class Seed {
  constructor(seed: {
    host: string;
    id: string;
-
    git?: string | null;
+
    git?: string | null; // TODO: Remove this after we have migrated to Heartwood.
    addr?: string | null;
    version?: string | null;
  }) {
    assert(isDomain(seed.host), `invalid seed host: ${seed.host}`);
-
    assert(/^[a-z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
+
    if (window.HEARTWOOD) {
+
      assert(/^[a-zA-Z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
+
    } else {
+
      assert(/^[a-z0-9]+$/.test(seed.id), `invalid seed id ${seed.id}`);
+
    }

    let _seed = null;
-
    let _git = null;
+
    let _git = null; // TODO: Remove this after we have migrated to Heartwood.
    let _seedPort: number | null = defaultSeedPort;
-
    let _gitPort: number | null = defaultGitPort;
+
    let _gitPort: number | null = defaultGitPort; // TODO: Remove this after we have migrated to Heartwood.

    if (seed.addr) {
      try {
@@ -73,15 +78,17 @@ export class Seed {
      assert(isDomain(_seed), `invalid seed host ${_seed}`);
    }

-
    if (seed.git) {
-
      try {
-
        const url = new URL(seed.git);
-
        _git = url.hostname;
-
        _gitPort = url.port ? Number(url.port) : null;
-
      } catch {
-
        _git = seed.git;
+
    if (window.HEARTWOOD) {
+
      if (seed.git) {
+
        try {
+
          const url = new URL(seed.git);
+
          _git = url.hostname;
+
          _gitPort = url.port ? Number(url.port) : null;
+
        } catch {
+
          _git = seed.git;
+
        }
+
        assert(isDomain(_git), `invalid seed git host ${_git}`);
      }
-
      assert(isDomain(_git), `invalid seed git host ${_git}`);
    }

    this.emoji = getSeedEmoji(seed.host);
@@ -89,10 +96,10 @@ export class Seed {
    // The `_seed` being more specific takes
    // precedence over the `host`, if available.
    _seed = _seed ?? seed.host;
-
    _git = _git ?? seed.host;
+
    _git = _git ?? seed.host; // TODO: Remove this after we have migrated to Heartwood.

    this.addr = { host: _seed, port: _seedPort };
-
    this.git = { host: _git, port: _gitPort };
+
    this.git = { host: _git, port: _gitPort }; // TODO: Remove this after we have migrated to Heartwood.
    this.node = { host: seed.host, id: seed.id, port: defaultNodePort };

    if (seed.version) {
@@ -121,10 +128,14 @@ export class Seed {
      ? await proj.Project.getDelegateProjects(id, this.addr, { perPage })
      : await proj.Project.getProjects(this.addr, { perPage });

-
    return result.map((project: proj.ProjectInfo) => ({
-
      ...project,
-
      id: project.id,
-
    }));
+
    if (window.HEARTWOOD) {
+
      return result;
+
    } else {
+
      return result.map((project: proj.ProjectInfo) => ({
+
        ...project,
+
        id: project.id,
+
      }));
+
    }
  }

  async getStats(): Promise<{
@@ -135,7 +146,11 @@ export class Seed {
  }

  static async getNode(host: Host): Promise<{ id: string }> {
-
    return new Request("/peer", host).get();
+
    if (window.HEARTWOOD) {
+
      return new Request("/node", host).get();
+
    } else {
+
      return new Request("/peer", host).get();
+
    }
  }

  static async getInfo(host: Host): Promise<{ version: string }> {
modified src/lib/utils.ts
@@ -105,7 +105,11 @@ export function formatSeedId(id: string): string {
export function formatRadicleId(id: string): string {
  assert(isRadicleId(id));

-
  return id.substring(0, 14) + "…" + id.substring(id.length - 6, id.length);
+
  if (window.HEARTWOOD) {
+
    return id.substring(0, 10) + "…" + id.substring(id.length - 6, id.length);
+
  } else {
+
    return id.substring(0, 14) + "…" + id.substring(id.length - 6, id.length);
+
  }
}

export function formatBalance(n: BigNumber, decimals?: number): string {
@@ -245,7 +249,11 @@ export const formatTimestamp = (

// Check whether the input is a Radicle ID.
export function isRadicleId(input: string): boolean {
-
  return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input);
+
  if (window.HEARTWOOD) {
+
    return /^rad:[a-zA-Z0-9]+$/.test(input);
+
  } else {
+
    return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input);
+
  }
}

// Check whether the input is a Radicle Peer ID.
@@ -295,7 +303,11 @@ export function formatName(input: string, wallet: Wallet): string {

// Parse a Radicle Id.
export function parseRadicleId(id: string): string {
-
  return id.replace(/^rad:[a-z]+:/, "");
+
  if (window.HEARTWOOD) {
+
    return id.replace(/^rad:/, "");
+
  } else {
+
    return id.replace(/^rad:[a-z]+:/, "");
+
  }
}

// Get amount of days passed between two dates without including the end date
modified src/views/projects/Blob.svelte
@@ -18,7 +18,7 @@
  export let line: string | undefined = undefined;

  const fileExtension = blob.path.split(".").pop() ?? "";
-
  const lastCommit = blob.info.lastCommit;
+
  const lastCommit = window.HEARTWOOD ? blob.lastCommit : blob.info.lastCommit;
  const parentDir = blob.path
    .match(/^.*\/|/)
    ?.values()
@@ -213,7 +213,7 @@
      <span class="file-name">
        <span class="txt-faded">{parentDir}</span>
        &#8203;
-
        <span>{blob.info.name}</span>
+
        <span>{window.HEARTWOOD ? blob.name : blob.info.name}</span>
      </span>
      <div class="right">
        {#if isMarkdown}
modified src/views/projects/Commit.svelte
@@ -73,5 +73,8 @@
      {/if}
    </div>
  </header>
-
  <Changeset stats={commit.stats} diff={commit.diff} on:browse={onBrowse} />
+
  <Changeset
+
    stats={window.HEARTWOOD ? commit.diff.stats : commit.stats}
+
    diff={commit.diff}
+
    on:browse={onBrowse} />
</div>
modified src/views/projects/Commit/CommitTeaser.svelte
@@ -94,7 +94,7 @@
    <CommitAuthorship {commit} noDelegate />
  </div>
  <div class="column-right">
-
    {#if commit.context.committer}
+
    {#if !window.HEARTWOOD && commit.context.committer}
      <div class="layout-desktop">
        <CommitVerifiedBadge {commit} />
      </div>
modified src/views/projects/Header.svelte
@@ -98,9 +98,12 @@
    {revision}
    on:branchChanged={event => updateRevision(event.detail)} />

-
  {#if seed.git.host}
+
  {#if window.HEARTWOOD && seed.addr.host}
+
    <CloneButton seedHost={seed.addr.host} {id} />
+
  {:else if seed.git.host}
    <CloneButton seedHost={seed.git.host} {id} />
  {/if}
+

  <span>
    {#if seed.addr.host}
      <HeaderToggleLabel
modified src/views/projects/Issue.svelte
@@ -136,15 +136,20 @@
  </header>
  <main>
    <div class="comments">
-
      <Comment comment={issue.comment} {getImage} {wallet} />
+
      {#if !window.HEARTWOOD}
+
        <Comment comment={issue.comment} {getImage} {wallet} />
+
      {/if}
+

      {#each issue.discussion as comment}
        <Comment {comment} {getImage} {wallet} />
-
        {#if comment.replies}
-
          <div class="replies">
-
            {#each comment.replies as reply}
-
              <Comment comment={reply} {getImage} {wallet} />
-
            {/each}
-
          </div>
+
        {#if !window.HEARTWOOD}
+
          {#if comment.replies}
+
            <div class="replies">
+
              {#each comment.replies as reply}
+
                <Comment comment={reply} {getImage} {wallet} />
+
              {/each}
+
            </div>
+
          {/if}
        {/if}
      {/each}
    </div>
modified src/views/projects/Patch.svelte
@@ -138,8 +138,10 @@
      </div>
    {:else if activeTab === PatchTab.Diff && revision.changeset}
      <Changeset
+
        stats={window.HEARTWOOD
+
          ? revision.changeset.diff.stats
+
          : revision.changeset.stats}
        diff={revision.changeset.diff}
-
        stats={revision.changeset.stats}
        on:browse={e => onBrowse(e, revision.oid)} />
    {:else if activeTab === PatchTab.Diff}
      <Placeholder emoji="🍳">
modified src/views/projects/Patch/PatchTimeline.svelte
@@ -66,11 +66,13 @@
    {:else if element.type === TimelineType.Thread}
      <div class="margin-left">
        <Comment comment={element.inner} {wallet} {getImage} />
-
        <div class="replies">
-
          {#each element.inner.replies as comment}
-
            <Comment caption="replied" {comment} {wallet} {getImage} />
-
          {/each}
-
        </div>
+
        {#if !window.HEARTWOOD}
+
          <div class="replies">
+
            {#each element.inner.replies as comment}
+
              <Comment caption="replied" {comment} {wallet} {getImage} />
+
            {/each}
+
          </div>
+
        {/if}
      </div>
    {/if}
  {/each}
modified src/views/projects/Tree.svelte
@@ -3,8 +3,6 @@

  import { createEventDispatcher } from "svelte";

-
  import { ObjectType } from "@app/lib/project";
-

  import File from "./Tree/File.svelte";
  import Folder from "./Tree/Folder.svelte";

@@ -20,11 +18,11 @@
</script>

{#each tree.entries as entry (entry.path)}
-
  {#if entry.info.objectType === ObjectType.Tree}
+
  {#if window.HEARTWOOD ? entry.kind === "tree" : entry.info.objectType === "TREE"}
    <Folder
      {fetchTree}
      {loadingPath}
-
      name={entry.info.name}
+
      name={window.HEARTWOOD ? entry.name : entry.info.name}
      prefix={`${entry.path}/`}
      currentPath={path}
      on:select={onSelect} />
@@ -32,7 +30,7 @@
    <File
      active={entry.path === path}
      loading={entry.path === loadingPath}
-
      name={entry.info.name}
+
      name={window.HEARTWOOD ? entry.name : entry.info.name}
      on:click={() => onSelect({ detail: entry.path })} />
  {/if}
{/each}
modified src/views/projects/Tree/Folder.svelte
@@ -2,7 +2,6 @@
  import type { MaybeTree } from "@app/lib/project";

  import Loading from "@app/components/Loading.svelte";
-
  import { ObjectType } from "@app/lib/project";
  import { createEventDispatcher } from "svelte";

  import File from "./File.svelte";
@@ -75,10 +74,10 @@
    {:then tree}
      {#if tree}
        {#each tree.entries as entry (entry.path)}
-
          {#if entry.info.objectType === ObjectType.Tree}
+
          {#if window.HEARTWOOD ? entry.kind === "tree" : entry.info.objectType === "TREE"}
            <svelte:self
              {fetchTree}
-
              name={entry.info.name}
+
              name={window.HEARTWOOD ? entry.name : entry.info.name}
              on:select={onSelectFile}
              prefix={`${entry.path}/`}
              {loadingPath}
@@ -87,7 +86,7 @@
            <File
              active={entry.path === currentPath}
              loading={entry.path === loadingPath}
-
              name={entry.info.name}
+
              name={window.HEARTWOOD ? entry.name : entry.info.name}
              on:click={() => {
                onSelectFile({ detail: entry.path });
              }} />
modified src/views/registrations/View.svelte
@@ -124,7 +124,9 @@
          name: "id",
          label: "Radicle",
          validate: "identity",
-
          placeholder: "Radicle ID, eg. rad:git:hnrkqdpm9ub19oc8d…",
+
          placeholder: window.HEARTWOOD
+
            ? "Radicle ID, eg. rad:zKtT7DmF9H34KkvcKj…"
+
            : "Radicle ID, eg. rad:git:hnrkqdpm9ub19oc8d…",
          description: "The local radicle identity associated with this name.",
          value: r.profile.id ?? "",
          editable: true,
modified tests/e2e/project.spec.ts
@@ -251,6 +251,10 @@ test("clone modal", async ({ page }) => {
});

test("peer and branch switching", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.goto(projectFixtureUrl);

  // Alice's peer.
@@ -335,6 +339,10 @@ test("peer and branch switching", async ({ page }) => {
});

test("only one modal can be open at a time", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.goto(projectFixtureUrl);

  await page.getByTitle("Change peer").click();
@@ -366,6 +374,10 @@ test("only one modal can be open at a time", async ({ page }) => {
});

test.describe("browser error handling", () => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  test("error appears when folder can't be loaded", async ({ page }) => {
    await page.route(
      `**/v1/projects/${ridPrefix}${rid}/tree/${aliceMainHead}/markdown/`,
modified tests/e2e/project/commit.spec.ts
@@ -8,6 +8,10 @@ import {
const modifiedFileFixture = `${projectFixtureUrl}/remotes/${bobRemote}/commits/2b32f6fe50090ebdb4cd7441e943330da3e6ff04`;

test("navigation from commit list", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.goto(projectFixtureUrl);
  await page.getByTitle("Change peer").click();
  await page.locator("text=bob hyyzz9").click();
@@ -18,6 +22,10 @@ test("navigation from commit list", async ({ page }) => {
});

test("relative timestamps", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.addInitScript(() => {
    window.initializeTestStubs = () => {
      window.e2eTestStubs.FakeTimers.install({
@@ -34,6 +42,10 @@ test("relative timestamps", async ({ page }) => {
});

test("modified file", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.goto(modifiedFileFixture);

  // Commit header.
@@ -59,6 +71,10 @@ test("modified file", async ({ page }) => {
});

test("created file", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.goto(
    `${projectFixtureUrl}/remotes/${bobRemote}/commits/d6318f7f3d9c15b8ac6dd52267c53220d00f0982`,
  );
@@ -69,6 +85,10 @@ test("created file", async ({ page }) => {
});

test("deleted file", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.goto(
    `${projectFixtureUrl}/remotes/${bobRemote}/commits/cd13c2d9a8a930d64a82b6134b44d1b872e33662`,
  );
modified tests/e2e/project/commits.spec.ts
@@ -6,6 +6,10 @@ import {
} from "@tests/support/fixtures.js";

test("peer and branch switching", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.goto(projectFixtureUrl);
  await page.locator('role=button[name="Commit count"]').click();

@@ -83,6 +87,10 @@ test("peer and branch switching", async ({ page }) => {
});

test("verified badge", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.goto(projectFixtureUrl);
  await page.locator('role=button[name="Commit count"]').click();

@@ -101,6 +109,10 @@ test("verified badge", async ({ page }) => {
});

test("relative timestamps", async ({ page }) => {
+
  if (process.env.HEARTWOOD) {
+
    test.skip();
+
  }
+

  await page.addInitScript(() => {
    window.initializeTestStubs = () => {
      window.e2eTestStubs.FakeTimers.install({
added tests/fixtures/seeds/palm-heartwood.tar.bz2
modified tests/support/fixtures.ts
@@ -160,7 +160,11 @@ function log(text: string, label: string, outputLog: Stream.Writable) {
  }
}

-
export function appConfigWithFixture() {
+
export const appConfigWithFixture = process.env.HEARTWOOD
+
  ? configFixtureHeartwood
+
  : configFixture;
+

+
export function configFixture() {
  window.APP_CONFIG = {
    walletConnect: {
      bridge: "https://radicle.bridge.walletconnect.org",
@@ -181,15 +185,41 @@ export function appConfigWithFixture() {
  };
}

+
export function configFixtureHeartwood() {
+
  window.APP_CONFIG = {
+
    walletConnect: {
+
      bridge: "https://radicle.bridge.walletconnect.org",
+
    },
+
    reactions: [],
+
    seeds: {
+
      pinned: [{ host: "0.0.0.0", emoji: "🚀" }],
+
    },
+
    projects: {
+
      pinned: [
+
        {
+
          name: "source-browsing",
+
          id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
+
          seed: "0.0.0.0",
+
        },
+
      ],
+
    },
+
  };
+
}
+

export const aliceMainHead = "fcc929424b82984b7cbff9c01d2e20d9b1249842";
-
export const aliceRemote =
-
  "hybg18bc4cu8z9xtj44skxperfdpxpp1wp8zygyzti5kfiggdizfxy";
+
export const aliceRemote = process.env.HEARTWOOD
+
  ? "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
  : "hybg18bc4cu8z9xtj44skxperfdpxpp1wp8zygyzti5kfiggdizfxy";
+

export const bobRemote =
  "hyyzz9w4ffg16zftjki3enajm4mkqkayb5ch1p6ns3f83np1hqkrp6";
-
export const rid = "hnrkdi8be7n4hhqoz9rpzrgd68u9dr3zsxgmy";
-
export const ridPrefix = "rad:git:";
+
export const rid = process.env.HEARTWOOD
+
  ? "zKtT7DmF9H34KkvcKj9PHW19WzjT"
+
  : "hnrkdi8be7n4hhqoz9rpzrgd68u9dr3zsxgmy";
+
export const ridPrefix = process.env.HEARTWOOD ? "rad:" : "rad:git:";
export const projectFixtureUrl = `/seeds/0.0.0.0/${ridPrefix}${rid}`;
-
export const seedPort = 8777;
-
export const seedVersion = "0.2.0";
-
export const seedRemote =
-
  "hybuytx44z9cfsm5739wecia9j4b7expgc15qkazph59szp57m4d3o";
+
export const seedPort = process.env.HEARTWOOD ? 8080 : "8777";
+
export const seedVersion = process.env.HEARTWOOD ? "0.1.0" : "0.2.0";
+
export const seedRemote = process.env.HEARTWOOD
+
  ? "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
  : "hybuytx44z9cfsm5739wecia9j4b7expgc15qkazph59szp57m4d3o";
modified tests/support/globalSetup.ts
@@ -9,24 +9,30 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
// 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<void> {
-
  const palmTestFixtureSeedId = seedRemote;
-

  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";
+
    (process.env.HEARTWOOD
+
      ? "👉 You can start it with `./scripts/run-httpd-with-fixtures`\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:${seedPort}`);
-
    const data = await response.json();
-
    peerId = data.peer.id;
+
    if (process.env.HEARTWOOD) {
+
      const response = await fetch(`http://0.0.0.0:${seedPort}/api`);
+
      const data = await response.json();
+
      peerId = data.node.id;
+
    } else {
+
      const response = await fetch(`http://0.0.0.0:${seedPort}`);
+
      const data = await response.json();
+
      peerId = data.peer.id;
+
    }
  } catch (err) {
    console.error(err);
    throw new Error(notRunningMessage);
  }

-
  if (peerId !== palmTestFixtureSeedId) {
+
  if (peerId !== seedRemote) {
    const wrongSeedMessage = `The server on port ${seedPort} doesn't have the right fixtures.\n`;
    throw new Error(wrongSeedMessage + notRunningMessage);
  }
modified tests/unit/router.test.ts
@@ -101,11 +101,11 @@ describe("routeToPath", () => {
        params: {
          view: { resource: "tree" },
          seed: "willow.radicle.garden",
-
          id: "rad:git:hnrkmg77m8tfzj4gi4pa4mbhgysfgzwntjpao",
+
          id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
        },
      },
      output:
-
        "/seeds/willow.radicle.garden/rad:git:hnrkmg77m8tfzj4gi4pa4mbhgysfgzwntjpao/tree",
+
        "/seeds/willow.radicle.garden/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT/tree",
      description: "Seed Project Route",
    },
  ])("$description", (route: any) => {
@@ -219,8 +219,7 @@ describe("pathToRoute", () => {
      description: "registrations Submit Route",
    },
    {
-
      input:
-
        "/seeds/willow.radicle.garden/rad:git:hnrkmg77m8tfzj4gi4pa4mbhgysfgzwntjpao",
+
      input: "/seeds/willow.radicle.garden/rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
      output: {
        resource: "projects",
        params: {
@@ -228,7 +227,7 @@ describe("pathToRoute", () => {
          seed: "willow.radicle.garden",
          profile: undefined,
          peer: undefined,
-
          id: "rad:git:hnrkmg77m8tfzj4gi4pa4mbhgysfgzwntjpao",
+
          id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
        },
      },
      description: "Seed Project Route",
modified tests/unit/utils.test.ts
@@ -37,9 +37,15 @@ describe("Format functions", () => {
  });

  test("formatRadicleId", () => {
-
    expect(
-
      utils.formatRadicleId("rad:git:hnrkemobagsicpf9sr95o3g551otspcd84c9o"),
-
    ).toEqual("rad:git:hnrkem…d84c9o");
+
    if (process.env.HEARTWOOD) {
+
      expect(utils.formatRadicleId("rad:zKtT7DmF9H34KkvcKj9PHW19WzjT")).toEqual(
+
        "rad:zKtT7D…19WzjT",
+
      );
+
    } else {
+
      expect(
+
        utils.formatRadicleId("rad:git:hnrkemobagsicpf9sr95o3g551otspcd84c9o"),
+
      ).toEqual("rad:git:hnrkem…d84c9o");
+
    }
  });

  test("formatRadicleId throw when wrong ID", () => {
@@ -117,7 +123,9 @@ describe("String Assertions", () => {
  });

  test.each([
-
    { id: "rad:git:hnrkemobagsicpf9sr95o3g551otspcd84c9o", expected: true },
+
    process.env.HEARTWOOD
+
      ? { id: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT", expected: true }
+
      : { id: "rad:git:hnrkemobagsicpf9sr95o3g551otspcd84c9o", expected: true },
    { id: "0x1234567890123456789012345678901234567890", expected: false },
  ])("isRadicleId $id => $expected", ({ id, expected }) => {
    expect(utils.isRadicleId(id)).toEqual(expected);
modified tests/visual/landingPage.spec.ts
@@ -5,6 +5,16 @@ test.use({
});

test("landing page", async ({ page }) => {
+
  await page.addInitScript(() => {
+
    window.initializeTestStubs = () => {
+
      window.e2eTestStubs.FakeTimers.install({
+
        now: new Date("November 24 2022 12:00:00").valueOf(),
+
        shouldClearNativeTimers: true,
+
        shouldAdvanceTime: false,
+
      });
+
    };
+
  });
+

  await page.addInitScript(appConfigWithFixture);
  await page.goto("/", { waitUntil: "networkidle" });
  await expect(page).toHaveScreenshot();
modified tests/visual/seed.spec.ts
@@ -1,6 +1,16 @@
import { test, expect } from "@tests/support/fixtures.js";

test("seed page", async ({ page }) => {
+
  await 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("/seeds/radicle.local", { waitUntil: "networkidle" });
  await expect(page).toHaveScreenshot();
});
modified vite.config.ts
@@ -12,6 +12,7 @@ function defineConstants() {
  const constants = {
    VITEST: process.env.VITEST !== undefined,
    PLAYWRIGHT: process.env.PLAYWRIGHT_TEST_BASE_URL !== undefined,
+
    HEARTWOOD: process.env.HEARTWOOD !== undefined,
  };

  // Don't overwrite HASH_ROUTING in Playwright tests, so we can control it