mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 02:23:22 +00:00
Fix Automatic mode prerequisites and plug-in initialisation targets
This commit is contained in:
1 parent
ce0524d49f
commit
e83833d4e8
12 files changed
+735
-19
No files matched your search
@@ -783,6 +783,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
|
||||||
|
|||||||
@@ -68,6 +68,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.",
|
||||||
"Preparing follow-up report…": "Preparing follow-up report…",
|
"Preparing follow-up report…": "Preparing follow-up report…",
|
||||||
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
|
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
|
||||||
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
|
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -248,10 +248,14 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
|||||||
|
|
||||||
`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 migration from legacy remote settings 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 action Notice whose controls remain usable in mobile layouts; 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 migration from legacy remote settings 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 action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
`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,10 @@
|
|||||||
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,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
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 +27,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 +64,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 +121,7 @@ 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 CustomisationSyncTestGlobal = typeof globalThis & {
|
type CustomisationSyncTestGlobal = typeof globalThis & {
|
||||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||||
@@ -131,6 +148,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 +172,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 +270,196 @@ 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: "Overwrite Remote" | "Overwrite Local"): 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 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
|
||||||
|
): 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);
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||||
|
await selectAutomaticInitialAction(page, "Overwrite Remote");
|
||||||
|
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."
|
||||||
|
);
|
||||||
|
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 +503,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 +523,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 +537,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 +619,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 +648,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 +660,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,
|
||||||
@@ -574,6 +804,7 @@ async function main(): Promise<void> {
|
|||||||
const testCase = selectedCase();
|
const testCase = selectedCase();
|
||||||
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 +835,7 @@ 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);
|
||||||
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 +859,12 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
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 +874,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 +891,18 @@ async function main(): Promise<void> {
|
|||||||
pluginName,
|
pluginName,
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
|
const pluginDataEntry = testAutomatic
|
||||||
|
? await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "PLUGIN_DATA", pluginName)
|
||||||
|
: undefined;
|
||||||
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 +922,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) {
|
||||||
|
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 +943,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 +981,42 @@ 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);
|
||||||
} 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();
|
||||||
|
session = await startConfiguredSession(context, vaultA, sourceDeviceName, { syncInternalFiles: true });
|
||||||
|
try {
|
||||||
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
|
const receivedStyles = await waitForPathContent(
|
||||||
|
vaultA.path,
|
||||||
|
pluginStylesPath,
|
||||||
|
(content) => content === targetPluginStylesContent
|
||||||
|
);
|
||||||
|
const receivedData = await waitForPathContent(
|
||||||
|
vaultA.path,
|
||||||
|
pluginDataPath,
|
||||||
|
(content) => content === targetPluginDataContent
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
receivedStyles,
|
||||||
|
targetPluginStylesContent,
|
||||||
|
"Automatic plug-in group lost PLUGIN_MAIN files."
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
receivedData,
|
||||||
|
targetPluginDataContent,
|
||||||
|
"Automatic plug-in group lost the PLUGIN_DATA file."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await session.app.stop();
|
||||||
|
}
|
||||||
|
console.log("Automatic Customisation Sync 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.`);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -132,6 +147,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>;
|
||||||
@@ -633,6 +756,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;
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
## 1.0.39
|
## 1.0.39
|
||||||
|
|
||||||
20th October, 2026
|
20th October, 2026
|
||||||
|
|||||||
Reference in new issue
Block a user