mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
Merge pull request #1334 from vrtmrz/fix/offline-scan-on-demand-chunks
Adopt Commonlib 0.4.1 for on-demand Fetch recovery
This commit is contained in:
12 files changed
+602
-5
No files matched your search
Generated
+4
-4
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.4.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.4.1",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.7",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
@@ -4081,9 +4081,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.0.tgz",
|
||||
"integrity": "sha512-2bhkrd0rRWSiy90rjOxpaO01Ei2V9VSwifzv4ePUttxrzt195zjWNZCzW9ZMvJvAtyPoifShilW0J4kig4oKFA==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.1.tgz",
|
||||
"integrity": "sha512-lp0RLvFIIO2qJbWqxFMGhPZpe3SjydfhJLQkEtou8Db7ls7H9Op9fYIhRA08lr6CRWymZ5HbqHf2xdDmE3YB3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
|
||||
+2
-1
@@ -54,6 +54,7 @@
|
||||
"test:e2e:obsidian:cli-help": "tsx test/e2e-obsidian/scripts/cli-help.ts",
|
||||
"test:e2e:obsidian:debug-ui": "tsx test/e2e-obsidian/scripts/debug-ui.ts",
|
||||
"test:e2e:obsidian:focused": "tsx test/e2e-obsidian/scripts/run-focused.ts",
|
||||
"test:e2e:obsidian:fetch-on-demand-chunks": "tsx test/e2e-obsidian/scripts/fetch-on-demand-chunks.ts",
|
||||
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
|
||||
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
|
||||
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||
@@ -211,7 +212,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.4.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.4.1",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.7",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
|
||||
@@ -94,6 +94,7 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`.
|
||||
- The selected local database suffix is saved automatically, including when `--write-settings` is omitted.
|
||||
- Differing local files can remain and be preserved as conflict branches alongside the fetched content. For recovery from an intact remote, preserve the existing Vault separately and Fetch into an empty Vault; `--force` does not force file overwriting.
|
||||
- CouchDB content checks obtain missing Chunks through the ordinary retrieval policy, including when **Fetch chunks on demand** (`readChunksOnline`) omits them from replication. **Use Only Local Chunks** (`useOnlyLocalChunk`) still prevents direct remote requests. If required content remains unavailable, Fetch fails and retains its reconstruction record; use `--verbose` to inspect the affected path and shortened revision.
|
||||
- `ls [prefix]`: List files currently stored in the local database.
|
||||
- `push <src> <dst>`: Push a local file `<src>` into the database at path `<dst>`.
|
||||
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
|
||||
|
||||
@@ -77,6 +77,21 @@ npm run test:e2e:docker:mirror --workspace self-hosted-livesync-cli
|
||||
|
||||
The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local databases and need no CouchDB service. The complete `test:e2e:docker:all` command also runs the other Docker CLI suites and manages a disposable CouchDB fixture; use it to check the full Docker CI gate. Keep the ordinary-edit and unknown-provenance scenarios aligned between the Node.js and Bash suites.
|
||||
|
||||
## CLI Fetch with on-demand chunks
|
||||
|
||||
Build the CLI, then run `node test/e2e-cli/run-task.ts test:fetch-on-demand-chunks`
|
||||
with Node.js 26 and Docker available. The standard CLI CI suite includes this task.
|
||||
It uses the existing disposable CouchDB test container and the CLI fixture
|
||||
connection variables, with local test defaults when they are absent.
|
||||
|
||||
The scenario fetches into an empty Vault, repeats Fetch with the same files, and
|
||||
checks exact bytes, Metadata revisions, and removal of the reconstruction record.
|
||||
It then prohibits direct Chunk requests, verifies that unavailable content keeps
|
||||
Fetch failed and prevents daemon startup, and retries with requests permitted.
|
||||
The final daemon startup must preserve the original bytes and revision. Commonlib
|
||||
unit tests separately verify that the prohibition sends no direct request and
|
||||
that equal-time, equal-size edits retain their recorded revision ancestry.
|
||||
|
||||
## CLI Fetch database continuity
|
||||
|
||||
The standard CLI CI suite and direct task runner execute
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"test:e2e-matrix:minio-enc0",
|
||||
"test:e2e-matrix:minio-enc1",
|
||||
"test:fetch-database-continuity",
|
||||
"test:fetch-on-demand-chunks",
|
||||
"test:journal-chunk-recovery",
|
||||
"test:journal-receive-failure"
|
||||
]
|
||||
@@ -32,6 +32,9 @@
|
||||
"test:fetch-database-continuity": {
|
||||
"test": ["test-fetch-database-continuity.ts"]
|
||||
},
|
||||
"test:fetch-on-demand-chunks": {
|
||||
"test": ["test-fetch-on-demand-chunks.ts"]
|
||||
},
|
||||
"test:remote-administration-exit-codes": {
|
||||
"test": ["test-remote-administration-exit-codes.ts"]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { startCliInBackground, type BackgroundCliProcess } from "./helpers/backgroundCli.ts";
|
||||
import { runCli, runCliOrFail } from "./helpers/cli.ts";
|
||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
|
||||
function envOrDefault(keys: string[], fallback: string): string {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function setChunkRetrievalSettings(settingsPath: string, useOnlyLocalChunk: boolean): Promise<void> {
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as Record<string, unknown>;
|
||||
settings.readChunksOnline = true;
|
||||
settings.useOnlyLocalChunk = useOnlyLocalChunk;
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
}
|
||||
|
||||
async function assertMarkerAbsent(markerPath: string, message: string): Promise<void> {
|
||||
let exists = true;
|
||||
try {
|
||||
await stat(markerPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") exists = false;
|
||||
else throw error;
|
||||
}
|
||||
assert.equal(exists, false, message);
|
||||
}
|
||||
|
||||
async function readMetadataRevision(
|
||||
databasePath: string,
|
||||
vaultPath: string,
|
||||
settingsPath: string,
|
||||
filePath: string
|
||||
): Promise<string> {
|
||||
const infoText = await runCliOrFail(
|
||||
databasePath,
|
||||
"--vault",
|
||||
vaultPath,
|
||||
"--settings",
|
||||
settingsPath,
|
||||
"info",
|
||||
filePath
|
||||
);
|
||||
const info = JSON.parse(infoText) as { revision?: unknown };
|
||||
assert.equal(typeof info.revision, "string", "Metadata revision is missing for " + filePath + ": " + infoText);
|
||||
return info.revision as string;
|
||||
}
|
||||
|
||||
async function stopDaemon(daemon: BackgroundCliProcess | undefined): Promise<void> {
|
||||
if (!daemon) return;
|
||||
await daemon.stop().catch(() => {});
|
||||
}
|
||||
|
||||
test(
|
||||
"fetch: on-demand chunks preserve existing Vault Metadata and recover after an interrupted fetch",
|
||||
{ timeout: 180_000 },
|
||||
async () => {
|
||||
await using workDir = await TempDir.create("livesync-cli-fetch-on-demand-chunks");
|
||||
|
||||
const couchdbUri = envOrDefault(["COUCHDB_URI", "hostname"], "http://127.0.0.1:5989").replace(/\/$/, "");
|
||||
const couchdbUser = envOrDefault(["COUCHDB_USER", "username"], "admin");
|
||||
const couchdbPassword = envOrDefault(["COUCHDB_PASSWORD", "password"], "testpassword");
|
||||
const dbPrefix = envOrDefault(["COUCHDB_DBNAME", "dbname"], "livesync-test-db-ci");
|
||||
const dbname = (dbPrefix + "-fetch-on-demand-chunks-" + Date.now() + "-" + Math.floor(Math.random() * 1_000_000))
|
||||
.toLowerCase();
|
||||
|
||||
const sourceDatabase = workDir.join("source-database");
|
||||
const receiverDatabase = workDir.join("receiver-database");
|
||||
const sourceVault = workDir.join("source-vault");
|
||||
const receiverVault = workDir.join("receiver-vault");
|
||||
const sourceSettings = workDir.join("source-settings.json");
|
||||
const receiverSettings = workDir.join("receiver-settings.json");
|
||||
const notePath = "notes/on-demand-chunks.md";
|
||||
const sourceNote = join(sourceVault, notePath);
|
||||
const receiverNote = join(receiverVault, notePath);
|
||||
const expectedBytes = Buffer.from("Repeated fetch must preserve this ASCII note.\n", "utf8");
|
||||
const sourceMarker = join(sourceDatabase, ".livesync", "reconstruction.json");
|
||||
const receiverMarker = join(receiverDatabase, ".livesync", "reconstruction.json");
|
||||
|
||||
await Promise.all([
|
||||
mkdir(sourceDatabase, { recursive: true }),
|
||||
mkdir(receiverDatabase, { recursive: true }),
|
||||
mkdir(sourceVault, { recursive: true }),
|
||||
mkdir(receiverVault, { recursive: true }),
|
||||
mkdir(join(sourceVault, "notes"), { recursive: true }),
|
||||
]);
|
||||
await writeFile(sourceNote, expectedBytes);
|
||||
|
||||
let daemon: BackgroundCliProcess | undefined;
|
||||
try {
|
||||
await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname);
|
||||
await Promise.all([initSettingsFile(sourceSettings), initSettingsFile(receiverSettings)]);
|
||||
await Promise.all([
|
||||
applyCouchdbSettings(sourceSettings, couchdbUri, couchdbUser, couchdbPassword, dbname, true),
|
||||
applyCouchdbSettings(receiverSettings, couchdbUri, couchdbUser, couchdbPassword, dbname, true),
|
||||
]);
|
||||
await Promise.all([
|
||||
setChunkRetrievalSettings(sourceSettings, false),
|
||||
setChunkRetrievalSettings(receiverSettings, false),
|
||||
]);
|
||||
|
||||
await runCliOrFail(
|
||||
sourceDatabase,
|
||||
"--vault",
|
||||
sourceVault,
|
||||
"--settings",
|
||||
sourceSettings,
|
||||
"rebuild",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
await assertMarkerAbsent(sourceMarker, "Source rebuild should clear reconstruction.json");
|
||||
assert.deepEqual(await readFile(sourceNote), expectedBytes, "Source rebuild must preserve the Vault bytes");
|
||||
|
||||
await runCliOrFail(
|
||||
receiverDatabase,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"fetch",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
assert.deepEqual(await readFile(receiverNote), expectedBytes, "Initial fetch must reproduce the note bytes");
|
||||
await assertMarkerAbsent(receiverMarker, "Initial fetch should clear reconstruction.json");
|
||||
const originalRevision = await readMetadataRevision(receiverDatabase, receiverVault, receiverSettings, notePath);
|
||||
|
||||
await runCliOrFail(
|
||||
receiverDatabase,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"fetch",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
assert.deepEqual(await readFile(receiverNote), expectedBytes, "Repeated fetch must preserve the Vault bytes");
|
||||
assert.equal(
|
||||
await readMetadataRevision(receiverDatabase, receiverVault, receiverSettings, notePath),
|
||||
originalRevision,
|
||||
"Repeated fetch must retain the original Metadata revision"
|
||||
);
|
||||
await assertMarkerAbsent(receiverMarker, "Repeated fetch should clear reconstruction.json");
|
||||
|
||||
await setChunkRetrievalSettings(receiverSettings, true);
|
||||
const omittedChunkFetch = await runCli(
|
||||
"-v",
|
||||
receiverDatabase,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"fetch",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
assert.notEqual(omittedChunkFetch.code, 0, "Fetch must fail when local-only chunk reads omit the remote chunk");
|
||||
assert.ok(
|
||||
omittedChunkFetch.combined.includes(
|
||||
`Could not reconcile ${notePath}: content for current database revision ${displayRev(originalRevision)} is unavailable;`
|
||||
),
|
||||
omittedChunkFetch.combined
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readFile(receiverNote),
|
||||
expectedBytes,
|
||||
"A failed fetch must preserve the receiver Vault bytes"
|
||||
);
|
||||
const failedMarker = JSON.parse(await readFile(receiverMarker, "utf8")) as {
|
||||
command?: unknown;
|
||||
phase?: unknown;
|
||||
};
|
||||
assert.equal(failedMarker.command, "fetch", "Failed fetch should retain its recovery marker");
|
||||
assert.equal(failedMarker.phase, "running", "Failed fetch marker should remain in the running phase");
|
||||
|
||||
const blockedDaemon = await runCli(
|
||||
receiverDatabase,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"daemon"
|
||||
);
|
||||
assert.notEqual(blockedDaemon.code, 0, "Daemon must refuse to start while fetch recovery is required");
|
||||
assert.match(blockedDaemon.combined, /interrupted fetch requires recovery/i);
|
||||
|
||||
await setChunkRetrievalSettings(receiverSettings, false);
|
||||
await runCliOrFail(
|
||||
receiverDatabase,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"fetch",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
await assertMarkerAbsent(receiverMarker, "Successful recovery fetch should clear reconstruction.json");
|
||||
assert.deepEqual(await readFile(receiverNote), expectedBytes, "Recovery fetch must preserve the Vault bytes");
|
||||
assert.equal(
|
||||
await readMetadataRevision(receiverDatabase, receiverVault, receiverSettings, notePath),
|
||||
originalRevision,
|
||||
"Recovery fetch must retain the original Metadata revision"
|
||||
);
|
||||
|
||||
daemon = startCliInBackground(
|
||||
receiverDatabase,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"daemon"
|
||||
);
|
||||
await daemon.waitUntilContains("[Daemon] Initial replication complete", 45_000);
|
||||
assert.equal(await daemon.stop(), 0, daemon.combined);
|
||||
daemon = undefined;
|
||||
|
||||
assert.deepEqual(await readFile(receiverNote), expectedBytes, "Daemon startup must preserve the Vault bytes");
|
||||
assert.equal(
|
||||
await readMetadataRevision(receiverDatabase, receiverVault, receiverSettings, notePath),
|
||||
originalRevision,
|
||||
"Daemon startup must not create an unnecessary Metadata revision"
|
||||
);
|
||||
await assertMarkerAbsent(receiverMarker, "Recovered operation should leave reconstruction.json absent");
|
||||
} finally {
|
||||
await stopDaemon(daemon);
|
||||
await stopCouchdb().catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -85,6 +85,7 @@ npm run test:e2e:obsidian:focused -- stale-file-restart
|
||||
npm run test:e2e:obsidian:focused -- folder-batch
|
||||
npm run test:e2e:obsidian:focused -- security-seed-reconnect
|
||||
npm run test:e2e:obsidian:focused -- couchdb-http-cache
|
||||
npm run test:e2e:obsidian:focused -- fetch-on-demand-chunks
|
||||
npm run test:e2e:obsidian:focused -- replication-status
|
||||
npm run test:e2e:obsidian:focused -- sync-guard
|
||||
```
|
||||
@@ -396,6 +397,18 @@ Set `E2E_OBSIDIAN_ARTIFACT_ROOT` to a directory containing the exact release `ma
|
||||
|
||||
When these checks pass for the release artefacts, BRAT acceptance can be limited to installation or update, enabling the plug-in, and a cold start. Check any other changes included in that release using their own regression scenarios. The diagnostic export checks verify report payloads; they do not exercise the operating system's clipboard or download implementation on iOS or Android.
|
||||
|
||||
### Fetch with on-demand Chunks
|
||||
|
||||
`fetch-on-demand-chunks` checks Standard Fetch finalisation for an existing Vault
|
||||
when native Request API selection makes Fast Setup fall back from Streaming
|
||||
replication. It uses encrypted CouchDB, obfuscated paths, internal Metadata
|
||||
encryption, Markdown and attachment files, and on-demand Chunk retrieval. The
|
||||
scenario requires local Chunks to be absent before the strict scan, then checks
|
||||
remote delivery, exact file bytes, unchanged Metadata revisions, provenance,
|
||||
readiness, and released suspension after finalisation. It covers the shared
|
||||
failure path reported in #1329 and #1332; a Linux Obsidian run does not establish
|
||||
verification on the reporter's iPadOS/WebKit device.
|
||||
|
||||
### Environment variables
|
||||
|
||||
- `OBSIDIAN_BINARY`: explicit Obsidian executable path.
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
type FileMetadata = {
|
||||
path: string;
|
||||
id: string;
|
||||
revision: string;
|
||||
children: string[];
|
||||
};
|
||||
|
||||
type InitialState = {
|
||||
appVersion: string;
|
||||
files: FileMetadata[];
|
||||
};
|
||||
|
||||
type FetchOutcome = {
|
||||
scanResult: boolean;
|
||||
finalised: boolean;
|
||||
chunksPresentBeforeScan: number;
|
||||
chunksPresentAfterScan: number;
|
||||
totalChunkIds: number;
|
||||
remoteChunkRequests: number[];
|
||||
appReady: boolean;
|
||||
appSuspended: boolean;
|
||||
suspendFileWatching: boolean;
|
||||
files: Array<{
|
||||
path: string;
|
||||
id: string;
|
||||
revision: string;
|
||||
conflicts: string[];
|
||||
provenanceRevision: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const paths = [
|
||||
"E2E/plain.md",
|
||||
"E2E/drawing.excalidraw.md",
|
||||
"E2E/attachment.pdf",
|
||||
"E2E/image.png",
|
||||
];
|
||||
const contents = [
|
||||
Buffer.from("# Existing note\n\n日本語 and English.\n".repeat(200)),
|
||||
Buffer.from("# Excalidraw fixture\n\nExisting drawing data.\n".repeat(200)),
|
||||
Buffer.from("%PDF-1.4\n" + "Synthetic attachment content.\n".repeat(2048)),
|
||||
Buffer.from(Array.from({ length: 65536 }, (_, index) => (index * 13 + 17) % 256)),
|
||||
];
|
||||
const digest = (bytes: ArrayLike<number>) => createHash("sha256").update(Uint8Array.from(bytes)).digest("hex");
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const couchDb = await loadCouchDbConfig();
|
||||
await assertCouchDbReachable(couchDb);
|
||||
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "fetch-on-demand-chunks");
|
||||
const connection = { ...couchDb, dbName };
|
||||
let databaseCreated = false;
|
||||
let vault: TemporaryVault | undefined;
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
await createCouchDbDatabase(couchDb, dbName);
|
||||
databaseCreated = true;
|
||||
vault = await createTemporaryVault("obsidian-livesync-fetch-on-demand-");
|
||||
const vaultPath = vault.path;
|
||||
|
||||
const remoteDebuggingPort = obsidianRemoteDebuggingPort(process.env);
|
||||
const sessionEnv = {
|
||||
...process.env,
|
||||
E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(remoteDebuggingPort),
|
||||
};
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd(),
|
||||
pluginData: createE2eCouchDbPluginData(connection, {
|
||||
encrypt: true,
|
||||
E2EEAlgorithm: "v2",
|
||||
passphrase: "synthetic-fetch-on-demand-fixture",
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: true,
|
||||
doctorProcessedVersion: "1.0.33",
|
||||
idDerivationVersion: 0,
|
||||
pathNormalizationVersion: 0,
|
||||
useRequestAPI: true,
|
||||
useOnlyLocalChunk: false,
|
||||
readChunksOnline: true,
|
||||
useIndexedDBAdapter: false,
|
||||
concurrencyOfReadChunksOnline: 40,
|
||||
minimumIntervalOfReadChunksOnline: 50,
|
||||
showVerboseLog: true,
|
||||
lessInformationInLog: false,
|
||||
doNotSuspendOnFetching: false,
|
||||
}),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
env: sessionEnv,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await prepareRemote(cliBinary, session.cliEnv);
|
||||
|
||||
const evaluate = async <T>(expression: string): Promise<T> => {
|
||||
if (!session) throw new Error("No active Obsidian session.");
|
||||
let result: string | undefined;
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
result = await page.evaluate<string>(expression);
|
||||
});
|
||||
if (result === undefined) throw new Error("The renderer returned no result.");
|
||||
return JSON.parse(result) as T;
|
||||
};
|
||||
const seededSettings = await evaluate<Record<string, unknown>>(
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const paths=${JSON.stringify(paths)};`,
|
||||
`const contents=${JSON.stringify(contents.map((bytes) => bytes.toString("base64")))};`,
|
||||
"await app.vault.createFolder('E2E');",
|
||||
"for(let index=0;index<paths.length;index++){",
|
||||
" const bytes=Uint8Array.from(atob(contents[index]),character=>character.charCodeAt(0));",
|
||||
" if(paths[index].endsWith('.md')) await app.vault.create(paths[index],new TextDecoder().decode(bytes));",
|
||||
" else await app.vault.createBinary(paths[index],bytes.buffer);",
|
||||
"}",
|
||||
"await core.services.fileProcessing.commitPendingFileEvents();",
|
||||
"for(const path of paths){",
|
||||
" const file=app.vault.getAbstractFileByPath(path);",
|
||||
" if(!file) throw new Error('Fixture file is missing: '+path);",
|
||||
" if(!(await core.fileHandler.storeFileToDB(file))) throw new Error('Could not store fixture '+path);",
|
||||
"}",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({",
|
||||
" encrypt:settings.encrypt,E2EEAlgorithm:settings.E2EEAlgorithm,",
|
||||
" usePathObfuscation:settings.usePathObfuscation,encryptInternalMetadata:settings.encryptInternalMetadata,",
|
||||
" idDerivationVersion:settings.idDerivationVersion,pathNormalizationVersion:settings.pathNormalizationVersion,",
|
||||
" readChunksOnline:settings.readChunksOnline,useOnlyLocalChunk:settings.useOnlyLocalChunk,",
|
||||
" useRequestAPI:settings.useRequestAPI,useIndexedDBAdapter:settings.useIndexedDBAdapter",
|
||||
"});",
|
||||
"})()",
|
||||
].join("")
|
||||
);
|
||||
assert.deepEqual(seededSettings, {
|
||||
encrypt: true,
|
||||
E2EEAlgorithm: "v2",
|
||||
usePathObfuscation: true,
|
||||
encryptInternalMetadata: true,
|
||||
idDerivationVersion: 0,
|
||||
pathNormalizationVersion: 0,
|
||||
readChunksOnline: true,
|
||||
useOnlyLocalChunk: false,
|
||||
useRequestAPI: true,
|
||||
useIndexedDBAdapter: false,
|
||||
});
|
||||
|
||||
await pushLocalChanges(cliBinary, session.cliEnv);
|
||||
const before = await evaluate<InitialState>(
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const paths=${JSON.stringify(paths)};`,
|
||||
"return JSON.stringify({appVersion:core.services.API.getAppVersion(),files:",
|
||||
"await Promise.all(paths.map(async path=>{",
|
||||
" const meta=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
" if(!meta) throw new Error('Fixture Metadata is missing: '+path);",
|
||||
" return {path,id:meta._id,revision:meta._rev,children:meta.children};",
|
||||
"}))});",
|
||||
"})()",
|
||||
].join("")
|
||||
);
|
||||
assert.equal(before.files.length, paths.length, "The remote fixture must contain all four files.");
|
||||
assert.ok(before.files.every((file) => file.children.length > 0), "Every fixture file must have Chunks.");
|
||||
const diskBefore = await Promise.all(
|
||||
paths.map(async (path) => ({ path, hash: digest(await readFile(join(vaultPath, path))) }))
|
||||
);
|
||||
assert.deepEqual(
|
||||
diskBefore,
|
||||
paths.map((path, index) => ({ path, hash: digest(contents[index]) })),
|
||||
"The seeded Vault bytes must match the fixture."
|
||||
);
|
||||
|
||||
const outcome = await evaluate<FetchOutcome>(
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const paths=${JSON.stringify(paths)};`,
|
||||
"await core.rebuilder.$fetchLocalDBFast(false);",
|
||||
"const db=core.localDatabase.localDatabase;",
|
||||
"const metadata=await Promise.all(paths.map(path=>core.localDatabase.getDBEntryMeta(path,undefined,true)));",
|
||||
"const ids=[...new Set(metadata.flatMap(meta=>meta.children))];",
|
||||
"const initialChunks=await db.allDocs({keys:ids,include_docs:true});",
|
||||
"const chunksPresentBeforeScan=initialChunks.rows.filter(row=>row.doc&&!row.value?.deleted).length;",
|
||||
"const replicator=core.services.replicator.getActiveReplicator();",
|
||||
"if(!replicator||typeof replicator.fetchRemoteChunks!=='function') throw new Error('Remote Chunk fetch is unavailable');",
|
||||
"const originalFetchRemoteChunks=replicator.fetchRemoteChunks;",
|
||||
"const ownDescriptor=Object.getOwnPropertyDescriptor(replicator,'fetchRemoteChunks');",
|
||||
"const remoteChunkRequests=[];",
|
||||
"replicator.fetchRemoteChunks=async function(...args){",
|
||||
" remoteChunkRequests.push(args[0]?.length??0);",
|
||||
" return await originalFetchRemoteChunks.apply(this,args);",
|
||||
"};",
|
||||
"let scanResult;let finalised;",
|
||||
"try{",
|
||||
" scanResult=await core.services.vault.scanVault(true,true,false,{strictContentCheck:true});",
|
||||
" finalised=await core.rebuilder.finishRebuild();",
|
||||
"}finally{",
|
||||
" if(ownDescriptor) Object.defineProperty(replicator,'fetchRemoteChunks',ownDescriptor);",
|
||||
" else delete replicator.fetchRemoteChunks;",
|
||||
"}",
|
||||
"const afterMetadata=await Promise.all(paths.map(path=>core.localDatabase.getDBEntryMeta(path,undefined,true)));",
|
||||
"const afterChunks=await db.allDocs({keys:ids,include_docs:true});",
|
||||
"const provenance=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"const files=await Promise.all(afterMetadata.map(async(meta,index)=>({",
|
||||
" path:paths[index],id:meta._id,revision:meta._rev,",
|
||||
" conflicts:(await db.get(meta._id,{conflicts:true}))._conflicts??[],",
|
||||
" provenanceRevision:(await provenance.get(paths[index]))?.revision??null",
|
||||
"})));",
|
||||
"return JSON.stringify({scanResult,finalised,chunksPresentBeforeScan,",
|
||||
" chunksPresentAfterScan:afterChunks.rows.filter(row=>row.doc&&!row.value?.deleted).length,",
|
||||
" totalChunkIds:ids.length,remoteChunkRequests,",
|
||||
" appReady:core.services.appLifecycle.isReady(),",
|
||||
" appSuspended:core.services.appLifecycle.isSuspended(),",
|
||||
" suspendFileWatching:core.services.setting.currentSettings().suspendFileWatching,files});",
|
||||
"})()",
|
||||
].join("")
|
||||
);
|
||||
const diskAfter = await Promise.all(
|
||||
paths.map(async (path) => ({ path, hash: digest(await readFile(join(vaultPath, path))) }))
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
scenario: "fetch-on-demand-chunks",
|
||||
appVersion: before.appVersion,
|
||||
files: before.files.map(({ path, revision, children }) => ({ path, revision, chunkCount: children.length })),
|
||||
scanResult: outcome.scanResult,
|
||||
finalised: outcome.finalised,
|
||||
chunksPresentBeforeScan: outcome.chunksPresentBeforeScan,
|
||||
chunksPresentAfterScan: outcome.chunksPresentAfterScan,
|
||||
remoteChunkRequestCount: outcome.remoteChunkRequests.length,
|
||||
appReady: outcome.appReady,
|
||||
appSuspended: outcome.appSuspended,
|
||||
suspendFileWatching: outcome.suspendFileWatching,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(outcome.chunksPresentBeforeScan, 0, "The fetch must omit Chunks until strict scanning.");
|
||||
assert.ok(outcome.totalChunkIds > 0, "The Metadata must reference Chunks.");
|
||||
assert.equal(outcome.chunksPresentAfterScan, outcome.totalChunkIds, "Strict scanning must retrieve every Chunk.");
|
||||
assert.equal(outcome.scanResult, true, "The strict Vault scan must succeed.");
|
||||
assert.equal(outcome.finalised, true, "The Standard Fetch rebuild must finalise.");
|
||||
assert.ok(outcome.remoteChunkRequests.some((count) => count > 0), "Strict scanning must request remote Chunks.");
|
||||
assert.equal(outcome.appReady, true, "The application must become ready after Fetch.");
|
||||
assert.equal(outcome.appSuspended, false, "The application must not remain suspended after Fetch.");
|
||||
assert.equal(outcome.suspendFileWatching, false, "File watching must resume after Fetch.");
|
||||
assert.deepEqual(diskAfter, diskBefore, "Fetch must preserve the exact physical Vault bytes.");
|
||||
assert.equal(outcome.files.length, before.files.length);
|
||||
for (let index = 0; index < before.files.length; index++) {
|
||||
assert.equal(outcome.files[index].id, before.files[index].id, "Fetch must preserve the Metadata ID.");
|
||||
assert.equal(
|
||||
outcome.files[index].revision,
|
||||
before.files[index].revision,
|
||||
"Fetch must preserve the exact Metadata revision."
|
||||
);
|
||||
assert.deepEqual(outcome.files[index].conflicts, [], "Fetch must not create Metadata conflicts.");
|
||||
assert.equal(
|
||||
outcome.files[index].provenanceRevision,
|
||||
before.files[index].revision,
|
||||
"File reflection provenance must identify the exact Metadata revision."
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (session) {
|
||||
await captureObsidianPage(session.remoteDebuggingPort, "fetch-on-demand-chunks-failure.png", async () => {}).catch(
|
||||
(captureError) => console.error("Could not capture the failed Obsidian session:", captureError)
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
if (session) await session.app.stop();
|
||||
} finally {
|
||||
try {
|
||||
if (vault) await vault.dispose();
|
||||
} finally {
|
||||
if (databaseCreated) await deleteCouchDbDatabase(couchDb, dbName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -28,6 +28,7 @@ const testSteps: Step[] = [
|
||||
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
|
||||
{ name: "joined devices with CouchDB", args: ["run", "test:e2e:obsidian:joined-devices"] },
|
||||
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
|
||||
{ name: "on-demand Chunk fetch", args: ["run", "test:e2e:obsidian:fetch-on-demand-chunks"] },
|
||||
{
|
||||
name: "manual CouchDB setup workflow",
|
||||
args: ["run", "test:e2e:obsidian:couchdb-manual-setup-workflow"],
|
||||
|
||||
@@ -26,6 +26,7 @@ const focusedScenarios = new Set([
|
||||
"third-party-chunks",
|
||||
"couchdb-upload",
|
||||
"couchdb-http-cache",
|
||||
"fetch-on-demand-chunks",
|
||||
"chunk-fetch-retry",
|
||||
"couchdb-manual-setup-workflow",
|
||||
"cli-to-obsidian-sync",
|
||||
|
||||
@@ -12,10 +12,17 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- CouchDB Fetch can finish content checks for existing files when **Fetch chunks on demand** has omitted the required Chunks from replication. The same correction applies when Fast Setup falls back to Standard Fetch. (#1332)
|
||||
- **Use Only Local Chunks** remains respected; unavailable content keeps the operation incomplete for retry.
|
||||
|
||||
### CLI
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Repeating CouchDB Fetch into an existing Vault can complete with **Fetch chunks on demand** enabled. The content checks retrieve missing Chunks through the configured policy while preserving **Use Only Local Chunks** and unresolved-file recovery. (#1329)
|
||||
|
||||
- The CLI daemon now starts even when hidden directories in our Vault are unreadable. Vault scans skip these directories, which remain excluded from synchronisation. (#1325)
|
||||
|
||||
- The daemon now detects files written into newly created folders during a burst of writes, including files renamed from temporary names, without requiring a restart. (#1328)
|
||||
|
||||
Reference in new issue
Block a user