Introduce Vitest test suite for UID generation and command behavior

Sets up Vitest with an aliased obsidian stub so tests can load production
modules without the real Electron-bound API, and a fake-app builder
(in-memory vault, frontmatter, processFrontMatter) for testing commands
that touch the Obsidian app object.

Coverage:
- uidUtils: generator selection (UUID/NanoID/ULID), NanoID separator
  injection, collision retry, getUIDFromFile/setUID/removeUID across
  edge cases (custom uidKey, legacy key cleanup, error paths).
- commands: settings-driven decisions (autoGenerateUid toggle, scope=vault
  vs folder, exclusions including multi-entry / whitespace / blank /
  trailing-slash variants), bulk vs per-file parity, copy/clear flows,
  and degenerate "everything excluded" combinations.

Includes one intentionally failing it() at commands.test.ts:225 that
documents the inconsistency between handleClearUIDsInFolder (treats "/"
as the whole vault) and the exclusion matcher (treats "/" as inert).

The build's tsc step now excludes tests/ and *.test.ts; Vitest type-checks
test code via esbuild at run time. Run with: npm test (or npm run test:watch).
This commit is contained in:
Netajam 2026-04-27 20:36:59 +02:00
parent c1987a46f1
commit 3fd4a2ff97
8 changed files with 3181 additions and 874 deletions

