mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
Link Radicle entities from comments
References are picked or pasted in the composer, stored as `rad:` URIs with a readable label, and shown as chips or links that open in-app when the entity is here. Co-Authored-By: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
co-authored by
Rūdolfs Ošiņš
parent
990e51c38e
commit
91ab693b4b
@@ -0,0 +1,238 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { RadiclePeer } from "@tests/support/peerManager.js";
|
||||
|
||||
import { createProject } from "@tests/support/collaboration.js";
|
||||
import {
|
||||
expect,
|
||||
reload,
|
||||
test,
|
||||
waitForCommand,
|
||||
} from "@tests/support/fixtures.js";
|
||||
|
||||
async function openIssue(page: Page, peer: RadiclePeer) {
|
||||
const project = await createProject(peer);
|
||||
await page.goto(`/repos/${project.rid}/issues/${project.issueId}`);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "The notes are too quiet" }),
|
||||
).toBeVisible();
|
||||
return project;
|
||||
}
|
||||
|
||||
function commentBox(page: Page) {
|
||||
return page.getByPlaceholder("Leave a comment");
|
||||
}
|
||||
|
||||
async function type(page: Page, text: string) {
|
||||
await commentBox(page).click();
|
||||
await page.keyboard.insertText(text);
|
||||
}
|
||||
|
||||
function suggestion(page: Page, text: string) {
|
||||
return page.locator(".suggestion", { hasText: text });
|
||||
}
|
||||
|
||||
async function comment(page: Page) {
|
||||
await waitForCommand(page, "create_issue_comment", () =>
|
||||
page.getByRole("button", { name: /^Comment/ }).click(),
|
||||
);
|
||||
}
|
||||
|
||||
function rendered(page: Page) {
|
||||
return page.locator(".markdown");
|
||||
}
|
||||
|
||||
test("mentioning yourself inserts your DID", async ({ page, peer }) => {
|
||||
await openIssue(page, peer);
|
||||
|
||||
await type(page, "@ali");
|
||||
await expect(suggestion(page, "alice").first()).toContainText("you");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(commentBox(page)).toHaveValue(
|
||||
`[@alice](did:key:${peer.nodeId}) `,
|
||||
);
|
||||
|
||||
await comment(page);
|
||||
await reload(page);
|
||||
await expect(
|
||||
rendered(page).locator(".mention-node", { hasText: "alice" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("a pasted patch URI becomes a chip that opens the patch", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid, patchId } = await openIssue(page, peer);
|
||||
const uri = `${rid}/cob/xyz.radicle.patch/${patchId}`;
|
||||
|
||||
await type(page, uri);
|
||||
await expect(suggestion(page, "Shout the third line")).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(commentBox(page)).toHaveValue(`[Shout the third line](${uri}) `);
|
||||
|
||||
await comment(page);
|
||||
await reload(page);
|
||||
const chip = rendered(page).locator("a.mention", {
|
||||
hasText: "Shout the third line",
|
||||
});
|
||||
await expect(chip).toBeVisible();
|
||||
await chip.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/patches/${patchId}`));
|
||||
});
|
||||
|
||||
test("unsafe and invalid references stay inert", async ({ page, peer }) => {
|
||||
const { rid } = await openIssue(page, peer);
|
||||
|
||||
await commentBox(page).fill(
|
||||
`[click me](javascript:alert(1)) and ${rid}/bogus here`,
|
||||
);
|
||||
await comment(page);
|
||||
await reload(page);
|
||||
|
||||
const link = rendered(page).locator("a", { hasText: "click me" });
|
||||
await expect(link).toBeVisible();
|
||||
expect(await link.getAttribute("href")).toBeNull();
|
||||
await expect(rendered(page).getByText(`${rid}/bogus here`)).toBeVisible();
|
||||
await expect(rendered(page).locator("a.mention")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a typed commit prefix expands to the commit", async ({ page, peer }) => {
|
||||
const { rid, repoFolder } = await openIssue(page, peer);
|
||||
const { stdout: head } = await peer.git(["rev-parse", "main"], {
|
||||
cwd: repoFolder,
|
||||
});
|
||||
|
||||
await type(page, `#${head.slice(0, 7)}`);
|
||||
await expect(suggestion(page, "Add notes")).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(commentBox(page)).toHaveValue(
|
||||
`[Add notes](${rid}/commit/${head}) `,
|
||||
);
|
||||
|
||||
await comment(page);
|
||||
await reload(page);
|
||||
await rendered(page).locator("a.mention", { hasText: "Add notes" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/commits/${head}`));
|
||||
});
|
||||
|
||||
test("a pasted explorer file URL opens the file in-app", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid } = await openIssue(page, peer);
|
||||
const url = `https://radicle.network/nodes/seed.radicle.xyz/${rid}/tree/main/notes.txt#L3`;
|
||||
const uri = `${rid}/commit/main?path=notes.txt#L3`;
|
||||
|
||||
await type(page, url);
|
||||
await expect(suggestion(page, "notes.txt")).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(commentBox(page)).toHaveValue(
|
||||
`[project: notes.txt#L3](${uri}) `,
|
||||
);
|
||||
|
||||
await comment(page);
|
||||
await reload(page);
|
||||
const link = rendered(page).locator("a", {
|
||||
hasText: "project: notes.txt#L3",
|
||||
});
|
||||
await link.click({ button: "right" });
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Open in radicle.network" }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
`https://radicle.network/nodes/rosa.radicle.network/${rid}/tree/main/notes.txt#L3`,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Copy rad: URI" }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(/\/home\/main\?path=notes\.txt/);
|
||||
await expect(page.locator("code", { hasText: "three" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("every reference type renders and links where it should", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const { rid, issueId, patchId, repoFolder } = await openIssue(page, peer);
|
||||
const { stdout: head } = await peer.git(["rev-parse", "main"], {
|
||||
cwd: repoFolder,
|
||||
});
|
||||
const nid = peer.nodeId;
|
||||
const missingRid = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5";
|
||||
const missingOid = "f".repeat(40);
|
||||
const explorer = `https://radicle.network/nodes/rosa.radicle.network`;
|
||||
const repo = `/repos/${rid}`;
|
||||
|
||||
const references: [string, string][] = [
|
||||
["repo", rid],
|
||||
["issue", `${rid}/cob/xyz.radicle.issue/${issueId}`],
|
||||
["patch", `${rid}/cob/xyz.radicle.patch/${patchId}`],
|
||||
["commit", `${rid}/commit/${head}`],
|
||||
["person", `did:key:${nid}`],
|
||||
["file", `${rid}/commit/main?path=notes.txt#L2`],
|
||||
["root", `${rid}/commit/main?path=`],
|
||||
["branch", `${rid}/commit/main`],
|
||||
["tag", `${rid}/tag/v1.0`],
|
||||
["remote", `${rid}/${nid}`],
|
||||
["issues", `${rid}/cob/xyz.radicle.issue`],
|
||||
["patches", `${rid}/cob/xyz.radicle.patch`],
|
||||
["release", `${rid}/cob/dev.radicle.artifact/${missingOid}`],
|
||||
["releases", `${rid}/cob/dev.radicle.artifact`],
|
||||
["tree", `${rid}/tree/${missingOid}`],
|
||||
["missing repo", missingRid],
|
||||
["missing issue", `${rid}/cob/xyz.radicle.issue/${missingOid}`],
|
||||
];
|
||||
await commentBox(page).fill(
|
||||
references.map(([label, uri]) => `- [${label}](${uri})`).join("\n"),
|
||||
);
|
||||
await comment(page);
|
||||
await reload(page);
|
||||
const body = rendered(page).last();
|
||||
const link = (href: string) => body.locator(`a[href="${href}"]`);
|
||||
|
||||
await expect(link(`${repo}/home`).first()).toHaveClass(/mention/);
|
||||
await expect(link(`${repo}/issues/${issueId}?status=all`)).toContainText(
|
||||
"The notes are too quiet",
|
||||
);
|
||||
await expect(link(`${repo}/patches/${patchId}`)).toContainText(
|
||||
"Shout the third line",
|
||||
);
|
||||
await expect(link(`${repo}/commits/${head}`)).toContainText("Add notes");
|
||||
await expect(body.locator(".mention-node")).toContainText("alice");
|
||||
|
||||
await expect(link(`${repo}/home/main?path=notes.txt`)).toHaveText("file");
|
||||
await expect(link(`${repo}/home/main`)).toHaveText(["root", "branch"]);
|
||||
await expect(link(`${repo}/home/v1.0`)).toHaveText("tag");
|
||||
await expect(link(`${repo}/home/remotes/${nid}`)).toHaveText("remote");
|
||||
await expect(link(`${repo}/issues?status=all`)).toHaveText("issues");
|
||||
await expect(link(`${repo}/patches`)).toHaveText("patches");
|
||||
await expect(
|
||||
link(`${explorer}/${rid}/releases/${missingOid}`),
|
||||
).toHaveAttribute("target", "_blank");
|
||||
await expect(link(`${explorer}/${rid}/releases`)).toHaveText("releases");
|
||||
await expect(
|
||||
body.locator("a:not([href])", { hasText: "tree" }),
|
||||
).toBeVisible();
|
||||
|
||||
const missingRepo = link(`${explorer}/${missingRid}`);
|
||||
await expect(missingRepo).toHaveClass(/unresolved/);
|
||||
await expect(missingRepo).toContainText("z3gqcJ");
|
||||
const missingIssue = link(`${explorer}/${rid}/issues/${missingOid}`);
|
||||
await expect(missingIssue).toHaveClass(/unresolved/);
|
||||
await expect(missingIssue).toContainText("missing issue");
|
||||
await expect(missingIssue).toContainText("fffffff");
|
||||
|
||||
await link(`${repo}/patches/${patchId}`).click({ button: "right" });
|
||||
await expect(page.getByRole("menuitem")).toHaveText([
|
||||
"Open in radicle.network",
|
||||
"Copy link to radicle.network",
|
||||
"Copy rad: URI",
|
||||
]);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await link(`${repo}/issues?status=all`).click();
|
||||
await expect(page).toHaveURL(/\/issues\?status=all$/);
|
||||
});
|
||||
@@ -32,6 +32,28 @@ describe("cached", () => {
|
||||
expect(f).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("keeps a miss only for missTtl", async () => {
|
||||
const f = vi.fn(async () => null);
|
||||
const c = cached(f, () => "key", { max: 10, ttl: 60_000 }, 1);
|
||||
|
||||
await c();
|
||||
await c();
|
||||
expect(f).toHaveBeenCalledTimes(1);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await c();
|
||||
expect(f).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("keeps a hit for the full ttl", async () => {
|
||||
const f = vi.fn(async () => "hit");
|
||||
const c = cached(f, () => "key", { max: 10, ttl: 60_000 }, 1);
|
||||
|
||||
await c();
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await c();
|
||||
expect(f).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("retries after a rejection", async () => {
|
||||
const f = vi
|
||||
.fn<() => Promise<string>>()
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { findMentionTrigger } from "@app/lib/mentionTrigger";
|
||||
|
||||
const repo = "z3gqcJUoA1n9HaHKufZs5FCSGazv5";
|
||||
const rid = `rad:${repo}`;
|
||||
const nid = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
|
||||
const oid = "5e4683023476778201ab25c87e1343fbfbe67c89";
|
||||
|
||||
function trigger(text: string, caret = text.length) {
|
||||
return findMentionTrigger(text, caret);
|
||||
}
|
||||
|
||||
describe("queries", () => {
|
||||
test.each([
|
||||
["@", { scope: "all", start: 0, query: "" }],
|
||||
["#", { scope: "cobs", start: 0, query: "" }],
|
||||
["hi @rud", { scope: "all", start: 3, query: "rud" }],
|
||||
["(#fix", { scope: "cobs", start: 1, query: "fix" }],
|
||||
["line\n@a.b-c", { scope: "all", start: 5, query: "a.b-c" }],
|
||||
])("%j", (text, expected) => {
|
||||
expect(trigger(text)).toEqual({
|
||||
kind: "query",
|
||||
end: text.length,
|
||||
...expected,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["an email address", "me@example"],
|
||||
["a URL fragment", "page#section"],
|
||||
["a heading", "# Heading"],
|
||||
["a query past the limit", `@${"a".repeat(65)}`],
|
||||
])("ignores %s", (_, text) => {
|
||||
expect(trigger(text)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("only considers text up to the caret", () => {
|
||||
expect(trigger("@rud and more", 4)).toMatchObject({ query: "rud" });
|
||||
expect(trigger("@rud and more")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("identifiers", () => {
|
||||
test.each([
|
||||
[`see did:key:${nid}`, 4, { type: "node", nid }],
|
||||
[`(${rid}`, 1, { type: "repo", rid }],
|
||||
[
|
||||
`https://app.radicle.xyz/nodes/seed/${rid}/issues/${oid}`,
|
||||
0,
|
||||
{ type: "cob", kind: "issue", rid, oid },
|
||||
],
|
||||
[`https://app.radicle.xyz/nodes/seed/${rid}`, 0, { type: "repo", rid }],
|
||||
[nid, 0, { type: "node", nid }],
|
||||
[`x ${repo}`, 2, { type: "repo", rid }],
|
||||
])("%s", (text, start, target) => {
|
||||
expect(trigger(text)).toEqual({
|
||||
kind: "identifier",
|
||||
start,
|
||||
end: text.length,
|
||||
target,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not reopen on an inserted link", () => {
|
||||
expect(trigger(`[heartwood](${rid})`)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("offers a full oid, lowercased", () => {
|
||||
expect(trigger(`see ${oid.toUpperCase()}`)).toEqual({
|
||||
kind: "oid",
|
||||
start: 4,
|
||||
end: 44,
|
||||
oid,
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores an abbreviated oid", () => {
|
||||
expect(trigger(oid.slice(0, 39))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("explorer file links", () => {
|
||||
test("defers the branch and path split to the repo", () => {
|
||||
const url = `https://radicle.network/nodes/seed/${rid}/tree/0004-general-uri-scheme/general-uri-scheme.adoc`;
|
||||
expect(trigger(`see ${url}`)).toEqual({
|
||||
kind: "tree",
|
||||
start: 4,
|
||||
end: 4 + url.length,
|
||||
rid,
|
||||
namespace: undefined,
|
||||
path: "0004-general-uri-scheme/general-uri-scheme.adoc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rad: file links", () => {
|
||||
test("offers a file URI as a file", () => {
|
||||
const uri = `${rid}/commit/master?path=.github/README.md`;
|
||||
expect(trigger(`see ${uri}`)).toMatchObject({
|
||||
kind: "link",
|
||||
start: 4,
|
||||
end: 4 + uri.length,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a tag", `${rid}/tag/v1.0`],
|
||||
["a branch", `${rid}/commit/main`],
|
||||
["a remote", `${rid}/${nid}`],
|
||||
[
|
||||
"a remote's explorer page",
|
||||
`https://radicle.network/nodes/seed/${rid}/remotes/${nid}`,
|
||||
],
|
||||
])("offers %s as a link", (_, text) => {
|
||||
expect(trigger(text)).toMatchObject({ kind: "link", start: 0 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a tree by oid", `${rid}/tree/${oid}`],
|
||||
["a blob by oid", `${rid}/blob/${oid}`],
|
||||
["an unknown cob type", `${rid}/cob/org.example/${oid}`],
|
||||
])("offers nothing for %s, which has no page", (_, text) => {
|
||||
expect(trigger(text)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not take the tail of a path as a bare id", () => {
|
||||
expect(trigger(`/some/path/${nid}`)).toBeUndefined();
|
||||
expect(trigger(`see:${oid}`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("explorer links without a chip", () => {
|
||||
const node = "https://radicle.network/nodes/seed";
|
||||
|
||||
test.each([
|
||||
["an issue list", `${node}/${rid}/issues`],
|
||||
["a path at an oid", `${node}/${rid}/tree/${oid}/src`],
|
||||
["a tree at an oid", `${node}/${rid}/tree/${oid}`],
|
||||
])("offers %s as a link", (_, url) => {
|
||||
expect(trigger(url)).toMatchObject({ kind: "link", start: 0 });
|
||||
});
|
||||
|
||||
test("leaves a lone branch for the repo to resolve", () => {
|
||||
expect(trigger(`${node}/${rid}/tree/main`)).toMatchObject({
|
||||
kind: "tree",
|
||||
path: "main",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("releases and lines", () => {
|
||||
const node = "https://radicle.network/nodes/seed";
|
||||
|
||||
test("offers a release as a link", () => {
|
||||
expect(trigger(`${node}/${rid}/releases/${oid}`)).toMatchObject({
|
||||
kind: "link",
|
||||
reference: {
|
||||
uri: {
|
||||
resource: { type: "cob", typeName: "dev.radicle.artifact", oid },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the line of a file on a branch", () => {
|
||||
expect(trigger(`${node}/${rid}/tree/main/src/lib.rs#L10`)).toMatchObject({
|
||||
kind: "tree",
|
||||
path: "main/src/lib.rs",
|
||||
fragment: "L10",
|
||||
});
|
||||
});
|
||||
|
||||
test("offers a rad: file URI with a line as a link", () => {
|
||||
expect(trigger(`${rid}/commit/main?path=src/lib.rs#L10`)).toMatchObject({
|
||||
kind: "link",
|
||||
reference: { uri: { fragment: "L10" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { markdownWithExtensions } from "@app/lib/markdown";
|
||||
import {
|
||||
bareReferenceStart,
|
||||
describeLink,
|
||||
entityMarkdown,
|
||||
entityUri,
|
||||
entityUrl,
|
||||
matchBareReference,
|
||||
parseBareIdentifier,
|
||||
parseEntityHref,
|
||||
referenceMarkdown,
|
||||
referenceRoute,
|
||||
} from "@app/lib/mentions";
|
||||
import { parseReference } from "@app/lib/radUri";
|
||||
|
||||
const repo = "z3gqcJUoA1n9HaHKufZs5FCSGazv5";
|
||||
const rid = `rad:${repo}`;
|
||||
const nid = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
|
||||
const oid = "5e4683023476778201ab25c87e1343fbfbe67c89";
|
||||
|
||||
describe("parseEntityHref", () => {
|
||||
test.each([
|
||||
[`did:key:${nid}`, { type: "node", nid }],
|
||||
[rid, { type: "repo", rid }],
|
||||
[`${rid}/commit/${oid}`, { type: "commit", rid, oid }],
|
||||
[
|
||||
`${rid}/cob/xyz.radicle.issue/${oid}`,
|
||||
{ type: "cob", kind: "issue", rid, oid },
|
||||
],
|
||||
[
|
||||
`${rid}/cob/xyz.radicle.patch/${oid}`,
|
||||
{ type: "cob", kind: "patch", rid, oid },
|
||||
],
|
||||
[
|
||||
`https://app.radicle.xyz/nodes/seed/${rid}/patches/${oid}`,
|
||||
{ type: "cob", kind: "patch", rid, oid },
|
||||
],
|
||||
[` ${rid} `, { type: "repo", rid }],
|
||||
])("%s", (href, expected) => {
|
||||
expect(parseEntityHref(href)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a tag", `${rid}/tag/v1.0`],
|
||||
["a tree", `${rid}/tree/${oid}`],
|
||||
["a commit by branch", `${rid}/commit/master`],
|
||||
["an unknown cob type", `${rid}/cob/org.example/${oid}`],
|
||||
["a cob list", `${rid}/cob/xyz.radicle.issue`],
|
||||
["a namespace", `${rid}/${nid}`],
|
||||
["a query", `${rid}/commit/${oid}?path=src`],
|
||||
["a fragment", `${rid}/commit/${oid}#L10`],
|
||||
["an unrelated link", "https://example.com"],
|
||||
])("has no chip for %s", (_, href) => {
|
||||
expect(parseEntityHref(href)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBareIdentifier", () => {
|
||||
test.each([
|
||||
[nid, { type: "node", nid }],
|
||||
[repo, { type: "repo", rid }],
|
||||
["zlib", undefined],
|
||||
[oid, undefined],
|
||||
])("%s", (token, expected) => {
|
||||
expect(parseBareIdentifier(token)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bareReferenceStart", () => {
|
||||
test.each([
|
||||
[`${rid} at the start`, 0],
|
||||
[`see ${rid}`, 4],
|
||||
[`(did:key:${nid})`, 1],
|
||||
[`Conrad:${repo}`, undefined],
|
||||
[`Conrad:${repo} and ${rid}`, 41],
|
||||
])("%s", (src, expected) => {
|
||||
expect(bareReferenceStart(src)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchBareReference", () => {
|
||||
test.each([
|
||||
[`${rid}.`, rid],
|
||||
[`${rid}).`, rid],
|
||||
[`${rid}'s`, rid],
|
||||
[`${rid}, then`, rid],
|
||||
[`did:key:${nid}!`, `did:key:${nid}`],
|
||||
[`${rid}/commit/master?path=src.`, `${rid}/commit/master?path=src`],
|
||||
])("%s", (src, raw) => {
|
||||
expect(matchBareReference(src)?.raw).toBe(raw);
|
||||
});
|
||||
|
||||
test("gives up on a run too long to be a reference", () => {
|
||||
const start = performance.now();
|
||||
expect(matchBareReference(`rad:z${"'a".repeat(50_000)}`)).toBeUndefined();
|
||||
expect(performance.now() - start).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test("leaves an invalid reference as prose", () => {
|
||||
expect(matchBareReference(`${rid}/bogus here`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown", () => {
|
||||
const render = (src: string) => markdownWithExtensions.parse(src) as string;
|
||||
|
||||
test("links a bare reference", () => {
|
||||
expect(render(`See ${rid}.`)).toBe(
|
||||
`<p>See <a href="${rid}">${rid}</a>.</p>\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves the tail of a word alone", () => {
|
||||
expect(render(`Conrad:${repo}`)).not.toContain("<a");
|
||||
});
|
||||
|
||||
test("does not nest a link inside a link label", () => {
|
||||
expect(render(`[see ${rid}](https://example.com)`)).toBe(
|
||||
`<p><a href="https://example.com">see ${rid}</a></p>\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves code alone", () => {
|
||||
expect(render(`\`${rid}\``)).toBe(`<p><code>${rid}</code></p>\n`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialising", () => {
|
||||
const issue = { type: "cob", kind: "issue", rid, oid } as const;
|
||||
|
||||
test("entityUri writes the RIP 4 form", () => {
|
||||
expect(entityUri(issue)).toBe(`${rid}/cob/xyz.radicle.issue/${oid}`);
|
||||
expect(entityUri({ type: "node", nid })).toBe(`did:key:${nid}`);
|
||||
expect(entityUri({ ...issue, kind: "patch" })).toBe(
|
||||
`${rid}/cob/xyz.radicle.patch/${oid}`,
|
||||
);
|
||||
expect(entityUri({ type: "commit", rid, oid })).toBe(
|
||||
`${rid}/commit/${oid}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("referenceMarkdown links any reference", () => {
|
||||
expect(
|
||||
referenceMarkdown(
|
||||
{
|
||||
type: "uri",
|
||||
uri: { repo, resource: { type: "commit", ref: "main" } },
|
||||
},
|
||||
"heartwood: README",
|
||||
),
|
||||
).toBe(`[heartwood: README](${rid}/commit/main)`);
|
||||
});
|
||||
|
||||
test("entityMarkdown escapes the label", () => {
|
||||
expect(entityMarkdown(issue, "Fix [x] \\ y")).toBe(
|
||||
`[Fix \\[x\\] \\\\ y](${rid}/cob/xyz.radicle.issue/${oid})`,
|
||||
);
|
||||
});
|
||||
|
||||
test("entityUrl uses the configured explorer and preferred seed", () => {
|
||||
const config = {
|
||||
publicExplorer: "https://explorer.example/nodes/$host/$rid$path",
|
||||
preferredSeeds: [`${nid}@seed.example.com:8776`],
|
||||
} as Config;
|
||||
|
||||
expect(entityUrl(issue, config)).toBe(
|
||||
`https://explorer.example/nodes/seed.example.com/${rid}/issues/${oid}`,
|
||||
);
|
||||
expect(entityUrl({ type: "node", nid }, config)).toBe(
|
||||
`https://explorer.example/nodes/seed.example.com/users/did:key:${nid}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("entityUrl keeps a path prefix in the explorer URL", () => {
|
||||
const config = {
|
||||
publicExplorer: "https://example.com/explorer/nodes/$host/$rid$path",
|
||||
preferredSeeds: [],
|
||||
} as unknown as Config;
|
||||
|
||||
expect(entityUrl({ type: "repo", rid }, config)).toBe(
|
||||
`https://example.com/explorer/nodes/rosa.radicle.network/${rid}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("referenceRoute", () => {
|
||||
test.each([
|
||||
[
|
||||
`${rid}/commit/master?path=.github/README.md`,
|
||||
{
|
||||
resource: "repo.home",
|
||||
rid,
|
||||
revision: "master",
|
||||
path: ".github/README.md",
|
||||
},
|
||||
],
|
||||
[
|
||||
`${rid}/commit/main?path=My%20File.md`,
|
||||
{ resource: "repo.home", rid, revision: "main", path: "My File.md" },
|
||||
],
|
||||
[
|
||||
`${rid}/commit/refs/heads/main?path=`,
|
||||
{ resource: "repo.home", rid, revision: "main" },
|
||||
],
|
||||
[
|
||||
`${rid}/${nid}/commit/main?path=src`,
|
||||
{ resource: "repo.home", rid, peer: nid, revision: "main", path: "src" },
|
||||
],
|
||||
[`${rid}/tag/v1.0`, { resource: "repo.home", rid, revision: "v1.0" }],
|
||||
[`${rid}/commit/${oid}`, { resource: "repo.commit", rid, commit: oid }],
|
||||
[
|
||||
`${rid}/cob/xyz.radicle.issue/${oid}`,
|
||||
{ resource: "repo.issue", rid, issue: oid, status: "all" },
|
||||
],
|
||||
[
|
||||
`${rid}/cob/xyz.radicle.patch/${oid}`,
|
||||
{
|
||||
resource: "repo.patch",
|
||||
rid,
|
||||
patch: oid,
|
||||
status: undefined,
|
||||
reviewId: undefined,
|
||||
},
|
||||
],
|
||||
[`${rid}/${nid}`, { resource: "repo.home", rid, peer: nid }],
|
||||
[
|
||||
`${rid}/cob/xyz.radicle.issue`,
|
||||
{ resource: "repo.issues", rid, status: "all" },
|
||||
],
|
||||
[
|
||||
`${rid}/cob/xyz.radicle.patch`,
|
||||
{ resource: "repo.patches", rid, status: undefined },
|
||||
],
|
||||
])("%s", (href, expected) => {
|
||||
expect(referenceRoute(parseReference(href)!)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
`${rid}/cob/dev.radicle.artifact`,
|
||||
`${rid}/tree/${oid}`,
|
||||
`did:key:${nid}`,
|
||||
])("has no page for %s", href => {
|
||||
expect(referenceRoute(parseReference(href)!)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeLink", () => {
|
||||
test.each([
|
||||
[`${rid}/commit/main?path=src/lib.rs`, "heartwood: src/lib.rs", "document"],
|
||||
[
|
||||
`${rid}/commit/main?path=src/lib.rs#L10`,
|
||||
"heartwood: src/lib.rs#L10",
|
||||
"document",
|
||||
],
|
||||
[`${rid}/commit/main?path=`, "heartwood: main", "folder"],
|
||||
[`${rid}/commit/${oid}?path=`, "heartwood: 5e46830", "folder"],
|
||||
[`${rid}/commit/refs/heads/main`, "heartwood: main", "branch"],
|
||||
[`${rid}/tag/v1.0`, "heartwood: v1.0", "label"],
|
||||
[`${rid}/cob/xyz.radicle.issue`, "heartwood: issues", "issue"],
|
||||
[`${rid}/cob/xyz.radicle.patch`, "heartwood: patches", "patch"],
|
||||
[`${rid}/cob/dev.radicle.artifact`, "heartwood: releases", "archive"],
|
||||
[
|
||||
`${rid}/cob/dev.radicle.artifact/${oid}`,
|
||||
"heartwood: release 5e46830",
|
||||
"archive",
|
||||
],
|
||||
[`${rid}/${nid}`, "heartwood: z6MknS…tBU8Vi", "repository"],
|
||||
])("%s", (href, label, icon) => {
|
||||
expect(describeLink(parseReference(href)!, "heartwood")).toMatchObject({
|
||||
label,
|
||||
icon,
|
||||
});
|
||||
});
|
||||
|
||||
test("names a remote by its alias", () => {
|
||||
expect(
|
||||
describeLink(parseReference(`${rid}/${nid}`)!, "heartwood", "cloudhead"),
|
||||
).toMatchObject({ label: "heartwood: cloudhead" });
|
||||
});
|
||||
|
||||
test.each([
|
||||
rid,
|
||||
`${rid}/commit/${oid}`,
|
||||
`${rid}/cob/xyz.radicle.issue/${oid}`,
|
||||
`${rid}/tree/${oid}`,
|
||||
`${rid}/${nid}/cob/xyz.radicle.issue/${oid}`,
|
||||
`did:key:${nid}`,
|
||||
])("is not a link for %s", href => {
|
||||
expect(describeLink(parseReference(href)!, "heartwood")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hostile input", () => {
|
||||
test("renders a run of references in linear time", () => {
|
||||
const start = performance.now();
|
||||
void markdownWithExtensions.parse("rad:".repeat(25_000));
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test("links at most a fixed number of references per document", () => {
|
||||
const start = performance.now();
|
||||
const html = markdownWithExtensions.parse(
|
||||
`${rid} `.repeat(5_000),
|
||||
) as string;
|
||||
expect(performance.now() - start).toBeLessThan(1_000);
|
||||
expect(html.match(/<a /g)?.length).toBe(200);
|
||||
});
|
||||
|
||||
test.each([
|
||||
`${rid}/commit/../../../inbox`,
|
||||
`${rid}/commit/.hidden`,
|
||||
`${rid}/commit/main.lock`,
|
||||
`${rid}/tag/v1.`,
|
||||
`${rid}/commit/a..b?path=src`,
|
||||
])("has no route or link for the unsafe ref in %s", href => {
|
||||
const reference = parseReference(href)!;
|
||||
expect(referenceRoute(reference)).toBeUndefined();
|
||||
expect(describeLink(reference, "heartwood")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+230
-10
@@ -2,9 +2,15 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { RadUri } from "@app/lib/radUri";
|
||||
import {
|
||||
cobListType,
|
||||
explorerUrl,
|
||||
filePath,
|
||||
fileReference,
|
||||
formatRadUri,
|
||||
isFileReference,
|
||||
isSafeRef,
|
||||
issueType,
|
||||
parseExplorerTreeUrl,
|
||||
parseExplorerUrl,
|
||||
parseRadUri,
|
||||
parseReference,
|
||||
@@ -419,7 +425,6 @@ describe("explorer URL edge cases", () => {
|
||||
["users before the end", `${node}/users/did:key:${node01}/x`],
|
||||
["a branch followed by a path", `${node}/rad:${repo00}/tree/feature/x`],
|
||||
["a history page", `${node}/rad:${repo00}/history/main`],
|
||||
["an issue with extra segments", `${node}/rad:${repo00}/issues/${oid}/x`],
|
||||
[
|
||||
"an invalid remote",
|
||||
`${node}/rad:${repo00}/remotes/z6Mk11111111111111111111111111111111111111111111`,
|
||||
@@ -454,19 +459,43 @@ describe("explorer URL edge cases", () => {
|
||||
{ repo: repo00, namespace: node01 },
|
||||
],
|
||||
[
|
||||
"a branch",
|
||||
`${node}/rad:${repo00}/tree/main`,
|
||||
{ repo: repo00, resource: { type: "commit", ref: "main" } },
|
||||
],
|
||||
[
|
||||
"a remote's branch",
|
||||
`${node}/rad:${repo00}/remotes/${node01}/tree/main`,
|
||||
"a tree at an oid",
|
||||
`${node}/rad:${repo00}/tree/${oid}`,
|
||||
{
|
||||
repo: repo00,
|
||||
namespace: node01,
|
||||
resource: { type: "commit", ref: "main" },
|
||||
resource: { type: "commit", ref: oid },
|
||||
query: [{ param: "path", value: "" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"an encoded path at an oid",
|
||||
`${node}/rad:${repo00}/tree/${oid}/My%20File.md`,
|
||||
{
|
||||
repo: repo00,
|
||||
resource: { type: "commit", ref: oid },
|
||||
query: [{ param: "path", value: "My%20File.md" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"an issue list",
|
||||
`${node}/rad:${repo00}/issues?status=open`,
|
||||
{ repo: repo00, resource: { type: "cob", typeName: issueType } },
|
||||
],
|
||||
[
|
||||
"a patch list",
|
||||
`${node}/rad:${repo00}/patches`,
|
||||
{ repo: repo00, resource: { type: "cob", typeName: patchType } },
|
||||
],
|
||||
[
|
||||
"a release list",
|
||||
`${node}/rad:${repo00}/releases`,
|
||||
{ repo: repo00, resource: { type: "cob", typeName: releaseType } },
|
||||
],
|
||||
[
|
||||
"a bare repo id",
|
||||
`${node}/${repo00}/issues/${oid}`,
|
||||
{ repo: repo00, resource: { type: "cob", typeName: issueType, oid } },
|
||||
],
|
||||
[
|
||||
"a path at a commit",
|
||||
`${node}/rad:${repo00}/tree/${oid}/src/lib`,
|
||||
@@ -487,3 +516,194 @@ describe("explorer URL edge cases", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("file links", () => {
|
||||
const base = "https://radicle.network";
|
||||
const node = `${base}/nodes/seed.example.com`;
|
||||
|
||||
test.each([
|
||||
[
|
||||
`${node}/rad:${repo00}/tree/0004-general-uri-scheme/general-uri-scheme.adoc`,
|
||||
{ repo: repo00, path: "0004-general-uri-scheme/general-uri-scheme.adoc" },
|
||||
],
|
||||
[
|
||||
`${node}/rad:${repo00}/remotes/${node01}/tree/feature/x/src`,
|
||||
{ repo: repo00, namespace: node01, path: "feature/x/src" },
|
||||
],
|
||||
[
|
||||
`${node}/rad:${repo00}/tree/main/My%20File.md`,
|
||||
{ repo: repo00, path: "main/My File.md" },
|
||||
],
|
||||
])("parseExplorerTreeUrl reads %s", (url, expected) => {
|
||||
expect(parseExplorerTreeUrl(url)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
"a branch",
|
||||
`${node}/rad:${repo00}/tree/main`,
|
||||
{ repo: repo00, path: "main" },
|
||||
],
|
||||
[
|
||||
"a remote's branch",
|
||||
`${node}/rad:${repo00}/remotes/${node01}/tree/main`,
|
||||
{ repo: repo00, namespace: node01, path: "main" },
|
||||
],
|
||||
])("parseExplorerTreeUrl reads %s on its own", (_, url, expected) => {
|
||||
expect(parseExplorerTreeUrl(url)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a tree at an oid", `${node}/rad:${repo00}/tree/${oid}/src`],
|
||||
["another page", `${node}/rad:${repo00}/history/main/src`],
|
||||
])("parseExplorerTreeUrl leaves %s to parseExplorerUrl", (_, url) => {
|
||||
expect(parseExplorerTreeUrl(url)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("fileReference writes a path query that maps back to the file", () => {
|
||||
const reference = fileReference(
|
||||
{ repo: repo00 },
|
||||
"main",
|
||||
"docs/My File.md",
|
||||
)!;
|
||||
expect(
|
||||
formatRadUri(reference.type === "uri" ? reference.uri : { repo: "" }),
|
||||
).toBe(`rad:${repo00}/commit/main?path=docs/My%20File.md`);
|
||||
expect(explorerUrl(reference, base, "seed.example.com")).toBe(
|
||||
`${node}/rad:${repo00}/tree/main/docs/My%20File.md`,
|
||||
);
|
||||
});
|
||||
|
||||
test("fileReference keeps the remote", () => {
|
||||
expect(
|
||||
explorerUrl(
|
||||
fileReference({ repo: repo00, namespace: node01 }, "main", "src")!,
|
||||
base,
|
||||
"seed.example.com",
|
||||
),
|
||||
).toBe(`${node}/rad:${repo00}/remotes/${node01}/tree/main/src`);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[`rad:${repo00}/commit/main?path=src`, true],
|
||||
[`rad:${repo00}/tag/v1.0?blob=README.md`, true],
|
||||
[`rad:${repo00}/commit/main`, false],
|
||||
[`rad:${repo00}/commit/main?path=`, true],
|
||||
[`rad:${repo00}?path=src`, false],
|
||||
[`did:key:${node01}`, false],
|
||||
])("isFileReference(%s) is %s", (input, expected) => {
|
||||
expect(isFileReference(parseReference(input)!)).toBe(expected);
|
||||
});
|
||||
|
||||
test("filePath decodes the path", () => {
|
||||
expect(
|
||||
filePath(
|
||||
parseReference(`rad:${repo00}/commit/main?path=docs/My%20File.md`)!,
|
||||
),
|
||||
).toEqual({ revision: "main", path: "docs/My File.md" });
|
||||
expect(
|
||||
filePath(parseReference(`rad:${repo00}/commit/main`)!),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("fileReference rejects a revision a URI cannot hold", () => {
|
||||
expect(fileReference({ repo: repo00 }, "a b", "src")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tree roots and lists", () => {
|
||||
const base = "https://radicle.network";
|
||||
const node = `${base}/nodes/seed`;
|
||||
|
||||
test.each([
|
||||
`${node}/rad:${repo00}/tree/${oid}`,
|
||||
`${node}/rad:${repo00}/issues`,
|
||||
`${node}/rad:${repo00}/patches`,
|
||||
`${node}/rad:${repo00}/releases`,
|
||||
])("maps %s both ways", url => {
|
||||
expect(explorerUrl(parseExplorerUrl(url)!, base, "seed")).toBe(url);
|
||||
});
|
||||
|
||||
test("fileReference with an empty path names the root", () => {
|
||||
expect(
|
||||
explorerUrl(fileReference({ repo: repo00 }, "main", "")!, base, "seed"),
|
||||
).toBe(`${node}/rad:${repo00}/tree/main`);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[`rad:${repo00}/cob/${issueType}`, issueType],
|
||||
[`rad:${repo00}/cob/${issueType}/${oid}`, undefined],
|
||||
[`rad:${repo00}`, undefined],
|
||||
])("cobListType(%s) is %s", (input, expected) => {
|
||||
expect(cobListType(parseReference(input)!)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line fragments", () => {
|
||||
const base = "https://radicle.network";
|
||||
const node = `${base}/nodes/seed`;
|
||||
|
||||
test("a line of a file maps both ways", () => {
|
||||
const url = `${node}/rad:${repo00}/tree/${oid}/src/lib.rs#L10`;
|
||||
const reference = parseExplorerUrl(url)!;
|
||||
expect(reference).toMatchObject({ uri: { fragment: "L10" } });
|
||||
expect(explorerUrl(reference, base, "seed")).toBe(url);
|
||||
});
|
||||
|
||||
test("parseExplorerTreeUrl keeps the line", () => {
|
||||
expect(
|
||||
parseExplorerTreeUrl(`${node}/rad:${repo00}/tree/main/src/lib.rs#L10`),
|
||||
).toEqual({ repo: repo00, path: "main/src/lib.rs", fragment: "L10" });
|
||||
});
|
||||
|
||||
test("drops a fragment a URI cannot hold", () => {
|
||||
expect(
|
||||
parseExplorerUrl(`${node}/rad:${repo00}/tree/${oid}/src#a{b}`),
|
||||
).toMatchObject({ uri: { fragment: undefined } });
|
||||
});
|
||||
|
||||
test("only files carry a fragment onto the explorer", () => {
|
||||
expect(
|
||||
explorerUrl(
|
||||
parseReference(`rad:${repo00}/commit/${oid}#L10`)!,
|
||||
base,
|
||||
"seed",
|
||||
),
|
||||
).toBe(`${node}/rad:${repo00}/commits/${oid}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("explorer paths stay on the page they name", () => {
|
||||
const base = "https://radicle.network";
|
||||
const link = (input: string) =>
|
||||
explorerUrl(parseReference(input)!, base, "seed");
|
||||
|
||||
test.each([
|
||||
`rad:${repo00}/commit/../..`,
|
||||
`rad:${repo00}/commit/main?path=../../users`,
|
||||
`rad:${repo00}/commit/main?path=src/./lib`,
|
||||
])("has no page for %s", input => {
|
||||
expect(link(input)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("encodes characters a path segment cannot hold", () => {
|
||||
expect(link(`rad:${repo00}/commit/main?path=a?b`)).toBe(
|
||||
`${base}/nodes/seed/rad:${repo00}/tree/main/a%3Fb`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
["main", true],
|
||||
["feature/x", true],
|
||||
["v1.0", true],
|
||||
[oid, true],
|
||||
["..", false],
|
||||
["a/../b", false],
|
||||
["a..b", false],
|
||||
[".hidden", false],
|
||||
["main.lock", false],
|
||||
["v1.", false],
|
||||
])("isSafeRef(%j) is %j", (ref, expected) => {
|
||||
expect(isSafeRef(ref)).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,10 @@ describe("repoRouteToPath and repoUrlToRoute round trip", () => {
|
||||
"/repos/RID/home/remotes/PEER/feature/branch",
|
||||
{ resource: "repo.home", rid, peer, revision: "feature/branch" },
|
||||
],
|
||||
[
|
||||
"/repos/RID/home/main?path=src%2Flib.rs",
|
||||
{ resource: "repo.home", rid, revision: "main", path: "src/lib.rs" },
|
||||
],
|
||||
["/repos/RID/commits", { resource: "repo.commits", rid }],
|
||||
[
|
||||
"/repos/RID/commits/main",
|
||||
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
coAuthors,
|
||||
creditedCoAuthors,
|
||||
explorerHost,
|
||||
explorerUrl,
|
||||
explorerLink,
|
||||
formatGitIdentity,
|
||||
formatRepositoryId,
|
||||
formatTimestamp,
|
||||
identityKey,
|
||||
isCommit,
|
||||
isPublishableReview,
|
||||
parseNodeId,
|
||||
parseRepositoryId,
|
||||
@@ -162,15 +161,6 @@ test("truncateDid", () => {
|
||||
expect(truncateDid(`did:key:${nid}`)).toBe("did:key:z6MkqG…WTvLSK");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["a".repeat(40), true],
|
||||
["A".repeat(40), false],
|
||||
["a".repeat(39), false],
|
||||
["g".repeat(40), false],
|
||||
])("isCommit(%j) is %j", (input, expected) => {
|
||||
expect(isCommit(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["refs/heads/main", "main"],
|
||||
["refs/heads/feature/x", "feature/x"],
|
||||
@@ -246,10 +236,12 @@ describe("explorer links", () => {
|
||||
} as Config;
|
||||
}
|
||||
|
||||
const repo = { type: "uri", uri: { repo: rid.slice(4) } } as const;
|
||||
|
||||
test("uses the first preferred seed host", () => {
|
||||
expect(
|
||||
explorerUrl(
|
||||
`${rid}/issues/abc`,
|
||||
explorerLink(
|
||||
repo,
|
||||
config({
|
||||
preferredSeeds: [
|
||||
`${nid}@seed.example.com:8776`,
|
||||
@@ -257,11 +249,11 @@ describe("explorer links", () => {
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(`https://radicle.network/nodes/seed.example.com/${rid}/issues/abc`);
|
||||
).toBe(`https://radicle.network/nodes/seed.example.com/${rid}`);
|
||||
});
|
||||
|
||||
test("falls back to the default seed", () => {
|
||||
expect(explorerUrl(rid, config({}))).toBe(
|
||||
expect(explorerLink(repo, config({}))).toBe(
|
||||
`https://radicle.network/nodes/rosa.radicle.network/${rid}`,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user