2026-03-31 04:32:58 +00:00
|
|
|
import * as assert from "node:assert/strict";
|
|
|
|
|
import test from "node:test";
|
|
|
|
|
import type { TFile } from "obsidian";
|
|
|
|
|
import {
|
|
|
|
|
PluginRegistrationController,
|
|
|
|
|
resolveCommentProtocolTarget,
|
|
|
|
|
type EditorMenuItemLike,
|
|
|
|
|
type EditorMenuLike,
|
2026-05-04 19:40:03 +00:00
|
|
|
} from "../src/app/pluginRegistrationController";
|
2026-03-31 04:32:58 +00:00
|
|
|
|
|
|
|
|
function createFile(path: string): TFile {
|
|
|
|
|
return {
|
|
|
|
|
path,
|
|
|
|
|
basename: path.split("/").pop()?.replace(/\.[^.]+$/, "") ?? path,
|
|
|
|
|
extension: path.split(".").pop() ?? "",
|
|
|
|
|
} as TFile;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createMenuHarness() {
|
|
|
|
|
const items: Array<{
|
|
|
|
|
title: string | null;
|
|
|
|
|
icon: string | null;
|
|
|
|
|
onClick: (() => void | Promise<void>) | null;
|
|
|
|
|
}> = [];
|
|
|
|
|
|
|
|
|
|
const menu: EditorMenuLike = {
|
|
|
|
|
addItem(builder) {
|
|
|
|
|
const item: EditorMenuItemLike & {
|
|
|
|
|
title: string | null;
|
|
|
|
|
icon: string | null;
|
|
|
|
|
onClickHandler: (() => void | Promise<void>) | null;
|
|
|
|
|
} = {
|
|
|
|
|
title: null,
|
|
|
|
|
icon: null,
|
|
|
|
|
onClickHandler: null,
|
|
|
|
|
setTitle(title: string) {
|
|
|
|
|
this.title = title;
|
|
|
|
|
return this;
|
|
|
|
|
},
|
|
|
|
|
setIcon(icon: string) {
|
|
|
|
|
this.icon = icon;
|
|
|
|
|
return this;
|
|
|
|
|
},
|
|
|
|
|
onClick(callback: () => void | Promise<void>) {
|
|
|
|
|
this.onClickHandler = callback;
|
|
|
|
|
return this;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
builder(item);
|
|
|
|
|
items.push({
|
|
|
|
|
title: item.title,
|
|
|
|
|
icon: item.icon,
|
|
|
|
|
onClick: item.onClickHandler,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return { menu, items };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 14:30:44 +00:00
|
|
|
function createHarness(options: { selectionAction?: "add-comment" | "orphan-anchor" } = {}) {
|
2026-03-31 04:32:58 +00:00
|
|
|
const registerViewCalls: Array<{ viewType: string; creator: (leaf: unknown) => unknown }> = [];
|
2026-07-09 07:51:02 +00:00
|
|
|
const registerExtensionsCalls: Array<{ extensions: string[]; viewType: string }> = [];
|
2026-03-31 04:32:58 +00:00
|
|
|
const protocolHandlers = new Map<string, (params: Record<string, unknown>) => void>();
|
|
|
|
|
const commands: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
icon: string;
|
2026-04-07 14:13:01 +00:00
|
|
|
callback?: () => Promise<void> | void;
|
|
|
|
|
editorCallback?: (editor: { somethingSelected(): boolean }, view: { file: TFile | null }) => Promise<void> | void;
|
2026-03-31 04:32:58 +00:00
|
|
|
}> = [];
|
|
|
|
|
let editorMenuHandler: ((menu: EditorMenuLike, editor: { somethingSelected(): boolean }, view: { file: TFile | null }) => void) | null = null;
|
|
|
|
|
const ribbonActions: Array<{ icon: string; title: string; callback: () => void }> = [];
|
|
|
|
|
const createdSidebarLeaves: unknown[] = [];
|
2026-07-09 07:51:02 +00:00
|
|
|
const createdPublicHtmlLeaves: unknown[] = [];
|
2026-03-31 04:32:58 +00:00
|
|
|
const draftCalls: Array<{ selected: boolean; filePath: string | null }> = [];
|
2026-05-31 14:30:44 +00:00
|
|
|
const selectionActionCalls: Array<{ selected: boolean; filePath: string | null }> = [];
|
2026-04-26 03:33:50 +00:00
|
|
|
const openedCommentTargets: Array<{ filePath: string | null; commentId: string }> = [];
|
2026-04-05 22:37:04 +00:00
|
|
|
let openIndexNoteCount = 0;
|
2026-03-31 04:32:58 +00:00
|
|
|
|
|
|
|
|
const controller = new PluginRegistrationController({
|
2026-05-13 18:44:11 +00:00
|
|
|
manifestId: "aside",
|
|
|
|
|
iconId: "aside-icon",
|
2026-03-31 04:32:58 +00:00
|
|
|
registerView: (viewType, creator) => {
|
|
|
|
|
registerViewCalls.push({ viewType, creator });
|
|
|
|
|
},
|
2026-07-09 07:51:02 +00:00
|
|
|
registerExtensions: (extensions, viewType) => {
|
|
|
|
|
registerExtensionsCalls.push({ extensions, viewType });
|
|
|
|
|
},
|
2026-03-31 04:32:58 +00:00
|
|
|
registerObsidianProtocolHandler: (action, handler) => {
|
|
|
|
|
protocolHandlers.set(action, handler);
|
|
|
|
|
},
|
|
|
|
|
addCommand: (command) => {
|
|
|
|
|
commands.push(command);
|
|
|
|
|
},
|
|
|
|
|
registerEditorMenu: (handler) => {
|
|
|
|
|
editorMenuHandler = handler;
|
|
|
|
|
},
|
|
|
|
|
addRibbonIcon: (icon, title, callback) => {
|
|
|
|
|
ribbonActions.push({ icon, title, callback });
|
|
|
|
|
},
|
|
|
|
|
createSidebarView: (leaf) => {
|
|
|
|
|
createdSidebarLeaves.push(leaf);
|
|
|
|
|
return { leaf };
|
|
|
|
|
},
|
2026-07-09 07:51:02 +00:00
|
|
|
createPublicHtmlView: (leaf) => {
|
|
|
|
|
createdPublicHtmlLeaves.push(leaf);
|
|
|
|
|
return { leaf, publicHtml: true };
|
|
|
|
|
},
|
2026-03-31 04:32:58 +00:00
|
|
|
startDraftFromEditorSelection: async (editor, file) => {
|
|
|
|
|
draftCalls.push({
|
|
|
|
|
selected: editor.somethingSelected(),
|
|
|
|
|
filePath: file?.path ?? null,
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-05-31 14:30:44 +00:00
|
|
|
getEditorSelectionAction: (editor, file) => {
|
|
|
|
|
selectionActionCalls.push({
|
|
|
|
|
selected: editor.somethingSelected(),
|
|
|
|
|
filePath: file?.path ?? null,
|
|
|
|
|
});
|
|
|
|
|
return options.selectionAction ?? "add-comment";
|
|
|
|
|
},
|
2026-04-26 03:33:50 +00:00
|
|
|
openCommentById: async (filePath, commentId) => {
|
|
|
|
|
openedCommentTargets.push({ filePath, commentId });
|
|
|
|
|
},
|
2026-04-05 22:37:04 +00:00
|
|
|
openIndexNote: async () => {
|
|
|
|
|
openIndexNoteCount += 1;
|
2026-03-31 04:32:58 +00:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
controller,
|
|
|
|
|
registerViewCalls,
|
2026-07-09 07:51:02 +00:00
|
|
|
registerExtensionsCalls,
|
2026-03-31 04:32:58 +00:00
|
|
|
protocolHandlers,
|
|
|
|
|
commands,
|
|
|
|
|
getEditorMenuHandler: () => editorMenuHandler,
|
|
|
|
|
ribbonActions,
|
|
|
|
|
createdSidebarLeaves,
|
2026-07-09 07:51:02 +00:00
|
|
|
createdPublicHtmlLeaves,
|
2026-03-31 04:32:58 +00:00
|
|
|
draftCalls,
|
2026-05-31 14:30:44 +00:00
|
|
|
selectionActionCalls,
|
2026-04-26 03:33:50 +00:00
|
|
|
openedCommentTargets,
|
2026-04-05 22:37:04 +00:00
|
|
|
getOpenIndexNoteCount: () => openIndexNoteCount,
|
2026-03-31 04:32:58 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 07:51:02 +00:00
|
|
|
test("plugin registration controller registers views, protocol handler, selection command, and ribbon action", async () => {
|
2026-03-31 04:32:58 +00:00
|
|
|
const harness = createHarness();
|
|
|
|
|
const editorFile = createFile("docs/file.md");
|
|
|
|
|
|
|
|
|
|
harness.controller.register();
|
|
|
|
|
|
2026-07-09 07:51:02 +00:00
|
|
|
assert.deepEqual(harness.registerViewCalls.map((call) => call.viewType), [
|
|
|
|
|
"aside-view",
|
|
|
|
|
"aside-public-html-view",
|
|
|
|
|
]);
|
2026-03-31 04:32:58 +00:00
|
|
|
assert.equal(harness.registerViewCalls[0].creator({ id: "leaf-1" }) instanceof Object, true);
|
|
|
|
|
assert.deepEqual(harness.createdSidebarLeaves, [{ id: "leaf-1" }]);
|
2026-07-09 07:51:02 +00:00
|
|
|
assert.equal(harness.registerViewCalls[1].creator({ id: "leaf-2" }) instanceof Object, true);
|
|
|
|
|
assert.deepEqual(harness.createdPublicHtmlLeaves, [{ id: "leaf-2" }]);
|
|
|
|
|
assert.deepEqual(harness.registerExtensionsCalls, [{
|
|
|
|
|
extensions: ["html", "htm"],
|
|
|
|
|
viewType: "aside-public-html-view",
|
|
|
|
|
}]);
|
2026-06-12 03:07:00 +00:00
|
|
|
assert.deepEqual(Array.from(harness.protocolHandlers.keys()), ["aside-comment"]);
|
2026-07-09 07:51:02 +00:00
|
|
|
assert.deepEqual(harness.commands.map((command) => command.id), ["add-comment-to-selection"]);
|
|
|
|
|
assert.deepEqual(harness.commands.map((command) => command.name), ["Add comment to selection"]);
|
2026-06-07 05:55:38 +00:00
|
|
|
assert.deepEqual(harness.ribbonActions.map((action) => action.title), ["Open Aside"]);
|
|
|
|
|
|
|
|
|
|
await harness.commands[0].callback?.();
|
2026-07-09 07:51:02 +00:00
|
|
|
await harness.commands[0].editorCallback?.(
|
2026-03-31 04:32:58 +00:00
|
|
|
{ somethingSelected: () => true },
|
|
|
|
|
{ file: editorFile },
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(harness.draftCalls, [{
|
|
|
|
|
selected: true,
|
|
|
|
|
filePath: editorFile.path,
|
|
|
|
|
}]);
|
|
|
|
|
|
2026-05-13 18:44:11 +00:00
|
|
|
harness.protocolHandlers.get("aside-comment")?.({});
|
|
|
|
|
harness.protocolHandlers.get("aside-comment")?.({
|
2026-03-31 04:32:58 +00:00
|
|
|
file: "docs/file.md",
|
|
|
|
|
commentId: "comment-1",
|
|
|
|
|
});
|
|
|
|
|
await Promise.resolve();
|
2026-04-26 03:33:50 +00:00
|
|
|
assert.deepEqual(harness.openedCommentTargets, [{
|
2026-04-01 21:52:34 +00:00
|
|
|
filePath: "docs/file.md",
|
|
|
|
|
commentId: "comment-1",
|
|
|
|
|
}]);
|
2026-03-31 04:32:58 +00:00
|
|
|
|
|
|
|
|
harness.ribbonActions[0].callback();
|
|
|
|
|
await Promise.resolve();
|
2026-06-07 17:41:58 +00:00
|
|
|
assert.equal(harness.getOpenIndexNoteCount(), 1);
|
2026-03-31 04:32:58 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("plugin registration controller only adds the editor menu item for active selections", async () => {
|
|
|
|
|
const harness = createHarness();
|
|
|
|
|
const editorMenuHandler = (() => {
|
|
|
|
|
harness.controller.register();
|
|
|
|
|
return harness.getEditorMenuHandler();
|
|
|
|
|
})();
|
|
|
|
|
const file = createFile("docs/file.md");
|
|
|
|
|
|
|
|
|
|
assert.ok(editorMenuHandler);
|
|
|
|
|
|
|
|
|
|
const emptyMenuHarness = createMenuHarness();
|
|
|
|
|
editorMenuHandler?.(
|
|
|
|
|
emptyMenuHarness.menu,
|
|
|
|
|
{ somethingSelected: () => false },
|
|
|
|
|
{ file },
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(emptyMenuHarness.items, []);
|
|
|
|
|
|
|
|
|
|
const selectedMenuHarness = createMenuHarness();
|
|
|
|
|
editorMenuHandler?.(
|
|
|
|
|
selectedMenuHarness.menu,
|
|
|
|
|
{ somethingSelected: () => true },
|
|
|
|
|
{ file },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(selectedMenuHarness.items.map((item) => ({
|
|
|
|
|
title: item.title,
|
|
|
|
|
icon: item.icon,
|
|
|
|
|
})), [{
|
|
|
|
|
title: "Add comment to selection",
|
2026-05-13 18:44:11 +00:00
|
|
|
icon: "aside-icon",
|
2026-03-31 04:32:58 +00:00
|
|
|
}]);
|
|
|
|
|
|
|
|
|
|
await selectedMenuHarness.items[0].onClick?.();
|
|
|
|
|
assert.deepEqual(harness.draftCalls, [{
|
|
|
|
|
selected: true,
|
|
|
|
|
filePath: file.path,
|
|
|
|
|
}]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-31 14:30:44 +00:00
|
|
|
test("plugin registration controller labels a matched anchor selection as anchor removal", async () => {
|
|
|
|
|
const harness = createHarness({ selectionAction: "orphan-anchor" });
|
|
|
|
|
const editorMenuHandler = (() => {
|
|
|
|
|
harness.controller.register();
|
|
|
|
|
return harness.getEditorMenuHandler();
|
|
|
|
|
})();
|
|
|
|
|
const file = createFile("docs/file.md");
|
|
|
|
|
|
|
|
|
|
const selectedMenuHarness = createMenuHarness();
|
|
|
|
|
editorMenuHandler?.(
|
|
|
|
|
selectedMenuHarness.menu,
|
|
|
|
|
{ somethingSelected: () => true },
|
|
|
|
|
{ file },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(selectedMenuHarness.items.map((item) => ({
|
|
|
|
|
title: item.title,
|
|
|
|
|
icon: item.icon,
|
|
|
|
|
})), [{
|
|
|
|
|
title: "Remove anchor from side note",
|
|
|
|
|
icon: "aside-icon",
|
|
|
|
|
}]);
|
|
|
|
|
assert.deepEqual(harness.selectionActionCalls, [{
|
|
|
|
|
selected: true,
|
|
|
|
|
filePath: file.path,
|
|
|
|
|
}]);
|
|
|
|
|
|
|
|
|
|
await selectedMenuHarness.items[0].onClick?.();
|
|
|
|
|
assert.deepEqual(harness.draftCalls, [{
|
|
|
|
|
selected: true,
|
|
|
|
|
filePath: file.path,
|
|
|
|
|
}]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-21 22:42:25 +00:00
|
|
|
test("comment protocol target resolution requires a comment id and treats file as optional", () => {
|
2026-03-31 04:32:58 +00:00
|
|
|
assert.equal(resolveCommentProtocolTarget({}), null);
|
|
|
|
|
assert.equal(resolveCommentProtocolTarget({ file: "docs/file.md" }), null);
|
2026-04-21 22:42:25 +00:00
|
|
|
assert.deepEqual(resolveCommentProtocolTarget({ commentId: "comment-1" }), {
|
|
|
|
|
filePath: null,
|
|
|
|
|
commentId: "comment-1",
|
|
|
|
|
});
|
2026-03-31 04:32:58 +00:00
|
|
|
assert.deepEqual(resolveCommentProtocolTarget({
|
|
|
|
|
file: "docs/file.md",
|
|
|
|
|
commentId: "comment-1",
|
|
|
|
|
}), {
|
|
|
|
|
filePath: "docs/file.md",
|
|
|
|
|
commentId: "comment-1",
|
|
|
|
|
});
|
|
|
|
|
});
|