mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-26 19:13:20 +00:00
Group Obsidian startup notices by operation
This commit is contained in:
1 parent
ed3f81e9f9
commit
d28282edad
10 files changed
+613
-164
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,18 +1279,24 @@ 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queueNotification(key: FilePath) {
|
queueNotification(key: FilePath) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,133 +139,152 @@ 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", {
|
||||||
const errorFiles = [] as ErrorInfo[];
|
message: "Checking for incomplete documents...",
|
||||||
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 unrecoverable = errorFiles.filter((e) => {
|
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
|
||||||
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"),
|
|
||||||
})
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const message = $msg("moduleMigration.fix0256.message", {
|
try {
|
||||||
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
const errorFiles = [] as ErrorInfo[];
|
||||||
messageUnrecoverable,
|
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||||
});
|
const path = this.getPath(metaDoc);
|
||||||
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
|
|
||||||
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
|
if (!isValidPath(path)) {
|
||||||
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
|
continue;
|
||||||
const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
|
}
|
||||||
title: $msg("moduleMigration.fix0256.title"),
|
if (!(await this.services.vault.isTargetFile(path))) {
|
||||||
defaultAction: CHECK_IT_LATER,
|
continue;
|
||||||
});
|
}
|
||||||
if (ret == FIX) {
|
if (!isMetaEntry(metaDoc)) {
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
stubFile.stat.mtime = Date.now();
|
const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc);
|
||||||
const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false);
|
if (!doc || !isLoadedEntry(doc)) {
|
||||||
if (result) {
|
continue;
|
||||||
Logger(`Successfully restored ${file.path} from storage`);
|
}
|
||||||
} else {
|
if (isDeletedEntry(doc)) {
|
||||||
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
|
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) {
|
if (errorFiles.length == 0) {
|
||||||
// User chose to dismiss the issue
|
Logger("No size mismatches found", LOG_LEVEL_INFO);
|
||||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
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<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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 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({
|
const session = await startObsidianLiveSyncSession({
|
||||||
binary: context.binary,
|
binary: context.binary,
|
||||||
cliBinary: context.cliBinary,
|
cliBinary: context.cliBinary,
|
||||||
vault,
|
vault,
|
||||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
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 configureCouchDb(
|
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
|
||||||
context.cliBinary,
|
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
|
||||||
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 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();
|
||||||
|
|||||||
Reference in new issue
Block a user