mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-28 06:17:06 +00:00
252 lines
12 KiB
TypeScript
252 lines
12 KiB
TypeScript
/**
|
|
* Verifies one complete Object Storage upload from a real Obsidian Vault,
|
|
* through LiveSync's local database and Journal Sync, to an S3-compatible
|
|
* service observed independently through the AWS SDK.
|
|
*
|
|
* The isolated Vault starts with Object Storage settings and the device-local
|
|
* compatibility acknowledgement already in place. Unconfigured start-up is
|
|
* intentionally inert and belongs to the onboarding scenario; compatibility
|
|
* review and visible setup have their own dedicated workflows. Supplying those
|
|
* prerequisites here keeps this scenario focused on the upload boundary.
|
|
*
|
|
* Note creation, local-database observation, one-shot synchronisation, request
|
|
* accounting, remote-object inspection, and prefix cleanup remain in one
|
|
* scenario so that a pass proves the same payload crossed every boundary.
|
|
* Separate successes would not prove that those observations belonged to the
|
|
* same upload.
|
|
*/
|
|
import { evalObsidianJson } from "../runner/cli.ts";
|
|
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
|
import {
|
|
assertEqual,
|
|
configureObjectStorage,
|
|
createE2eObjectStoragePluginData,
|
|
createE2eObsidianDeviceLocalState,
|
|
prepareRemote,
|
|
pushLocalChanges,
|
|
waitForLiveSyncCoreReady,
|
|
type LocalDatabaseEntry,
|
|
} from "../runner/liveSyncWorkflow.ts";
|
|
import {
|
|
deleteObjectStoragePrefix,
|
|
ensureObjectStorageBucket,
|
|
listObjectStorageObjects,
|
|
loadObjectStorageConfig,
|
|
makeUniqueBucketPrefix,
|
|
readObjectStorageJson,
|
|
} from "../runner/objectStorage.ts";
|
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
|
import { createTemporaryVault } from "../runner/vault.ts";
|
|
import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../runner/remoteActivity.ts";
|
|
|
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
|
|
|
const adaptive = process.argv.includes("--adaptive");
|
|
const unsupportedArguments = process.argv.slice(2).filter((argument) => argument !== "--adaptive");
|
|
if (unsupportedArguments.length > 0) {
|
|
throw new Error(`Unsupported Object Storage upload argument: ${unsupportedArguments.join(", ")}`);
|
|
}
|
|
const journalSettings = adaptive
|
|
? {
|
|
journalFormat: "adaptive-v1",
|
|
packReadPolicy: "range",
|
|
}
|
|
: {};
|
|
const scenarioName = adaptive ? "Adaptive S3" : "Object Storage";
|
|
const notePath = adaptive ? "E2E/adaptive-s3-upload.md" : "E2E/minio-upload.md";
|
|
const noteContent = [
|
|
`# ${scenarioName} upload from real Obsidian`,
|
|
"",
|
|
"This note is created through Obsidian and uploaded by Self-hosted LiveSync to S3-compatible Object Storage.",
|
|
"The test is intentionally small, but it crosses the real Obsidian, Journal Sync, and AWS SDK boundary.",
|
|
`Created at: ${new Date().toISOString()}`,
|
|
"",
|
|
].join("\n");
|
|
|
|
async function createNoteAndWaitForLocalDb(cliBinary: string, env: NodeJS.ProcessEnv): Promise<LocalDatabaseEntry> {
|
|
return await evalObsidianJson<LocalDatabaseEntry>(
|
|
cliBinary,
|
|
[
|
|
"(async()=>{",
|
|
`const path=${JSON.stringify(notePath)};`,
|
|
`const content=${JSON.stringify(noteContent)};`,
|
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
|
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
|
|
"const existing=app.vault.getAbstractFileByPath(path);",
|
|
"if(existing) await app.vault.delete(existing);",
|
|
"await app.vault.create(path,content);",
|
|
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
|
|
"let entry=false;",
|
|
"for(let i=0;i<40;i++){",
|
|
"await core.services.fileProcessing.commitPendingFileEvents();",
|
|
"entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);",
|
|
"if(entry&&entry._id&&Array.isArray(entry.children)&&entry.children.length>0) break;",
|
|
"await sleep(250);",
|
|
"}",
|
|
"if(!entry||!entry._id) throw new Error('Timed out waiting for local database entry');",
|
|
"return JSON.stringify({id:entry._id,path:entry.path,type:entry.type,children:entry.children||[]});",
|
|
"})()",
|
|
].join(""),
|
|
env
|
|
);
|
|
}
|
|
|
|
async function waitForObjectStorageObjects(prefix: string, requiredKeyPrefix?: string): Promise<string[]> {
|
|
const objectStorage = await loadObjectStorageConfig();
|
|
const timeoutMs = Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 20000);
|
|
const deadline = Date.now() + timeoutMs;
|
|
let keys: string[] = [];
|
|
while (Date.now() < deadline) {
|
|
const objects = await listObjectStorageObjects(objectStorage, prefix);
|
|
keys = objects.flatMap((object) => (object.Key ? [object.Key] : []));
|
|
if (keys.length > 0 && (!requiredKeyPrefix || keys.some((key) => key.startsWith(requiredKeyPrefix)))) {
|
|
return keys;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for Object Storage objects under ${prefix}${requiredKeyPrefix ? ` with prefix ${requiredKeyPrefix}` : ""}. Last keys: ${keys.join(", ")}`
|
|
);
|
|
}
|
|
|
|
async function assertAdaptiveObjects(prefix: string, keys: string[]): Promise<void> {
|
|
const objectStorage = await loadObjectStorageConfig();
|
|
const manifestKey = `${prefix}a1~manifest.json`;
|
|
const requiredPrefixes = [`${prefix}a1~writer~`, `${prefix}a1~commit~`];
|
|
if (!keys.includes(manifestKey)) {
|
|
throw new Error(`Adaptive Journal manifest is missing. Keys: ${keys.join(", ")}`);
|
|
}
|
|
for (const requiredPrefix of requiredPrefixes) {
|
|
if (!keys.some((key) => key.startsWith(requiredPrefix))) {
|
|
throw new Error(`Adaptive Journal object prefix ${requiredPrefix} is missing. Keys: ${keys.join(", ")}`);
|
|
}
|
|
}
|
|
if (keys.includes(`${prefix}_00000000-milestone.json`)) {
|
|
throw new Error("Adaptive Journal wrote the legacy Opaque Journal milestone.");
|
|
}
|
|
|
|
const manifest = await readObjectStorageJson<{
|
|
format?: unknown;
|
|
formatVersion?: unknown;
|
|
manifestAuth?: unknown;
|
|
objectLayout?: unknown;
|
|
repositoryId?: unknown;
|
|
}>(objectStorage, manifestKey);
|
|
assertEqual(manifest.format, "adaptive-journal", "Unexpected Adaptive Journal manifest format.");
|
|
assertEqual(manifest.formatVersion, 1, "Unexpected Adaptive Journal manifest version.");
|
|
assertEqual(manifest.objectLayout, "commit-bundle-v1", "Unexpected Adaptive Journal object layout.");
|
|
if (typeof manifest.repositoryId !== "string" || manifest.repositoryId.length === 0) {
|
|
throw new Error("Adaptive Journal manifest did not contain a repository ID.");
|
|
}
|
|
if (typeof manifest.manifestAuth !== "string" || manifest.manifestAuth.length === 0) {
|
|
throw new Error("Adaptive Journal manifest did not contain its authentication value.");
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const binary = requireObsidianBinary();
|
|
const cli = discoverObsidianCli();
|
|
if (!cli.binary) {
|
|
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
|
}
|
|
|
|
const objectStorage = await loadObjectStorageConfig();
|
|
const bucketPrefix = makeUniqueBucketPrefix(adaptive ? "adaptive-s3-upload" : "minio-upload");
|
|
const vault = await createTemporaryVault();
|
|
let session: ObsidianLiveSyncSession | undefined;
|
|
|
|
try {
|
|
await ensureObjectStorageBucket(objectStorage);
|
|
|
|
console.log(`Using Obsidian executable: ${binary}`);
|
|
console.log(`Temporary vault: ${vault.path}`);
|
|
console.log(`Temporary Object Storage bucket: ${objectStorage.bucket}`);
|
|
console.log(`Temporary Object Storage prefix: ${bucketPrefix}`);
|
|
|
|
session = await startObsidianLiveSyncSession({
|
|
binary,
|
|
cliBinary: cli.binary,
|
|
vault,
|
|
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
|
pluginData: createE2eObjectStoragePluginData(
|
|
{
|
|
...objectStorage,
|
|
bucketPrefix,
|
|
},
|
|
journalSettings
|
|
),
|
|
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
|
});
|
|
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
|
|
|
const configured = await configureObjectStorage(
|
|
cli.binary,
|
|
session.cliEnv,
|
|
{
|
|
...objectStorage,
|
|
bucketPrefix,
|
|
},
|
|
journalSettings
|
|
);
|
|
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
|
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not marked as configured.");
|
|
assertEqual(configured.remoteType, "MINIO", "Remote type was not Object Storage.");
|
|
assertEqual(configured.endpoint, objectStorage.endpoint, "Configured Object Storage endpoint did not match.");
|
|
assertEqual(configured.bucket, objectStorage.bucket, "Configured Object Storage bucket did not match.");
|
|
assertEqual(configured.bucketPrefix, bucketPrefix, "Configured Object Storage bucket prefix did not match.");
|
|
assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow.");
|
|
if (adaptive) {
|
|
assertEqual(configured.journalFormat, "adaptive-v1", "Adaptive Journal format was not retained.");
|
|
assertEqual(configured.packReadPolicy, "range", "Adaptive Journal Pack retrieval was not retained.");
|
|
assertEqual(configured.expectedRepositoryId, "", "A new Adaptive repository should not be pre-bound.");
|
|
}
|
|
|
|
await prepareRemote(cli.binary, session.cliEnv);
|
|
const activityBeforeUpload = await waitForRemoteActivityState(
|
|
session.remoteDebuggingPort,
|
|
REMOTE_ACTIVITY_EXPECTED_STATE.idle
|
|
);
|
|
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
|
|
await pushLocalChanges(cli.binary, session.cliEnv);
|
|
const activityAfterUpload = await waitForRemoteActivityState(
|
|
session.remoteDebuggingPort,
|
|
REMOTE_ACTIVITY_EXPECTED_STATE.idle
|
|
);
|
|
if (activityAfterUpload.requestCount <= activityBeforeUpload.requestCount) {
|
|
throw new Error("Object Storage synchronisation did not advance the tracked remote-request count.");
|
|
}
|
|
assertEqual(
|
|
activityAfterUpload.responseCount,
|
|
activityAfterUpload.requestCount,
|
|
"Object Storage remote-request counters did not rebalance after synchronisation."
|
|
);
|
|
|
|
const keys = await waitForObjectStorageObjects(
|
|
bucketPrefix,
|
|
adaptive ? `${bucketPrefix}a1~commit~` : undefined
|
|
);
|
|
if (adaptive) {
|
|
await assertAdaptiveObjects(bucketPrefix, keys);
|
|
}
|
|
|
|
console.log(
|
|
`Uploaded ${localEntry.path} through ${scenarioName} Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}`
|
|
);
|
|
} finally {
|
|
if (session) {
|
|
await session.app.stop();
|
|
}
|
|
await vault.dispose();
|
|
if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") {
|
|
await deleteObjectStoragePrefix(objectStorage, bucketPrefix).catch((error: unknown) => {
|
|
console.warn(error instanceof Error ? error.message : error);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
console.error(error instanceof Error ? error.stack : error);
|
|
process.exit(1);
|
|
});
|