fix: initialise missing compatibility markers without pausing

This commit is contained in:
vorotamoroz
2026-09-29 08:28:01 +00:00
parent b83f837065
commit 126adcb955
22 changed files with 326 additions and 212 deletions
@@ -45,17 +45,17 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
- Continue to use the internal database version `VER` for changes which require explicit compatibility review. Changing the plug-in SemVer alone does not increment `VER`.
- Store the last acknowledged internal database version through Commonlib's device-local small-configuration contract under `database-compatibility-version`. Copy the legacy raw local-storage value into that contract once, then remove the legacy key after the copy has completed.
- Initialise the marker to the current `VER` only when Commonlib identifies a genuinely new Vault with no pending review. A configured existing Vault with a missing or invalid marker requires review instead of being silently accepted.
- Initialise an absent marker to the current `VER` when no other compatibility reason requires review. This applies to both new and configured existing Vaults. An invalid marker still requires review.
- Defer database compatibility evaluation for an existing unconfigured Vault. It cannot replicate, so do not persist a misleading pause or acknowledge its missing marker while onboarding is still pending. Keep the marker absent so a later configured start evaluates the same state before ordinary synchronisation.
- Treat a missing marker on a configured Vault as an ambiguous device transition. Copying or restoring a Vault, or opening it with a new Obsidian profile, can preserve settings and database files without preserving device-local storage. Do not infer acknowledgement from an empty local database: a recovery operation, partial copy, or remote-first setup can also produce that state. Explain these cases and require an explicit decision in the compatibility dialogue.
- A missing device-local marker alone is not evidence of incompatibility. A new device, a copied or restored Vault, a new Obsidian profile, or settings imported into another database namespace may have no marker. Initialise it without opening a review when all other checks permit this. Keep remote format, feature, and settings checks independent of this local baseline.
- Derive one structured pause from the acknowledged database version, Commonlib's settings-migration state, and any persisted legacy review message. Persist the generic `versionUpFlash` message without changing any automatic synchronisation setting, because Commonlib already treats that field as a replication gate.
- Treat non-empty `versionUpFlash` as a runtime replication gate. Standard and one-shot replication must stop before remote work begins.
- Apply the same ordinary replication policy to P2P pull, push, and peer-requested synchronisation. An explicitly confirmed Fetch or Rebuild may bypass the ordinary policy because it is the operation selected to construct or recover the local state.
- Present the reason in a dedicated dialogue after the Obsidian layout is ready. The details view is explanatory only and returns to the summary before any decision can be made. The safe default and closing either dialogue keep synchronisation paused. A persistent Notice and a command allow the dialogue to be reopened without using the settings pane.
- Let the person read focused compatibility details without presenting the whole release history as a safety instruction. The Change Log remains a manually opened release-history pane and contains no compatibility acknowledgement control.
- Offer an explicit resume action only when every reason is recoverable in the running implementation. An upgrade, a missing or invalid marker on an existing Vault, and a reviewed migration from an older settings schema are resumable after all devices have been updated. A downgrade from a newer acknowledged `VER`, or settings saved by a future schema, cannot be acknowledged by the older installation.
- Offer an explicit resume action only when every reason is recoverable in the running implementation. An upgrade, an invalid marker on an existing Vault, and a reviewed migration from an older settings schema are resumable after all devices have been updated. A downgrade from a newer acknowledged `VER`, or settings saved by a future schema, cannot be acknowledged by the older installation.
- On resume, clear `versionUpFlash` and persist that fail-closed change before recording the current `VER` as acknowledged. If saving fails, restore the gate. Reapply settings only after the marker has advanced so that the previously configured synchronisation behaviour can resume without reconstruction.
- Preserve the original legacy review message as a structured reason when no more specific database or settings-schema reason is available. Escape it before including it in Markdown UI.
- Preserve the original legacy review message as a structured reason when no more specific database or settings-schema reason is available. This includes a previously saved generic pause: its original cause cannot be inferred from an absent marker, so it still requires explicit resume. Escape it before including it in Markdown UI.
- Continue to reject a remote version document which is newer than the running implementation. That receiver-side check is independent of the local upgrade review.
- From remote generation 13, assess the `used_features` list in that document as a separate compatibility dimension. A client must recognise every listed feature before it interprets the database or runs maintenance which depends on Metadata. Declare a feature before writing its representation, and retain the declaration while older data may depend on it. An unknown identifier is reported as text without requiring a descriptive label in that client.
- Do not advance the device-local `VER` acknowledgement merely because a remote feature is introduced. The remote generation and its feature list govern remote admission; `VER` remains the local compatibility review gate. Connecting to a generation-12 database does not promote it solely because the client understands generation 13.
@@ -78,7 +78,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated.
- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume.
- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap.
- Never mark compatibility as acknowledged merely because fetch, rebuild, or local database reset completed. The person must still use the explicit resume action. This keeps destructive recovery intent separate from protocol and settings compatibility acknowledgement.
- Never acknowledge a pending compatibility review merely because Fetch, Rebuild, or local database reset completed. The person must still use the explicit resume action for that review. This keeps destructive recovery intent separate from protocol and settings compatibility acknowledgement.
## Consequences
@@ -87,7 +87,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
- An internal compatibility change remains fail-closed for replication, but it no longer destroys the person's synchronisation preferences.
- A new installation has no previous internal-version marker and therefore does not show an upgrade review. Its initial settings and onboarding remain responsible for keeping replication disabled until configuration is complete.
- An existing unconfigured installation also remains on onboarding without a compatibility warning. Unlike a genuinely new Vault, it does not receive an acknowledgement marker; activation leaves the compatibility decision for its next configured start.
- A copied or restored configured Vault can show a one-time compatibility review on its new device or profile. This is intentional even when its local database appears empty, because emptiness does not prove how the Vault was produced.
- A copied or restored configured Vault starts without a review when its device-local marker is absent and no other review is pending. Known version differences, invalid markers, settings-schema issues, and saved reviews still require the appropriate action.
- A genuinely new Vault receives current recommendations without applying them as fallbacks to an existing configuration. It remains inert until onboarding is accepted.
- Accepted new-device and existing-device setup cannot enable ordinary processing before the selected Rebuild or Fetch has been reserved.
- An older installation cannot dismiss evidence that a newer implementation or settings schema has already been used on the device.
@@ -96,11 +96,11 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
## Verification
- Unit tests verify new-Vault initialisation, upgrades, missing and invalid markers, downgrades, future settings schemas, legacy marker migration, acknowledgement ordering, and save-failure recovery while retaining automatic synchronisation choices.
- Unit tests verify initialisation of missing markers in new and existing Vaults, upgrades, invalid markers, downgrades, future settings schemas, retained earlier reviews, legacy marker migration, acknowledgement ordering, and save-failure recovery while retaining automatic synchronisation choices.
- Commonlib package tests verify conservative stored-setting completion, independently mutable new-Vault settings, legacy file-name case normalisation, future-schema protection, and the focused settings entry from a clean consumer.
- Host unit tests verify new-Vault factory use, conservative import paths, the unconfigured start-up gate, deferred compatibility evaluation and later re-evaluation, flag-before-settings ordering, rollback when the flag cannot be reserved, ordinary configured edits, and compatibility acknowledgement persistence.
- Unit tests verify that a pending review is honoured by the packaged Commonlib replication service before remote activity begins.
- Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator.
- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control.
- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches.
- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies that it is initialised without a pause, and completes remote Metadata, Chunk, and activity checks. The two-Vault workflow checks the same automatic initialisation and reuses the profile state on later launches. The Object Storage Setup URI and QR workflows complete Fetch and a two-device round trip without an acknowledgement action, and check the marker after restart. The QR fixture carries a distinct database suffix and checks its namespace and marker before Fetch resets the local database using the receiving device's own suffix.
- Unit tests fix configured Vault admission at priority 1, the three flag-file recovery priorities at 5, 10, and 20, and compatibility review at priority 30. A recovery which stops start-up therefore cannot race the compatibility dialogue.
+1 -1
View File
@@ -71,7 +71,7 @@ This pane always shows the current release history. It does not track whether a
Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. After the Obsidian layout is ready, a pending review opens as **Synchronisation paused for compatibility review**. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. Closing it or selecting **Keep synchronisation paused** leaves synchronisation paused. Use the persistent Notice's **Review why** link, or run the `Review why synchronisation is paused` command, to reopen it. Opening **Change Log** does not acknowledge the review.
A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. When the detected state can be handled by the running version, **Resume synchronisation** records the current internal database version and restores the configured behaviour. An older installation cannot dismiss a pause caused by a newer database or settings version.
An absent device-local acknowledgement alone does not require a review, including when adding a device or opening a copied Vault in a new profile. LiveSync records the current internal database version if no other review is pending. An existing unconfigured Vault remains in onboarding; its next configured start evaluates compatibility before recording an absent marker. Known version differences, an invalid marker, settings-schema issues, and an already saved review still require attention. When the detected state can be handled by the running version, **Resume synchronisation** records the current internal database version and restores the configured behaviour. An older installation cannot dismiss a pause caused by a newer database or settings version.
## 1. Quick Setup and Extra menus
+1 -1
View File
@@ -55,7 +55,7 @@ At start-up, LiveSync can restore a missing device-local revision record when th
## Synchronisation is paused for compatibility review
A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, or when a configured Vault is copied, restored, or opened in a new Obsidian profile without its device-local acknowledgement.
A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, when the saved version marker is invalid, or when an earlier review remains pending. Adding a device or opening a copied Vault with no device-local acknowledgement does not itself trigger a review. A pause already saved by an earlier release still needs the explicit resume action, because the saved message does not identify its original cause.
The **Synchronisation paused for compatibility review** dialogue opens after the Obsidian layout is ready. If it has been closed, use the persistent Notice's **Review why** link, or run `Review why synchronisation is paused` from the command palette. Opening **Change Log** does not clear the pause.
+1
View File
@@ -71,6 +71,7 @@
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
"test:e2e:obsidian:object-storage-qr-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts --qr",
"test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts --custom-http-handler",
"test:e2e:obsidian:p2p-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts",
"pretest:e2e:obsidian:p2p-connection-check": "npm run build && npm run build --workspace webpeer",
+5 -16
View File
@@ -8,7 +8,7 @@ export const DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX = "obsidian-live-s
export const COMPATIBILITY_PAUSE_SETTING_MESSAGE =
"Remote synchronisation is paused until this device's compatibility review has been completed.";
export type DatabaseCompatibilityVersionState = "missing" | "invalid" | "upgrade" | "downgrade";
export type DatabaseCompatibilityVersionState = "invalid" | "upgrade" | "downgrade";
export interface DatabaseCompatibilityReason {
source: "database-version";
@@ -57,18 +57,9 @@ export interface CompatibilityEvaluationInput {
function databaseVersionReason(
acknowledgedVersion: string | null,
currentVersion: number,
isNewVault: boolean
currentVersion: number
): DatabaseCompatibilityReason | undefined {
if (acknowledgedVersion === null || acknowledgedVersion === "") {
if (isNewVault) return undefined;
return {
source: "database-version",
state: "missing",
currentVersion,
resumable: true,
};
}
if (acknowledgedVersion === null || acknowledgedVersion === "") return undefined;
const parsed = Number(acknowledgedVersion);
if (!Number.isSafeInteger(parsed)) {
@@ -104,9 +95,8 @@ function databaseVersionReason(
* The caller owns persistence, user interaction, and the actual replication gate.
*/
export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput): CompatibilityEvaluation {
const isNewVault = input.migrationState?.isNewVault === true;
const reasons: CompatibilityPauseReason[] = [];
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion, isNewVault);
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion);
if (databaseReason) reasons.push(databaseReason);
if (input.migrationState?.requiresSyncReview === true) {
@@ -130,8 +120,7 @@ export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput):
if (reasons.length === 0) {
return {
initialiseAcknowledgedVersion:
isNewVault && (input.acknowledgedVersion === null || input.acknowledgedVersion === ""),
initialiseAcknowledgedVersion: input.acknowledgedVersion === null || input.acknowledgedVersion === "",
};
}
+82 -48
View File
@@ -68,8 +68,32 @@ describe("database compatibility evaluation", () => {
});
});
it("requires review when an existing Vault has no valid acknowledged version", () => {
for (const acknowledgedVersion of [null, "invalid"]) {
it.each([null, ""])(
"initialises a missing marker (%s) without pausing an existing Vault",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result).toEqual({ initialiseAcknowledgedVersion: true });
}
);
it("initialises a missing marker when no settings migration state is available", () => {
expect(
evaluateCompatibilityPause({
acknowledgedVersion: null,
currentVersion: 12,
legacyReviewMessage: "",
})
).toEqual({ initialiseAcknowledgedVersion: true });
});
it("requires review when an existing Vault has an invalid acknowledged version", () => {
for (const acknowledgedVersion of ["invalid", "NaN", "12.5"]) {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
@@ -77,39 +101,44 @@ describe("database compatibility evaluation", () => {
legacyReviewMessage: "",
});
expect(result.pause?.resumable).toBe(true);
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version" });
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version", state: "invalid" });
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
});
it("does not permit a future settings schema to be acknowledged by an older implementation", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 3,
targetVersion: 2,
isFromFutureSchema: true,
requiresSyncReview: true,
}),
legacyReviewMessage: "",
});
expect(result.pause).toEqual({
resumable: false,
reasons: [
{
source: "settings-schema",
it.each(["12", null])(
"does not permit a future settings schema with marker %s to be acknowledged",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 3,
currentVersion: 2,
targetVersion: 2,
isFromFutureSchema: true,
resumable: false,
reviewReasons: [],
},
],
});
});
requiresSyncReview: true,
}),
legacyReviewMessage: "",
});
it("retains a settings migration review in the host compatibility reason", () => {
expect(result.pause).toEqual({
resumable: false,
reasons: [
{
source: "settings-schema",
sourceVersion: 3,
currentVersion: 2,
isFromFutureSchema: true,
resumable: false,
reviewReasons: [],
},
],
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
);
it.each(["12", null])("retains a settings migration review with marker %s", (acknowledgedVersion) => {
const reviewReasons = [
{
code: "legacy-update-review-pending",
@@ -118,7 +147,7 @@ describe("database compatibility evaluation", () => {
},
];
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 9,
@@ -137,27 +166,32 @@ describe("database compatibility evaluation", () => {
resumable: true,
reviewReasons,
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
});
it("compatibility: retains an earlier unstructured review when no structured reason can be reconstructed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "Review an earlier compatibility change.",
});
it.each(["12", null])(
"compatibility: retains an earlier unstructured review with marker %s",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "Review an earlier compatibility change.",
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "legacy-review",
message: "Review an earlier compatibility change.",
resumable: true,
},
],
});
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "legacy-review",
message: "Review an earlier compatibility change.",
resumable: true,
},
],
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
);
it("compatibility: scopes the earlier review marker to the Vault", () => {
expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault");
+2 -3
View File
@@ -66,9 +66,8 @@ export class CompatibilityReviewController {
// An existing unconfigured Vault cannot replicate, so a database
// compatibility pause would only compete with onboarding and persist
// a misleading sync warning. Do not acknowledge the missing marker:
// activation on a later start must evaluate the same state again.
// Genuinely new Vaults still initialise their marker below.
// a misleading sync warning. Its next configured start evaluates any
// known compatibility reasons before initialising an absent marker.
if (settings.isConfigured !== true && migrationState?.isNewVault !== true) {
this.pause = undefined;
this.ui.clearReminder();
@@ -103,15 +103,43 @@ describe("compatibility review controller", () => {
fixture.settings.isConfigured = true;
await expect(fixture.controller.initialise()).resolves.toBe(true);
expect(fixture.controller.pendingPause?.reasons).toContainEqual({
source: "database-version",
state: "missing",
currentVersion: 12,
resumable: true,
});
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.settings.versionUpFlash).toBe("");
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.saveSettingData).not.toHaveBeenCalled();
});
it("starts a configured Vault with no device marker without a review or settings changes", async () => {
const fixture = createFixture({ marker: null });
const previousSettings = { ...fixture.settings };
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.settings).toEqual(previousSettings);
expect(fixture.saveSettingData).not.toHaveBeenCalled();
expect(fixture.ui.showSummary).not.toHaveBeenCalled();
expect(fixture.ui.showReminder).not.toHaveBeenCalled();
});
it("keeps an already persisted review when the device marker is absent", async () => {
const fixture = createFixture({ marker: null, versionUpFlash: COMPATIBILITY_PAUSE_SETTING_MESSAGE });
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.controller.pendingPause?.reasons).toEqual([
{
source: "legacy-review",
message: COMPATIBILITY_PAUSE_SETTING_MESSAGE,
resumable: true,
},
]);
expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false);
expect(fixture.saveSettingData).toHaveBeenCalledOnce();
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
});
it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => {
@@ -19,9 +19,6 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
if (reason.state === "downgrade") {
return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`;
}
if (reason.state === "missing") {
return `- No previously acknowledged internal database version was found for this existing Vault. This can happen when a Vault is copied or restored, or when it is opened with a new Obsidian profile. This installation uses version **${reason.currentVersion}**. An empty local database does not mean that it is safe to resume automatically.`;
}
return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`;
}
if (reason.source === "settings-schema") {
@@ -23,13 +23,13 @@ const resumablePause: CompatibilityPause = {
};
describe("Obsidian compatibility review", () => {
it("explains why a configured Vault can be missing its device-local acknowledgement", async () => {
it("explains an invalid device-local acknowledgement", () => {
const pause: CompatibilityPause = {
resumable: true,
reasons: [
{
source: "database-version",
state: "missing",
state: "invalid",
currentVersion: 12,
resumable: true,
},
@@ -37,9 +37,8 @@ describe("Obsidian compatibility review", () => {
};
const details = compatibilityReviewDetailsMarkdown(pause);
expect(details).toContain("copied or restored");
expect(details).toContain("new Obsidian profile");
expect(details).toContain("does not mean that it is safe to resume automatically");
expect(details).toContain("saved internal database version marker is invalid");
expect(details).toContain("version **12**");
});
it("offers the generic resume action in a vertical action dialogue", async () => {
+7 -5
View File
@@ -131,9 +131,9 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
`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, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay 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:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI and QR round trips, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, internal Metadata Doctor, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay 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, 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.
`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 that the marker is initialised without a compatibility dialogue, reminder, or runtime pause. It then creates a note in real Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the Metadata document and its Chunks.
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.
@@ -141,7 +141,7 @@ The same workflow checks the two remote-activity status boundaries. It first hol
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, the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option, and the Security Seed reconnect workflow captures each significant application state. 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 reuse the same profile-backed acknowledgement, rather than seeding a replacement or repeatedly applying a decision for the first device. 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.
The two-Vault workflow verifies that each isolated Vault initialises its missing marker without a compatibility pause. Later process launches reuse the same profile-backed acknowledgement. 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.
@@ -166,7 +166,9 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique Object Storage prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique Object Storage prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. The test requires the current version marker and absence of a compatibility pause after Fetch and after restarting the same Vault, without accepting a review automatically.
`test:e2e:obsidian:object-storage-qr-workflow` runs the same Object Storage round trip with QR settings on the second device. It takes the first device's settings, assigns a distinct database suffix in the QR fixture, encodes them with Commonlib's QR encoder, passes the payload to the real QR decoding entry point, and selects **Join this device** in the visible dialogue. Unlike the Setup URI, the QR payload includes a database suffix; explicitly choosing one makes the namespace change independent of Obsidian's initial defaults. Before Fetch begins, the scenario verifies that the imported namespace has its current compatibility marker without a pause. Fetch then selects the receiving device's own suffix when resetting the local database. The test verifies the marker and absence of a pause again after Fetch and after a natural restart. It covers the QR settings and setup flow, without requiring a camera or exercising operating-system URI dispatch.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
@@ -212,7 +214,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
`test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use.
The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred rather than accepted; a later configured start must evaluate it. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself.
The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred while onboarding is pending; a later configured start records the current version if no other review is required. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself.
For CouchDB and Object Storage, the workflow then configures 0.25.83 from its own defaults, saves the selected remote, and restarts that release with the same profile before creating history. This both verifies that the old settings persist and lets the old release initialise its replicator from the same saved state as an ordinary existing Vault. The runner waits for that release's asynchronously initialised persistent node identity, creates, edits, renames, and deletes notes, and synchronises each transition before installing the target. Every launch of the upgraded device uses the same isolated Obsidian profile. The session layer closes the renderer before its process-tree fallback, so Chromium persists the legacy compatibility marker naturally; the target must read and migrate that actual profile state to its current namespaced key. The final target restart likewise consumes the marker persisted by the preceding target session. The runner does not reconstruct that device's Vault data, plug-in settings, local database files, device-local state, or remote state. Before the target performs any synchronisation, it must retain the same Vault profile, local database, node identity, remote profile, local checkpoint, and remote milestone. The local node-info document is the identity source of truth; a transient replicator field is used only to confirm that the old asynchronous initialisation has completed. Its first synchronisation must be a no-op: CouchDB document revisions and `update_seq` must remain unchanged, while Object Storage must neither upload nor download journal bodies. The upgraded device then sends a new delta. A separate fresh 1.0 verifier starts from an explicit fixture containing settings and compatibility state for the current version, receives the complete surviving history, and returns another delta; it is not part of the migration assertion for legacy remote settings. The upgraded Vault receives that return journey and retains it across restart.
+22 -79
View File
@@ -6,7 +6,7 @@ import { type ObsidianLiveSyncSettings, VER } from "@vrtmrz/livesync-commonlib/c
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { CouchDbConfig } from "./couchdb.ts";
import type { ObjectStorageConfig } from "./objectStorage.ts";
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
import { withObsidianPage } from "./ui.ts";
export type ConfiguredSettings = {
isConfigured: boolean;
@@ -52,11 +52,6 @@ export type CompatibilityMarkerWaitOptions = {
intervalMs?: number;
};
export type ResumeCompatibilityReviewOptions = {
verifyMissingDeviceMarkerExplanation?: boolean;
screenshotPrefix?: string;
};
export type ObsidianServiceContextContractResult = {
contextType: string;
eventResult: string[];
@@ -154,83 +149,31 @@ export async function assertE2eCompatibilityMarker(
return state;
}
export async function assertE2eCompatibilityReviewPending(
export async function assertE2eCompatibilityUnpaused(
cliBinary: string,
env: NodeJS.ProcessEnv
env: NodeJS.ProcessEnv,
port: number
): 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 });
});
}
const state = await assertE2eCompatibilityMarker(cliBinary, env);
assertEqual(state.versionUpFlash, "", "Compatibility review unexpectedly paused synchronisation.");
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 });
for (const candidate of page.context().pages()) {
assertEqual(
await candidate
.locator(".modal-container:visible")
.filter({ hasText: "Synchronisation paused for compatibility review" })
.count(),
0,
"An unexpected compatibility review dialogue appeared."
);
assertEqual(
await candidate.locator(".notice.livesync-compatibility-review-notice:visible").count(),
0,
"An unexpected compatibility review reminder appeared."
);
}
});
return state;
}
export function createE2eCouchDbPluginData(
+6
View File
@@ -406,6 +406,12 @@ export async function finishInitialisation(
let readySince: number | undefined;
while (Date.now() < deadline) {
const resumeVisible = await withObsidianPage(port, async (page) => {
const alignedSettingsNotice = page.locator(".modal-container").filter({
hasText: "Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
});
if (await alignedSettingsNotice.isVisible()) {
await alignedSettingsNotice.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
}
return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible();
}).catch(() => false);
if (resumeVisible) {
@@ -35,15 +35,13 @@ vi.mock("./pathAssertions.ts", () => ({
vi.mock("./liveSyncWorkflow.ts", () => ({
assertEqual: vi.fn(),
assertE2eCompatibilityMarker: vi.fn(async () => undefined),
assertE2eCompatibilityReviewPending: vi.fn(async () => undefined),
assertE2eCompatibilityUnpaused: vi.fn(async () => undefined),
configureCouchDb: vi.fn(async () => undefined),
createE2eCouchDbPluginData: vi.fn(() => ({})),
prepareRemote: vi.fn(async () => undefined),
pushLocalChanges: vi.fn(async () => {
throw new Error("simulated Obsidian CLI timeout");
}),
resumeCompatibilityReview: vi.fn(async () => undefined),
waitForLiveSyncCoreReady: vi.fn(async () => undefined),
waitForLocalDatabaseEntry: vi.fn(async () => ({ id: "note-id", children: [] })),
}));
+2 -9
View File
@@ -11,12 +11,10 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
assertE2eCompatibilityUnpaused,
configureCouchDb,
createE2eCouchDbPluginData,
prepareRemote,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
@@ -113,12 +111,7 @@ async function main(): Promise<void> {
}),
});
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);
await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
+2 -1
View File
@@ -566,7 +566,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
);
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
hasText:
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.",
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
});
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await resetNotice
@@ -1303,6 +1303,7 @@ async function main(): Promise<void> {
},
{
notifyThresholdOfRemoteStorageSize: -1,
versionUpFlash: "Review an earlier compatibility change before synchronisation resumes.",
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
+4
View File
@@ -35,6 +35,10 @@ const testSteps: Step[] = [
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
},
{
name: "Object Storage QR workflow",
args: ["run", "test:e2e:obsidian:object-storage-qr-workflow"],
},
{
name: "Object Storage Custom HTTP Handler Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-custom-http-handler-setup-uri-workflow"],
@@ -3,10 +3,14 @@ import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { encodeSettingsToQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityUnpaused,
pushLocalChanges,
type ConfiguredSettings,
waitForLiveSyncCoreReady,
@@ -29,8 +33,10 @@ import {
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
resumeCompatibilityReviewIfShown,
continueWithoutRemoteSettings,
captureGuideDialogue,
modalByTitle,
selectRadioOption,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
@@ -46,9 +52,12 @@ process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const execFileAsync = promisify(execFile);
const useCustomRequestHandler = process.argv.includes("--custom-http-handler");
const captures: SetupCaptureNames = useCustomRequestHandler
? { scenario: "object-storage-custom-http-handler-setup-uri", guide: "object-storage-custom-http-handler-setup" }
: { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const useQRCode = process.argv.includes("--qr");
const captures: SetupCaptureNames = useQRCode
? { scenario: "object-storage-qr", guide: "object-storage-qr-setup" }
: useCustomRequestHandler
? { scenario: "object-storage-custom-http-handler-setup-uri", guide: "object-storage-custom-http-handler-setup" }
: { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const noteFromFirst = "E2E/object-storage/from-first.md";
const noteFromSecond = "E2E/object-storage/from-second.md";
const firstContent =
@@ -249,6 +258,37 @@ async function captureNote(port: number, path: string, text: string, filename: s
return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first());
}
async function importQRCode(port: number, qrData: string): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((data) => {
const obsidian = globalThis as typeof globalThis & {
app: {
plugins: {
plugins: Record<
string,
{ core: { modules: { decodeQR?: (qr: string) => Promise<unknown> }[] } }
>;
};
};
};
const setup = obsidian.app.plugins.plugins["obsidian-livesync"].core.modules.find(
(module) => typeof module.decodeQR === "function"
);
if (!setup?.decodeQR) throw new Error("The QR settings decoder is unavailable.");
// The decoder remains pending while the real setup dialogues run.
void setup.decodeQR(data);
}, qrData);
});
const title = "Mostly Complete: Decision Required";
const screenshot = await captureGuideDialogue(port, `guide-${captures.guide}-join-choice.png`, title);
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await selectRadioOption(modal, "📥 Join this device");
await modal.getByRole("button", { name: "Proceed to the next step.", exact: true }).click();
});
return screenshot;
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -274,7 +314,7 @@ async function main(): Promise<void> {
screenshots.push(await continueWithoutRemoteSettings(portA, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
await assertE2eCompatibilityUnpaused(context.cliBinary, sessionA.cliEnv, portA);
assertEqual(
firstState.endpoint,
objectStorage.endpoint,
@@ -304,6 +344,18 @@ async function main(): Promise<void> {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
screenshots.push(...generated.screenshots);
const qrSettings = useQRCode
? await evalObsidianJson<ObsidianLiveSyncSettings>(
context.cliBinary,
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings())",
sessionA.cliEnv
)
: undefined;
if (qrSettings) {
// Represent QR settings from a device with a different database
// suffix; isolated Obsidian profiles can share the default suffix.
qrSettings.additionalSuffixOfDatabaseName = "qr-source";
}
const startupState = await configureMigratedStartupScheduling(context.cliBinary, sessionA.cliEnv);
assertEqual(startupState.liveSync, true, "The first device did not persist its Continuous setting.");
assertEqual(startupState.syncOnStart, true, "The first device did not persist syncOnStart.");
@@ -325,11 +377,41 @@ async function main(): Promise<void> {
await stopSession(context, sessionA);
const sessionB = await startSession(context, vaultB, portB);
screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures));
const initialMarker = await assertE2eCompatibilityMarker(context.cliBinary, sessionB.cliEnv);
if (qrSettings) {
assertEqual(
initialMarker.additionalSuffix === `-${qrSettings.additionalSuffixOfDatabaseName}`,
false,
"The QR fixture must start with distinct source and receiver database suffixes."
);
}
screenshots.push(
qrSettings
? await importQRCode(portB, encodeSettingsToQRCodeData(qrSettings))
: await enterSetupURI(portB, "existing", generated.artifact, captures)
);
screenshots.push(await captureAndStartInitialisation(portB, "existing", captures));
if (qrSettings) {
// Inspect the imported namespace before Fetch resets the local
// database and restores this device's own suffix.
await withObsidianPage(portB, async (page) => {
await modalByTitle(page, "Data retrieval scheduled").waitFor({ state: "visible", timeout: 30000 });
});
const importedMarker = await assertE2eCompatibilityUnpaused(context.cliBinary, sessionB.cliEnv, portB);
assertEqual(
importedMarker.expectedStorageKey === initialMarker.expectedStorageKey,
false,
"The QR fixture did not exercise a changed device-local namespace after settings import."
);
assertEqual(
importedMarker.additionalSuffix,
`-${qrSettings.additionalSuffixOfDatabaseName}`,
"The second device did not import the QR source's database suffix."
);
}
screenshots.push(...(await confirmFastFetch(portB, captures)));
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
await resumeCompatibilityReviewIfShown(portB);
const fetchedMarker = await assertE2eCompatibilityUnpaused(context.cliBinary, sessionB.cliEnv, portB);
assertEqual(
secondState.endpoint,
objectStorage.endpoint,
@@ -359,10 +441,24 @@ async function main(): Promise<void> {
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await stopSession(context, sessionB);
const returningSessionB = await startSession(context, vaultB, portB);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionB.cliEnv);
const restartedMarker = await assertE2eCompatibilityUnpaused(
context.cliBinary,
returningSessionB.cliEnv,
portB
);
assertEqual(
restartedMarker.expectedStorageKey,
fetchedMarker.expectedStorageKey,
"Restart changed the device-local namespace selected by Fetch."
);
await waitForPathContent(vaultB, noteFromFirst, firstContent);
await stopSession(context, returningSessionB);
const returningSessionA = await startSession(context, vaultA, portA);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
await assertE2eCompatibilityUnpaused(context.cliBinary, returningSessionA.cliEnv, portA);
// Deliberately omit manual replication here. Object Storage reports
// Continuous as not applicable, so startup scheduling must honour the
// retained syncOnStart setting by running an unattended OneShot.
@@ -377,10 +473,37 @@ async function main(): Promise<void> {
);
console.log(
`Object Storage Setup URI and two-device roundtrip succeeded with the ${
`Object Storage ${useQRCode ? "QR" : "Setup URI"} and two-device roundtrip succeeded with the ${
useCustomRequestHandler ? "Custom HTTP Handler" : "default HTTP handler"
}. Screenshots: ${screenshots.join(", ")}`
);
} catch (error) {
for (const session of context.activeSessions) {
await captureObsidianPage(session.remoteDebuggingPort, `${captures.scenario}-failure.png`, async (page) => {
console.error(
"Visible dialogue titles:",
await page.locator(".modal-container:visible .modal-title").allTextContents()
);
console.error(
"Initialisation state:",
await evalObsidianJson(
context.cliBinary,
`(()=>{
const core=app.plugins.plugins['obsidian-livesync'].core;
const settings=core.services.setting.currentSettings();
return JSON.stringify({configured:settings.isConfigured,
databaseReady:core.services.database.isDatabaseReady(),appReady:core.services.appLifecycle.isReady(),
suspended:core.services.appLifecycle.isSuspended(),versionUpFlash:settings.versionUpFlash,
activeConfigurationId:settings.activeConfigurationId,
remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length});})()`,
session.cliEnv
)
);
}).catch((diagnosticError: unknown) =>
console.error("Could not capture initialisation failure:", diagnosticError)
);
}
throw error;
} finally {
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
+3 -1
View File
@@ -6,6 +6,7 @@ import {
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { REVIEW_HARNESS_STATE_KEY } from "../../../src/features/ReviewHarness/reviewHarnessController.ts";
import { REVIEW_HARNESS_FIXTURE_ROOT } from "../../../src/features/ReviewHarness/reviewHarnessVaultFixture.ts";
import { evalObsidianJson } from "../runner/cli.ts";
@@ -391,9 +392,10 @@ async function main(): Promise<void> {
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
doctorProcessedVersion: DoctorRegulation.version,
settingVersion: CURRENT_SETTING_VERSION,
isConfigured: true,
versionUpFlash: "Review an earlier compatibility change before synchronisation resumes.",
additionalSuffixOfDatabaseName: "",
enableDebugTools: true,
notifyThresholdOfRemoteStorageSize: 0,
+1
View File
@@ -21,6 +21,7 @@ const focusedScenarios = new Set([
"cli-to-obsidian-sync",
"minio-upload",
"object-storage-setup-uri-workflow",
"object-storage-qr-workflow",
"object-storage-custom-http-handler-setup-uri-workflow",
"p2p-setup-uri-workflow",
"partial-startup-file-failure",
+2 -13
View File
@@ -15,13 +15,11 @@ import { discoverObsidianCli, requireObsidianBinary } from "../runner/environmen
import { waitForExactCaseOnlyRename } from "../runner/pathAssertions.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
assertE2eCompatibilityUnpaused,
configureCouchDb,
createE2eCouchDbPluginData,
prepareRemote,
pushLocalChanges,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
@@ -60,7 +58,6 @@ type RunnerContext = {
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
reviewedVaults: Set<string>;
activeSessions: Set<ObsidianLiveSyncSession>;
};
@@ -450,7 +447,6 @@ async function startConfiguredSession(
password: context.couchDb.password,
dbName: context.dbName,
};
const reviewAlreadyCompleted = context.reviewedVaults.has(vault.path);
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
@@ -461,12 +457,7 @@ async function startConfiguredSession(
context.activeSessions.add(session);
try {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
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 assertE2eCompatibilityUnpaused(context.cliBinary, session.cliEnv, session.remoteDebuggingPort);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, overrides);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
@@ -1385,7 +1376,6 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
couchDb,
dbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
const encryptedContext: RunnerContext = {
@@ -1393,7 +1383,6 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
couchDb,
dbName: encryptedDbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
+5
View File
@@ -34,6 +34,11 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
- We can now distinguish the three Setup URI and QR code choices by their short labels and icons: initialise or overwrite the remote, join this device, or apply settings only.
#### Fixed
- We can now add a device or open a copied Vault without a compatibility pause solely because its device-local version record is absent.
- Existing version or settings incompatibilities still require review. A pause already saved by an earlier release still needs one explicit resume action.
## 1.0.32
27th September, 2026