mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 11:33:21 +00:00
Merge pull request #1276 from vrtmrz/fix/automatic-hidden-file-sync
Fix Automatic mode prerequisites and plug-in initialisation targets
This commit is contained in:
12 files changed
+861
-21
No files matched your search
@@ -795,6 +795,8 @@ When a saved setting changes whether a normal file can be reflected, LiveSync re
|
|||||||
|
|
||||||
Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files.
|
Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files.
|
||||||
|
|
||||||
|
**Automatic** delegates an item's files to Hidden File Sync. Enable and initialise Hidden File Sync in **Sync Settings** on this device first, after reviewing its [target and ignore patterns](./tips/hidden-file-sync.md#review-the-file-selection). Choosing Automatic while Hidden File Sync is disabled displays a Notice and leaves the current mode unchanged. Existing Automatic assignments, including those imported from another device, remain saved while Hidden File Sync is disabled.
|
||||||
|
|
||||||
### 1. Customisation Sync
|
### 1. Customisation Sync
|
||||||
|
|
||||||
#### Device name
|
#### Device name
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ Hidden File Sync must be enabled independently after ordinary setup on each devi
|
|||||||
|
|
||||||
If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately.
|
If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately.
|
||||||
|
|
||||||
|
## Automatic in Customisation Sync
|
||||||
|
|
||||||
|
**Automatic** delegates an item's files to Hidden File Sync. Enable Hidden File Sync on this device through the steps above before choosing Automatic. If Hidden File Sync is disabled, the choice displays a Notice without changing the item's mode or starting initialisation. Previously saved Automatic assignments remain saved, so this prerequisite also applies after importing settings from another device.
|
||||||
|
|
||||||
|
Once Hidden File Sync is enabled, choose the item's initial action in Customisation Sync. Selecting a whole plug-in applies that action to both its **MAIN** and **DATA** selections.
|
||||||
|
|
||||||
## Related settings
|
## Related settings
|
||||||
|
|
||||||
The [settings reference](../settings.md#7-hidden-files-advanced) describes periodic scanning, scanning before replication, notification suppression, selectors, and overwrite patterns. Change those controls only after the basic Hidden File Sync path is working.
|
The [settings reference](../settings.md#7-hidden-files-advanced) describes periodic scanning, scanning before replication, notification suppression, selectors, and overwrite patterns. Change those controls only after the basic Hidden File Sync path is working.
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
* remove it from this map in the same change.
|
* remove it from this map in the same change.
|
||||||
*/
|
*/
|
||||||
export const liveSyncProvisionalEnglishMessages = {
|
export const liveSyncProvisionalEnglishMessages = {
|
||||||
|
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
|
||||||
|
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
|
||||||
|
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
|
||||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
|
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
|
||||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
|
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
|
||||||
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
|
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
|
||||||
|
|||||||
@@ -20,10 +20,14 @@
|
|||||||
MODE_SHINY,
|
MODE_SHINY,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { normalizePath } from "@/deps";
|
import { normalizePath } from "@/deps";
|
||||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
import {
|
||||||
|
HiddenFileSync,
|
||||||
|
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
|
||||||
|
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||||
import { $msg as translateMessage } from "@/common/translation";
|
import { $msg as translateMessage } from "@/common/translation";
|
||||||
|
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||||
import {
|
import {
|
||||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
@@ -139,6 +143,11 @@
|
|||||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||||
return await addOn.deleteData(data);
|
return await addOn.deleteData(data);
|
||||||
}
|
}
|
||||||
|
function checkAutomaticSyncAvailable() {
|
||||||
|
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
|
||||||
|
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
function askMode(evt: MouseEvent, title: string, key: string) {
|
function askMode(evt: MouseEvent, title: string, key: string) {
|
||||||
const menu = new Menu();
|
const menu = new Menu();
|
||||||
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
||||||
@@ -149,6 +158,7 @@
|
|||||||
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
||||||
.onClick((e) => {
|
.onClick((e) => {
|
||||||
if (mode === MODE_AUTOMATIC) {
|
if (mode === MODE_AUTOMATIC) {
|
||||||
|
if (!checkAutomaticSyncAvailable()) return;
|
||||||
askOverwriteModeForAutomatic(evt, key);
|
askOverwriteModeForAutomatic(evt, key);
|
||||||
} else {
|
} else {
|
||||||
setMode(key, mode as SYNC_MODE);
|
setMode(key, mode as SYNC_MODE);
|
||||||
@@ -161,10 +171,11 @@
|
|||||||
menu.showAtMouseEvent(evt);
|
menu.showAtMouseEvent(evt);
|
||||||
}
|
}
|
||||||
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
||||||
|
if (!checkAutomaticSyncAvailable()) return;
|
||||||
setMode(key, MODE_AUTOMATIC);
|
setMode(key, MODE_AUTOMATIC);
|
||||||
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
|
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
|
||||||
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
|
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
|
||||||
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||||
}
|
}
|
||||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||||
const menu = new Menu();
|
const menu = new Menu();
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
|
||||||
|
export function getAutomaticSyncTargetFiles(
|
||||||
|
key: string,
|
||||||
|
configDir: string,
|
||||||
|
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
|
||||||
|
): string[] {
|
||||||
|
const keys = key.startsWith("PLUGIN_ALL/")
|
||||||
|
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
|
||||||
|
: [key];
|
||||||
|
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
|
||||||
|
return [...files].map((file) => `${configDir}/${file}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||||
|
|
||||||
|
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
|
||||||
|
return {
|
||||||
|
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
|
||||||
|
"PLUGIN_MAIN/example": {
|
||||||
|
key: "PLUGIN_MAIN/example",
|
||||||
|
mode: MODE_AUTOMATIC,
|
||||||
|
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
|
||||||
|
},
|
||||||
|
"PLUGIN_DATA/example": {
|
||||||
|
key: "PLUGIN_DATA/example",
|
||||||
|
mode: MODE_AUTOMATIC,
|
||||||
|
files: ["plugins/example/data.json"],
|
||||||
|
},
|
||||||
|
"PLUGIN_ETC/example": {
|
||||||
|
key: "PLUGIN_ETC/example",
|
||||||
|
mode: MODE_AUTOMATIC,
|
||||||
|
files: ["plugins/example/extra.json"],
|
||||||
|
},
|
||||||
|
"PLUGIN_MAIN/other": {
|
||||||
|
key: "PLUGIN_MAIN/other",
|
||||||
|
mode: MODE_AUTOMATIC,
|
||||||
|
files: ["plugins/other/main.js"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getAutomaticSyncTargetFiles", () => {
|
||||||
|
it("initialises a single selected item in the current configuration directory", () => {
|
||||||
|
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
|
||||||
|
".custom-config/app.json",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initialises both saved child selections when the whole plug-in is selected", () => {
|
||||||
|
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
|
||||||
|
".obsidian/plugins/example/data.json",
|
||||||
|
".obsidian/plugins/example/main.js",
|
||||||
|
".obsidian/plugins/example/manifest.json",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains the available child files when the other child has no saved entry", () => {
|
||||||
|
const entries = createEntries();
|
||||||
|
delete entries["PLUGIN_DATA/example"];
|
||||||
|
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
|
||||||
|
".obsidian/plugins/example/main.js",
|
||||||
|
".obsidian/plugins/example/manifest.json",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
|
||||||
|
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,6 +69,9 @@ type HiddenFileInitialisationProgress = {
|
|||||||
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
||||||
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
|
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
|
||||||
|
|
||||||
|
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
|
||||||
|
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
|
||||||
|
|
||||||
function getComparingMTime(
|
function getComparingMTime(
|
||||||
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
|
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
|
||||||
includeDeleted = false
|
includeDeleted = false
|
||||||
@@ -1444,6 +1447,11 @@ Offline Changed files: ${files.length}`;
|
|||||||
initialisationProgress?: HiddenFileInitialisationProgress
|
initialisationProgress?: HiddenFileInitialisationProgress
|
||||||
) {
|
) {
|
||||||
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||||
|
if (!this.isThisModuleEnabled()) {
|
||||||
|
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
|
||||||
|
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
|
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
|
||||||
// p.log("Resolving conflicts before starting...");
|
// p.log("Resolving conflicts before starting...");
|
||||||
// await this.resolveConflictOnInternalFiles();
|
// await this.resolveConflictOnInternalFiles();
|
||||||
@@ -1494,6 +1502,7 @@ Offline Changed files: ${files.length}`;
|
|||||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||||
}
|
}
|
||||||
p.done();
|
p.done();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
// <-- Initialization functions
|
// <-- Initialization functions
|
||||||
|
|
||||||
@@ -1925,6 +1934,7 @@ Offline Changed files: ${files.length}`;
|
|||||||
|
|
||||||
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
|
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
|
||||||
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
|
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
|
||||||
|
let initialised = false;
|
||||||
let result: ConfigureHiddenFileSyncResult;
|
let result: ConfigureHiddenFileSyncResult;
|
||||||
try {
|
try {
|
||||||
result = await configureHiddenFileSyncMode(mode, {
|
result = await configureHiddenFileSyncMode(mode, {
|
||||||
@@ -1954,7 +1964,7 @@ Offline Changed files: ${files.length}`;
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
initialise: async (direction) => {
|
initialise: async (direction) => {
|
||||||
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||||
initialisationProgress = undefined;
|
initialisationProgress = undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1962,7 +1972,7 @@ Offline Changed files: ${files.length}`;
|
|||||||
initialisationProgress?.done("Failed");
|
initialisationProgress?.done("Failed");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (result == "ignored" || result == "disabled") {
|
if (result == "ignored" || result == "disabled" || !initialised) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// this.plugin.settings.useAdvancedMode = true;
|
// this.plugin.settings.useAdvancedMode = true;
|
||||||
|
|||||||
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("HiddenFileSync configuration-change notices", () => {
|
describe("HiddenFileSync configuration-change notices", () => {
|
||||||
|
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||||
|
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
||||||
|
async (direction) => {
|
||||||
|
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||||
|
const path = ".obsidian/example.json" as FilePath;
|
||||||
|
const operations = {
|
||||||
|
rebuildFromStorage: vi.fn(async () => [path]),
|
||||||
|
rebuildFromDatabase: vi.fn(async () => []),
|
||||||
|
rebuildMerging: vi.fn(async () => [path]),
|
||||||
|
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
|
||||||
|
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
|
||||||
|
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||||
|
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
const settings = { syncInternalFiles: false };
|
||||||
|
const createProgress = vi.fn(() => progress);
|
||||||
|
const log = vi.fn();
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
core: { settings },
|
||||||
|
_progress: createProgress,
|
||||||
|
_log: log,
|
||||||
|
...operations,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
|
||||||
|
|
||||||
|
for (const operation of Object.values(operations)) {
|
||||||
|
expect(operation).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(settings.syncInternalFiles).toBe(false);
|
||||||
|
expect(createProgress).not.toHaveBeenCalled();
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
|
||||||
|
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||||
|
const createProgress = vi.fn();
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
core: { settings: { syncInternalFiles: false } },
|
||||||
|
_progress: createProgress,
|
||||||
|
_log: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(createProgress).not.toHaveBeenCalled();
|
||||||
|
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
|
||||||
|
const path = ".obsidian/example.json" as FilePath;
|
||||||
|
const deleted: MetaEntry = {
|
||||||
|
_id: "i:example" as DocumentID,
|
||||||
|
_rev: "2-deleted",
|
||||||
|
path: `i:${path}` as FilePathWithPrefix,
|
||||||
|
type: "plain",
|
||||||
|
ctime: 1,
|
||||||
|
mtime: 10,
|
||||||
|
size: 0,
|
||||||
|
deleted: true,
|
||||||
|
children: [],
|
||||||
|
eden: {},
|
||||||
|
};
|
||||||
|
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||||
|
const deleteFile = vi.fn(async () => "OK" as const);
|
||||||
|
const updateDeletion = vi.fn();
|
||||||
|
const getFiles = vi.fn(async () => [deleted]);
|
||||||
|
const processedStorage = new Map([[path, "storage-processed"]]);
|
||||||
|
const processedDatabase = new Map([[path, "database-processed"]]);
|
||||||
|
const resetProcessed = vi.fn(() => processedDatabase.clear());
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
core: {
|
||||||
|
settings: { syncInternalFiles: false },
|
||||||
|
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
|
||||||
|
},
|
||||||
|
_progress: vi.fn(() => progress),
|
||||||
|
_log: vi.fn(),
|
||||||
|
_verbose: vi.fn(),
|
||||||
|
_fileInfoLastProcessed: processedStorage,
|
||||||
|
_databaseInfoLastProcessed: processedDatabase,
|
||||||
|
getAllDatabaseFiles: getFiles,
|
||||||
|
getPath: (doc: MetaEntry) => doc.path,
|
||||||
|
resetLastProcessedDatabase: resetProcessed,
|
||||||
|
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
|
||||||
|
__deleteFile: deleteFile,
|
||||||
|
updateLastProcessedDeletion: updateDeletion,
|
||||||
|
adoptCurrentStorageFilesAsProcessed: vi.fn(),
|
||||||
|
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
|
||||||
|
scanAllStorageChanges: vi.fn(),
|
||||||
|
scanAllDatabaseChanges: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
|
||||||
|
|
||||||
|
expect(deleteFile).not.toHaveBeenCalled();
|
||||||
|
expect(updateDeletion).not.toHaveBeenCalled();
|
||||||
|
expect(getFiles).not.toHaveBeenCalled();
|
||||||
|
expect(resetProcessed).not.toHaveBeenCalled();
|
||||||
|
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
|
||||||
|
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
|
||||||
|
});
|
||||||
|
|
||||||
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
||||||
const commands: Array<{
|
const commands: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
|
|||||||
expect(command?.checkCallback?.(true)).toBe(false);
|
expect(command?.checkCallback?.(true)).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settings.useAdvancedMode = true;
|
||||||
|
for (const commandId of commandIds) {
|
||||||
|
const command = commands.find(({ id }) => id === commandId);
|
||||||
|
expect(command?.checkCallback?.(true)).toBe(false);
|
||||||
|
expect(command?.checkCallback?.(false)).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
settings.syncInternalFiles = true;
|
settings.syncInternalFiles = true;
|
||||||
settings.useAdvancedMode = true;
|
settings.useAdvancedMode = true;
|
||||||
for (const commandId of commandIds) {
|
for (const commandId of commandIds) {
|
||||||
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
|||||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
Object.assign(hiddenFileSync, {
|
Object.assign(hiddenFileSync, {
|
||||||
|
core: { settings: { syncInternalFiles: true } },
|
||||||
_progress: vi.fn(() => progress),
|
_progress: vi.fn(() => progress),
|
||||||
rebuildMerging,
|
rebuildMerging,
|
||||||
adoptCurrentStorageFilesAsProcessed,
|
adoptCurrentStorageFilesAsProcessed,
|
||||||
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
|
|||||||
scanAllDatabaseChanges,
|
scanAllDatabaseChanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||||
expect(progress.done).toHaveBeenCalledOnce();
|
expect(progress.done).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||||
|
"retains the selected files and completes %s initialisation when enabled",
|
||||||
|
async (direction) => {
|
||||||
|
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
|
||||||
|
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
|
||||||
|
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||||
|
const rebuildFromStorage = vi.fn(async () => files);
|
||||||
|
const rebuildFromDatabase = vi.fn(async () => entries);
|
||||||
|
const rebuildMerging = vi.fn(async () => files);
|
||||||
|
const adoptStorage = vi.fn();
|
||||||
|
const adoptDatabase = vi.fn();
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
core: { settings: { syncInternalFiles: true } },
|
||||||
|
_progress: vi.fn(() => progress),
|
||||||
|
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
|
||||||
|
rebuildFromStorage,
|
||||||
|
rebuildFromDatabase,
|
||||||
|
rebuildMerging,
|
||||||
|
adoptCurrentStorageFilesAsProcessed: adoptStorage,
|
||||||
|
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
|
||||||
|
scanAllStorageChanges: vi.fn(),
|
||||||
|
scanAllDatabaseChanges: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
if (direction === "pushForce") {
|
||||||
|
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||||
|
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||||
|
} else if (direction === "pullForce") {
|
||||||
|
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||||
|
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||||
|
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||||
|
} else {
|
||||||
|
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
|
||||||
|
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||||
|
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
|
||||||
|
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
|
||||||
|
expect(progress.done).toHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not report completion when the initialisation entry point refuses to start", async () => {
|
||||||
|
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||||
|
await handlers.enable();
|
||||||
|
await handlers.initialise("safe");
|
||||||
|
return "enabled";
|
||||||
|
});
|
||||||
|
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||||
|
const log = vi.fn();
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
core: { services: { setting: { applyPartial: vi.fn() } } },
|
||||||
|
_progress: vi.fn(() => progress),
|
||||||
|
_log: log,
|
||||||
|
initialiseInternalFileSync: vi.fn(async () => false),
|
||||||
|
});
|
||||||
|
|
||||||
|
await hiddenFileSync.configureHiddenFileSync("MERGE");
|
||||||
|
|
||||||
|
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
|
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
|
||||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||||
await handlers.enable();
|
await handlers.enable();
|
||||||
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
|||||||
const applyPartial = vi.fn(async () => {
|
const applyPartial = vi.fn(async () => {
|
||||||
events.push("apply-settings");
|
events.push("apply-settings");
|
||||||
});
|
});
|
||||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
const initialiseInternalFileSync = vi.fn(async () => true);
|
||||||
const log = vi.fn();
|
const log = vi.fn();
|
||||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
Object.assign(hiddenFileSync, {
|
Object.assign(hiddenFileSync, {
|
||||||
|
|||||||
@@ -252,10 +252,14 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
|||||||
|
|
||||||
`test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database, and also runs in the complete local suite. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path.
|
`test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database, and also runs in the complete local suite. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path.
|
||||||
|
|
||||||
|
Before enablement, the scenario directly requests initialisation while Hidden File Sync is disabled. It requires a refused result, unchanged local files, Metadata revisions, and processed markers, and no initialisation progress Notice.
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
|
The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
|
||||||
|
|
||||||
|
`--case=automatic` separately checks that choosing **Automatic** while Hidden File Sync is disabled displays a Notice without changing modes, files, Metadata revisions, or processed markers. The fixture starts with a saved Automatic selection and requires it to survive start-up and both rejected actions. It also checks disabling the feature after opening **Initial Action**. After enablement, selecting a whole plug-in must initialise both **MAIN** and **DATA**, and transfer their files to the other Vault. Separate temporary Vaults exercise **Overwrite Remote**, **Overwrite Local**, and **Use newer**. The latter two start with different local and database contents; deterministic per-file modification times require **Use newer** to keep the newer database MAIN content and the newer local DATA content.
|
||||||
|
|
||||||
`test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`.
|
`test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`.
|
||||||
|
|
||||||
`test:e2e:obsidian:remote-feature-change` starts real Obsidian with continuous CouchDB replication, then changes the remote version document from generation 12 to generation 13 with an unknown feature. It waits for the control document to reach the local database and the active Replicator to retire, checks that another replication is refused, and verifies that an already accepted Vault note remains intact. After restarting the same Vault, the current remote declaration still blocks finite replication and the actual continuous connection attempt. No KV feature history is involved. It is a focused test outside `test:e2e:obsidian:local-suite`; recovery with a future compatible client remains a separate validation boundary.
|
`test:e2e:obsidian:remote-feature-change` starts real Obsidian with continuous CouchDB replication, then changes the remote version document from generation 12 to generation 13 with an unknown feature. It waits for the control document to reach the local database and the active Replicator to retire, checks that another replication is refused, and verifies that an already accepted Vault note remains intact. After restarting the same Vault, the current remote declaration still blocks finite replication and the actual continuous connection attempt. No KV feature history is involved. It is a focused test outside `test:e2e:obsidian:local-suite`; recovery with a future compatible client remains a separate validation boundary.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import {
|
||||||
|
MODE_AUTOMATIC,
|
||||||
|
VERSIONING_DOCID,
|
||||||
|
type ObsidianLiveSyncSettings,
|
||||||
|
type LoadedEntry,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import { evalObsidianJson } from "../runner/cli.ts";
|
import { evalObsidianJson } from "../runner/cli.ts";
|
||||||
import {
|
import {
|
||||||
@@ -23,11 +29,12 @@ import {
|
|||||||
prepareRemote,
|
prepareRemote,
|
||||||
pushLocalChanges,
|
pushLocalChanges,
|
||||||
waitForLiveSyncCoreReady,
|
waitForLiveSyncCoreReady,
|
||||||
|
waitForLocalDatabaseEntry,
|
||||||
} 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 { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||||
import type { Locator } from "playwright";
|
import type { Locator, Page } from "playwright";
|
||||||
|
|
||||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||||
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||||
@@ -59,6 +66,18 @@ const pluginDir = ".obsidian/plugins/livesync-e2e-sample";
|
|||||||
const pluginManifestPath = `${pluginDir}/manifest.json`;
|
const pluginManifestPath = `${pluginDir}/manifest.json`;
|
||||||
const pluginMainPath = `${pluginDir}/main.js`;
|
const pluginMainPath = `${pluginDir}/main.js`;
|
||||||
const pluginStylesPath = `${pluginDir}/styles.css`;
|
const pluginStylesPath = `${pluginDir}/styles.css`;
|
||||||
|
const pluginDisplayName = "LiveSync E2E Sample";
|
||||||
|
const pluginDataPath = `${pluginDir}/data.json`;
|
||||||
|
const pluginDataContent = JSON.stringify({ source: "customisation-sync-a", enabled: true }, null, 4) + "\n";
|
||||||
|
const targetPluginDataContent = JSON.stringify({ source: "customisation-sync-b", enabled: true }, null, 4) + "\n";
|
||||||
|
const existingAutomaticKey = "SNIPPET/livesync-e2e-existing-automatic.css";
|
||||||
|
const existingAutomaticSelections: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] = {
|
||||||
|
[existingAutomaticKey]: {
|
||||||
|
key: existingAutomaticKey,
|
||||||
|
mode: MODE_AUTOMATIC,
|
||||||
|
files: ["snippets/livesync-e2e-existing-automatic.css"],
|
||||||
|
},
|
||||||
|
};
|
||||||
const identicalSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-identical.css";
|
const identicalSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-identical.css";
|
||||||
const identicalSnippetContent = ".livesync-customisation-identical { color: #3d6f54; }\n";
|
const identicalSnippetContent = ".livesync-customisation-identical { color: #3d6f54; }\n";
|
||||||
const differentSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-different.css";
|
const differentSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-different.css";
|
||||||
@@ -104,7 +123,8 @@ const pluginFixtures = [
|
|||||||
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
|
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type CustomisationSyncCase = "all" | "visibility" | "mtime";
|
type CustomisationSyncCase = "all" | "visibility" | "mtime" | "automatic";
|
||||||
|
type AutomaticInitialAction = "Overwrite Remote" | "Overwrite Local" | "Use newer";
|
||||||
|
|
||||||
type CustomisationSyncTestGlobal = typeof globalThis & {
|
type CustomisationSyncTestGlobal = typeof globalThis & {
|
||||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||||
@@ -131,6 +151,14 @@ type CustomisationScanResult = {
|
|||||||
files: string[];
|
files: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AutomaticSyncSnapshot = {
|
||||||
|
syncInternalFiles: boolean;
|
||||||
|
pluginSyncExtendedSetting: Record<string, { key: string; mode: number; files: string[] }>;
|
||||||
|
hiddenFiles: { path: string; rev: string; deleted: boolean }[];
|
||||||
|
processedFiles: [string, string | undefined][];
|
||||||
|
processedDatabaseFiles: [string, string | undefined][];
|
||||||
|
};
|
||||||
|
|
||||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||||
const fullPath = join(vaultPath, path);
|
const fullPath = join(vaultPath, path);
|
||||||
await mkdir(dirname(fullPath), { recursive: true });
|
await mkdir(dirname(fullPath), { recursive: true });
|
||||||
@@ -147,10 +175,11 @@ function selectedCase(): CustomisationSyncCase {
|
|||||||
if (args.length === 0) return "all";
|
if (args.length === 0) return "all";
|
||||||
const arg = args[0];
|
const arg = args[0];
|
||||||
if (args.length !== 1 || !arg?.startsWith("--case=")) {
|
if (args.length !== 1 || !arg?.startsWith("--case=")) {
|
||||||
throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime]");
|
throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime|automatic]");
|
||||||
}
|
}
|
||||||
const selected = arg.slice("--case=".length);
|
const selected = arg.slice("--case=".length);
|
||||||
if (selected === "all" || selected === "visibility" || selected === "mtime") return selected;
|
if (selected === "all" || selected === "visibility" || selected === "mtime" || selected === "automatic")
|
||||||
|
return selected;
|
||||||
throw new Error(`Unknown Customisation Sync E2E case: ${selected}`);
|
throw new Error(`Unknown Customisation Sync E2E case: ${selected}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +273,282 @@ async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyn
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAutomaticModeForPlugin(page: Page, pluginName: string): Promise<Locator> {
|
||||||
|
const opened = await page.evaluate(() =>
|
||||||
|
(globalThis as CustomisationSyncTestGlobal).app?.commands?.executeCommandById(
|
||||||
|
"obsidian-livesync:livesync-plugin-dialog-ex"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (opened !== true) throw new Error("Could not open the Customisation Sync dialogue command.");
|
||||||
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||||
|
const pluginRow = dialogue.locator(".labelrow").filter({ hasText: pluginName }).first();
|
||||||
|
await pluginRow.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await pluginRow.locator("button.status").click();
|
||||||
|
const automaticOption = page.locator(".menu-item").filter({ hasText: "Automatic" }).last();
|
||||||
|
await automaticOption.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await automaticOption.click();
|
||||||
|
return dialogue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAutomaticInitialAction(page: Page, label: AutomaticInitialAction): Promise<void> {
|
||||||
|
const action = page.locator(".menu-item").filter({ hasText: label }).last();
|
||||||
|
await action.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await action.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAutomaticSyncSnapshot(cliBinary: string, env: NodeJS.ProcessEnv): Promise<AutomaticSyncSnapshot> {
|
||||||
|
return await evalObsidianJson<AutomaticSyncSnapshot>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
"const hidden=core.getAddOn('HiddenFileSync');",
|
||||||
|
"const files=await hidden.getAllDatabaseFiles();",
|
||||||
|
"const processed=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
|
||||||
|
"return JSON.stringify({",
|
||||||
|
"syncInternalFiles:core.settings.syncInternalFiles,",
|
||||||
|
"pluginSyncExtendedSetting:core.settings.pluginSyncExtendedSetting,",
|
||||||
|
"hiddenFiles:files.map((entry)=>{const path=hidden.getPath(entry);return {path:path.startsWith('i:')?path.slice(2):path,rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)};}).sort((a,b)=>a.path.localeCompare(b.path)),",
|
||||||
|
"processedFiles:processed(hidden._fileInfoLastProcessed),",
|
||||||
|
"processedDatabaseFiles:processed(hidden._databaseInfoLastProcessed),",
|
||||||
|
"});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAutomaticFileContents(context: RunnerContext, session: ObsidianLiveSyncSession) {
|
||||||
|
const files = await evalObsidianJson<
|
||||||
|
{ path: string; entry: LoadedEntry | false; local: string; localMtime: number }[]
|
||||||
|
>(
|
||||||
|
context.cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
`const paths=${JSON.stringify([pluginStylesPath, pluginDataPath])};`,
|
||||||
|
"const files=await Promise.all(paths.map(async(path)=>{",
|
||||||
|
"const entry=await core.localDatabase.getDBEntry('i:'+path,undefined,false,true);",
|
||||||
|
"const stat=await app.vault.adapter.stat(path);",
|
||||||
|
"if(!stat) throw new Error('Missing Automatic fixture file: '+path);",
|
||||||
|
"return {path,entry,local:await app.vault.adapter.read(path),localMtime:stat.mtime};",
|
||||||
|
"}));",
|
||||||
|
"return JSON.stringify(files);",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
session.cliEnv
|
||||||
|
);
|
||||||
|
return await Promise.all(
|
||||||
|
files.map(async (file) => ({
|
||||||
|
path: file.path,
|
||||||
|
stored: file.entry ? await readAsBlob(file.entry).text() : null,
|
||||||
|
storedMtime: file.entry ? file.entry.mtime : 0,
|
||||||
|
local: file.local,
|
||||||
|
localMtime: file.localMtime,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setHiddenSyncEnabledForFixture(
|
||||||
|
cliBinary: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await evalObsidianJson<unknown>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
`core.settings.syncInternalFiles=${JSON.stringify(enabled)};`,
|
||||||
|
"return JSON.stringify({syncInternalFiles:core.settings.syncInternalFiles});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectAutomaticSyncSelection(
|
||||||
|
context: RunnerContext,
|
||||||
|
session: ObsidianLiveSyncSession,
|
||||||
|
vault: TemporaryVault,
|
||||||
|
pluginName: string,
|
||||||
|
initialAction: AutomaticInitialAction
|
||||||
|
): Promise<void> {
|
||||||
|
const noticeText =
|
||||||
|
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
|
||||||
|
const trackedFiles = [pluginStylesPath, pluginDataPath];
|
||||||
|
const contentsBefore = await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)));
|
||||||
|
const disabledBefore = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify(disabledBefore.pluginSyncExtendedSetting[existingAutomaticKey]),
|
||||||
|
JSON.stringify(existingAutomaticSelections[existingAutomaticKey]),
|
||||||
|
"The fixture did not retain the saved Automatic selection while Hidden File Sync was disabled."
|
||||||
|
);
|
||||||
|
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||||
|
const notice = page.locator(".notice:visible").filter({ hasText: noticeText }).last();
|
||||||
|
await notice.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
const initialActionMenu = page.locator(".menu:visible").filter({ hasText: "Initial Action" });
|
||||||
|
if ((await initialActionMenu.count()) !== 0) {
|
||||||
|
throw new Error("Automatic displayed an Initial Action menu while Hidden File Sync was disabled.");
|
||||||
|
}
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
|
||||||
|
await notice.click({ force: true });
|
||||||
|
await notice.waitFor({ state: "hidden", timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabledAfter = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||||
|
const contentsAfter = await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)));
|
||||||
|
if (JSON.stringify(disabledAfter) !== JSON.stringify(disabledBefore)) {
|
||||||
|
throw new Error(
|
||||||
|
`Automatic changed Hidden File Sync state while it was disabled: ${JSON.stringify({
|
||||||
|
before: disabledBefore,
|
||||||
|
after: disabledAfter,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify(contentsAfter),
|
||||||
|
JSON.stringify(contentsBefore),
|
||||||
|
"Automatic changed plug-in files while Hidden File Sync was disabled."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exercise the second guard: the user may open Initial Action while enabled,
|
||||||
|
// then disable Hidden File Sync before choosing a direction.
|
||||||
|
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||||
|
const initialActionMenu = page.locator(".menu:visible").filter({ hasText: "Initial Action" });
|
||||||
|
await initialActionMenu.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
});
|
||||||
|
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, false);
|
||||||
|
const beforeDisabledAction = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
await selectAutomaticInitialAction(page, "Overwrite Remote");
|
||||||
|
const notice = page.locator(".notice:visible").filter({ hasText: noticeText }).last();
|
||||||
|
await notice.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await notice.click({ force: true });
|
||||||
|
await notice.waitFor({ state: "hidden", timeout: 10000 });
|
||||||
|
});
|
||||||
|
const afterDisabledAction = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||||
|
if (JSON.stringify(afterDisabledAction) !== JSON.stringify(beforeDisabledAction)) {
|
||||||
|
throw new Error(
|
||||||
|
`Automatic changed Hidden File Sync state after it was disabled from Initial Action: ${JSON.stringify({
|
||||||
|
before: beforeDisabledAction,
|
||||||
|
after: afterDisabledAction,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify(await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)))),
|
||||||
|
JSON.stringify(contentsBefore),
|
||||||
|
"Automatic changed plug-in files after Hidden File Sync was disabled from Initial Action."
|
||||||
|
);
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||||
|
if (initialAction !== "Overwrite Remote") {
|
||||||
|
const fixtures = [
|
||||||
|
{
|
||||||
|
path: pluginStylesPath,
|
||||||
|
stored: pluginStylesContent,
|
||||||
|
local: targetPluginStylesContent,
|
||||||
|
storedIsNewer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: pluginDataPath,
|
||||||
|
stored: pluginDataContent,
|
||||||
|
local: targetPluginDataContent,
|
||||||
|
storedIsNewer: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const actualFiles = await readAutomaticFileContents(context, session);
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
const actual = actualFiles.find((file) => file.path === fixture.path);
|
||||||
|
if (
|
||||||
|
!actual ||
|
||||||
|
actual.stored !== fixture.stored ||
|
||||||
|
actual.local !== fixture.local ||
|
||||||
|
actual.storedMtime > actual.localMtime !== fixture.storedIsNewer ||
|
||||||
|
actual.storedMtime === actual.localMtime
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Automatic direction fixture did not retain distinct contents and modification times: ${JSON.stringify(actual)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||||
|
await selectAutomaticInitialAction(page, initialAction);
|
||||||
|
for (const label of ["MAIN", "DATA"]) {
|
||||||
|
const row = dialogue.locator(".filerow").filter({ hasText: label }).first();
|
||||||
|
await row.locator(".statusnote").filter({ hasText: "Automatic" }).waitFor({ state: "visible" });
|
||||||
|
}
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
|
||||||
|
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true });
|
||||||
|
}
|
||||||
|
const enabled = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||||
|
const mainMode = enabled.pluginSyncExtendedSetting[`PLUGIN_MAIN/${pluginName}`];
|
||||||
|
const dataMode = enabled.pluginSyncExtendedSetting[`PLUGIN_DATA/${pluginName}`];
|
||||||
|
if (mainMode?.mode !== MODE_AUTOMATIC || dataMode?.mode !== MODE_AUTOMATIC) {
|
||||||
|
throw new Error(
|
||||||
|
`Automatic did not save both plug-in group child settings: ${JSON.stringify({ mainMode, dataMode })}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify([...mainMode.files].sort()),
|
||||||
|
JSON.stringify([
|
||||||
|
`plugins/${pluginName}/main.js`,
|
||||||
|
`plugins/${pluginName}/manifest.json`,
|
||||||
|
`plugins/${pluginName}/styles.css`,
|
||||||
|
]),
|
||||||
|
"Automatic did not select the PLUGIN_MAIN files."
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify([...dataMode.files].sort()),
|
||||||
|
JSON.stringify([`plugins/${pluginName}/data.json`]),
|
||||||
|
"Automatic did not select the PLUGIN_DATA file."
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
enabled.hiddenFiles.filter((entry) => entry.path.startsWith(pluginDir + "/")).length,
|
||||||
|
4,
|
||||||
|
"Automatic did not initialise all files in the PLUGIN_ALL group."
|
||||||
|
);
|
||||||
|
const expectedFiles = [
|
||||||
|
{
|
||||||
|
path: pluginStylesPath,
|
||||||
|
content: initialAction === "Overwrite Remote" ? targetPluginStylesContent : pluginStylesContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: pluginDataPath,
|
||||||
|
content: initialAction === "Overwrite Local" ? pluginDataContent : targetPluginDataContent,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const deadline = Date.now() + 15000;
|
||||||
|
let actualFiles = await readAutomaticFileContents(context, session);
|
||||||
|
const contentsMatch = () =>
|
||||||
|
actualFiles.every(
|
||||||
|
(file, index) => file.stored === expectedFiles[index].content && file.local === expectedFiles[index].content
|
||||||
|
);
|
||||||
|
while (!contentsMatch() && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
actualFiles = await readAutomaticFileContents(context, session);
|
||||||
|
}
|
||||||
|
if (!contentsMatch()) {
|
||||||
|
throw new Error(`${initialAction} did not apply both MAIN and DATA: ${JSON.stringify(actualFiles)}`);
|
||||||
|
}
|
||||||
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
|
}
|
||||||
|
|
||||||
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
|
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
|
||||||
await rm(join(vaultPath, path), { force: true });
|
await rm(join(vaultPath, path), { force: true });
|
||||||
}
|
}
|
||||||
@@ -287,7 +592,8 @@ async function waitForPathContent(
|
|||||||
async function startConfiguredSession(
|
async function startConfiguredSession(
|
||||||
context: RunnerContext,
|
context: RunnerContext,
|
||||||
vault: TemporaryVault,
|
vault: TemporaryVault,
|
||||||
deviceName: string
|
deviceName: string,
|
||||||
|
settings: Partial<Pick<ObsidianLiveSyncSettings, "syncInternalFiles" | "pluginSyncExtendedSetting">> = {}
|
||||||
): Promise<ObsidianLiveSyncSession> {
|
): Promise<ObsidianLiveSyncSession> {
|
||||||
const couchDbSettings = {
|
const couchDbSettings = {
|
||||||
uri: context.couchDb.uri,
|
uri: context.couchDb.uri,
|
||||||
@@ -306,6 +612,7 @@ async function startConfiguredSession(
|
|||||||
autoSweepPlugins: false,
|
autoSweepPlugins: false,
|
||||||
autoSweepPluginsPeriodic: false,
|
autoSweepPluginsPeriodic: false,
|
||||||
syncInternalFiles: false,
|
syncInternalFiles: false,
|
||||||
|
...settings,
|
||||||
};
|
};
|
||||||
const session = await startObsidianLiveSyncSession({
|
const session = await startObsidianLiveSyncSession({
|
||||||
binary: context.binary,
|
binary: context.binary,
|
||||||
@@ -319,6 +626,18 @@ async function startConfiguredSession(
|
|||||||
});
|
});
|
||||||
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||||
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
|
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
|
||||||
|
if (Object.keys(settings.pluginSyncExtendedSetting ?? {}).length > 0) {
|
||||||
|
const restoredSelections = await evalObsidianJson<ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]>(
|
||||||
|
context.cliBinary,
|
||||||
|
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.pluginSyncExtendedSetting)",
|
||||||
|
session.cliEnv
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify(restoredSelections),
|
||||||
|
JSON.stringify(settings.pluginSyncExtendedSetting),
|
||||||
|
"The saved Automatic selection changed during start-up."
|
||||||
|
);
|
||||||
|
}
|
||||||
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, customisationSettings);
|
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, customisationSettings);
|
||||||
await evalObsidianJson<unknown>(
|
await evalObsidianJson<unknown>(
|
||||||
context.cliBinary,
|
context.cliBinary,
|
||||||
@@ -389,7 +708,7 @@ async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv,
|
|||||||
async function deleteCustomisationSyncEntry(
|
async function deleteCustomisationSyncEntry(
|
||||||
cliBinary: string,
|
cliBinary: string,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||||
name: string,
|
name: string,
|
||||||
term?: string
|
term?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -418,7 +737,7 @@ async function deleteCustomisationSyncEntry(
|
|||||||
async function waitForCustomisationEntry(
|
async function waitForCustomisationEntry(
|
||||||
cliBinary: string,
|
cliBinary: string,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||||
name: string,
|
name: string,
|
||||||
term?: string,
|
term?: string,
|
||||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
|
||||||
@@ -430,7 +749,7 @@ async function waitForCustomisationEntry(
|
|||||||
async function waitForCustomisationEntries(
|
async function waitForCustomisationEntries(
|
||||||
cliBinary: string,
|
cliBinary: string,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||||
name: string,
|
name: string,
|
||||||
count: number,
|
count: number,
|
||||||
term?: string,
|
term?: string,
|
||||||
@@ -570,10 +889,13 @@ async function applyRemoteCustomisationGroup(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function runWorkflow(
|
||||||
const testCase = selectedCase();
|
testCase: CustomisationSyncCase,
|
||||||
|
initialAction: AutomaticInitialAction = "Overwrite Remote"
|
||||||
|
): Promise<void> {
|
||||||
const testVisibility = testCase === "all" || testCase === "visibility";
|
const testVisibility = testCase === "all" || testCase === "visibility";
|
||||||
const testMtime = testCase === "all" || testCase === "mtime";
|
const testMtime = testCase === "all" || testCase === "mtime";
|
||||||
|
const testAutomatic = testCase === "automatic";
|
||||||
const binary = requireObsidianBinary();
|
const binary = requireObsidianBinary();
|
||||||
const cli = discoverObsidianCli();
|
const cli = discoverObsidianCli();
|
||||||
if (!cli.binary) {
|
if (!cli.binary) {
|
||||||
@@ -604,6 +926,11 @@ async function main(): Promise<void> {
|
|||||||
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
|
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
|
||||||
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
|
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
|
||||||
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
|
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
|
||||||
|
if (testAutomatic) {
|
||||||
|
await writeVaultFile(vaultA.path, pluginDataPath, pluginDataContent);
|
||||||
|
await setVaultFileMtime(vaultA.path, pluginStylesPath, targetPluginMtime);
|
||||||
|
await setVaultFileMtime(vaultA.path, pluginDataPath, sourcePluginMtime);
|
||||||
|
}
|
||||||
if (testVisibility) {
|
if (testVisibility) {
|
||||||
for (const fixture of visibilityFixtures) {
|
for (const fixture of visibilityFixtures) {
|
||||||
await writeVaultFile(vaultA.path, fixture.path, fixture.source);
|
await writeVaultFile(vaultA.path, fixture.path, fixture.source);
|
||||||
@@ -627,6 +954,14 @@ async function main(): Promise<void> {
|
|||||||
await setVaultFileMtime(vaultB.path, fixture.path, targetPluginMtime);
|
await setVaultFileMtime(vaultB.path, fixture.path, targetPluginMtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (testAutomatic) {
|
||||||
|
for (const fixture of pluginFixtures) {
|
||||||
|
await writeVaultFile(vaultB.path, fixture.path, fixture.target);
|
||||||
|
}
|
||||||
|
await writeVaultFile(vaultB.path, pluginDataPath, targetPluginDataContent);
|
||||||
|
await setVaultFileMtime(vaultB.path, pluginStylesPath, sourcePluginMtime);
|
||||||
|
await setVaultFileMtime(vaultB.path, pluginDataPath, targetPluginMtime);
|
||||||
|
}
|
||||||
|
|
||||||
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
|
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
|
||||||
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
|
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
|
||||||
@@ -636,6 +971,9 @@ async function main(): Promise<void> {
|
|||||||
for (const fixture of pluginFixtures) {
|
for (const fixture of pluginFixtures) {
|
||||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||||
}
|
}
|
||||||
|
if (testAutomatic) {
|
||||||
|
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginDataPath);
|
||||||
|
}
|
||||||
if (testVisibility) {
|
if (testVisibility) {
|
||||||
for (const fixture of visibilityFixtures) {
|
for (const fixture of visibilityFixtures) {
|
||||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||||
@@ -650,15 +988,35 @@ async function main(): Promise<void> {
|
|||||||
pluginName,
|
pluginName,
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
|
const pluginDataEntry = testAutomatic
|
||||||
|
? await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "PLUGIN_DATA", pluginName)
|
||||||
|
: undefined;
|
||||||
|
if (testAutomatic && initialAction !== "Overwrite Remote") {
|
||||||
|
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||||
|
await evalObsidianJson<unknown>(
|
||||||
|
context.cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
"const hidden=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');",
|
||||||
|
`const paths=${JSON.stringify([pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath])};`,
|
||||||
|
"const result=await hidden.initialiseInternalFileSync('pushForce',false,paths);",
|
||||||
|
"if(result!==true) throw new Error('Could not prepare source hidden-file documents.');",
|
||||||
|
"return JSON.stringify({initialised:result});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
session.cliEnv
|
||||||
|
);
|
||||||
|
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, false);
|
||||||
|
}
|
||||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
||||||
const ids = new Set(docs.map((doc) => doc._id));
|
const ids = new Set(docs.map((doc) => doc._id));
|
||||||
const entries = [entry, configEntry, ...pluginEntries];
|
const entries = [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])];
|
||||||
return entries.every(
|
return entries.every(
|
||||||
(target) => ids.has(target.id) && target.children.every((childId) => ids.has(childId))
|
(target) => ids.has(target.id) && target.children.every((childId) => ids.has(childId))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
for (const target of [entry, configEntry, ...pluginEntries]) {
|
for (const target of [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])]) {
|
||||||
const remoteEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, target.id);
|
const remoteEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, target.id);
|
||||||
if (
|
if (
|
||||||
!remoteEntry.path?.startsWith("/\\:") ||
|
!remoteEntry.path?.startsWith("/\\:") ||
|
||||||
@@ -678,8 +1036,15 @@ async function main(): Promise<void> {
|
|||||||
throw new Error("The remote feature list does not declare encrypted internal Metadata.");
|
throw new Error("The remote feature list does not declare encrypted internal Metadata.");
|
||||||
}
|
}
|
||||||
await session.app.stop();
|
await session.app.stop();
|
||||||
|
if (testAutomatic && initialAction === "Overwrite Remote") {
|
||||||
|
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
|
||||||
|
await removeVaultFile(vaultA.path, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
session = await startConfiguredSession(context, vaultB, targetDeviceName);
|
session = await startConfiguredSession(context, vaultB, targetDeviceName, {
|
||||||
|
pluginSyncExtendedSetting: testAutomatic ? existingAutomaticSelections : {},
|
||||||
|
});
|
||||||
if (testVisibility) {
|
if (testVisibility) {
|
||||||
for (const fixture of visibilityFixtures) {
|
for (const fixture of visibilityFixtures) {
|
||||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||||
@@ -692,6 +1057,15 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
|
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
|
||||||
|
if (testAutomatic) {
|
||||||
|
await waitForCustomisationEntry(
|
||||||
|
context.cliBinary,
|
||||||
|
session.cliEnv,
|
||||||
|
"PLUGIN_DATA",
|
||||||
|
pluginName,
|
||||||
|
sourceDeviceName
|
||||||
|
);
|
||||||
|
}
|
||||||
if (testVisibility) {
|
if (testVisibility) {
|
||||||
for (const fixture of visibilityFixtures) {
|
for (const fixture of visibilityFixtures) {
|
||||||
const name = fixture.path.split("/").pop() ?? fixture.path;
|
const name = fixture.path.split("/").pop() ?? fixture.path;
|
||||||
@@ -721,10 +1095,43 @@ async function main(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
if (testVisibility) await inspectCustomisationVisibility(session);
|
if (testVisibility) await inspectCustomisationVisibility(session);
|
||||||
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
|
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
|
||||||
|
if (testAutomatic) {
|
||||||
|
await inspectAutomaticSyncSelection(context, session, vaultB, pluginName, initialAction);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await session.app.stop().catch(() => undefined);
|
await session.app.stop().catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
if (testAutomatic) {
|
||||||
|
await session.app.stop();
|
||||||
|
const receivingVault = initialAction === "Overwrite Remote" ? vaultA : await createTemporaryVault();
|
||||||
|
session = await startConfiguredSession(context, receivingVault, sourceDeviceName, {
|
||||||
|
syncInternalFiles: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
|
const expectedStyles =
|
||||||
|
initialAction === "Overwrite Remote" ? targetPluginStylesContent : pluginStylesContent;
|
||||||
|
const expectedData = initialAction === "Overwrite Local" ? pluginDataContent : targetPluginDataContent;
|
||||||
|
const receivedStyles = await waitForPathContent(
|
||||||
|
receivingVault.path,
|
||||||
|
pluginStylesPath,
|
||||||
|
(content) => content === expectedStyles
|
||||||
|
);
|
||||||
|
const receivedData = await waitForPathContent(
|
||||||
|
receivingVault.path,
|
||||||
|
pluginDataPath,
|
||||||
|
(content) => content === expectedData
|
||||||
|
);
|
||||||
|
assertEqual(receivedStyles, expectedStyles, "Automatic plug-in group lost PLUGIN_MAIN files.");
|
||||||
|
assertEqual(receivedData, expectedData, "Automatic plug-in group lost the PLUGIN_DATA file.");
|
||||||
|
} finally {
|
||||||
|
await session.app.stop();
|
||||||
|
if (receivingVault !== vaultA) await receivingVault.dispose();
|
||||||
|
}
|
||||||
|
console.log(`Automatic ${initialAction} initialised and transferred both plug-in group children.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (testCase !== "all") {
|
if (testCase !== "all") {
|
||||||
await session.app.stop();
|
await session.app.stop();
|
||||||
console.log(`Customisation Sync ${testCase} regression case passed.`);
|
console.log(`Customisation Sync ${testCase} regression case passed.`);
|
||||||
@@ -841,6 +1248,18 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const testCase = selectedCase();
|
||||||
|
if (testCase === "automatic") {
|
||||||
|
for (const action of ["Overwrite Remote", "Overwrite Local", "Use newer"] as const) {
|
||||||
|
console.log(`Checking Automatic initial action: ${action}`);
|
||||||
|
await runWorkflow(testCase, action);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await runWorkflow(testCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
console.error(error instanceof Error ? error.stack : error);
|
console.error(error instanceof Error ? error.stack : error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -63,6 +63,21 @@ const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
|
|||||||
const targetPath = ".obsidian/livesync-targeted/only-a.json";
|
const targetPath = ".obsidian/livesync-targeted/only-a.json";
|
||||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||||
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
|
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
|
||||||
|
const disabledInitialisationProbeKey = "__livesyncE2EDisabledHiddenInitialisation";
|
||||||
|
|
||||||
|
type DisabledInitialisationSnapshot = {
|
||||||
|
syncInternalFiles: boolean;
|
||||||
|
hiddenFiles: { id: string; path: string; rev: string; deleted: boolean }[];
|
||||||
|
processedFiles: [string, string | undefined][];
|
||||||
|
processedDatabaseFiles: [string, string | undefined][];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DisabledInitialisationObservation = {
|
||||||
|
result: boolean | null;
|
||||||
|
before: DisabledInitialisationSnapshot;
|
||||||
|
after: DisabledInitialisationSnapshot;
|
||||||
|
progressNotices: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type RunnerContext = {
|
type RunnerContext = {
|
||||||
binary: string;
|
binary: string;
|
||||||
@@ -133,6 +148,114 @@ async function waitForPathDeleted(
|
|||||||
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
|
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertDisabledInitialisationIsNoOp(
|
||||||
|
context: RunnerContext,
|
||||||
|
session: ObsidianLiveSyncSession,
|
||||||
|
vault: TemporaryVault
|
||||||
|
): Promise<void> {
|
||||||
|
const snippetExisted = await pathExists(vault.path, snippetPath);
|
||||||
|
const originalSnippet = snippetExisted ? await readVaultFile(vault.path, snippetPath) : undefined;
|
||||||
|
const originalJson = await readVaultFile(vault.path, mergeJsonPath);
|
||||||
|
const preservedSnippet = "body { --disabled-hidden-initialisation: preserved; }\n";
|
||||||
|
const preservedJson = JSON.stringify({ disabledInitialisation: "preserved" }, null, 4) + "\n";
|
||||||
|
await writeVaultFile(vault.path, snippetPath, preservedSnippet);
|
||||||
|
await writeVaultFile(vault.path, mergeJsonPath, preservedJson);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
await page.evaluate((stateKey) => {
|
||||||
|
const state = { progressNotices: [] as string[] };
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
for (const notice of document.querySelectorAll<HTMLElement>(".notice")) {
|
||||||
|
const text = notice.textContent?.trim() ?? "";
|
||||||
|
if (text.includes("[⚙") && !state.progressNotices.includes(text)) {
|
||||||
|
state.progressNotices.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||||
|
(globalThis as unknown as Record<string, { progressNotices: string[]; observer: MutationObserver }>)[
|
||||||
|
stateKey
|
||||||
|
] = { ...state, observer };
|
||||||
|
}, disabledInitialisationProbeKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const observation = await evalObsidianJson<DisabledInitialisationObservation>(
|
||||||
|
context.cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
`const targetFiles=${JSON.stringify([snippetPath, mergeJsonPath])};`,
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||||
|
"const snapshot=async()=>{",
|
||||||
|
"const files=await addOn.getAllDatabaseFiles();",
|
||||||
|
"const markers=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
|
||||||
|
"return {",
|
||||||
|
"syncInternalFiles:core.settings.syncInternalFiles,",
|
||||||
|
"hiddenFiles:files.map((entry)=>({id:entry._id,path:addOn.getPath(entry),rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)})).sort((a,b)=>a.path.localeCompare(b.path)),",
|
||||||
|
"processedFiles:markers(addOn._fileInfoLastProcessed),",
|
||||||
|
"processedDatabaseFiles:markers(addOn._databaseInfoLastProcessed),",
|
||||||
|
"};};",
|
||||||
|
"const before=await snapshot();",
|
||||||
|
"const result=await addOn.initialiseInternalFileSync('pullForce',true,targetFiles);",
|
||||||
|
"const after=await snapshot();",
|
||||||
|
"return JSON.stringify({result:result===undefined?null:result,before,after});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
session.cliEnv,
|
||||||
|
hiddenFileCliTimeoutMs
|
||||||
|
);
|
||||||
|
const progressNotices = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
return await page.evaluate((stateKey) => {
|
||||||
|
const state = (
|
||||||
|
globalThis as unknown as Record<
|
||||||
|
string,
|
||||||
|
{ progressNotices: string[]; observer: MutationObserver } | undefined
|
||||||
|
>
|
||||||
|
)[stateKey];
|
||||||
|
state?.observer.disconnect();
|
||||||
|
return state?.progressNotices ?? [];
|
||||||
|
}, disabledInitialisationProbeKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const failures: string[] = [];
|
||||||
|
if (observation.result !== false)
|
||||||
|
failures.push(`expected false result, received ${String(observation.result)}`);
|
||||||
|
if (observation.before.syncInternalFiles !== false || observation.after.syncInternalFiles !== false) {
|
||||||
|
failures.push("Hidden File Sync setting changed while initialisation was disabled");
|
||||||
|
}
|
||||||
|
if (JSON.stringify(observation.before) !== JSON.stringify(observation.after)) {
|
||||||
|
failures.push("local hidden database state or processed markers changed");
|
||||||
|
}
|
||||||
|
if ((await readVaultFile(vault.path, snippetPath)) !== preservedSnippet) {
|
||||||
|
failures.push("the existing local snippet was deleted or replaced");
|
||||||
|
}
|
||||||
|
if ((await readVaultFile(vault.path, mergeJsonPath)) !== preservedJson) {
|
||||||
|
failures.push("the existing local JSON file was replaced");
|
||||||
|
}
|
||||||
|
if (progressNotices.length > 0)
|
||||||
|
failures.push(`initialisation created progress Notices: ${JSON.stringify(progressNotices)}`);
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Disabled Hidden File Sync initialisation was not a no-op: ${failures.join("; ")}\n${JSON.stringify({
|
||||||
|
observation,
|
||||||
|
progressNotices,
|
||||||
|
})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"Disabled Hidden File Sync initialisation returned false without changing hidden files or cached state."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await writeVaultFile(vault.path, mergeJsonPath, originalJson);
|
||||||
|
if (snippetExisted && originalSnippet !== undefined) {
|
||||||
|
await writeVaultFile(vault.path, snippetPath, originalSnippet);
|
||||||
|
} else {
|
||||||
|
await removeVaultFile(vault.path, snippetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hasJsonValues(content: string, values: Record<string, unknown>): boolean {
|
function hasJsonValues(content: string, values: Record<string, unknown>): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||||
@@ -649,6 +772,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
|
|||||||
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
|
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await assertDisabledInitialisationIsNoOp(context, session, vault);
|
||||||
await withObsidianPage(port, async (page) => {
|
await withObsidianPage(port, async (page) => {
|
||||||
await page.evaluate((stateKey) => {
|
await page.evaluate((stateKey) => {
|
||||||
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
|
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
### Synchronisation
|
### Synchronisation
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- We receive a clear Notice when **Automatic** requires Hidden File Sync to be enabled on this device. Our existing mode remains unchanged, and hidden-file initialisation cannot start while the feature is disabled. (#1270)
|
||||||
|
- We can initialise a whole plug-in's **MAIN** and **DATA** selections using the chosen Automatic initial action.
|
||||||
- We can run startup integrity checks with lower memory use for healthy Vault files. Files that change between the size check and content reading no longer trigger an unnecessary repair warning when their actual sizes agree and there are no conflicts. (#1283)
|
- We can run startup integrity checks with lower memory use for healthy Vault files. Files that change between the size check and content reading no longer trigger an unnecessary repair warning when their actual sizes agree and there are no conflicts. (#1283)
|
||||||
|
|
||||||
## 1.0.40
|
## 1.0.40
|
||||||
|
|||||||
Reference in new issue
Block a user