Group Obsidian startup notices by operation

This commit is contained in:
vorotamoroz committed 2026-07-19 07:04:54 +00:00
1 parent ed3f81e9f9
commit d28282edad
10 files changed
+491 -42

No files matched your search

@@ -57,6 +57,9 @@ import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.d
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts"; import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce"; 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 { declare global {
interface OPTIONAL_SYNC_FEATURES { interface OPTIONAL_SYNC_FEATURES {
FETCH: "FETCH"; FETCH: "FETCH";
@@ -1227,6 +1230,8 @@ Offline Changed files: ${files.length}`;
notifyConfigChange() { notifyConfigChange() {
const updatedFolders = [...this.queuedNotificationFiles]; const updatedFolders = [...this.queuedNotificationFiles];
this.queuedNotificationFiles.clear(); this.queuedNotificationFiles.clear();
const noticeGroups = this.services.context.noticeGroups;
let hasNoticeItems = false;
try { try {
//@ts-ignore //@ts-ignore
const manifests = Object.values(this.app.plugins.manifests) as unknown as PluginManifest[]; 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. // If notified about plug-ins, reloading Obsidian may not be necessary.
const updatePluginId = manifest.id; const updatePluginId = manifest.id;
const updatePluginName = manifest.name; const updatePluginName = manifest.name;
this.core.confirm.askInPopup( const itemKey = `plugin:${updatePluginId}`;
`updated-${updatePluginId}`, noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, itemKey, {
`Files in ${updatePluginName} has been updated!\nPress {HERE} to reload ${updatePluginName}, or press elsewhere to dismiss this message.`, message: `Files in ${updatePluginName} were updated.`,
(anchor) => { action: {
anchor.text = "HERE"; label: `Reload ${updatePluginName}`,
anchor.addEventListener("click", () => { onSelect: () => {
fireAndForget(async () => { fireAndForget(async () => {
this._log( this._log(
`Unloading plugin: ${updatePluginName}`, `Unloading plugin: ${updatePluginName}`,
LOG_LEVEL_NOTICE, LOG_LEVEL_NOTICE,
"plugin-reload-" + updatePluginId "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); 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); await this.app.plugins.loadPlugin(updatePluginId);
this._log( this._log(
`Plugin reloaded: ${updatePluginName}`, `Plugin reloaded: ${updatePluginName}`,
LOG_LEVEL_NOTICE, LOG_LEVEL_NOTICE,
"plugin-reload-" + updatePluginId "plugin-reload-" + updatePluginId
); );
noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, itemKey);
}); });
},
},
}); });
} hasNoticeItems = true;
);
} }
} catch (ex) { } catch (ex) {
this._log("Error on checking plugin status."); this._log("Error on checking plugin status.");
@@ -1272,17 +1279,23 @@ Offline Changed files: ${files.length}`;
// If something changes left, notify for reloading Obsidian. // If something changes left, notify for reloading Obsidian.
if (updatedFolders.indexOf(this.services.API.getSystemConfigDir()) >= 0) { if (updatedFolders.indexOf(this.services.API.getSystemConfigDir()) >= 0) {
if (!this.services.appLifecycle.isReloadingScheduled()) { if (!this.services.appLifecycle.isReloadingScheduled()) {
this.core.confirm.askInPopup( noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, "restart", {
`updated-any-hidden`, message: "Other Obsidian settings files were updated.",
`Some setting files have been modified\nPress {HERE} to schedule a reload of Obsidian, or press elsewhere to dismiss this message.`, action: {
(anchor) => { label: "Schedule an Obsidian restart",
anchor.text = "HERE"; onSelect: () => {
anchor.addEventListener("click", () => {
this.services.appLifecycle.scheduleRestart(); 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 });
} }
} }
@@ -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");
});
});
+37 -6
View File
@@ -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 { import {
EVENT_REQUEST_OPEN_P2P, EVENT_REQUEST_OPEN_P2P,
EVENT_REQUEST_OPEN_SETTING_WIZARD, 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 { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { isValidPath } from "@/common/utils.ts"; import { isValidPath } from "@/common/utils.ts";
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types"; 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 { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import type { LiveSyncCore } from "@/main.ts"; import type { LiveSyncCore } from "@/main.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts"; import { SetupManager } from "@/modules/features/SetupManager.ts";
@@ -26,7 +36,9 @@ type ErrorInfo = {
isConflicted?: boolean; isConflicted?: boolean;
}; };
export class ModuleMigration extends AbstractModule { const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) { async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation( const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
{ {
@@ -127,8 +139,13 @@ export class ModuleMigration extends AbstractModule {
return Promise.resolve(true); return Promise.resolve(true);
} }
this._log("Checking for incomplete documents...", LOG_LEVEL_NOTICE, "check-incomplete"); const noticeGroups = this.core.services.context.noticeGroups;
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
message: "Checking for incomplete documents...",
});
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
try {
const errorFiles = [] as ErrorInfo[]; const errorFiles = [] as ErrorInfo[];
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) { for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
const path = this.getPath(metaDoc); const path = this.getPath(metaDoc);
@@ -186,11 +203,17 @@ export class ModuleMigration extends AbstractModule {
} }
} }
if (errorFiles.length == 0) { if (errorFiles.length == 0) {
Logger("No size mismatches found", LOG_LEVEL_NOTICE); 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); await this.core.kvDB.set("checkIncompleteDocs", true);
return Promise.resolve(true); return Promise.resolve(true);
} }
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_NOTICE); 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: // We have to repair them following rules and situations:
// A. DB Recorded != DB Stored // A. DB Recorded != DB Stored
// A.1. DB Recorded == Storage Stored // A.1. DB Recorded == Storage Stored
@@ -254,6 +277,14 @@ export class ModuleMigration extends AbstractModule {
} }
return Promise.resolve(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<boolean> { async hasCompromisedChunks(): Promise<boolean> {
@@ -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");
});
});
@@ -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();
}
}
@@ -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();
});
});
@@ -3,17 +3,20 @@ import type { App, Plugin } from "@/deps";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { eventHub } from "@/common/events"; import { eventHub } from "@/common/events";
import { translateLiveSyncMessage } from "@/common/translation"; import { translateLiveSyncMessage } from "@/common/translation";
import type { ObsidianNoticeGroups } from "./ObsidianNoticeGroups";
/** Host capabilities owned by one Self-hosted LiveSync plug-in instance. */ /** Host capabilities owned by one Self-hosted LiveSync plug-in instance. */
export class ObsidianServiceContext extends ServiceContext { export class ObsidianServiceContext extends ServiceContext {
app: App; app: App;
plugin: Plugin; plugin: Plugin;
liveSyncPlugin: ObsidianLiveSyncPlugin; 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 }); super({ events: eventHub, translate: translateLiveSyncMessage });
this.app = app; this.app = app;
this.plugin = plugin; this.plugin = plugin;
this.liveSyncPlugin = liveSyncPlugin; this.liveSyncPlugin = liveSyncPlugin;
this.noticeGroups = noticeGroups;
} }
} }
@@ -13,7 +13,8 @@ describe("ObsidianServiceContext contract", () => {
const app = {} as Parameters[0]; const app = {} as Parameters[0];
const plugin = {} as Parameters[1]; const plugin = {} as Parameters[1];
const liveSyncPlugin = {} as Parameters[2]; 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({ expect(observeServiceContext(context, TRANSLATION_KEY)).toEqual({
translation: translateLiveSyncMessage(TRANSLATION_KEY), translation: translateLiveSyncMessage(TRANSLATION_KEY),
@@ -23,5 +24,6 @@ describe("ObsidianServiceContext contract", () => {
expect(context.app).toBe(app); expect(context.app).toBe(app);
expect(context.plugin).toBe(plugin); expect(context.plugin).toBe(plugin);
expect(context.liveSyncPlugin).toBe(liveSyncPlugin); expect(context.liveSyncPlugin).toBe(liveSyncPlugin);
expect(context.noticeGroups).toBe(noticeGroups);
}); });
}); });
+4 -1
View File
@@ -25,12 +25,14 @@ import { ObsidianUIService } from "./ObsidianUIService";
import { createScreenWakeLockManager } from "octagonal-wheels/browser/wakeLock"; import { createScreenWakeLockManager } from "octagonal-wheels/browser/wakeLock";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { OpenKeyValueDatabase } from "@/common/KeyValueDB"; import { OpenKeyValueDatabase } from "@/common/KeyValueDB";
import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups";
// InjectableServiceHub // InjectableServiceHub
export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceContext> { export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceContext> {
constructor(plugin: ObsidianLiveSyncPlugin) { 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 API = new ObsidianAPIService(context);
const conflict = new ObsidianConflictService(context); const conflict = new ObsidianConflictService(context);
@@ -62,6 +64,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
const screenWakeLock = createScreenWakeLockManager(); const screenWakeLock = createScreenWakeLockManager();
appLifecycle.onUnload.addHandler(async () => { appLifecycle.onUnload.addHandler(async () => {
await screenWakeLock.dispose(); await screenWakeLock.dispose();
noticeGroups.dispose();
return true; return true;
}); });
const database = new ObsidianDatabaseService(context, { const database = new ObsidianDatabaseService(context, {
@@ -1,5 +1,11 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { evalObsidianJson } from "../runner/cli.ts"; import { evalObsidianJson } from "../runner/cli.ts";
import { import {
assertCouchDbReachable, assertCouchDbReachable,
@@ -13,7 +19,10 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { import {
assertEqual, assertEqual,
assertE2eCompatibilityMarker,
configureCouchDb, configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote, prepareRemote,
pushLocalChanges, pushLocalChanges,
waitForLiveSyncCoreReady, waitForLiveSyncCoreReady,
@@ -21,7 +30,13 @@ import {
type LocalDatabaseEntry, type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts"; } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.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"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
@@ -300,31 +315,33 @@ async function startConfiguredSession(
vault: TemporaryVault, vault: TemporaryVault,
overrides: Record<string, unknown> = {} overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> { ): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({ const couchDbSettings = {
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri, uri: context.couchDb.uri,
username: context.couchDb.username, username: context.couchDb.username,
password: context.couchDb.password, password: context.couchDb.password,
dbName: context.dbName, dbName: context.dbName,
}, };
{ const hiddenFileSettings = {
syncInternalFiles: true, syncInternalFiles: true,
syncInternalFilesBeforeReplication: true, syncInternalFilesBeforeReplication: true,
watchInternalFileChanges: false, watchInternalFileChanges: false,
syncInternalFilesTargetPatterns: "", syncInternalFilesTargetPatterns: "",
...overrides, ...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 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); await prepareRemote(context.cliBinary, session.cliEnv);
return session; 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."); 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<void> {
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<void> {
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<void> {
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<void> { async function main(): Promise<void> {
const binary = requireObsidianBinary(); const binary = requireObsidianBinary();
const cli = discoverObsidianCli(); const cli = discoverObsidianCli();
@@ -517,6 +646,7 @@ async function main(): Promise<void> {
await runJsonConflictRoundTrip(context, vaultA, vaultB); await runJsonConflictRoundTrip(context, vaultA, vaultB);
await runJsonManualConflictResolution(context, vaultB); await runJsonManualConflictResolution(context, vaultB);
await runTargetMismatch(context, vaultA, vaultB); await runTargetMismatch(context, vaultA, vaultB);
await runConfigurationNoticeGrouping(context, vaultB);
} finally { } finally {
await vaultA.dispose(); await vaultA.dispose();
await vaultB.dispose(); await vaultB.dispose();