2392
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,8 @@
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"test:watch": "vitest",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
@ -19,7 +21,8 @@
"esbuild": "^0.25.2",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "4.7.4",
"vitest": "^1.6.1"
},
"dependencies": {
"nanoid": "^3.3.7",

816
src/commands.test.ts Normal file
View file

@ -0,0 +1,816 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TFolder } from 'obsidian';
import {
handleAutoGenerateUid,
handleAddMissingUidsInScope,
handleClearUIDsInFolder,
handleCopyTitleUid,
handleGenerateUpdateUid,
handleCreateUidIfMissing,
handleRemoveUid,
handleCopyUid,
handleCopyTitlesAndUidsFromFolder,
handleCopyTitlesAndUidsForMultipleFiles,
} from './commands';
import { makeFakeApp, installFakeClipboard } from '../tests/fakes/app';
import type { UIDGeneratorSettings } from './settings';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
describe('handleAutoGenerateUid — settings-driven decisions', () => {
type Case = {
name: string;
settings: Partial<UIDGeneratorSettings>;
filePath: string;
extension?: string;
existingUid?: string;
shouldGenerate: boolean;
};
const cases: Case[] = [
{
name: 'autoGenerateUid=false → no-op even on a plain markdown file',
settings: { autoGenerateUid: false },
filePath: 'foo.md',
shouldGenerate: false,
},
{
name: 'non-markdown file → no-op',
settings: { autoGenerateUid: true },
filePath: 'image.png',
extension: 'png',
shouldGenerate: false,
},
{
name: 'autoGen=on, scope=vault, no exclusions → generates',
settings: { autoGenerateUid: true, autoGenerationScope: 'vault' },
filePath: 'foo.md',
shouldGenerate: true,
},
{
name: 'autoGen=on, file already has uid → no-op',
settings: { autoGenerateUid: true, autoGenerationScope: 'vault' },
filePath: 'foo.md',
existingUid: 'pre-existing',
shouldGenerate: false,
},
{
name: 'scope=folder, file inside scope → generates',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
},
filePath: 'Notes/foo.md',
shouldGenerate: true,
},
{
name: 'scope=folder, file in nested subfolder of scope → generates',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
},
filePath: 'Notes/sub/deep/foo.md',
shouldGenerate: true,
},
{
name: 'scope=folder, file outside scope → no-op',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
},
filePath: 'OtherFolder/foo.md',
shouldGenerate: false,
},
{
name: 'scope=folder with empty folder setting → no-op (defensive)',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: '',
},
filePath: 'foo.md',
shouldGenerate: false,
},
{
name: 'scope=vault with exclusion match → no-op',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'vault',
autoGenerationExclusions: ['Drafts'],
},
filePath: 'Drafts/foo.md',
shouldGenerate: false,
},
{
name: 'scope=vault with exclusion match in subfolder → no-op',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'vault',
autoGenerationExclusions: ['Drafts'],
},
filePath: 'Drafts/sub/foo.md',
shouldGenerate: false,
},
{
name: 'scope=vault, exclusion does not match this file → generates',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'vault',
autoGenerationExclusions: ['Drafts'],
},
filePath: 'Notes/foo.md',
shouldGenerate: true,
},
{
name: 'scope=folder with nested exclusion → exclusion wins',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
autoGenerationExclusions: ['Notes/private'],
},
filePath: 'Notes/private/secret.md',
shouldGenerate: false,
},
{
name: 'scope=folder with sibling not under exclusion → generates',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
autoGenerationExclusions: ['Notes/private'],
},
filePath: 'Notes/public/ok.md',
shouldGenerate: true,
},
];
for (const c of cases) {
it(c.name, async () => {
const fm = c.existingUid ? { uid: c.existingUid } : {};
const app = makeFakeApp({
files: [{ path: c.filePath, extension: c.extension, frontmatter: fm }],
settings: c.settings,
});
const file = app.files[0];
await handleAutoGenerateUid(app.plugin, file);
const after = app.frontmatterByPath.get(c.filePath) ?? {};
if (c.shouldGenerate) {
expect(after.uid).toMatch(UUID_RE);
expect(app.plugin.uidCache.has(after.uid as string)).toBe(true);
} else if (c.existingUid) {
expect(after.uid).toBe(c.existingUid);
} else {
expect(after.uid).toBeUndefined();
}
});
}
});
describe('handleAutoGenerateUid — exclusion list edge cases', () => {
async function runOn(
filePath: string,
exclusions: string[],
): Promise<string | undefined> {
const app = makeFakeApp({
files: [{ path: filePath }],
settings: {
autoGenerateUid: true,
autoGenerationScope: 'vault',
autoGenerationExclusions: exclusions,
},
});
await handleAutoGenerateUid(app.plugin, app.files[0]);
return app.frontmatterByPath.get(filePath)?.uid as string | undefined;
}
it('honours every entry in a multi-folder exclusion list', async () => {
expect(await runOn('Drafts/a.md', ['Drafts', 'Templates'])).toBeUndefined();
expect(await runOn('Templates/b.md', ['Drafts', 'Templates'])).toBeUndefined();
expect(await runOn('Notes/c.md', ['Drafts', 'Templates'])).toMatch(UUID_RE);
});
it('still applies later exclusions when an earlier one does not match', async () => {
// `.some()` short-circuits on the first match; this proves a non-match
// at the head of the list does not cause subsequent entries to be skipped.
expect(await runOn('Templates/b.md', ['Drafts', 'Templates'])).toBeUndefined();
});
it('treats a trailing slash on an exclusion path as equivalent', async () => {
expect(await runOn('Drafts/a.md', ['Drafts/'])).toBeUndefined();
});
it('strips surrounding whitespace from exclusion entries', async () => {
expect(await runOn('Drafts/a.md', [' Drafts '])).toBeUndefined();
});
it('ignores blank/whitespace-only exclusion entries instead of matching everything', async () => {
// If an empty string accidentally matched as a prefix, every file would be excluded.
// The guard `normEx && (...)` should keep blank entries inert.
expect(await runOn('Notes/a.md', ['', ' '])).toMatch(UUID_RE);
});
it('combines blank entries with real ones without losing the real exclusion', async () => {
expect(await runOn('Drafts/a.md', ['', 'Drafts', ' '])).toBeUndefined();
expect(await runOn('Notes/b.md', ['', 'Drafts', ' '])).toMatch(UUID_RE);
});
it('"/" excludes every file (consistent with handleClearUIDsInFolder)', async () => {
// EXPECTED behavior — currently fails. handleClearUIDsInFolder treats "/"
// as "the whole vault"; the exclusion matcher should follow the same
// convention so users get consistent semantics across settings.
// Fix lives in commands.ts: special-case normEx === '/' to match every path.
expect(await runOn('note.md', ['/'])).toBeUndefined();
expect(await runOn('Notes/sub/deep.md', ['/'])).toBeUndefined();
// Mixed with another entry, "/" should still short-circuit every file.
expect(await runOn('Notes/b.md', ['/', 'Drafts'])).toBeUndefined();
});
});
describe('settings that effectively exclude every file', () => {
// There's no single "exclude the whole vault" setting — the toggle for that
// is `autoGenerateUid: false`. These tests cover degenerate combinations
// that produce the same effect, to make sure nothing crashes or surprises.
it('per-file: scope=folder pointing at a non-existent path skips every file', async () => {
const app = makeFakeApp({
files: [
{ path: 'a.md' },
{ path: 'Notes/b.md' },
{ path: 'Notes/sub/c.md' },
],
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'NonExistent',
},
});
for (const file of app.files) {
await handleAutoGenerateUid(app.plugin, file);
}
for (const path of ['a.md', 'Notes/b.md', 'Notes/sub/c.md']) {
expect(app.frontmatterByPath.get(path)?.uid).toBeUndefined();
}
expect(app.processFrontMatter).not.toHaveBeenCalled();
});
it('per-file: scope=folder + that same folder excluded skips every file', async () => {
const app = makeFakeApp({
files: [
{ path: 'Notes/a.md' },
{ path: 'Notes/sub/b.md' },
{ path: 'Other/c.md' },
],
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
autoGenerationExclusions: ['Notes'], // cancels out the scope
},
});
for (const file of app.files) {
await handleAutoGenerateUid(app.plugin, file);
}
for (const path of ['Notes/a.md', 'Notes/sub/b.md', 'Other/c.md']) {
expect(app.frontmatterByPath.get(path)?.uid).toBeUndefined();
}
});
it('bulk: handleAddMissingUidsInScope completes cleanly when every file is out of scope', async () => {
const app = makeFakeApp({
files: [
{ path: 'a.md' },
{ path: 'Notes/b.md' },
{ path: 'Notes/sub/c.md' },
],
settings: {
autoGenerationScope: 'folder',
autoGenerationFolder: 'NonExistent',
},
});
await expect(handleAddMissingUidsInScope(app.plugin)).resolves.toBeUndefined();
for (const path of ['a.md', 'Notes/b.md', 'Notes/sub/c.md']) {
expect(app.frontmatterByPath.get(path)?.uid).toBeUndefined();
}
expect(app.processFrontMatter).not.toHaveBeenCalled();
});
it('bulk: handleAddMissingUidsInScope completes cleanly when every file is excluded', async () => {
const app = makeFakeApp({
files: [
{ path: 'Drafts/a.md' },
{ path: 'Templates/b.md' },
{ path: 'Templates/sub/c.md' },
],
settings: {
autoGenerationScope: 'vault',
autoGenerationExclusions: ['Drafts', 'Templates'],
},
});
await expect(handleAddMissingUidsInScope(app.plugin)).resolves.toBeUndefined();
for (const path of ['Drafts/a.md', 'Templates/b.md', 'Templates/sub/c.md']) {
expect(app.frontmatterByPath.get(path)?.uid).toBeUndefined();
}
expect(app.processFrontMatter).not.toHaveBeenCalled();
});
});
describe('parity — handleAutoGenerateUid vs handleAddMissingUidsInScope', () => {
// Both functions implement the same "should this file be skipped?" decision.
// One operates per-file (event-driven), the other in bulk. If the two ever
// drift, users would see inconsistent behavior between auto-gen on file
// create/open vs. the manual "Generate missing UIDs now" button.
type Scenario = {
name: string;
settings: Partial<UIDGeneratorSettings>;
files: string[];
};
const scenarios: Scenario[] = [
{
name: 'vault scope, no exclusions',
settings: { autoGenerateUid: true, autoGenerationScope: 'vault' },
files: ['root.md', 'Notes/a.md', 'Notes/sub/b.md', 'Drafts/c.md'],
},
{
name: 'folder scope, no exclusions',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
},
files: ['root.md', 'Notes/a.md', 'Notes/sub/b.md', 'Other/c.md'],
},
{
name: 'vault scope with exclusions',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'vault',
autoGenerationExclusions: ['Drafts', 'Templates'],
},
files: ['Notes/a.md', 'Drafts/b.md', 'Templates/c.md', 'root.md'],
},
{
name: 'folder scope plus exclusion under that scope',
settings: {
autoGenerateUid: true,
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
autoGenerationExclusions: ['Notes/private'],
},
files: [
'Notes/a.md',
'Notes/private/secret.md',
'Notes/public/ok.md',
'Other/d.md',
],
},
];
async function runHandlerPerFile(s: Scenario): Promise<Set<string>> {
const app = makeFakeApp({
files: s.files.map((path) => ({ path })),
settings: s.settings,
});
for (const file of app.files) {
await handleAutoGenerateUid(app.plugin, file);
}
return collectUidPaths(app);
}
async function runBulk(s: Scenario): Promise<Set<string>> {
const app = makeFakeApp({
files: s.files.map((path) => ({ path })),
settings: s.settings,
});
await handleAddMissingUidsInScope(app.plugin);
return collectUidPaths(app);
}
function collectUidPaths(app: ReturnType<typeof makeFakeApp>): Set<string> {
const out = new Set<string>();
for (const [path, fm] of app.frontmatterByPath) {
if (fm.uid) out.add(path);
}
return out;
}
for (const s of scenarios) {
it(`makes the same skip/generate decisions: ${s.name}`, async () => {
const perFile = await runHandlerPerFile(s);
const bulk = await runBulk(s);
expect(Array.from(bulk).sort()).toEqual(Array.from(perFile).sort());
// Sanity check: at least one file should have been generated, otherwise
// the scenario is degenerate and both functions trivially "agree".
expect(perFile.size).toBeGreaterThan(0);
});
}
});
describe('handleAddMissingUidsInScope — bulk decision over many files', () => {
it('adds UIDs only to in-scope, non-excluded, uid-less markdown files', async () => {
const app = makeFakeApp({
files: [
{ path: 'Notes/a.md' }, // in scope, no uid → add
{ path: 'Notes/b.md', frontmatter: { uid: 'keep-me' } }, // in scope, has uid → skip
{ path: 'Notes/private/secret.md' }, // excluded → skip
{ path: 'Drafts/c.md' }, // out of scope → skip
{ path: 'Notes/sub/d.md' }, // in scope (nested) → add
{ path: 'image.png', extension: 'png' }, // not markdown → skip (not in getMarkdownFiles)
],
settings: {
autoGenerationScope: 'folder',
autoGenerationFolder: 'Notes',
autoGenerationExclusions: ['Notes/private'],
},
});
await handleAddMissingUidsInScope(app.plugin);
const fm = (path: string) => app.frontmatterByPath.get(path) ?? {};
expect(fm('Notes/a.md').uid).toMatch(UUID_RE);
expect(fm('Notes/b.md').uid).toBe('keep-me');
expect(fm('Notes/private/secret.md').uid).toBeUndefined();
expect(fm('Drafts/c.md').uid).toBeUndefined();
expect(fm('Notes/sub/d.md').uid).toMatch(UUID_RE);
});
it('uses the configured uidKey, not "uid"', async () => {
const app = makeFakeApp({
files: [{ path: 'Notes/a.md' }],
settings: { uidKey: 'note-id', autoGenerationScope: 'vault' },
});
await handleAddMissingUidsInScope(app.plugin);
const fm = app.frontmatterByPath.get('Notes/a.md') ?? {};
expect(fm['note-id']).toMatch(UUID_RE);
expect(fm.uid).toBeUndefined();
});
it('uses the configured generator (NanoID)', async () => {
const app = makeFakeApp({
files: [{ path: 'a.md' }, { path: 'b.md' }],
settings: {
autoGenerationScope: 'vault',
uidGenerator: 'nanoid',
nanoidAlphabet: 'xyz',
nanoidLength: 8,
},
});
await handleAddMissingUidsInScope(app.plugin);
for (const path of ['a.md', 'b.md']) {
const fm = app.frontmatterByPath.get(path) ?? {};
expect(fm.uid).toMatch(/^[xyz]{8}$/);
}
});
});
describe('handleClearUIDsInFolder — folder scoping', () => {
it('removes uids only from files inside the target folder', async () => {
const app = makeFakeApp({
files: [
{ path: 'Notes/a.md', frontmatter: { uid: 'a-uid' } },
{ path: 'Notes/sub/b.md', frontmatter: { uid: 'b-uid' } },
{ path: 'Other/c.md', frontmatter: { uid: 'c-uid' } },
],
});
await handleClearUIDsInFolder(app.plugin, 'Notes');
expect(app.frontmatterByPath.get('Notes/a.md')?.uid).toBeUndefined();
expect(app.frontmatterByPath.get('Notes/sub/b.md')?.uid).toBeUndefined();
expect(app.frontmatterByPath.get('Other/c.md')?.uid).toBe('c-uid');
});
it('clears uids from every file when folder path is "/"', async () => {
const app = makeFakeApp({
files: [
{ path: 'a.md', frontmatter: { uid: 'a-uid' } },
{ path: 'sub/b.md', frontmatter: { uid: 'b-uid' } },
],
});
await handleClearUIDsInFolder(app.plugin, '/');
expect(app.frontmatterByPath.get('a.md')?.uid).toBeUndefined();
expect(app.frontmatterByPath.get('sub/b.md')?.uid).toBeUndefined();
});
it('does nothing when the folder does not exist', async () => {
const app = makeFakeApp({
files: [{ path: 'Notes/a.md', frontmatter: { uid: 'keep' } }],
});
await handleClearUIDsInFolder(app.plugin, 'NonExistent');
expect(app.frontmatterByPath.get('Notes/a.md')?.uid).toBe('keep');
expect(app.processFrontMatter).not.toHaveBeenCalled();
});
it('honours a custom uidKey when clearing', async () => {
const app = makeFakeApp({
files: [{ path: 'Notes/a.md', frontmatter: { 'note-id': 'gone', other: 'kept' } }],
settings: { uidKey: 'note-id' },
});
await handleClearUIDsInFolder(app.plugin, 'Notes');
const fm = app.frontmatterByPath.get('Notes/a.md') ?? {};
expect(fm['note-id']).toBeUndefined();
expect(fm.other).toBe('kept');
});
});
describe('handleCopyTitleUid — format selection', () => {
let clipboard: ReturnType<typeof installFakeClipboard>;
beforeEach(() => {
clipboard = installFakeClipboard();
});
afterEach(() => {
clipboard.restore();
});
it('uses copyFormatString when the file has a uid', async () => {
const app = makeFakeApp({
files: [{ path: 'Notes/Hello.md', frontmatter: { uid: 'abc-123' } }],
settings: { copyFormatString: '{title} :: {uid}' },
});
handleCopyTitleUid(app.plugin, app.files[0]);
// The handler kicks off a fire-and-forget promise; flush microtasks
await new Promise((r) => setImmediate(r));
expect(clipboard.writes).toEqual(['Hello :: abc-123']);
});
it('uses copyFormatStringMissingUid when the file has no uid', async () => {
const app = makeFakeApp({
files: [{ path: 'Notes/Hello.md' }],
settings: { copyFormatStringMissingUid: '{title} (no {uidKey})' },
});
handleCopyTitleUid(app.plugin, app.files[0]);
await new Promise((r) => setImmediate(r));
expect(clipboard.writes).toEqual(['Hello (no uid)']);
});
it('substitutes {uidKey} with the configured custom uidKey', async () => {
const app = makeFakeApp({
files: [{ path: 'Hello.md' }],
settings: {
uidKey: 'note-id',
copyFormatStringMissingUid: '{title} — missing {uidKey}',
},
});
handleCopyTitleUid(app.plugin, app.files[0]);
await new Promise((r) => setImmediate(r));
expect(clipboard.writes).toEqual(['Hello — missing note-id']);
});
});
describe('handleGenerateUpdateUid — overwrites the active file uid', () => {
it('replaces an existing uid with a fresh one', async () => {
const app = makeFakeApp({
files: [{ path: 'note.md', frontmatter: { uid: 'old-value' } }],
});
app.setActiveFile(app.files[0]);
await handleGenerateUpdateUid(app.plugin, true);
const fm = app.frontmatterByPath.get('note.md') ?? {};
expect(fm.uid).toMatch(UUID_RE);
expect(fm.uid).not.toBe('old-value');
});
it('creates a uid when none exists', async () => {
const app = makeFakeApp({ files: [{ path: 'note.md' }] });
app.setActiveFile(app.files[0]);
await handleGenerateUpdateUid(app.plugin, true);
expect(app.frontmatterByPath.get('note.md')?.uid).toMatch(UUID_RE);
});
it('is a no-op when there is no active markdown file', async () => {
const app = makeFakeApp({ files: [{ path: 'note.md' }] });
// active file deliberately not set
await handleGenerateUpdateUid(app.plugin, true);
expect(app.processFrontMatter).not.toHaveBeenCalled();
expect(app.frontmatterByPath.get('note.md')?.uid).toBeUndefined();
});
});
describe('handleCreateUidIfMissing — only sets when absent', () => {
it('writes a new uid when the file has none', async () => {
const app = makeFakeApp({ files: [{ path: 'note.md' }] });
app.setActiveFile(app.files[0]);
await handleCreateUidIfMissing(app.plugin);
expect(app.frontmatterByPath.get('note.md')?.uid).toMatch(UUID_RE);
});
it('preserves an existing uid (no overwrite)', async () => {
const app = makeFakeApp({
files: [{ path: 'note.md', frontmatter: { uid: 'keep-me' } }],
});
app.setActiveFile(app.files[0]);
await handleCreateUidIfMissing(app.plugin);
expect(app.frontmatterByPath.get('note.md')?.uid).toBe('keep-me');
// Short-circuits before processFrontMatter is invoked.
expect(app.processFrontMatter).not.toHaveBeenCalled();
});
});
describe('handleRemoveUid', () => {
it('removes the uid from the active file when present', async () => {
const app = makeFakeApp({
files: [{ path: 'note.md', frontmatter: { uid: 'gone-soon' } }],
});
app.setActiveFile(app.files[0]);
await handleRemoveUid(app.plugin);
expect(app.frontmatterByPath.get('note.md')?.uid).toBeUndefined();
});
it('is a no-op when the active file has no uid', async () => {
const app = makeFakeApp({ files: [{ path: 'note.md' }] });
app.setActiveFile(app.files[0]);
await handleRemoveUid(app.plugin);
// removeUID is called but finds nothing to delete; frontmatter stays empty.
expect(app.frontmatterByPath.get('note.md')?.uid).toBeUndefined();
});
it('removes the value at a custom uidKey only', async () => {
const app = makeFakeApp({
files: [
{
path: 'note.md',
frontmatter: { 'note-id': 'gone', tag: 'kept' },
},
],
settings: { uidKey: 'note-id' },
});
app.setActiveFile(app.files[0]);
await handleRemoveUid(app.plugin);
const fm = app.frontmatterByPath.get('note.md') ?? {};
expect(fm['note-id']).toBeUndefined();
expect(fm.tag).toBe('kept');
});
});
describe('handleCopyUid — copies the raw uid (no formatting)', () => {
let clipboard: ReturnType<typeof installFakeClipboard>;
beforeEach(() => {
clipboard = installFakeClipboard();
});
afterEach(() => {
clipboard.restore();
});
it('writes only the uid value, not title or key', async () => {
const app = makeFakeApp({
files: [{ path: 'Hello.md', frontmatter: { uid: 'just-me' } }],
});
app.setActiveFile(app.files[0]);
handleCopyUid(app.plugin);
await new Promise((r) => setImmediate(r));
expect(clipboard.writes).toEqual(['just-me']);
});
});
describe('handleCopyTitlesAndUidsFromFolder — bulk copy with format selection', () => {
let clipboard: ReturnType<typeof installFakeClipboard>;
beforeEach(() => {
clipboard = installFakeClipboard();
});
afterEach(() => {
clipboard.restore();
});
it('mixes the two format strings per file based on uid presence', async () => {
const app = makeFakeApp({
files: [
{ path: 'Notes/A.md', frontmatter: { uid: 'a-uid' } },
{ path: 'Notes/B.md' },
{ path: 'Notes/sub/C.md', frontmatter: { uid: 'c-uid' } },
{ path: 'Other/D.md', frontmatter: { uid: 'd-uid' } },
],
settings: {
copyFormatString: '{title}={uid}',
copyFormatStringMissingUid: '{title}=missing',
},
});
const folder = app.folders.get('Notes') as TFolder;
await handleCopyTitlesAndUidsFromFolder(app.plugin, folder);
expect(clipboard.writes).toHaveLength(1);
const lines = clipboard.writes[0].split('\n').sort();
expect(lines).toEqual(['A=a-uid', 'B=missing', 'C=c-uid'].sort());
});
it('copies the entire vault when folder path is "/"', async () => {
const app = makeFakeApp({
files: [
{ path: 'A.md', frontmatter: { uid: 'a' } },
{ path: 'sub/B.md', frontmatter: { uid: 'b' } },
],
settings: { copyFormatString: '{title}:{uid}' },
});
const root = app.folders.get('/') as TFolder;
await handleCopyTitlesAndUidsFromFolder(app.plugin, root);
expect(clipboard.writes).toHaveLength(1);
const lines = clipboard.writes[0].split('\n').sort();
expect(lines).toEqual(['A:a', 'B:b']);
});
it('does not write to the clipboard when the folder is empty', async () => {
const app = makeFakeApp({
files: [{ path: 'Other/A.md', frontmatter: { uid: 'a' } }],
});
// Build a folder reference that exists in the tree but has no md children.
const empty = new TFolder();
empty.path = 'NoMd';
empty.name = 'NoMd';
await handleCopyTitlesAndUidsFromFolder(app.plugin, empty);
expect(clipboard.writes).toEqual([]);
});
});
describe('handleCopyTitlesAndUidsForMultipleFiles', () => {
let clipboard: ReturnType<typeof installFakeClipboard>;
beforeEach(() => {
clipboard = installFakeClipboard();
});
afterEach(() => {
clipboard.restore();
});
it('joins per-file output with newline and uses the right format per file', async () => {
const app = makeFakeApp({
files: [
{ path: 'A.md', frontmatter: { uid: 'a-uid' } },
{ path: 'B.md' },
],
settings: {
copyFormatString: '{title}={uid}',
copyFormatStringMissingUid: '{title}=missing',
},
});
await handleCopyTitlesAndUidsForMultipleFiles(app.plugin, app.files);
expect(clipboard.writes).toEqual(['A=a-uid\nB=missing']);
});
it('does not write to the clipboard when the input array is empty', async () => {
const app = makeFakeApp({ files: [] });
await handleCopyTitlesAndUidsForMultipleFiles(app.plugin, []);
expect(clipboard.writes).toEqual([]);
});
});

