vicky469_aside/tests/pluginLifecycleController.test.ts
vicky469 e94254ee2d feat(publish): add local Pages publishing
Add experimental Cloudflare Pages publishing through local Wrangler, pane-header publish actions, public HTML preview support, artifact safety checks, and state cleanup for rename/delete/unpublish flows.
2026-07-09 15:51:02 +08:00

412 lines
17 KiB
TypeScript

import * as assert from "node:assert/strict";
import test from "node:test";
import type { TAbstractFile, TFile } from "obsidian";
import { CommentManager, type Comment } from "../src/commentManager";
import { PluginLifecycleController } from "../src/app/pluginLifecycleController";
import { AggregateCommentIndex } from "../src/index/AggregateCommentIndex";
function createFile(path: string): TFile {
return {
path,
basename: path.split("/").pop()?.replace(/\.[^.]+$/, "") ?? path,
extension: path.split(".").pop() ?? "",
} as TFile;
}
function createFolder(path: string, children: TAbstractFile[] = []): TAbstractFile {
return {
path,
name: path.split("/").pop() ?? path,
children,
} as unknown as TAbstractFile;
}
function createComment(overrides: Partial<Comment> = {}): Comment {
return {
id: overrides.id ?? "comment-1",
filePath: overrides.filePath ?? "docs/file.md",
startLine: overrides.startLine ?? 0,
startChar: overrides.startChar ?? 0,
endLine: overrides.endLine ?? 0,
endChar: overrides.endChar ?? 4,
selectedText: overrides.selectedText ?? "text",
selectedTextHash: overrides.selectedTextHash ?? "hash:text",
comment: overrides.comment ?? "body",
timestamp: overrides.timestamp ?? 1,
anchorKind: overrides.anchorKind ?? "selection",
orphaned: overrides.orphaned ?? false,
};
}
function createHarness(options: {
initialComments?: Comment[];
refreshThrows?: boolean;
publishedArtifactPaths?: string[];
} = {}) {
const commentManager = new CommentManager(options.initialComments ?? []);
const aggregateCommentIndex = new AggregateCommentIndex();
const commentsByFile = new Map<string, Comment[]>();
for (const comment of options.initialComments ?? []) {
const commentsForFile = commentsByFile.get(comment.filePath) ?? [];
commentsForFile.push(comment);
commentsByFile.set(comment.filePath, commentsForFile);
}
for (const [filePath, comments] of commentsByFile.entries()) {
aggregateCommentIndex.updateFile(filePath, comments);
}
const clearedParsedPaths: string[] = [];
const clearedDerivedPaths: string[] = [];
const loadedFiles: string[] = [];
const warnings: Array<{ message: string; error: unknown }> = [];
const scheduledTimers = new Map<number, () => void>();
const clearedTimers: number[] = [];
let nextTimerId = 1;
let refreshCommentViewsCount = 0;
let refreshEditorDecorationsCount = 0;
let scheduleAggregateNoteRefreshCount = 0;
let refreshAggregateNoteNowCount = 0;
let syncIndexNoteViewClassesCount = 0;
let modifyHandledPath: string | null = null;
let detachSidebarViewsCount = 0;
const renamedStoredComments: Array<{ previousFilePath: string; nextFilePath: string }> = [];
const deletedStoredComments: string[] = [];
const deletedStoredCommentFolders: string[] = [];
const renamedPublishedArtifactPaths: Array<{ previousFilePath: string; nextFilePath: string }> = [];
const deletedPublishedArtifactPaths: string[] = [];
const deletedPublishedArtifactFolders: string[] = [];
const controller = new PluginLifecycleController({
app: {} as never,
getCommentManager: () => commentManager,
getAggregateCommentIndex: () => aggregateCommentIndex,
renameStoredComments: async (previousFilePath, nextFilePath) => {
renamedStoredComments.push({ previousFilePath, nextFilePath });
},
deleteStoredComments: async (filePath) => {
deletedStoredComments.push(filePath);
},
deleteStoredCommentsInFolder: async (folderPath) => {
deletedStoredCommentFolders.push(folderPath);
},
renamePublishedPublicArtifactPath: async (previousFilePath, nextFilePath) => {
renamedPublishedArtifactPaths.push({ previousFilePath, nextFilePath });
},
deletePublishedPublicArtifactPath: async (filePath) => {
deletedPublishedArtifactPaths.push(filePath);
},
deletePublishedPublicArtifactPathsInFolder: async (folderPath) => {
deletedPublishedArtifactFolders.push(folderPath);
},
clearParsedNoteCache: (filePath) => {
clearedParsedPaths.push(filePath);
},
clearDerivedCommentLinksForFile: (filePath) => {
clearedDerivedPaths.push(filePath);
},
isCommentableFile: (file): file is TFile => !!file && (file as { extension?: unknown }).extension === "md",
isPageNoteCapableFile: (file): file is TFile =>
!!file && ["md", "pdf"].includes(String((file as { extension?: unknown }).extension)),
loadCommentsForFile: async (file) => {
if (file) {
loadedFiles.push(file.path);
}
},
refreshCommentViews: async () => {
refreshCommentViewsCount += 1;
},
refreshEditorDecorations: () => {
if (options.refreshThrows) {
throw new Error("boom");
}
refreshEditorDecorationsCount += 1;
},
refreshAggregateNoteNow: async () => {
refreshAggregateNoteNowCount += 1;
},
scheduleAggregateNoteRefresh: () => {
scheduleAggregateNoteRefreshCount += 1;
},
syncIndexNoteViewClasses: () => {
syncIndexNoteViewClassesCount += 1;
},
handleMarkdownFileModified: async (file) => {
modifyHandledPath = file.path;
},
detachSidebarViews: () => {
detachSidebarViewsCount += 1;
},
scheduleTimer: (callback, _ms) => {
const timerId = nextTimerId;
nextTimerId += 1;
scheduledTimers.set(timerId, callback);
return timerId;
},
clearTimer: (timerId) => {
clearedTimers.push(timerId);
scheduledTimers.delete(timerId);
},
warn: (message, error) => {
warnings.push({ message, error });
},
});
return {
controller,
commentManager,
aggregateCommentIndex,
clearedParsedPaths,
clearedDerivedPaths,
loadedFiles,
warnings,
getScheduledTimerIds: () => Array.from(scheduledTimers.keys()),
runTimer: (timerId: number) => scheduledTimers.get(timerId)?.(),
clearedTimers,
getRefreshCommentViewsCount: () => refreshCommentViewsCount,
getRefreshEditorDecorationsCount: () => refreshEditorDecorationsCount,
getRefreshAggregateNoteNowCount: () => refreshAggregateNoteNowCount,
getScheduleAggregateNoteRefreshCount: () => scheduleAggregateNoteRefreshCount,
getSyncIndexNoteViewClassesCount: () => syncIndexNoteViewClassesCount,
getDetachSidebarViewsCount: () => detachSidebarViewsCount,
getModifyHandledPath: () => modifyHandledPath,
renamedStoredComments,
deletedStoredComments,
deletedStoredCommentFolders,
renamedPublishedArtifactPaths,
deletedPublishedArtifactPaths,
deletedPublishedArtifactFolders,
};
}
test("plugin lifecycle controller handles layout ready without eager comment hydration", async () => {
const harness = createHarness();
await harness.controller.handleLayoutReady();
assert.equal(harness.getRefreshCommentViewsCount(), 0);
assert.equal(harness.getRefreshEditorDecorationsCount(), 0);
assert.equal(harness.getScheduleAggregateNoteRefreshCount(), 0);
assert.equal(harness.getSyncIndexNoteViewClassesCount(), 1);
});
test("plugin lifecycle controller keeps renamed comment files and indexes aligned", async () => {
const originalFile = createFile("docs/file.md");
const renamedFile = createFile("docs/renamed.md");
const harness = createHarness({
initialComments: [createComment({ filePath: originalFile.path })],
});
await harness.controller.handleFileRename(renamedFile, originalFile.path);
assert.equal(harness.commentManager.getCommentById("comment-1")?.filePath, renamedFile.path);
assert.equal(harness.aggregateCommentIndex.getCommentById("comment-1")?.filePath, renamedFile.path);
assert.deepEqual(harness.renamedStoredComments, [{
previousFilePath: originalFile.path,
nextFilePath: renamedFile.path,
}]);
assert.deepEqual(harness.clearedParsedPaths, [originalFile.path, renamedFile.path]);
assert.deepEqual(harness.clearedDerivedPaths, [originalFile.path]);
assert.deepEqual(harness.loadedFiles, [renamedFile.path]);
assert.equal(harness.getRefreshCommentViewsCount(), 1);
assert.equal(harness.getRefreshEditorDecorationsCount(), 1);
assert.equal(harness.getScheduleAggregateNoteRefreshCount(), 1);
});
test("plugin lifecycle controller keeps renamed PDF page-note files and indexes aligned", async () => {
const originalFile = createFile("docs/file.pdf");
const renamedFile = createFile("docs/renamed.pdf");
const harness = createHarness({
initialComments: [
createComment({
filePath: originalFile.path,
anchorKind: "page",
selectedText: "file",
selectedTextHash: "hash:file",
}),
],
});
await harness.controller.handleFileRename(renamedFile, originalFile.path);
assert.equal(harness.commentManager.getCommentById("comment-1")?.filePath, renamedFile.path);
assert.equal(harness.aggregateCommentIndex.getCommentById("comment-1")?.filePath, renamedFile.path);
assert.deepEqual(harness.renamedStoredComments, [{
previousFilePath: originalFile.path,
nextFilePath: renamedFile.path,
}]);
assert.deepEqual(harness.clearedParsedPaths, [originalFile.path, renamedFile.path]);
assert.deepEqual(harness.clearedDerivedPaths, [originalFile.path]);
assert.deepEqual(harness.loadedFiles, [renamedFile.path]);
assert.equal(harness.getRefreshCommentViewsCount(), 1);
assert.equal(harness.getRefreshEditorDecorationsCount(), 1);
assert.equal(harness.getScheduleAggregateNoteRefreshCount(), 1);
});
test("plugin lifecycle controller retargets published standalone artifacts on file rename", async () => {
const originalFile = createFile("public/page.html");
const renamedFile = createFile("public/renamed.html");
const harness = createHarness();
await harness.controller.handleFileRename(renamedFile, originalFile.path);
assert.deepEqual(harness.renamedPublishedArtifactPaths, [{
previousFilePath: originalFile.path,
nextFilePath: renamedFile.path,
}]);
});
test("plugin lifecycle controller clears deleted comment files only when commentable", async () => {
const deletedFile = createFile("docs/file.md");
const harness = createHarness({
initialComments: [createComment({ filePath: deletedFile.path })],
});
await harness.controller.handleFileDelete(createFile("docs/ignored.png"));
await harness.controller.handleFileDelete(deletedFile);
assert.deepEqual(harness.commentManager.getCommentsForFile(deletedFile.path), []);
assert.deepEqual(
harness.aggregateCommentIndex.getAllComments().filter((comment) => comment.filePath === deletedFile.path),
[],
);
assert.deepEqual(harness.deletedStoredComments, [deletedFile.path]);
assert.deepEqual(harness.clearedParsedPaths, [deletedFile.path]);
assert.deepEqual(harness.clearedDerivedPaths, [deletedFile.path]);
assert.equal(harness.getRefreshCommentViewsCount(), 1);
assert.equal(harness.getRefreshEditorDecorationsCount(), 1);
assert.equal(harness.getRefreshAggregateNoteNowCount(), 1);
assert.equal(harness.getScheduleAggregateNoteRefreshCount(), 0);
});
test("plugin lifecycle controller clears deleted PDF page-note files", async () => {
const deletedFile = createFile("docs/file.pdf");
const harness = createHarness({
initialComments: [
createComment({
filePath: deletedFile.path,
anchorKind: "page",
selectedText: "file",
selectedTextHash: "hash:file",
}),
],
});
await harness.controller.handleFileDelete(createFile("docs/ignored.png"));
await harness.controller.handleFileDelete(deletedFile);
assert.deepEqual(harness.commentManager.getCommentsForFile(deletedFile.path), []);
assert.deepEqual(
harness.aggregateCommentIndex.getAllComments().filter((comment) => comment.filePath === deletedFile.path),
[],
);
assert.deepEqual(harness.deletedStoredComments, [deletedFile.path]);
assert.deepEqual(harness.clearedParsedPaths, [deletedFile.path]);
assert.deepEqual(harness.clearedDerivedPaths, [deletedFile.path]);
assert.equal(harness.getRefreshCommentViewsCount(), 1);
assert.equal(harness.getRefreshEditorDecorationsCount(), 1);
assert.equal(harness.getRefreshAggregateNoteNowCount(), 1);
assert.equal(harness.getScheduleAggregateNoteRefreshCount(), 0);
});
test("plugin lifecycle controller prunes published standalone artifacts on file delete", async () => {
const deletedFile = createFile("public/page.html");
const harness = createHarness();
await harness.controller.handleFileDelete(deletedFile);
assert.deepEqual(harness.deletedPublishedArtifactPaths, [deletedFile.path]);
});
test("plugin lifecycle controller clears cached comments under deleted folders", async () => {
const harness = createHarness({
initialComments: [
createComment({ filePath: "Deleted/a.md", id: "deleted-a" }),
createComment({ filePath: "Deleted/nested/b.md", id: "deleted-b" }),
createComment({
filePath: "Deleted/c.pdf",
id: "deleted-c",
anchorKind: "page",
selectedText: "c",
selectedTextHash: "hash:c",
}),
createComment({ filePath: "Deletedness/c.md", id: "keep-c" }),
],
});
await harness.controller.handleFileDelete(createFolder("Deleted", [
createFile("Deleted/a.md"),
createFolder("Deleted/nested", [
createFile("Deleted/nested/b.md"),
]),
createFile("Deleted/c.pdf"),
createFile("Deleted/ignored.png"),
]));
assert.deepEqual(
harness.commentManager.getAllComments().map((comment) => comment.id).sort(),
["keep-c"],
);
assert.deepEqual(
harness.aggregateCommentIndex.getAllComments().map((comment) => comment.id).sort(),
["keep-c"],
);
assert.deepEqual(harness.deletedStoredComments, []);
assert.deepEqual(harness.deletedStoredCommentFolders, ["Deleted"]);
assert.deepEqual(harness.clearedParsedPaths.sort(), ["Deleted/a.md", "Deleted/c.pdf", "Deleted/nested/b.md"]);
assert.deepEqual(harness.clearedDerivedPaths.sort(), ["Deleted/a.md", "Deleted/c.pdf", "Deleted/nested/b.md"]);
assert.equal(harness.getRefreshCommentViewsCount(), 1);
assert.equal(harness.getRefreshEditorDecorationsCount(), 1);
assert.equal(harness.getRefreshAggregateNoteNowCount(), 1);
});
test("plugin lifecycle controller prunes published standalone artifacts under deleted folders", async () => {
const harness = createHarness();
await harness.controller.handleFileDelete(createFolder("public", [
createFile("public/page.html"),
]));
assert.deepEqual(harness.deletedPublishedArtifactFolders, ["public"]);
});
test("plugin lifecycle controller only forwards markdown modify events", async () => {
const harness = createHarness();
await harness.controller.handleFileModify(createFile("docs/file.pdf"));
assert.equal(harness.getModifyHandledPath(), null);
await harness.controller.handleFileModify(createFile("docs/file.md"));
assert.equal(harness.getModifyHandledPath(), "docs/file.md");
});
test("plugin lifecycle controller debounces editor refreshes and reports refresh errors", () => {
const harness = createHarness({ refreshThrows: true });
harness.controller.handleEditorChange("docs/file.md");
const firstTimerId = harness.getScheduledTimerIds()[0];
harness.controller.handleEditorChange("docs/file.md");
const secondTimerId = harness.getScheduledTimerIds()[0];
assert.notEqual(firstTimerId, secondTimerId);
assert.deepEqual(harness.clearedTimers, [firstTimerId]);
harness.runTimer(secondTimerId);
assert.equal(harness.getRefreshEditorDecorationsCount(), 0);
assert.equal(harness.warnings.length, 1);
assert.equal(harness.warnings[0].message, "Failed to refresh decorations on editor-change");
});
test("plugin lifecycle controller unload clears timers and detaches stale sidebar views", () => {
const harness = createHarness();
harness.controller.handleEditorChange("docs/file.md");
const timerId = harness.getScheduledTimerIds()[0];
harness.controller.handleUnload();
assert.deepEqual(harness.clearedTimers, [timerId]);
assert.deepEqual(harness.getScheduledTimerIds(), []);
assert.equal(harness.getDetachSidebarViewsCount(), 1);
});