mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 12:40:12 +00:00
test: cover on-demand Fetch recovery in CLI and Obsidian
This commit is contained in:
1 parent
c2e4e9198b
commit
a43882d743
11 files changed
+592
No files matched your search
@@ -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,236 @@
|
||||
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.match(omittedChunkFetch.combined, /useOnlyLocalChunk=true/);
|
||||
assert.match(omittedChunkFetch.combined, /Could not reconcile/);
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user