597
src/uidUtils.test.ts Normal file
View file

@ -0,0 +1,597 @@
import { describe, it, expect, vi } from 'vitest';
import { TFile } from 'obsidian';
import {
generateUID,
formatCopyString,
getUIDFromFile,
setUID,
removeUID,
} from './uidUtils';
import type UIDGenerator from './main';
type Settings = {
uidKey: string;
uidGenerator: 'uuid' | 'nanoid' | 'ulid';
nanoidLength: number;
nanoidAlphabet: string;
nanoidSeparators: Array<{ char: string; position: number }>;
};
function makePlugin(
overrides: Partial<Settings> = {},
cache: Set<string> = new Set(),
): UIDGenerator {
const settings: Settings = {
uidKey: 'uid',
uidGenerator: 'uuid',
nanoidLength: 21,
nanoidAlphabet: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
nanoidSeparators: [],
...overrides,
};
return { settings, uidCache: cache } as unknown as UIDGenerator;
}
type Frontmatter = Record<string, unknown>;
function makeFile(path: string, extension = 'md'): TFile {
const file = new TFile();
file.path = path;
file.extension = extension;
return file;
}
interface PluginHarness {
plugin: UIDGenerator;
frontmatter: Frontmatter;
uidCache: Set<string>;
uidPathMap: Map<string, string>;
processFrontMatter: ReturnType<typeof vi.fn>;
}
function makePluginWithApp(opts: {
uidKey?: string;
frontmatter?: Frontmatter;
getFileCache?: (file: TFile) => unknown;
processFrontMatterImpl?: (
file: TFile,
fn: (fm: Frontmatter) => void,
) => Promise<void>;
} = {}): PluginHarness {
const fm: Frontmatter = opts.frontmatter ?? {};
const uidCache = new Set<string>();
const uidPathMap = new Map<string, string>();
const defaultProcess = async (
_file: TFile,
fn: (frontmatter: Frontmatter) => void,
) => {
fn(fm);
};
const processFrontMatter = vi.fn(opts.processFrontMatterImpl ?? defaultProcess);
const plugin = {
settings: {
uidKey: opts.uidKey ?? 'uid',
uidGenerator: 'uuid',
nanoidLength: 21,
nanoidAlphabet: 'abc',
nanoidSeparators: [],
},
uidCache,
uidPathMap,
app: {
metadataCache: {
getFileCache: opts.getFileCache ?? (() => ({ frontmatter: fm })),
},
fileManager: {
processFrontMatter,
},
},
} as unknown as UIDGenerator;
return { plugin, frontmatter: fm, uidCache, uidPathMap, processFrontMatter };
}
describe('formatCopyString', () => {
it('substitutes title, uid, and uidKey', () => {
const plugin = makePlugin({ uidKey: 'note-id' });
expect(
formatCopyString(plugin, '{title} - {uidKey}: {uid}', 'My Note', 'abc123'),
).toBe('My Note - note-id: abc123');
});
it('replaces missing uid with empty string', () => {
const plugin = makePlugin();
expect(formatCopyString(plugin, '{title} {uid}', 'Hello', null)).toBe('Hello ');
expect(formatCopyString(plugin, '{title} {uid}', 'Hello', undefined)).toBe('Hello ');
});
it('replaces missing title with empty string', () => {
const plugin = makePlugin();
expect(formatCopyString(plugin, '{title}-{uid}', '', 'x')).toBe('-x');
});
it('replaces every occurrence of a placeholder', () => {
const plugin = makePlugin();
expect(formatCopyString(plugin, '{uid}/{uid}', '', 'abc')).toBe('abc/abc');
});
it('returns the format string unchanged when no placeholders are present', () => {
const plugin = makePlugin();
expect(formatCopyString(plugin, 'no placeholders here', 't', 'u')).toBe(
'no placeholders here',
);
});
it('returns an empty string when the format string is empty', () => {
const plugin = makePlugin();
expect(formatCopyString(plugin, '', 'title', 'uid')).toBe('');
});
it('substitutes a {uid} literal inside the title (placeholders are processed in order)', () => {
// {title} is replaced before {uid}, so a literal "{uid}" in the title
// will itself be replaced on the next pass. Documenting current behavior.
const plugin = makePlugin();
expect(formatCopyString(plugin, '{title}', 'has {uid} inside', 'X')).toBe(
'has X inside',
);
});
});
describe('generateUID — generator selection', () => {
it('returns a valid UUID v4 by default', () => {
const id = generateUID(makePlugin({ uidGenerator: 'uuid' }));
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('returns a 26-char Crockford Base32 ULID when configured', () => {
const id = generateUID(makePlugin({ uidGenerator: 'ulid' }));
expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
});
it('returns a NanoID with the configured length and alphabet', () => {
const id = generateUID(
makePlugin({ uidGenerator: 'nanoid', nanoidAlphabet: 'abc', nanoidLength: 10 }),
);
expect(id).toHaveLength(10);
expect(id).toMatch(/^[abc]+$/);
});
});
describe('generateUID — NanoID separator injection', () => {
const constantNanoid = (length: number) => ({
uidGenerator: 'nanoid' as const,
nanoidAlphabet: 'a',
nanoidLength: length,
});
it('injects a separator at a positive position', () => {
const plugin = makePlugin({
...constantNanoid(6),
nanoidSeparators: [{ char: '-', position: 3 }],
});
expect(generateUID(plugin)).toBe('aaa-aaa');
});
it('injects a separator at a negative position (counted from the end)', () => {
const plugin = makePlugin({
...constantNanoid(6),
nanoidSeparators: [{ char: '_', position: -2 }],
});
expect(generateUID(plugin)).toBe('aaaa_aa');
});
it('clamps positions outside the valid range', () => {
const plugin = makePlugin({
...constantNanoid(4),
nanoidSeparators: [
{ char: 'L', position: -100 },
{ char: 'R', position: 100 },
],
});
expect(generateUID(plugin)).toBe('LaaaaR');
});
it('applies multiple separators in the correct order', () => {
const plugin = makePlugin({
...constantNanoid(6),
nanoidSeparators: [
{ char: '-', position: 2 },
{ char: '-', position: 4 },
],
});
expect(generateUID(plugin)).toBe('aa-aa-aa');
});
it('ignores separators with empty or multi-char strings', () => {
const plugin = makePlugin({
...constantNanoid(4),
nanoidSeparators: [
{ char: '', position: 2 },
{ char: 'xx', position: 1 },
],
});
expect(generateUID(plugin)).toBe('aaaa');
});
it('inserts a separator at position 0 (prepended)', () => {
const plugin = makePlugin({
...constantNanoid(4),
nanoidSeparators: [{ char: '<', position: 0 }],
});
expect(generateUID(plugin)).toBe('<aaaa');
});
it('inserts a separator at position == nanoidLength (appended)', () => {
const plugin = makePlugin({
...constantNanoid(4),
nanoidSeparators: [{ char: '>', position: 4 }],
});
expect(generateUID(plugin)).toBe('aaaa>');
});
it('inserts a separator at position == -nanoidLength (wraps to position 0)', () => {
const plugin = makePlugin({
...constantNanoid(4),
nanoidSeparators: [{ char: '|', position: -4 }],
});
expect(generateUID(plugin)).toBe('|aaaa');
});
it('handles two separators sharing the same position', () => {
// Both have position 2; sort is stable on ties, so they're inserted at the
// same slice index in iteration order. Each insertion shifts the next.
const plugin = makePlugin({
...constantNanoid(4),
nanoidSeparators: [
{ char: 'X', position: 2 },
{ char: 'Y', position: 2 },
],
});
expect(generateUID(plugin)).toMatch(/^aa(XY|YX)aa$/);
});
});
describe('generateUID — collision handling', () => {
it('returns the duplicate after exhausting retries when no unique id is possible', () => {
const cache = new Set<string>(['a']);
const plugin = makePlugin(
{ uidGenerator: 'nanoid', nanoidAlphabet: 'a', nanoidLength: 1 },
cache,
);
expect(generateUID(plugin)).toBe('a');
});
it('returns a non-cached id when one is available', () => {
const cache = new Set<string>(); // empty cache, no collisions possible
const plugin = makePlugin(
{ uidGenerator: 'nanoid', nanoidAlphabet: 'ab', nanoidLength: 8 },
cache,
);
const id = generateUID(plugin);
expect(id).toMatch(/^[ab]{8}$/);
expect(cache.has(id)).toBe(false);
});
});
describe('generateUID — NanoID generator caching', () => {
it('reflects an alphabet change immediately (cache is invalidated)', () => {
const plugin = makePlugin({
uidGenerator: 'nanoid',
nanoidAlphabet: 'a',
nanoidLength: 6,
});
expect(generateUID(plugin)).toBe('aaaaaa');
plugin.settings.nanoidAlphabet = 'b';
expect(generateUID(plugin)).toBe('bbbbbb');
});
it('reflects a length change immediately (cache is invalidated)', () => {
const plugin = makePlugin({
uidGenerator: 'nanoid',
nanoidAlphabet: 'a',
nanoidLength: 4,
});
expect(generateUID(plugin)).toHaveLength(4);
plugin.settings.nanoidLength = 12;
expect(generateUID(plugin)).toHaveLength(12);
});
});
describe('getUIDFromFile', () => {
it('returns null when file is null', () => {
const { plugin } = makePluginWithApp();
expect(getUIDFromFile(plugin, null)).toBeNull();
});
it('returns null when the file has no frontmatter', () => {
const { plugin } = makePluginWithApp({ getFileCache: () => ({}) });
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBeNull();
});
it('returns null when the configured key is missing', () => {
const { plugin } = makePluginWithApp({
uidKey: 'note-id',
frontmatter: { other: 'value' },
});
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBeNull();
});
it('returns the value when the key is present as a string', () => {
const { plugin } = makePluginWithApp({
frontmatter: { uid: 'abc-123' },
});
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBe('abc-123');
});
it('coerces numeric YAML values to a string', () => {
const { plugin } = makePluginWithApp({
frontmatter: { uid: 12345 },
});
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBe('12345');
});
it('returns null for unsupported value types (boolean, array, object)', () => {
const cases: Frontmatter[] = [
{ uid: true },
{ uid: [1, 2, 3] },
{ uid: { nested: 'thing' } },
];
for (const fm of cases) {
const { plugin } = makePluginWithApp({ frontmatter: fm });
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBeNull();
}
});
it('swallows errors thrown by the metadata cache and returns null', () => {
const { plugin } = makePluginWithApp({
getFileCache: () => {
throw new Error('boom');
},
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBeNull();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('coerces a numeric 0 to the string "0" (does not treat it as missing)', () => {
const { plugin } = makePluginWithApp({ frontmatter: { uid: 0 } });
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBe('0');
});
it('returns the empty string when the value is an empty string', () => {
// Empty string is technically "present"; the function returns it verbatim.
// Documenting current behavior — callers must check for falsy themselves.
const { plugin } = makePluginWithApp({ frontmatter: { uid: '' } });
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBe('');
});
it('returns null when the value is explicitly null', () => {
const { plugin } = makePluginWithApp({ frontmatter: { uid: null } });
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBeNull();
});
it('returns null when getFileCache returns null', () => {
const { plugin } = makePluginWithApp({ getFileCache: () => null });
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBeNull();
});
it('reads the configured custom uidKey instead of "uid"', () => {
const { plugin } = makePluginWithApp({
uidKey: 'note-id',
frontmatter: { uid: 'wrong', 'note-id': 'right' },
});
expect(getUIDFromFile(plugin, makeFile('note.md'))).toBe('right');
});
});
describe('setUID', () => {
it('returns false for a null file', async () => {
const { plugin } = makePluginWithApp();
expect(await setUID(plugin, null as unknown as TFile, 'x')).toBe(false);
});
it('returns false for a non-TFile object', async () => {
const { plugin, processFrontMatter } = makePluginWithApp();
const fakeFile = { path: 'note.md', extension: 'md' } as unknown as TFile;
expect(await setUID(plugin, fakeFile, 'x')).toBe(false);
expect(processFrontMatter).not.toHaveBeenCalled();
});
it('returns false for non-markdown files', async () => {
const { plugin, processFrontMatter } = makePluginWithApp();
expect(await setUID(plugin, makeFile('image.png', 'png'), 'x')).toBe(false);
expect(processFrontMatter).not.toHaveBeenCalled();
});
it('returns false when the UID is empty', async () => {
const { plugin, processFrontMatter } = makePluginWithApp();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(await setUID(plugin, makeFile('note.md'), '')).toBe(false);
expect(processFrontMatter).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it('sets the UID and updates caches when the key is missing', async () => {
const { plugin, frontmatter, uidCache, uidPathMap } = makePluginWithApp({
frontmatter: {},
});
const file = makeFile('note.md');
expect(await setUID(plugin, file, 'new-uid')).toBe(true);
expect(frontmatter.uid).toBe('new-uid');
expect(uidCache.has('new-uid')).toBe(true);
expect(uidPathMap.get('note.md')).toBe('new-uid');
});
it('does not overwrite an existing UID when overwrite=false', async () => {
const { plugin, frontmatter, uidCache, uidPathMap } = makePluginWithApp({
frontmatter: { uid: 'existing' },
});
const file = makeFile('note.md');
expect(await setUID(plugin, file, 'new-uid')).toBe(false);
expect(frontmatter.uid).toBe('existing');
expect(uidCache.has('new-uid')).toBe(false);
expect(uidPathMap.has('note.md')).toBe(false);
});
it('overwrites an existing UID when overwrite=true and the value differs', async () => {
const { plugin, frontmatter, uidCache } = makePluginWithApp({
frontmatter: { uid: 'old' },
});
const file = makeFile('note.md');
expect(await setUID(plugin, file, 'new', true)).toBe(true);
expect(frontmatter.uid).toBe('new');
expect(uidCache.has('new')).toBe(true);
});
it('reports no change when overwrite=true but the value is identical', async () => {
const { plugin, uidCache } = makePluginWithApp({
frontmatter: { uid: 'same' },
});
const file = makeFile('note.md');
expect(await setUID(plugin, file, 'same', true)).toBe(false);
expect(uidCache.has('same')).toBe(false);
});
it('removes legacy uid/Uid/UID keys when a custom key is configured', async () => {
const { plugin, frontmatter } = makePluginWithApp({
uidKey: 'note-id',
frontmatter: { uid: 'a', Uid: 'b', UID: 'c' },
});
const file = makeFile('note.md');
expect(await setUID(plugin, file, 'fresh')).toBe(true);
expect(frontmatter['note-id']).toBe('fresh');
expect(frontmatter.uid).toBeUndefined();
expect(frontmatter.Uid).toBeUndefined();
expect(frontmatter.UID).toBeUndefined();
});
it('returns false and logs when processFrontMatter rejects', async () => {
const { plugin } = makePluginWithApp({
processFrontMatterImpl: async () => {
throw new Error('disk error');
},
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(await setUID(plugin, makeFile('note.md'), 'x')).toBe(false);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('treats an existing empty-string UID as missing and writes the new value', async () => {
const { plugin, frontmatter, uidCache } = makePluginWithApp({
frontmatter: { uid: '' },
});
expect(await setUID(plugin, makeFile('note.md'), 'fresh')).toBe(true);
expect(frontmatter.uid).toBe('fresh');
expect(uidCache.has('fresh')).toBe(true);
});
it('treats an existing null UID as missing and writes the new value', async () => {
const { plugin, frontmatter } = makePluginWithApp({
frontmatter: { uid: null },
});
expect(await setUID(plugin, makeFile('note.md'), 'fresh')).toBe(true);
expect(frontmatter.uid).toBe('fresh');
});
it('preserves the active uid key while still cleaning Uid/UID legacy variants', async () => {
const { plugin, frontmatter } = makePluginWithApp({
uidKey: 'uid',
frontmatter: { Uid: 'legacy-mixed', UID: 'legacy-upper' },
});
expect(await setUID(plugin, makeFile('note.md'), 'new-uid')).toBe(true);
expect(frontmatter.uid).toBe('new-uid');
expect(frontmatter.Uid).toBeUndefined();
expect(frontmatter.UID).toBeUndefined();
});
it('rejects non-md extensions like canvas', async () => {
const { plugin, processFrontMatter } = makePluginWithApp();
expect(await setUID(plugin, makeFile('board.canvas', 'canvas'), 'x')).toBe(false);
expect(processFrontMatter).not.toHaveBeenCalled();
});
it('rejects files with no extension', async () => {
const { plugin, processFrontMatter } = makePluginWithApp();
expect(await setUID(plugin, makeFile('weird', ''), 'x')).toBe(false);
expect(processFrontMatter).not.toHaveBeenCalled();
});
});
describe('removeUID', () => {
it('returns false for a null file', async () => {
const { plugin } = makePluginWithApp();
expect(await removeUID(plugin, null as unknown as TFile)).toBe(false);
});
it('returns false for a non-TFile object', async () => {
const { plugin, processFrontMatter } = makePluginWithApp();
const fakeFile = { path: 'note.md', extension: 'md' } as unknown as TFile;
expect(await removeUID(plugin, fakeFile)).toBe(false);
expect(processFrontMatter).not.toHaveBeenCalled();
});
it('returns false when the UID key is not present', async () => {
const { plugin, frontmatter } = makePluginWithApp({
frontmatter: { other: 'thing' },
});
expect(await removeUID(plugin, makeFile('note.md'))).toBe(false);
expect(frontmatter.other).toBe('thing');
});
it('removes the UID and updates caches when present', async () => {
const { plugin, frontmatter, uidCache, uidPathMap } = makePluginWithApp({
frontmatter: { uid: 'gone' },
});
uidCache.add('gone');
uidPathMap.set('note.md', 'gone');
expect(await removeUID(plugin, makeFile('note.md'))).toBe(true);
expect(frontmatter.uid).toBeUndefined();
expect(uidCache.has('gone')).toBe(false);
expect(uidPathMap.has('note.md')).toBe(false);
});
it('returns false and logs when processFrontMatter rejects', async () => {
const { plugin } = makePluginWithApp({
processFrontMatterImpl: async () => {
throw new Error('disk error');
},
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(await removeUID(plugin, makeFile('note.md'))).toBe(false);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('removes the value at the configured custom uidKey', async () => {
const { plugin, frontmatter } = makePluginWithApp({
uidKey: 'note-id',
frontmatter: { 'note-id': 'gone', uid: 'untouched' },
});
expect(await removeUID(plugin, makeFile('note.md'))).toBe(true);
expect(frontmatter['note-id']).toBeUndefined();
expect(frontmatter.uid).toBe('untouched');
});
it('removes a falsy numeric UID and cleans up the cache via its string form', async () => {
const { plugin, frontmatter, uidCache, uidPathMap } = makePluginWithApp({
frontmatter: { uid: 0 },
});
uidCache.add('0');
uidPathMap.set('note.md', '0');
expect(await removeUID(plugin, makeFile('note.md'))).toBe(true);
expect(frontmatter.uid).toBeUndefined();
expect(uidCache.has('0')).toBe(false);
expect(uidPathMap.has('note.md')).toBe(false);
});
});

178
tests/fakes/app.ts Normal file
View file

@ -0,0 +1,178 @@
import { vi } from 'vitest';
import { TFile, TFolder, MarkdownView } from 'obsidian';
import type UIDGenerator from '../../src/main';
import type { UIDGeneratorSettings } from '../../src/settings';
export interface FileSpec {
path: string;
extension?: string;
frontmatter?: Record<string, unknown>;
}
const DEFAULT_SETTINGS: UIDGeneratorSettings = {
uidKey: 'uid',
autoGenerateUid: false,
uidGenerator: 'uuid',
nanoidLength: 21,
nanoidAlphabet: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
nanoidSeparators: [],
autoGenerationScope: 'vault',
autoGenerationFolder: '',
autoGenerationExclusions: [],
folderToClear: '',
copyFormatString: '{title} - {uidKey}: {uid}',
copyFormatStringMissingUid: '{title} - No {uidKey}',
};
function buildTFile(spec: FileSpec, parent: TFolder | null): TFile {
const file = new TFile();
file.path = spec.path;
file.extension = spec.extension ?? 'md';
const slash = spec.path.lastIndexOf('/');
const name = slash >= 0 ? spec.path.slice(slash + 1) : spec.path;
const ext = '.' + file.extension;
file.basename = file.extension && name.endsWith(ext) ? name.slice(0, -ext.length) : name;
file.parent = parent;
return file;
}
function buildFolderTree(files: TFile[]): Map<string, TFolder> {
const folders = new Map<string, TFolder>();
const root = new TFolder();
root.path = '/';
root.name = '';
folders.set('/', root);
for (const file of files) {
const parts = file.path.split('/');
parts.pop();
let parentPath = '/';
let parent = root;
for (const part of parts) {
const path = parentPath === '/' ? part : `${parentPath}/${part}`;
let folder = folders.get(path);
if (!folder) {
folder = new TFolder();
folder.path = path;
folder.name = part;
folders.set(path, folder);
parent.children.push(folder);
}
parent = folder;
parentPath = path;
}
file.parent = parent;
parent.children.push(file);
}
return folders;
}
export interface FakeApp {
plugin: UIDGenerator;
settings: UIDGeneratorSettings;
files: TFile[];
folders: Map<string, TFolder>;
frontmatterByPath: Map<string, Record<string, unknown>>;
processFrontMatter: ReturnType<typeof vi.fn>;
clipboardWrites: string[];
activeFile: TFile | null;
setActiveFile(file: TFile | null): void;
}
export function makeFakeApp(opts: {
files: FileSpec[];
settings?: Partial<UIDGeneratorSettings>;
uidCache?: Set<string>;
uidPathMap?: Map<string, string>;
}): FakeApp {
const settings: UIDGeneratorSettings = { ...DEFAULT_SETTINGS, ...opts.settings };
const uidCache = opts.uidCache ?? new Set<string>();
const uidPathMap = opts.uidPathMap ?? new Map<string, string>();
const fmByPath = new Map<string, Record<string, unknown>>();
for (const spec of opts.files) {
fmByPath.set(spec.path, { ...(spec.frontmatter ?? {}) });
}
const tFiles = opts.files.map((spec) => buildTFile(spec, null));
const folders = buildFolderTree(tFiles);
let activeFile: TFile | null = null;
const activeView: MarkdownView = new MarkdownView();
const processFrontMatter = vi.fn(
async (file: TFile, fn: (frontmatter: Record<string, unknown>) => void) => {
let fm = fmByPath.get(file.path);
if (!fm) {
fm = {};
fmByPath.set(file.path, fm);
}
fn(fm);
},
);
const clipboardWrites: string[] = [];
const plugin = {
settings,
uidCache,
uidPathMap,
app: {
workspace: {
getActiveViewOfType: (_view: unknown) => (activeFile ? activeView : null),
activeLeaf: null,
getLeavesOfType: (_type: string) => [],
},
vault: {
getMarkdownFiles: () => tFiles.filter((f) => f.extension === 'md'),
getAbstractFileByPath: (path: string) => {
if (folders.has(path)) return folders.get(path) ?? null;
return tFiles.find((f) => f.path === path) ?? null;
},
},
metadataCache: {
getFileCache: (file: TFile) => ({ frontmatter: fmByPath.get(file.path) }),
},
fileManager: {
processFrontMatter,
},
},
} as unknown as UIDGenerator;
return {
plugin,
settings,
files: tFiles,
folders,
frontmatterByPath: fmByPath,
processFrontMatter,
clipboardWrites,
get activeFile() {
return activeFile;
},
setActiveFile(file: TFile | null) {
activeFile = file;
activeView.file = file;
},
};
}
export function installFakeClipboard(): { writes: string[]; restore: () => void } {
const writes: string[] = [];
const fakeNavigator = {
clipboard: {
writeText: vi.fn(async (text: string) => {
writes.push(text);
}),
},
};
// vi.stubGlobal uses Object.defineProperty so it works even when the
// runtime exposes `navigator` as a read-only getter.
vi.stubGlobal('navigator', fakeNavigator);
return {
writes,
restore: () => {
vi.unstubAllGlobals();
},
};
}

45
tests/obsidian-stub.ts Normal file
View file

@ -0,0 +1,45 @@
// Test-only stub for the `obsidian` package, which ships types but no runtime entry.
// Vitest aliases imports of `obsidian` to this file so test code can load modules
// that import from `obsidian` without pulling in the real Electron-bound API.
//
// Only the runtime behavior the production code under test relies on is implemented.
// Anything else (full vault traversal, real plugin lifecycle, etc.) is intentionally
// out of scope — tests build their own fakes against this surface.
export class TAbstractFile {
path = '';
}
export class TFile extends TAbstractFile {
extension = '';
basename = '';
parent: TFolder | null = null;
}
export class TFolder extends TAbstractFile {
name = '';
children: TAbstractFile[] = [];
}
export class MarkdownView {
file: TFile | null = null;
}
export class WorkspaceLeaf {
view: unknown = null;
}
export class Notice {
constructor(_message: string, _timeout?: number) {}
setMessage(_message: string): this {
return this;
}
hide(): void {}
}
export function normalizePath(input: string): string {
if (!input) return '';
let p = input.replace(/\\/g, '/').replace(/\/+/g, '/').trim();
while (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
return p;
}

View file

@ -23,8 +23,14 @@
"./src/typings" // Your custom typings folder
],
"include": [
"**/*.ts","**/*.d.ts", "src/typings/obsidian.d.ts"
"**/*.ts","**/*.d.ts", "src/typings/obsidian.d.ts"
],
"exclude": [
"node_modules",
"tests",
"src/**/*.test.ts",
"vitest.config.ts"
]
}

14
vitest.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import * as path from 'path';
export default defineConfig({
resolve: {
alias: {
obsidian: path.resolve(__dirname, 'tests/obsidian-stub.ts'),
},
},
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});