logancyang_obsidian-copilot/src/commands/customCommandUtils.test.ts
Emt-lin ff3636637f
feat: Add Web Viewer bridge for referencing open web tabs in chat (#2096)
* feat: Add Web Viewer bridge for referencing open web tabs in chat.

* refactor: Simplify markdown image extraction parser。

* fix: Prevent web selection from auto-reappearing after removal or new chat.

* fix: Suppress active web tab when web selection exists.

* feat: Add YouTube transcript extraction for Web Viewer tabs.

* feat: Built-in slash prompts for web clippers.

* feat: Improve web selection tracking and simplify context settings

  - Merge context settings: combine note/web active content and selection settings
  - Add auto-clear for web selection badge when deselected on page
  - Make note and web selections mutually exclusive
  - Show website favicon in web selection badge
  - Add migration logic and tests for settings
2026-01-12 17:54:54 -08:00

790 lines
29 KiB
TypeScript

import { processPrompt } from "@/commands/customCommandUtils";
import { validateCommandName } from "@/commands/customCommandUtils";
import { CustomCommand } from "@/commands/type";
import {
extractTemplateNoteFiles,
getFileContent,
getFileName,
getNotesFromPath,
getNotesFromTags,
} from "@/utils";
import { Notice, TFile, Vault } from "obsidian";
import * as settingsModelModule from "@/settings/model";
import { PromptSortStrategy } from "@/types";
import { sortSlashCommands } from "@/commands/customCommandUtils";
import { DEFAULT_SETTINGS } from "@/constants";
import { logWarn } from "@/logger";
// Mock Obsidian
jest.mock("obsidian", () => ({
Notice: jest.fn(),
TFile: jest.fn(),
Vault: jest.fn(),
}));
// Mock logger
jest.mock("@/logger", () => ({
logWarn: jest.fn(),
logInfo: jest.fn(),
logError: jest.fn(),
}));
// Mock the utility functions
jest.mock("@/utils", () => ({
extractTemplateNoteFiles: jest.fn().mockReturnValue([]),
getFileContent: jest.fn(),
getFileName: jest.fn(),
getNotesFromPath: jest.fn(),
getNotesFromTags: jest.fn(),
processVariableNameForNotePath: jest.fn(),
}));
describe("processedPrompt()", () => {
let mockVault: Vault;
let mockActiveNote: TFile;
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
jest.resetAllMocks();
// Set default implementations for critical mocks
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([]);
// Create mock objects with adapter.stat
mockVault = {
adapter: {
stat: jest.fn().mockResolvedValue({
ctime: Date.now(),
mtime: Date.now(),
}),
},
} as unknown as Vault;
mockActiveNote = {
path: "path/to/active/note.md",
basename: "Active Note",
} as TFile;
});
it("should add 1 context and selectedText", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "This is a {variable} and {}.",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "here is some selected text 12345";
(getFileContent as jest.Mock).mockResolvedValueOnce("here is the note content for note0");
(getFileName as jest.Mock).mockReturnValueOnce("Variable Note");
(getNotesFromPath as jest.Mock).mockResolvedValueOnce([mockActiveNote]);
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
'This is a {variable} and {selected_text}.\n\n<selected_text>\nhere is some selected text 12345\n</selected_text>\n\n<variable name="variable">\n<variable_note>\n<path>path/to/active/note.md</path>\n## Variable Note\n\nhere is the note content for note0\n</variable_note>\n</variable>'
);
expect(result.includedFiles).toContain(mockActiveNote);
});
it("should add 2 context and no selectedText", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "This is a {variable1} and {variable2}.",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "";
(getFileContent as jest.Mock)
.mockResolvedValueOnce("here is the note content for note0")
.mockResolvedValueOnce("note content for note1");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValueOnce("Variable1 Note").mockReturnValueOnce("Variable2 Note");
const mockNote1 = { path: "path/to/note1.md", basename: "Variable1 Note" } as TFile;
const mockNote2 = { path: "path/to/note2.md", basename: "Variable2 Note" } as TFile;
(getNotesFromPath as jest.Mock)
.mockResolvedValueOnce([mockNote1])
.mockResolvedValueOnce([mockNote2]);
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
'This is a {variable1} and {variable2}.\n\n<variable name="variable1">\n<variable_note>\n<path>path/to/note1.md</path>\n## Variable1 Note\n\nhere is the note content for note0\n</variable_note>\n</variable>\n\n<variable name="variable2">\n<variable_note>\n<path>path/to/note2.md</path>\n## Variable2 Note\n\nnote content for note1\n</variable_note>\n</variable>'
);
expect(result.includedFiles).toContain(mockNote1);
expect(result.includedFiles).toContain(mockNote2);
});
it("should add 1 selectedText and no context", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "Rewrite the following text {}",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "here is some selected text 12345";
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
"Rewrite the following text {selected_text}\n\n<selected_text>\nhere is some selected text 12345\n</selected_text>"
);
expect(result.includedFiles).toEqual([]);
});
it("should process {activeNote} correctly", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "This is the active note: {activenote}",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "";
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Active Note");
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
'This is the active note: {activenote}\n\n<variable name="activenote">\n<variable_note>\n<path>path/to/active/note.md</path>\n## Active Note\n\nContent of the active note\n</variable_note>\n</variable>'
);
expect(result.includedFiles).toContain(mockActiveNote);
expect(getFileContent).toHaveBeenCalledWith(mockActiveNote, mockVault);
});
it("should handle {activeNote} when no active note is provided", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "This is the active note: {activeNote}",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "";
const result = await processPrompt(doc.content, selectedText, mockVault, undefined);
expect(result.processedPrompt).toBe("This is the active note: {activeNote}\n\n");
expect(result.includedFiles).toEqual([]);
expect(Notice).toHaveBeenCalledWith("No active note found.");
});
it("should handle prompts without variables", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "This is a test prompt with no variables.",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "selected text";
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe("This is a test prompt with no variables.\n\n");
expect(result.includedFiles).toEqual([]);
});
it("should process a single tag variable correctly", async () => {
const customPrompt = "Notes related to {#tag} are:";
const selectedText = "";
// Mock note file for the tag
const mockNoteForTag = {
path: "path/to/tagged/note.md",
basename: "Tagged Note",
} as TFile;
// Mock getNotesFromTags to return our mock note
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag]);
// Mock getFileName to return the basename
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Tagged Note");
// Mock getFileContent to return content for the note
(getFileContent as jest.Mock).mockResolvedValue("Note content for #tag");
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
'Notes related to {#tag} are:\n\n<variable name="#tag">\n<variable_note>\n<path>path/to/tagged/note.md</path>\n## Tagged Note\n\nNote content for #tag\n</variable_note>\n</variable>'
);
expect(result.includedFiles).toContain(mockNoteForTag);
});
it("should process multiple tag variables correctly", async () => {
const customPrompt = "Notes related to {#tag1,#tag2,#tag3} are:";
const selectedText = "";
// Mock note files for the tags
const mockNoteForTag1 = {
basename: "Tagged Note 1",
path: "path/to/tagged/note1.md",
} as TFile;
const mockNoteForTag2 = {
basename: "Tagged Note 2",
path: "path/to/tagged/note2.md",
} as TFile;
// Mock getNotesFromTags to return our mock notes
(getNotesFromTags as jest.Mock).mockResolvedValue([mockNoteForTag1, mockNoteForTag2]);
// Mock getFileName to return the basename
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockImplementation((file: TFile) => file.basename);
// Mock getFileContent to return content for each note
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Tagged Note 1") {
return "Note content for #tag1";
} else if (file.basename === "Tagged Note 2") {
return "Note content for #tag2";
}
return "";
});
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
'Notes related to {#tag1,#tag2,#tag3} are:\n\n<variable name="#tag1,#tag2,#tag3">\n<variable_note>\n<path>path/to/tagged/note1.md</path>\n## Tagged Note 1\n\nNote content for #tag1\n</variable_note>\n\n<variable_note>\n<path>path/to/tagged/note2.md</path>\n## Tagged Note 2\n\nNote content for #tag2\n</variable_note>\n</variable>'
);
expect(result.includedFiles).toContain(mockNoteForTag1);
expect(result.includedFiles).toContain(mockNoteForTag2);
});
it("should process [[note title]] syntax correctly", async () => {
const customPrompt = "Content of [[Test Note]] is important.";
const selectedText = "";
const mockTestNote = { basename: "Test Note", path: "Test Note.md" } as TFile;
// Mock the necessary functions
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockTestNote]);
(getFileContent as jest.Mock).mockResolvedValue("Test note content");
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toContain("Content of [[Test Note]] is important");
expect(result.processedPrompt).toContain("<note_context>");
expect(result.processedPrompt).toContain("<title>Test Note</title>");
expect(result.processedPrompt).toContain("<path>Test Note.md</path>");
expect(result.processedPrompt).toContain("Test note content");
expect(result.processedPrompt).toContain("</note_context>");
expect(result.includedFiles).toContain(mockTestNote);
});
it("should process {[[note title]]} syntax correctly without duplication", async () => {
const customPrompt = "Content of {[[Test Note]]} is important. Look at [[Test Note]].";
const selectedText = "";
// Mock the necessary functions
const mockNoteFile = {
basename: "Test Note",
path: "Test Note.md",
} as TFile;
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNoteFile]);
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Test Note");
(getFileContent as jest.Mock).mockResolvedValue("Test note content");
// Mock getNotesFromPath to return our mock note
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNoteFile]);
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
// Verify the prompt text is preserved
expect(result.processedPrompt).toContain(
"Content of {[[Test Note]]} is important. Look at [[Test Note]]."
);
// Verify note content is included in note_context format
expect(result.processedPrompt).toContain("<note_context>");
expect(result.processedPrompt).toContain("<title>Test Note</title>");
expect(result.processedPrompt).toContain("<path>Test Note.md</path>");
expect(result.processedPrompt).toContain("Test note content");
expect(result.processedPrompt).toContain("</note_context>");
// Note: extractTemplateNoteFiles will only find {[[Test Note]]}, not bare [[Test Note]]
expect(result.includedFiles).toEqual([mockNoteFile]);
expect(extractTemplateNoteFiles).toHaveBeenCalledWith(customPrompt, mockVault);
});
it("should only process {[[note title]]} syntax, not bare [[note title]]", async () => {
const customPrompt = "{[[Note1]]} content and [[Note2]] are both important.";
const selectedText = "";
// Mock the necessary functions
const mockNote1 = {
basename: "Note1",
path: "Note1.md",
} as TFile;
// Only Note1 should be extracted since it's wrapped in {[[]]}
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1]);
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockImplementation((file: TFile) => file.basename);
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Note1") {
return "Note1 content";
}
return "";
});
// Mock getNotesFromPath to return our mock note
(getNotesFromPath as jest.Mock).mockResolvedValue([mockNote1]);
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toContain(
"{[[Note1]]} content and [[Note2]] are both important"
);
// Only Note1 content should be included (from {[[Note1]]})
expect(result.processedPrompt).toContain("<note_context>");
expect(result.processedPrompt).toContain("<title>Note1</title>");
expect(result.processedPrompt).toContain("<path>Note1.md</path>");
expect(result.processedPrompt).toContain("Note1 content");
expect(result.processedPrompt).toContain("</note_context>");
// Note2 should NOT be included because it's bare [[Note2]] without {}
expect(result.processedPrompt).not.toContain("<title>Note2</title>");
expect(result.includedFiles).toEqual([mockNote1]);
expect(result.includedFiles.length).toBe(1);
});
it("should handle multiple occurrences of {[[note title]]} syntax", async () => {
const customPrompt = "{[[Note1]]} is related to {[[Note2]]} and {[[Note3]]}.";
const selectedText = "";
const mockNote1 = { basename: "Note1", path: "Note1.md" } as TFile;
const mockNote2 = { basename: "Note2", path: "Note2.md" } as TFile;
const mockNote3 = { basename: "Note3", path: "Note3.md" } as TFile;
// Mock the necessary functions
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1, mockNote2, mockNote3]);
(getNotesFromPath as jest.Mock).mockResolvedValue([]);
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Note1") {
return "Note1 content";
} else if (file.basename === "Note2") {
return "Note2 content";
} else if (file.basename === "Note3") {
return "Note3 content";
}
return "";
});
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toContain(
"{[[Note1]]} is related to {[[Note2]]} and {[[Note3]]}."
);
// All notes should be in note_context format
expect(result.processedPrompt).toContain("<note_context>");
expect(result.processedPrompt).toContain("<title>Note1</title>");
expect(result.processedPrompt).toContain("<path>Note1.md</path>");
expect(result.processedPrompt).toContain("Note1 content");
expect(result.processedPrompt).toContain("<title>Note2</title>");
expect(result.processedPrompt).toContain("<path>Note2.md</path>");
expect(result.processedPrompt).toContain("Note2 content");
expect(result.processedPrompt).toContain("<title>Note3</title>");
expect(result.processedPrompt).toContain("<path>Note3.md</path>");
expect(result.processedPrompt).toContain("Note3 content");
expect(result.includedFiles).toEqual(expect.arrayContaining([mockNote1, mockNote2, mockNote3]));
expect(result.includedFiles.length).toBe(3);
});
it("should handle non-existent note titles gracefully", async () => {
const customPrompt = "[[Non-existent Note]] should not cause errors.";
const selectedText = "";
// Mock the necessary functions
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([]); // Assume it returns empty if note doesn't exist
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe("[[Non-existent Note]] should not cause errors.\n\n");
expect(result.includedFiles).toEqual([]);
});
it("should process {activenote} only once when it appears multiple times", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "This is the active note: {activeNote}. And again: {activeNote}",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "";
// Mock getFileName and getFileContent
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Active Note");
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
// Check that getFileContent was called with the active note at least once
expect(getFileContent).toHaveBeenCalledWith(mockActiveNote, mockVault);
expect(result.processedPrompt).toBe(
'This is the active note: {activeNote}. And again: {activeNote}\n\n<variable name="activeNote">\n<variable_note>\n<path>path/to/active/note.md</path>\n## Active Note\n\nContent of the active note\n</variable_note>\n</variable>'
);
expect(result.includedFiles).toContain(mockActiveNote);
});
it("should use active note content when {} is present and no selected text", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "Summarize this: {}",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "";
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
'Summarize this: {selected_text}\n\n<selected_text type="active_note">\nContent of the active note\n</selected_text>'
);
// Active note should be included because of {}
expect(result.includedFiles).toContain(mockActiveNote);
});
it("should not duplicate active note content when both {} and {activeNote} are present", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "Summarize this: {}. Additional info: {activeNote}",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "";
// Mock getFileContent for the active note when processed via {}
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
const { getFileName } = jest.requireMock("@/utils") as any;
getFileName.mockReturnValue("Active Note");
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
'Summarize this: {selected_text}. Additional info: {activeNote}\n\n<selected_text type="active_note">\nContent of the active note\n</selected_text>'
);
// Ensure getFileContent was called for the {} replacement
expect(getFileContent).toHaveBeenCalledWith(mockActiveNote, mockVault);
// Active note should be included only once
expect(result.includedFiles).toEqual([mockActiveNote]);
});
it("should prioritize selected text over active note when both are available", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "Analyze this: {}",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "This is the selected text";
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
"Analyze this: {selected_text}\n\n<selected_text>\nThis is the selected text\n</selected_text>"
);
// Active note should not be included when selected text is present for {}
expect(result.includedFiles).toEqual([]);
});
it("should handle invalid variable names correctly", async () => {
const doc: CustomCommand = {
title: "test-prompt",
content: "This is a test prompt with {invalidVariable} name and {activeNote}",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
};
const selectedText = "";
(getNotesFromPath as jest.Mock).mockImplementation((_vault: Vault, variableName: string) => {
if (variableName === "Active Note") {
// Assuming processVariableNameForNotePath is mocked to return this
return [mockActiveNote];
}
return [];
});
(getFileName as jest.Mock).mockReturnValue(mockActiveNote.basename);
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
if (file.basename === "Active Note") {
return "Active Note Content";
}
return "";
});
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
'This is a test prompt with {invalidVariable} name and {activeNote}\n\n<variable name="activeNote">\n<variable_note>\n<path>path/to/active/note.md</path>\n## Active Note\n\nActive Note Content\n</variable_note>\n</variable>'
);
expect(result.includedFiles).toContain(mockActiveNote);
// Expect the warning for the invalid variable
expect(logWarn).toHaveBeenCalledWith("No notes found for variable: invalidVariable");
});
});
describe("sortSlashCommands", () => {
const sampleCommands = [
{
title: "Beta",
content: "",
showInContextMenu: false,
showInSlashMenu: true,
order: 2,
modelKey: "",
lastUsedMs: 100,
},
{
title: "Alpha",
content: "",
showInContextMenu: false,
showInSlashMenu: true,
order: 1,
modelKey: "",
lastUsedMs: 150,
},
{
title: "Gamma",
content: "",
showInContextMenu: false,
showInSlashMenu: true,
order: 3,
modelKey: "",
lastUsedMs: 200,
},
];
afterEach(() => {
jest.clearAllMocks();
});
it("sorts by recency (TIMESTAMP)", () => {
jest.spyOn(settingsModelModule, "getSettings").mockReturnValue({
...DEFAULT_SETTINGS,
promptSortStrategy: PromptSortStrategy.TIMESTAMP,
});
const sorted = sortSlashCommands(sampleCommands);
expect(sorted.map((c: any) => c.title)).toEqual(["Gamma", "Alpha", "Beta"]);
});
it("sorts alphabetically (ALPHABETICAL)", () => {
jest.spyOn(settingsModelModule, "getSettings").mockReturnValue({
...DEFAULT_SETTINGS,
promptSortStrategy: PromptSortStrategy.ALPHABETICAL,
});
const sorted = sortSlashCommands(sampleCommands);
expect(sorted.map((c: any) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
});
it("sorts by manual order (MANUAL)", () => {
jest.spyOn(settingsModelModule, "getSettings").mockReturnValue({
...DEFAULT_SETTINGS,
promptSortStrategy: PromptSortStrategy.MANUAL,
});
const sorted = sortSlashCommands(sampleCommands);
expect(sorted.map((c: any) => c.title)).toEqual(["Alpha", "Beta", "Gamma"]);
});
it("returns original order for unknown strategy", () => {
jest.spyOn(settingsModelModule, "getSettings").mockReturnValue({
...DEFAULT_SETTINGS,
promptSortStrategy: "UNKNOWN",
});
const sorted = sortSlashCommands(sampleCommands);
expect(sorted).toEqual(sampleCommands);
});
});
describe("parseCustomCommandFile", () => {
let originalApp: any;
let mockFile: any;
let mockFrontmatter: any;
let mockMetadata: any;
beforeEach(() => {
// Save and mock global app
originalApp = global.app;
mockFrontmatter = {
"copilot-command-context-menu-enabled": true,
"copilot-command-slash-enabled": false,
"copilot-command-context-menu-order": 42,
"copilot-command-model-key": "gpt-4",
"copilot-command-last-used": 1234567890,
};
mockMetadata = { frontmatter: mockFrontmatter };
global.app = {
vault: {
read: jest
.fn()
.mockResolvedValue(
"---\ncopilot-command-context-menu-enabled: true\ncopilot-command-slash-enabled: false\ncopilot-command-context-menu-order: 42\ncopilot-command-model-key: gpt-4\ncopilot-command-last-used: 1234567890\n---\nPrompt content here."
) as any,
} as any,
metadataCache: {
getFileCache: jest.fn().mockReturnValue(mockMetadata) as any,
} as any,
} as any;
mockFile = {
basename: "Test Command",
extension: "md",
path: "CustomCommands/Test Command.md",
};
});
afterEach(() => {
global.app = originalApp;
});
it("parses a custom command file with frontmatter and content", async () => {
const { parseCustomCommandFile } = await import("@/commands/customCommandUtils");
const result = await parseCustomCommandFile(mockFile);
expect(result).toEqual({
title: "Test Command",
modelKey: "gpt-4",
content: "Prompt content here.",
showInContextMenu: true,
showInSlashMenu: false,
order: 42,
lastUsedMs: 1234567890,
});
expect(app.vault.read).toHaveBeenCalledWith(mockFile);
expect(app.metadataCache.getFileCache).toHaveBeenCalledWith(mockFile);
});
it("uses EMPTY_COMMAND defaults if frontmatter is missing", async () => {
(global.app.vault as any).read.mockResolvedValue("Prompt content only, no frontmatter.");
(global.app.metadataCache as any).getFileCache.mockReturnValue({});
const { parseCustomCommandFile } = await import("@/commands/customCommandUtils");
const result = await parseCustomCommandFile(mockFile);
expect(result).toEqual({
title: "Test Command",
modelKey: "",
content: "Prompt content only, no frontmatter.",
showInContextMenu: true,
showInSlashMenu: true,
order: 0,
lastUsedMs: 0,
});
});
});
describe("validateCommandName", () => {
const baseCommands = [
{
title: "Command One",
content: "",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
},
{
title: "Command Two",
content: "",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
},
{
title: "Another Command",
content: "",
showInContextMenu: false,
showInSlashMenu: false,
order: 0,
modelKey: "",
lastUsedMs: 0,
},
];
it("returns null for a unique, valid name", () => {
expect(validateCommandName("New Command", baseCommands)).toBeNull();
});
it("returns error for duplicate name (case-insensitive)", () => {
expect(validateCommandName("command one", baseCommands)).toBe(
"A command with this name already exists"
);
expect(validateCommandName("COMMAND TWO", baseCommands)).toBe(
"A command with this name already exists"
);
});
it("returns error for invalid characters", () => {
const invalids = [
"Invalid#Name",
"Invalid<Name>",
'Invalid:"Name"',
"Invalid/Name",
"Invalid\\Name",
"Invalid|Name",
"Invalid?Name",
"Invalid*Name",
"Invalid[Name]",
"Invalid^Name",
"Invalid\x00Name",
"Invalid\x1FName",
];
for (const name of invalids) {
expect(validateCommandName(name, baseCommands)).toMatch(
/Command name contains invalid characters/
);
}
});
it("returns null if unchanged currentCommandName", () => {
expect(validateCommandName("Command One", baseCommands, "Command One")).toBeNull();
expect(validateCommandName(" Command One ", baseCommands, "Command One")).toBeNull();
});
});