Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot] 809caffed9 Releasing 1.0.0-beta.4 2026-07-25 05:08:39 +00:00
41 changed files with 389 additions and 2788 deletions
-22
View File
@@ -32,28 +32,6 @@ While suspended:
The flag deliberately enables file logging, which may affect performance. Remove it after the emergency has been understood.
## Recover a conflicted or mismatched file
Use this workflow when one file, or a small number of known files, has conflicts, missing chunks, or a difference between the current Vault file and the local LiveSync database. The inspection is device-local: it does not query a remote database or prove that another device has the same chunks.
The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other live version.
1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version.
2. If another device or backup has the intended content, preserve that copy before changing any revision.
3. If the current Vault file is readable, select **Recreate current chunks**. This can restore only chunks derived from the current Vault contents; it cannot reconstruct unique bytes from an unavailable historical or conflict revision.
4. Select **Inspect conflicts and file/database differences****Scan all files**.
5. Review the database winner, every conflict revision, and any unavailable shared ancestor separately. Revision identifiers, `Δsize`, `Δtime`, and chunk availability are diagnostic evidence; they do not decide which content is correct.
6. Use the wrench menu on the exact revision:
- **Compare with Vault** opens a read-only comparison for readable text.
- **Apply this revision to Vault** replaces the Vault file with that readable database revision.
- **Mark this revision as the Vault version** records an exact byte-for-byte match without creating a child revision.
- **Store Vault file as a child of this revision** preserves the current Vault bytes on that selected branch.
- **Retry reading revision** retries configured chunk retrieval without changing the revision tree.
- **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed.
7. Synchronise the healthy source if chunks were restored, scan again, and confirm that the expected conflict or difference has disappeared before resuming ordinary editing.
An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another live branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files).
## Reset synchronisation on this device
Use this when the remote copy is trusted but this device's local LiveSync database is incomplete, corrupt, or no longer aligned with it.
+6 -6
View File
@@ -731,16 +731,16 @@ Stop reflecting database changes to storage files.
Recreate chunks from files currently present in the Vault. This can repair missing chunks for those exact current contents after they have been confirmed as authoritative. It cannot reconstruct unavailable historical or conflict content.
#### Inspect conflicts and file/database differences
Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded.
Select **Scan all files** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
#### Resolve All conflicted files by the newer one
After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable.
#### Verify and repair all files
Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded.
`Retry reading revision` retries the configured chunk-retrieval path without changing the revision tree. `Discard unreadable revision` is offered only for an exact current live revision which remains unreadable; after confirmation, it creates a logical deletion for that revision. Prefer recovery from another replica or backup before discarding it.
#### Check and convert non-path-obfuscated files
### 4. Reset
+3 -16
View File
@@ -50,24 +50,11 @@ The compatibility implementation currently selects the newer modification time f
A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.
**Hatch****Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
**Hatch****Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. It reports exact revision identifiers and local chunk availability separately:
Each reported live revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:
- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files.
- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation.
- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection.
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch.
- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance.
- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree.
- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains.
Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win.
A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
- **Discard unreadable revision** is available only for a current winner or conflict revision which remains unreadable when the action is performed. It requires confirmation and creates a logical deletion for that exact revision.
- A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.
+1 -1
View File
@@ -47,7 +47,7 @@ Garbage Collection deliberately trades historical recoverability for storage. A
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Inspect conflicts and file/database differences**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Verify and repair all files**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
## Verification
+1 -1
View File
@@ -27,7 +27,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
- Boot-up sequence (boot-sequence)
- The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational.
- Broken files (Size mismatch)
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time.
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be detected and resolved by running validation tools such as `Verify and repair all files` on the Hatch pane.
- Chunk / Chunks
- Divided units of data stored in the database or object storage to facilitate efficient synchronisation.
- Compaction
+3 -5
View File
@@ -57,12 +57,10 @@ If the log reports missing chunks or a size mismatch:
2. restart Obsidian once to rule out an interrupted fetch;
3. synchronise a device or restore a backup which still has the correct content;
4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise;
5. follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file); run `Inspect conflicts and file/database differences` from `Hatch`, then use each revision's wrench menu to review and act on that exact branch; and
6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf.
5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; and
6. use `Discard unreadable revision` only after confirming that the exact revision is no longer recoverable or wanted.
The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted.
`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact live revision while another live branch remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable live leaf. Neither action purges history or reconstructs missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
`Retry reading revision` does not change the revision tree. `Discard unreadable revision` creates a logical deletion for one current winner or conflict revision after rechecking it. It does not purge history or reconstruct missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.0-beta.5",
"version": "1.0.0-beta.4",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+17 -17
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.0-beta.5",
"version": "1.0.0-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.0-beta.5",
"version": "1.0.0-beta.4",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -22,7 +22,7 @@
"@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/livesync-commonlib": "0.1.0-rc.14",
"@vrtmrz/livesync-commonlib": "0.1.0-rc.12",
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
@@ -56,7 +56,7 @@
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.6",
"@vrtmrz/obsidian-test-session": "0.2.5",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
@@ -4764,9 +4764,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.0-rc.14",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.14.tgz",
"integrity": "sha512-5aEy0x/aGNJjNdQYpQQ3VK386qlXZNInxq8pcsjf+NTV/X8jBQ+Q+omiRjYqn6BpS30xVWWeAZ/vdTeaiLsDVw==",
"version": "0.1.0-rc.12",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.12.tgz",
"integrity": "sha512-pMiOL4x5pDKCYOwtH3nG+9Ra1sLjowItBUrYoHpvcrweV4NSN0OkAEMk1vxBQlKMmKtnrAdy0WuQvmsdLOWHqA==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -4839,9 +4839,9 @@
}
},
"node_modules/@vrtmrz/obsidian-test-session": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.6.tgz",
"integrity": "sha512-7xDTmTW3igBxwQGgbbpoRZU0JvOBpGgdRT4qF2WVrKz03OtKMJdkfJILY04/HZ+n4tvn9Ze8phJBr0dx3wewcQ==",
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.5.tgz",
"integrity": "sha512-ZsI+Yx3z6IEFfh5Ey5mEUBNI0SUD6oDhP7D9LSZVEqPmdJz2JMiag7d8u/p1i6KpsoI2HbDvtw4RHpB+BQReAw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5975,15 +5975,15 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -15913,7 +15913,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.0-beta.5-cli",
"version": "1.0.0-beta.4-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -15938,7 +15938,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.0-beta.5-webapp",
"version": "1.0.0-beta.4-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
@@ -15950,7 +15950,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.0-beta.5-webpeer",
"version": "1.0.0-beta.4-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.0-beta.5",
"version": "1.0.0-beta.4",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -116,7 +116,7 @@
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.6",
"@vrtmrz/obsidian-test-session": "0.2.5",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
@@ -165,7 +165,7 @@
"@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/livesync-commonlib": "0.1.0-rc.14",
"@vrtmrz/livesync-commonlib": "0.1.0-rc.12",
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.0-beta.5-cli",
"version": "1.0.0-beta.4-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.0-beta.5-webapp",
"version": "1.0.0-beta.4-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.0-beta.5-webpeer",
"version": "1.0.0-beta.4-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -74,51 +74,21 @@ export const liveSyncProvisionalEnglishMessages = {
"Database information for ${FILE}": "Database information for ${FILE}",
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.":
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.",
"📁 Vault: ${SIZE} B · ${TIME}": "📁 Vault: ${SIZE} B · ${TIME}",
"📁 Vault: missing": "📁 Vault: missing",
"🗄️ Local DB: missing": "🗄️ Local DB: missing",
"Vault and database revision": "Vault and database revision",
"Vault file": "Vault file",
"Database revision": "Database revision",
"Vault file is newer": "Vault file is newer",
"Database revision is newer": "Database revision is newer",
"Within the two-second comparison window": "Within the two-second comparison window",
"Timestamp comparison unavailable": "Timestamp comparison unavailable",
"Vault file: modified ${TIME}, size ${SIZE}": "Vault file: modified ${TIME}, size ${SIZE}",
"Vault file: missing": "Vault file: missing",
"Local database document: missing": "Local database document: missing",
"${ROLE}: ${REVISION}": "${ROLE}: ${REVISION}",
"Winner revision": "Winner revision",
"Conflict revision": "Conflict revision",
"Unknown revision": "Unknown revision",
"🗑️ Logical deletion": "🗑️ Logical deletion",
"Logical deletion": "Logical deletion",
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}":
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}",
"🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}",
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B":
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
"📦 DB: recorded ${RECORDED} B · decoded unavailable":
"📦 DB: recorded ${RECORDED} B · decoded unavailable",
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B":
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})":
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
"✅ Matches Vault": "✅ Matches Vault",
"⚠️ Differs from Vault": "⚠️ Differs from Vault",
"✅ Vault matches winner": "✅ Vault matches winner",
"⚠️ Conflicts: ${COUNT}": "⚠️ Conflicts: ${COUNT}",
"Compare with Vault": "Compare with Vault",
"Apply this revision to Vault": "Apply this revision to Vault",
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.":
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.",
"Apply database revision to Vault": "Apply database revision to Vault",
"Mark this revision as the Vault version": "Mark this revision as the Vault version",
"Store Vault file as a child of this revision": "Store Vault file as a child of this revision",
"Apply logical deletion to Vault": "Apply logical deletion to Vault",
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.":
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.",
"Unreadable on this device; ${COUNT} referenced chunks are missing or deleted":
"Unreadable on this device; ${COUNT} referenced chunks are missing or deleted",
"Matches the current Vault file": "Matches the current Vault file",
"Differs from the current Vault file": "Differs from the current Vault file",
"Retry reading revision": "Retry reading revision",
"Discard this branch": "Discard this branch",
"Discard branch": "Discard branch",
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.":
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.",
"Discard unreadable revision": "Discard unreadable revision",
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.":
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.",
@@ -127,11 +97,9 @@ export const liveSyncProvisionalEnglishMessages = {
"Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.",
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.":
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.",
"More actions for revision ${REVISION}": "More actions for revision ${REVISION}",
"More actions for ${FILE}": "More actions for ${FILE}",
"Show revision history": "Show revision history",
"Store Vault file as a new local database document":
"Store Vault file as a new local database document",
"Use Vault file in local database": "Use Vault file in local database",
"Restore database winner to Vault": "Restore database winner to Vault",
"Copy database information": "Copy database information",
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
@@ -140,11 +108,10 @@ export const liveSyncProvisionalEnglishMessages = {
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.":
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
"Inspect conflicts and file/database differences":
"Inspect conflicts and file/database differences",
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
"Scan all files": "Scan all files",
"Verify and repair all files": "Verify and repair all files",
"Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.":
"Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.",
"Verify all": "Verify all",
"Connection settings": "Connection settings",
"Saved connections": "Saved connections",
} as const;
-1
View File
@@ -26,7 +26,6 @@ export {
WorkspaceLeaf,
Menu,
request,
setIcon,
getLanguage,
requireApiVersion,
ButtonComponent,
@@ -1530,29 +1530,6 @@ Offline Changed files: ${files.length}`;
}
}
private async getLiveInternalRevision(
prefixedFileName: FilePathWithPrefix,
revision: string
): Promise<MetaEntry | false> {
const [selected, current, conflicts] = await Promise.all([
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, revision, true),
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, undefined, true),
this.core.databaseFileAccess.getConflictedRevs(prefixedFileName),
]);
const liveRevisions = new Set([
...(current && current._rev ? [current._rev] : []),
...conflicts,
]);
if (!selected || selected._rev !== revision || !liveRevisions.has(revision)) {
this._log(
`Could not use hidden-file revision ${revision} of ${stripAllPrefixes(prefixedFileName)}; the selected revision is no longer live`,
LOG_LEVEL_NOTICE
);
return false;
}
return selected;
}
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path;
@@ -1604,79 +1581,6 @@ Offline Changed files: ${files.length}`;
});
}
async storeInternalFileToDatabaseWithBaseRevision(
file: InternalFileInfo | UXFileInfo,
baseRevision: string,
createIfDifferent = true
): Promise<boolean> {
const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path;
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
return false;
}
const prefixedFileName = addPrefix(storeFilePath, ICHeader);
return await serialized("file-" + prefixedFileName, async () => {
try {
const baseData = await this.getLiveInternalRevision(prefixedFileName, baseRevision);
if (baseData === false) {
return false;
}
const fileInfo = "stat" in file && "body" in file ? file : await this.loadFileWithInfo(storeFilePath);
if (fileInfo.deleted) {
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
}
if (!baseData.deleted && !baseData._deleted) {
const loadedBase = await this.core.databaseFileAccess.fetchEntryFromMeta(baseData, true, true);
if (loadedBase && (await isDocContentSame(readAsBlob(loadedBase), fileInfo.body))) {
this.updateLastProcessed(storeFilePath, baseData, fileInfo.stat);
return true;
}
}
if (!createIfDifferent) {
this._log(
`Could not mark hidden file ${storeFilePath} as revision ${baseRevision}; the storage content differs`,
LOG_LEVEL_NOTICE
);
return false;
}
const storedRevision = await this.core.databaseFileAccess.storeWithBaseRevision(
{
...fileInfo,
path: storeFilePath,
name: fileInfo.name || storeFilePath.split("/").pop() || "",
isInternal: true,
},
baseRevision,
true
);
if (storedRevision === false) {
return false;
}
this.updateLastProcessed(
storeFilePath,
{
...baseData,
_rev: storedRevision,
path: prefixedFileName,
ctime: fileInfo.stat.ctime,
mtime: fileInfo.stat.mtime,
size: fileInfo.stat.size,
deleted: false,
},
fileInfo.stat
);
this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Done`);
return true;
} catch (ex) {
this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
});
}
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
const storeFilePath = filenameSrc;
const storageFilePath = filenameSrc;
@@ -1740,8 +1644,7 @@ Offline Changed files: ${files.length}`;
metaEntry?: MetaEntry | LoadedEntry,
preventDoubleProcess = true,
onlyNew = false,
includeDeletion = true,
requiredLiveRevision?: string
includeDeletion = true
) {
const prefixedFileName = addPrefix(storageFilePath, ICHeader);
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
@@ -1750,11 +1653,9 @@ Offline Changed files: ${files.length}`;
return await serialized("file-" + prefixedFileName, async () => {
try {
// Check conflicted status
const metaOnDB = requiredLiveRevision
? await this.getLiveInternalRevision(prefixedFileName, requiredLiveRevision)
: metaEntry
? metaEntry
: await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true);
const metaOnDB = metaEntry
? metaEntry
: await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true);
if (metaOnDB === false) throw new Error(`File not found on database.:${storageFilePath}`);
// Prevent overwrite for Prevent overwriting while some conflicted revision exists.
if (metaOnDB?._conflicts?.length) {
@@ -1828,24 +1729,6 @@ Offline Changed files: ${files.length}`;
});
}
async extractInternalFileRevisionFromDatabase(
storageFilePath: FilePath,
revision: string,
force = false
): Promise<boolean> {
return Boolean(
await this.extractInternalFileFromDatabase(
storageFilePath,
force,
undefined,
true,
false,
true,
revision
)
);
}
async __checkIsNeedToWriteFile(storageFilePath: FilePath, content: string | ArrayBuffer): Promise<boolean> {
try {
const storageContent = await this.core.storageAccess.readHiddenFileAuto(storageFilePath);
@@ -1,12 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
type DocumentID,
LOG_LEVEL_NOTICE,
type FilePath,
type FilePathWithPrefix,
type MetaEntry,
type UXFileInfo,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
@@ -34,70 +27,6 @@ vi.mock("./configureHiddenFileSyncMode.ts", () => ({
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
function createHiddenRevisionOperation() {
const path = ".obsidian/plugins/example/data.json" as FilePath;
const file = {
path,
name: "data.json",
isInternal: true,
body: new Blob(["{\"value\":\"vault\"}"]),
stat: {
ctime: 1,
mtime: 2,
size: 17,
type: "file",
},
} as UXFileInfo;
const selected = {
_id: "i:example" as DocumentID,
_rev: "2-selected",
path: `i:${path}` as FilePathWithPrefix,
ctime: 1,
mtime: 2,
size: 17,
type: "plain",
datatype: "plain",
children: [],
eden: {},
deleted: false,
} as MetaEntry;
const winner = {
...selected,
_rev: "3-winner",
} as MetaEntry;
const databaseFileAccess = {
fetchEntryMeta: vi.fn(
async (_path: unknown, revision?: string) =>
revision === selected._rev ? selected : winner
),
getConflictedRevs: vi.fn(async () => [selected._rev]),
fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: "{\"value\":\"database\"}" })),
storeWithBaseRevision: vi.fn(async () => "3-vault-child"),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
vault: {
isIgnoredByIgnoreFile: vi.fn(async () => false),
},
},
databaseFileAccess,
},
loadFileWithInfo: vi.fn(async () => file),
updateLastProcessed: vi.fn(),
_log: vi.fn(),
});
return {
hiddenFileSync,
path,
file,
selected,
winner,
databaseFileAccess,
};
}
describe("HiddenFileSync configuration-change notices", () => {
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{
@@ -341,130 +270,3 @@ describe("HiddenFileSync configuration-change notices", () => {
expect(progress.done).toHaveBeenCalledWith("Failed");
});
});
describe("HiddenFileSync exact revision repair operations", () => {
it("stores the current hidden Vault file as a child of the selected live revision", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
).resolves.toBe(true);
expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith(
expect.objectContaining({
path: file.path,
body: file.body,
isInternal: true,
}),
selected._rev,
true
);
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
file.path,
expect.objectContaining({ _rev: "3-vault-child" }),
file.stat
);
});
it("refuses to extend a hidden-file revision which is no longer live", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
).resolves.toBe(false);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
});
it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
file,
selected._rev!,
false
)
).resolves.toBe(false);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
});
it("marks a matching hidden-file revision without creating a child", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({
...selected,
data: "{\"value\":\"vault\"}",
});
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
file,
selected._rev!,
false
)
).resolves.toBe(true);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
file.path,
selected,
file.stat
);
});
it("applies the selected live hidden-file revision through the existing extraction path", async () => {
const {
hiddenFileSync,
path,
selected,
} = createHiddenRevisionOperation();
const extract = vi.fn(async () => true);
hiddenFileSync.extractInternalFileFromDatabase = extract;
await expect(
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
).resolves.toBe(true);
expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev);
});
it("does not apply a hidden-file revision which ceased to be live", async () => {
const {
hiddenFileSync,
path,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
await expect(
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
).resolves.toBe(false);
expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled();
});
});
@@ -13,13 +13,6 @@ export const POSTPONED = Symbol("postponed");
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
export type ConflictResolveModalOptions = {
readOnly?: boolean;
title?: string;
localName?: string;
remoteName?: string;
};
export class ConflictResolveModal extends Modal {
result: diff_result;
filename: FilePathWithPrefix;
@@ -32,7 +25,6 @@ export class ConflictResolveModal extends Modal {
title: string = "Conflicting changes";
pluginPickMode: boolean = false;
readOnly: boolean = false;
localName: string = "Base";
remoteName: string = "Conflicted";
offEvent?: ReturnType<typeof eventHub.onEvent>;
@@ -45,22 +37,16 @@ export class ConflictResolveModal extends Modal {
filename: FilePathWithPrefix,
diff: diff_result,
pluginPickMode?: boolean,
remoteName?: string,
options?: ConflictResolveModalOptions
remoteName?: string
) {
super(app);
this.result = diff;
this.filename = filename;
this.pluginPickMode = pluginPickMode || false;
this.readOnly = options?.readOnly ?? false;
if (this.pluginPickMode) {
this.title = "Pick a version";
this.remoteName = `${remoteName || "Remote"}`;
this.localName = "Local";
} else if (this.readOnly) {
this.title = options?.title ?? "Vault and database revision";
this.localName = options?.localName ?? "Vault file";
this.remoteName = options?.remoteName ?? "Database revision";
}
}
@@ -115,18 +101,16 @@ export class ConflictResolveModal extends Modal {
if (this.offEvent) {
this.offEvent();
}
if (!this.readOnly) {
// Cancel an older dialogue for this path before subscribing this
// instance. Emitting after subscription would close the replacement
// itself; the instance-owned result promise then completes the older
// caller even when it only begins waiting after this event.
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
});
}
// Cancel an older dialogue for this path before subscribing this
// instance. Emitting after subscription would close the replacement
// itself; the instance-owned result promise then completes the older
// caller even when it only begins waiting after this event.
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
});
this.titleEl.setText(this.title);
contentEl.empty();
const diffOptionsRow = contentEl.createDiv("");
@@ -175,31 +159,24 @@ export class ConflictResolveModal extends Modal {
this.appendVersionInfo(div2, "deleted", this.localName, date1);
this.appendVersionInfo(div2, "added", this.remoteName, date2);
const actionContainer = contentEl.createDiv("conflict-action-container");
if (this.readOnly) {
actionContainer.createEl("button", { text: "Close" }, (e) => {
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
});
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
});
if (!this.pluginPickMode) {
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(CANCELLED));
});
} else {
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
});
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
});
if (!this.pluginPickMode) {
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
});
}
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
});
}
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
});
if (diffLength > 100 * 1024) {
this.diffView.empty();
this.diffView.setText("(Too large diff to display)");
@@ -5,8 +5,6 @@ import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/li
vi.mock("@/deps.ts", () => ({
App: class App {},
Modal: class Modal {
createdButtons: string[] = [];
private createElement(): Record<string, unknown> {
const element: Record<string, unknown> = {
addClass: vi.fn(),
@@ -24,14 +22,6 @@ vi.mock("@/deps.ts", () => ({
};
element.createDiv = vi.fn(() => this.createElement());
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
if (
_tag === "button" &&
typeof _options === "object" &&
_options !== null &&
"text" in _options
) {
this.createdButtons.push(String((_options as { text: unknown }).text));
}
const child = this.createElement();
callback?.(child);
return child;
@@ -92,55 +82,4 @@ describe("ConflictResolveModal result lifecycle", () => {
expect(previousResult).toBe(CANCELLED);
expect(replacementState).toBe("still-open");
});
it("renders a read-only comparison with no resolution actions", () => {
const ReadOnlyModal = ConflictResolveModal as unknown as new (
...args: unknown[]
) => ConflictResolveModal & { createdButtons: string[] };
const modal = new ReadOnlyModal(
{},
"repair-preview.md",
conflict,
false,
undefined,
{
readOnly: true,
title: "Vault and database revision",
localName: "Vault file",
remoteName: "Database revision",
}
);
modal.onOpen();
expect(modal.createdButtons).toContain("Close");
expect(modal.createdButtons).not.toContain("Use Vault file");
expect(modal.createdButtons).not.toContain("Use Database revision");
expect(modal.createdButtons).not.toContain("Concat both");
expect(modal.createdButtons).not.toContain("Not now");
modal.close();
});
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
const previous = new ConflictResolveModal({} as never, filename, conflict);
const ReadOnlyModal = ConflictResolveModal as unknown as new (
...args: unknown[]
) => ConflictResolveModal;
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
readOnly: true,
});
previous.onOpen();
comparison.onOpen();
const previousState = await Promise.race([
previous.waitForResult(),
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
]);
previous.sendResponse(CANCELLED);
comparison.close();
expect(previousState).toBe("still-open");
});
});
+164 -565
View File
@@ -3,14 +3,13 @@ import {
type DocumentID,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type MetaEntry,
type FilePath,
type EntryDoc,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createBlob, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { Menu, diff_match_patch, setIcon } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -21,6 +20,7 @@ import {
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
eventHub,
} from "@/common/events.ts";
import { ICHeader } from "@/common/types.ts";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
@@ -33,17 +33,11 @@ import {
retryReadFileDatabaseRevision,
} from "@/serviceFeatures/fileDatabaseInfo.ts";
import {
discardLiveBranch,
discardUnreadableLiveRevision,
inspectFileRepair,
type FileRepairInspection,
type FileRepairRevision,
} from "@/serviceFeatures/fileRepair.ts";
import {
getFileRepairRevisionActions,
getFileRepairRevisionComparison,
} from "@/serviceFeatures/fileRepairPresentation.ts";
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
// hatchWarn.addClass("op-warn-info");
@@ -129,196 +123,57 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
void addPanel(paneEl, "Recovery and Repair").then((paneEl) => {
const resultArea = paneEl.createDiv({ text: "", cls: "sls-repair-results" });
type RepairMenuAction = {
title: string;
run: () => Promise<void> | void;
warning?: boolean;
};
const addActionMenu = (
const addActionButton = (
parent: HTMLElement,
label: string,
actions: RepairMenuAction[]
text: string,
action: (button: HTMLButtonElement) => Promise<void> | void,
warning = false
) => {
if (actions.length === 0) {
return;
}
this.createEl(parent, "button", { cls: "sls-repair-action-menu" }, (button) => {
setIcon(button, "wrench");
button.setAttr("aria-label", label);
button.setAttr("title", label);
button.onClickEvent(() => {
const menu = new Menu();
for (const action of actions) {
menu.addItem((item) => {
item.setTitle(action.title);
if (action.warning) {
item.setWarning(true);
}
item.onClick(() => {
button.disabled = true;
void Promise.resolve()
.then(() => action.run())
.catch((error) => {
Logger(error, LOG_LEVEL_VERBOSE);
Logger(
`Repair action '${action.title}' failed`,
LOG_LEVEL_NOTICE
);
})
.finally(() => {
if (button.isConnected) {
button.disabled = false;
}
});
});
});
this.createEl(parent, "button", { text }, (button) => {
if (warning) {
button.addClass("mod-warning");
}
button.onClickEvent(async () => {
button.disabled = true;
try {
await action(button);
} finally {
if (button.isConnected) {
button.disabled = false;
}
}
const rect = button.getBoundingClientRect();
menu.showAtPosition({ x: rect.left, y: rect.bottom });
});
});
};
const findHiddenFile = async (path: string) => {
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
if (!addOn) {
return false;
}
const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path);
if (!file) {
Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE);
return false;
}
return { addOn, file };
};
const storeStorageInDatabase = async (path: string): Promise<boolean> => {
if (path.startsWith(".")) {
const hidden = await findHiddenFile(path);
return hidden
? Boolean(await hidden.addOn.storeInternalFileToDatabase(hidden.file, true))
: false;
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
if (!addOn) {
return false;
}
const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path);
if (!file) {
Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE);
return false;
}
return Boolean(await addOn.storeInternalFileToDatabase(file, true));
}
return Boolean(await this.core.fileHandler.storeFileToDB(path as FilePath, true));
};
const storeStorageOnRevision = async (
const applyWinnerToStorage = async (
path: string,
revision: string,
createIfDifferent = true
revision: FileRepairRevision
): Promise<boolean> => {
if (path.startsWith(".")) {
const hidden = await findHiddenFile(path);
return hidden
? Boolean(
await hidden.addOn.storeInternalFileToDatabaseWithBaseRevision(
hidden.file,
revision,
createIfDifferent
)
)
: false;
}
return Boolean(
await this.core.fileHandler.storeFileToDBWithBaseRevision(
path as FilePath,
revision,
createIfDifferent
)
);
};
const applyRevisionToStorage = async (
path: string,
revision: string,
force: boolean
): Promise<boolean> => {
if (path.startsWith(".")) {
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
return addOn
? Boolean(
await addOn.extractInternalFileRevisionFromDatabase(
path as FilePath,
revision,
force
)
)
: false;
}
return Boolean(
await this.core.fileHandler.dbToStorageWithSpecificRev(
path as FilePath,
revision,
force
)
);
};
const openRevisionComparison = async (
path: string,
selectedRevision: string
): Promise<boolean> => {
const latest = await inspectFileRepair(this.core, path);
const revision = latest.revisions.find(
({ metadata }) => metadata.revision === selectedRevision
);
if (
!latest.information.storage.exists ||
!revision ||
revision.loadedEntry === false
) {
Logger(
`Could not compare ${path} revision ${selectedRevision}; the Vault file or selected live revision is no longer readable`,
LOG_LEVEL_NOTICE
);
if (revision.loadedEntry === false) {
return false;
}
const vaultText = await createBlob(
await this.core.storageAccess.readHiddenFileBinary(path)
).text();
const databaseText = await readAsBlob(revision.loadedEntry).text();
const dmp = new diff_match_patch();
const diff = dmp.diff_main(vaultText, databaseText);
dmp.diff_cleanupSemantic(diff);
const result: diff_result = {
left: {
rev: "vault",
data: vaultText,
ctime: latest.information.storage.ctime ?? 0,
mtime: latest.information.storage.mtime ?? 0,
},
right: {
rev: selectedRevision,
data: databaseText,
ctime: revision.metadata.ctime,
mtime: revision.metadata.mtime,
},
diff,
};
new ConflictResolveModal(
this.app,
path as FilePathWithPrefix,
result,
false,
undefined,
{
readOnly: true,
title: $msg("Vault and database revision"),
localName: $msg("Vault file"),
remoteName: $msg("Database revision"),
}
).open();
return true;
};
const formatSigned = (value: number) => `${value >= 0 ? "+" : ""}${value}`;
const timestampRelationLabel = (
relation: ReturnType<typeof getFileRepairRevisionComparison>["timestampRelation"]
) => {
switch (relation) {
case "vault-newer":
return $msg("Vault file is newer");
case "database-newer":
return $msg("Database revision is newer");
case "same-window":
return $msg("Within the two-second comparison window");
default:
return $msg("Timestamp comparison unavailable");
if (revision.loadedEntry.path.startsWith(ICHeader)) {
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
return addOn
? Boolean(await addOn.extractInternalFileFromDatabase(path as FilePath, true))
: false;
}
return Boolean(await this.core.fileHandler.dbToStorage(revision.loadedEntry as MetaEntry, null, true));
};
const addRepairResult = (inspection: FileRepairInspection) => {
const { information, revisions } = inspection;
@@ -333,128 +188,40 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
Logger(`Verification no longer reports a problem for ${path}`, LOG_LEVEL_NOTICE);
}
};
const runMutation = async (
description: string,
mutation: () => Promise<boolean>
) => {
try {
const succeeded = await mutation();
if (!succeeded) {
Logger(`${description} failed: ${path}`, LOG_LEVEL_NOTICE);
}
} finally {
await refresh();
}
};
const discardLiveBranchAction = (revision: string): RepairMenuAction => ({
title: $msg("Discard this branch"),
warning: true,
run: async () => {
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.",
{
REVISION: revision,
FILE: path,
}
),
{
title: $msg("Discard branch"),
defaultOption: "No",
}
)) === "yes";
if (!confirmed) {
return;
}
const result = await discardLiveBranch(this.core, path, revision);
Logger(
`Discard database branch ${revision} of ${path}: ${result}`,
result === "discarded" ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE
);
await refresh();
},
});
const fileHeader = this.createEl(card, "div", { cls: "sls-repair-header" });
this.createEl(fileHeader, "h6", { text: path });
const fileMenuHost = this.createEl(fileHeader, "div");
this.createEl(card, "h6", { text: path });
if (information.storage.exists) {
this.createEl(card, "div", {
text: $msg("📁 Vault: ${SIZE} B · ${TIME}", {
text: $msg("Vault file: modified ${TIME}, size ${SIZE}", {
TIME: new Date(information.storage.mtime ?? 0).toLocaleString(),
SIZE: `${information.storage.size ?? 0}`,
}),
cls: "sls-repair-metric",
});
} else {
this.createEl(card, "div", {
text: $msg("📁 Vault: missing"),
cls: "sls-repair-metric",
});
this.createEl(card, "div", { text: $msg("Vault file: missing") });
}
if (!information.database.exists) {
this.createEl(card, "div", {
text: $msg("🗄️ Local DB: missing"),
cls: "sls-repair-metric",
});
}
if (information.database.conflictCount > 0) {
const winner = revisions.find(({ role }) => role === "winner");
const vaultMatchesWinner =
winner !== undefined &&
(winner.metadata.deleted
? !information.storage.exists
: information.storage.exists &&
winner.contentMatchesStorage === true);
const status = this.createEl(card, "div", { cls: "sls-repair-status" });
if (vaultMatchesWinner) {
this.createEl(status, "span", {
text: $msg("✅ Vault matches winner"),
cls: "sls-repair-status-ok",
});
}
this.createEl(status, "span", {
text: $msg("⚠️ Conflicts: ${COUNT}", {
COUNT: `${information.database.conflictCount}`,
}),
cls: "sls-repair-status-warning",
});
this.createEl(card, "div", { text: $msg("Local database document: missing") });
}
const addRevision = (revision: FileRepairRevision) => {
const { metadata } = revision;
const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" });
const revisionHeader = this.createEl(revisionEl, "div", {
cls: "sls-repair-header",
});
this.createEl(revisionHeader, "div", {
this.createEl(revisionEl, "div", {
text: $msg("${ROLE}: ${REVISION}", {
ROLE: revision.role === "winner" ? $msg("Winner revision") : $msg("Conflict revision"),
REVISION: metadata.revision ?? $msg("Unknown revision"),
}),
cls: "sls-repair-revision-title",
});
const revisionMenuHost = this.createEl(revisionHeader, "div");
const comparison = getFileRepairRevisionComparison(inspection, revision);
if (metadata.deleted) {
this.createEl(revisionEl, "div", {
text: $msg("🗑️ Logical deletion"),
cls: "sls-repair-metric",
});
this.createEl(revisionEl, "div", { text: $msg("Logical deletion") });
} else if (revision.contentReadable) {
this.createEl(revisionEl, "div", {
text: $msg(
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
{
RECORDED: `${comparison.recordedSize}`,
DECODED: `${comparison.decodedSize ?? 0}`,
DIFFERENCE: formatSigned(
comparison.recordedToDecodedSizeDifference ?? 0
),
}
),
cls: "sls-repair-metric",
text: $msg("Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}", {
RECORDED: `${metadata.recordedSize}`,
ACTUAL: `${revision.loadedEntry === false ? 0 : readAsBlob(revision.loadedEntry).size}`,
}),
});
} else {
const missing = metadata.chunks.filter(
@@ -462,16 +229,10 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
!embedded && localDatabaseState !== "available"
);
this.createEl(revisionEl, "div", {
text: $msg("🧩 Missing chunks: ${COUNT}", {
text: $msg("Unreadable on this device; ${COUNT} referenced chunks are missing or deleted", {
COUNT: `${missing.length}`,
}),
cls: "sls-repair-metric mod-warning",
});
this.createEl(revisionEl, "div", {
text: $msg("📦 DB: recorded ${RECORDED} B · decoded unavailable", {
RECORDED: `${comparison.recordedSize}`,
}),
cls: "sls-repair-metric",
cls: "mod-warning",
});
if (missing.length > 0) {
this.createEl(revisionEl, "code", {
@@ -482,196 +243,28 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
});
}
}
if (
comparison.vaultSize !== null &&
comparison.databaseToVaultSizeDifference !== null
) {
this.createEl(revisionEl, "div", {
text: $msg("📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B", {
VAULT: `${comparison.vaultSize}`,
DIFFERENCE: formatSigned(
comparison.databaseToVaultSizeDifference
),
}),
cls: "sls-repair-metric",
});
}
if (
comparison.vaultMtime !== null &&
comparison.timestampDifferenceMs !== null
) {
this.createEl(revisionEl, "div", {
text: $msg(
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
{
DATABASE_TIME: new Date(
comparison.databaseMtime
).toLocaleString(),
VAULT_TIME: new Date(
comparison.vaultMtime
).toLocaleString(),
DIFFERENCE: formatSigned(
comparison.timestampDifferenceMs
),
RELATION: timestampRelationLabel(
comparison.timestampRelation
),
}
),
cls: "sls-repair-metric",
});
}
if (revision.contentMatchesStorage === true) {
this.createEl(revisionEl, "div", {
text: $msg("✅ Matches Vault"),
cls: "sls-repair-metric",
});
this.createEl(revisionEl, "div", { text: $msg("Matches the current Vault file") });
} else if (revision.contentMatchesStorage === false) {
this.createEl(revisionEl, "div", {
text: $msg("⚠️ Differs from Vault"),
cls: "sls-repair-metric mod-warning",
});
this.createEl(revisionEl, "div", { text: $msg("Differs from the current Vault file") });
}
const policy = getFileRepairRevisionActions(inspection, revision);
const revisionActions: RepairMenuAction[] = [];
if (metadata.revision && policy.compareWithVault) {
revisionActions.push({
title: $msg("Compare with Vault"),
run: async () => {
await openRevisionComparison(path, metadata.revision!);
},
if (!metadata.deleted && !revision.contentReadable && metadata.revision) {
const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" });
addActionButton(actions, $msg("Retry reading revision"), async () => {
const loaded = await retryReadFileDatabaseRevision(this.core, path, metadata.revision!);
Logger(
loaded
? `Revision ${metadata.revision} of ${path} is readable after retry`
: `Revision ${metadata.revision} of ${path} remains unreadable`,
LOG_LEVEL_NOTICE
);
await refresh();
});
}
if (metadata.revision && policy.applyRevisionToVault) {
revisionActions.push({
title: $msg("Apply this revision to Vault"),
run: async () => {
if (await this.core.storageAccess.isExistsIncludeHidden(path)) {
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.",
{
REVISION: metadata.revision!,
FILE: path,
}
),
{
title: $msg("Apply database revision to Vault"),
defaultOption: "No",
}
)) === "yes";
if (!confirmed) {
return;
}
}
await runMutation(
`Apply database revision ${metadata.revision} to the Vault`,
() =>
applyRevisionToStorage(
path,
metadata.revision!,
true
)
);
},
});
}
if (metadata.revision && policy.markAsVaultRevision) {
revisionActions.push({
title: $msg("Mark this revision as the Vault version"),
run: async () => {
await runMutation(
`Mark database revision ${metadata.revision} as the Vault version`,
() =>
storeStorageOnRevision(
path,
metadata.revision!,
false
)
);
},
});
}
if (metadata.revision && policy.storeVaultOnBranch) {
revisionActions.push({
title: $msg("Store Vault file as a child of this revision"),
run: async () => {
await runMutation(
`Store the Vault file on database revision ${metadata.revision}`,
() =>
storeStorageOnRevision(
path,
metadata.revision!
)
);
},
});
}
if (metadata.revision && policy.applyLogicalDeletionToVault) {
revisionActions.push({
title: $msg("Apply logical deletion to Vault"),
warning: true,
run: async () => {
if (await this.core.storageAccess.isExistsIncludeHidden(path)) {
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.",
{
REVISION: metadata.revision!,
FILE: path,
}
),
{
title: $msg("Apply logical deletion to Vault"),
defaultOption: "No",
}
)) === "yes";
if (!confirmed) {
return;
}
}
await runMutation(
`Apply logical deletion ${metadata.revision} to the Vault`,
() =>
applyRevisionToStorage(
path,
metadata.revision!,
true
)
);
},
});
}
if (metadata.revision && policy.retryRevision) {
revisionActions.push({
title: $msg("Retry reading revision"),
run: async () => {
const loaded = await retryReadFileDatabaseRevision(
this.core,
path,
metadata.revision!
);
Logger(
loaded
? `Revision ${metadata.revision} of ${path} is readable after retry`
: `Revision ${metadata.revision} of ${path} remains unreadable`,
LOG_LEVEL_NOTICE
);
await refresh();
},
});
}
if (metadata.revision && policy.discardBranch) {
revisionActions.push(discardLiveBranchAction(metadata.revision));
}
if (metadata.revision && policy.discardRevision) {
revisionActions.push({
title: $msg("Discard unreadable revision"),
warning: true,
run: async () => {
addActionButton(
actions,
$msg("Discard unreadable revision"),
async () => {
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
@@ -700,54 +293,55 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
);
await refresh();
},
});
true
);
}
addActionMenu(
revisionMenuHost,
$msg("More actions for revision ${REVISION}", {
REVISION: metadata.revision ?? $msg("Unknown revision"),
}),
revisionActions
);
};
revisions.forEach(addRevision);
for (const revision of information.database.unavailableConflictRevisions) {
const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" });
const revisionHeader = this.createEl(revisionEl, "div", {
cls: "sls-repair-header",
});
this.createEl(revisionHeader, "div", {
this.createEl(revisionEl, "div", {
text: $msg("${ROLE}: ${REVISION}", {
ROLE: $msg("Conflict revision"),
REVISION: revision,
}),
cls: "sls-repair-revision-title",
});
const revisionMenuHost = this.createEl(revisionHeader, "div");
this.createEl(revisionEl, "div", {
text: $msg("Revision metadata is unavailable on this device"),
cls: "mod-warning",
});
addActionMenu(
revisionMenuHost,
$msg("More actions for revision ${REVISION}", {
REVISION: revision,
}),
[
{
title: $msg("Retry reading revision"),
run: async () => {
await retryReadFileDatabaseRevision(
this.core,
path,
revision
);
await refresh();
},
},
discardLiveBranchAction(revision),
]
const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" });
addActionButton(actions, $msg("Retry reading revision"), async () => {
await retryReadFileDatabaseRevision(this.core, path, revision);
await refresh();
});
addActionButton(
actions,
$msg("Discard unreadable revision"),
async () => {
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.",
{
REVISION: revision,
FILE: path,
}
),
{
title: $msg("Discard unreadable revision"),
defaultOption: "No",
}
)) === "yes";
if (!confirmed) {
return;
}
await discardUnreadableLiveRevision(this.core, path, revision);
await refresh();
},
true
);
}
@@ -771,41 +365,45 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
}
const winner = revisions.find(({ role }) => role === "winner");
const fileActions: RepairMenuAction[] = [];
if (winner?.loadedEntry) {
const actions = this.createEl(card, "div", { cls: "sls-repair-actions" });
if (winner?.loadedEntry && information.storage.exists) {
const winnerEntry = winner.loadedEntry;
fileActions.push({
title: $msg("Show revision history"),
run: () => {
eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, {
file: path as FilePathWithPrefix,
fileOnDB: winnerEntry,
});
},
addActionButton(actions, $msg("Show revision history"), () => {
eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, {
file: path as FilePathWithPrefix,
fileOnDB: winnerEntry,
});
});
}
if (information.storage.exists && !information.database.exists) {
fileActions.push({
title: $msg("Store Vault file as a new local database document"),
run: async () => {
await runMutation(
"Store the Vault file as a new local database document",
() => storeStorageInDatabase(path)
);
},
if (
information.storage.exists &&
information.database.conflictCount === 0 &&
(!winner || winner.contentReadable)
) {
addActionButton(actions, $msg("Use Vault file in local database"), async () => {
if (!(await storeStorageInDatabase(path))) {
Logger(`Failed to store the Vault file in the local database: ${path}`, LOG_LEVEL_NOTICE);
return;
}
await refresh();
});
}
fileActions.push({
title: $msg("Copy database information"),
run: async () => {
await copyFileDatabaseInfo(this.core, path);
},
if (
!information.storage.exists &&
information.database.conflictCount === 0 &&
winner?.loadedEntry
) {
addActionButton(actions, $msg("Restore database winner to Vault"), async () => {
if (!(await applyWinnerToStorage(path, winner))) {
Logger(`Failed to restore the database winner to the Vault: ${path}`, LOG_LEVEL_NOTICE);
return;
}
await refresh();
});
}
addActionButton(actions, $msg("Copy database information"), async () => {
await copyFileDatabaseInfo(this.core, path);
});
addActionMenu(
fileMenuHost,
$msg("More actions for ${FILE}", { FILE: path }),
fileActions
);
};
new Setting(paneEl)
@@ -824,20 +422,47 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
})
);
new Setting(paneEl)
.setName($msg("Inspect conflicts and file/database differences"))
.setName("Resolve All conflicted files by the newer one")
.setDesc(
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one."
)
.addButton((button) =>
button
.setButtonText("Resolve All")
.setCta()
.onClick(async () => {
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable."
),
{
title: $msg("Resolve all conflicts by the newest version"),
defaultOption: "No",
}
)) === "yes";
if (!confirmed) {
return;
}
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
})
);
new Setting(paneEl)
.setName($msg("Verify and repair all files"))
.setDesc(
$msg(
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision."
"Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision."
)
)
.addButton((button) =>
button
.setButtonText($msg("Scan all files"))
.setButtonText($msg("Verify all"))
.setDisabled(false)
.setCta()
.onClick(async () => {
resultArea.replaceChildren();
Logger("Start inspecting file/database state", LOG_LEVEL_NOTICE, "verify");
Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify");
this.core.localDatabase.clearCaches();
const allPaths = await collectFileDatabaseInfoPaths(this.core);
let i = 0;
@@ -894,32 +519,6 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
// Logger(`${i}/${files.length}\n`, LOG_LEVEL_NOTICE, "verify-processed");
})
);
new Setting(paneEl)
.setName("Resolve All conflicted files by the newer one")
.setDesc(
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one."
)
.addButton((button) =>
button
.setButtonText("Resolve All")
.setCta()
.onClick(async () => {
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable."
),
{
title: $msg("Resolve all conflicts by the newest version"),
defaultOption: "No",
}
)) === "yes";
if (!confirmed) {
return;
}
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
})
);
new Setting(paneEl)
.setName("Check and convert non-path-obfuscated files")
.setDesc("")
@@ -1,67 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
platform: {
isMobile: false,
},
}));
vi.mock("@/deps.ts", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
}));
vi.mock("@/deps", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
}));
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
ObsHttpHandler: class {},
}));
vi.mock("./ObsidianConfirm", () => ({
ObsidianConfirm: class {},
}));
import { ObsidianAPIService } from "./ObsidianAPIService";
import type { ObsidianServiceContext } from "./ObsidianServiceContext";
function createService(workspace: Record<string, unknown>, isMobile = false): ObsidianAPIService {
return new ObsidianAPIService({
app: { workspace, isMobile },
} as unknown as ObsidianServiceContext);
}
beforeEach(() => {
mocks.platform.isMobile = false;
vi.clearAllMocks();
});
describe("ObsidianAPIService.showWindowOnRight", () => {
it("keeps the status view in the right leaf on mobile", async () => {
mocks.platform.isMobile = true;
const rightLeaf = {
setViewState: vi.fn().mockResolvedValue(undefined),
};
const workspace = {
getLeavesOfType: vi.fn(() => []),
getLeaf: vi.fn(),
getRightLeaf: vi.fn(() => rightLeaf),
revealLeaf: vi.fn().mockResolvedValue(undefined),
};
const service = createService(workspace, true);
expect(service.isMobile()).toBe(true);
await service.showWindowOnRight("p2p-status");
expect(workspace.getLeavesOfType).toHaveBeenCalledWith("p2p-status");
expect(workspace.getRightLeaf).toHaveBeenCalledWith(false);
expect(workspace.getLeaf).not.toHaveBeenCalled();
expect(rightLeaf.setViewState).toHaveBeenCalledWith({
type: "p2p-status",
active: false,
});
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
});
});
+4 -27
View File
@@ -35,8 +35,6 @@ export type DiscardUnreadableRevisionResult =
| "no-longer-live"
| "revision-is-readable";
export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision";
export async function inspectFileRepair(core: FileRepairCore, path: string): Promise<FileRepairInspection> {
const information = await inspectFileDatabaseInfo(core, path);
const storageContent = information.storage.exists
@@ -64,12 +62,12 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro
}
const winner = revisions.find(({ role }) => role === "winner");
const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted;
const databaseAndStorageDiffer =
information.storage.exists !== winnerRepresentsStoredFile ||
information.storage.exists !== information.database.exists ||
(information.storage.exists &&
winnerRepresentsStoredFile &&
winner.contentMatchesStorage === false);
winner !== undefined &&
(winner.metadata.deleted || winner.contentMatchesStorage === false)) ||
(!information.storage.exists && winner !== undefined && !winner.metadata.deleted);
const unreadableLiveRevision =
information.database.unavailableConflictRevisions.length > 0 ||
revisions.some(({ contentReadable }) => !contentReadable);
@@ -109,24 +107,3 @@ export async function discardUnreadableLiveRevision(
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
return deleted ? "discarded" : "failed";
}
export async function discardLiveBranch(
core: FileRepairCore,
path: string,
revision: string
): Promise<DiscardLiveBranchResult> {
const latest = await inspectFileDatabaseInfo(core, path);
const liveRevisions = [
latest.database.currentRevision,
...latest.database.conflictRevisions,
].filter((candidate): candidate is string => candidate !== null);
if (!liveRevisions.includes(revision)) {
return "no-longer-live";
}
if (liveRevisions.length < 2) {
return "only-live-revision";
}
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
return deleted ? "discarded" : "failed";
}
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
discardLiveBranch,
discardUnreadableLiveRevision,
inspectFileRepair,
} from "./fileRepair";
@@ -17,7 +16,6 @@ function createCore() {
size: 7,
type: "plain",
children: ["h:current"],
deleted: false,
eden: {},
};
const conflict = {
@@ -120,29 +118,6 @@ describe("file repair inspection", () => {
expect(inspection.requiresAttention).toBe(true);
});
it("omits a logical deletion which already matches an absent Vault file", async () => {
const { core, current } = createCore();
current.deleted = true;
current._conflicts = [];
current.children = [];
core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false);
core.storageAccess.statHidden.mockResolvedValue(null as never);
const inspection = await inspectFileRepair(core as never, "note.md");
expect(inspection.revisions).toEqual([
expect.objectContaining({
role: "winner",
contentReadable: true,
metadata: expect.objectContaining({
deleted: true,
revision: "3-current",
}),
}),
]);
expect(inspection.requiresAttention).toBe(false);
});
it("rechecks liveness and readability before discarding an exact revision", async () => {
const { core, deleteRevisionFromDB } = createCore();
@@ -194,35 +169,4 @@ describe("file repair inspection", () => {
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
});
it("discards an exact readable winner while another live branch remains", async () => {
const { core, deleteRevisionFromDB } = createCore();
await expect(
discardLiveBranch(core as never, "note.md", "3-current")
).resolves.toBe("discarded");
expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current");
});
it("refuses to discard the only live branch", async () => {
const { core, current, deleteRevisionFromDB } = createCore();
current._conflicts = [];
await expect(
discardLiveBranch(core as never, "note.md", "3-current")
).resolves.toBe("only-live-revision");
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
});
it("refuses to discard a branch which is no longer live", async () => {
const { core, deleteRevisionFromDB } = createCore();
await expect(
discardLiveBranch(core as never, "note.md", "1-stale")
).resolves.toBe("no-longer-live");
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
});
});
@@ -1,144 +0,0 @@
import {
BASE_IS_NEW,
EVEN,
TARGET_IS_NEW,
} from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
import {
compareMTime,
readAsBlob,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type {
FileRepairInspection,
FileRepairRevision,
} from "./fileRepair";
export type FileRepairRevisionActions = {
compareWithVault: boolean;
applyRevisionToVault: boolean;
markAsVaultRevision: boolean;
storeVaultOnBranch: boolean;
applyLogicalDeletionToVault: boolean;
retryRevision: boolean;
discardBranch: boolean;
discardRevision: boolean;
};
export type FileRepairTimestampRelation =
| "vault-newer"
| "database-newer"
| "same-window"
| "unavailable";
export type FileRepairRevisionComparison = {
recordedSize: number;
decodedSize: number | null;
recordedToDecodedSizeDifference: number | null;
vaultSize: number | null;
databaseToVaultSizeDifference: number | null;
databaseMtime: number;
vaultMtime: number | null;
timestampDifferenceMs: number | null;
timestampRelation: FileRepairTimestampRelation;
};
export function getFileRepairRevisionActions(
inspection: FileRepairInspection,
revision: FileRepairRevision
): FileRepairRevisionActions {
const storageExists = inspection.information.storage.exists;
const hasRevision = revision.metadata.revision !== null;
const readableFileRevision =
!revision.metadata.deleted &&
revision.contentReadable &&
revision.loadedEntry !== false;
const matchesVault = storageExists && revision.contentMatchesStorage === true;
const hasConflictBranches = inspection.information.database.conflictCount > 0;
return {
compareWithVault:
readableFileRevision &&
storageExists &&
revision.contentMatchesStorage === false &&
isPlainText(inspection.information.path),
applyRevisionToVault:
hasRevision &&
readableFileRevision &&
(!storageExists || revision.contentMatchesStorage !== true),
markAsVaultRevision:
hasRevision &&
readableFileRevision &&
matchesVault,
storeVaultOnBranch:
hasRevision &&
storageExists &&
revision.contentMatchesStorage !== true,
applyLogicalDeletionToVault:
hasRevision &&
revision.metadata.deleted &&
storageExists,
retryRevision:
hasRevision &&
!revision.metadata.deleted &&
!revision.contentReadable,
discardBranch: hasRevision && hasConflictBranches,
discardRevision:
hasRevision &&
!hasConflictBranches &&
!revision.metadata.deleted &&
!revision.contentReadable,
};
}
export function getFileRepairRevisionComparison(
inspection: FileRepairInspection,
revision: FileRepairRevision
): FileRepairRevisionComparison {
const decodedSize =
revision.loadedEntry === false
? null
: readAsBlob(revision.loadedEntry).size;
const vaultSize =
inspection.information.storage.exists
? (inspection.information.storage.size ?? null)
: null;
const databaseMtime = revision.metadata.mtime;
const vaultMtime =
inspection.information.storage.exists
? (inspection.information.storage.mtime ?? null)
: null;
const timestampDifferenceMs =
databaseMtime > 0 && vaultMtime !== null && vaultMtime > 0
? vaultMtime - databaseMtime
: null;
let timestampRelation: FileRepairTimestampRelation = "unavailable";
if (timestampDifferenceMs !== null) {
const comparison = compareMTime(vaultMtime!, databaseMtime);
timestampRelation =
comparison === EVEN
? "same-window"
: comparison === BASE_IS_NEW
? "vault-newer"
: comparison === TARGET_IS_NEW
? "database-newer"
: "unavailable";
}
return {
recordedSize: revision.metadata.recordedSize,
decodedSize,
recordedToDecodedSizeDifference:
decodedSize === null
? null
: decodedSize - revision.metadata.recordedSize,
vaultSize,
databaseToVaultSizeDifference:
decodedSize === null || vaultSize === null
? null
: vaultSize - decodedSize,
databaseMtime,
vaultMtime,
timestampDifferenceMs,
timestampRelation,
};
}
@@ -1,230 +0,0 @@
import { describe, expect, it } from "vitest";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { FileRepairInspection, FileRepairRevision } from "./fileRepair";
import {
getFileRepairRevisionActions,
getFileRepairRevisionComparison,
} from "./fileRepairPresentation";
function createInspection(
revision: Partial<FileRepairRevision> = {},
storage: { exists: boolean; size?: number; mtime?: number } = {
exists: true,
size: 12,
mtime: 5_500,
}
): { inspection: FileRepairInspection; revision: FileRepairRevision } {
const completeRevision = {
role: "conflict",
metadata: {
documentId: "f:note",
revision: "2-conflict",
current: false,
deleted: false,
storageType: "plain",
storageLayout: "chunked",
ctime: 1,
mtime: 2_000,
recordedSize: 9,
revisionHistory: [],
chunkReferences: 0,
uniqueChunkReferences: 0,
embeddedChunkReferences: 0,
locallyStoredChunkReferences: 0,
contentAvailableLocally: true,
chunks: [],
},
contentReadable: true,
contentMatchesStorage: false,
loadedEntry: {
_id: "f:note",
_rev: "2-conflict",
path: "note.md",
ctime: 1,
mtime: 2_000,
size: 9,
type: "plain",
datatype: "plain",
children: [],
eden: {},
data: "content",
},
...revision,
} as FileRepairRevision;
const inspection = {
information: {
path: "note.md",
databasePath: "note.md" as FilePathWithPrefix,
storage,
database: {
source: "local database on this device",
remoteQueried: false,
exists: true,
currentRevision: "3-winner",
conflictCount: 1,
conflictRevisions: ["2-conflict"],
unavailableConflictRevisions: [],
revisions: [],
mergeBases: [],
},
},
revisions: [completeRevision],
requiresAttention: true,
} satisfies FileRepairInspection;
return { inspection, revision: completeRevision };
}
describe("file repair presentation", () => {
it("offers both reconciliation directions for a readable differing revision", () => {
const { inspection, revision } = createInspection();
expect(getFileRepairRevisionActions(inspection, revision)).toEqual({
compareWithVault: true,
applyRevisionToVault: true,
markAsVaultRevision: false,
storeVaultOnBranch: true,
applyLogicalDeletionToVault: false,
retryRevision: false,
discardRevision: false,
discardBranch: true,
});
});
it("marks an exact matching revision without creating another child", () => {
const { inspection, revision } = createInspection({
contentMatchesStorage: true,
});
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
compareWithVault: false,
applyRevisionToVault: false,
markAsVaultRevision: true,
storeVaultOnBranch: false,
discardBranch: true,
});
});
it("does not offer a text comparison for a binary file", () => {
const { inspection, revision } = createInspection();
inspection.information.path = "image.png";
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
compareWithVault: false,
applyRevisionToVault: true,
storeVaultOnBranch: true,
});
});
it("offers explicit deletion or branch extension for a logical deletion", () => {
const { inspection, revision } = createInspection({
metadata: {
...createInspection().revision.metadata,
deleted: true,
},
contentReadable: true,
contentMatchesStorage: null,
loadedEntry: false,
});
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
applyRevisionToVault: false,
storeVaultOnBranch: true,
applyLogicalDeletionToVault: true,
retryRevision: false,
discardRevision: false,
discardBranch: true,
});
});
it("offers retry, discard, and branch extension for an unreadable live revision", () => {
const { inspection, revision } = createInspection({
contentReadable: false,
contentMatchesStorage: null,
loadedEntry: false,
});
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
compareWithVault: false,
applyRevisionToVault: false,
markAsVaultRevision: false,
storeVaultOnBranch: true,
retryRevision: true,
discardRevision: false,
discardBranch: true,
});
});
it("keeps the existing unreadable-leaf escape hatch when there is no conflict branch", () => {
const { inspection, revision } = createInspection({
role: "winner",
contentReadable: false,
contentMatchesStorage: null,
loadedEntry: false,
});
inspection.information.database.conflictCount = 0;
inspection.information.database.conflictRevisions = [];
inspection.information.database.currentRevision = revision.metadata.revision;
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
discardRevision: true,
discardBranch: false,
});
});
it("does not offer a storage action for a matching absent logical deletion", () => {
const { inspection, revision } = createInspection(
{
metadata: {
...createInspection().revision.metadata,
deleted: true,
},
contentReadable: true,
contentMatchesStorage: null,
loadedEntry: false,
},
{ exists: false }
);
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
applyLogicalDeletionToVault: false,
storeVaultOnBranch: false,
});
});
it("reports recorded, decoded, Vault-size, and timestamp differences", () => {
const { inspection, revision } = createInspection();
expect(getFileRepairRevisionComparison(inspection, revision)).toEqual({
recordedSize: 9,
decodedSize: 7,
recordedToDecodedSizeDifference: -2,
vaultSize: 12,
databaseToVaultSizeDifference: 5,
databaseMtime: 2_000,
vaultMtime: 5_500,
timestampDifferenceMs: 3_500,
timestampRelation: "vault-newer",
});
});
it("uses the same two-second timestamp comparison window as synchronisation", () => {
const { inspection, revision } = createInspection(
{
metadata: {
...createInspection().revision.metadata,
mtime: 3_001,
},
},
{
exists: true,
size: 12,
mtime: 3_999,
}
);
expect(getFileRepairRevisionComparison(inspection, revision)).toMatchObject({
timestampDifferenceMs: 998,
timestampRelation: "same-window",
});
});
});
+7 -56
View File
@@ -612,62 +612,6 @@ body.is-mobile .livesync-compatibility-review-notice {
background: var(--background-secondary);
}
.sls-repair-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--size-4-2);
min-width: 0;
}
.sls-repair-header > :first-child {
flex: 1 1 auto;
min-width: 0;
}
.sls-repair-header h6 {
margin: 0;
overflow-wrap: anywhere;
}
.sls-repair-status {
display: flex;
flex-wrap: wrap;
gap: var(--size-4-2);
margin-top: var(--size-4-1);
font-size: var(--font-ui-smaller);
}
.sls-repair-status-ok {
color: var(--text-success);
}
.sls-repair-status-warning {
color: var(--text-warning);
}
.sls-repair-metric {
margin-top: var(--size-4-1);
font-size: var(--font-ui-smaller);
line-height: var(--line-height-tight);
overflow-wrap: anywhere;
}
.sls-repair-action-menu {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--clickable-icon-size);
width: var(--clickable-icon-size);
height: var(--clickable-icon-size);
padding: 0;
}
.sls-repair-action-menu .svg-icon {
width: 18px;
height: 18px;
}
.sls-repair-revision {
margin-top: var(--size-4-2);
padding: var(--size-4-2);
@@ -692,6 +636,13 @@ body.is-mobile .livesync-compatibility-review-notice {
color: var(--text-warning);
}
.sls-repair-actions {
display: flex;
flex-wrap: wrap;
gap: var(--size-4-2);
margin-top: var(--size-4-2);
}
/* Diff navigation */
.diff-options-row {
display: flex;
+7 -11
View File
@@ -7,12 +7,12 @@ The generic application discovery, isolated-vault, plug-in installation, process
The current smoke runner verifies the launch path and the loaded plug-in's Service Context composition:
1. create a temporary vault,
2. install the built Self-hosted LiveSync plug-in artefacts,
2. install the built Self-hosted LiveSync plug-in artifacts,
3. launch real Obsidian,
4. open the temporary vault through `obsidian-cli`,
5. prepare the isolated Vault trust state and handle any Obsidian trust prompt,
6. preserve natural plug-in loading, or complete requested pre-load work before loading the plug-in once in controlled start-up,
7. verify through the active renderer that the plug-in is loaded,
5. enable Obsidian community plug-ins for the temporary app profile,
6. reload Self-hosted LiveSync through `obsidian-cli`,
7. verify through `obsidian-cli eval` that the plug-in is loaded,
8. observe event and translation results from the actual `ObsidianServiceContext`,
9. verify that the Service Hub and every exposed service retain that exact Context,
10. optionally drive a real vault or CouchDB workflow through Obsidian's own API, and
@@ -24,8 +24,6 @@ Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/comm
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
When a LiveSync-owned scenario must establish application state before the plug-in's first load, pass an instance-scoped `lifecycle.beforePluginStart` callback through that wrapper. For example, the P2P pane scenario calls `setObsidianMobileTestModeBeforePluginStart()` there so LiveSync observes the mobile application state while registering its command and view. Mobile emulation reopens Obsidian's workspace layout; this helper waits for both the `is-mobile` body state and `workspace.layoutReady` before controlled loading continues. The shared package owns the controlled start-up order and guarantees that the plug-in loads once; the LiveSync scenario owns the resulting command, workspace placement, and visible UI assertions. Changing the state only after loading the plug-in is not evidence of its mobile start-up behaviour.
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
On macOS, `@vrtmrz/obsidian-test-session` keeps the generated Vault and profile below `/tmp` so Obsidian's Unix-domain CLI socket remains below the platform path limit. It also gives only the isolated Obsidian process Chromium's mock-keychain flag, preventing the empty test HOME from opening a blocking login-keychain dialogue. LiveSync's deterministic fixture selects the built-in default language so a host-language translation prompt cannot pause plug-in readiness. The case-only rename check enumerates the parent directory and compares exact spellings because an old-path lookup still resolves the renamed file on the default case-insensitive macOS filesystem.
@@ -112,7 +110,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
`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:p2p-pane` starts one unconfigured CouchDB-only session and one configured P2P session. It proves that the command remains registered while the retired command, automatic pane, and unconfigured ribbon entry are absent. For the configured profile, it verifies that the ribbon and current status command reach the pane without opening it at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. 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, MinIO, 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.
@@ -128,8 +126,6 @@ The two-Vault workflow performs the missing-marker review once for each isolated
`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.
The isolated Obsidian session starts with its CouchDB settings and device-local compatibility acknowledgement already in place. This keeps the scenario focused on cross-runtime data compatibility; unconfigured start-up and visible CouchDB onboarding are covered by their dedicated workflows.
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.
For example, to test an executable on `PATH`:
@@ -145,7 +141,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
npm run test:e2e:obsidian:cli-to-obsidian-sync
```
`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:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage 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 MinIO 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 A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
@@ -165,7 +161,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
`test:e2e:obsidian:revision-repair` creates two live revisions in a temporary real Obsidian Vault, removes a chunk used only by the non-winning revision, and proves that automatic conflict checking does not discard the unreadable branch. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, and leave the revision tree unchanged when reading is retried. The scenario then verifies both the cancellation path and the explicit confirmation path for discarding that exact unreadable live revision, requires the winner to remain unchanged, and captures the repair card. It uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
`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. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
@@ -10,7 +10,6 @@ vi.mock("./cli.ts", () => ({ evalObsidianJson }));
import {
assertE2eCompatibilityMarker,
createE2eCouchDbPluginData,
prepareRemote,
waitForLiveSyncCoreReady,
type CompatibilityMarkerState,
} from "./liveSyncWorkflow.ts";
@@ -78,18 +77,3 @@ describe("Real Obsidian core readiness", () => {
expect(evalObsidianJson).toHaveBeenCalledTimes(2);
});
});
describe("remote fixture preparation", () => {
it("waits for the remote Security Seed after resolving a new remote", async () => {
evalObsidianJson.mockReset();
evalObsidianJson.mockResolvedValueOnce({ status: "resolved", securitySeedReady: true });
await prepareRemote("obsidian-cli", {});
const evaluatedCode = String(evalObsidianJson.mock.calls[0]?.[1] ?? "");
expect(evaluatedCode.indexOf("markRemoteResolved")).toBeLessThan(
evaluatedCode.indexOf("ensurePBKDF2Salt")
);
expect(evaluatedCode).toContain("Timed out preparing the remote Security Seed");
});
});
+1 -10
View File
@@ -462,7 +462,6 @@ export function assertObsidianServiceContextContract(result: ObsidianServiceCont
}
export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_PREPARE_TIMEOUT_MS ?? 20000);
await evalObsidianJson<unknown>(
cliBinary,
[
@@ -472,16 +471,8 @@ export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv):
"const replicator=core.services.replicator.getActiveReplicator();",
"await replicator.tryCreateRemoteDatabase(settings);",
"await replicator.markRemoteResolved(settings);",
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
"let securitySeedReady=false;",
"do{",
"securitySeedReady=await replicator.ensurePBKDF2Salt(settings,false,false);",
"if(securitySeedReady) break;",
"await new Promise((resolve)=>setTimeout(resolve,250));",
"}while(Date.now()<deadline);",
"if(!securitySeedReady) throw new Error('Timed out preparing the remote Security Seed');",
"const status=await replicator.getRemoteStatus(settings);",
"return JSON.stringify({status,securitySeedReady});",
"return JSON.stringify({status});",
"})()",
].join(""),
env
+11 -65
View File
@@ -1,5 +1,3 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
@@ -14,20 +12,13 @@ export const desktopViewport = { width: 1024, height: 768 } as const;
export const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const;
type ObsidianTestApp = {
isMobile?: boolean;
emulateMobile?: (mobile: boolean) => void;
plugins?: { plugins: Record<string, unknown> };
workspace?: { layoutReady?: boolean };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function applyObsidianMobileTestMode(
port: number,
enabled: boolean,
timeoutMs: number,
waitForLiveSync: boolean
): Promise<void> {
export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.setViewportSize(enabled ? mobileViewport : desktopViewport);
await page.evaluate((nextEnabled) => {
@@ -37,49 +28,17 @@ async function applyObsidianMobileTestMode(
}
obsidianApp.emulateMobile(nextEnabled);
}, enabled);
// Obsidian reopens its workspace layout when platform emulation
// changes. Loading a controlled plug-in before that transition has
// completed can leave the plug-in enabled but absent from the active
// renderer.
try {
await page.waitForFunction(
({ nextEnabled, waitForLiveSync }) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
return (
document.body.classList.contains("is-mobile") === nextEnabled &&
obsidianApp?.workspace?.layoutReady === true &&
(!waitForLiveSync || obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined)
);
},
{ nextEnabled: enabled, waitForLiveSync },
{ timeout: timeoutMs }
);
} catch (error) {
const state = await page.evaluate(() => {
await page.waitForFunction(
(nextEnabled) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
return {
appIsMobile: obsidianApp?.isMobile ?? null,
bodyClasses: document.body.className,
documentReadyState: document.readyState,
liveSyncLoaded: obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined,
viewport: { width: window.innerWidth, height: window.innerHeight },
workspaceLayoutReady: obsidianApp?.workspace?.layoutReady ?? null,
};
});
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(outputDirectory, { recursive: true });
const screenshotPath = join(
outputDirectory,
waitForLiveSync
? "mobile-mode-transition.failure.png"
: "mobile-mode-before-plugin-start.failure.png"
);
await page.screenshot({ path: screenshotPath, fullPage: true });
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Obsidian mobile-mode transition did not settle: ${JSON.stringify(state)}; screenshot=${screenshotPath}; cause=${detail}`
);
}
return (
document.body.classList.contains("is-mobile") === nextEnabled &&
obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined
);
},
enabled,
{ timeout: timeoutMs }
);
await page.evaluate(
(safeArea) => {
for (const edge of ["top", "right", "bottom", "left"] as const) {
@@ -93,19 +52,6 @@ async function applyObsidianMobileTestMode(
});
}
/** Enters mobile emulation before LiveSync's first load in a controlled session. */
export async function setObsidianMobileTestModeBeforePluginStart(
port: number,
enabled: boolean,
timeoutMs: number
): Promise<void> {
await applyObsidianMobileTestMode(port, enabled, timeoutMs, false);
}
export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise<void> {
await applyObsidianMobileTestMode(port, enabled, timeoutMs, true);
}
export async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise<void> {
const dialogue = container.locator(".modal").last();
const closeButton = dialogue.locator(".modal-close-button");
-11
View File
@@ -1,14 +1,3 @@
/**
* Supplies the runner-owned CouchDB fixture operations for the Security Seed
* reconnect scenario. Production code remains responsible for fetching,
* caching, and applying the Seed; this helper only validates the managed
* synchronisation-parameter document, replaces the one intended field, and
* compares document snapshots.
*
* Callers expose only short SHA-256 fingerprints. The original and replacement
* Seed values must stay inside the isolated test process and must not be
* written to diagnostics, screenshots, or machine-readable results.
*/
import { createHash, randomBytes } from "node:crypto";
import type { CouchDbDocument } from "./couchdb.ts";
-32
View File
@@ -52,36 +52,4 @@ describe("LiveSync real-Obsidian session", () => {
})
);
});
it("forwards instance-scoped lifecycle hooks and the selected plug-in start mode", async () => {
const beforePluginStart = vi.fn(async () => undefined);
const vault = {
path: "/tmp/mobile-vault",
statePath: "/tmp/mobile-state",
name: "mobile-vault",
id: "mobile-vault-id",
homePath: "/tmp/mobile-state/home",
xdgConfigPath: "/tmp/mobile-state/xdg-config",
xdgCachePath: "/tmp/mobile-state/xdg-cache",
xdgDataPath: "/tmp/mobile-state/xdg-data",
userDataPath: "/tmp/mobile-state/user-data",
processMarker: "/tmp/mobile-state",
dispose: vi.fn(async () => undefined),
};
await startObsidianLiveSyncSession({
binary: "/Applications/Obsidian",
cliBinary: "obsidian-cli",
vault,
pluginStartup: "controlled",
lifecycle: { beforePluginStart },
});
expect(startObsidianPluginSession).toHaveBeenCalledWith(
expect.objectContaining({
lifecycle: { beforePluginStart },
pluginStartup: "controlled",
})
);
});
});
+1 -10
View File
@@ -1,9 +1,4 @@
import {
startObsidianPluginSession,
type ObsidianPluginSession,
type ObsidianPluginSessionLifecycle,
type ObsidianPluginStartupMode,
} from "@vrtmrz/obsidian-test-session";
import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
import type { TemporaryVault } from "./vault.ts";
export type ObsidianLiveSyncSession = ObsidianPluginSession;
@@ -16,8 +11,6 @@ export type StartObsidianLiveSyncSessionOptions = {
startupGraceMs?: number;
pluginData?: Record<string, unknown>;
localStorageEntries?: Readonly<Record<string, string>>;
pluginStartup?: ObsidianPluginStartupMode;
lifecycle?: ObsidianPluginSessionLifecycle;
env?: NodeJS.ProcessEnv;
};
@@ -33,8 +26,6 @@ export async function startObsidianLiveSyncSession(
startupGraceMs: options.startupGraceMs,
pluginData: options.pluginData,
localStorageEntries: options.localStorageEntries,
pluginStartup: options.pluginStartup,
lifecycle: options.lifecycle,
env: options.env,
});
}
@@ -13,8 +13,7 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
configureCouchDb,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -312,23 +311,25 @@ async function main(): Promise<void> {
cliBinary: obsidianCli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(
{
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
},
{
encrypt: true,
passphrase: e2eePassphrase,
usePathObfuscation: true,
E2EEAlgorithm: "v2",
}
),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv);
await configureCouchDb(
obsidianCli.binary,
session.cliEnv,
{
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
},
{
encrypt: true,
passphrase: e2eePassphrase,
usePathObfuscation: true,
E2EEAlgorithm: "v2",
}
);
await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv);
await prepareRemote(obsidianCli.binary, session.cliEnv);
await pushLocalChanges(obsidianCli.binary, session.cliEnv);
+3 -24
View File
@@ -765,39 +765,18 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
for (const label of [
"Write logs into the file",
"Recreate chunks for current Vault files",
"Inspect conflicts and file/database differences",
"Resolve All conflicted files by the newer one",
"Verify and repair all files",
]) {
await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
const settingNames = await liveSyncSettings.locator(".setting-item-name").allTextContents();
const recreateIndex = settingNames.findIndex((name) =>
name.includes("Recreate chunks for current Vault files")
);
const inspectIndex = settingNames.findIndex((name) =>
name.includes("Inspect conflicts and file/database differences")
);
const resolveIndex = settingNames.findIndex((name) =>
name.includes("Resolve All conflicted files by the newer one")
);
if (
recreateIndex === -1 ||
inspectIndex === -1 ||
resolveIndex === -1 ||
!(recreateIndex < inspectIndex && inspectIndex < resolveIndex)
) {
throw new Error(
"Recovery actions are not ordered from chunk recreation through inspection to bulk conflict resolution"
);
}
await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await liveSyncSettings.getByRole("button", { name: "Scan all files", exact: true }).waitFor({
await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
@@ -918,7 +897,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
await page
.locator(".sls-setting:visible")
.last()
.getByRole("button", { name: "Scan all files", exact: true })
.getByRole("button", { name: "Verify all", exact: true })
.click({
timeout: uiTimeoutMs,
});
-24
View File
@@ -1,27 +1,8 @@
/**
* Verifies one complete Object Storage upload from a real Obsidian Vault,
* through LiveSync's local database and Journal Sync, to an S3-compatible
* service observed independently through the AWS SDK.
*
* The isolated Vault starts with Object Storage settings and the device-local
* compatibility acknowledgement already in place. Unconfigured start-up is
* intentionally inert and belongs to the onboarding scenario; compatibility
* review and visible setup have their own dedicated workflows. Supplying those
* prerequisites here keeps this scenario focused on the upload boundary.
*
* Note creation, local-database observation, one-shot synchronisation, request
* accounting, remote-object inspection, and prefix cleanup remain in one
* scenario so that a pass proves the same payload crossed every boundary.
* Separate successes would not prove that those observations belonged to the
* same upload.
*/
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureObjectStorage,
createE2eObjectStoragePluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -119,11 +100,6 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eObjectStoragePluginData({
...objectStorage,
bucketPrefix,
}),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
+34 -220
View File
@@ -1,181 +1,46 @@
/**
* Verifies the complete user-visible contract of the P2P status pane in real
* Obsidian: a configured CouchDB-only Vault with no P2P profile is not
* presented with P2P controls, while configured P2P devices can deliberately
* open the current pane in the appropriate workspace area.
*
* Desktop and mobile use separate Vaults, profiles, and Obsidian processes.
* Mobile mode is enabled before LiveSync's first load so that command and view
* registration observe the mobile application state, and no desktop workspace
* state can make a misplaced or restored pane appear correct.
*
* Command registration, automatic-opening policy, ribbon availability,
* workspace ownership, layout, and screenshots are kept in one scenario
* because together they describe one navigation path. Checking them in
* isolation could miss a pane which is registered correctly but opens in the
* wrong area, or one which is visible only because another session restored it.
*/
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { ConsoleMessage, Page } from "playwright";
import type { Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts";
import { setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000);
type ObsidianTestLeaf = {
containerEl?: HTMLElement;
view?: { getViewType?: () => string };
};
type ObsidianTestWorkspace = {
activeLeaf?: ObsidianTestLeaf;
getLeavesOfType?: (type: string) => ObsidianTestLeaf[];
getRightLeaf?: (split: boolean) => ObsidianTestLeaf | null;
rightSplit?: { containerEl?: HTMLElement };
};
type ObsidianTestApp = {
commands?: {
commands?: Record<string, unknown>;
executeCommandById(commandId: string): boolean;
};
isMobile?: boolean;
plugins?: {
plugins?: Record<
string,
{
core?: {
services?: {
API?: {
isMobile?: () => boolean;
};
};
};
}
>;
};
workspace?: ObsidianTestWorkspace;
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function openP2PStatusPane(page: Page) {
return await page.evaluate((commandId) => {
const app = (globalThis as ObsidianTestGlobal).app;
const plugin = app?.plugins?.plugins?.["obsidian-livesync"];
return {
opened: app?.commands?.executeCommandById(commandId) === true,
appIsMobile: app?.isMobile ?? null,
apiIsMobile: plugin?.core?.services?.API?.isMobile?.() ?? null,
bodyIsMobile: document.body.classList.contains("is-mobile"),
};
}, "obsidian-livesync:open-p2p-server-status");
}
async function collectP2PWorkspaceState(page: Page) {
return await page.evaluate(() => {
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
const activeLeaf = workspace?.activeLeaf;
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
const rightLeaf = workspace?.getRightLeaf?.(false);
return {
bodyClasses: document.body.className,
activeLeaf: {
type: activeLeaf?.view?.getViewType?.() ?? null,
visible: activeLeaf?.containerEl?.checkVisibility?.() ?? null,
classes: activeLeaf?.containerEl?.className ?? null,
},
p2pLeaves: p2pLeaves.map((leaf) => ({
type: leaf.view?.getViewType?.() ?? null,
visible: leaf.containerEl?.checkVisibility?.() ?? null,
classes: leaf.containerEl?.className ?? null,
})),
rightLeaf: {
type: rightLeaf?.view?.getViewType?.() ?? null,
visible: rightLeaf?.containerEl?.checkVisibility?.() ?? null,
classes: rightLeaf?.containerEl?.className ?? null,
},
visibleP2PContents: document.querySelectorAll(
".workspace-leaf-content[data-type='p2p-server-status']:not(.is-hidden)"
).length,
};
async function openP2PStatusPane(): Promise<void> {
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
return await page.evaluate(
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:open-p2p-server-status"
);
});
}
async function assertMobileP2PPlacement(page: Page): Promise<void> {
const placement = await page.evaluate(() => {
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
const rightSplit = workspace?.rightSplit?.containerEl;
const rightLeaf = workspace?.getRightLeaf?.(false);
return {
p2pLeafCount: p2pLeaves.length,
inRightSplit: p2pLeaves.some(
(leaf) =>
(rightSplit?.contains(leaf.containerEl ?? null) ?? false) ||
(leaf.containerEl?.closest(".mod-right-split, .workspace-drawer.mod-right") ?? null) !== null
),
rightLeafType: rightLeaf?.view?.getViewType?.() ?? null,
p2pLeafClasses: p2pLeaves.map((leaf) => leaf.containerEl?.className ?? null),
rightSplitClasses: rightSplit?.className ?? null,
};
});
if (!placement.inRightSplit) {
throw new Error(`The mobile P2P status view was not opened in the right leaf: ${JSON.stringify(placement)}`);
if (!opened) {
throw new Error("The P2P status command was not registered or could not be executed.");
}
}
async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<string> {
await openP2PStatusPane();
return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => {
const runtimeErrors: string[] = [];
const onPageError = (error: Error) => runtimeErrors.push(`pageerror: ${error.message}`);
const onConsole = (message: ConsoleMessage) => {
if (message.type() === "error") runtimeErrors.push(`console: ${message.text()}`);
};
page.on("pageerror", onPageError);
page.on("console", onConsole);
let dispatchState: Awaited<ReturnType<typeof openP2PStatusPane>> | undefined;
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
try {
dispatchState = await openP2PStatusPane(page);
if (!dispatchState.opened) {
throw new Error("The P2P status command was not registered or could not be executed.");
}
if (
mobile &&
(dispatchState.appIsMobile !== true ||
dispatchState.apiIsMobile !== true ||
dispatchState.bodyIsMobile !== true)
) {
throw new Error(
`The mobile P2P command did not observe a fully mobile application state: ${JSON.stringify(dispatchState)}`
);
}
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
} catch (error) {
const workspaceState = await collectP2PWorkspaceState(page);
console.error(
`P2P command state after failed open: ${JSON.stringify({ dispatchState, runtimeErrors })}`
);
console.error(`P2P workspace state after failed open: ${JSON.stringify(workspaceState)}`);
throw error;
} finally {
page.off("pageerror", onPageError);
page.off("console", onConsole);
}
if (mobile) {
await assertMobileP2PPlacement(page);
}
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
@@ -186,14 +51,7 @@ async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<s
});
const remoteSelector = pane.getByRole("combobox", { name: "Select active P2P remote" });
await remoteSelector.waitFor({ state: "visible", timeout: uiTimeoutMs });
const remoteSelectionDeadline = Date.now() + uiTimeoutMs;
let remoteConfigurationId = "";
while (Date.now() < remoteSelectionDeadline) {
remoteConfigurationId = (await remoteSelector.inputValue()).trim();
if (remoteConfigurationId !== "") break;
await page.waitForTimeout(50);
}
if (remoteConfigurationId === "") {
if ((await remoteSelector.inputValue()).trim() === "") {
throw new Error("The configured P2P status pane did not select an active P2P remote.");
}
if (
@@ -226,7 +84,7 @@ async function assertP2PUIIsOptIn(): Promise<void> {
throw new Error("The retired P2P pane command is still exposed.");
}
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
throw new Error("The P2P status pane opened automatically for a CouchDB user without P2P configured.");
throw new Error("The P2P status pane opened automatically for an unconfigured CouchDB user.");
}
if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) {
throw new Error("The P2P ribbon icon was shown without a P2P configuration.");
@@ -246,43 +104,12 @@ async function assertConfiguredP2PUIIsAvailable(): Promise<void> {
});
}
async function assertConfiguredP2PCommandIsAvailable(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const state = await page.evaluate(() => {
const app = (globalThis as ObsidianTestGlobal).app;
const commands = app?.commands?.commands ?? {};
return {
commandRegistered: commands["obsidian-livesync:open-p2p-server-status"] !== undefined,
openPaneCount: app?.workspace?.getLeavesOfType?.("p2p-server-status").length ?? 0,
};
});
if (!state.commandRegistered) {
throw new Error("The configured P2P status command was not registered in mobile mode.");
}
if (state.openPaneCount !== 0) {
throw new Error("The configured P2P status pane opened before the mobile user requested it.");
}
});
}
async function dismissOpenNotices(page: Page): Promise<void> {
const deadline = Date.now() + uiTimeoutMs;
let quietSince = Date.now();
while (Date.now() < deadline) {
const dismissed = await page.evaluate(() => {
const notices = (Array.from(document.querySelectorAll(".notice")) as HTMLElement[]).filter(
(notice) => notice.checkVisibility?.() ?? notice.offsetParent !== null
);
for (const notice of notices) {
const closeButton = notice.querySelector(".notice-close-button") as HTMLElement | null;
// Obsidian 1.12 does not render a separate close control for
// every Notice; clicking the Notice itself is its standard
// dismiss action.
(closeButton ?? notice).click();
}
return notices.length;
});
if (dismissed === 0) {
const notices = page.locator(".notice:visible");
if ((await notices.count()) === 0) {
if (Date.now() - quietSince >= 500) {
return;
}
@@ -290,7 +117,14 @@ async function dismissOpenNotices(page: Page): Promise<void> {
continue;
}
quietSince = Date.now();
await page.waitForTimeout(50);
const closeButton = notices.first().locator(".notice-close-button");
if ((await closeButton.count()) > 0) {
await closeButton.click({ force: true, timeout: uiTimeoutMs });
} else {
// Obsidian 1.12 does not render a separate close control for every
// Notice; clicking the Notice itself is its standard dismiss action.
await notices.first().click({ force: true, position: { x: 2, y: 2 }, timeout: uiTimeoutMs });
}
}
throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot.");
}
@@ -335,8 +169,7 @@ async function withP2PSession(
binary: string,
cliBinary: string,
pluginData: Record<string, unknown>,
verify: () => Promise<void>,
options: { mobileBeforePluginStart?: boolean } = {}
verify: () => Promise<void>
): Promise<void> {
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
@@ -348,17 +181,6 @@ async function withP2PSession(
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData,
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
lifecycle: options.mobileBeforePluginStart
? {
beforePluginStart: async ({ remoteDebuggingPort }) => {
await setObsidianMobileTestModeBeforePluginStart(
remoteDebuggingPort,
true,
uiTimeoutMs
);
},
}
: undefined,
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await verify();
@@ -388,25 +210,17 @@ async function main(): Promise<void> {
async () => {
await assertConfiguredP2PUIIsAvailable();
const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false);
console.log(
`Configured P2P status UI remained opt-in and was reachable on desktop. Screenshot: ${desktopScreenshot}`
);
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
try {
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
console.log(
`Configured P2P status UI remained opt-in and was reachable on desktop and mobile. Screenshots: ${desktopScreenshot}, ${mobileScreenshot}`
);
} finally {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs);
}
}
);
await withP2PSession(
binary,
cli.binary,
createConfiguredP2PPluginData(),
async () => {
await assertConfiguredP2PCommandIsAvailable();
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
console.log(
`Configured P2P status UI remained opt-in and was reachable on mobile. Screenshot: ${mobileScreenshot}`
);
},
{ mobileBeforePluginStart: true }
);
}
main().catch((error: unknown) => {
+3 -155
View File
@@ -1,5 +1,3 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
@@ -8,17 +6,11 @@ import {
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
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";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianDialogue,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS ?? 15000);
@@ -34,135 +26,6 @@ type ReviewHarnessTestGlobal = typeof globalThis & {
reviewHarnessCopiedReport?: string;
};
type ReviewHarnessReadinessSnapshot = {
coreAvailable: boolean;
databaseReady?: boolean;
appReady?: boolean;
configured?: boolean;
remoteType?: string;
settingVersion?: number;
suspended?: boolean;
unresolvedMessages: string[];
};
const sensitiveDiagnosticLine =
/security seed|passphrase|password|credential|secret|access.?key|jwt.?key|authori[sz]ation|obsidian:\/\/setuplivesync|sls\+/iu;
const interruptedStartupMessages = [
"No replicator has been activated or has not been initialised yet.",
"Self-hosted LiveSync cannot be initialised, exiting loading.",
];
function redactDiagnosticLine(line: string): string {
if (sensitiveDiagnosticLine.test(line)) return "[REDACTED SENSITIVE LOG LINE]";
return line.replace(/\bhttps?:\/\/[^/\s:@]+:[^@\s/]+@/giu, "https://[REDACTED]@");
}
async function assertNoInterruptedStartupNotice(stage: string): Promise<void> {
const notices = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.waitForTimeout(1500);
return await page.locator(".notice").allTextContents();
});
const interrupted = notices.filter((notice) =>
interruptedStartupMessages.some((message) => notice.includes(message))
);
if (interrupted.length > 0) {
throw new Error(`LiveSync emitted an interrupted-startup Notice during ${stage}: ${interrupted.join(" | ")}`);
}
console.log(`No interrupted-startup Notice observed during ${stage}.`);
}
async function captureReadinessFailure(
cliBinary: string,
session: ObsidianLiveSyncSession,
readinessError: unknown
): Promise<void> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(outputDirectory, { recursive: true });
const captureErrors: string[] = [];
let screenshotPath: string | undefined;
try {
screenshotPath = await captureObsidianPage(
obsidianRemoteDebuggingPort(),
"review-harness-core-not-ready.png",
async () => undefined
);
} catch (error) {
captureErrors.push(`screenshot: ${error instanceof Error ? error.message : String(error)}`);
}
let readiness: ReviewHarnessReadinessSnapshot | undefined;
try {
readiness = await evalObsidianJson<ReviewHarnessReadinessSnapshot>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync']?.core;",
"if(!core)return JSON.stringify({coreAvailable:false,unresolvedMessages:[]});",
"const settings=core.services.setting.currentSettings();",
"let unresolvedMessages=[];",
"try{",
"unresolvedMessages=(await core.services.appLifecycle.getUnresolvedMessages()).flat()",
".filter((message)=>message!==undefined&&message!==null)",
".map((message)=>String(message)).slice(-50);",
"}catch(error){unresolvedMessages=[`Could not inspect unresolved messages: ${String(error)}`];}",
"return JSON.stringify({",
"coreAvailable:true,",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"configured:settings?.isConfigured===true,",
"remoteType:settings?.remoteType??'',",
"settingVersion:settings?.settingVersion,",
"suspended:core.services.appLifecycle.isSuspended(),",
"unresolvedMessages,",
"});",
"})()",
].join(""),
session.cliEnv
);
readiness.unresolvedMessages = readiness.unresolvedMessages.map(redactDiagnosticLine);
} catch (error) {
captureErrors.push(`readiness snapshot: ${error instanceof Error ? error.message : String(error)}`);
}
let recentLog: string[] = [];
try {
recentLog = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const opened = await page.evaluate(
(commandId) =>
(globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:view-log"
);
if (!opened) throw new Error("The Show log command was not registered.");
const logPane = page.locator(".logpane");
await logPane.waitFor({ state: "visible", timeout: 5000 });
return (await logPane.locator(".log pre").allTextContents()).slice(-80).map(redactDiagnosticLine);
});
} catch (error) {
captureErrors.push(`recent log: ${error instanceof Error ? error.message : String(error)}`);
}
const resultPath = join(outputDirectory, "review-harness-core-not-ready.json");
await writeFile(
resultPath,
`${JSON.stringify(
{
capturedAt: new Date().toISOString(),
failure: readinessError instanceof Error ? readinessError.message : String(readinessError),
screenshotPath,
readiness,
recentLog,
captureErrors,
},
null,
2
)}\n`,
"utf8"
);
if (screenshotPath) console.error(`Review Harness core readiness screenshot: ${screenshotPath}`);
console.error(`Review Harness core readiness diagnostics: ${resultPath}`);
}
async function openHarness(): Promise<void> {
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
return await page.evaluate(
@@ -340,8 +203,6 @@ async function copyAndReadReport(): Promise<string> {
async function verifyMobileHarness(): Promise<string> {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
if (await harness.isVisible()) return;
await page.evaluate(async (viewType) => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
@@ -391,7 +252,7 @@ async function main(): Promise<void> {
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
doctorProcessedVersion: "0.25.27",
settingVersion: CURRENT_SETTING_VERSION,
isConfigured: true,
additionalSuffixOfDatabaseName: "",
@@ -408,20 +269,7 @@ async function main(): Promise<void> {
periodicReplication: true,
},
});
await assertNoInterruptedStartupNotice("plug-in session start");
try {
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
} catch (error) {
await captureReadinessFailure(cli.binary, session, error).catch((diagnosticError: unknown) => {
console.error(
`Could not capture Review Harness readiness diagnostics: ${
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)
}`
);
});
throw error;
}
await assertNoInterruptedStartupNotice("core readiness");
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await keepCompatibilityPaused();
await openHarness();
await waitForHarness();
+52 -453
View File
@@ -8,10 +8,8 @@ import {
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import type { Locator, Page } from "playwright";
const path = "revision-repair.md";
const healthyDeletedPath = "healthy-logical-deletion.md";
const baseContent = "Revision repair\n\nShared base.\n";
const branchContents = [
`Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`,
@@ -30,11 +28,6 @@ type RevisionTree = {
conflictRevisions: string[];
};
type VaultWinnerState = {
matches: boolean;
winnerRevision: string;
};
type ObsidianSettingsController = {
open(): void;
openTabById(tabId: string): void;
@@ -63,49 +56,6 @@ async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv):
);
}
async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(healthyDeletedPath)};`,
`const content=${JSON.stringify(`Healthy logical deletion\n\n${"D".repeat(4096)}\n`)};`,
"let file=app.vault.getAbstractFileByPath(path);",
"if(!file) file=await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
await waitForLocalDatabaseEntry(cliBinary, env, healthyDeletedPath);
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(healthyDeletedPath)};`,
`const timeoutMs=${JSON.stringify(uiTimeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Logical-deletion fixture is missing from the Vault: ${path}`);",
"await app.vault.delete(file);",
"const id=await core.services.path.path2id(path);",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
" await core.services.fileProcessing.commitPendingFileEvents();",
" const doc=await core.localDatabase.localDatabase.get(id,{conflicts:true}).catch(()=>false);",
" if(!app.vault.getAbstractFileByPath(path)&&doc?.deleted&&(doc._conflicts??[]).length===0){",
" return JSON.stringify(doc._rev);",
" }",
" await sleep(250);",
"}",
"throw new Error(`Timed out waiting for a healthy logical deletion: ${path}`);",
"})()",
].join(""),
env
);
}
async function createBrokenConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
@@ -178,96 +128,6 @@ async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Prom
);
}
async function readVaultWinnerState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<VaultWinnerState> {
return await evalObsidianJson<VaultWinnerState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Vault file is missing: ${path}`);",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
"if(!entry||!entry._rev) throw new Error(`Database winner is missing: ${path}`);",
"const vaultContent=await app.vault.read(file);",
"const data=Array.isArray(entry.data)?entry.data:[entry.data];",
"const databaseContent=await new Blob(data).text();",
"return JSON.stringify({",
" matches:vaultContent===databaseContent,",
" winnerRevision:entry._rev,",
"});",
"})()",
].join(""),
env
);
}
async function readFileReflectionProvenance(
cliBinary: string,
env: NodeJS.ProcessEnv,
targetPath = path
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
"return JSON.stringify((await store.get(path))??null);",
"})()",
].join(""),
env
);
}
function repairCard(settings: Locator): Locator {
return settings.locator(".sls-repair-result").filter({ hasText: path });
}
function revisionCard(settings: Locator, revision: string): Locator {
return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision });
}
async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise<Locator> {
const actionButton = revisionCard(settings, revision).getByRole("button", {
name: `More actions for revision ${revision}`,
exact: true,
});
await actionButton.locator("svg.lucide-wrench").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await actionButton.click({ timeout: uiTimeoutMs });
const menu = page.locator(".menu:visible").last();
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
const box = await menu.boundingBox();
const viewport = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
if (
box === null ||
box.y < 0 ||
box.y + box.height > viewport.height - 4
) {
throw new Error(
`Revision action menu is outside the viewport: ${JSON.stringify({
box,
viewport,
})}`
);
}
return menu;
}
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise<void> {
const menu = await openRevisionActionMenu(page, settings, revision);
const item = menu.getByText(action, { exact: true });
await item.waitFor({ state: "visible", timeout: uiTimeoutMs });
await item.click({ timeout: uiTimeoutMs });
}
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
@@ -327,22 +187,7 @@ async function main(): Promise<void> {
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv);
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
const healthyDeletionProvenance = await readFileReflectionProvenance(
cliBinary,
session.cliEnv,
healthyDeletedPath
);
if (healthyDeletionProvenance !== null) {
throw new Error(
`A healthy logical deletion retained Vault provenance indefinitely: ${JSON.stringify({
healthyDeletedPath,
healthyDeletionRevision,
healthyDeletionProvenance,
})}`
);
}
await requestConflictCheck(cliBinary, session.cliEnv);
const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv);
@@ -369,24 +214,18 @@ async function main(): Promise<void> {
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
const verifySetting = settings.locator(".setting-item").filter({
has: page.getByText("Inspect conflicts and file/database differences", {
exact: true,
}),
has: page.getByText("Verify and repair all files", { exact: true }),
});
await verifySetting.getByRole("button", { name: "Scan all files", exact: true }).click({
await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({
timeout: uiTimeoutMs,
});
const card = repairCard(settings);
const card = settings.locator(".sls-repair-result").filter({ hasText: path });
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) {
throw new Error(
`File/database inspection reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).`
);
}
const winnerRevision = revisionCard(settings, fixture.winnerRevision);
const brokenRevision = revisionCard(settings, fixture.conflictRevision);
const brokenRevision = card
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision });
await brokenRevision
.getByText(/🧩 Missing chunks: 1/u)
.getByText(/Unreadable on this device/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({
state: "visible",
@@ -395,276 +234,43 @@ async function main(): Promise<void> {
if ((await card.locator(".sls-repair-revision").count()) !== 2) {
throw new Error("Verify and Repair did not render the winner and conflict revision separately.");
}
for (const label of [
/📦 DB: recorded/u,
/📁 Vault:/u,
/Δsize vs DB/u,
/🕒 DB /u,
/Δtime /u,
/ Differs from Vault/u,
]) {
await winnerRevision.getByText(label).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
await brokenRevision.getByText(/decoded unavailable/u).waitFor({
state: "visible",
await brokenRevision.getByRole("button", { name: "Retry reading revision", exact: true }).click({
timeout: uiTimeoutMs,
});
const winnerMenu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
for (const label of [
"Compare with Vault",
"Apply this revision to Vault",
"Store Vault file as a child of this revision",
"Discard this branch",
]) {
await winnerMenu.getByText(label, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
if (
(await winnerMenu
.getByText("Mark this revision as the Vault version", {
exact: true,
})
.count()) !== 0
) {
throw new Error("A differing revision incorrectly offered to record an exact Vault match.");
}
await page.keyboard.press("Escape");
});
const repairCardScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const card = page.locator(".sls-repair-result").filter({ hasText: path });
await card.evaluate((element) => {
const htmlElement = element as HTMLElement;
htmlElement.dataset.e2eOriginalStyle = htmlElement.getAttribute("style") ?? "";
htmlElement.style.width = "360px";
htmlElement.style.maxWidth = "100%";
});
const dimensions = await card.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
if (dimensions.scrollWidth > dimensions.clientWidth + 1) {
throw new Error(
`Revision repair card overflowed at mobile width: ${JSON.stringify(dimensions)}`
);
}
});
const mobileWidthScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-mobile-width.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const card = page.locator(".sls-repair-result").filter({ hasText: path });
await card.evaluate((element) => {
const htmlElement = element as HTMLElement;
const originalStyle = htmlElement.dataset.e2eOriginalStyle ?? "";
if (originalStyle.length > 0) {
htmlElement.setAttribute("style", originalStyle);
} else {
htmlElement.removeAttribute("style");
}
delete htmlElement.dataset.e2eOriginalStyle;
});
});
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await openRevisionActionMenu(page, settings, fixture.winnerRevision);
});
const readableMenuScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-readable-actions.png",
(page) => page.locator(".menu:visible").last()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.winnerRevision, "Compare with Vault");
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByText(path, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText(/Vault file:/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText(/Database revision:/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const actions = modal.locator(".conflict-action-container");
await actions.getByRole("button", { name: "Close", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
for (const action of ["Use Vault file", "Use Database revision", "Concat both", "Not now"]) {
if ((await actions.getByRole("button", { name: action, exact: true }).count()) !== 0) {
throw new Error(`Read-only comparison exposed the resolution action '${action}'.`);
}
}
});
const comparisonScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-read-only-comparison.png",
(page) =>
page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
})
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
});
await modal
.locator(".conflict-action-container")
.getByRole("button", { name: "Close", exact: true })
.click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const beforeApply = await readRevisionTree(cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.winnerRevision, "Apply this revision to Vault");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Apply database revision to Vault",
}),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await revisionCard(settings, fixture.winnerRevision)
.getByText("✅ Matches Vault", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const status = repairCard(settings).locator(".sls-repair-status");
await status
.getByText("✅ Vault matches winner", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await status
.getByText("⚠️ Conflicts: 1", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const matchedWinnerWithConflictScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-winner-match-with-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
const afterApply = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterApply) !== JSON.stringify(beforeApply)) {
throw new Error(
`Applying a live revision to the Vault changed the revision tree: ${JSON.stringify({
beforeApply,
afterApply,
})}`
);
}
const vaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
const appliedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
!vaultWinner.matches ||
vaultWinner.winnerRevision !== fixture.winnerRevision ||
appliedProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Applying the winner did not preserve exact Vault provenance: ${JSON.stringify({
vaultWinner,
appliedProvenance,
fixture,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const menu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
await menu
.getByText("Mark this revision as the Vault version", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await menu
.getByText("Discard this branch", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await menu
.getByText("Mark this revision as the Vault version", { exact: true })
.click({ timeout: uiTimeoutMs });
await revisionCard(settings, fixture.winnerRevision)
.getByText("✅ Matches Vault", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterExactMark = await readRevisionTree(cliBinary, session.cliEnv);
const markedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
JSON.stringify(afterExactMark) !== JSON.stringify(beforeApply) ||
markedProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Recording an exact Vault match changed the tree or lost provenance: ${JSON.stringify({
beforeApply,
afterExactMark,
markedProvenance,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const menu = await openRevisionActionMenu(page, settings, fixture.conflictRevision);
for (const label of [
"Store Vault file as a child of this revision",
"Retry reading revision",
"Discard this branch",
]) {
await menu.getByText(label, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
});
const unreadableMenuScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-actions-context.png",
(page) => page.locator("body")
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Retry reading revision");
await revisionCard(settings, fixture.conflictRevision)
.getByText(/🧩 Missing chunks:/u)
await settings
.locator(".sls-repair-result")
.filter({ hasText: path })
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision })
.getByText(/Unreadable on this device/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterRetry = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterRetry) !== JSON.stringify(beforeApply)) {
if (!afterRetry.conflictRevisions.includes(fixture.conflictRevision)) {
throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`);
}
const screenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
const brokenRevision = () =>
settings
.locator(".sls-repair-result")
.filter({ hasText: path })
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision });
await brokenRevision()
.getByRole("button", { name: "Discard unreadable revision", exact: true })
.click({ timeout: uiTimeoutMs });
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs });
@@ -672,23 +278,36 @@ async function main(): Promise<void> {
});
const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterCancellation) !== JSON.stringify(beforeApply)) {
if (!afterCancellation.conflictRevisions.includes(fixture.conflictRevision)) {
throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
const brokenRevision = settings
.locator(".sls-repair-result")
.filter({ hasText: path })
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision });
await brokenRevision
.getByRole("button", { name: "Discard unreadable revision", exact: true })
.click({ timeout: uiTimeoutMs });
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await repairCard(settings).waitFor({ state: "hidden", timeout: uiTimeoutMs });
await settings
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision })
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv);
if (afterDiscard.winnerRevision !== fixture.winnerRevision || afterDiscard.conflictRevisions.length !== 0) {
if (
afterDiscard.winnerRevision !== fixture.winnerRevision ||
afterDiscard.conflictRevisions.length !== 0
) {
throw new Error(
`Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({
fixture,
@@ -696,31 +315,11 @@ async function main(): Promise<void> {
})}`
);
}
const finalVaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
const finalProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
!finalVaultWinner.matches ||
finalVaultWinner.winnerRevision !== fixture.winnerRevision ||
finalProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Discarding the unreadable branch disturbed the healthy Vault reflection: ${JSON.stringify({
finalVaultWinner,
finalProvenance,
fixture,
})}`
);
}
console.log(
"Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation."
"Real Obsidian kept an unreadable conflict revision through automatic checking and retry, rendered every live revision separately, required confirmation, and discarded only the selected revision."
);
console.log(`Repair card screenshot: ${repairCardScreenshot}`);
console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`);
console.log(`Readable revision actions screenshot: ${readableMenuScreenshot}`);
console.log(`Read-only comparison screenshot: ${comparisonScreenshot}`);
console.log(`Matching winner with conflict screenshot: ${matchedWinnerWithConflictScreenshot}`);
console.log(`Unreadable revision actions screenshot: ${unreadableMenuScreenshot}`);
console.log(`Repair screenshot: ${screenshot}`);
} finally {
if (session) {
await session.app.stop();
@@ -1,26 +1,3 @@
/**
* Provides release evidence for the Security Seed refresh behaviour shared by
* supported platforms in real Obsidian. It verifies that an already-open
* device keeps its deliberately stale cached Seed until replication, refreshes
* from the managed CouchDB fixture before encrypting, and never restores the
* old Seed to the remote synchronisation-parameter document.
*
* The scenario uses isolated Vaults, profiles, and a random database because
* settings, the local database, the renderer process, and CouchDB must all
* participate in the result. Device A is restarted with the same Vault and
* profile, while device B is fresh. The devices run sequentially after the
* same-process stale-cache assertion because desktop Obsidian may enforce a
* single application instance; running them concurrently would test launcher
* behaviour rather than LiveSync's shared plug-in implementation.
*
* Seed replacement, A-to-B decryption, B-to-A return synchronisation, final
* remote-document comparison, error-log inspection, screenshots, and strict
* teardown remain one scenario. Together they prove that the same replacement
* Seed was used across the complete encrypted round trip and was not later
* rolled back. Independent passing checks would not establish that continuity.
* The result records fingerprints only and does not claim to cover an
* iPadOS-specific background or reconnect lifecycle.
*/
import { execFileSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
-17
View File
@@ -12,23 +12,6 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
## Unreleased
## 1.0.0-beta.5
26th July, 2026
### Improved
- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain.
- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation.
### Fixed
- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent.
### Testing
- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions.
## 1.0.0-beta.4
25th July, 2026
+1 -2
View File
@@ -10,6 +10,5 @@
"1.0.0-beta.1": "1.7.2",
"1.0.0-beta.2": "1.7.2",
"1.0.0-beta.3": "1.7.2",
"1.0.0-beta.4": "1.7.2",
"1.0.0-beta.5": "1.7.2"
"1.0.0-beta.4": "1.7.2"
}