mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-27 15:40:36 +08:00
In preparation for merging radicle-desktop into radicle-gui. It's a pure rename, so git can follow every file across the move.
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);
|
|
});
|
|
});
|