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:
Brandon Oxendine
2026-10-05 17:29:02 +02:00
committed by Rūdolfs Ošiņš
co-authored by Rūdolfs Ošiņš
parent 990e51c38e
commit 91ab693b4b
40 changed files with 3903 additions and 225 deletions
+238
View File
@@ -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$/);
});
+22
View File
@@ -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>>()
+180
View File
@@ -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" } },
});
});
});
+324
View File
@@ -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
View File
@@ -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);
});
+4
View File
@@ -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",
+7 -15
View File
@@ -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}`,
);
});