mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-25 22:40:29 +08:00
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.
252 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
});
|