mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 11:33:21 +00:00
Merge main and validate local saves with Commonlib 0.2.1
This commit is contained in:
commit
6d289a8824
26 files changed
+1858
-83
No files matched your search
@@ -806,6 +806,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.
|
||||
|
||||
**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
|
||||
|
||||
#### Device name
|
||||
|
||||
@@ -24,7 +24,7 @@ export secret_key=<SECRET KEY>
|
||||
export bucket=vault-data
|
||||
export region=auto
|
||||
export bucket_prefix=my-vault
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
@@ -128,6 +128,9 @@ export database=obsidiannotes
|
||||
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> In fish, `hostname` is read-only and cannot be exported. Pass the values through `env` instead: `curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | env hostname=http://localhost:5984 username=<...> password=<...> database=obsidiannotes bash`.
|
||||
|
||||
If it results like the following:
|
||||
```
|
||||
CouchDB provisioning completed.
|
||||
@@ -173,20 +176,20 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
|
||||
|
||||
### 1. Generate the setup URI on a desktop device or server
|
||||
|
||||
The `username` and `password` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
|
||||
The `LIVESYNC_USER` and `LIVESYNC_PASS` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
|
||||
|
||||
```bash
|
||||
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com
|
||||
export database=obsidiannotes
|
||||
export username=<INSERT COUCHDB USERNAME FOR LIVESYNC>
|
||||
export password=<INSERT THE COUCHDB PASSWORD>
|
||||
export passphrase=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_HOST=https://tiles-photograph-routine-groundwater.trycloudflare.com
|
||||
export LIVESYNC_DB=obsidiannotes
|
||||
export LIVESYNC_USER=<INSERT COUCHDB USERNAME FOR LIVESYNC>
|
||||
export LIVESYNC_PASS=<INSERT THE COUCHDB PASSWORD>
|
||||
export LIVESYNC_PASSPHRASE=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<INSERT A SEPARATE SETUP URI PASSPHRASE> # Optional
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> `passphrase` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel.
|
||||
> `LIVESYNC_PASSPHRASE` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel.
|
||||
>
|
||||
> If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once.
|
||||
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ export remote_type=p2p
|
||||
export p2p_relays=wss://relay.example.com
|
||||
export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted
|
||||
export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
Generated
+9
-9
@@ -23,13 +23,13 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.1",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it": "^14.3.3",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
@@ -4116,9 +4116,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.0.tgz",
|
||||
"integrity": "sha512-K24Pkke+FTjZI7DjIV7qkZk+BmC9VebN3da+2cVD9SWOgoGl7OvDyXTHDafbrmmM4vi4ydaydAEh9glO1xHF6A==",
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.1.tgz",
|
||||
"integrity": "sha512-fnQb2FcEG+NqIQX9KhTstea4B7C4VAL0g9ry5rp41qnJDuLaR5zfCz995L+l3Khyrp/9kcPw4VX5RUmV+8GVog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
@@ -4134,7 +4134,7 @@
|
||||
"events": "^3.3.0",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it": "^14.3.3",
|
||||
"minimatch": "^10.2.6",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
@@ -8263,9 +8263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.3.2",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz",
|
||||
"integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==",
|
||||
"version": "14.3.3",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
|
||||
"integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
+4
-2
@@ -96,7 +96,9 @@
|
||||
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
||||
"test:e2e:obsidian:hidden-file-selectors": "tsx test/e2e-obsidian/scripts/hidden-file-selectors.ts",
|
||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||
"test:e2e:obsidian:customisation-catalogue-reload": "tsx test/e2e-obsidian/scripts/customisation-catalogue-reload.ts",
|
||||
"test:e2e:obsidian:optional-file-save-completion": "tsx test/e2e-obsidian/scripts/optional-file-save-completion.ts",
|
||||
"test:e2e:obsidian:livesync-batch": "tsx test/e2e-obsidian/scripts/issue1207-livesync-batch.ts",
|
||||
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
||||
"test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts",
|
||||
"test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts",
|
||||
@@ -197,13 +199,13 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.1",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it": "^14.3.3",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
* remove it from this map in the same change.
|
||||
*/
|
||||
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) Skip changes to matching normal and hidden files, locally and remotely.":
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: (path: string) => path,
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor {} }));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
|
||||
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
import { ConfigSync, PluginDataExDisplayV2, pluginManifests } from "./CmdConfigSync";
|
||||
|
||||
const filenames = ["main.js", "manifest.json", "styles.css"];
|
||||
const content: Record<string, string> = {
|
||||
"main.js": "module.exports = class ExamplePlugin {};",
|
||||
"manifest.json": JSON.stringify({ id: "example", name: "Example", version: "1.0.0" }),
|
||||
"styles.css": ".example { color: red; }",
|
||||
};
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function catalogue() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.syncOnlyRegEx = "\\.(?:md|png|pdf)$" as typeof fixture.settings.syncOnlyRegEx;
|
||||
const paths = filenames.map((name) => `ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix);
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const wireContent = `fixture-header\u200b\n\u200c${btoa(content[filenames[index]])}`;
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, wireContent))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
const gates = new Map(paths.map((path) => [path, deferred()]));
|
||||
const readEntry = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
|
||||
await gates.get(path)?.promise;
|
||||
return readEntry(path, ...args);
|
||||
});
|
||||
const written = new Map<string, string>();
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
localDatabase: fixture.localDatabase,
|
||||
settings: fixture.settings,
|
||||
services: fixture.services,
|
||||
storageAccess: {
|
||||
ensureDir: vi.fn(async () => true),
|
||||
readHiddenFileText: vi.fn(async (path: string) => written.get(path) ?? ""),
|
||||
writeHiddenFileAuto: vi.fn(async (path: string, data: string) => {
|
||||
written.set(path, data);
|
||||
}),
|
||||
},
|
||||
},
|
||||
pluginList: [],
|
||||
pluginListGeneration: 0,
|
||||
loadedManifest_mTime: new Map(),
|
||||
updatingV2Count: 0,
|
||||
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
|
||||
_log: vi.fn(),
|
||||
storeCustomisationFileV2: vi.fn(async () => true),
|
||||
});
|
||||
const updates: Promise<void>[] = [];
|
||||
const update = configSync.updatePluginListV2.bind(configSync);
|
||||
vi.spyOn(configSync, "updatePluginListV2").mockImplementation((...args) => {
|
||||
const promise = update(...args);
|
||||
updates.push(promise);
|
||||
return promise;
|
||||
});
|
||||
const release = (order = filenames) => {
|
||||
for (const name of order) gates.get(`ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix)!.resolve();
|
||||
};
|
||||
return { ...fixture, configSync, updates, paths, release, written };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
pluginManifests.clear();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("issue #1271: rebuilding a multi-file Customisation Sync item", () => {
|
||||
it.each([
|
||||
{ order: filenames },
|
||||
{ order: [...filenames].reverse() },
|
||||
{ order: ["manifest.json", "main.js", "styles.css"] },
|
||||
])("retains all per-file documents when reads are released as $order", async ({ order }) => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release(order);
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
const documents = await fixture.database.allDocs({ keys: fixture.paths });
|
||||
expect(documents.rows).toHaveLength(3);
|
||||
expect(fixture.configSync.pluginList).toHaveLength(1);
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("applies all three files after the first reload", async () => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
await expect(
|
||||
fixture.configSync.applyDataV2(fixture.configSync.pluginList[0] as PluginDataExDisplayV2)
|
||||
).resolves.toBe(true);
|
||||
expect([...fixture.written.keys()].sort()).toEqual(
|
||||
filenames.map((name) => `.obsidian/plugins/example/${name}`).sort()
|
||||
);
|
||||
for (const name of filenames)
|
||||
expect(fixture.written.get(`.obsidian/plugins/example/${name}`)).toBe(content[name]);
|
||||
});
|
||||
|
||||
it("fills an existing row during a second enumeration", async () => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(fixture.updates);
|
||||
await fixture.configSync.updatePluginList(false);
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("retains all files when item updates run sequentially", async () => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
|
||||
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("updates a different item while another item's reads are pending", async () => {
|
||||
const fixture = await catalogue();
|
||||
const otherPath = "ix:device/PLUGIN_MAIN/other%main.js" as FilePathWithPrefix;
|
||||
await fixture.localDatabase.putDBEntry(
|
||||
optionalFileEntry(otherPath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
|
||||
);
|
||||
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
|
||||
try {
|
||||
await fixture.configSync.updatePluginListV2(false, otherPath);
|
||||
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["other"]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(3);
|
||||
} finally {
|
||||
fixture.release();
|
||||
await Promise.all(pending);
|
||||
}
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it("continues updating an item after an earlier read fails", async () => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockRejectedValueOnce(new Error("Read failed"));
|
||||
const failed = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
|
||||
const next = fixture.configSync.updatePluginListV2(false, fixture.paths[1]);
|
||||
|
||||
await expect(failed).rejects.toThrow("Read failed");
|
||||
await next;
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
|
||||
"plugins/example/manifest.json",
|
||||
]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["read", "file update"])("reload discards cached files while an earlier %s is pending", async (phase) => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
|
||||
const earlierRead = deferred();
|
||||
const freshRead = deferred();
|
||||
const entered = deferred();
|
||||
const createFile = fixture.configSync.createPluginDataExFileV2.bind(fixture.configSync);
|
||||
let reads = 0;
|
||||
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockImplementation(async (path) => {
|
||||
if (reads++ === 0) {
|
||||
if (phase === "read") {
|
||||
entered.resolve();
|
||||
await earlierRead.promise;
|
||||
}
|
||||
} else {
|
||||
await freshRead.promise;
|
||||
}
|
||||
return createFile(path);
|
||||
});
|
||||
if (phase === "file update") {
|
||||
const entry = fixture.configSync.pluginList[0] as PluginDataExDisplayV2;
|
||||
const setFile = entry.setFile.bind(entry);
|
||||
vi.spyOn(entry, "setFile").mockImplementationOnce(async (file) => {
|
||||
entered.resolve();
|
||||
await earlierRead.promise;
|
||||
await setFile(file);
|
||||
});
|
||||
}
|
||||
const pending = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
|
||||
try {
|
||||
await entered.promise;
|
||||
for (const path of fixture.paths.slice(1)) await fixture.database.remove(await fixture.database.get(path));
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
earlierRead.resolve();
|
||||
await pending;
|
||||
} finally {
|
||||
earlierRead.resolve();
|
||||
freshRead.resolve();
|
||||
await Promise.all(fixture.updates);
|
||||
}
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
|
||||
"plugins/example/main.js",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clearing the disabled feature prevents pending updates from repopulating the list", async () => {
|
||||
const fixture = await catalogue();
|
||||
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
|
||||
fixture.settings.usePluginSync = false;
|
||||
await fixture.configSync.updatePluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(pending);
|
||||
|
||||
expect(fixture.configSync.pluginList).toEqual([]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it("a superseded enumeration cannot reintroduce a deleted item", async () => {
|
||||
const fixture = await catalogue();
|
||||
const obsoletePath = "ix:device/PLUGIN_MAIN/obsolete%main.js" as FilePathWithPrefix;
|
||||
await fixture.localDatabase.putDBEntry(
|
||||
optionalFileEntry(obsoletePath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
|
||||
);
|
||||
const enumerate = fixture.localDatabase.findEntries.bind(fixture.localDatabase);
|
||||
type EnumeratedEntry = ReturnType<typeof enumerate> extends AsyncGenerator<infer Entry> ? Entry : never;
|
||||
const obsoleteDocs: EnumeratedEntry[] = [];
|
||||
for await (const doc of enumerate(obsoletePath, `${obsoletePath}\u{10ffff}`, { include_docs: true })) {
|
||||
obsoleteDocs.push(doc);
|
||||
}
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
let scans = 0;
|
||||
vi.spyOn(fixture.localDatabase, "findEntries").mockImplementation((...args) => {
|
||||
if (scans++ !== 0) return enumerate(...args);
|
||||
return (async function* () {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
yield* obsoleteDocs;
|
||||
})();
|
||||
});
|
||||
const pending = fixture.configSync.updatePluginList(false);
|
||||
try {
|
||||
await entered.promise;
|
||||
await fixture.database.remove(await fixture.database.get(obsoletePath));
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
} finally {
|
||||
release.resolve();
|
||||
fixture.release();
|
||||
await pending;
|
||||
await Promise.all(fixture.updates);
|
||||
}
|
||||
|
||||
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["example"]);
|
||||
expect(fixture.configSync.pluginList[0].files).toHaveLength(3);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -416,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
|
||||
|
||||
pluginList: IPluginDataExDisplay[] = [];
|
||||
private pluginListGeneration = 0;
|
||||
showPluginSyncModal() {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
return;
|
||||
@@ -536,6 +537,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async reloadPluginList(showMessage: boolean) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginList = [];
|
||||
this.loadedManifest_mTime.clear();
|
||||
pluginList.set(this.pluginList);
|
||||
@@ -765,39 +767,46 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
updatingV2Count = 0;
|
||||
|
||||
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
||||
const generation = this.pluginListGeneration;
|
||||
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
|
||||
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
|
||||
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
||||
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
||||
await serialized(`config:catalogue:${pathV1}`, async () => {
|
||||
if (!isCurrent()) return;
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
||||
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
||||
|
||||
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
|
||||
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
|
||||
if (newEntry) {
|
||||
entry = newEntry;
|
||||
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
|
||||
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
|
||||
if (newEntry) {
|
||||
entry = newEntry;
|
||||
}
|
||||
} else if (oldEntry instanceof PluginDataExDisplayV2) {
|
||||
entry = oldEntry;
|
||||
}
|
||||
} else if (oldEntry instanceof PluginDataExDisplayV2) {
|
||||
entry = oldEntry;
|
||||
}
|
||||
if (!entry) return;
|
||||
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
||||
if (file) {
|
||||
await entry.setFile(file);
|
||||
} else {
|
||||
entry.deleteFile(unifiedFilenameWithKey);
|
||||
if (entry.files.length == 0) {
|
||||
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
||||
if (!entry) return;
|
||||
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
||||
if (!isCurrent()) return;
|
||||
if (file) {
|
||||
await entry.setFile(file);
|
||||
} else {
|
||||
entry.deleteFile(unifiedFilenameWithKey);
|
||||
if (entry.files.length == 0) {
|
||||
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
||||
newList.push(entry);
|
||||
this.pluginList = newList;
|
||||
if (!isCurrent()) return;
|
||||
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
||||
newList.push(entry);
|
||||
this.pluginList = newList;
|
||||
|
||||
scheduleTask("updatePluginListV2", 100, () => {
|
||||
pluginList.set(this.pluginList);
|
||||
scheduleTask("updatePluginListV2", 100, () => {
|
||||
pluginList.set(this.pluginList);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
this.updatingV2Count--;
|
||||
@@ -875,11 +884,13 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
|
||||
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginScanProcessor.clearQueue();
|
||||
this.pluginList = [];
|
||||
pluginList.set(this.pluginList);
|
||||
return;
|
||||
}
|
||||
const generation = this.pluginListGeneration;
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
@@ -892,6 +903,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
})
|
||||
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
||||
for await (const v of plugins) {
|
||||
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
|
||||
if (v.deleted || v._deleted) continue;
|
||||
if (v.path.indexOf("%") !== -1) {
|
||||
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
||||
|
||||
@@ -20,10 +20,14 @@
|
||||
MODE_SHINY,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
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 type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -139,6 +143,11 @@
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
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) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
||||
@@ -149,6 +158,7 @@
|
||||
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
||||
.onClick((e) => {
|
||||
if (mode === MODE_AUTOMATIC) {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
askOverwriteModeForAutomatic(evt, key);
|
||||
} else {
|
||||
setMode(key, mode as SYNC_MODE);
|
||||
@@ -161,10 +171,11 @@
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
setMode(key, MODE_AUTOMATIC);
|
||||
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
|
||||
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
|
||||
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
|
||||
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,9 @@ type HiddenFileInitialisationProgress = {
|
||||
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
||||
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(
|
||||
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
|
||||
includeDeleted = false
|
||||
@@ -1529,6 +1532,11 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?: HiddenFileInitialisationProgress
|
||||
) {
|
||||
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);
|
||||
// p.log("Resolving conflicts before starting...");
|
||||
// await this.resolveConflictOnInternalFiles();
|
||||
@@ -1579,6 +1587,7 @@ Offline Changed files: ${files.length}`;
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
p.done();
|
||||
return true;
|
||||
}
|
||||
// <-- Initialization functions
|
||||
|
||||
@@ -2059,6 +2068,7 @@ Offline Changed files: ${files.length}`;
|
||||
|
||||
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
|
||||
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
|
||||
let initialised = false;
|
||||
let result: ConfigureHiddenFileSyncResult;
|
||||
try {
|
||||
result = await configureHiddenFileSyncMode(mode, {
|
||||
@@ -2088,7 +2098,7 @@ Offline Changed files: ${files.length}`;
|
||||
);
|
||||
},
|
||||
initialise: async (direction) => {
|
||||
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialisationProgress = undefined;
|
||||
},
|
||||
});
|
||||
@@ -2096,7 +2106,7 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?.done("Failed");
|
||||
throw error;
|
||||
}
|
||||
if (result == "ignored" || result == "disabled") {
|
||||
if (result == "ignored" || result == "disabled" || !initialised) {
|
||||
return;
|
||||
}
|
||||
// this.plugin.settings.useAdvancedMode = true;
|
||||
|
||||
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
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.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed,
|
||||
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
scanAllDatabaseChanges,
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||
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 () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const applyPartial = vi.fn(async () => {
|
||||
events.push("apply-settings");
|
||||
});
|
||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
||||
const initialiseInternalFileSync = vi.fn(async () => true);
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
|
||||
@@ -127,7 +127,7 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
`test:e2e:obsidian:joined-devices` uses a temporary Vault and isolated CouchDB database to open Joined devices from Remote Configuration, a version Notice, and its registered command. `test:e2e:obsidian:joined-devices-object-storage` runs the same assertions against a unique Object Storage prefix, including the conditional removal supported by the test S3 service. It checks that newer and older Self-hosted LiveSync versions share one Notice with one recommendation and review action, that the current device appears first with current metadata, and that locked-out and other-device records are shown. It confirms that a manual refresh reads an updated remote record and that cancelling the inline confirmation leaves the remote unchanged before forgetting one device. It verifies the raw milestone retains the other device, remote state, and preferred-ID tweaks, then checks mobile scrolling, safe-area bounds, and touch targets. It captures the version Notice, desktop list, forget confirmation, completed removal, and mobile dialogue; unrelated Notices are dismissed before the list captures.
|
||||
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. It also checks that batching starts disabled, that each Sync Mode shows its own batching choice and the appropriate shared delay controls, and that switching modes and reopening settings preserves both choices. It reads the saved plug-in data to confirm that the choices are persisted independently. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
|
||||
The default runner uses the maintained pre-1.13 Obsidian fixture and therefore exercises the complete compatibility-review, mobile-layout, and imperative-settings path. To exercise only the native settings contract against an additional Obsidian 1.13-or-later installation, supply its executable and companion CLI explicitly:
|
||||
|
||||
@@ -252,10 +252,18 @@ 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.
|
||||
|
||||
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-catalogue-reload` opens the Customisation Sync dialogue, enables Maintenance mode, and clicks Reload. It verifies that this first Reload lists all three files of a sample plug-in, then selects the source copy and clicks Apply to restore their contents. It also checks a second enumeration and Apply through the existing methods in one temporary Vault with a local database. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_CUSTOMISATION_CATALOGUE_OUTPUT` to choose the JSON report path.
|
||||
|
||||
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:livesync-batch` uses two real Obsidian Vaults and a disposable local CouchDB to compare batching disabled, the older `batchSave` choice alone, and the explicit LiveSync batching choice. It covers repeated edits in both directions, template-like writes, deletion and recreation, incoming changes, maximum delay, concurrent paths, manual flush, and restart recovery before and after a database write. Restarts use the saved profile with natural plug-in start-up. `ISSUE1207_CASE_FILTER` selects case names; `ISSUE1207_RESTART_BUCKET=same|exact|different` exercises interrupted processing at the selected timestamp condition. `ISSUE1207_RESTART_SAVE_AFTER_EDIT=true` separately checks a settings save after the pending edit. Use `npm run test:e2e:obsidian:focused -- livesync-batch` to build and run the default matrix. This scenario requires a localhost CouchDB and is outside the complete local suite.
|
||||
|
||||
`test:e2e:obsidian:optional-file-save-completion` exercises Hidden File Sync and both Customisation Sync formats in one isolated real Obsidian Vault. It holds a local database write to verify that its event stays in the snapshot and that flush waits for the write, then checks unchanged notifications, subsequent edits, and repeated deletions. Injected save failures remain pending and must replay successfully before the next start reaches readiness. This local scenario disables replication and requires no remote service. It 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`.
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const pluginName = "livesync-catalogue-example";
|
||||
const sourceTerm = "catalogue-source";
|
||||
const pluginRoot = `.obsidian/plugins/${pluginName}`;
|
||||
const pluginFiles = {
|
||||
[`${pluginRoot}/main.js`]: "module.exports = class ExamplePlugin {};\n",
|
||||
[`${pluginRoot}/manifest.json`]: JSON.stringify({
|
||||
id: pluginName,
|
||||
name: "Catalogue Example",
|
||||
version: "1.0.0",
|
||||
minAppVersion: "1.0.0",
|
||||
description: "An isolated test fixture",
|
||||
author: "Self-hosted LiveSync",
|
||||
}),
|
||||
[`${pluginRoot}/styles.css`]: ".livesync-catalogue-example { color: red; }\n",
|
||||
};
|
||||
const allowList = "\\.(?:md|png|pdf)$";
|
||||
const coreCode = "const core=app.plugins.plugins['obsidian-livesync'].core;";
|
||||
const configCode = `${coreCode}const addOn=core.getAddOn('ConfigSync');`;
|
||||
const settleCode = `
|
||||
const settle=async()=>{
|
||||
await new Promise(resolve=>setTimeout(resolve,100));
|
||||
for(let attempt=0;attempt<200;attempt++){
|
||||
if(addOn.updatingV2Count===0)return;
|
||||
await new Promise(resolve=>setTimeout(resolve,25));
|
||||
}
|
||||
throw new Error('Customisation Sync did not settle');
|
||||
};
|
||||
`;
|
||||
const sourceRowCode = `
|
||||
const row=addOn.pluginList.find(entry=>entry.category==='PLUGIN_MAIN'&&entry.name===${JSON.stringify(pluginName)}&&entry.term===${JSON.stringify(sourceTerm)});
|
||||
if(!row)throw new Error('The source catalogue row is missing');
|
||||
`;
|
||||
|
||||
type Observation = { name: string; expected: unknown; actual: unknown; passed: boolean };
|
||||
|
||||
async function writeFixtures(vaultPath: string, files: Record<string, string>) {
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
await mkdir(dirname(join(vaultPath, path)), { recursive: true });
|
||||
await writeFile(join(vaultPath, path), content);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFixtures(vaultPath: string, files: Record<string, string>) {
|
||||
for (const path of Object.keys(files)) await rm(join(vaultPath, path), { force: true });
|
||||
}
|
||||
|
||||
async function matchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||
const matches: string[] = [];
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
try {
|
||||
if ((await readFile(join(vaultPath, path), "utf8")) === content) matches.push(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return matches.sort();
|
||||
}
|
||||
|
||||
async function waitForMatchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
let matches = await matchingFiles(vaultPath, files);
|
||||
while (matches.length !== Object.keys(files).length && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
matches = await matchingFiles(vaultPath, files);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error("The Obsidian CLI is unavailable");
|
||||
const cliBinary = cli.binary;
|
||||
const output =
|
||||
process.env.E2E_CUSTOMISATION_CATALOGUE_OUTPUT ?? join(tmpdir(), "livesync-customisation-catalogue.json");
|
||||
const observations: Observation[] = [];
|
||||
const report: Record<string, unknown> = {
|
||||
revision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||
sourceDiffSha256: createHash("sha256")
|
||||
.update(new Uint8Array(execFileSync("git", ["diff", "--binary", "HEAD"])))
|
||||
.digest("hex"),
|
||||
scenarioSha256: createHash("sha256")
|
||||
.update(new Uint8Array(await readFile(fileURLToPath(import.meta.url))))
|
||||
.digest("hex"),
|
||||
commonlibEntrySha256: createHash("sha256")
|
||||
.update(
|
||||
new Uint8Array(
|
||||
await readFile(
|
||||
"node_modules/@vrtmrz/livesync-commonlib/dist/managers/EntryManager/EntryManagerImpls.js"
|
||||
)
|
||||
)
|
||||
)
|
||||
.digest("hex"),
|
||||
bundleSha256: createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex"),
|
||||
commonlibVersion: JSON.parse(await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8"))
|
||||
.version,
|
||||
observations,
|
||||
};
|
||||
const vault = await createTemporaryVault("livesync-customisation-catalogue-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const record = (name: string, actual: unknown, expected: unknown) => {
|
||||
const observation = { name, expected, actual, passed: isDeepStrictEqual(actual, expected) };
|
||||
observations.push(observation);
|
||||
console.log(JSON.stringify(observation));
|
||||
};
|
||||
const evaluate = async <T>(code: string): Promise<T> => {
|
||||
if (!session) throw new Error("The isolated Obsidian session has not started");
|
||||
return await evalObsidianJson<T>(cliBinary, `(async()=>{${code}})()`, session.cliEnv, 60_000);
|
||||
};
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
pluginData: createE2eCouchDbPluginData(
|
||||
{ uri: "http://127.0.0.1:9", username: "", password: "", dbName: "catalogue-local-only" },
|
||||
{ periodicReplication: false, syncOnFileOpen: false, useOnlyLocalChunk: true }
|
||||
),
|
||||
});
|
||||
report.pluginVersion = session.readiness.pluginVersion;
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
report.runtime = await evaluate(`${coreCode}
|
||||
core.services.setting.setDeviceAndVaultName('catalogue-local');
|
||||
Object.assign(core.settings,{
|
||||
isConfigured:true,usePluginSync:true,usePluginSyncV2:true,syncInternalFiles:false,
|
||||
autoSweepPlugins:false,autoSweepPluginsPeriodic:false,watchInternalFileChanges:false,
|
||||
syncOnlyRegEx:${JSON.stringify(allowList)},syncIgnoreRegEx:''
|
||||
});
|
||||
return JSON.stringify({userAgent:navigator.userAgent,databaseReady:core.localDatabase.isReady});
|
||||
`);
|
||||
|
||||
await writeFixtures(vault.path, pluginFiles);
|
||||
await evaluate(`${configCode}${settleCode}
|
||||
for(const path of ${JSON.stringify(Object.keys(pluginFiles))}){
|
||||
const stored=await addOn.storeCustomisationFileV2(path,${JSON.stringify(sourceTerm)});
|
||||
if(stored===false)throw new Error('Could not seed a Customisation Sync document');
|
||||
}
|
||||
await settle();
|
||||
return JSON.stringify({seeded:true});
|
||||
`);
|
||||
const opened = await evaluate<{ opened: boolean }>(
|
||||
`return JSON.stringify({opened:app.commands.executeCommandById('obsidian-livesync:livesync-plugin-dialog-ex')});`
|
||||
);
|
||||
if (!opened.opened) throw new Error("Could not open the Customisation Sync dialogue command");
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||
await dialogue
|
||||
.locator("label")
|
||||
.filter({ hasText: "Maintenance mode" })
|
||||
.locator('input[type="checkbox"]')
|
||||
.check();
|
||||
await dialogue.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
});
|
||||
const catalogue = await evaluate<{ files: string[]; documents: number }>(`${configCode}${settleCode}
|
||||
await settle();
|
||||
${sourceRowCode}
|
||||
let documents=0;
|
||||
for await(const doc of core.localDatabase.findEntries('ix:','ix;')){
|
||||
if(doc.path.startsWith(${JSON.stringify(`ix:${sourceTerm}/PLUGIN_MAIN/${pluginName}%`)})&&!doc.deleted&&!doc._deleted)documents++;
|
||||
}
|
||||
return JSON.stringify({files:row.files.map(file=>file.filename).sort(),documents});
|
||||
`);
|
||||
record("#1271: all source documents remain in the database", catalogue.documents, 3);
|
||||
record(
|
||||
"#1271: one reload lists all files",
|
||||
catalogue.files,
|
||||
Object.keys(pluginFiles)
|
||||
.map((path) => path.substring(".obsidian/".length))
|
||||
.sort()
|
||||
);
|
||||
await removeFixtures(vault.path, pluginFiles);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||
const row = dialogue.locator(`.filerow:has(select option[value="${sourceTerm}"])`);
|
||||
await row.locator("select").selectOption(sourceTerm);
|
||||
await row.locator(".body").getByRole("button", { name: "✓", exact: true }).click();
|
||||
});
|
||||
report.firstApply = { action: "UI Apply", sourceTerm };
|
||||
record(
|
||||
"#1271: first Apply restores all file contents",
|
||||
await waitForMatchingFiles(vault.path, pluginFiles),
|
||||
Object.keys(pluginFiles).sort()
|
||||
);
|
||||
|
||||
const secondPass = await evaluate<string[]>(`${configCode}${settleCode}
|
||||
await addOn.updatePluginList(false);await settle();${sourceRowCode}
|
||||
return JSON.stringify(row.files.map(file=>file.filename).sort());
|
||||
`);
|
||||
record(
|
||||
"#1271 control: a second pass lists all files",
|
||||
secondPass,
|
||||
Object.keys(pluginFiles)
|
||||
.map((path) => path.substring(".obsidian/".length))
|
||||
.sort()
|
||||
);
|
||||
await removeFixtures(vault.path, pluginFiles);
|
||||
await evaluate(`${configCode}${sourceRowCode}return JSON.stringify({applied:await addOn.applyDataV2(row)});`);
|
||||
record(
|
||||
"#1271 control: Apply after a second pass restores all contents",
|
||||
await matchingFiles(vault.path, pluginFiles),
|
||||
Object.keys(pluginFiles).sort()
|
||||
);
|
||||
} catch (error) {
|
||||
report.runtimeError = error instanceof Error ? error.stack : String(error);
|
||||
if (session) report.processOutput = JSON.stringify(session.app.output()).slice(-4_000);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await writeFile(output, JSON.stringify(report, null, 2) + "\n");
|
||||
console.log(`Verification report: ${output}`);
|
||||
} finally {
|
||||
try {
|
||||
if (session) await session.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (observations.some((observation) => !observation.passed)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
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 { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
@@ -23,11 +29,12 @@ import {
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.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_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||
@@ -59,6 +66,18 @@ const pluginDir = ".obsidian/plugins/livesync-e2e-sample";
|
||||
const pluginManifestPath = `${pluginDir}/manifest.json`;
|
||||
const pluginMainPath = `${pluginDir}/main.js`;
|
||||
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 identicalSnippetContent = ".livesync-customisation-identical { color: #3d6f54; }\n";
|
||||
const differentSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-different.css";
|
||||
@@ -104,7 +123,8 @@ const pluginFixtures = [
|
||||
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
|
||||
] 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 & {
|
||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||
@@ -131,6 +151,14 @@ type CustomisationScanResult = {
|
||||
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> {
|
||||
const fullPath = join(vaultPath, path);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
@@ -147,10 +175,11 @@ function selectedCase(): CustomisationSyncCase {
|
||||
if (args.length === 0) return "all";
|
||||
const arg = args[0];
|
||||
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);
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
await rm(join(vaultPath, path), { force: true });
|
||||
}
|
||||
@@ -287,7 +592,8 @@ async function waitForPathContent(
|
||||
async function startConfiguredSession(
|
||||
context: RunnerContext,
|
||||
vault: TemporaryVault,
|
||||
deviceName: string
|
||||
deviceName: string,
|
||||
settings: Partial<Pick<ObsidianLiveSyncSettings, "syncInternalFiles" | "pluginSyncExtendedSetting">> = {}
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const couchDbSettings = {
|
||||
uri: context.couchDb.uri,
|
||||
@@ -306,6 +612,7 @@ async function startConfiguredSession(
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
syncInternalFiles: false,
|
||||
...settings,
|
||||
};
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary: context.binary,
|
||||
@@ -319,6 +626,18 @@ async function startConfiguredSession(
|
||||
});
|
||||
await waitForLiveSyncCoreReady(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 evalObsidianJson<unknown>(
|
||||
context.cliBinary,
|
||||
@@ -389,7 +708,7 @@ async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv,
|
||||
async function deleteCustomisationSyncEntry(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
term?: string
|
||||
): Promise<void> {
|
||||
@@ -418,7 +737,7 @@ async function deleteCustomisationSyncEntry(
|
||||
async function waitForCustomisationEntry(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
term?: string,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
|
||||
@@ -430,7 +749,7 @@ async function waitForCustomisationEntry(
|
||||
async function waitForCustomisationEntries(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
count: number,
|
||||
term?: string,
|
||||
@@ -570,10 +889,13 @@ async function applyRemoteCustomisationGroup(
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const testCase = selectedCase();
|
||||
async function runWorkflow(
|
||||
testCase: CustomisationSyncCase,
|
||||
initialAction: AutomaticInitialAction = "Overwrite Remote"
|
||||
): Promise<void> {
|
||||
const testVisibility = testCase === "all" || testCase === "visibility";
|
||||
const testMtime = testCase === "all" || testCase === "mtime";
|
||||
const testAutomatic = testCase === "automatic";
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
@@ -604,6 +926,11 @@ async function main(): Promise<void> {
|
||||
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
|
||||
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
|
||||
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) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await writeVaultFile(vaultA.path, fixture.path, fixture.source);
|
||||
@@ -627,6 +954,14 @@ async function main(): Promise<void> {
|
||||
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);
|
||||
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
|
||||
@@ -636,6 +971,9 @@ async function main(): Promise<void> {
|
||||
for (const fixture of pluginFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
}
|
||||
if (testAutomatic) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginDataPath);
|
||||
}
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
@@ -650,15 +988,35 @@ async function main(): Promise<void> {
|
||||
pluginName,
|
||||
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 waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
const entries = [entry, configEntry, ...pluginEntries];
|
||||
const entries = [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])];
|
||||
return entries.every(
|
||||
(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);
|
||||
if (
|
||||
!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.");
|
||||
}
|
||||
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) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
@@ -692,6 +1057,15 @@ async function main(): Promise<void> {
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
|
||||
if (testAutomatic) {
|
||||
await waitForCustomisationEntry(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
"PLUGIN_DATA",
|
||||
pluginName,
|
||||
sourceDeviceName
|
||||
);
|
||||
}
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
const name = fixture.path.split("/").pop() ?? fixture.path;
|
||||
@@ -721,10 +1095,43 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
if (testVisibility) await inspectCustomisationVisibility(session);
|
||||
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
|
||||
if (testAutomatic) {
|
||||
await inspectAutomaticSyncSelection(context, session, vaultB, pluginName, initialAction);
|
||||
}
|
||||
} catch (error) {
|
||||
await session.app.stop().catch(() => undefined);
|
||||
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") {
|
||||
await session.app.stop();
|
||||
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) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -63,6 +63,21 @@ const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
|
||||
const targetPath = ".obsidian/livesync-targeted/only-a.json";
|
||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
|
||||
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 = {
|
||||
binary: string;
|
||||
@@ -133,6 +148,114 @@ async function waitForPathDeleted(
|
||||
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 {
|
||||
try {
|
||||
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."
|
||||
);
|
||||
});
|
||||
await assertDisabledInitialisationIsNoOp(context, session, vault);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
|
||||
|
||||
@@ -286,8 +286,9 @@ async function main(): Promise<void> {
|
||||
artifactRoot,
|
||||
vault: slot.vault,
|
||||
startupGraceMs: 1000,
|
||||
pluginStartup: restart ? "natural" : undefined,
|
||||
pluginData: restart ? undefined : createE2eCouchDbPluginData(couch, overrides),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(slot.vault.name),
|
||||
localStorageEntries: restart ? undefined : createE2eObsidianDeviceLocalState(slot.vault.name),
|
||||
env: { E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(await availablePort()) },
|
||||
lifecycle: {
|
||||
beforeLaunch: async () => {
|
||||
@@ -710,7 +711,7 @@ async function main(): Promise<void> {
|
||||
if (restartBucket)
|
||||
await evaluate(
|
||||
pair.a,
|
||||
`const deadline=Date.now()+3000;
|
||||
`const deadline=Date.now()+core.services.setting.currentSettings().batchSaveMinimumDelay*1000+3000;
|
||||
while(!globalThis.__issue1207Batch.inflightPaused&&Date.now()<deadline) await new Promise(resolve=>setTimeout(resolve,10));
|
||||
if(!globalThis.__issue1207Batch.inflightPaused) throw new Error('The in-flight test gate did not start');`
|
||||
);
|
||||
|
||||
@@ -43,6 +43,9 @@ const focusedScenarios = new Set([
|
||||
"hidden-file-snippet-sync",
|
||||
"hidden-file-selectors",
|
||||
"customisation-sync",
|
||||
"customisation-catalogue-reload",
|
||||
"optional-file-save-completion",
|
||||
"livesync-batch",
|
||||
"received-change-readiness",
|
||||
"internal-metadata-doctor",
|
||||
"setting-markdown-export",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
@@ -59,6 +60,10 @@ type LiveSyncTestPlugin = {
|
||||
useEdgeCaseMode: boolean;
|
||||
hashCacheMaxCount: number;
|
||||
customChunkSize: number;
|
||||
liveSync: boolean;
|
||||
periodicReplication: boolean;
|
||||
batchSave: boolean;
|
||||
batchSaveInLiveSync: boolean;
|
||||
};
|
||||
config: { getValue(key: string, defaultValue: string): Promise<string> };
|
||||
};
|
||||
@@ -643,6 +648,105 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function verifyBatchSettings(vaultPath: string): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
let navigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
let settingsPage = await navigator.openPage("Sync Settings");
|
||||
const row = (name: string) =>
|
||||
settingsPage.locator(".setting-item").filter({ has: page.getByText(name, { exact: true }) });
|
||||
const legacyName = "Batch database update";
|
||||
const liveName = "Batch database updates in LiveSync mode";
|
||||
const delays = ["Minimum delay for batch database updating", "Maximum delay for batch database updating"];
|
||||
|
||||
async function assertState(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const settings = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins[
|
||||
"obsidian-livesync"
|
||||
]?.core.services.setting.currentSettings();
|
||||
return (
|
||||
settings?.liveSync === expected.liveSync &&
|
||||
settings?.batchSave === expected.batchSave &&
|
||||
settings?.batchSaveInLiveSync === expected.batchSaveInLiveSync
|
||||
);
|
||||
},
|
||||
{ liveSync, batchSave, batchSaveInLiveSync },
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
const visibleName = liveSync ? liveName : legacyName;
|
||||
await row(visibleName).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await row(liveSync ? legacyName : liveName).waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
const enabled = liveSync ? batchSaveInLiveSync : batchSave;
|
||||
if ((await row(visibleName).locator(".checkbox-container.is-enabled").count()) !== Number(enabled)) {
|
||||
throw new Error(`The displayed batching choice did not match ${visibleName}: ${enabled}`);
|
||||
}
|
||||
for (const name of delays) {
|
||||
await row(name).waitFor({ state: enabled ? "visible" : "hidden", timeout: uiTimeoutMs });
|
||||
}
|
||||
}
|
||||
|
||||
async function mode(value: "ONEVENTS" | "PERIODIC" | "LIVESYNC") {
|
||||
await row("Sync Mode").locator("select").selectOption(value);
|
||||
await page.waitForFunction(
|
||||
(value) => {
|
||||
const settings = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins[
|
||||
"obsidian-livesync"
|
||||
]?.core.services.setting.currentSettings();
|
||||
return (
|
||||
settings?.liveSync === (value === "LIVESYNC") &&
|
||||
settings?.periodicReplication === (value === "PERIODIC")
|
||||
);
|
||||
},
|
||||
value,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
}
|
||||
|
||||
await assertState(false, false, false);
|
||||
await row(legacyName).locator(".checkbox-container").click();
|
||||
await assertState(false, true, false);
|
||||
await mode("LIVESYNC");
|
||||
await assertState(true, true, false);
|
||||
await row(liveName).locator(".checkbox-container").click();
|
||||
await assertState(true, true, true);
|
||||
await mode("PERIODIC");
|
||||
await assertState(false, true, true);
|
||||
await row(legacyName).locator(".checkbox-container").click();
|
||||
await assertState(false, false, true);
|
||||
await mode("LIVESYNC");
|
||||
await assertState(true, false, true);
|
||||
await row(liveName).locator(".checkbox-container").click();
|
||||
await assertState(true, false, false);
|
||||
await mode("ONEVENTS");
|
||||
await assertState(false, false, false);
|
||||
await row(legacyName).locator(".checkbox-container").click();
|
||||
await assertState(false, true, false);
|
||||
await navigator.close();
|
||||
navigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
settingsPage = await navigator.openPage("Sync Settings");
|
||||
await assertState(false, true, false);
|
||||
await mode("LIVESYNC");
|
||||
await assertState(true, true, false);
|
||||
await navigator.close();
|
||||
const saved = JSON.parse(
|
||||
await readFile(join(vaultPath, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8")
|
||||
);
|
||||
if (saved.batchSave !== true || saved.batchSaveInLiveSync !== false || saved.liveSync !== true) {
|
||||
throw new Error("The independent batching choices were not saved to plug-in data.");
|
||||
}
|
||||
navigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
settingsPage = await navigator.openPage("Sync Settings");
|
||||
await assertState(true, true, false);
|
||||
await mode("ONEVENTS");
|
||||
await row(legacyName).locator(".checkbox-container").click();
|
||||
await assertState(false, false, false);
|
||||
await navigator.close();
|
||||
console.log(
|
||||
"Batching controls start disabled, show the current mode's delays, and retain independent saved choices."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
|
||||
return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const sleepPreferences = await page.evaluate(() => {
|
||||
@@ -1080,6 +1184,7 @@ async function main(): Promise<void> {
|
||||
console.log(`Config Doctor screenshot: ${configDoctorScreenshot}`);
|
||||
}
|
||||
settingsRenderer = await verifyEffectiveSettings();
|
||||
await verifyBatchSettings(vault.path);
|
||||
const initialisation = await verifyPendingSettingsInitialisationFlow();
|
||||
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
|
||||
console.log(
|
||||
|
||||
+16
@@ -12,6 +12,20 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Customisation Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can reload the customisation list and apply every file in a multi-file item on the first attempt. (#1271)
|
||||
- Reloading or clearing the list also prevents earlier pending updates from restoring removed files and items.
|
||||
|
||||
### Setup tools
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can generate a Setup URI with prefixed connection and encryption variables, avoiding collisions with shell or system variables such as `hostname` and `USERNAME`. (#817, #1274)
|
||||
- The generator reads `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE`. The previous names remain supported when the corresponding prefixed value is unset or blank.
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### New Feature
|
||||
@@ -27,6 +41,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
- Hidden File Sync and Customisation Sync now report completion after saving, including delayed customisation updates. Failed saves remain pending, while unchanged notifications allow later edits to proceed.
|
||||
- Start-up reconciliation checks content when modification times are equal, so an edit within the same timestamp interval is not mistaken for an unchanged file.
|
||||
- Existing revision provenance preserves the correct edit branch. Unreadable or continually changing content remains unresolved rather than being reported as successfully reconciled.
|
||||
- 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)
|
||||
|
||||
## 1.0.40
|
||||
|
||||
+10
-8
@@ -31,21 +31,23 @@ Authentication and other non-retryable HTTP failures stop immediately. Network a
|
||||
|
||||
The existing `flyio/generate_setupuri.ts` path remains a CouchDB-only compatibility wrapper for the Fly.io deployment script.
|
||||
|
||||
The connection and Vault encryption values use a `LIVESYNC_` prefix so that they do not collide with shell or system variables such as `hostname` and `USERNAME`. The unprefixed names `hostname`, `username`, `password`, `database`, and `passphrase` are still read when the corresponding prefixed value is unset or blank.
|
||||
|
||||
The generator creates a fresh random ID key by default and includes it in the encrypted Setup URI. It prints a tagged `sls-id-v1:` recovery code. Set `id_recovery_code` to that code when generating another URI for the same Vault; a new run without it creates a different key. This restores only the ID key: reuse the original connection details too. For P2P, provide the original `p2p_room_id` and `p2p_passphrase` because omitted values are generated afresh. Set `id_mode=legacy` to generate a URI with the previous ID behaviour. `id_mode=legacy` and `id_recovery_code` cannot be combined. Keep the recovery code private and retain it if every device might be lost.
|
||||
|
||||
### CouchDB
|
||||
|
||||
```sh
|
||||
export hostname=https://couch.example.com
|
||||
export username=couchdb-admin-username
|
||||
export password=couchdb-admin-password
|
||||
export database=obsidiannotes
|
||||
export passphrase=a-strong-vault-encryption-passphrase
|
||||
export LIVESYNC_HOST=https://couch.example.com
|
||||
export LIVESYNC_USER=couchdb-admin-username
|
||||
export LIVESYNC_PASS=couchdb-admin-password
|
||||
export LIVESYNC_DB=obsidiannotes
|
||||
export LIVESYNC_PASSPHRASE=a-strong-vault-encryption-passphrase
|
||||
export uri_passphrase=a-separate-setup-uri-passphrase # Optional
|
||||
deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
If `uri_passphrase` is omitted, the tool generates and prints a cryptographically random one. Store the Setup URI and its passphrase separately. The `passphrase` value protects synchronised Vault data and must also be stored safely.
|
||||
If `uri_passphrase` is omitted, the tool generates and prints a cryptographically random one. Store the Setup URI and its passphrase separately. The `LIVESYNC_PASSPHRASE` value protects synchronised Vault data and must also be stored safely.
|
||||
|
||||
The generator defaults to `uri_mode=ephemeral`. The URI opens only during the current fixed seven-day UTC window, and the tool prints its exact end time. It may have less than seven days remaining when generated. For an indefinitely reusable URI, set `uri_mode=persistent` before running the command. Persistent uses the existing encrypted format, which older clients that already support that format can read. The time condition controls opening the URI; it does not revoke settings or credentials after import.
|
||||
|
||||
@@ -59,7 +61,7 @@ export secret_key=<SECRET KEY>
|
||||
export bucket=vault-data
|
||||
export region=auto
|
||||
export bucket_prefix=team-a # Optional
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
@@ -69,7 +71,7 @@ Optional Object Storage variables are `use_custom_request_handler`, `force_path_
|
||||
|
||||
```sh
|
||||
export remote_type=p2p
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
decodeSettingsFromSetupURI,
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -9,6 +10,76 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("reads LIVESYNC_ variables before the unprefixed names", async () => {
|
||||
const generated = await generateSetupURI({
|
||||
LIVESYNC_HOST: "https://couch.example.test",
|
||||
LIVESYNC_USER: "alice",
|
||||
LIVESYNC_PASS: "couch-secret",
|
||||
LIVESYNC_DB: "notes",
|
||||
LIVESYNC_PASSPHRASE: "vault-secret",
|
||||
hostname: "https://shell.example.test",
|
||||
username: "shell-user",
|
||||
password: "shell-secret",
|
||||
database: "shell-db",
|
||||
passphrase: "shell-passphrase",
|
||||
uri_passphrase: "setup-secret",
|
||||
});
|
||||
const decoded = await decodeSettingsFromSetupURI(
|
||||
generated.setupURI,
|
||||
generated.setupPassphrase,
|
||||
);
|
||||
assert(decoded, "Commonlib could not decode the CouchDB Setup URI");
|
||||
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
||||
assert(
|
||||
effective.couchDB_URI === "https://couch.example.test" &&
|
||||
effective.couchDB_USER === "alice" &&
|
||||
effective.couchDB_PASSWORD === "couch-secret" &&
|
||||
effective.couchDB_DBNAME === "notes",
|
||||
"the CouchDB connection did not come from the LIVESYNC_ variables",
|
||||
);
|
||||
assert(
|
||||
effective.passphrase === "vault-secret",
|
||||
"the Vault passphrase did not come from LIVESYNC_PASSPHRASE",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("falls back to the unprefixed names when LIVESYNC_ variables are blank", async () => {
|
||||
const generated = await generateSetupURI({
|
||||
LIVESYNC_HOST: " ",
|
||||
hostname: "https://couch.example.test",
|
||||
username: "alice",
|
||||
password: "couch-secret",
|
||||
database: "notes",
|
||||
passphrase: "vault-secret",
|
||||
uri_passphrase: "setup-secret",
|
||||
});
|
||||
const decoded = await decodeSettingsFromSetupURI(
|
||||
generated.setupURI,
|
||||
generated.setupPassphrase,
|
||||
);
|
||||
assert(decoded, "Commonlib could not decode the CouchDB Setup URI");
|
||||
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
||||
assert(
|
||||
effective.couchDB_URI === "https://couch.example.test" &&
|
||||
effective.couchDB_USER === "alice" &&
|
||||
effective.couchDB_PASSWORD === "couch-secret" &&
|
||||
effective.couchDB_DBNAME === "notes" &&
|
||||
effective.passphrase === "vault-secret",
|
||||
"the generator did not fall back to the unprefixed names",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("names both variables when a CouchDB value is missing", async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await generateSetupURI({ uri_passphrase: "setup-secret" });
|
||||
} catch (error) {
|
||||
rejected = error instanceof Error &&
|
||||
error.message === "LIVESYNC_HOST (or hostname) is required";
|
||||
}
|
||||
assert(rejected, "the generator did not name both host variables");
|
||||
});
|
||||
|
||||
Deno.test("generates an Object Storage Setup URI with a selected S3 profile", async () => {
|
||||
const generated = await generateSetupURI({
|
||||
remote_type: "s3",
|
||||
@@ -259,3 +330,171 @@ for (const mode of ["ephemeral", "persistent"] as const) {
|
||||
assert(rejected, "legacy mode silently ignored a recovery code");
|
||||
});
|
||||
}
|
||||
|
||||
const couchDBConnection = {
|
||||
hostname: "https://couch.example.test",
|
||||
username: "alice",
|
||||
password: "couch-secret",
|
||||
database: "notes",
|
||||
passphrase: "vault-secret",
|
||||
};
|
||||
const prefixedCouchDBConnection = {
|
||||
LIVESYNC_HOST: couchDBConnection.hostname,
|
||||
LIVESYNC_USER: couchDBConnection.username,
|
||||
LIVESYNC_PASS: couchDBConnection.password,
|
||||
LIVESYNC_DB: couchDBConnection.database,
|
||||
LIVESYNC_PASSPHRASE: couchDBConnection.passphrase,
|
||||
};
|
||||
const commandEnvironments: { name: string; values: Record<string, string> }[] =
|
||||
[
|
||||
{ name: "prefixed values only", values: prefixedCouchDBConnection },
|
||||
{
|
||||
name: "prefixed values before conflicting legacy values",
|
||||
values: {
|
||||
...prefixedCouchDBConnection,
|
||||
hostname: "https://shell.example.test",
|
||||
username: "shell-user",
|
||||
password: "shell-password",
|
||||
database: "shell-database",
|
||||
passphrase: "shell-passphrase",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blank prefixed values fall back to legacy values",
|
||||
values: {
|
||||
...couchDBConnection,
|
||||
LIVESYNC_HOST: " ",
|
||||
LIVESYNC_USER: "\t",
|
||||
LIVESYNC_PASS: " ",
|
||||
LIVESYNC_DB: "\t",
|
||||
LIVESYNC_PASSPHRASE: " ",
|
||||
},
|
||||
},
|
||||
{ name: "compatibility: legacy values only", values: couchDBConnection },
|
||||
];
|
||||
|
||||
async function runGeneratorCommand(
|
||||
script: URL,
|
||||
values: Record<string, string>,
|
||||
) {
|
||||
const environment = Deno.env.toObject();
|
||||
const inputNames = new Set([
|
||||
...Object.keys(couchDBConnection),
|
||||
...Object.keys(prefixedCouchDBConnection),
|
||||
"remote_type",
|
||||
"uri_passphrase",
|
||||
"uri_mode",
|
||||
"id_mode",
|
||||
"id_recovery_code",
|
||||
"endpoint",
|
||||
"access_key",
|
||||
"secret_key",
|
||||
"bucket",
|
||||
"region",
|
||||
"bucket_prefix",
|
||||
"bucket_custom_headers",
|
||||
"use_custom_request_handler",
|
||||
"force_path_style",
|
||||
"p2p_room_id",
|
||||
"p2p_passphrase",
|
||||
"p2p_relays",
|
||||
"p2p_app_id",
|
||||
"p2p_auto_start",
|
||||
"p2p_auto_broadcast",
|
||||
].map((name) => name.toLowerCase()));
|
||||
for (const name of Object.keys(environment)) {
|
||||
if (inputNames.has(name.toLowerCase())) delete environment[name];
|
||||
}
|
||||
const result = await new Deno.Command(Deno.execPath(), {
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
fileURLToPath(new URL("../flyio/deno.jsonc", import.meta.url)),
|
||||
"--frozen",
|
||||
"--lock",
|
||||
fileURLToPath(new URL("../flyio/deno.lock", import.meta.url)),
|
||||
"--allow-env",
|
||||
script.href,
|
||||
],
|
||||
clearEnv: true,
|
||||
env: {
|
||||
...environment,
|
||||
uri_passphrase: "setup-secret",
|
||||
uri_mode: "persistent",
|
||||
...values,
|
||||
},
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
}).output();
|
||||
assert(
|
||||
result.success,
|
||||
`generator failed: ${new TextDecoder().decode(result.stderr)}`,
|
||||
);
|
||||
const output = new TextDecoder().decode(result.stdout);
|
||||
const uri = output.match(/obsidian:\/\/setuplivesync\?settings=\S+/u)?.[0];
|
||||
assert(uri, "the command did not print a Setup URI");
|
||||
const decoded = await decodeSettingsFromSetupURI(uri, "setup-secret");
|
||||
assert(decoded, "the command output could not be decoded");
|
||||
return { ...DEFAULT_SETTINGS, ...decoded };
|
||||
}
|
||||
|
||||
for (
|
||||
const [name, path] of [
|
||||
["Setup generator", "./generate_setup_uri.ts"],
|
||||
["Fly.io wrapper", "../flyio/generate_setupuri.ts"],
|
||||
]
|
||||
) {
|
||||
for (const scenario of commandEnvironments) {
|
||||
Deno.test(`E2E ${name}: ${scenario.name}`, async () => {
|
||||
const settings = await runGeneratorCommand(
|
||||
new URL(path, import.meta.url),
|
||||
scenario.values,
|
||||
);
|
||||
assert(
|
||||
settings.couchDB_URI === couchDBConnection.hostname &&
|
||||
settings.couchDB_USER === couchDBConnection.username &&
|
||||
settings.couchDB_PASSWORD === couchDBConnection.password &&
|
||||
settings.couchDB_DBNAME === couchDBConnection.database &&
|
||||
settings.passphrase === couchDBConnection.passphrase,
|
||||
"the command did not preserve the selected connection and Vault passphrase",
|
||||
);
|
||||
const profileId = settings.activeConfigurationId;
|
||||
const profile = profileId
|
||||
? settings.remoteConfigurations?.[profileId]
|
||||
: undefined;
|
||||
assert(
|
||||
profile?.uri.startsWith("sls+https://"),
|
||||
"the generated URI did not select its CouchDB profile",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const remoteType of ["s3", "p2p"]) {
|
||||
Deno.test(`E2E Setup generator: prefixed Vault passphrase for ${remoteType}`, async () => {
|
||||
const settings = await runGeneratorCommand(
|
||||
new URL("./generate_setup_uri.ts", import.meta.url),
|
||||
{
|
||||
remote_type: remoteType,
|
||||
endpoint: "https://objects.example.test",
|
||||
access_key: "access-key",
|
||||
secret_key: "secret-key",
|
||||
bucket: "vault-data",
|
||||
LIVESYNC_PASSPHRASE: "vault-secret",
|
||||
passphrase: "shell-passphrase",
|
||||
},
|
||||
);
|
||||
assert(
|
||||
settings.passphrase === "vault-secret",
|
||||
"the command did not prefer LIVESYNC_PASSPHRASE",
|
||||
);
|
||||
const profileId = settings.activeConfigurationId;
|
||||
const profile = profileId
|
||||
? settings.remoteConfigurations?.[profileId]
|
||||
: undefined;
|
||||
assert(
|
||||
profile?.uri.startsWith(`sls+${remoteType}://`),
|
||||
"the command did not select the requested remote profile",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -64,9 +64,17 @@ function configureIdDerivation(
|
||||
function requireValue(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
name: string,
|
||||
legacyName?: string,
|
||||
): string {
|
||||
const value = environment[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
const value = environment[name]?.trim() ||
|
||||
(legacyName && environment[legacyName]?.trim());
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
legacyName
|
||||
? `${name} (or ${legacyName}) is required`
|
||||
: `${name} is required`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -97,7 +105,7 @@ function applyEncryptedVaultSettings(
|
||||
Object.assign(settings, {
|
||||
isConfigured: true,
|
||||
encrypt: true,
|
||||
passphrase: requireValue(environment, "passphrase"),
|
||||
passphrase: requireValue(environment, "LIVESYNC_PASSPHRASE", "passphrase"),
|
||||
usePathObfuscation: true,
|
||||
});
|
||||
}
|
||||
@@ -107,10 +115,10 @@ function createCouchDBSettings(
|
||||
): ObsidianLiveSyncSettings {
|
||||
const settings = createNewVaultSettings();
|
||||
Object.assign(settings, PREFERRED_SETTING_SELF_HOSTED, {
|
||||
couchDB_URI: requireValue(environment, "hostname"),
|
||||
couchDB_USER: requireValue(environment, "username"),
|
||||
couchDB_PASSWORD: requireValue(environment, "password"),
|
||||
couchDB_DBNAME: requireValue(environment, "database"),
|
||||
couchDB_URI: requireValue(environment, "LIVESYNC_HOST", "hostname"),
|
||||
couchDB_USER: requireValue(environment, "LIVESYNC_USER", "username"),
|
||||
couchDB_PASSWORD: requireValue(environment, "LIVESYNC_PASS", "password"),
|
||||
couchDB_DBNAME: requireValue(environment, "LIVESYNC_DB", "database"),
|
||||
batchSave: true,
|
||||
periodicReplication: true,
|
||||
syncOnStart: true,
|
||||
|
||||
Reference in new issue
Block a user