Require explicit review for restored Vaults

This commit is contained in:
vorotamoroz
2026-07-19 07:05:22 +00:00
parent d28282edad
commit f0ec046cf4
14 changed files with 410 additions and 121 deletions
+4 -2
View File
@@ -82,12 +82,14 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, Svelte dialogue mounting, settings UI, vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
`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.
`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, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, 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 two remote-activity status boundaries. It first holds a real CouchDB request at the selected fetch implementation and confirms that `🌐N` is visible while `📲` is absent. It then holds the real one-shot replication immediately before its replicator call, confirms that `📲` is visible while no physical request is active, releases it, and requires the finite and bounded activity counts to return to zero, the request and response counts to balance, and both indicators to disappear. Finally, it creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same logical active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active states deterministic without replacing the remote request or 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`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, and the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
The two-Vault workflow performs the missing-marker review once for each isolated Vault. Later process launches seed the acknowledgement which the same real profile would retain, rather than repeatedly applying a first-device decision. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode.
`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.
@@ -111,7 +113,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, per-device target filters where one vault ignores a note that the other vault synchronises, and a separate encrypted round-trip with Path Obfuscation enabled. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict automatic merge check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`, but it is not part of the default local suite.
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises.
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one mobile-safe Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
+221 -46
View File
@@ -1,7 +1,11 @@
import { evalObsidianJson } from "./cli.ts";
import { SERVICE_CONTEXT_MEMBERS } from "../../contracts/serviceContext.ts";
import { DATABASE_COMPATIBILITY_VERSION_KEY } from "../../../src/common/databaseCompatibility.ts";
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { CouchDbConfig } from "./couchdb.ts";
import type { ObjectStorageConfig } from "./objectStorage.ts";
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
export type ConfiguredSettings = {
isConfigured: boolean;
@@ -21,6 +25,28 @@ export type CoreReadiness = {
appReady: boolean;
};
export type ReplicationAttempt = CoreReadiness & {
succeeded: boolean;
isOnline: boolean;
activeReplicator: string;
versionUpFlash: string;
unresolvedMessages: unknown[];
};
export type CompatibilityMarkerState = {
vaultName: string;
additionalSuffix: string;
expectedStorageKey: string;
rawStorageValue: string | null;
serviceValue: string;
versionUpFlash: string;
};
export type ResumeCompatibilityReviewOptions = {
verifyMissingDeviceMarkerExplanation?: boolean;
screenshotPrefix?: string;
};
export type ObsidianServiceContextContractResult = {
contextType: string;
eventResult: string[];
@@ -40,28 +66,170 @@ export type LocalDatabaseEntry = {
children: string[];
};
function e2ePreferredSettingsSource(): string[] {
return [
"liveSync:false,",
"syncOnStart:false,",
"syncOnSave:false,",
"usePluginSync:false,",
"usePluginSyncV2:true,",
"useEden:false,",
"customChunkSize:60,",
"sendChunksBulk:false,",
"sendChunksBulkMaxSize:1,",
"chunkSplitterVersion:'v3-rabin-karp',",
"readChunksOnline:true,",
"disableCheckingConfigMismatch:false,",
"enableCompression:false,",
"hashAlg:'xxhash64',",
"handleFilenameCaseSensitive:false,",
"doNotUseFixedRevisionForChunks:true,",
"E2EEAlgorithm:'v2',",
"doctorProcessedVersion:'0.25.27',",
"isConfigured:true,",
];
const E2E_PREFERRED_SETTINGS = {
liveSync: false,
syncOnStart: false,
syncOnSave: false,
usePluginSync: false,
usePluginSyncV2: true,
useEden: false,
customChunkSize: 60,
sendChunksBulk: false,
sendChunksBulkMaxSize: 1,
chunkSplitterVersion: "v3-rabin-karp",
readChunksOnline: true,
disableCheckingConfigMismatch: false,
enableCompression: false,
hashAlg: "xxhash64",
handleFilenameCaseSensitive: false,
doNotUseFixedRevisionForChunks: true,
E2EEAlgorithm: "v2",
doctorProcessedVersion: "0.25.27",
settingVersion: CURRENT_SETTING_VERSION,
isConfigured: true,
} as const;
export function createE2eObsidianDeviceLocalState(
vaultName: string,
additionalSuffixOfDatabaseName = ""
): Readonly<Record<string, string>> {
return {
[`${vaultName}-${additionalSuffixOfDatabaseName}-${DATABASE_COMPATIBILITY_VERSION_KEY}`]: `${VER}`,
};
}
export async function readE2eCompatibilityMarker(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<CompatibilityMarkerState> {
return await evalObsidianJson<CompatibilityMarkerState>(
cliBinary,
[
"(()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const setting=core.services.setting;",
"const settings=setting.currentSettings();",
"const vaultName=core.services.API.getSystemVaultName();",
`const markerKey=${JSON.stringify(DATABASE_COMPATIBILITY_VERSION_KEY)};`,
"const additionalSuffix=`-${settings.additionalSuffixOfDatabaseName??''}`;",
"const expectedStorageKey=`${vaultName}${additionalSuffix}-${markerKey}`;",
"return JSON.stringify({",
"vaultName,additionalSuffix,expectedStorageKey,",
"rawStorageValue:localStorage.getItem(expectedStorageKey),",
"serviceValue:setting.getSmallConfig(markerKey),",
"versionUpFlash:settings.versionUpFlash,",
"});",
"})()",
].join(""),
env
);
}
export async function assertE2eCompatibilityMarker(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<CompatibilityMarkerState> {
const state = await readE2eCompatibilityMarker(cliBinary, env);
if (state.serviceValue !== `${VER}`) {
throw new Error(
`The E2E compatibility marker was not available on first plug-in load: ${JSON.stringify(state)}`
);
}
return state;
}
export async function assertE2eCompatibilityReviewPending(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<CompatibilityMarkerState> {
const state = await readE2eCompatibilityMarker(cliBinary, env);
if (state.serviceValue !== "" || state.rawStorageValue !== null || state.versionUpFlash === "") {
throw new Error(`The copied-Vault compatibility review was not pending: ${JSON.stringify(state)}`);
}
return state;
}
export async function resumeCompatibilityReview(
port: number,
options: ResumeCompatibilityReviewOptions = {}
): Promise<void> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
const title = "Synchronisation paused for compatibility review";
const summaryLocator = (page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) =>
page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
if (options.screenshotPrefix) {
const summaryScreenshot = await captureObsidianDialogue(
port,
`${options.screenshotPrefix}-summary.png`,
async (page) => {
await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs });
}
);
console.log(`Compatibility review summary screenshot: ${summaryScreenshot}`);
}
if (options.verifyMissingDeviceMarkerExplanation === true) {
await withObsidianPage(port, async (page) => {
const summary = summaryLocator(page);
await summary.waitFor({ state: "visible", timeout: timeoutMs });
await summary.getByRole("button", { name: "Review compatibility details" }).click();
});
const detailsScreenshot = options.screenshotPrefix
? await captureObsidianDialogue(port, `${options.screenshotPrefix}-details.png`, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.waitFor({ state: "visible", timeout: timeoutMs });
await details.getByText("copied or restored", { exact: false }).waitFor({
state: "visible",
timeout: timeoutMs,
});
await details.getByText("new Obsidian profile", { exact: false }).waitFor({
state: "visible",
timeout: timeoutMs,
});
await details
.getByText("does not mean that it is safe to resume automatically", { exact: false })
.waitFor({
state: "visible",
timeout: timeoutMs,
});
})
: undefined;
if (detailsScreenshot) console.log(`Compatibility review details screenshot: ${detailsScreenshot}`);
await withObsidianPage(port, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.getByRole("button", { name: "Back to compatibility review" }).click();
await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
await withObsidianPage(port, async (page) => {
const summary = summaryLocator(page);
await summary.waitFor({ state: "visible", timeout: timeoutMs });
await summary.getByRole("button", { name: "Resume synchronisation" }).click();
await summary.waitFor({ state: "hidden", timeout: timeoutMs });
});
}
export function createE2eCouchDbPluginData(
settings: Pick<CouchDbConfig, "uri" | "username" | "password"> & { dbName: string },
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
couchDB_URI: settings.uri,
couchDB_USER: settings.username,
couchDB_PASSWORD: settings.password,
couchDB_DBNAME: settings.dbName,
remoteType: "",
...E2E_PREFERRED_SETTINGS,
...overrides,
};
}
export function assertEqual(actual: unknown, expected: unknown, message: string): void {
@@ -76,21 +244,14 @@ export async function configureCouchDb(
settings: Pick<CouchDbConfig, "uri" | "username" | "password"> & { dbName: string },
overrides: Record<string, unknown> = {}
): Promise<ConfiguredSettings> {
const nextSettings = createE2eCouchDbPluginData(settings, overrides);
return await evalObsidianJson<ConfiguredSettings>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const core=plugin.core;",
"const nextSettings={",
`couchDB_URI:${JSON.stringify(settings.uri)},`,
`couchDB_USER:${JSON.stringify(settings.username)},`,
`couchDB_PASSWORD:${JSON.stringify(settings.password)},`,
`couchDB_DBNAME:${JSON.stringify(settings.dbName)},`,
"remoteType:'',",
...e2ePreferredSettingsSource(),
...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`),
"};",
`const nextSettings=${JSON.stringify(nextSettings)};`,
"await core.services.setting.applyExternalSettings(nextSettings,true);",
"await core.services.control.applySettings();",
"const current=core.services.setting.currentSettings();",
@@ -115,25 +276,26 @@ export async function configureObjectStorage(
settings: ObjectStorageConfig & { bucketPrefix: string },
overrides: Record<string, unknown> = {}
): Promise<ConfiguredSettings> {
const nextSettings = {
remoteType: "MINIO",
endpoint: settings.endpoint,
accessKey: settings.accessKey,
secretKey: settings.secretKey,
bucket: settings.bucket,
region: settings.region,
forcePathStyle: settings.forcePathStyle,
bucketPrefix: settings.bucketPrefix,
bucketCustomHeaders: "",
...E2E_PREFERRED_SETTINGS,
...overrides,
};
return await evalObsidianJson<ConfiguredSettings>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const core=plugin.core;",
"const nextSettings={",
"remoteType:'MINIO',",
`endpoint:${JSON.stringify(settings.endpoint)},`,
`accessKey:${JSON.stringify(settings.accessKey)},`,
`secretKey:${JSON.stringify(settings.secretKey)},`,
`bucket:${JSON.stringify(settings.bucket)},`,
`region:${JSON.stringify(settings.region)},`,
`forcePathStyle:${JSON.stringify(settings.forcePathStyle)},`,
`bucketPrefix:${JSON.stringify(settings.bucketPrefix)},`,
"bucketCustomHeaders:'',",
...e2ePreferredSettingsSource(),
...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`),
"};",
`const nextSettings=${JSON.stringify(nextSettings)};`,
"await core.services.setting.applyExternalSettings(nextSettings,true);",
"await core.services.control.applySettings();",
"const current=core.services.setting.currentSettings();",
@@ -265,18 +427,31 @@ export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv):
}
export async function pushLocalChanges(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
const attempt = await evalObsidianJson<ReplicationAttempt>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.fileProcessing.commitPendingFileEvents();",
"const result=await core.services.replication.replicate(true);",
"return JSON.stringify({result:!!result});",
"const settings=core.services.setting.currentSettings();",
"const activeReplicator=core.services.replicator.getActiveReplicator();",
"return JSON.stringify({",
"succeeded:!!result,",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"isOnline:core.services.API.isOnline,",
"activeReplicator:activeReplicator?.constructor?.name??'(none)',",
"versionUpFlash:settings.versionUpFlash,",
"unresolvedMessages:(await core.services.appLifecycle.getUnresolvedMessages()).flat(),",
"});",
"})()",
].join(""),
env
);
if (!attempt.succeeded) {
throw new Error(`Finite replication did not start or complete: ${JSON.stringify(attempt)}`);
}
}
export async function waitForLocalDatabaseEntry(
+2
View File
@@ -9,6 +9,7 @@ export type StartObsidianLiveSyncSessionOptions = {
vault: TemporaryVault;
startupGraceMs?: number;
pluginData?: Record<string, unknown>;
localStorageEntries?: Readonly<Record<string, string>>;
};
export async function startObsidianLiveSyncSession(
@@ -22,5 +23,6 @@ export async function startObsidianLiveSyncSession(
artifactRoot: process.cwd(),
startupGraceMs: options.startupGraceMs,
pluginData: options.pluginData,
localStorageEntries: options.localStorageEntries,
});
}
@@ -11,8 +11,12 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
configureCouchDb,
createE2eCouchDbPluginData,
prepareRemote,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
@@ -101,8 +105,20 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData({
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
}),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await assertE2eCompatibilityReviewPending(cli.binary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort, {
verifyMissingDeviceMarkerExplanation: true,
screenshotPrefix: "compatibility-review-copied-vault",
});
await assertE2eCompatibilityMarker(cli.binary, session.cliEnv);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
+33 -13
View File
@@ -13,9 +13,14 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
@@ -42,6 +47,7 @@ type RunnerContext = {
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
reviewedVaults: Set<string>;
};
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
@@ -163,24 +169,32 @@ async function startConfiguredSession(
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const reviewAlreadyCompleted = context.reviewedVaults.has(vault.path);
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides),
// The real profile retains this device-local acknowledgement. Seed it
// on later process launches because the isolated Electron runner does
// not currently preserve localStorage across those launches.
localStorageEntries: reviewAlreadyCompleted ? createE2eObsidianDeviceLocalState(vault.name) : undefined,
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
overrides
);
if (!reviewAlreadyCompleted) {
await assertE2eCompatibilityReviewPending(context.cliBinary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort);
}
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
if (!reviewAlreadyCompleted) context.reviewedVaults.add(vault.path);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, overrides);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
@@ -517,8 +531,14 @@ async function main(): Promise<void> {
const vaultB = await createTemporaryVault();
const encryptedVaultA = await createTemporaryVault();
const encryptedVaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
const encryptedContext: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName: encryptedDbName };
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, reviewedVaults: new Set() };
const encryptedContext: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName: encryptedDbName,
reviewedVaults: new Set(),
};
try {
await assertCouchDbReachable(couchDb);