Files
Rūdolfs Ošiņš 38279298d1 Keep document links inside the app
Clicking a relative link in a README reloaded the whole app, and links
starting with `/` dropped you in the inbox. Links to files in the repo
now open in the source view, at the linked section if there is one, and
links the app can't follow explain why instead of navigating the window
away.

This also fixes a missing linked file showing the previous file's
content instead of an error, and section links disabling the back
button.
2026-10-08 10:29:43 +02:00

252 lines
8.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { DocumentLink } from "@app/lib/documentLinks";
import {
classifyDocumentLink,
enhanceDocumentLink,
scrollToFragment,
scrollToLocationFragment,
} from "@app/lib/documentLinks";
const { show } = vi.hoisted(() => ({ show: vi.fn() }));
vi.mock("@app/lib/modal", () => ({ show }));
describe("classifyDocumentLink", () => {
test.each<[string, string, DocumentLink]>([
["docs/guide.md", "README.md", { type: "file", path: "docs/guide.md" }],
["./guide.md", "docs/index.md", { type: "file", path: "docs/guide.md" }],
["../README.md", "docs/index.md", { type: "file", path: "README.md" }],
["/src/main.rs", "docs/index.md", { type: "file", path: "src/main.rs" }],
["docs/", "README.md", { type: "file", path: "docs" }],
[
"docs/guide.md#usage",
"README.md",
{ type: "file", path: "docs/guide.md", fragment: "usage" },
],
[
"guide.md#caf%C3%A9",
"README.md",
{ type: "file", path: "guide.md", fragment: "café" },
],
["guide.md?plain=1", "README.md", { type: "file", path: "guide.md" }],
["my%20notes.md", "README.md", { type: "file", path: "my notes.md" }],
["LICENSE", "", { type: "file", path: "LICENSE" }],
["/", "docs/index.md", { type: "file", path: undefined }],
["..", "README.md", { type: "file", path: undefined }],
["#usage", "README.md", { type: "fragment", id: "usage" }],
["#caf%C3%A9", "README.md", { type: "fragment", id: "café" }],
["README.md#usage", "README.md", { type: "fragment", id: "usage" }],
["README.md#Usage", "README.md", { type: "fragment", id: "Usage" }],
["#caf%", "README.md", { type: "fragment", id: "caf%" }],
["#", "README.md", { type: "fragment", id: "" }],
["", "README.md", { type: "fragment", id: "" }],
["https://radicle.xyz", "README.md", { type: "external" }],
["HTTP://radicle.xyz", "README.md", { type: "external" }],
["mailto:alice@example.com", "README.md", { type: "external" }],
["tel:+123", "README.md", { type: "external" }],
["ftp://example.com/file", "README.md", { type: "unsupported" }],
["javascript:alert(1)", "README.md", { type: "unsupported" }],
["//radicle.xyz/notes.md", "README.md", { type: "unsupported" }],
])("classifies %j in %j", (href, from, expected) => {
expect(classifyDocumentLink(href, from)).toEqual(expected);
});
});
describe("enhanceDocumentLink", () => {
let container: HTMLElement;
function link(href: string | undefined): HTMLAnchorElement {
const anchor = document.createElement("a");
if (href !== undefined) {
anchor.setAttribute("href", href);
}
anchor.textContent = "link";
container.appendChild(anchor);
return anchor;
}
function click(anchor: HTMLAnchorElement): MouseEvent {
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
anchor.dispatchEvent(event);
return event;
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
show.mockReset();
});
test("opens a file in the repository instead of following the link", () => {
const openFile = vi.fn();
const anchor = link("../CONTRIBUTING.md");
enhanceDocumentLink(anchor, () => ({ path: "docs/index.md", openFile }));
expect(click(anchor).defaultPrevented).toBe(true);
expect(openFile).toHaveBeenCalledWith("CONTRIBUTING.md", undefined);
expect(show).not.toHaveBeenCalled();
});
test("opens the repository root", () => {
const openFile = vi.fn();
const anchor = link("/");
enhanceDocumentLink(anchor, () => ({ path: "docs/index.md", openFile }));
expect(click(anchor).defaultPrevented).toBe(true);
expect(openFile).toHaveBeenCalledWith(undefined, undefined);
});
test("resolves against the document shown when the link is followed", () => {
const openFile = vi.fn();
let path = "README.md";
const anchor = link("guide.md");
enhanceDocumentLink(anchor, () => ({ path, openFile }));
path = "docs/README.md";
click(anchor);
expect(openFile).toHaveBeenCalledWith("docs/guide.md", undefined);
});
test("opens a file at the section it links to", () => {
const openFile = vi.fn();
const anchor = link("guide.md#Usage");
enhanceDocumentLink(anchor, () => ({ path: "README.md", openFile }));
click(anchor);
expect(openFile).toHaveBeenCalledWith("guide.md", "Usage");
});
test("opens external links outside the app", () => {
const anchor = link("https://radicle.xyz");
enhanceDocumentLink(anchor, () => ({ path: "README.md" }));
expect(anchor.target).toBe("_blank");
expect(anchor.rel).toBe("noopener noreferrer");
expect(anchor.onclick).toBeNull();
});
test("scrolls to a fragment in the document", () => {
const heading = document.createElement("h2");
heading.id = "usage";
container.appendChild(heading);
const scroll = vi
.spyOn(heading, "scrollIntoView")
.mockReturnValue(undefined);
const anchor = link("#usage");
enhanceDocumentLink(anchor, () => ({ path: "README.md" }));
expect(click(anchor).defaultPrevented).toBe(true);
expect(scroll).toHaveBeenCalled();
});
test("scrolls to a named anchor", () => {
const target = document.createElement("a");
target.setAttribute("name", "legacy");
container.appendChild(target);
const scroll = vi
.spyOn(target, "scrollIntoView")
.mockReturnValue(undefined);
const anchor = link("#legacy");
enhanceDocumentLink(anchor, () => ({ path: "README.md" }));
click(anchor);
expect(scroll).toHaveBeenCalled();
});
test("stays put for a missing fragment", () => {
const anchor = link("#missing");
enhanceDocumentLink(anchor, () => ({ path: "README.md" }));
expect(click(anchor).defaultPrevented).toBe(true);
expect(show).not.toHaveBeenCalled();
});
test.each([
["ftp://example.com/file", true],
["//radicle.xyz/notes.md", true],
// A document that isn't part of a repository has no files to open.
["guide.md", false],
])("explains why %j can't be opened", (href, inRepo) => {
const openFile = vi.fn();
const anchor = link(href);
enhanceDocumentLink(anchor, () => ({
path: "README.md",
openFile: inRepo ? openFile : undefined,
}));
expect(click(anchor).defaultPrevented).toBe(true);
expect(openFile).not.toHaveBeenCalled();
expect(show).toHaveBeenCalledWith(
expect.objectContaining({
props: { link: href, reason: "unsupported" },
}),
);
});
test("leaves anchors without a link alone", () => {
const anchor = link(undefined);
enhanceDocumentLink(anchor, () => ({ path: "README.md" }));
expect(anchor.onclick).toBeNull();
expect(anchor.target).toBe("");
});
});
describe("scrollToFragment", () => {
let container: HTMLElement;
function target(tag: string, attribute: string, value: string) {
const element = document.createElement(tag);
element.setAttribute(attribute, value);
container.appendChild(element);
return vi.spyOn(element, "scrollIntoView").mockReturnValue(undefined);
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
test("falls back to the lowercase id of a heading", () => {
const heading = target("h2", "id", "usage");
scrollToFragment("Usage");
expect(heading).toHaveBeenCalled();
});
test("prefers an exact match over the lowercase id", () => {
const heading = target("h2", "id", "usage");
const anchor = target("a", "name", "Usage");
scrollToFragment("Usage");
expect(anchor).toHaveBeenCalled();
expect(heading).not.toHaveBeenCalled();
});
test("scrolls to the location's fragment once per location", () => {
const heading = target("h2", "id", "café");
window.history.replaceState(null, "", "/repos#caf%C3%A9");
const scrolledAt = scrollToLocationFragment(undefined);
expect(heading).toHaveBeenCalledTimes(1);
scrollToLocationFragment(scrolledAt);
expect(heading).toHaveBeenCalledTimes(1);
window.history.replaceState(null, "", "/other#caf%C3%A9");
scrollToLocationFragment(scrolledAt);
expect(heading).toHaveBeenCalledTimes(2);
});
});