mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-16 09:36:00 +00:00
fix: align chunk reads with remote delivery
Consume commonlib's delivery lifecycle, classify finite replication entry points, and document why the five-minute inactivity fuse is only a leak safety valve. Record the remote-activity counter correction and preserve continuous replication's unbounded live channel.
This commit is contained in:
@@ -66,6 +66,10 @@ npm run test:e2e:obsidian:local-suite:services
|
||||
|
||||
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, configures Self-hosted LiveSync through `obsidian-cli eval`, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
|
||||
|
||||
The same workflow checks the remote-activity status boundary. It holds the real one-shot replication immediately before its replicator call, confirms that the status bar contains `📲` while finite replication is active, releases it, and then requires the finite and bounded activity counts to return to zero, the HTTP request and response counts to balance, and `📲` to disappear. It then creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active state deterministic without replacing either remote operation.
|
||||
|
||||
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
|
||||
|
||||
`test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise.
|
||||
|
||||
By default, the compatibility check runs `node src/apps/cli/dist/index.cjs`. Set `LIVESYNC_CLI_COMMAND` to test another CLI build or distribution. The value may be a quoted command line or a JSON array of executable and prefix arguments; the scenario arguments are appended without going through a shell.
|
||||
@@ -128,6 +132,8 @@ Useful environment variables:
|
||||
- `E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS`: timeout for waiting until Self-hosted LiveSync reports that its core lifecycle and local database are ready.
|
||||
- `E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS`: timeout for waiting until a file appears in Self-hosted LiveSync's local database.
|
||||
- `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents.
|
||||
- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds.
|
||||
- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots captured after a remote-activity failure; default is `/tmp/obsidian-livesync-e2e`.
|
||||
- `E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS`: timeout for waiting until Object Storage contains uploaded E2E objects.
|
||||
- `E2E_OBSIDIAN_KEEP_COUCHDB=true`: keep the temporary CouchDB database for inspection.
|
||||
- `E2E_OBSIDIAN_KEEP_OBJECT_STORAGE=true`: keep the temporary Object Storage prefix for inspection.
|
||||
|
||||
@@ -126,6 +126,26 @@ export async function createCouchDbDatabase(config: CouchDbConfig, dbName: strin
|
||||
}
|
||||
}
|
||||
|
||||
export async function putCouchDbDocument(
|
||||
config: CouchDbConfig,
|
||||
dbName: string,
|
||||
document: CouchDbDocument
|
||||
): Promise<void> {
|
||||
const response = await fetch(databaseUrl(config, dbName, `/${encodeURIComponent(document._id)}`), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
authorization: authHeader(config),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(document),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to write CouchDB document ${document._id}. HTTP ${response.status}: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: string): Promise<void> {
|
||||
const response = await fetch(databaseUrl(config, dbName), {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { withObsidianPage } from "./ui.ts";
|
||||
|
||||
export const REMOTE_ACTIVITY_E2E_STATE_KEY = "__livesyncE2ERemoteActivity";
|
||||
|
||||
export type RemoteActivitySnapshot = {
|
||||
boundedRemoteActivityCount: number;
|
||||
finiteReplicationActivityCount: number;
|
||||
gateDone?: boolean;
|
||||
gateEntered?: boolean;
|
||||
gateError?: string;
|
||||
gateKind?: string;
|
||||
requestCount: number;
|
||||
responseCount: number;
|
||||
statusBarFound: boolean;
|
||||
statusBarText: string;
|
||||
};
|
||||
|
||||
export type ExpectedRemoteActivityState = "chunk-fetch-active" | "finite-replication-active" | "idle";
|
||||
|
||||
type RuntimeCounter = { value?: number };
|
||||
|
||||
type RuntimeCore = {
|
||||
services?: {
|
||||
API?: {
|
||||
requestCount?: RuntimeCounter;
|
||||
responseCount?: RuntimeCounter;
|
||||
};
|
||||
replicator?: {
|
||||
boundedRemoteActivityCount?: RuntimeCounter;
|
||||
finiteReplicationActivityCount?: RuntimeCounter;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type RuntimeGate = {
|
||||
done?: boolean;
|
||||
entered?: boolean;
|
||||
error?: string;
|
||||
kind?: string;
|
||||
};
|
||||
|
||||
type RendererGlobals = typeof globalThis & {
|
||||
app?: {
|
||||
plugins?: {
|
||||
plugins?: Record<string, { core?: RuntimeCore }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
async function readRemoteActivitySnapshotFromPage(page: Page): Promise<RemoteActivitySnapshot> {
|
||||
return await page.evaluate(
|
||||
({ pluginId, stateKey }) => {
|
||||
const globals = globalThis as RendererGlobals;
|
||||
const core = globals.app?.plugins?.plugins?.[pluginId]?.core;
|
||||
if (!core) throw new Error(`Obsidian plug-in is not loaded: ${pluginId}`);
|
||||
const gate = (globalThis as unknown as Record<string, RuntimeGate | undefined>)[stateKey];
|
||||
const statusBars = Array.from(document.querySelectorAll<HTMLElement>(".syncstatusbar"));
|
||||
return {
|
||||
boundedRemoteActivityCount: Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1),
|
||||
finiteReplicationActivityCount: Number(
|
||||
core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1
|
||||
),
|
||||
gateDone: gate?.done,
|
||||
gateEntered: gate?.entered,
|
||||
gateError: gate?.error,
|
||||
gateKind: gate?.kind,
|
||||
requestCount: Number(core.services?.API?.requestCount?.value ?? -1),
|
||||
responseCount: Number(core.services?.API?.responseCount?.value ?? -1),
|
||||
statusBarFound: statusBars.length > 0,
|
||||
statusBarText: statusBars.map((element) => element.textContent ?? "").join("\n"),
|
||||
} satisfies RemoteActivitySnapshot;
|
||||
},
|
||||
{ pluginId: "obsidian-livesync", stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY }
|
||||
);
|
||||
}
|
||||
|
||||
export async function readRemoteActivitySnapshot(port: number): Promise<RemoteActivitySnapshot> {
|
||||
return await withObsidianPage(port, async (page) => await readRemoteActivitySnapshotFromPage(page));
|
||||
}
|
||||
|
||||
function formatWaitFailure(
|
||||
expected: ExpectedRemoteActivityState,
|
||||
snapshot: RemoteActivitySnapshot | undefined,
|
||||
error: unknown
|
||||
): Error {
|
||||
return new Error(
|
||||
[
|
||||
`Timed out waiting for remote activity state: ${expected}`,
|
||||
snapshot ? `Last snapshot: ${JSON.stringify(snapshot)}` : undefined,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForRemoteActivityState(
|
||||
port: number,
|
||||
expected: ExpectedRemoteActivityState,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000)
|
||||
): Promise<RemoteActivitySnapshot> {
|
||||
try {
|
||||
return await withObsidianPage(port, async (page) => {
|
||||
await page.waitForFunction(
|
||||
({ expectedState, pluginId, stateKey }) => {
|
||||
const globals = globalThis as RendererGlobals;
|
||||
const core = globals.app?.plugins?.plugins?.[pluginId]?.core;
|
||||
if (!core) return false;
|
||||
const gate = (globalThis as unknown as Record<string, RuntimeGate | undefined>)[stateKey];
|
||||
const statusBarText = Array.from(document.querySelectorAll<HTMLElement>(".syncstatusbar"))
|
||||
.map((element) => element.textContent ?? "")
|
||||
.join("\n");
|
||||
const bounded = Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1);
|
||||
const finite = Number(core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1);
|
||||
const requests = Number(core.services?.API?.requestCount?.value ?? -1);
|
||||
const responses = Number(core.services?.API?.responseCount?.value ?? -1);
|
||||
const iconVisible = statusBarText.includes("📲");
|
||||
|
||||
if (expectedState === "finite-replication-active") {
|
||||
return (
|
||||
gate?.kind === "one-shot" &&
|
||||
gate.entered === true &&
|
||||
bounded > 0 &&
|
||||
finite > 0 &&
|
||||
iconVisible
|
||||
);
|
||||
}
|
||||
if (expectedState === "chunk-fetch-active") {
|
||||
return (
|
||||
gate?.kind === "chunk-fetch" &&
|
||||
gate.entered === true &&
|
||||
bounded > 0 &&
|
||||
finite === 0 &&
|
||||
iconVisible
|
||||
);
|
||||
}
|
||||
return bounded === 0 && finite === 0 && requests === responses && !iconVisible;
|
||||
},
|
||||
{
|
||||
expectedState: expected,
|
||||
pluginId: "obsidian-livesync",
|
||||
stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY,
|
||||
},
|
||||
{ timeout: timeoutMs }
|
||||
);
|
||||
return await readRemoteActivitySnapshotFromPage(page);
|
||||
});
|
||||
} catch (error) {
|
||||
const snapshot = await readRemoteActivitySnapshot(port).catch(() => undefined);
|
||||
throw formatWaitFailure(expected, snapshot, error);
|
||||
}
|
||||
}
|
||||
|
||||
export type RemoteActivityDiagnostics = {
|
||||
screenshotPath: string;
|
||||
snapshot: RemoteActivitySnapshot;
|
||||
snapshotPath: string;
|
||||
};
|
||||
|
||||
export async function captureRemoteActivityDiagnostics(
|
||||
port: number,
|
||||
label: string
|
||||
): Promise<RemoteActivityDiagnostics> {
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const safeLabel = label.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "remote-activity";
|
||||
const prefix = `${safeLabel}-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
const screenshotPath = join(outputDirectory, `${prefix}.png`);
|
||||
const snapshotPath = join(outputDirectory, `${prefix}.json`);
|
||||
const snapshot = await withObsidianPage(port, async (page) => {
|
||||
const current = await readRemoteActivitySnapshotFromPage(page);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
return current;
|
||||
});
|
||||
await writeFile(snapshotPath, `${JSON.stringify(snapshot, undefined, 2)}\n`, "utf8");
|
||||
return { screenshotPath, snapshot, snapshotPath };
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { evalObsidianJson } from "./cli.ts";
|
||||
import { REMOTE_ACTIVITY_E2E_STATE_KEY } from "./remoteActivity.ts";
|
||||
|
||||
export type HeldRemoteActivityResult = {
|
||||
done: boolean;
|
||||
entered: boolean;
|
||||
error?: string;
|
||||
kind: "chunk-fetch" | "one-shot";
|
||||
requestedIds?: string[];
|
||||
result?: boolean;
|
||||
resultCount?: number;
|
||||
};
|
||||
|
||||
const stateKeySource = JSON.stringify(REMOTE_ACTIVITY_E2E_STATE_KEY);
|
||||
|
||||
export async function startHeldOneShotReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<{ started: boolean }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const stateKey=${stateKeySource};`,
|
||||
"const host=globalThis;",
|
||||
"if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const replicator=core.services.replicator.getActiveReplicator();",
|
||||
"if(!replicator) throw new Error('No active replicator is available.');",
|
||||
"const original=replicator.openReplication;",
|
||||
"let releaseGate;",
|
||||
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
|
||||
"const state={kind:'one-shot',entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};",
|
||||
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
|
||||
"state.restore=()=>{replicator.openReplication=original;};",
|
||||
"host[stateKey]=state;",
|
||||
"replicator.openReplication=async function(...args){",
|
||||
"state.entered=true;",
|
||||
"await gate;",
|
||||
"return await original.apply(this,args);",
|
||||
"};",
|
||||
"state.promise=(async()=>{",
|
||||
"try{",
|
||||
"if(!(await core.services.fileProcessing.commitPendingFileEvents())) throw new Error('Pending file events could not be committed.');",
|
||||
"state.result=!!(await core.services.replication.replicate(true));",
|
||||
"}catch(error){",
|
||||
"state.error=error instanceof Error?error.message:String(error);",
|
||||
"}finally{",
|
||||
"state.restore();",
|
||||
"state.done=true;",
|
||||
"}",
|
||||
"})();",
|
||||
"return JSON.stringify({started:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function startHeldChunkFetch(cliBinary: string, env: NodeJS.ProcessEnv, chunkId: string): Promise<void> {
|
||||
await evalObsidianJson<{ started: boolean }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const stateKey=${stateKeySource};`,
|
||||
`const chunkId=${JSON.stringify(chunkId)};`,
|
||||
"const host=globalThis;",
|
||||
"if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const replicator=core.services.replicator.getActiveReplicator();",
|
||||
"if(!replicator) throw new Error('No active replicator is available.');",
|
||||
"const localDb=core.localDatabase.localDatabase;",
|
||||
"const existing=await localDb.get(chunkId).catch(()=>undefined);",
|
||||
"if(existing&&!existing._deleted) throw new Error(`The remote-only chunk already exists locally: ${chunkId}`);",
|
||||
"const original=replicator.fetchRemoteChunks;",
|
||||
"let releaseGate;",
|
||||
"let resolveDone;",
|
||||
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
|
||||
"const donePromise=new Promise((resolve)=>{resolveDone=resolve;});",
|
||||
"const state={kind:'chunk-fetch',entered:false,done:false,released:false,error:undefined,resultCount:undefined,requestedIds:undefined,promise:donePromise,release:undefined,restore:undefined};",
|
||||
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
|
||||
"state.restore=()=>{replicator.fetchRemoteChunks=original;};",
|
||||
"host[stateKey]=state;",
|
||||
"replicator.fetchRemoteChunks=async function(...args){",
|
||||
"state.entered=true;",
|
||||
"state.requestedIds=Array.isArray(args[0])?[...args[0]]:[];",
|
||||
"await gate;",
|
||||
"try{",
|
||||
"const result=await original.apply(this,args);",
|
||||
"state.resultCount=Array.isArray(result)?result.length:0;",
|
||||
"return result;",
|
||||
"}catch(error){",
|
||||
"state.error=error instanceof Error?error.message:String(error);",
|
||||
"throw error;",
|
||||
"}finally{",
|
||||
"state.restore();",
|
||||
"state.done=true;",
|
||||
"resolveDone();",
|
||||
"}",
|
||||
"};",
|
||||
"core.localDatabase.managers.chunkFetcher.onEvent([chunkId]);",
|
||||
"return JSON.stringify({started:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function finishHeldRemoteActivity(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<HeldRemoteActivityResult> {
|
||||
return await evalObsidianJson<HeldRemoteActivityResult>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const stateKey=${stateKeySource};`,
|
||||
"const state=globalThis[stateKey];",
|
||||
"if(!state) throw new Error('No remote activity E2E gate is installed.');",
|
||||
"state.release();",
|
||||
"await state.promise;",
|
||||
"return JSON.stringify({kind:state.kind,entered:state.entered,done:state.done,error:state.error,result:state.result,resultCount:state.resultCount,requestedIds:state.requestedIds});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForRestoredChunk(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
chunkId: string,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000)
|
||||
): Promise<{ id: string; type: string }> {
|
||||
return await evalObsidianJson<{ id: string; type: string }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const chunkId=${JSON.stringify(chunkId)};`,
|
||||
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const localDb=core.localDatabase.localDatabase;",
|
||||
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
|
||||
"while(Date.now()<deadline){",
|
||||
"const chunk=await localDb.get(chunkId).catch(()=>undefined);",
|
||||
"if(chunk&&!chunk._deleted&&chunk.type==='leaf'&&typeof chunk.data==='string') return JSON.stringify({id:chunk._id,type:chunk.type});",
|
||||
"await sleep(100);",
|
||||
"}",
|
||||
"throw new Error(`Timed out waiting for the fetched chunk to return: ${chunkId}`);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearHeldRemoteActivity(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<{ cleared: boolean }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const stateKey=${stateKeySource};`,
|
||||
"const state=globalThis[stateKey];",
|
||||
"if(!state) return JSON.stringify({cleared:false});",
|
||||
"if(!state.done) throw new Error('The remote activity E2E gate is still running.');",
|
||||
"delete globalThis[stateKey];",
|
||||
"return JSON.stringify({cleared:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function cleanUpHeldRemoteActivity(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<{ cleared: boolean }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const stateKey=${stateKeySource};`,
|
||||
"const state=globalThis[stateKey];",
|
||||
"if(!state) return JSON.stringify({cleared:false});",
|
||||
"state.release?.();",
|
||||
"await Promise.race([Promise.resolve(state.promise),new Promise((resolve)=>setTimeout(resolve,5000))]);",
|
||||
"state.restore?.();",
|
||||
"delete globalThis[stateKey];",
|
||||
"return JSON.stringify({cleared:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deleteCouchDbDatabase,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
putCouchDbDocument,
|
||||
waitForCouchDbDocs,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
@@ -12,10 +13,18 @@ import {
|
||||
assertEqual,
|
||||
configureCouchDb,
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
type LocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { captureRemoteActivityDiagnostics, waitForRemoteActivityState } from "../runner/remoteActivity.ts";
|
||||
import {
|
||||
cleanUpHeldRemoteActivity,
|
||||
clearHeldRemoteActivity,
|
||||
finishHeldRemoteActivity,
|
||||
startHeldChunkFetch,
|
||||
startHeldOneShotReplication,
|
||||
waitForRestoredChunk,
|
||||
} from "../runner/remoteActivityWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
@@ -72,6 +81,7 @@ async function main(): Promise<void> {
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "obsidian-upload");
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
let activityStage = "session-startup";
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -104,8 +114,25 @@ async function main(): Promise<void> {
|
||||
assertEqual(configured.syncOnSave, false, "Sync on save should remain disabled during this workflow.");
|
||||
|
||||
await prepareRemote(cli.binary, session.cliEnv);
|
||||
activityStage = "initial-idle";
|
||||
const initialIdle = await waitForRemoteActivityState(session.remoteDebuggingPort, "idle");
|
||||
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
|
||||
await pushLocalChanges(cli.binary, session.cliEnv);
|
||||
|
||||
activityStage = "one-shot-active";
|
||||
await startHeldOneShotReplication(cli.binary, session.cliEnv);
|
||||
const oneShotActive = await waitForRemoteActivityState(
|
||||
session.remoteDebuggingPort,
|
||||
"finite-replication-active"
|
||||
);
|
||||
const oneShotResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
|
||||
assertEqual(oneShotResult.error, undefined, "One-shot replication failed while its activity was observed.");
|
||||
assertEqual(oneShotResult.result, true, "One-shot replication did not report success.");
|
||||
activityStage = "one-shot-idle";
|
||||
const oneShotIdle = await waitForRemoteActivityState(session.remoteDebuggingPort, "idle");
|
||||
if (oneShotIdle.requestCount <= initialIdle.requestCount) {
|
||||
throw new Error("One-shot replication did not make an observed remote request.");
|
||||
}
|
||||
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
|
||||
|
||||
const remoteDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
@@ -118,11 +145,73 @@ async function main(): Promise<void> {
|
||||
"Remote metadata path did not match the local database entry."
|
||||
);
|
||||
|
||||
const sourceChunkId = localEntry.children[0];
|
||||
if (!sourceChunkId) throw new Error("The uploaded note did not produce a chunk for the fetch workflow.");
|
||||
const sourceChunk = remoteDocs.find((document) => document._id === sourceChunkId);
|
||||
if (!sourceChunk || sourceChunk.type !== "leaf") {
|
||||
throw new Error(`The uploaded source chunk was not found in CouchDB: ${sourceChunkId}`);
|
||||
}
|
||||
const { _rev: _sourceRevision, ...remoteOnlyChunk } = sourceChunk;
|
||||
const chunkId = `h:e2e-remote-activity-${Date.now().toString(36)}`;
|
||||
await putCouchDbDocument(couchDb, dbName, { ...remoteOnlyChunk, _id: chunkId });
|
||||
activityStage = "chunk-fetch-active";
|
||||
await startHeldChunkFetch(cli.binary, session.cliEnv, chunkId);
|
||||
const chunkFetchActive = await waitForRemoteActivityState(session.remoteDebuggingPort, "chunk-fetch-active");
|
||||
const chunkFetchResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
|
||||
assertEqual(
|
||||
chunkFetchResult.error,
|
||||
undefined,
|
||||
"On-demand chunk fetching failed while its activity was observed."
|
||||
);
|
||||
if (!chunkFetchResult.requestedIds?.includes(chunkId)) {
|
||||
throw new Error(`The on-demand chunk request did not include the selected chunk: ${chunkId}`);
|
||||
}
|
||||
if ((chunkFetchResult.resultCount ?? 0) < 1) {
|
||||
throw new Error(`The remote did not return the selected chunk: ${chunkId}`);
|
||||
}
|
||||
const restoredChunk = await waitForRestoredChunk(cli.binary, session.cliEnv, chunkId);
|
||||
assertEqual(restoredChunk.id, chunkId, "The restored chunk ID did not match the requested chunk.");
|
||||
activityStage = "chunk-fetch-idle";
|
||||
const chunkFetchIdle = await waitForRemoteActivityState(session.remoteDebuggingPort, "idle");
|
||||
if (chunkFetchIdle.requestCount <= oneShotIdle.requestCount) {
|
||||
throw new Error("On-demand chunk fetching did not make an observed remote request.");
|
||||
}
|
||||
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
|
||||
|
||||
console.log(
|
||||
`Uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s) to CouchDB database ${dbName}`
|
||||
);
|
||||
console.log(
|
||||
[
|
||||
`One-shot activity: ${oneShotActive.statusBarText.trim()} -> idle`,
|
||||
`Chunk-fetch activity: ${chunkFetchActive.statusBarText.trim()} -> idle`,
|
||||
`Balanced remote requests: ${chunkFetchIdle.requestCount}/${chunkFetchIdle.responseCount}`,
|
||||
].join("\n")
|
||||
);
|
||||
} catch (error) {
|
||||
if (session) {
|
||||
const diagnostics = await captureRemoteActivityDiagnostics(
|
||||
session.remoteDebuggingPort,
|
||||
`couchdb-upload-${activityStage}`
|
||||
).catch((diagnosticError: unknown) => {
|
||||
console.warn(
|
||||
`Could not capture remote activity diagnostics: ${
|
||||
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)
|
||||
}`
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
if (diagnostics) {
|
||||
console.error(`Remote activity screenshot: ${diagnostics.screenshotPath}`);
|
||||
console.error(`Remote activity snapshot: ${diagnostics.snapshotPath}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (session) {
|
||||
await cleanUpHeldRemoteActivity(cli.binary, session.cliEnv).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
await session.app.stop();
|
||||
}
|
||||
await vault.dispose();
|
||||
|
||||
Reference in New Issue
Block a user