tests/links.test.ts
Rama main · réplica de lectura del repositorio original.
// Wikilink parsing and resolution — server (server/indexer.ts) and client
// (client/editor/links.ts), which must agree: the editor decides where a click
// goes, the indexer decides where the backlink/graph edge goes, and a reader
// who sees them disagree has found a bug.
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import { collectNotes, resolveLink as clientResolve, parseWikilink, WIKILINK_RE, type NoteRef } from "../client/editor/links.ts";
import {
initIndexer,
resolveEmbed as resolveEmbedRaw,
resolveLink as resolveLinkRaw,
wikilinkRegex,
} from "../server/indexer.ts";
import { initSite } from "../server/site.ts";
import { buildTree, initVault } from "../server/vault.ts";
import type { TreeNode } from "../shared/types.ts";
import { makeDir, makeVault, note, removeVault } from "./helpers/vault.ts";
const NFD_NAME = "cafe\u0301.md"; // e + combining acute, as a macOS vault stores it
// Both resolvers take a language scope as their third argument (the published
// collection is language-filtered for visitors under `languageFilter`). None
// of the cases below is about that filter, so they pin it to `null` — "no
// language scoping" — and keep testing the two axes they are actually about:
// the name, and whether the caller is a visitor.
const resolveLink = (name: string, publishedOnly = false): string | null =>
resolveLinkRaw(name, publishedOnly, null);
const resolveEmbed = (name: string, publishedOnly = false): string | null =>
resolveEmbedRaw(name, publishedOnly, null);
const data = makeDir();
const root = makeVault({
"Note.md": "# Note\n",
"folder/Note.md": "# Note (deeper)\n",
// Same basename, two depths: "x/y/Deep.md" is the SHORTER string but the
// DEEPER path. The two resolvers disagree here — see the parity block.
"x/y/Deep.md": "# Deep\n",
"zzzz/Deep.md": "# Deep\n",
// A file named after a folder that also exists.
"Guides.md": "# Guides\n",
"Guides/Intro.md": "# Intro\n",
"Guides/Guides.md": "# Guides (inner)\n",
"OnlyFolder/Inside.md": "# Inside\n",
"مذكرة.md": "# مذكرة\n",
"Notes/What's next? (draft).md": "# What's next?\n",
[NFD_NAME]: "# Cafe\n",
"attachments/img.png": "png",
"attachments/deep/img.png": "png",
"Pub.md": note({ publish: "true" }, "# Pub\n"),
"Priv.md": "# Priv\n",
});
let tree: TreeNode;
before(async () => {
initSite({ ASTROLABE_DATA: data });
initVault(root);
await initIndexer();
tree = await buildTree();
});
after(() => {
removeVault(root);
removeVault(data);
});
// ------------------------------------------------------------------ parsing
describe("wikilink syntax", () => {
const parse = (text: string): string[] => {
const re = wikilinkRegex();
const out: string[] = [];
for (let m = re.exec(text); m !== null; m = re.exec(text)) out.push(m[1]);
return out;
};
it("finds plain, aliased, anchored and anchored+aliased links", () => {
assert.deepEqual(parse("see [[A]] and [[B|bee]] and [[C#Head]] and [[D#Head|dee]]"), [
"A",
"B",
"C",
"D",
]);
});
it("finds the target inside an ![[embed]]", () => {
assert.deepEqual(parse("![[diagram.png]]"), ["diagram.png"]);
});
it("ignores empty and unbalanced brackets", () => {
assert.deepEqual(parse("[[]] [[ ]] [not a link] [[unclosed"), [" "]);
});
it("does not treat a bare #anchor link as a target (Obsidian's [[#Head]])", () => {
// The server regex requires at least one non-# character, so a same-note
// anchor link contributes no graph edge — correct, since it points here.
assert.deepEqual(parse("[[#Head]]"), []);
});
it("client parseWikilink splits target / #heading / |alias", () => {
assert.deepEqual(parseWikilink("A"), { target: "A", heading: null, alias: null });
assert.deepEqual(parseWikilink("A|alias"), { target: "A", heading: null, alias: "alias" });
assert.deepEqual(parseWikilink("A#Head"), { target: "A", heading: "Head", alias: null });
assert.deepEqual(parseWikilink("A#Head|alias"), { target: "A", heading: "Head", alias: "alias" });
assert.deepEqual(parseWikilink(" A # Head "), { target: "A", heading: "Head", alias: null });
assert.deepEqual(parseWikilink("#Head"), { target: "", heading: "Head", alias: null });
// A "#" inside the alias belongs to the alias, not to the anchor.
assert.deepEqual(parseWikilink("A|see #tag"), { target: "A", heading: null, alias: "see #tag" });
});
it("the client's inner-text regex agrees with the server's on ordinary links", () => {
const text = "[[A]] [[B|bee]] [[C#Head]] [[D#Head|dee]]";
const inner = [...text.matchAll(WIKILINK_RE)].map((m) => parseWikilink(m[1]).target);
assert.deepEqual(inner, ["A", "B", "C", "D"]);
});
});
// --------------------------------------------------------------- resolution
describe("server resolveLink", () => {
const cases: [string, string | null][] = [
["Note", "Note.md"],
["note", "Note.md"],
["NOTE", "Note.md"],
["Note.md", "Note.md"],
["folder/Note", "folder/Note.md"],
["folder/Note.md", "folder/Note.md"],
["FOLDER/note", "folder/Note.md"],
["./folder/Note", "folder/Note.md"],
["folder\\Note", "folder/Note.md"],
// Duplicate basenames: fewest segments first, then shortest string.
["Deep", "zzzz/Deep.md"],
["x/y/Deep", "x/y/Deep.md"],
// A file named after a sibling folder resolves to the FILE.
["Guides", "Guides.md"],
["Guides/Guides", "Guides/Guides.md"],
["Guides/Intro", "Guides/Intro.md"],
// A folder is not a note.
["OnlyFolder", null],
// Unicode + punctuation titles.
["مذكرة", "مذكرة.md"],
["What's next? (draft)", "Notes/What's next? (draft).md"],
["what's NEXT? (draft)", "Notes/What's next? (draft).md"],
// Anchors and aliases are stripped before resolution.
["Note#Some heading", "Note.md"],
["Note|an alias", "Note.md"],
["Note#Some heading|an alias", "Note.md"],
["Missing note", null],
["", null],
];
for (const [name, expected] of cases) {
it(`[[${name}]] → ${expected ?? "null"}`, () => {
assert.equal(resolveLink(name), expected);
});
}
it("does NOT fold a unicode-decomposed filename with a composed link (known gap)", () => {
assert.equal(resolveLink("cafe\u0301"), NFD_NAME, "the exact spelling resolves");
assert.equal(resolveLink("caf\u00e9"), null, "the composed spelling does not");
});
});
describe("visitor-scoped resolution", () => {
it("resolves only published notes when publishedOnly is set", () => {
assert.equal(resolveLink("Pub"), "Pub.md");
assert.equal(resolveLink("Pub", true), "Pub.md");
assert.equal(resolveLink("Priv"), "Priv.md");
assert.equal(resolveLink("Priv", true), null, "an unpublished note must not be nameable");
});
it("refuses a path-form target the same way", () => {
assert.equal(resolveLink("Priv.md", true), null);
});
});
describe("resolveEmbed", () => {
it("resolves attachments by basename, shortest path first", () => {
assert.equal(resolveEmbed("img.png"), "attachments/img.png");
assert.equal(resolveEmbed("IMG.PNG"), "attachments/img.png");
assert.equal(resolveEmbed("missing.png"), null);
});
it("a path-form embed target resolves to exactly that file (fixed in 3.24.0)", () => {
// It was a KNOWN BUG until voice notes needed it: attachmentsByName is
// keyed by basename only, so "![[attachments/deep/img.png]]" — which
// Obsidian resolves, and which is the ONLY way to disambiguate two images
// with the same filename — drew a broken embed, and a voice note's
// `[[attachments/Voice/… .webm#t=0|🎙]]` read as an unreferenced file.
// resolveEmbed now asks for the exact path (case-insensitively) first.
assert.equal(resolveEmbed("attachments/deep/img.png"), "attachments/deep/img.png");
assert.equal(resolveEmbed("ATTACHMENTS/Deep/IMG.png"), "attachments/deep/img.png");
assert.equal(resolveEmbed("attachments/deep/img.png#t=0|alias"), "attachments/deep/img.png");
assert.equal(resolveEmbed("img.png"), "attachments/img.png", "…and the bare basename still takes the shortest path");
});
it("prefers a note over an attachment", () => {
assert.equal(resolveEmbed("Note"), "Note.md");
});
});
// -------------------------------------------------------------- both agree
describe("client/server parity", () => {
const AGREE = [
"Note",
"note",
"folder/Note",
"folder/Note.md",
"Guides",
"Guides/Guides",
"Guides/Intro",
"OnlyFolder",
"مذكرة",
"What's next? (draft)",
"Missing note",
"x/y/Deep",
];
for (const name of AGREE) {
it(`[[${name}]] resolves the same on both sides`, () => {
assert.equal(clientResolve(name, tree), resolveLink(name), `divergent resolution for ${name}`);
});
}
it("KNOWN BUG: duplicate basenames can resolve to DIFFERENT notes", () => {
// Server (indexer.pickShortest): fewest path segments wins, then length.
// Client (links.resolveLink): shortest STRING wins, depth ignored.
// "x/y/Deep.md" is 11 chars deep in 3 folders; "zzzz/Deep.md" is 12 chars
// in 2 — so a click in the editor and the backlink/graph edge for the same
// wikilink land on different files.
assert.equal(resolveLink("Deep"), "zzzz/Deep.md");
assert.equal(clientResolve("Deep", tree), "x/y/Deep.md");
});
it("client resolution ignores the anchor and alias too", () => {
assert.equal(clientResolve("Note#Heading", tree), "Note.md");
assert.equal(clientResolve("Note|alias", tree), "Note.md");
assert.equal(clientResolve("Note#Heading|alias", tree), "Note.md");
});
});
// ------------------------------------------- the client's index is memoized
// `collectNotes` and `resolveLink` share one table, rebuilt when the TREE
// OBJECT changes identity (client/editor/links.ts). That is the whole
// invalidation rule, so it is the whole risk: a memo that outlives its tree
// would answer a deleted note's path forever, and nothing else in the product
// would notice until a click 404'd.
describe("client note index (memoized on the tree)", () => {
const treeOf = (paths: string[]): TreeNode => ({
name: "",
path: "",
type: "folder",
children: paths.map((p) => ({ name: p.split("/").pop()!, path: p, type: "file" as const })),
});
it("answers the same thing twice for one tree", () => {
const t = treeOf(["Alpha.md", "deep/Beta.md"]);
assert.equal(clientResolve("Alpha", t), "Alpha.md");
assert.equal(clientResolve("Alpha", t), "Alpha.md");
assert.equal(clientResolve("deep/Beta", t), "deep/Beta.md");
assert.equal(collectNotes(t).length, 2);
});
it("forgets a note the NEXT tree does not have", () => {
const before = treeOf(["Gone.md", "Kept.md"]);
assert.equal(clientResolve("Gone", before), "Gone.md");
const after = treeOf(["Kept.md"]);
assert.equal(clientResolve("Gone", after), null, "the old tree's answer survived its tree");
assert.equal(clientResolve("Kept", after), "Kept.md");
});
it("sees a note the NEXT tree adds", () => {
const before = treeOf(["Kept.md"]);
assert.equal(clientResolve("Fresh", before), null);
const after = treeOf(["Kept.md", "Fresh.md"]);
assert.equal(clientResolve("Fresh", after), "Fresh.md");
});
it("goes back to nothing for a null tree", () => {
assert.equal(clientResolve("Kept", treeOf(["Kept.md"])), "Kept.md");
assert.equal(clientResolve("Kept", null), null);
assert.equal(collectNotes(null).length, 0);
});
it("keeps the shortest-path tie-break the un-memoized loop had", () => {
// Two notes named "Dup": the shorter PATH wins, ties broken alphabetically.
const t = treeOf(["zzzz/Dup.md", "a/Dup.md", "Dup.md"]);
assert.equal(clientResolve("Dup", t), "Dup.md");
const deeper = treeOf(["zzzz/Dup.md", "aaaa/Dup.md"]);
assert.equal(clientResolve("Dup", deeper), "aaaa/Dup.md");
});
it("hands back a list nobody can sort out from under the next caller", () => {
const t = treeOf(["Beta.md", "Alpha.md"]);
const first = collectNotes(t);
assert.deepEqual(
first.map((n) => n.title),
["Alpha", "Beta"],
);
assert.throws(() => (first as NoteRef[]).sort(() => -1), "the shared list is mutable");
assert.deepEqual(
collectNotes(t).map((n) => n.title),
["Alpha", "Beta"],
);
});
});