mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-25 03:57:06 +00:00
Require explicit review for restored Vaults
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user