Files
Rūdolfs Ošiņš 983fff9006 Move everything into desktop/
In preparation for merging radicle-desktop into radicle-gui. It's a pure rename, so git can follow every file across the move.
2026-10-12 08:41:12 +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);
});
});