mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-24 21:42:58 +00:00
Group Hidden File Sync initialisation progress
This commit is contained in:
@@ -54,11 +54,20 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
|
||||
import {
|
||||
configureHiddenFileSyncMode,
|
||||
type ConfigureHiddenFileSyncResult,
|
||||
} from "./configureHiddenFileSyncMode.ts";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
|
||||
|
||||
type HiddenFileInitialisationProgress = {
|
||||
log(message: string): void;
|
||||
once(message: string): void;
|
||||
done(message?: string): void;
|
||||
};
|
||||
|
||||
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
||||
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
|
||||
|
||||
@@ -1421,13 +1430,18 @@ Offline Changed files: ${files.length}`;
|
||||
direction: SyncDirection,
|
||||
showMessage: boolean,
|
||||
// filesAll: InternalFileInfo[] | false = false,
|
||||
targetFilesSrc: string[] | false = false
|
||||
targetFilesSrc: string[] | false = false,
|
||||
initialisationProgress?: HiddenFileInitialisationProgress
|
||||
) {
|
||||
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
const p = this._progress("[⚙ Initialise]\n", logLevel);
|
||||
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
|
||||
// p.log("Resolving conflicts before starting...");
|
||||
// await this.resolveConflictOnInternalFiles();
|
||||
p.log("Initialising hidden files sync...");
|
||||
// The initialisation progress owns the user-visible Notice. Its child
|
||||
// rebuild and scan operations still write ordinary log entries, but
|
||||
// must not each create another keep-alive Notice.
|
||||
const showChildNotices = false;
|
||||
// TODO: Handling ignore files cannot be performed to the hidden files.
|
||||
|
||||
const targetFiles = targetFilesSrc
|
||||
@@ -1436,35 +1450,38 @@ Offline Changed files: ${files.length}`;
|
||||
if (direction == "pushForce" || direction == "push") {
|
||||
const onlyNew = direction == "push";
|
||||
p.log(`Started: Storage --> Database ${onlyNew ? "(Only New)" : ""}`);
|
||||
const updatedFiles = await this.rebuildFromStorage(showMessage, targetFiles, onlyNew);
|
||||
const updatedFiles = await this.rebuildFromStorage(showChildNotices, targetFiles, onlyNew);
|
||||
// making doubly sure, No more losing files.
|
||||
// I did so many times during the development.
|
||||
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
|
||||
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
|
||||
// And, scan other changes on the database (i.e. files which are on only other devices)
|
||||
await this.scanAllStorageChanges(showMessage, true, false);
|
||||
await this.scanAllDatabaseChanges(showMessage, true, false);
|
||||
p.log("Checking for remaining storage and database changes...");
|
||||
await this.scanAllStorageChanges(showChildNotices, true, false);
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
if (direction == "pullForce" || direction == "pull") {
|
||||
const onlyNew = direction == "pull";
|
||||
p.log(`Started: Database --> Storage ${onlyNew ? "(Only New)" : ""}`);
|
||||
const updatedEntries = await this.rebuildFromDatabase(showMessage, targetFiles, onlyNew);
|
||||
const updatedEntries = await this.rebuildFromDatabase(showChildNotices, targetFiles, onlyNew);
|
||||
const updatedFiles = updatedEntries.map((e) => stripAllPrefixes(this.getPath(e)));
|
||||
// making doubly sure, No more losing files.
|
||||
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
|
||||
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
|
||||
// And, scan other changes on the database (i.e. files which are on only other devices)
|
||||
await this.scanAllDatabaseChanges(showMessage, true, false);
|
||||
await this.scanAllStorageChanges(showMessage, true, false);
|
||||
p.log("Checking for remaining database and storage changes...");
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
await this.scanAllStorageChanges(showChildNotices, true, false);
|
||||
}
|
||||
if (direction == "safe") {
|
||||
p.log(`Started: Database <--> Storage (by modified date)`);
|
||||
const updatedFiles = await this.rebuildMerging(showMessage, targetFiles);
|
||||
const updatedFiles = await this.rebuildMerging(showChildNotices, targetFiles);
|
||||
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
|
||||
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
|
||||
// And, scan other changes on the database (i.e. files which are on only other devices)
|
||||
await this.scanAllStorageChanges(showMessage, true, false);
|
||||
await this.scanAllDatabaseChanges(showMessage, true, false);
|
||||
p.log("Checking for remaining storage and database changes...");
|
||||
await this.scanAllStorageChanges(showChildNotices, true, false);
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
p.done();
|
||||
}
|
||||
@@ -1789,32 +1806,44 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
|
||||
const result = await configureHiddenFileSyncMode(mode, {
|
||||
disable: async () => {
|
||||
// await this.core.$allSuspendExtraSync();
|
||||
await this.core.services.setting.applyPartial(
|
||||
{
|
||||
syncInternalFiles: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
// this.core.settings.syncInternalFiles = false;
|
||||
// await this.core.saveSettings();
|
||||
},
|
||||
enable: async () => {
|
||||
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
|
||||
await this.core.services.setting.applyPartial(
|
||||
{
|
||||
useAdvancedMode: true,
|
||||
syncInternalFiles: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
},
|
||||
initialise: async (direction) => {
|
||||
await this.initialiseInternalFileSync(direction, true);
|
||||
},
|
||||
});
|
||||
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
|
||||
let result: ConfigureHiddenFileSyncResult;
|
||||
try {
|
||||
result = await configureHiddenFileSyncMode(mode, {
|
||||
disable: async () => {
|
||||
// await this.core.$allSuspendExtraSync();
|
||||
await this.core.services.setting.applyPartial(
|
||||
{
|
||||
syncInternalFiles: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
// this.core.settings.syncInternalFiles = false;
|
||||
// await this.core.saveSettings();
|
||||
},
|
||||
enable: async () => {
|
||||
// Open the one user-visible progress Notice before saving
|
||||
// the setting. Large Vaults can otherwise appear idle
|
||||
// before the initial file enumeration begins.
|
||||
initialisationProgress = this._progress("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
|
||||
initialisationProgress.log("Preparing Hidden File Sync...");
|
||||
await this.core.services.setting.applyPartial(
|
||||
{
|
||||
useAdvancedMode: true,
|
||||
syncInternalFiles: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
},
|
||||
initialise: async (direction) => {
|
||||
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialisationProgress = undefined;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
initialisationProgress?.done("Failed");
|
||||
throw error;
|
||||
}
|
||||
if (result == "ignored" || result == "disabled") {
|
||||
return;
|
||||
}
|
||||
@@ -1822,7 +1851,7 @@ Offline Changed files: ${files.length}`;
|
||||
// this.plugin.settings.syncInternalFiles = true;
|
||||
|
||||
// await this.plugin.saveSettings();
|
||||
this._log(`Done! Restarting the app is strongly recommended!`, LOG_LEVEL_NOTICE);
|
||||
this._log("Hidden File Sync initialisation completed.", LOG_LEVEL_INFO);
|
||||
}
|
||||
// <-- Configuration handling
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
@@ -21,6 +22,7 @@ vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
}));
|
||||
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => {
|
||||
@@ -102,4 +104,106 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
expect(core.services.appLifecycle.scheduleRestart).toHaveBeenCalledOnce();
|
||||
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart");
|
||||
});
|
||||
|
||||
it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => {
|
||||
const progress = {
|
||||
log: vi.fn(),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const rebuildMerging = vi.fn(async () => []);
|
||||
const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined);
|
||||
const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined);
|
||||
const scanAllStorageChanges = vi.fn(async () => undefined);
|
||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
_progress: vi.fn(() => progress),
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed,
|
||||
adoptCurrentDatabaseFilesAsProcessed,
|
||||
scanAllStorageChanges,
|
||||
scanAllDatabaseChanges,
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
|
||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not surround the initialisation progress with separate gathering and restart Notices", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
await handlers.initialise("safe");
|
||||
return "enabled";
|
||||
});
|
||||
const events: string[] = [];
|
||||
const progress = {
|
||||
log: vi.fn((message: string) => {
|
||||
events.push(`progress:${message}`);
|
||||
}),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const createProgress = vi.fn(() => progress);
|
||||
const applyPartial = vi.fn(async () => {
|
||||
events.push("apply-settings");
|
||||
});
|
||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
services: {
|
||||
setting: { applyPartial },
|
||||
},
|
||||
},
|
||||
initialiseInternalFileSync,
|
||||
_progress: createProgress,
|
||||
_log: log,
|
||||
});
|
||||
|
||||
await hiddenFileSync.configureHiddenFileSync("MERGE");
|
||||
|
||||
expect(createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
|
||||
expect(events[0]).toBe("progress:Preparing Hidden File Sync...");
|
||||
expect(initialiseInternalFileSync).toHaveBeenCalledWith("safe", true, false, progress);
|
||||
expect(log).not.toHaveBeenCalledWith("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
|
||||
expect(log).not.toHaveBeenCalledWith("Done! Restarting the app is strongly recommended!", LOG_LEVEL_NOTICE);
|
||||
expect(log).toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
|
||||
});
|
||||
|
||||
it("closes the preparation Notice when enabling Hidden File Sync fails", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
return "enabled";
|
||||
});
|
||||
const error = new Error("setting persistence failed");
|
||||
const progress = {
|
||||
log: vi.fn(),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
services: {
|
||||
setting: {
|
||||
applyPartial: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(hiddenFileSync.configureHiddenFileSync("MERGE")).rejects.toBe(error);
|
||||
|
||||
expect(progress.done).toHaveBeenCalledWith("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one mobile-safe Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so legacy-profile migration remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate mobile-safe action Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ const { evalObsidianJson } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./cli.ts", () => ({ evalObsidianJson }));
|
||||
|
||||
import { assertE2eCompatibilityMarker, type CompatibilityMarkerState } from "./liveSyncWorkflow.ts";
|
||||
import {
|
||||
assertE2eCompatibilityMarker,
|
||||
createE2eCouchDbPluginData,
|
||||
type CompatibilityMarkerState,
|
||||
} from "./liveSyncWorkflow.ts";
|
||||
|
||||
describe("compatibility marker persistence", () => {
|
||||
it("waits for an accepted review to reach device-local storage", async () => {
|
||||
@@ -32,3 +36,21 @@ describe("compatibility marker persistence", () => {
|
||||
expect(evalObsidianJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured CouchDB fixture", () => {
|
||||
it("starts in the current remote-profile format instead of exercising legacy migration", () => {
|
||||
const pluginData = createE2eCouchDbPluginData({
|
||||
uri: "https://couch.example",
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
dbName: "notes",
|
||||
});
|
||||
const remoteConfigurations = pluginData.remoteConfigurations as
|
||||
| Record<string, { id: string; uri: string }>
|
||||
| undefined;
|
||||
|
||||
expect(remoteConfigurations).toBeDefined();
|
||||
expect(Object.keys(remoteConfigurations ?? {})).toHaveLength(1);
|
||||
expect(pluginData.activeConfigurationId).toBe(Object.keys(remoteConfigurations ?? {})[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import { evalObsidianJson } from "./cli.ts";
|
||||
import { SERVICE_CONTEXT_MEMBERS } from "../../contracts/serviceContext.ts";
|
||||
import { DATABASE_COMPATIBILITY_VERSION_KEY } from "../../../src/common/databaseCompatibility.ts";
|
||||
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { type ObsidianLiveSyncSettings, VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { CouchDbConfig } from "./couchdb.ts";
|
||||
import type { ObjectStorageConfig } from "./objectStorage.ts";
|
||||
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
|
||||
@@ -236,7 +237,7 @@ export function createE2eCouchDbPluginData(
|
||||
settings: Pick<CouchDbConfig, "uri" | "username" | "password"> & { dbName: string },
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
const pluginData = {
|
||||
couchDB_URI: settings.uri,
|
||||
couchDB_USER: settings.username,
|
||||
couchDB_PASSWORD: settings.password,
|
||||
@@ -245,6 +246,12 @@ export function createE2eCouchDbPluginData(
|
||||
...E2E_PREFERRED_SETTINGS,
|
||||
...overrides,
|
||||
};
|
||||
upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "couchdb", {
|
||||
id: "e2e-couchdb",
|
||||
name: "E2E CouchDB",
|
||||
activate: true,
|
||||
});
|
||||
return pluginData;
|
||||
}
|
||||
|
||||
export function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import {
|
||||
captureObsidianPage,
|
||||
captureJsonResolveDialogue,
|
||||
clickJsonResolveOption,
|
||||
obsidianRemoteDebuggingPort,
|
||||
@@ -58,6 +59,7 @@ const mergeJsonPath = ".obsidian/livesync-e2e-merge.json";
|
||||
const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
|
||||
const targetPath = ".obsidian/livesync-targeted/only-a.json";
|
||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
@@ -558,6 +560,230 @@ async function clearHiddenFileNoticeFixtures(port: number): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function runInitialisationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
|
||||
const session = await startConfiguredSession(context, vault, {
|
||||
syncInternalFiles: false,
|
||||
syncInternalFilesBeforeReplication: false,
|
||||
});
|
||||
const port = session.remoteDebuggingPort;
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000);
|
||||
try {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while ((await page.locator(".notice:visible").count()) > 0 && Date.now() < deadline) {
|
||||
await page.locator(".notice:visible").first().click({
|
||||
force: true,
|
||||
position: { x: 2, y: 2 },
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
}
|
||||
assertEqual(
|
||||
await page.locator(".notice:visible").count(),
|
||||
0,
|
||||
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
|
||||
);
|
||||
});
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
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");
|
||||
const setting = core.services.setting;
|
||||
const originalApplyPartial = setting.applyPartial;
|
||||
const originalRebuildMerging = addOn.rebuildMerging;
|
||||
const state = {
|
||||
done: false,
|
||||
reachedPreparation: false,
|
||||
reachedInitialisation: false,
|
||||
maxVisibleProgressNotices: 0,
|
||||
visibleProgressNoticeTexts: [] as string[],
|
||||
sawStandaloneGatheringNotice: false,
|
||||
sawStandaloneRestartNotice: false,
|
||||
releasePreparation: undefined as (() => void) | undefined,
|
||||
releaseInitialisation: undefined as (() => void) | undefined,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
(globalThis as unknown as Record<string, typeof state>)[stateKey] = state;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
|
||||
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
|
||||
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
|
||||
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
|
||||
);
|
||||
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
|
||||
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
|
||||
);
|
||||
if (progressNotices.length > state.maxVisibleProgressNotices) {
|
||||
state.maxVisibleProgressNotices = progressNotices.length;
|
||||
state.visibleProgressNoticeTexts = progressNotices.map(
|
||||
(notice) => notice.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
setting.applyPartial = async (...args: unknown[]) => {
|
||||
const update = args[0] as { syncInternalFiles?: unknown } | undefined;
|
||||
if (update?.syncInternalFiles === true && !state.reachedPreparation) {
|
||||
state.reachedPreparation = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
state.releasePreparation = resolve;
|
||||
});
|
||||
}
|
||||
return await originalApplyPartial.apply(setting, args);
|
||||
};
|
||||
|
||||
addOn.rebuildMerging = async (...args: unknown[]) => {
|
||||
state.reachedInitialisation = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
state.releaseInitialisation = resolve;
|
||||
});
|
||||
return await originalRebuildMerging.apply(addOn, args);
|
||||
};
|
||||
|
||||
void core.services.setting
|
||||
.enableOptionalFeature("MERGE")
|
||||
.then(
|
||||
() => {
|
||||
state.done = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.done = true;
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
setting.applyPartial = originalApplyPartial;
|
||||
addOn.rebuildMerging = originalRebuildMerging;
|
||||
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
|
||||
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
|
||||
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
|
||||
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
|
||||
);
|
||||
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
|
||||
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
|
||||
);
|
||||
if (progressNotices.length > state.maxVisibleProgressNotices) {
|
||||
state.maxVisibleProgressNotices = progressNotices.length;
|
||||
state.visibleProgressNoticeTexts = progressNotices.map(
|
||||
(notice) => notice.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
observer.disconnect();
|
||||
});
|
||||
}, hiddenFileInitialisationStateKey);
|
||||
});
|
||||
|
||||
const screenshotPath = await captureObsidianPage(
|
||||
port,
|
||||
"hidden-file-initial-scan-progress.png",
|
||||
async (page) => {
|
||||
await page.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, { reachedPreparation?: boolean } | undefined>)[
|
||||
stateKey
|
||||
]?.reachedPreparation === true,
|
||||
hiddenFileInitialisationStateKey,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
|
||||
await progressNotices.first().waitFor({ state: "visible", timeout: timeoutMs });
|
||||
assertEqual(
|
||||
await progressNotices.count(),
|
||||
1,
|
||||
"Hidden File Sync showed more than one progress Notice before saving its enabled setting."
|
||||
);
|
||||
await progressNotices
|
||||
.filter({ hasText: "Preparing Hidden File Sync..." })
|
||||
.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
}
|
||||
);
|
||||
|
||||
const result = await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const state = (globalThis as unknown as Record<
|
||||
string,
|
||||
{ releasePreparation?: () => void } | undefined
|
||||
>)[stateKey];
|
||||
state?.releasePreparation?.();
|
||||
}, hiddenFileInitialisationStateKey);
|
||||
await page.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, { reachedInitialisation?: boolean } | undefined>)[
|
||||
stateKey
|
||||
]?.reachedInitialisation === true,
|
||||
hiddenFileInitialisationStateKey,
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
|
||||
assertEqual(
|
||||
await progressNotices.count(),
|
||||
1,
|
||||
"Hidden File Sync replaced its parent progress Notice when the first child phase started."
|
||||
);
|
||||
await page.evaluate((stateKey) => {
|
||||
const state = (
|
||||
globalThis as unknown as Record<string, { releaseInitialisation?: () => void } | undefined>
|
||||
)[stateKey];
|
||||
state?.releaseInitialisation?.();
|
||||
}, hiddenFileInitialisationStateKey);
|
||||
await page.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, { done?: boolean } | undefined>)[stateKey]?.done === true,
|
||||
hiddenFileInitialisationStateKey,
|
||||
{ timeout: hiddenFileCliTimeoutMs }
|
||||
);
|
||||
return await page.evaluate(
|
||||
(stateKey) =>
|
||||
(
|
||||
globalThis as unknown as Record<
|
||||
string,
|
||||
{
|
||||
error?: string;
|
||||
maxVisibleProgressNotices: number;
|
||||
visibleProgressNoticeTexts: string[];
|
||||
sawStandaloneGatheringNotice: boolean;
|
||||
sawStandaloneRestartNotice: boolean;
|
||||
}
|
||||
>
|
||||
)[stateKey],
|
||||
hiddenFileInitialisationStateKey
|
||||
);
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`Hidden File Sync initialisation failed: ${result.error}`);
|
||||
}
|
||||
assertEqual(
|
||||
result.maxVisibleProgressNotices,
|
||||
1,
|
||||
`Hidden File Sync split initialisation across multiple progress Notices: ${JSON.stringify(
|
||||
result.visibleProgressNoticeTexts
|
||||
)}`
|
||||
);
|
||||
assertEqual(
|
||||
result.sawStandaloneGatheringNotice,
|
||||
false,
|
||||
"Hidden File Sync showed the old standalone gathering Notice."
|
||||
);
|
||||
assertEqual(
|
||||
result.sawStandaloneRestartNotice,
|
||||
false,
|
||||
"Hidden File Sync showed the old standalone restart recommendation."
|
||||
);
|
||||
console.log(
|
||||
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
|
||||
);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function runConfigurationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
|
||||
const session = await startConfiguredSession(context, vault);
|
||||
const port = session.remoteDebuggingPort;
|
||||
@@ -646,6 +872,7 @@ async function main(): Promise<void> {
|
||||
await runJsonConflictRoundTrip(context, vaultA, vaultB);
|
||||
await runJsonManualConflictResolution(context, vaultB);
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runInitialisationNoticeGrouping(context, vaultB);
|
||||
await runConfigurationNoticeGrouping(context, vaultB);
|
||||
} finally {
|
||||
await vaultA.dispose();
|
||||
|
||||
@@ -15,6 +15,7 @@ Earlier releases remain available in the [0.25 release history](https://github.c
|
||||
### Improved
|
||||
|
||||
- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved.
|
||||
- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices.
|
||||
- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits.
|
||||
- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`.
|
||||
- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation.
|
||||
|
||||
Reference in New Issue
Block a user