mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-22 17:13:20 +00:00
Cover tweak recovery continuity in real Obsidian
This commit is contained in:
1 parent
83d40a561f
commit
456a742a72
3 files changed
+170
-6
No files matched your search
@@ -4,13 +4,16 @@
|
||||
* The source Vault creates a remote preferred profile with a missing legacy
|
||||
* filename-case value. The target has the effective false value, but differs
|
||||
* in the chunk size and V2 customisation setting. Applying the ordinary
|
||||
* remote settings action must permit a fresh synchronisation without a Fetch. A
|
||||
* remote settings action must permit a fresh synchronisation without a Fetch.
|
||||
* Separate true/false and true/missing case controls keep Fetch required.
|
||||
* An enabled but inactive internal Metadata encryption preference must survive
|
||||
* ordinary setting adoption and restart, and must not suppress the Sync now dialogue.
|
||||
* Automatic alignment and adoption which enables an encryption prerequisite also
|
||||
* exercise two-way transfer and restart against independent remote databases.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
@@ -54,6 +57,7 @@ const sourceNotePath = "E2E/tweak-compatibility/source.md";
|
||||
const restartedNotePath = "E2E/tweak-compatibility/restarted.md";
|
||||
const sourceNoteContent = `# Tweak compatibility source\n\n${"source-content ".repeat(1200)}\n`;
|
||||
const restartedNoteContent = `# Tweak compatibility restart\n\n${"restart-content ".repeat(1200)}\n`;
|
||||
const artifactRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd());
|
||||
|
||||
type MilestoneDocument = CouchDbDocument & {
|
||||
tweak_values?: Record<string, unknown>;
|
||||
@@ -208,6 +212,7 @@ async function startSession(
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
artifactRoot,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
...(pluginData === undefined ? {} : { pluginData }),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
@@ -242,18 +247,24 @@ async function readTweakState(
|
||||
usePluginSyncV2: unknown;
|
||||
handleFilenameCaseSensitive: unknown;
|
||||
encryptInternalMetadata: unknown;
|
||||
encrypt: unknown;
|
||||
usePathObfuscation: unknown;
|
||||
tweakModified: unknown;
|
||||
}> {
|
||||
return await evalObsidianJson<{
|
||||
customChunkSize: unknown;
|
||||
usePluginSyncV2: unknown;
|
||||
handleFilenameCaseSensitive: unknown;
|
||||
encryptInternalMetadata: unknown;
|
||||
encrypt: unknown;
|
||||
usePathObfuscation: unknown;
|
||||
tweakModified: unknown;
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({customChunkSize:settings.customChunkSize,usePluginSyncV2:settings.usePluginSyncV2,handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,encryptInternalMetadata:settings.encryptInternalMetadata});",
|
||||
"return JSON.stringify({customChunkSize:settings.customChunkSize,usePluginSyncV2:settings.usePluginSyncV2,handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,encryptInternalMetadata:settings.encryptInternalMetadata,encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,tweakModified:settings.tweakModified});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
@@ -337,12 +348,158 @@ async function verifyStaleTargetChoice(
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyRecoveryContinuity(
|
||||
binary: string,
|
||||
cliBinary: string,
|
||||
couchDb: CouchDbConfig,
|
||||
scenario: "automatic" | "encrypt" | "usePathObfuscation"
|
||||
): Promise<void> {
|
||||
const automatic = scenario === "automatic";
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, `tweak-${scenario.toLowerCase()}`);
|
||||
const settings = couchDbSettings(couchDb, dbName);
|
||||
const sourceVault = await createTemporaryVault();
|
||||
const targetVault = await createTemporaryVault();
|
||||
const sourceSettings = {
|
||||
encrypt: !automatic,
|
||||
usePathObfuscation: !automatic,
|
||||
encryptInternalMetadata: false,
|
||||
passphrase: "tweak-recovery-e2e-secret",
|
||||
E2EEAlgorithm: "v2",
|
||||
idDerivationVersion: 0,
|
||||
customChunkSize: 0,
|
||||
autoAcceptCompatibleTweak: false,
|
||||
};
|
||||
const expectedState = {
|
||||
encrypt: !automatic,
|
||||
usePathObfuscation: !automatic,
|
||||
encryptInternalMetadata: automatic,
|
||||
customChunkSize: 0,
|
||||
};
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const assertAdoptedSettings = async () => {
|
||||
assert(session, "The recovery scenario has no active Obsidian session.");
|
||||
const state = await readTweakState(cliBinary, session.cliEnv);
|
||||
for (const [key, value] of Object.entries(expectedState)) {
|
||||
assertEqual(state[key as keyof typeof state], value, `${scenario}: incorrect adopted ${key}.`);
|
||||
}
|
||||
const milestone = (await fetchCouchDbDocument(couchDb, dbName, milestoneId)) as MilestoneDocument;
|
||||
const preferred = milestone.tweak_values?.PREFERRED;
|
||||
assert(isRecord(preferred), `${scenario}: the remote preferred profile is missing.`);
|
||||
for (const [key, value] of Object.entries({ ...expectedState, encryptInternalMetadata: false })) {
|
||||
assertEqual(preferred[key], value, `${scenario}: incorrect remote ${key}.`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await createCouchDbDatabase(couchDb, dbName);
|
||||
console.log(`Recovery continuity scenario: ${scenario} (${dbName})`);
|
||||
session = await prepareConfiguredSession(binary, cliBinary, sourceVault, settings, sourceSettings);
|
||||
await prepareRemote(cliBinary, session.cliEnv);
|
||||
await writeNote(cliBinary, session.cliEnv, sourceNotePath, sourceNoteContent);
|
||||
await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, sourceNotePath);
|
||||
assertEqual(
|
||||
(await replicateOnce(cliBinary, session.cliEnv)).succeeded,
|
||||
true,
|
||||
`${scenario}: source upload failed.`
|
||||
);
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
|
||||
session = await prepareConfiguredSession(binary, cliBinary, targetVault, settings, {
|
||||
...sourceSettings,
|
||||
encryptInternalMetadata: true,
|
||||
...(automatic ? { customChunkSize: 60, autoAcceptCompatibleTweak: true } : { [scenario]: false }),
|
||||
});
|
||||
await prepareRemote(cliBinary, session.cliEnv);
|
||||
const initialState = await readTweakState(cliBinary, session.cliEnv);
|
||||
assertEqual(initialState.encryptInternalMetadata, true, `${scenario}: the saved preference is not enabled.`);
|
||||
// The target was configured last. Make the remote profile newer so automatic
|
||||
// alignment must adopt its chunk setting, rather than publish the target's.
|
||||
if (automatic) {
|
||||
assertEqual(initialState.customChunkSize, 60, "The automatic alignment fixture has no chunk mismatch.");
|
||||
const milestone = (await fetchCouchDbDocument(couchDb, dbName, milestoneId)) as MilestoneDocument;
|
||||
const preferred = milestone.tweak_values?.PREFERRED;
|
||||
assert(isRecord(preferred), "The automatic alignment fixture has no remote preferred profile.");
|
||||
assertEqual(preferred.customChunkSize, 0, "The remote fixture has the wrong chunk setting.");
|
||||
preferred.tweakModified =
|
||||
(typeof initialState.tweakModified === "number" ? initialState.tweakModified : 0) + 1;
|
||||
await putCouchDbDocument(couchDb, dbName, milestone);
|
||||
} else {
|
||||
assertEqual(initialState[scenario], false, `${scenario}: the encryption prerequisite is already enabled.`);
|
||||
}
|
||||
const adoption = replicateOnce(cliBinary, session.cliEnv);
|
||||
if (!automatic) {
|
||||
await selectCompatibilityAction(
|
||||
session.remoteDebuggingPort,
|
||||
applySettingsWithFetchAction,
|
||||
applySettingsAction
|
||||
);
|
||||
}
|
||||
await adoption;
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await assertAdoptedSettings();
|
||||
if (!automatic) {
|
||||
// The real Fetch must reflect the source before a subsequent replication
|
||||
// could hide a failure to rebuild or decrypt the local database.
|
||||
await waitForPathContent(targetVault.path, sourceNotePath, sourceNoteContent);
|
||||
}
|
||||
assertEqual(
|
||||
(await replicateOnce(cliBinary, session.cliEnv)).succeeded,
|
||||
true,
|
||||
`${scenario}: synchronisation after adoption failed.`
|
||||
);
|
||||
await waitForPathContent(targetVault.path, sourceNotePath, sourceNoteContent);
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
|
||||
session = await startSession(binary, cliBinary, targetVault);
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await assertE2eCompatibilityMarker(cliBinary, session.cliEnv);
|
||||
await assertAdoptedSettings();
|
||||
await writeNote(cliBinary, session.cliEnv, restartedNotePath, restartedNoteContent);
|
||||
await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, restartedNotePath);
|
||||
assertEqual(
|
||||
(await replicateOnce(cliBinary, session.cliEnv)).succeeded,
|
||||
true,
|
||||
`${scenario}: upload after restart failed.`
|
||||
);
|
||||
await assertAdoptedSettings();
|
||||
await session.app.stop();
|
||||
session = undefined;
|
||||
|
||||
session = await startSession(binary, cliBinary, sourceVault);
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
assertEqual(
|
||||
(await replicateOnce(cliBinary, session.cliEnv)).succeeded,
|
||||
true,
|
||||
`${scenario}: return transfer failed.`
|
||||
);
|
||||
await waitForPathContent(sourceVault.path, restartedNotePath, restartedNoteContent);
|
||||
console.log(`Recovery continuity passed: ${scenario}, persisted settings, restart, and two-way content.`);
|
||||
} finally {
|
||||
if (session) await session.app.stop().catch(() => undefined);
|
||||
await Promise.all([sourceVault.dispose(), targetVault.dispose()]);
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(", ")}`);
|
||||
}
|
||||
console.log(`Plug-in artefacts: ${artifactRoot}`);
|
||||
for (const name of ["main.js", "manifest.json", "styles.css"]) {
|
||||
const hash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile(join(artifactRoot, name))))
|
||||
.digest("hex");
|
||||
console.log(`SHA-256 ${name}: ${hash}`);
|
||||
}
|
||||
|
||||
const couchDb = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "tweak-compatibility");
|
||||
@@ -505,6 +662,9 @@ async function main(): Promise<void> {
|
||||
controlSession = undefined;
|
||||
|
||||
console.log("Tweak compatibility also rejected a stale Fetch choice after the remote changed.");
|
||||
for (const scenario of ["automatic", "encrypt", "usePathObfuscation"] as const) {
|
||||
await verifyRecoveryContinuity(binary, cli.binary, couchDb, scenario);
|
||||
}
|
||||
} finally {
|
||||
if (sourceSession) await sourceSession.app.stop().catch(() => undefined);
|
||||
if (targetSession) await targetSession.app.stop().catch(() => undefined);
|
||||
|
||||
Reference in new issue
Block a user