From d28282edad25af066b99373aaa2e0e3008005542 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 19 Jul 2026 07:04:54 +0000 Subject: [PATCH] Group Obsidian startup notices by operation --- .../HiddenFileSync/CmdHiddenFileSync.ts | 53 ++-- .../CmdHiddenFileSync.unit.spec.ts | 105 +++++++ src/modules/essential/ModuleMigration.ts | 275 ++++++++++-------- .../essential/ModuleMigration.unit.spec.ts | 79 +++++ src/modules/services/ObsidianNoticeGroups.ts | 51 ++++ .../ObsidianNoticeGroups.unit.spec.ts | 32 ++ .../services/ObsidianServiceContext.ts | 5 +- .../ObsidianServiceContext.unit.spec.ts | 4 +- src/modules/services/ObsidianServiceHub.ts | 5 +- .../scripts/hidden-file-snippet-sync.ts | 168 +++++++++-- 10 files changed, 613 insertions(+), 164 deletions(-) create mode 100644 src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts create mode 100644 src/modules/essential/ModuleMigration.unit.spec.ts create mode 100644 src/modules/services/ObsidianNoticeGroups.ts create mode 100644 src/modules/services/ObsidianNoticeGroups.unit.spec.ts diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.ts index 1d7b9b78..80b72414 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.ts @@ -57,6 +57,9 @@ import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.d import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts"; type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce"; +const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes"; +const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000; + declare global { interface OPTIONAL_SYNC_FEATURES { FETCH: "FETCH"; @@ -1227,6 +1230,8 @@ Offline Changed files: ${files.length}`; notifyConfigChange() { const updatedFolders = [...this.queuedNotificationFiles]; this.queuedNotificationFiles.clear(); + const noticeGroups = this.services.context.noticeGroups; + let hasNoticeItems = false; try { //@ts-ignore const manifests = Object.values(this.app.plugins.manifests) as unknown as PluginManifest[]; @@ -1238,31 +1243,33 @@ Offline Changed files: ${files.length}`; // If notified about plug-ins, reloading Obsidian may not be necessary. const updatePluginId = manifest.id; const updatePluginName = manifest.name; - this.core.confirm.askInPopup( - `updated-${updatePluginId}`, - `Files in ${updatePluginName} has been updated!\nPress {HERE} to reload ${updatePluginName}, or press elsewhere to dismiss this message.`, - (anchor) => { - anchor.text = "HERE"; - anchor.addEventListener("click", () => { + const itemKey = `plugin:${updatePluginId}`; + noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, itemKey, { + message: `Files in ${updatePluginName} were updated.`, + action: { + label: `Reload ${updatePluginName}`, + onSelect: () => { fireAndForget(async () => { this._log( `Unloading plugin: ${updatePluginName}`, LOG_LEVEL_NOTICE, "plugin-reload-" + updatePluginId ); - // @ts-ignore + // @ts-ignore -- Obsidian does not expose the plug-in lifecycle methods in its public API. await this.app.plugins.unloadPlugin(updatePluginId); - // @ts-ignore + // @ts-ignore -- Obsidian does not expose the plug-in lifecycle methods in its public API. await this.app.plugins.loadPlugin(updatePluginId); this._log( `Plugin reloaded: ${updatePluginName}`, LOG_LEVEL_NOTICE, "plugin-reload-" + updatePluginId ); + noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, itemKey); }); - }); - } - ); + }, + }, + }); + hasNoticeItems = true; } } catch (ex) { this._log("Error on checking plugin status."); @@ -1272,18 +1279,24 @@ Offline Changed files: ${files.length}`; // If something changes left, notify for reloading Obsidian. if (updatedFolders.indexOf(this.services.API.getSystemConfigDir()) >= 0) { if (!this.services.appLifecycle.isReloadingScheduled()) { - this.core.confirm.askInPopup( - `updated-any-hidden`, - `Some setting files have been modified\nPress {HERE} to schedule a reload of Obsidian, or press elsewhere to dismiss this message.`, - (anchor) => { - anchor.text = "HERE"; - anchor.addEventListener("click", () => { + noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, "restart", { + message: "Other Obsidian settings files were updated.", + action: { + label: "Schedule an Obsidian restart", + onSelect: () => { this.services.appLifecycle.scheduleRestart(); - }); - } - ); + noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, "restart"); + }, + }, + }); + hasNoticeItems = true; + } else { + noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, "restart"); } } + if (hasNoticeItems) { + noticeGroups.finish(HIDDEN_FILE_NOTICE_GROUP, { durationMs: HIDDEN_FILE_NOTICE_DURATION_MS }); + } } queueNotification(key: FilePath) { diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts new file mode 100644 index 00000000..53c4a3aa --- /dev/null +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/deps.ts", () => ({})); +vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ + JsonResolveModal: class JsonResolveModal {}, +})); +vi.mock("@/features/LiveSyncCommands.ts", () => ({ + LiveSyncCommands: class LiveSyncCommands { + plugin!: { app: unknown }; + core!: { services: unknown }; + get app() { + return this.plugin.app; + } + get services() { + return this.core.services; + } + }, +})); +vi.mock("./configureHiddenFileSyncMode.ts", () => ({ + configureHiddenFileSyncMode: vi.fn(), +})); + +import { HiddenFileSync } from "./CmdHiddenFileSync.ts"; + +describe("HiddenFileSync configuration-change notices", () => { + it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => { + const noticeGroups = { + setItem: vi.fn(), + finish: vi.fn(() => true), + removeItem: vi.fn(() => true), + }; + const plugin = { + app: { + plugins: { + manifests: { + alpha: { + id: "alpha", + name: "Alpha", + dir: ".obsidian/plugins/alpha", + }, + beta: { + id: "beta", + name: "Beta", + dir: ".obsidian/plugins/beta", + }, + }, + enabledPlugins: new Set(["alpha", "beta"]), + unloadPlugin: vi.fn(async () => undefined), + loadPlugin: vi.fn(async () => undefined), + }, + }, + }; + const core = { + confirm: { askInPopup: vi.fn() }, + services: { + context: { noticeGroups }, + API: { getSystemConfigDir: vi.fn(() => ".obsidian") }, + appLifecycle: { + isReloadingScheduled: vi.fn(() => false), + scheduleRestart: vi.fn(), + }, + }, + }; + const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; + Object.assign(hiddenFileSync, { + plugin, + core, + queuedNotificationFiles: new Set([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]), + _log: vi.fn(), + }); + + hiddenFileSync.notifyConfigChange(); + + expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "hidden-file-changes", "plugin:alpha", { + message: "Files in Alpha were updated.", + action: expect.objectContaining({ label: "Reload Alpha" }), + }); + expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "hidden-file-changes", "plugin:beta", { + message: "Files in Beta were updated.", + action: expect.objectContaining({ label: "Reload Beta" }), + }); + expect(noticeGroups.setItem).toHaveBeenNthCalledWith(3, "hidden-file-changes", "restart", { + message: "Other Obsidian settings files were updated.", + action: expect.objectContaining({ label: "Schedule an Obsidian restart" }), + }); + expect(noticeGroups.setItem.mock.calls.every(([groupKey]) => groupKey === "hidden-file-changes")).toBe(true); + expect(noticeGroups.finish).toHaveBeenCalledWith("hidden-file-changes", { durationMs: 20_000 }); + expect(core.confirm.askInPopup).not.toHaveBeenCalled(); + + const reloadAction = (noticeGroups.setItem.mock.calls[0]?.[2] as { action: { onSelect: () => void } }).action + .onSelect; + reloadAction(); + await vi.waitFor(() => { + expect(plugin.app.plugins.unloadPlugin).toHaveBeenCalledWith("alpha"); + expect(plugin.app.plugins.loadPlugin).toHaveBeenCalledWith("alpha"); + expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "plugin:alpha"); + }); + + const restartAction = (noticeGroups.setItem.mock.calls[2]?.[2] as { action: { onSelect: () => void } }).action + .onSelect; + restartAction(); + expect(core.services.appLifecycle.scheduleRestart).toHaveBeenCalledOnce(); + expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart"); + }); +}); diff --git a/src/modules/essential/ModuleMigration.ts b/src/modules/essential/ModuleMigration.ts index 1a9a561e..2517cbb1 100644 --- a/src/modules/essential/ModuleMigration.ts +++ b/src/modules/essential/ModuleMigration.ts @@ -1,4 +1,9 @@ -import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; +import { + LOG_LEVEL_INFO, + LOG_LEVEL_NOTICE, + LOG_LEVEL_VERBOSE, + Logger, +} from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { EVENT_REQUEST_OPEN_P2P, EVENT_REQUEST_OPEN_SETTING_WIZARD, @@ -12,7 +17,12 @@ import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n"; import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc"; import { isValidPath } from "@/common/utils.ts"; import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types"; -import { isDeletedEntry, isDocContentSame, isLoadedEntry, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { + isDeletedEntry, + isDocContentSame, + isLoadedEntry, + readAsBlob, +} from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation"; import type { LiveSyncCore } from "@/main.ts"; import { SetupManager } from "@/modules/features/SetupManager.ts"; @@ -26,7 +36,9 @@ type ErrorInfo = { isConflicted?: boolean; }; -export class ModuleMigration extends AbstractModule { +const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check"; + +export class ModuleMigration extends AbstractModule { async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) { const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation( { @@ -127,133 +139,152 @@ export class ModuleMigration extends AbstractModule { return Promise.resolve(true); } - this._log("Checking for incomplete documents...", LOG_LEVEL_NOTICE, "check-incomplete"); - - const errorFiles = [] as ErrorInfo[]; - for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) { - const path = this.getPath(metaDoc); - - if (!isValidPath(path)) { - continue; - } - if (!(await this.services.vault.isTargetFile(path))) { - continue; - } - if (!isMetaEntry(metaDoc)) { - continue; - } - - const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc); - if (!doc || !isLoadedEntry(doc)) { - continue; - } - if (isDeletedEntry(doc)) { - continue; - } - const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0; - - let storageFileContent; - try { - storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path); - } catch (e) { - Logger(`Failed to read file ${path}: Possibly unprocessed or missing`); - Logger(e, LOG_LEVEL_VERBOSE); - continue; - } - // const storageFileBlob = createBlob(storageFileContent); - const sizeOnStorage = storageFileContent.byteLength; - const recordedSize = doc.size; - const docBlob = readAsBlob(doc); - const actualSize = docBlob.size; - if ( - recordedSize !== actualSize || - sizeOnStorage !== actualSize || - sizeOnStorage !== recordedSize || - isConflicted - ) { - const contentMatched = await isDocContentSame(doc.data, storageFileContent); - errorFiles.push({ - path, - recordedSize, - actualSize, - storageSize: sizeOnStorage, - contentMatched, - isConflicted, - }); - Logger( - `Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}` - ); - } - } - if (errorFiles.length == 0) { - Logger("No size mismatches found", LOG_LEVEL_NOTICE); - await this.core.kvDB.set("checkIncompleteDocs", true); - return Promise.resolve(true); - } - Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_NOTICE); - // We have to repair them following rules and situations: - // A. DB Recorded != DB Stored - // A.1. DB Recorded == Storage Stored - // Possibly recoverable from storage. Just overwrite the DB content with storage content. - // A.2. Neither - // Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted. - // We do not fix it automatically. Leave it as is. Possibly other device can do this. - // B. DB Recorded == DB Stored , < Storage Stored - // Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`). - // We do not fix it automatically, but it will be automatically overwritten in other process. - // C. DB Recorded == DB Stored , > Storage Stored - // Probably restored by the user by resolving A or B on other device, We should overwrite the storage - // Also do not fix it automatically. It should be overwritten by replication. - const recoverable = errorFiles.filter((e) => { - return e.recordedSize === e.storageSize && !e.isConflicted; + const noticeGroups = this.core.services.context.noticeGroups; + noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", { + message: "Checking for incomplete documents...", }); - const unrecoverable = errorFiles.filter((e) => { - return e.recordedSize !== e.storageSize || e.isConflicted; - }); - const fileInfo = (e: (typeof errorFiles)[0]) => { - return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`; - }; - const messageUnrecoverable = - unrecoverable.length > 0 - ? $msg("moduleMigration.fix0256.messageUnrecoverable", { - filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"), - }) - : ""; + this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE); - const message = $msg("moduleMigration.fix0256.message", { - files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"), - messageUnrecoverable, - }); - const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater"); - const FIX = $msg("moduleMigration.fix0256.buttons.fix"); - const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever"); - const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], { - title: $msg("moduleMigration.fix0256.title"), - defaultAction: CHECK_IT_LATER, - }); - if (ret == FIX) { - for (const file of recoverable) { - // Overwrite the database with the files on the storage - const stubFile = await this.core.storageAccess.getFileStub(file.path); - if (stubFile == null) { - Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE); + try { + const errorFiles = [] as ErrorInfo[]; + for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) { + const path = this.getPath(metaDoc); + + if (!isValidPath(path)) { + continue; + } + if (!(await this.services.vault.isTargetFile(path))) { + continue; + } + if (!isMetaEntry(metaDoc)) { continue; } - stubFile.stat.mtime = Date.now(); - const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false); - if (result) { - Logger(`Successfully restored ${file.path} from storage`); - } else { - Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE); + const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc); + if (!doc || !isLoadedEntry(doc)) { + continue; + } + if (isDeletedEntry(doc)) { + continue; + } + const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0; + + let storageFileContent; + try { + storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path); + } catch (e) { + Logger(`Failed to read file ${path}: Possibly unprocessed or missing`); + Logger(e, LOG_LEVEL_VERBOSE); + continue; + } + // const storageFileBlob = createBlob(storageFileContent); + const sizeOnStorage = storageFileContent.byteLength; + const recordedSize = doc.size; + const docBlob = readAsBlob(doc); + const actualSize = docBlob.size; + if ( + recordedSize !== actualSize || + sizeOnStorage !== actualSize || + sizeOnStorage !== recordedSize || + isConflicted + ) { + const contentMatched = await isDocContentSame(doc.data, storageFileContent); + errorFiles.push({ + path, + recordedSize, + actualSize, + storageSize: sizeOnStorage, + contentMatched, + isConflicted, + }); + Logger( + `Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}` + ); } } - } else if (ret === DISMISS) { - // User chose to dismiss the issue - await this.core.kvDB.set("checkIncompleteDocs", true); - } + if (errorFiles.length == 0) { + Logger("No size mismatches found", LOG_LEVEL_INFO); + noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", { + message: "No size mismatches found", + }); + await this.core.kvDB.set("checkIncompleteDocs", true); + return Promise.resolve(true); + } + Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO); + noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", { + message: `Found ${errorFiles.length} size mismatches`, + }); + // We have to repair them following rules and situations: + // A. DB Recorded != DB Stored + // A.1. DB Recorded == Storage Stored + // Possibly recoverable from storage. Just overwrite the DB content with storage content. + // A.2. Neither + // Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted. + // We do not fix it automatically. Leave it as is. Possibly other device can do this. + // B. DB Recorded == DB Stored , < Storage Stored + // Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`). + // We do not fix it automatically, but it will be automatically overwritten in other process. + // C. DB Recorded == DB Stored , > Storage Stored + // Probably restored by the user by resolving A or B on other device, We should overwrite the storage + // Also do not fix it automatically. It should be overwritten by replication. + const recoverable = errorFiles.filter((e) => { + return e.recordedSize === e.storageSize && !e.isConflicted; + }); + const unrecoverable = errorFiles.filter((e) => { + return e.recordedSize !== e.storageSize || e.isConflicted; + }); + const fileInfo = (e: (typeof errorFiles)[0]) => { + return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`; + }; + const messageUnrecoverable = + unrecoverable.length > 0 + ? $msg("moduleMigration.fix0256.messageUnrecoverable", { + filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"), + }) + : ""; - return Promise.resolve(true); + const message = $msg("moduleMigration.fix0256.message", { + files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"), + messageUnrecoverable, + }); + const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater"); + const FIX = $msg("moduleMigration.fix0256.buttons.fix"); + const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever"); + const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], { + title: $msg("moduleMigration.fix0256.title"), + defaultAction: CHECK_IT_LATER, + }); + if (ret == FIX) { + for (const file of recoverable) { + // Overwrite the database with the files on the storage + const stubFile = await this.core.storageAccess.getFileStub(file.path); + if (stubFile == null) { + Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE); + continue; + } + + stubFile.stat.mtime = Date.now(); + const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false); + if (result) { + Logger(`Successfully restored ${file.path} from storage`); + } else { + Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE); + } + } + } else if (ret === DISMISS) { + // User chose to dismiss the issue + await this.core.kvDB.set("checkIncompleteDocs", true); + } + + return Promise.resolve(true); + } catch (error) { + noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", { + message: "The incomplete document check could not be completed.", + }); + throw error; + } finally { + noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP); + } } async hasCompromisedChunks(): Promise { diff --git a/src/modules/essential/ModuleMigration.unit.spec.ts b/src/modules/essential/ModuleMigration.unit.spec.ts new file mode 100644 index 00000000..b51665c7 --- /dev/null +++ b/src/modules/essential/ModuleMigration.unit.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/modules/features/SetupManager.ts", () => ({ + SetupManager: class SetupManager {}, +})); +vi.mock("@/deps.ts", () => ({})); +vi.mock("@/common/utils.ts", () => ({ + isValidPath: () => true, +})); + +import { ModuleMigration } from "./ModuleMigration.ts"; + +async function* noDocuments() { + return; +} + +async function* failedDocumentScan() { + throw new Error("scan failed"); +} + +function createMigration(findAllNormalDocs: typeof noDocuments | typeof failedDocumentScan = noDocuments) { + const noticeGroups = { + setItem: vi.fn(), + finish: vi.fn(() => true), + }; + const services = { + API: { + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + }, + context: { noticeGroups }, + vault: { isTargetFile: vi.fn(async () => true) }, + path: { getPath: vi.fn() }, + }; + const core = { + _services: services, + services, + kvDB: { + get: vi.fn(async () => false), + set: vi.fn(async () => undefined), + }, + localDatabase: { findAllNormalDocs }, + storageAccess: {}, + }; + return { + migration: new ModuleMigration(core as never), + noticeGroups, + }; +} + +describe("ModuleMigration incomplete-document notice", () => { + it("keeps the check and its result in one persistent named group", async () => { + const { migration, noticeGroups } = createMigration(); + + await expect(migration.hasIncompleteDocs()).resolves.toBe(true); + + expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", { + message: "Checking for incomplete documents...", + }); + expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", { + message: "No size mismatches found", + }); + expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check"); + }); + + it("finishes the group with a failure result when the scan throws", async () => { + const { migration, noticeGroups } = createMigration(failedDocumentScan); + + await expect(migration.hasIncompleteDocs()).rejects.toThrow("scan failed"); + + expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", { + message: "The incomplete document check could not be completed.", + }); + expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check"); + }); +}); diff --git a/src/modules/services/ObsidianNoticeGroups.ts b/src/modules/services/ObsidianNoticeGroups.ts new file mode 100644 index 00000000..a59105ec --- /dev/null +++ b/src/modules/services/ObsidianNoticeGroups.ts @@ -0,0 +1,51 @@ +import { KeyedNoticeGroupManager } from "@vrtmrz/obsidian-plugin-kit/notice"; + +export interface ObsidianNoticeGroupItem { + message: string; + action?: { + label: string; + onSelect: () => void; + }; +} + +interface KeyedNoticeGroupDriver { + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): unknown; + finish(groupKey: string, options?: { durationMs?: number | false }): boolean; + removeItem(groupKey: string, itemKey: string): boolean; + hide(groupKey: string): boolean; + dispose(): void; +} + +/** Obsidian-owned interactive Notice capability exposed through the application Context. */ +export interface ObsidianNoticeGroups { + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): void; + finish(groupKey: string, options?: { durationMs?: number | false }): boolean; + removeItem(groupKey: string, itemKey: string): boolean; + hide(groupKey: string): boolean; + dispose(): void; +} + +/** Adapts Fancy Kit grouped Notices without exposing Obsidian Notice instances to features. */ +export class ObsidianNoticeGroupManager implements ObsidianNoticeGroups { + constructor(private readonly manager: KeyedNoticeGroupDriver = new KeyedNoticeGroupManager()) {} + + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): void { + this.manager.setItem(groupKey, itemKey, item); + } + + finish(groupKey: string, options?: { durationMs?: number | false }): boolean { + return this.manager.finish(groupKey, options); + } + + removeItem(groupKey: string, itemKey: string): boolean { + return this.manager.removeItem(groupKey, itemKey); + } + + hide(groupKey: string): boolean { + return this.manager.hide(groupKey); + } + + dispose(): void { + this.manager.dispose(); + } +} diff --git a/src/modules/services/ObsidianNoticeGroups.unit.spec.ts b/src/modules/services/ObsidianNoticeGroups.unit.spec.ts new file mode 100644 index 00000000..3af2893d --- /dev/null +++ b/src/modules/services/ObsidianNoticeGroups.unit.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@vrtmrz/obsidian-plugin-kit/notice", () => ({ + KeyedNoticeGroupManager: class KeyedNoticeGroupManager {}, +})); + +import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups"; + +describe("ObsidianNoticeGroupManager", () => { + it("keeps Fancy Kit rendering behind the Context-owned capability", () => { + const driver = { + setItem: vi.fn(), + finish: vi.fn(() => true), + removeItem: vi.fn(() => true), + hide: vi.fn(() => true), + dispose: vi.fn(), + }; + const groups = new ObsidianNoticeGroupManager(driver); + const item = { + message: "Complete", + action: { label: "Review", onSelect: vi.fn() }, + }; + + groups.setItem("integrity", "result", item); + expect(driver.setItem).toHaveBeenCalledWith("integrity", "result", item); + expect(groups.finish("integrity", { durationMs: 1_000 })).toBe(true); + expect(groups.removeItem("integrity", "result")).toBe(true); + expect(groups.hide("integrity")).toBe(true); + groups.dispose(); + expect(driver.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/modules/services/ObsidianServiceContext.ts b/src/modules/services/ObsidianServiceContext.ts index fe2d1dd1..694b336e 100644 --- a/src/modules/services/ObsidianServiceContext.ts +++ b/src/modules/services/ObsidianServiceContext.ts @@ -3,17 +3,20 @@ import type { App, Plugin } from "@/deps"; import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { eventHub } from "@/common/events"; import { translateLiveSyncMessage } from "@/common/translation"; +import type { ObsidianNoticeGroups } from "./ObsidianNoticeGroups"; /** Host capabilities owned by one Self-hosted LiveSync plug-in instance. */ export class ObsidianServiceContext extends ServiceContext { app: App; plugin: Plugin; liveSyncPlugin: ObsidianLiveSyncPlugin; + readonly noticeGroups: ObsidianNoticeGroups; - constructor(app: App, plugin: Plugin, liveSyncPlugin: ObsidianLiveSyncPlugin) { + constructor(app: App, plugin: Plugin, liveSyncPlugin: ObsidianLiveSyncPlugin, noticeGroups: ObsidianNoticeGroups) { super({ events: eventHub, translate: translateLiveSyncMessage }); this.app = app; this.plugin = plugin; this.liveSyncPlugin = liveSyncPlugin; + this.noticeGroups = noticeGroups; } } diff --git a/src/modules/services/ObsidianServiceContext.unit.spec.ts b/src/modules/services/ObsidianServiceContext.unit.spec.ts index ffefbec9..d95f9306 100644 --- a/src/modules/services/ObsidianServiceContext.unit.spec.ts +++ b/src/modules/services/ObsidianServiceContext.unit.spec.ts @@ -13,7 +13,8 @@ describe("ObsidianServiceContext contract", () => { const app = {} as Parameters[0]; const plugin = {} as Parameters[1]; const liveSyncPlugin = {} as Parameters[2]; - const context = new ObsidianServiceContext(app, plugin, liveSyncPlugin); + const noticeGroups = {} as Parameters[3]; + const context = new ObsidianServiceContext(app, plugin, liveSyncPlugin, noticeGroups); expect(observeServiceContext(context, TRANSLATION_KEY)).toEqual({ translation: translateLiveSyncMessage(TRANSLATION_KEY), @@ -23,5 +24,6 @@ describe("ObsidianServiceContext contract", () => { expect(context.app).toBe(app); expect(context.plugin).toBe(plugin); expect(context.liveSyncPlugin).toBe(liveSyncPlugin); + expect(context.noticeGroups).toBe(noticeGroups); }); }); diff --git a/src/modules/services/ObsidianServiceHub.ts b/src/modules/services/ObsidianServiceHub.ts index bca05290..0b6e2b2d 100644 --- a/src/modules/services/ObsidianServiceHub.ts +++ b/src/modules/services/ObsidianServiceHub.ts @@ -25,12 +25,14 @@ import { ObsidianUIService } from "./ObsidianUIService"; import { createScreenWakeLockManager } from "octagonal-wheels/browser/wakeLock"; import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; import { OpenKeyValueDatabase } from "@/common/KeyValueDB"; +import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups"; // InjectableServiceHub export class ObsidianServiceHub extends InjectableServiceHub { constructor(plugin: ObsidianLiveSyncPlugin) { - const context = new ObsidianServiceContext(plugin.app, plugin, plugin); + const noticeGroups = new ObsidianNoticeGroupManager(); + const context = new ObsidianServiceContext(plugin.app, plugin, plugin, noticeGroups); const API = new ObsidianAPIService(context); const conflict = new ObsidianConflictService(context); @@ -62,6 +64,7 @@ export class ObsidianServiceHub extends InjectableServiceHub { await screenWakeLock.dispose(); + noticeGroups.dispose(); return true; }); const database = new ObsidianDatabaseService(context, { diff --git a/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts index 2203f893..171d13b6 100644 --- a/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts +++ b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts @@ -1,5 +1,11 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertLocatorWithinViewport, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; import { evalObsidianJson } from "../runner/cli.ts"; import { assertCouchDbReachable, @@ -13,7 +19,10 @@ import { import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, + assertE2eCompatibilityMarker, configureCouchDb, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -21,7 +30,13 @@ import { type LocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; -import { captureJsonResolveDialogue, clickJsonResolveOption, obsidianRemoteDebuggingPort } from "../runner/ui.ts"; +import { + captureJsonResolveDialogue, + clickJsonResolveOption, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; @@ -300,31 +315,33 @@ async function startConfiguredSession( vault: TemporaryVault, overrides: Record = {} ): Promise { + const couchDbSettings = { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }; + const hiddenFileSettings = { + syncInternalFiles: true, + syncInternalFilesBeforeReplication: true, + watchInternalFileChanges: false, + syncInternalFilesTargetPatterns: "", + ...overrides, + }; const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + // A fresh Vault waits for onboarding before opening its local database. + // Seed the same isolated settings used by configureCouchDb so that the + // application lifecycle can become ready without a user interaction. + pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); - await configureCouchDb( - context.cliBinary, - session.cliEnv, - { - uri: context.couchDb.uri, - username: context.couchDb.username, - password: context.couchDb.password, - dbName: context.dbName, - }, - { - syncInternalFiles: true, - syncInternalFilesBeforeReplication: true, - watchInternalFileChanges: false, - syncInternalFilesTargetPatterns: "", - ...overrides, - } - ); - await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv); + await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings); await prepareRemote(context.cliBinary, session.cliEnv); return session; } @@ -490,6 +507,118 @@ async function runTargetMismatch( console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target."); } +async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate( + ({ nextItemIds, nextIncludeRestart }) => { + const obsidianApp = (globalThis as typeof globalThis & { app: any }).app; + const plugin = obsidianApp.plugins.plugins["obsidian-livesync"]; + const core = plugin.core; + const addOn = core.getAddOn("HiddenFileSync"); + for (const id of ["alpha", "beta", "gamma"]) { + const pluginId = `livesync-e2e-${id}`; + obsidianApp.plugins.manifests[pluginId] = { + id: pluginId, + name: `E2E ${id[0]?.toUpperCase()}${id.slice(1)}`, + version: "1.0.0", + minAppVersion: "1.0.0", + description: "E2E fixture", + author: "Self-hosted LiveSync", + isDesktopOnly: false, + dir: `.obsidian/plugins/${pluginId}`, + }; + obsidianApp.plugins.enabledPlugins.add(pluginId); + } + addOn.queuedNotificationFiles.clear(); + for (const id of nextItemIds) { + addOn.queuedNotificationFiles.add(`.obsidian/plugins/livesync-e2e-${id}`); + } + if (nextIncludeRestart) { + addOn.queuedNotificationFiles.add(core.services.API.getSystemConfigDir()); + } + addOn.notifyConfigChange(); + }, + { nextItemIds: itemIds, nextIncludeRestart: includeRestart } + ); + }); +} + +async function clearHiddenFileNoticeFixtures(port: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate(() => { + const obsidianApp = (globalThis as typeof globalThis & { app: any }).app; + const plugin = obsidianApp.plugins.plugins["obsidian-livesync"]; + plugin.core.services.context.noticeGroups.hide("hidden-file-changes"); + for (const id of ["alpha", "beta", "gamma"]) { + const pluginId = `livesync-e2e-${id}`; + obsidianApp.plugins.enabledPlugins.delete(pluginId); + delete obsidianApp.plugins.manifests[pluginId]; + } + }); + }); +} + +async function runConfigurationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise { + const session = await startConfiguredSession(context, vault); + const port = session.remoteDebuggingPort; + const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000); + try { + await setObsidianMobileTestMode(port, true, timeoutMs); + await setHiddenFileNoticeFixtures(port, ["alpha", "beta"], true); + + await withObsidianPage(port, async (page) => { + const visibleGroups = page.locator(".notice:has(.vpk-keyed-notice-group):visible"); + await visibleGroups.first().waitFor({ state: "visible", timeout: timeoutMs }); + assertEqual(await visibleGroups.count(), 1, "Hidden File Sync created more than one visible Notice group."); + + const notice = visibleGroups.first(); + const rows = notice.locator(".vpk-keyed-notice-group__item"); + assertEqual(await rows.count(), 3, "Hidden File Sync did not group every configuration-change action."); + await notice.getByText("Files in E2E Alpha were updated.", { exact: true }).waitFor(); + await notice.getByText("Files in E2E Beta were updated.", { exact: true }).waitFor(); + await notice.getByText("Other Obsidian settings files were updated.", { exact: true }).waitFor(); + + await assertLocatorWithinViewport(page, notice, { label: "Hidden File Sync notification group" }); + await assertNoHorizontalOverflow(page, notice, { label: "Hidden File Sync notification group" }); + await assertLocatorWithinSafeArea(page, notice, { + label: "Hidden File Sync notification group", + safeAreaInsets: iPhoneSafeArea, + }); + const buttons = notice.getByRole("button"); + for (let index = 0; index < (await buttons.count()); index += 1) { + await assertLocatorHasMinimumTouchTarget(page, buttons.nth(index), { + label: `Hidden File Sync notification action ${index + 1}`, + }); + } + + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const screenshotPath = join(outputDirectory, "hidden-file-notice-group-mobile.png"); + await mkdir(dirname(screenshotPath), { recursive: true }); + await page.screenshot({ path: screenshotPath, fullPage: true, animations: "disabled" }); + + await rows.first().getByText("Files in E2E Alpha were updated.", { exact: true }).click(); + await notice.waitFor({ state: "hidden", timeout: timeoutMs }); + }); + + await setHiddenFileNoticeFixtures(port, ["gamma"], false); + await withObsidianPage(port, async (page) => { + const notice = page.locator(".notice:has(.vpk-keyed-notice-group):visible").first(); + await notice.waitFor({ state: "visible", timeout: timeoutMs }); + const rows = notice.locator(".vpk-keyed-notice-group__item"); + assertEqual(await rows.count(), 1, "A dismissed Hidden File Sync Notice repeated acknowledged rows."); + await rows.getByText("Files in E2E Gamma were updated.", { exact: true }).waitFor(); + }); + + console.log( + "Hidden File Sync grouped configuration notifications passed the mobile regression for issue #555." + ); + } finally { + await clearHiddenFileNoticeFixtures(port).catch(() => undefined); + await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined); + await session.app.stop(); + } +} + async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -517,6 +646,7 @@ async function main(): Promise { await runJsonConflictRoundTrip(context, vaultA, vaultB); await runJsonManualConflictResolution(context, vaultB); await runTargetMismatch(context, vaultA, vaultB); + await runConfigurationNoticeGrouping(context, vaultB); } finally { await vaultA.dispose(); await vaultB.dispose();