mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-14 20:20:12 +00:00
Merge pull request #1323 from vrtmrz/update/trystero-0.26
Enhance P2P Transport
This commit is contained in:
48 files changed
+2256
-185
No files matched your search
+1
-1
@@ -14,7 +14,7 @@ A document's original date and reviewed-version fields describe its baseline. Re
|
||||
| Find hosting services proposed by the community | [Third-party hosting listings](../README.md#hosting-services-proposed-by-the-community) |
|
||||
| Set up S3-compatible storage | [Object Storage Setup](setup_object_storage.md) and [transfers and service costs](setup_object_storage_costs.md) |
|
||||
| Set up peer-to-peer synchronisation | [P2P communication model](p2p.md), then [Peer-to-Peer Setup](setup_p2p.md) |
|
||||
| Diagnose P2P connectivity | [Browser-assisted check](../src/apps/webpeer/README.md#p2p-connection-check), [network tips](tips/p2p-sync-tips.md), and [self-hosted TURN](../docker/coturn/README.md) |
|
||||
| Diagnose P2P connectivity or transfer activity | [Diagnostic summary and logs](tips/p2p-diagnostics.md), [browser-assisted check](../src/apps/webpeer/README.md#p2p-connection-check), [network tips](tips/p2p-sync-tips.md), and [self-hosted TURN](../docker/coturn/README.md) |
|
||||
| Understand a setting or command | [Settings reference](settings.md) |
|
||||
| Read or customise synchronisation status | [Status indicators and CSS snippets](tips/status-display.md) |
|
||||
| Investigate or recover from a problem | [Troubleshooting](troubleshooting.md), then the relevant [recovery procedure](recovery.md) |
|
||||
|
||||
+9
-1
@@ -2,7 +2,15 @@
|
||||
|
||||
Peer-to-peer (P2P) synchronisation transfers Vault data between LiveSync devices through WebRTC. It does not require a central database containing a copy of the Vault. It does require a signalling relay so that devices can discover one another and establish a connection.
|
||||
|
||||
For the procedure for the first and additional devices, see [Set up peer-to-peer synchronisation](setup_p2p.md). For connection problems, see [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md).
|
||||
For the procedure for the first and additional devices, see [Set up peer-to-peer synchronisation](setup_p2p.md). For connection problems, see [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). Use the [P2P diagnostic guide](tips/p2p-diagnostics.md) to investigate discovery and transfer problems with the summary, logs, and progress indicators.
|
||||
|
||||
## Updating a P2P group
|
||||
|
||||
The Trystero 0.26.0 transport uses a data format which is
|
||||
incompatible with the previous transport. Update every device in the P2P
|
||||
group, including CLI and WebPeer clients, before resuming P2P synchronisation.
|
||||
Keeping the same Group ID, passphrase, and relay settings does not make mixed
|
||||
transport versions compatible.
|
||||
|
||||
## Connection model
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ The `Hatch` recovery controls are ordered by escalation. Running **Recreate chun
|
||||
- **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.
|
||||
- **Correct recorded file size** is available when a readable, conflict-free database winner has the same bytes as the Vault but its recorded size differs. It creates a child Metadata revision with the decoded byte size while preserving the body, Chunks, path, and timestamps. The action rechecks the selected revision; if the database has advanced, inspect the file again.
|
||||
- **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.
|
||||
|
||||
+1
-1
@@ -963,7 +963,7 @@ Recreate chunks from files currently present in the Vault. This can repair missi
|
||||
|
||||
Compare each Vault file with every current leaf 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 current leaves which can be discarded.
|
||||
|
||||
Select **Begin inspection** to run the inspection. Each reported file and current leaf 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.
|
||||
Select **Begin inspection** to run the inspection. Each reported file and current leaf revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, correcting an incorrect recorded file size, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Size correction is available when a readable revision exactly matches the Vault with no conflicts; it preserves the content and file timestamps and rechecks the selected revision before writing. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history.
|
||||
|
||||
The same inspection also reports local Metadata whose stored document ID does not agree with its recorded path. A stale entry does not suppress ordinary inspection when consistently addressed Metadata can still be resolved for that logical path; otherwise, the unresolved path is excluded from ordinary file-repair actions. When the current winner has no conflict leaves and has an unambiguous target, its wrench menu offers a separately confirmed, one-entry repair. The target is derived from the current local file-name case and path obfuscation settings, then written and verified before the obsolete ID is removed. Ambiguous, conflicted, deleted, excluded, or otherwise unsafe entries remain read-only. This action does not rename Vault files or folders. Follow [Repair a Metadata document ID mismatch](recovery.md#repair-a-metadata-document-id-mismatch) for the complete backup, repair, propagation, and verification procedure. For widespread naming differences across devices, use that guide to choose an authoritative Vault, correct its storage names while Obsidian is closed, rebuild the central remote, and reset the other devices.
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ Each reported current leaf revision has a compact wrench menu. The available act
|
||||
|
||||
Every mutating action rechecks that the selected revision is still a current leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
|
||||
|
||||
The inspection also offers **Correct recorded file size** when the conflict-free current winner is readable, its decoded bytes match the Vault exactly, and its recorded size differs from the decoded byte length. This creates a child Metadata revision which changes only the recorded size while retaining the content references and file timestamps. The operation rechecks the current revision before writing and records the resulting byte match as Vault provenance. Size and timestamps diagnose this condition; exact bytes establish that the Vault and database contain the same file.
|
||||
|
||||
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 current leaf cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable current leaf revisions can still be selected manually.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Diagnose P2P synchronisation
|
||||
|
||||
Use **P2P Status** to distinguish peer discovery, authentication, and data
|
||||
transfer before changing connection or database settings.
|
||||
|
||||
Participating devices must use
|
||||
[compatible P2P transport versions](../p2p.md#updating-a-p2p-group).
|
||||
If your version does not show **Diagnostic summary**, use
|
||||
[Peer-to-Peer Synchronisation Tips](p2p-sync-tips.md) and include the installed
|
||||
versions in your report.
|
||||
|
||||
## Capture one attempt
|
||||
|
||||
1. Keep both devices online, with Obsidian visible and the devices awake.
|
||||
Record which device originates the change and which should receive it.
|
||||
2. Open **P2P Status** on both devices, using
|
||||
**Self-hosted LiveSync: P2P Sync : Open P2P Status** in the command palette.
|
||||
3. In the **P2P Status** pane, expand **Diagnostic summary** below **Stats**.
|
||||
It starts collapsed. Record all three rows and their observation times before
|
||||
changing connection or database settings.
|
||||
4. Turn the **Diag** toggle on in the **P2P Status** card, then select
|
||||
**Open log** inside the expanded summary. In the opened Log Pane, ensure
|
||||
**Pause** is cleared before reproducing the problem. New diagnostic lines
|
||||
contain `[P2P Diag]`; **Verbose Log** is not required for these lines.
|
||||
5. Select **Replicate now** for the intended peer to test a manual round trip.
|
||||
Check **P2P Connection Request** prompts on both devices.
|
||||
For a problem with later automatic changes, first establish that the manual
|
||||
operation works, then make a small, non-private test edit on the source.
|
||||
Observe the receiving device as well as the source.
|
||||
6. Record the last relevant messages and progress on both devices, then
|
||||
[collect a report](#collect-a-report) before restarting or retrying.
|
||||
|
||||
The summary continues updating with **Diag** off. **Diag** controls new detailed
|
||||
output in the Log Pane; enabling it does not replay earlier hidden lines.
|
||||
Diagnostic messages also enter the existing recent report history while
|
||||
**Diag** is off. Opening the Log Pane displays messages already in its display
|
||||
buffer. Repeated unchanged non-failure observations may be omitted, so the log
|
||||
is not a record of every network message. **Pause** affects only the log display;
|
||||
replication and the summary continue updating.
|
||||
|
||||
## Read the summary
|
||||
|
||||
Each row holds the latest recorded observation in one category on this device.
|
||||
A new event from another peer can replace that row. Evidence can remain after
|
||||
a peer leaves or a connection closes; use the peer identifier and timestamp
|
||||
rather than reading the rows as a current connection-health checklist. The
|
||||
summary is held in memory and does not preserve history across a restart.
|
||||
|
||||
| Row | Examples | What it establishes |
|
||||
| --------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Relay** | `Room opening`, `Room opened`, `Room could not open`, `Room closed` | The local room lifecycle. 'Room opened; relay replies not observed' records local room opening. Relay acceptance remains unobserved; use later peer and sync observations to investigate discovery and data transfer. |
|
||||
| **Peer** | `Peer advertised`, `Peer left`, `Authentication accepted`, `Authentication refused`, `Authentication timed out` | Discovery or the observed authentication outcome for the identified peer. A timeout indicates that the request exceeded its time limit, rather than an explicit rejection. |
|
||||
| **Sync** | `Pull started`, `Pull completed`, `Watching peer changes`, `Announcing changes`, `Change notification received` | The latest observed pull, watch, announcement, or notification activity. Note that notifications signal a peer to fetch changes, rather than carrying Vault data directly. |
|
||||
|
||||
- **Not observed** indicates that no event has been recorded yet for that
|
||||
category, rather than confirming success, failure, or a zero count.
|
||||
- **Unavailable** means the diagnostic capability is unavailable in the
|
||||
current implementation. Include the installed version in the report.
|
||||
- `Authentication failed; authentication response not observed` and
|
||||
`Automatic watch skipped; peer announcement state not observed` identify an
|
||||
unavailable response, rather than claiming refusal or disabled announcements.
|
||||
- Summary times are local observation times, while `[P2P Diag]` lines include an
|
||||
`observed` UTC timestamp. Consider any device-clock differences when comparing
|
||||
reports across devices. Timestamps record when this device observed an event;
|
||||
they are not an ongoing heartbeat or live health check.
|
||||
|
||||
Relay rejection details are not provided by this diagnostic summary.
|
||||
Some existing RTC diagnostics also lack peer or room attribution and are not
|
||||
assigned to individual peers in these rows. **Stats** describes connection activity,
|
||||
whereas transferred document totals appear in transfer progress. If the summary
|
||||
cannot fully identify a failure, preserve the log output and note any unobserved
|
||||
categories in your report.
|
||||
|
||||
## Understand transfer progress
|
||||
|
||||
During synchronisation, transfer counters appear next to each peer in the
|
||||
P2P Status pane, in the editor status bar, and in the main status bar when
|
||||
P2P is the active synchronisation method.
|
||||
|
||||
| Indicator | Meaning |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **↑ / ↓** | Successfully sent or received database document revisions for the observed peer and direction, including both Metadata and Chunks. These are not counts of Vault files written. A new replication attempt starts new counts. |
|
||||
| **⏩↑ / ⏩↓** | Changes-feed entries compared in that direction, including entries already held by the receiver. The count can advance while no revisions need transferring. |
|
||||
| **seq** | A compatibility display of database sequence progress when document statistics are unavailable. Interpret it as a sequence value, not a document total or percentage. |
|
||||
|
||||
Each directional comparison label disappears three seconds after its count
|
||||
last changes. A hidden label alone does not establish whether comparison or
|
||||
replication has finished. A zero transfer count can be normal when the receiver
|
||||
already has the data.
|
||||
|
||||
`Pull completed` confirms that a single database pull has finished. To verify
|
||||
bidirectional synchronisation and full reflection, ensure the reverse direction
|
||||
completes and check the updated notes on the receiving device. For missing files,
|
||||
follow
|
||||
[the corresponding troubleshooting procedure](../troubleshooting.md#files-are-missing-or-excluded).
|
||||
|
||||
## Choose the next check
|
||||
|
||||
| Symptom or observation | Next check |
|
||||
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| The peer does not appear | Capture the **Relay** and **Peer** rows, then verify both devices' compatible versions, relay set, Group ID, and passphrase using [discovery checks](p2p-sync-tips.md#a-peer-does-not-appear). A `Connected` status indicates that the local room is open; relay replies remain unobserved. Use peer and sync observations and both devices' logs to investigate discovery and transfer. |
|
||||
| Authentication is refused | Confirm the peer identity on both devices and check for pending approval prompts. The summary displays the observed outcome; please ensure the connection request is explicitly accepted on the receiving device. |
|
||||
| Authentication times out, or a peer appears but cannot transfer | Keep both devices active and check for an unanswered **P2P Connection Request** before troubleshooting network connectivity. Compare logs between devices, then follow existing [WebRTC and TURN checks](p2p-sync-tips.md#a-peer-appears-but-synchronisation-cannot-connect) to inspect the data path. |
|
||||
| Manual replication works, but later edits do not arrive | Confirm **Announce changes** on the source and **Follow changes** for that source on the receiver. Compare `Announcing changes`, `Watching peer changes`, and notification events on both devices. `Change notification ignored; peer is not watched` identifies a missing follow on the receiver. |
|
||||
| A pull remains the last observation | Compare its timestamp, ↑/↓ and ⏩ changes, and both devices' logs. A subsequent operation may have replaced the summary row; verify with the surrounding logs and transfer counters to confirm whether a transfer is truly stalled. For a repeatable network-specific stall, use [message-size checks](p2p-sync-tips.md#a-connection-opens-but-a-transfer-stalls). |
|
||||
|
||||
After capturing the initial symptom, selecting **Disconnect** followed by
|
||||
**Open connection** and **Replicate now** can test reconnection. Because
|
||||
disconnecting cancels active operations, record whether a cancellation occurred.
|
||||
Testing reconnection, adjusting the message-size preset, or trying an
|
||||
alternative network can help isolate environmental differences.
|
||||
|
||||
## Collect a report
|
||||
|
||||
Collect evidence from both the source and receiving devices around the same
|
||||
attempt. Include:
|
||||
|
||||
- the plug-in or client version on each device, and which device originated the change;
|
||||
- the **Diagnostic summary** rows and times, plus the surrounding `[P2P Diag]`
|
||||
lines and any other relevant errors;
|
||||
- whether the peer appeared, whether manual **Replicate now** worked, and the
|
||||
observed ↑/↓, ⏩, or `seq` values;
|
||||
- whether **Announce changes** and **Follow changes** were enabled, and whether
|
||||
either application was in the background or the device had been asleep; and
|
||||
- the network types and whether TURN or a VPN was used, especially when the
|
||||
same devices work on one network but not another.
|
||||
|
||||
Run **Generate full report for opening the issue with debug info** before the
|
||||
recent history is overwritten. It includes up to 1,000 recent log lines,
|
||||
including verbose diagnostic messages even when **Verbose Log** is off.
|
||||
For a problem which crosses a restart, follow the existing
|
||||
[persistent logging procedure](../to_issue_reporting.md#a-problem-which-crosses-a-restart).
|
||||
|
||||
Review the complete report using the
|
||||
[issue-reporting redaction guidance](../to_issue_reporting.md#full-livesync-report).
|
||||
New `[P2P Diag]` messages use fixed descriptions and shortened peer identifiers,
|
||||
but other log messages can still contain private information. Keep consistent
|
||||
device aliases across the reports when replacing private identifiers.
|
||||
@@ -67,6 +67,8 @@ Keep Obsidian visible and the device awake during initial transfer, rebuild, or
|
||||
|
||||
## Collect evidence
|
||||
|
||||
Use the [P2P diagnostic guide](p2p-diagnostics.md) to capture the latest observations, enable **Diag**, and compare transfer progress and logs on both devices before changing settings.
|
||||
|
||||
If the same room works on one network but not another, include both network types in the report. Run `Generate full report for opening the issue with debug info`, remove credentials and private relay details, and state whether:
|
||||
|
||||
- both devices reached `Connected`;
|
||||
|
||||
@@ -11,6 +11,7 @@ If a maintainer requests additional evidence, follow the relevant procedure belo
|
||||
| Database integrity, current conflicts, or differences between sources | [Integrity report](#integrity-report) |
|
||||
| Local revision and Chunk usage | [Usage report](#usage-report) |
|
||||
| More information about a path pseudonym in an existing report | [File follow-up](#file-follow-up) |
|
||||
| P2P discovery, authentication, or transfer activity | [P2P summary and logs](tips/p2p-diagnostics.md#collect-a-report) |
|
||||
|
||||
Maintainers can link directly to each procedure and adapt the [example requests](#requesting-diagnostics).
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ Use Safari on a Mac and follow Apple's [Inspecting iOS and iPadOS](https://devel
|
||||
|
||||
## P2P does not connect or transfer changes
|
||||
|
||||
Use [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). Check signalling discovery separately from the WebRTC data path, and confirm which devices announce and follow changes. P2P is not a repair step for another transport.
|
||||
Start with [P2P diagnostic observations, logs, and transfer progress](tips/p2p-diagnostics.md). Use [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md) for network and configuration checks. Check signalling discovery separately from the WebRTC data path, and confirm which devices announce and follow changes. P2P is not a repair step for another transport.
|
||||
|
||||
## Obsidian or LiveSync remains suspended
|
||||
|
||||
|
||||
Generated
+24
-24
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.3.2",
|
||||
"@vrtmrz/livesync-commonlib": "0.4.0",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.7",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
@@ -1949,9 +1949,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/secp256k1": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
|
||||
"integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.2.0.tgz",
|
||||
"integrity": "sha512-Z3ZAWOTxJ0EuTuZTi7Y69iK7GgrLHh8sgm45lVDhg/b3Nk1TpAiKhick2KkZisHuupeepSkyIydN/J459SdX1w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -3281,6 +3281,22 @@
|
||||
"vite": "^8.0.0-beta.7 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@trystero-p2p/core": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.26.0.tgz",
|
||||
"integrity": "sha512-CZpeXPgfbDdFKrGxfD98F9tn/fTjya4mQ2o/Ax6H0qpBaJXCBz5+jyPtjsYEPAAcKRK/btOatCetFiQ5qIMCOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@trystero-p2p/nostr": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.26.0.tgz",
|
||||
"integrity": "sha512-HArp9fonBkqFZgw3U2D823fCePQv7haKkvjtwbGRkWzy+Wo9G3cCDEnG/GENDWwzERHrhRcRacpFsFt4JpwRJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/secp256k1": "^3.2.0",
|
||||
"@trystero-p2p/core": "0.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/svelte": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
||||
@@ -4065,9 +4081,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.3.2.tgz",
|
||||
"integrity": "sha512-PiKUdJUiBI4ot0AV1BjlkDoaY4Lbn9oTdEjL8FMX1qf2VCOwSnbsLoQMUr/45wmINpV31KWmqgr2zxqcYO+hpA==",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.4.0.tgz",
|
||||
"integrity": "sha512-2bhkrd0rRWSiy90rjOxpaO01Ei2V9VSwifzv4ePUttxrzt195zjWNZCzW9ZMvJvAtyPoifShilW0J4kig4oKFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
@@ -4077,7 +4093,7 @@
|
||||
"@smithy/middleware-apply-body-checksum": "^4.3.9",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@trystero-p2p/nostr": "0.25.3",
|
||||
"@trystero-p2p/nostr": "0.26.0",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"events": "^3.3.0",
|
||||
@@ -4115,22 +4131,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/core": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.25.3.tgz",
|
||||
"integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib/node_modules/@trystero-p2p/nostr": {
|
||||
"version": "0.25.3",
|
||||
"resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.25.3.tgz",
|
||||
"integrity": "sha512-nZV9Fl/GXuhIkJSQ+wIkdMoRLO1oSTUL/+vZorukaojeAdOpT/61e8b9Vzbt8L1ktMTaGPEJHw2BG/B5BCwBuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/secp256k1": "^3.1.0",
|
||||
"@trystero-p2p/core": "0.25.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib/node_modules/minimatch": {
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
|
||||
|
||||
+1
-1
@@ -211,7 +211,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.3.2",
|
||||
"@vrtmrz/livesync-commonlib": "0.4.0",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.7",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
* the same change.
|
||||
*/
|
||||
export const liveSyncProvisionalEnglishMessages = {
|
||||
"Diagnostic summary": "Diagnostic summary",
|
||||
"Open log": "Open log",
|
||||
"Detailed P2P diagnostics and Log Pane output are enabled":
|
||||
"Detailed P2P diagnostics and Log Pane output are enabled",
|
||||
"Enable detailed P2P diagnostics and Log Pane output":
|
||||
"Enable detailed P2P diagnostics and Log Pane output",
|
||||
"Enable SyncGuard": "Enable SyncGuard",
|
||||
"Enable device-local SyncGuard protection during synchronisation.":
|
||||
"Enable device-local SyncGuard protection during synchronisation.",
|
||||
@@ -280,6 +286,7 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"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",
|
||||
"Correct recorded file size": "Correct recorded file size",
|
||||
"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.",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { observeP2PDiagnosticsProgress, type P2PStatusProjection } from "@/modules/features/P2PStatusLabel";
|
||||
|
||||
interface Props {
|
||||
p2p: P2PServiceViews;
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let syncingPeerId = $state<string | null>(null);
|
||||
let peerProgress = $state<P2PStatusProjection["peers"]>(new Map());
|
||||
|
||||
const logLevel = $derived(showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
async function requestServerStatus() {
|
||||
@@ -34,6 +36,9 @@
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
}
|
||||
onMount(() => {
|
||||
const unsubscribeProgress = observeP2PDiagnosticsProgress(p2p.diagnostics, (snapshot) => {
|
||||
peerProgress = snapshot.peers;
|
||||
});
|
||||
// ServerStatus
|
||||
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
@@ -42,7 +47,10 @@
|
||||
await delay(100);
|
||||
await requestServerStatus();
|
||||
});
|
||||
return unsubscribe;
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeProgress();
|
||||
};
|
||||
});
|
||||
|
||||
async function handleSync(peerId: string) {
|
||||
@@ -109,6 +117,14 @@
|
||||
<div class="peer-item">
|
||||
<div class="peer-info">
|
||||
<div class="peer-name">{peer.name}</div>
|
||||
{#if peerProgress.has(peer.peerId)}
|
||||
<div
|
||||
class="peer-progress"
|
||||
title="↑↓: transferred document revisions (seq for older peers); ⏩: compared changes-feed entries"
|
||||
>
|
||||
{peerProgress.get(peer.peerId)?.message}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="peer-meta">
|
||||
<span class="badge">{peer.platform}</span>
|
||||
<span class="peer-id-mini" title={peer.peerId}>
|
||||
@@ -176,6 +192,12 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.peer-progress {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.p2p-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { observeP2PDiagnosticRows, type P2PDiagnosticRow } from "@/modules/features/P2PDiagnosticDisplay";
|
||||
import { VIEW_TYPE_LOG } from "@/modules/features/Log/LogPaneView";
|
||||
|
||||
interface Props {
|
||||
p2p: P2PServiceViews;
|
||||
@@ -29,6 +31,12 @@
|
||||
const initialSettings = readCurrentSettings();
|
||||
let roomSuffix = $state<string>(extractP2PRoomSuffix(initialSettings?.P2P_roomID ?? ""));
|
||||
let useDiagRTC = $state<boolean>(initialSettings?.P2P_useDiagRTC ?? false);
|
||||
let diagnosticRows = $state<readonly P2PDiagnosticRow[]>([]);
|
||||
|
||||
function openLog() {
|
||||
const currentCore = core;
|
||||
if (currentCore) fireAndForget(() => currentCore.services.API.showWindow(VIEW_TYPE_LOG));
|
||||
}
|
||||
|
||||
async function requestServerStatus() {
|
||||
p2p.diagnostics.requestStatus();
|
||||
@@ -66,6 +74,9 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribeDiagnostics = observeP2PDiagnosticRows(p2p.diagnostics, (rows) => {
|
||||
diagnosticRows = rows;
|
||||
});
|
||||
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
roomSuffix = extractP2PRoomSuffix(status?.roomId ?? "");
|
||||
@@ -84,6 +95,7 @@
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeDiagnostics();
|
||||
unsubscribe();
|
||||
unsubscribeStatus();
|
||||
unsubscribeSettings();
|
||||
@@ -161,8 +173,8 @@
|
||||
class="broadcast-button {useDiagRTC ? 'is-on' : 'is-off'}"
|
||||
onclick={toggleDiagRTC}
|
||||
title={useDiagRTC
|
||||
? translateMessage("Diagnostic RTCPeerConnection is enabled")
|
||||
: translateMessage("Use Diagnostic RTCPeerConnection for statistics")}
|
||||
? translateMessage("Detailed P2P diagnostics and Log Pane output are enabled")
|
||||
: translateMessage("Enable detailed P2P diagnostics and Log Pane output")}
|
||||
>
|
||||
{useDiagRTC ? translateMessage("On") : translateMessage("Off")}
|
||||
</button>
|
||||
@@ -190,12 +202,48 @@
|
||||
<span>{serverInfo.diag.totalClosedConnections}</span>
|
||||
</div>
|
||||
</div>
|
||||
<details class="diag-details">
|
||||
<summary>{translateMessage("Diagnostic summary")}</summary>
|
||||
<div class="diag-summary">
|
||||
{#each diagnosticRows as row (row.category)}
|
||||
<div title={row.title}>
|
||||
<span>{row.label}: {row.text}</span>{#if row.time}<span> · {row.time}</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if core}
|
||||
<button class="diag-open-log" onclick={openLog}>{translateMessage("Open log")}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.diag-details {
|
||||
margin-top: var(--size-4-2);
|
||||
}
|
||||
|
||||
.diag-details > summary {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.diag-summary {
|
||||
margin-top: var(--size-4-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.diag-open-log {
|
||||
margin-top: var(--size-4-1);
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
@@ -211,6 +259,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { SetupManager } from "@/modules/features/SetupManager";
|
||||
import { Menu } from "@/deps";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { observeP2PDiagnosticsProgress, type P2PStatusProjection } from "@/modules/features/P2PStatusLabel";
|
||||
import {
|
||||
hasExactP2PPeer,
|
||||
togglePersistedP2PPeer,
|
||||
@@ -42,6 +43,7 @@
|
||||
let decidingPeerId = $state<string | null>(null);
|
||||
let replicatingPeerId = $state<string | null>(null);
|
||||
let communicatingUntil = $state<Record<string, number>>({});
|
||||
let peerProgress = $state<P2PStatusProjection["peers"]>(new Map());
|
||||
const COMMUNICATION_HOLD_MS = 2500;
|
||||
// Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time.
|
||||
const readCurrentSettings = () => core.services.setting.currentSettings();
|
||||
@@ -125,6 +127,9 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribeProgress = observeP2PDiagnosticsProgress(p2p.diagnostics, (snapshot) => {
|
||||
peerProgress = snapshot.peers;
|
||||
});
|
||||
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
});
|
||||
@@ -159,6 +164,7 @@
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeProgress();
|
||||
unsubscribe();
|
||||
unsubscribeReplicatorStatus();
|
||||
unsubscribeReplicatorProgress();
|
||||
@@ -477,6 +483,14 @@
|
||||
<div class="peer-meta">
|
||||
<span class="badge">{peer.platform}</span>
|
||||
</div>
|
||||
{#if peerProgress.has(peer.peerId)}
|
||||
<div
|
||||
class="peer-progress"
|
||||
title="↑↓: transferred document revisions (seq for older peers); ⏩: compared changes-feed entries"
|
||||
>
|
||||
{peerProgress.get(peer.peerId)?.message}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="peer-actions">
|
||||
{#if isAccepted(peer)}
|
||||
@@ -484,37 +498,39 @@
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
{getAcceptanceStatus(peer)}
|
||||
</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
disabled={replicatingPeerId !== null}
|
||||
title={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating...")
|
||||
: translateMessage("Replicate now")}
|
||||
aria-label={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating")
|
||||
: translateMessage("Replicate now")}
|
||||
onclick={() => startReplication(peer)}
|
||||
>
|
||||
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
{translateMessage("Revoke")}
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("More actions for ${DEVICE}", { DEVICE: peer.name })}
|
||||
aria-label={translateMessage("More actions for ${DEVICE}", {
|
||||
DEVICE: peer.name,
|
||||
})}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={(event) => openPeerMenu(event, peer)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
<div class="decision-buttons">
|
||||
<button
|
||||
class="emoji-button"
|
||||
disabled={replicatingPeerId !== null}
|
||||
title={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating...")
|
||||
: translateMessage("Replicate now")}
|
||||
aria-label={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating")
|
||||
: translateMessage("Replicate now")}
|
||||
onclick={() => startReplication(peer)}
|
||||
>
|
||||
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
{translateMessage("Revoke")}
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("More actions for ${DEVICE}", { DEVICE: peer.name })}
|
||||
aria-label={translateMessage("More actions for ${DEVICE}", {
|
||||
DEVICE: peer.name,
|
||||
})}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={(event) => openPeerMenu(event, peer)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="decision-row watch-row">
|
||||
<span class="decision-label">{translateMessage("Follow changes")}</span>
|
||||
@@ -540,45 +556,49 @@
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">{translateMessage("PERMANENT")}</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("Allow permanently")}
|
||||
aria-label={translateMessage("Allow permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, false)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title={translateMessage("Deny permanently")}
|
||||
aria-label={translateMessage("Deny permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, false)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
<div class="decision-buttons">
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("Allow permanently")}
|
||||
aria-label={translateMessage("Allow permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, false)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title={translateMessage("Deny permanently")}
|
||||
aria-label={translateMessage("Deny permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, false)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">{translateMessage("SESSION")}</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("Allow in session")}
|
||||
aria-label={translateMessage("Allow in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, true)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title={translateMessage("Deny in session")}
|
||||
aria-label={translateMessage("Deny in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, true)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
<div class="decision-buttons">
|
||||
<button
|
||||
class="emoji-button"
|
||||
title={translateMessage("Allow in session")}
|
||||
aria-label={translateMessage("Allow in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, true)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title={translateMessage("Deny in session")}
|
||||
aria-label={translateMessage("Deny in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, true)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)}
|
||||
@@ -605,12 +625,24 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.peer-progress {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.p2p-container {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.peers-section button {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.peers-section {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.5rem;
|
||||
@@ -627,6 +659,8 @@
|
||||
|
||||
.pane-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
@@ -634,6 +668,8 @@
|
||||
|
||||
.remote-picker-wrap {
|
||||
display: inline-flex;
|
||||
flex: 1 1 10rem;
|
||||
max-width: 100%;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
@@ -641,8 +677,8 @@
|
||||
|
||||
.remote-picker {
|
||||
max-width: 10rem;
|
||||
min-width: 1em;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
@@ -661,10 +697,11 @@
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
padding: 0;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
display: inline-flex;
|
||||
@@ -700,8 +737,12 @@
|
||||
}
|
||||
|
||||
.refresh {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
@@ -736,6 +777,8 @@
|
||||
}
|
||||
|
||||
.peer-name {
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
@@ -752,6 +795,9 @@
|
||||
}
|
||||
|
||||
.badge {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
@@ -817,17 +863,25 @@
|
||||
}
|
||||
|
||||
.decision-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accepted-row {
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
.decision-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
@@ -835,22 +889,25 @@
|
||||
}
|
||||
|
||||
.action-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.emoji-button {
|
||||
width: 2rem;
|
||||
height: 1.7rem;
|
||||
flex-shrink: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
font-size: 1.125rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -903,7 +960,7 @@
|
||||
}
|
||||
|
||||
.revoke-inline {
|
||||
justify-self: start;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.no-peers {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
};
|
||||
let { close }: Props = $props();
|
||||
// export let close: () => void;
|
||||
function updateLog(logs: ReactiveInstance<string[]>) {
|
||||
function updateLog(logs: Pick<ReactiveInstance<string[]>, "value">) {
|
||||
const e = logs.value;
|
||||
if (!suspended) {
|
||||
messages = [...e];
|
||||
@@ -28,6 +28,7 @@
|
||||
}
|
||||
onMount(async () => {
|
||||
const _logMessages = reactive(() => logMessages.value);
|
||||
updateLog(_logMessages);
|
||||
_logMessages.onChanged(updateLog);
|
||||
Logger(msg("logPane.logWindowOpened", {}, lang));
|
||||
unsubscribe = () => _logMessages.offChanged(updateLog);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
debounce: (callback: () => void) => callback,
|
||||
normalizePath: (path: string) => path,
|
||||
Notice: class {},
|
||||
stringifyYaml: vi.fn(),
|
||||
}));
|
||||
vi.mock("./Log/LogPaneView.ts", () => ({ LogPaneView: class {}, VIEW_TYPE_LOG: "log" }));
|
||||
vi.mock("@/common/reportTool.ts", () => ({ generateReport: vi.fn() }));
|
||||
vi.mock("@/common/utils.ts", () => ({ isValidPath: () => true }));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_FILE_RENAMED: "renamed",
|
||||
EVENT_LAYOUT_READY: "ready",
|
||||
EVENT_LEAF_ACTIVE_CHANGED: "active",
|
||||
EVENT_ON_UNRESOLVED_ERROR: "error",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector", () => ({
|
||||
P2PLogCollector: class {},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/p2p", () => ({ EVENT_P2P_DIAGNOSTIC: "p2p-diagnostic" }));
|
||||
|
||||
async function harness(diag = true, verbose = false, lessInformation = false) {
|
||||
const { ModuleLog } = await import("./ModuleLog.ts");
|
||||
const { reactiveSource } = await import("octagonal-wheels/dataobject/reactive");
|
||||
const { logMessages } = await import("@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores");
|
||||
logMessages.value = [];
|
||||
const notices = { show: vi.fn() };
|
||||
const module = Object.assign(Object.create(ModuleLog.prototype), {
|
||||
core: {
|
||||
settings: { P2P_useDiagRTC: diag, showVerboseLog: verbose, lessInformationInLog: lessInformation },
|
||||
services: { vault: { getVaultName: () => "fixture" }, context: { notices } },
|
||||
},
|
||||
statusLog: reactiveSource("existing status"),
|
||||
logLines: [],
|
||||
notifies: {},
|
||||
}) as InstanceType<typeof ModuleLog>;
|
||||
const { LOG_LEVEL_VERBOSE, LOG_LEVEL_INFO } = await import("@vrtmrz/livesync-commonlib/compat/common/types");
|
||||
return { module, notices, logMessages, LOG_LEVEL_VERBOSE, LOG_LEVEL_INFO } as const;
|
||||
}
|
||||
|
||||
describe("P2P diagnostic log routing", () => {
|
||||
beforeEach(() => vi.resetModules());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("shows enabled diagnostics in Log Pane despite normal log visibility settings", async () => {
|
||||
const { module, notices, logMessages, LOG_LEVEL_VERBOSE } = await harness(true, false, true);
|
||||
module.__addLog("[P2P Diag] Peer: Authentication timed out", LOG_LEVEL_VERBOSE, "p2p-diagnostic");
|
||||
expect(logMessages.value).toEqual([expect.stringContaining("[P2P Diag] Peer: Authentication timed out")]);
|
||||
expect(module.statusLog.value).toBe("existing status");
|
||||
expect(module.logLines).toEqual([]);
|
||||
expect(notices.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps diagnostic detail out of editor status and notification surfaces", async () => {
|
||||
const { module, notices, logMessages, LOG_LEVEL_INFO } = await harness(true, true);
|
||||
module.__addLog("[P2P Diag] Sync: Pull completed", LOG_LEVEL_INFO, "p2p-diagnostic");
|
||||
expect(logMessages.value).toEqual([expect.stringContaining("[P2P Diag] Sync: Pull completed")]);
|
||||
expect(module.statusLog.value).toBe("existing status");
|
||||
expect(module.logLines).toEqual([]);
|
||||
expect(notices.show).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps disabled diagnostic detail out of Log Pane", async () => {
|
||||
const { module, logMessages, LOG_LEVEL_INFO } = await harness(false, true);
|
||||
module.__addLog("[P2P Diag] Relay: Room opened", LOG_LEVEL_INFO, "p2p-diagnostic");
|
||||
expect(logMessages.value).toEqual([]);
|
||||
expect(module.statusLog.value).toBe("existing status");
|
||||
});
|
||||
|
||||
it("preserves normal log routing", async () => {
|
||||
const { module, logMessages, LOG_LEVEL_INFO } = await harness();
|
||||
module.__addLog("Normal operation", LOG_LEVEL_INFO);
|
||||
expect(logMessages.value).toEqual([expect.stringContaining("Normal operation")]);
|
||||
expect(module.statusLog.value).toBe("Normal operation");
|
||||
expect(module.logLines).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
PREFIXMD_LOGFILE,
|
||||
REMOTE_P2P,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
@@ -37,6 +38,9 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { withLock } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import { EVENT_P2P_DIAGNOSTIC, type P2PDiagnosticEvent } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { formatP2PDiagnosticLog, P2P_DIAGNOSTIC_LOG_KEY } from "./P2PDiagnosticDisplay.ts";
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, getTrackedRequestCount } from "./RemoteActivityStatus.ts";
|
||||
import {
|
||||
createChunkFetchCounterLabel,
|
||||
@@ -44,6 +48,7 @@ import {
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
import { createFastForwardCheckedChangesDisplay, projectReplicationStatusLabel } from "./ReplicationStatusLabel.ts";
|
||||
import { createP2PProgressDisplay, selectReplicationStatus } from "./P2PStatusLabel.ts";
|
||||
import { createStatusBarRenderer, type StatusBarPart, type StatusBarRenderer } from "./StatusBarRenderer.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
@@ -163,7 +168,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
|
||||
observeForLogs() {
|
||||
const registerDisplay = <T extends { dispose(): void }>(display: T): T => {
|
||||
this.plugin.register(() => display.dispose());
|
||||
this.services.API.registerCleanup(() => display.dispose());
|
||||
return display;
|
||||
};
|
||||
const labelReplication = registerDisplay(
|
||||
@@ -204,6 +209,14 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const fastForwardCount = registerDisplay(
|
||||
createFastForwardCheckedChangesDisplay(this.services.replicator.replicationStatics)
|
||||
);
|
||||
const p2pProgress = registerDisplay(createP2PProgressDisplay(this.p2pLogCollector.p2pReplicationStats));
|
||||
const p2pIsPrimary = reactiveSource(this.settings.remoteType === REMOTE_P2P);
|
||||
this.services.API.registerCleanup(
|
||||
this.services.context.events.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||
p2pIsPrimary.value = settings.remoteType === REMOTE_P2P;
|
||||
})
|
||||
);
|
||||
this.services.API.registerCleanup(() => this.p2pLogCollector.dispose());
|
||||
const replicationStatLabel = computed(() =>
|
||||
projectReplicationStatusLabel(
|
||||
this.services.replicator.replicationStatics.value,
|
||||
@@ -220,10 +233,13 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const counterPart = (key: string, mark: string, value: number | undefined): StatusBarPart[] =>
|
||||
value === undefined ? [] : [{ key, text: " " + mark, value }];
|
||||
const statusLineLabel = computed(() => {
|
||||
const replication = replicationStatLabel();
|
||||
const { primary: replication, auxiliary: p2p } = selectReplicationStatus(
|
||||
replicationStatLabel(),
|
||||
p2pProgress.value,
|
||||
p2pIsPrimary.value
|
||||
);
|
||||
const queued = queueCountLabel();
|
||||
const waiting = waitingLabel();
|
||||
const p2p = this.p2pLogCollector.p2pReplicationLine.value;
|
||||
const firstLineParts = [
|
||||
...replication.parts,
|
||||
...counterPart("file-processing", "⏳", labelProc.count.value),
|
||||
@@ -239,10 +255,11 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
...counterPart("queue-conflicts", "🔩", labelConflictProcessCount.count.value),
|
||||
];
|
||||
return {
|
||||
message: replication.message + waiting + queued + (p2p === "" ? "" : "\n" + p2p),
|
||||
parts: p2p === "" ? firstLineParts : [...firstLineParts, { key: "p2p", text: "\n" + p2p }],
|
||||
message: replication.message + waiting + queued + (p2p.message === "" ? "" : "\n" + p2p.message),
|
||||
parts:
|
||||
p2p.message === "" ? firstLineParts : [...firstLineParts, { key: "p2p", text: "\n" }, ...p2p.parts],
|
||||
statusBarParts: firstLineParts,
|
||||
hasP2P: p2p !== "",
|
||||
hasP2P: p2p.message !== "",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -513,12 +530,12 @@ ${stringifyYaml(info)}
|
||||
if (this.statusLine) {
|
||||
const renderer = createStatusBarRenderer(this.statusLine);
|
||||
this.statusLineRenderer = renderer;
|
||||
this.plugin.register(() => renderer.dispose());
|
||||
this.services.API.registerCleanup(() => renderer.dispose());
|
||||
}
|
||||
if (this.statusBar) {
|
||||
const renderer = createStatusBarRenderer(this.statusBar);
|
||||
this.statusBarRenderer = renderer;
|
||||
this.plugin.register(() => renderer.dispose());
|
||||
this.services.API.registerCleanup(() => renderer.dispose());
|
||||
}
|
||||
this.adjustStatusDivPosition();
|
||||
this._log("Log module loaded", LOG_LEVEL_INFO);
|
||||
@@ -582,6 +599,10 @@ ${stringifyYaml(info)}
|
||||
this.writeLogToTheFile(now, vaultName, newMessage);
|
||||
}
|
||||
addLog(newMessage);
|
||||
if (key === P2P_DIAGNOSTIC_LOG_KEY) {
|
||||
if (this.settings?.P2P_useDiagRTC) addDisplayLog(newMessage);
|
||||
return;
|
||||
}
|
||||
if (memoOnly) {
|
||||
return;
|
||||
}
|
||||
@@ -623,6 +644,21 @@ ${stringifyYaml(info)}
|
||||
let logTarget: LogFunction = bufferedLog;
|
||||
const log: LogFunction = (message, level, key) => logTarget(message, level, key);
|
||||
services.API.addLog.setHandler(log);
|
||||
const lastDiagnosticState = new Map<P2PDiagnosticEvent["category"], P2PDiagnosticEvent>();
|
||||
const unsubscribeDiagnostics = services.context.events.onEvent(EVENT_P2P_DIAGNOSTIC, (event) => {
|
||||
try {
|
||||
const previous = lastDiagnosticState.get(event.category);
|
||||
const repeated =
|
||||
previous?.code === event.code &&
|
||||
previous.peerId === event.peerId &&
|
||||
previous.reason === event.reason;
|
||||
lastDiagnosticState.set(event.category, event);
|
||||
if (repeated && !/(failed|error|timeout|refused|cancelled)$/.test(event.code)) return;
|
||||
services.API.addLog(formatP2PDiagnosticLog(event), LOG_LEVEL_VERBOSE, P2P_DIAGNOSTIC_LOG_KEY);
|
||||
} catch {
|
||||
// Diagnostic output must not interrupt a transport operation.
|
||||
}
|
||||
});
|
||||
const collectGlobalLogs = () => {
|
||||
const entries = recentGlobalLogEntries.value;
|
||||
if (entries.length === 0) return;
|
||||
@@ -630,6 +666,8 @@ ${stringifyYaml(info)}
|
||||
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
|
||||
};
|
||||
this.logResource = createOwnedResource(log, () => {
|
||||
unsubscribeDiagnostics();
|
||||
lastDiagnosticState.clear();
|
||||
logTarget = logToConsole;
|
||||
recentGlobalLogEntries.offChanged(collectGlobalLogs);
|
||||
this.recentLogEntries.offChanged(this.displayLogEntries);
|
||||
|
||||
@@ -8,6 +8,9 @@ vi.mock("@/deps.ts", () => ({
|
||||
stringifyYaml: vi.fn(),
|
||||
}));
|
||||
vi.mock("./Log/LogPaneView.ts", () => ({ LogPaneView: class {}, VIEW_TYPE_LOG: "log" }));
|
||||
vi.mock("./StatusBarRenderer.ts", () => ({
|
||||
createStatusBarRenderer: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@/common/reportTool.ts", () => ({ generateReport: vi.fn() }));
|
||||
vi.mock("@/common/utils.ts", () => ({ isValidPath: () => true }));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
@@ -22,7 +25,10 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"
|
||||
P2PLogCollector: class {},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/p2p", () => ({ EVENT_P2P_DIAGNOSTIC: "p2p-diagnostic" }));
|
||||
|
||||
async function createHarness() {
|
||||
const { createLiveSyncEventHub } = await import("@vrtmrz/livesync-commonlib/context");
|
||||
const { ModuleLog } = await import("./ModuleLog.ts");
|
||||
const { bindableFunction, allFunction } =
|
||||
await import("@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils");
|
||||
@@ -33,8 +39,9 @@ async function createHarness() {
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
registerCleanup: vi.fn<(callback: () => void) => void>(),
|
||||
},
|
||||
context: { events: {}, notices: { hide: vi.fn() } },
|
||||
context: { events: createLiveSyncEventHub(), notices: { hide: vi.fn() } },
|
||||
appLifecycle: {
|
||||
onInitialise: allFunction<() => Promise<boolean>>(),
|
||||
onSettingLoaded: allFunction<() => Promise<boolean>>(),
|
||||
@@ -61,6 +68,73 @@ describe("ModuleLog service connection", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("registers status renderer cleanup through the API without native plug-in registration", async () => {
|
||||
const { services, module } = await createHarness();
|
||||
const { createStatusBarRenderer } = await import("./StatusBarRenderer.ts");
|
||||
const factory = vi.mocked(createStatusBarRenderer);
|
||||
factory.mockClear();
|
||||
Object.assign(module, { statusLine: {}, statusBar: {} });
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
expect(services.API.registerCleanup).toHaveBeenCalledTimes(2);
|
||||
const renderers = factory.mock.results.map((result) => result.value);
|
||||
for (const renderer of renderers) expect(renderer.dispose).not.toHaveBeenCalled();
|
||||
for (const [cleanup] of services.API.registerCleanup.mock.calls) cleanup();
|
||||
for (const renderer of renderers) expect(renderer.dispose).toHaveBeenCalledOnce();
|
||||
await module.logResource.dispose();
|
||||
});
|
||||
|
||||
it("routes accepted P2P diagnostics only through their owning context and disposes the subscription", async () => {
|
||||
const first = await createHarness();
|
||||
const second = await createHarness();
|
||||
await first.services.appLifecycle.onSettingLoaded();
|
||||
await second.services.appLifecycle.onSettingLoaded();
|
||||
first.sink.mockClear();
|
||||
second.sink.mockClear();
|
||||
first.services.context.events.emitEvent("p2p-diagnostic", {
|
||||
category: "peer",
|
||||
code: "auth-timeout",
|
||||
timestamp: 123,
|
||||
});
|
||||
expect(first.sink).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[P2P Diag] Peer: Authentication timed out"),
|
||||
expect.any(Number),
|
||||
"p2p-diagnostic"
|
||||
);
|
||||
expect(second.sink).not.toHaveBeenCalled();
|
||||
await first.module.logResource.dispose();
|
||||
first.sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
first.services.context.events.emitEvent("p2p-diagnostic", {
|
||||
category: "peer",
|
||||
code: "peer-left",
|
||||
timestamp: 124,
|
||||
});
|
||||
expect(first.sink).not.toHaveBeenCalled();
|
||||
expect(consoleLog).not.toHaveBeenCalled();
|
||||
consoleLog.mockRestore();
|
||||
});
|
||||
|
||||
it("does not repeat unchanged diagnostic state but retains repeated failure observations", async () => {
|
||||
const { services, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
services.context.events.emitEvent("p2p-diagnostic", {
|
||||
category: "sync",
|
||||
code: "watch-enabled",
|
||||
timestamp: 100,
|
||||
});
|
||||
services.context.events.emitEvent("p2p-diagnostic", {
|
||||
category: "sync",
|
||||
code: "watch-enabled",
|
||||
timestamp: 200,
|
||||
});
|
||||
expect(sink).toHaveBeenCalledTimes(1);
|
||||
services.context.events.emitEvent("p2p-diagnostic", { category: "peer", code: "auth-timeout", timestamp: 300 });
|
||||
services.context.events.emitEvent("p2p-diagnostic", { category: "peer", code: "auth-timeout", timestamp: 400 });
|
||||
expect(sink).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("buffers startup logs and preserves global and service call order", async () => {
|
||||
const { services, sink, Logger } = await createHarness();
|
||||
Logger("before-global");
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
P2PDiagnostics,
|
||||
P2PDiagnosticCode,
|
||||
P2PDiagnosticEvent,
|
||||
P2PDiagnosticReason,
|
||||
P2PDiagnosticSnapshot,
|
||||
} from "@vrtmrz/livesync-commonlib/p2p";
|
||||
|
||||
export const P2P_DIAGNOSTIC_LOG_KEY = "p2p-diagnostic";
|
||||
|
||||
const labels = {
|
||||
"room-opening": "Room opening",
|
||||
"room-opened": "Room opened",
|
||||
"room-closed": "Room closed",
|
||||
"room-open-failed": "Room could not open",
|
||||
"room-close-failed": "Room could not close",
|
||||
"room-open-cancelled": "Room opening cancelled",
|
||||
"room-join-error": "Peer could not join the room",
|
||||
"peer-advertised": "Peer advertised",
|
||||
"peer-left": "Peer left",
|
||||
"rtc-failed": "RTC connection failed",
|
||||
"auth-accepted": "Authentication accepted",
|
||||
"auth-refused": "Authentication refused",
|
||||
"auth-timeout": "Authentication timed out",
|
||||
"auth-cancelled": "Authentication cancelled",
|
||||
"auth-error": "Authentication failed",
|
||||
"pull-started": "Pull started",
|
||||
"pull-completed": "Pull completed",
|
||||
"pull-failed": "Pull failed",
|
||||
"pull-cancelled": "Pull cancelled",
|
||||
"watch-enabled": "Watching peer changes",
|
||||
"watch-disabled": "Stopped watching peer changes",
|
||||
"watch-skipped": "Automatic watch skipped",
|
||||
"broadcast-enabled": "Announcing changes",
|
||||
"broadcast-disabled": "Stopped announcing changes",
|
||||
"change-notification-received": "Change notification received",
|
||||
"change-notification-ignored": "Change notification ignored",
|
||||
"change-notification-sent": "Change notification sent",
|
||||
"change-notification-timeout": "Change notification timed out",
|
||||
"change-notification-cancelled": "Change notification cancelled",
|
||||
"change-notification-error": "Change notification failed",
|
||||
} satisfies Record<P2PDiagnosticCode, string>;
|
||||
|
||||
const reasons: Partial<Record<P2PDiagnosticReason, string>> = {
|
||||
"relay-details-unobserved": "relay replies not observed",
|
||||
"transport-error": "transport error",
|
||||
"replication-error": "replication error",
|
||||
"replication-not-ready": "replication not ready",
|
||||
"already-running": "replication already running",
|
||||
"not-watching": "peer is not watched",
|
||||
"not-accepted": "peer is not accepted",
|
||||
"setup-in-progress": "setup in progress",
|
||||
"peer-not-broadcasting": "peer is not announcing changes",
|
||||
"peer-broadcast-unobserved": "peer announcement state not observed",
|
||||
"authentication-unobserved": "authentication response not observed",
|
||||
ICE_GATHERING_NOT_COMPLETED: "ICE gathering did not complete",
|
||||
ICE_CONNECTIVITY_FAILED: "ICE connectivity failed",
|
||||
STUN_REQUEST_TIMEOUT: "STUN request timed out",
|
||||
SIGNALING_NOT_STABLE: "signalling did not stabilise",
|
||||
CONNECTION_DROPPED_AFTER_ESTABLISHED: "established connection dropped",
|
||||
NETWORK_INTERRUPTED: "network interrupted",
|
||||
UNKNOWN: "reason not observed",
|
||||
};
|
||||
|
||||
const categories = ["relay", "peer", "sync"] as const;
|
||||
const categoryLabels = { relay: "Relay", peer: "Peer", sync: "Sync" } as const;
|
||||
|
||||
function describeEvent(event: P2PDiagnosticEvent): string {
|
||||
const label = labels[event.code] ?? "Unknown diagnostic event";
|
||||
const reason = event.reason ? reasons[event.reason] : undefined;
|
||||
const peer = event.peerId ? ` [${event.peerId.replace(/[^A-Za-z0-9_-]/g, "?").slice(0, 12)}]` : "";
|
||||
return label + (reason ? `; ${reason}` : "") + peer;
|
||||
}
|
||||
|
||||
function eventDate(event: P2PDiagnosticEvent): Date | undefined {
|
||||
const date = new Date(event.timestamp);
|
||||
return Number.isFinite(date.getTime()) ? date : undefined;
|
||||
}
|
||||
|
||||
export function formatP2PDiagnosticLog(event: P2PDiagnosticEvent): string {
|
||||
const time = eventDate(event)?.toISOString() ?? "unknown time";
|
||||
return `[P2P Diag] ${categoryLabels[event.category]}: ${describeEvent(event)} (observed ${time})`;
|
||||
}
|
||||
|
||||
export type P2PDiagnosticRow = {
|
||||
category: (typeof categories)[number];
|
||||
label: string;
|
||||
text: string;
|
||||
time: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function projectP2PDiagnosticRows(snapshot: P2PDiagnosticSnapshot, available = true): P2PDiagnosticRow[] {
|
||||
return categories.map((category) => {
|
||||
const event = snapshot[category];
|
||||
const date = event ? eventDate(event) : undefined;
|
||||
return {
|
||||
category,
|
||||
label: categoryLabels[category],
|
||||
text: event ? describeEvent(event) : available ? "Not observed" : "Unavailable",
|
||||
time: date?.toLocaleTimeString() ?? "",
|
||||
title: date?.toLocaleString() ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Replays the latest three observations, then follows the same accepted events written to the log. */
|
||||
export function observeP2PDiagnosticRows(
|
||||
diagnostics: P2PDiagnostics,
|
||||
listener: (rows: readonly P2PDiagnosticRow[]) => void
|
||||
): () => void {
|
||||
const available = !!(diagnostics.getDiagnosticSnapshot || diagnostics.onDiagnosticEvent);
|
||||
let snapshot = diagnostics.getDiagnosticSnapshot?.() ?? {};
|
||||
let disposed = false;
|
||||
const unsubscribe = diagnostics.onDiagnosticEvent?.((event) => {
|
||||
if (disposed) return;
|
||||
snapshot = { ...snapshot, [event.category]: event };
|
||||
listener(projectP2PDiagnosticRows(snapshot, available));
|
||||
});
|
||||
listener(projectP2PDiagnosticRows(snapshot, available));
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { P2PDiagnosticEvent, P2PDiagnostics, P2PDiagnosticSnapshot } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { formatP2PDiagnosticLog, observeP2PDiagnosticRows, projectP2PDiagnosticRows } from "./P2PDiagnosticDisplay.ts";
|
||||
|
||||
const peerLeft: P2PDiagnosticEvent = {
|
||||
category: "peer",
|
||||
code: "peer-left",
|
||||
timestamp: 1_700_000_000_000,
|
||||
peerId: "fixture-peer-id",
|
||||
};
|
||||
|
||||
function diagnostics(snapshot: P2PDiagnosticSnapshot = {}) {
|
||||
let listener: ((event: P2PDiagnosticEvent) => void) | undefined;
|
||||
const unsubscribe = vi.fn();
|
||||
const service: P2PDiagnostics = {
|
||||
requestStatus: vi.fn(),
|
||||
getPeerConnectionMetrics: vi.fn(),
|
||||
getDiagnosticSnapshot: () => snapshot,
|
||||
onDiagnosticEvent: (callback) => {
|
||||
listener = callback;
|
||||
return unsubscribe;
|
||||
},
|
||||
};
|
||||
return { service, unsubscribe, emit: (event: P2PDiagnosticEvent) => listener?.(event) };
|
||||
}
|
||||
|
||||
describe("P2P diagnostic presentation", () => {
|
||||
it("shows unobserved categories rather than claiming zero or success", () => {
|
||||
const rows = projectP2PDiagnosticRows({});
|
||||
expect(rows.map((row) => [row.label, row.text, row.time])).toEqual([
|
||||
["Relay", "Not observed", ""],
|
||||
["Peer", "Not observed", ""],
|
||||
["Sync", "Not observed", ""],
|
||||
]);
|
||||
expect(projectP2PDiagnosticRows({}, false).every((row) => row.text === "Unavailable")).toBe(true);
|
||||
});
|
||||
|
||||
it("states that room activation does not measure relay acceptance", () => {
|
||||
const event: P2PDiagnosticEvent = {
|
||||
category: "relay",
|
||||
code: "room-opened",
|
||||
timestamp: peerLeft.timestamp,
|
||||
reason: "relay-details-unobserved",
|
||||
};
|
||||
expect(projectP2PDiagnosticRows({ relay: event })[0].text).toBe("Room opened; relay replies not observed");
|
||||
expect(formatP2PDiagnosticLog(event)).toContain("[P2P Diag] Relay: Room opened; relay replies not observed");
|
||||
expect(formatP2PDiagnosticLog(event)).toContain(new Date(event.timestamp).toISOString());
|
||||
});
|
||||
|
||||
it("distinguishes explicit refusal, timeout, and unobserved watch policy", () => {
|
||||
const refused: P2PDiagnosticEvent = { category: "peer", code: "auth-refused", timestamp: peerLeft.timestamp };
|
||||
const timedOut = { ...refused, code: "auth-timeout" } as const;
|
||||
expect(formatP2PDiagnosticLog(refused)).toContain("Authentication refused");
|
||||
expect(formatP2PDiagnosticLog(timedOut)).toContain("Authentication timed out");
|
||||
expect(
|
||||
formatP2PDiagnosticLog({
|
||||
category: "sync",
|
||||
code: "watch-skipped",
|
||||
reason: "peer-broadcast-unobserved",
|
||||
timestamp: peerLeft.timestamp,
|
||||
})
|
||||
).toContain("peer announcement state not observed");
|
||||
});
|
||||
|
||||
it("replays a lost peer's latest observation and retains it while other categories change", () => {
|
||||
const fixture = diagnostics({ peer: peerLeft });
|
||||
const listener = vi.fn();
|
||||
const dispose = observeP2PDiagnosticRows(fixture.service, listener);
|
||||
expect(listener.mock.lastCall?.[0][1].text).toContain("Peer left");
|
||||
fixture.emit({ category: "relay", code: "room-closed", timestamp: peerLeft.timestamp + 100 });
|
||||
const latest = listener.mock.lastCall?.[0];
|
||||
expect(latest?.[0].text).toBe("Room closed");
|
||||
expect(latest?.[1].text).toContain("Peer left");
|
||||
expect(latest?.[1].title).toBe(new Date(peerLeft.timestamp).toLocaleString());
|
||||
dispose();
|
||||
dispose();
|
||||
fixture.emit({ ...peerLeft, code: "peer-advertised" });
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("accepts an older diagnostics view without extending its contract", () => {
|
||||
const listener = vi.fn();
|
||||
const dispose = observeP2PDiagnosticRows(
|
||||
{ requestStatus: vi.fn(), getPeerConnectionMetrics: vi.fn() },
|
||||
listener
|
||||
);
|
||||
expect(listener.mock.lastCall?.[0].map((row: { text: string }) => row.text)).toEqual([
|
||||
"Unavailable",
|
||||
"Unavailable",
|
||||
"Unavailable",
|
||||
]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("does not put peer control characters into a log line and tolerates missing timestamps", () => {
|
||||
expect(formatP2PDiagnosticLog({ ...peerLeft, peerId: "bad\npeer\tvalue" })).not.toMatch(/[\n\t]/);
|
||||
const rows = projectP2PDiagnosticRows({ peer: { ...peerLeft, timestamp: NaN } });
|
||||
expect(rows[1].time).toBe("");
|
||||
expect(formatP2PDiagnosticLog({ ...peerLeft, timestamp: NaN })).toContain("unknown time");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import type { P2PReplicationProgress } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
import type { P2PDiagnostics } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { FAST_FORWARD_COUNT_LINGER_MS } from "./ReplicationStatusLabel.ts";
|
||||
import type { DisposableReactiveValue } from "./StatusBarDisplay.ts";
|
||||
import type { StatusBarPart } from "./StatusBarRenderer.ts";
|
||||
|
||||
type PeerStatusProjection = { message: string; parts: StatusBarPart[] };
|
||||
export type P2PStatusProjection = PeerStatusProjection & {
|
||||
peers: ReadonlyMap<string, PeerStatusProjection>;
|
||||
};
|
||||
|
||||
export function selectReplicationStatus(
|
||||
primary: PeerStatusProjection,
|
||||
p2p: P2PStatusProjection,
|
||||
p2pIsPrimary: boolean
|
||||
): { primary: PeerStatusProjection; auxiliary: PeerStatusProjection } {
|
||||
if (!p2pIsPrimary) return { primary, auxiliary: p2p };
|
||||
const parts: StatusBarPart[] = p2p.parts.length
|
||||
? [{ key: "sync", text: "Sync: " }, ...p2p.parts]
|
||||
: [{ key: "sync", text: "Sync: 💤 P2P" }];
|
||||
return { primary: { message: partsMessage(parts), parts }, auxiliary: { message: "", parts: [] } };
|
||||
}
|
||||
|
||||
const directions = ["sending", "fetching"] as const;
|
||||
type Direction = (typeof directions)[number];
|
||||
|
||||
function directionParts(
|
||||
peer: P2PReplicationProgress,
|
||||
direction: Direction,
|
||||
checkedChanges: number | undefined
|
||||
): StatusBarPart[] {
|
||||
const progress = peer[direction];
|
||||
const key = `p2p-${peer.peerId}-${direction}`;
|
||||
const arrow = direction === "sending" ? "↑" : "↓";
|
||||
const parts: StatusBarPart[] = progress.statistics
|
||||
? [
|
||||
{
|
||||
key,
|
||||
text: ` ${arrow} `,
|
||||
value: progress.statistics.documents,
|
||||
title: direction === "sending" ? "Sent document revisions" : "Received document revisions",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key,
|
||||
text: ` ${arrow} seq `,
|
||||
value: progress.current,
|
||||
suffix: progress.current === progress.max ? "" : ` (${progress.max})`,
|
||||
},
|
||||
];
|
||||
if (checkedChanges !== undefined) {
|
||||
parts.push({
|
||||
key: `${key}-ff`,
|
||||
text: ` ⏩${arrow} `,
|
||||
value: checkedChanges,
|
||||
title: "Compared changes-feed entries",
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function partsMessage(parts: readonly StatusBarPart[]): string {
|
||||
return parts.map((part) => part.text + (part.value === undefined ? "" : part.value + (part.suffix ?? ""))).join("");
|
||||
}
|
||||
|
||||
/** Retains transfer counts while independently expiring each peer's directional comparison labels. */
|
||||
export function createP2PProgressDisplay(
|
||||
source: ReactiveValue<readonly P2PReplicationProgress[]>,
|
||||
lingerMs = FAST_FORWARD_COUNT_LINGER_MS
|
||||
): DisposableReactiveValue<P2PStatusProjection> {
|
||||
const displayed = reactiveSource<P2PStatusProjection>({ message: "", parts: [], peers: new Map() });
|
||||
const observed = new Map<string, number>();
|
||||
const visible = new Map<string, number>();
|
||||
const timers = new Map<string, number>();
|
||||
let disposed = false;
|
||||
|
||||
const cancelTimer = (key: string) => {
|
||||
const timer = timers.get(key);
|
||||
if (timer !== undefined) compatGlobal.clearTimeout(timer);
|
||||
timers.delete(key);
|
||||
};
|
||||
const render = () => {
|
||||
const peers = new Map<string, PeerStatusProjection>();
|
||||
const parts: StatusBarPart[] = [];
|
||||
for (const peer of source.value) {
|
||||
const active = peer.fetching.isActive || peer.sending.isActive;
|
||||
const peerParts = directions.flatMap((direction) =>
|
||||
directionParts(peer, direction, visible.get(`${peer.peerId}:${direction}`))
|
||||
);
|
||||
const namedParts = [
|
||||
{ key: `p2p-${peer.peerId}`, text: `${active ? "⚡" : "💤"} ${peer.peerName}` },
|
||||
...peerParts,
|
||||
];
|
||||
peers.set(peer.peerId, { message: partsMessage(peerParts).trim(), parts: peerParts });
|
||||
if (parts.length) parts.push({ key: `p2p-${peer.peerId}-separator`, text: " / " });
|
||||
parts.push(...namedParts);
|
||||
}
|
||||
displayed.value = { message: partsMessage(parts), parts, peers };
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const present = new Set<string>();
|
||||
for (const peer of source.value) {
|
||||
for (const direction of directions) {
|
||||
const key = `${peer.peerId}:${direction}`;
|
||||
present.add(key);
|
||||
const count = peer[direction].statistics?.checkedChanges;
|
||||
if (count === observed.get(key)) continue;
|
||||
cancelTimer(key);
|
||||
visible.delete(key);
|
||||
if (count === undefined) observed.delete(key);
|
||||
else observed.set(key, count);
|
||||
if (count === undefined || !Number.isFinite(count) || count <= 0) continue;
|
||||
visible.set(key, count);
|
||||
timers.set(
|
||||
key,
|
||||
compatGlobal.setTimeout(
|
||||
() => {
|
||||
timers.delete(key);
|
||||
if (disposed) return;
|
||||
visible.delete(key);
|
||||
render();
|
||||
},
|
||||
Math.max(0, lingerMs)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of observed.keys()) {
|
||||
if (present.has(key)) continue;
|
||||
cancelTimer(key);
|
||||
observed.delete(key);
|
||||
visible.delete(key);
|
||||
}
|
||||
render();
|
||||
};
|
||||
source.onChanged(update);
|
||||
update();
|
||||
return {
|
||||
get value() {
|
||||
return displayed.value;
|
||||
},
|
||||
onChanged: displayed.onChanged,
|
||||
offChanged: displayed.offChanged,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
source.offChanged(update);
|
||||
for (const key of timers.keys()) cancelTimer(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Bridges diagnostics snapshots to the same display used by the editor and status bar. */
|
||||
export function observeP2PDiagnosticsProgress(
|
||||
diagnostics: P2PDiagnostics,
|
||||
listener: (snapshot: P2PStatusProjection) => void
|
||||
): () => void {
|
||||
const source = reactiveSource<readonly P2PReplicationProgress[]>(diagnostics.getReplicationProgress?.() ?? []);
|
||||
const display = createP2PProgressDisplay(source);
|
||||
const update = () => listener(display.value);
|
||||
display.onChanged(update);
|
||||
const unsubscribe = diagnostics.onReplicationProgress?.((snapshot) => {
|
||||
source.value = snapshot;
|
||||
});
|
||||
update();
|
||||
let disposed = false;
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
display.offChanged(update);
|
||||
display.dispose();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { P2PReplicationProgress } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
import type { P2PDiagnostics } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import {
|
||||
observeP2PDiagnosticsProgress,
|
||||
createP2PProgressDisplay,
|
||||
selectReplicationStatus,
|
||||
type P2PStatusProjection,
|
||||
} from "./P2PStatusLabel.ts";
|
||||
|
||||
function peer(peerId = "peer-a"): P2PReplicationProgress {
|
||||
return {
|
||||
peerId,
|
||||
peerName: peerId,
|
||||
sending: { current: 800, max: 900, isActive: true, statistics: { documents: 2, checkedChanges: 40 } },
|
||||
fetching: { current: 600, max: 700, isActive: true, statistics: { documents: 0, checkedChanges: 33 } },
|
||||
};
|
||||
}
|
||||
|
||||
describe("P2P progress display", () => {
|
||||
const disposables: { dispose(): void }[] = [];
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
for (const display of disposables.splice(0)) display.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
function displayFor(initial: P2PReplicationProgress[] = [peer()]) {
|
||||
const source = reactiveSource<readonly P2PReplicationProgress[]>(initial);
|
||||
const display = createP2PProgressDisplay(source);
|
||||
disposables.push(display);
|
||||
return { source, display };
|
||||
}
|
||||
|
||||
it("shows transferred revisions independently from seq and zero-transfer comparisons", () => {
|
||||
const { display, source } = displayFor();
|
||||
expect(display.value.message).toBe("⚡ peer-a ↑ 2 ⏩↑ 40 ↓ 0 ⏩↓ 33");
|
||||
expect(display.value.parts.find((part) => part.key === "p2p-peer-a-fetching")).toMatchObject({ value: 0 });
|
||||
vi.advanceTimersByTime(3_000);
|
||||
expect(display.value.message).toBe("⚡ peer-a ↑ 2 ↓ 0");
|
||||
expect(source.value[0].fetching.statistics).toEqual({ documents: 0, checkedChanges: 33 });
|
||||
});
|
||||
|
||||
it("labels legacy seq honestly and distinguishes unavailable counts from measured zero", () => {
|
||||
const legacy = peer();
|
||||
delete legacy.sending.statistics;
|
||||
const { display } = displayFor([legacy]);
|
||||
expect(display.value.message).toContain("↑ seq 800 (900)");
|
||||
expect(display.value.message).toContain("↓ 0 ⏩↓ 33");
|
||||
expect(display.value.message).not.toContain("⏩↑");
|
||||
});
|
||||
|
||||
it("expires independently by direction and peer without extending on unrelated activity", () => {
|
||||
const { display, source } = displayFor([peer(), peer("peer-b")]);
|
||||
vi.advanceTimersByTime(2_000);
|
||||
const next = peer();
|
||||
next.fetching.statistics = { documents: 0, checkedChanges: 66 };
|
||||
source.value = [next, peer("peer-b")];
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(display.value.peers.get("peer-a")?.message).toBe("↑ 2 ↓ 0 ⏩↓ 66");
|
||||
expect(display.value.peers.get("peer-b")?.message).toBe("↑ 2 ↓ 0");
|
||||
source.value = [{ ...next, peerName: "renamed" }, peer("peer-b")];
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(display.value.message).not.toContain("⏩");
|
||||
source.value = [next, peer("peer-b")];
|
||||
expect(display.value.message).not.toContain("⏩");
|
||||
});
|
||||
|
||||
it("starts a fresh label lifetime after a counter reset and retains terminal transfer totals", () => {
|
||||
const { display, source } = displayFor();
|
||||
const reset = peer();
|
||||
reset.fetching.statistics = { documents: 0, checkedChanges: 0 };
|
||||
source.value = [reset];
|
||||
expect(display.value.message).not.toContain("⏩↓");
|
||||
vi.advanceTimersByTime(3_000);
|
||||
const completed = peer();
|
||||
completed.sending.isActive = false;
|
||||
completed.fetching.isActive = false;
|
||||
source.value = [completed];
|
||||
expect(display.value.message).toBe("💤 peer-a ↑ 2 ↓ 0 ⏩↓ 33");
|
||||
vi.advanceTimersByTime(3_000);
|
||||
expect(display.value.message).toBe("💤 peer-a ↑ 2 ↓ 0");
|
||||
});
|
||||
|
||||
it("removes labels and pending timers on disconnect, and detaches on disposal", () => {
|
||||
const { display, source } = displayFor();
|
||||
source.value = [];
|
||||
expect(display.value.message).toBe("");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
source.value = [peer()];
|
||||
expect(display.value.message).toContain("⏩↓ 33");
|
||||
display.dispose();
|
||||
const last = display.value;
|
||||
source.value = [];
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(display.value).toBe(last);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("takes a late diagnostics snapshot, follows updates, and unsubscribes for either pane", () => {
|
||||
let listener: ((snapshot: readonly P2PReplicationProgress[]) => void) | undefined;
|
||||
const unsubscribe = vi.fn();
|
||||
const diagnostics: P2PDiagnostics = {
|
||||
requestStatus: vi.fn(),
|
||||
getPeerConnectionMetrics: vi.fn(),
|
||||
getReplicationProgress: () => [peer()],
|
||||
onReplicationProgress: (handler) => {
|
||||
listener = handler;
|
||||
return unsubscribe;
|
||||
},
|
||||
};
|
||||
let snapshot: P2PStatusProjection | undefined;
|
||||
const dispose = observeP2PDiagnosticsProgress(diagnostics, (value) => {
|
||||
snapshot = value;
|
||||
});
|
||||
disposables.push({ dispose });
|
||||
expect(snapshot?.peers.get("peer-a")?.message).toContain("↓ 0 ⏩↓ 33");
|
||||
listener?.([]);
|
||||
expect(snapshot?.peers.size).toBe(0);
|
||||
dispose();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("supports diagnostics from an older service without progress methods", () => {
|
||||
const listener = vi.fn();
|
||||
const dispose = observeP2PDiagnosticsProgress(
|
||||
{ requestStatus: vi.fn(), getPeerConnectionMetrics: vi.fn() },
|
||||
listener
|
||||
);
|
||||
disposables.push({ dispose });
|
||||
expect(listener).toHaveBeenLastCalledWith({ message: "", parts: [], peers: new Map() });
|
||||
});
|
||||
|
||||
it("uses P2P for the Only P2P primary display and preserves the CouchDB display when auxiliary", () => {
|
||||
const { display } = displayFor();
|
||||
const couchDB = { message: "Sync: ⚡ ↑ 12 ↓ 34", parts: [{ key: "sent", text: " ↑ ", value: 12 }] };
|
||||
const auxiliary = selectReplicationStatus(couchDB, display.value, false);
|
||||
expect(auxiliary.primary).toBe(couchDB);
|
||||
expect(auxiliary.auxiliary.message).toContain("↑ 2");
|
||||
const onlyP2P = selectReplicationStatus(couchDB, display.value, true);
|
||||
expect(onlyP2P.primary.message).toBe("Sync: ⚡ peer-a ↑ 2 ⏩↑ 40 ↓ 0 ⏩↓ 33");
|
||||
expect(onlyP2P.auxiliary.message).toBe("");
|
||||
expect(couchDB.parts[0].value).toBe(12);
|
||||
expect(
|
||||
selectReplicationStatus(couchDB, { message: "", parts: [], peers: new Map() }, true).primary.message
|
||||
).toBe("Sync: 💤 P2P");
|
||||
});
|
||||
});
|
||||
@@ -32,12 +32,14 @@ import {
|
||||
chooseAndCopyFileDatabaseInfo,
|
||||
collectFileDatabaseInfoPaths,
|
||||
copyFileDatabaseInfo,
|
||||
inspectFileDatabaseInfo,
|
||||
retryReadFileDatabaseRevision,
|
||||
} from "@/serviceFeatures/fileDatabaseInfo.ts";
|
||||
import {
|
||||
discardLiveBranch,
|
||||
discardUnreadableLiveRevision,
|
||||
inspectFileRepair,
|
||||
repairRevisionSize,
|
||||
type FileRepairInspection,
|
||||
type FileRepairRevision,
|
||||
} from "@/serviceFeatures/fileRepair.ts";
|
||||
@@ -786,6 +788,31 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.repairSize) {
|
||||
revisionActions.push({
|
||||
title: $msg("Correct recorded file size"),
|
||||
run: async () => {
|
||||
await runMutation(
|
||||
`Correct the recorded size on database revision ${metadata.revision}`,
|
||||
async () => {
|
||||
const result = await repairRevisionSize(
|
||||
this.core,
|
||||
path,
|
||||
metadata.revision!
|
||||
);
|
||||
if (result !== "repaired" && result !== "unchanged") {
|
||||
return false;
|
||||
}
|
||||
const current = await inspectFileDatabaseInfo(this.core, path);
|
||||
const currentRevision = current.database.currentRevision;
|
||||
return currentRevision !== null
|
||||
? storeStorageOnRevision(path, currentRevision, false)
|
||||
: false;
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.storeVaultOnBranch) {
|
||||
revisionActions.push({
|
||||
title: $msg("Store Vault file as a child of this revision"),
|
||||
|
||||
@@ -135,6 +135,11 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
return this.context.plugin.registerObsidianProtocolHandler(action, handler);
|
||||
}
|
||||
|
||||
override registerCleanup(callback: () => void): void {
|
||||
// Keep disposal owned by the native component, preserving its unload timing.
|
||||
this.context.plugin.register(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* In Obsidian, we will use the native `requestUrl` function as the default fetch handler,
|
||||
* to address unavoidable CORS issues.
|
||||
|
||||
@@ -248,3 +248,16 @@ describe("ObsidianAPIService.nativeFetch cache control", () => {
|
||||
expect(sentHeaders().has("pragma")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.registerCleanup", () => {
|
||||
it("delegates the original callback to native component ownership without invoking it immediately", () => {
|
||||
const register = vi.fn();
|
||||
const service = new ObsidianAPIService({ app: {}, plugin: { register } } as unknown as ObsidianServiceContext);
|
||||
const cleanup = vi.fn();
|
||||
expect(service.registerCleanup(cleanup)).toBeUndefined();
|
||||
expect(register).toHaveBeenCalledExactlyOnceWith(cleanup);
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
register.mock.calls[0][0]();
|
||||
expect(cleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type { LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createBlob, isDocContentSame, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
|
||||
export type FileRepairCore = FileDatabaseInfoCore & {
|
||||
fileHandler: Pick<IFileHandler, "deleteRevisionFromDB">;
|
||||
localDatabase: FileDatabaseInfoCore["localDatabase"] &
|
||||
Pick<import("@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB").LiveSyncLocalDB, "putRaw">;
|
||||
storageAccess: FileDatabaseInfoCore["storageAccess"] & Pick<StorageAccess, "readHiddenFileBinary">;
|
||||
};
|
||||
|
||||
@@ -29,11 +32,7 @@ export type FileRepairInspection = {
|
||||
requiresAttention: boolean;
|
||||
};
|
||||
|
||||
export type DiscardUnreadableRevisionResult =
|
||||
| "discarded"
|
||||
| "failed"
|
||||
| "no-longer-live"
|
||||
| "revision-is-readable";
|
||||
export type DiscardUnreadableRevisionResult = "discarded" | "failed" | "no-longer-live" | "revision-is-readable";
|
||||
|
||||
export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision";
|
||||
|
||||
@@ -67,9 +66,7 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro
|
||||
const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted;
|
||||
const databaseAndStorageDiffer =
|
||||
information.storage.exists !== winnerRepresentsStoredFile ||
|
||||
(information.storage.exists &&
|
||||
winnerRepresentsStoredFile &&
|
||||
winner.contentMatchesStorage === false);
|
||||
(information.storage.exists && winnerRepresentsStoredFile && winner.contentMatchesStorage === false);
|
||||
const unreadableLiveRevision =
|
||||
information.database.unavailableConflictRevisions.length > 0 ||
|
||||
revisions.some(({ contentReadable }) => !contentReadable);
|
||||
@@ -77,6 +74,10 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro
|
||||
databaseAndStorageDiffer ||
|
||||
information.database.conflictCount > 0 ||
|
||||
unreadableLiveRevision ||
|
||||
revisions.some(
|
||||
({ loadedEntry, metadata }) =>
|
||||
loadedEntry !== false && readAsBlob(loadedEntry).size !== metadata.recordedSize
|
||||
) ||
|
||||
(information.database.exists && winner === undefined);
|
||||
|
||||
return {
|
||||
@@ -92,10 +93,9 @@ export async function discardUnreadableLiveRevision(
|
||||
revision: string
|
||||
): Promise<DiscardUnreadableRevisionResult> {
|
||||
const latest = await inspectFileDatabaseInfo(core, path);
|
||||
const liveRevisions = [
|
||||
latest.database.currentRevision,
|
||||
...latest.database.conflictRevisions,
|
||||
].filter((candidate): candidate is string => candidate !== null);
|
||||
const liveRevisions = [latest.database.currentRevision, ...latest.database.conflictRevisions].filter(
|
||||
(candidate): candidate is string => candidate !== null
|
||||
);
|
||||
if (!liveRevisions.includes(revision)) {
|
||||
return "no-longer-live";
|
||||
}
|
||||
@@ -116,10 +116,9 @@ export async function discardLiveBranch(
|
||||
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);
|
||||
const liveRevisions = [latest.database.currentRevision, ...latest.database.conflictRevisions].filter(
|
||||
(candidate): candidate is string => candidate !== null
|
||||
);
|
||||
if (!liveRevisions.includes(revision)) {
|
||||
return "no-longer-live";
|
||||
}
|
||||
@@ -130,3 +129,74 @@ export async function discardLiveBranch(
|
||||
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
|
||||
return deleted ? "discarded" : "failed";
|
||||
}
|
||||
|
||||
export function canRepairRevisionSize(inspection: FileRepairInspection, revision: FileRepairRevision): boolean {
|
||||
return (
|
||||
revision.role === "winner" &&
|
||||
revision.metadata.current &&
|
||||
revision.metadata.revision !== null &&
|
||||
revision.metadata.revision === inspection.information.database.currentRevision &&
|
||||
revision.contentReadable &&
|
||||
!revision.metadata.deleted &&
|
||||
revision.metadata.storageLayout === "chunked" &&
|
||||
inspection.information.database.conflictCount === 0 &&
|
||||
inspection.information.database.unavailableConflictRevisions.length === 0 &&
|
||||
inspection.information.storage.exists &&
|
||||
revision.contentMatchesStorage === true &&
|
||||
revision.loadedEntry !== false &&
|
||||
readAsBlob(revision.loadedEntry).size !== revision.metadata.recordedSize
|
||||
);
|
||||
}
|
||||
|
||||
export async function repairRevisionSize(
|
||||
core: FileRepairCore,
|
||||
path: string,
|
||||
revision: string
|
||||
): Promise<"repaired" | "unchanged" | "stale" | "unsafe" | "failed"> {
|
||||
const inspection = await inspectFileRepair(core, path);
|
||||
if (inspection.information.database.currentRevision !== revision) return "stale";
|
||||
const winner = inspection.revisions.find((candidate) => candidate.role === "winner");
|
||||
if (!winner || winner.loadedEntry === false) return "unsafe";
|
||||
if (!canRepairRevisionSize(inspection, winner)) {
|
||||
return !winner.metadata.deleted &&
|
||||
winner.contentMatchesStorage === true &&
|
||||
inspection.information.database.conflictCount === 0 &&
|
||||
readAsBlob(winner.loadedEntry).size === winner.metadata.recordedSize
|
||||
? "unchanged"
|
||||
: "unsafe";
|
||||
}
|
||||
const content = readAsBlob(winner.loadedEntry);
|
||||
const raw = await core.localDatabase.localDatabase
|
||||
.get(winner.metadata.documentId, { conflicts: true })
|
||||
.catch((error: unknown) => {
|
||||
if (isNotFoundError(error)) return false as const;
|
||||
throw error;
|
||||
});
|
||||
if (raw === false) return "stale";
|
||||
if (
|
||||
(raw.type !== "plain" && raw.type !== "newnote") ||
|
||||
raw._rev !== revision ||
|
||||
raw._deleted ||
|
||||
("deleted" in raw && raw.deleted) ||
|
||||
raw._conflicts?.length
|
||||
)
|
||||
return "stale";
|
||||
if (
|
||||
!(await core.storageAccess.isExistsIncludeHidden(path)) ||
|
||||
!(await isDocContentSame(content, createBlob(await core.storageAccess.readHiddenFileBinary(path))))
|
||||
) {
|
||||
return "unsafe";
|
||||
}
|
||||
// Preserve the exact stored document; loaded content must never be embedded in Metadata.
|
||||
const corrected = { ...raw, size: content.size };
|
||||
delete corrected._conflicts;
|
||||
delete corrected._revs_info;
|
||||
delete corrected._revisions;
|
||||
try {
|
||||
const result = await core.localDatabase.putRaw(corrected);
|
||||
return result.ok ? "repaired" : "failed";
|
||||
} catch (error) {
|
||||
if ((error as { status?: number }).status === 409) return "stale";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import PouchDB from "pouchdb-core";
|
||||
import MemoryAdapter from "pouchdb-adapter-memory";
|
||||
PouchDB.plugin(MemoryAdapter);
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discardLiveBranch,
|
||||
discardUnreadableLiveRevision,
|
||||
inspectFileRepair,
|
||||
} from "./fileRepair";
|
||||
import { discardLiveBranch, discardUnreadableLiveRevision, inspectFileRepair, repairRevisionSize } from "./fileRepair";
|
||||
|
||||
function createCore() {
|
||||
const current = {
|
||||
@@ -48,6 +47,7 @@ function createCore() {
|
||||
getFilesIncludeHidden: vi.fn(async () => ["note.md"]),
|
||||
},
|
||||
localDatabase: {
|
||||
putRaw: vi.fn(async (_doc: unknown) => ({ ok: true, id: "f:note", rev: "4-repaired" })),
|
||||
localDatabase: {
|
||||
get: vi.fn(async (_id: string, options?: { rev?: string }) =>
|
||||
options?.rev === "2-conflict" ? conflict : current
|
||||
@@ -94,6 +94,20 @@ function createCore() {
|
||||
}
|
||||
|
||||
describe("file repair inspection", () => {
|
||||
it("reports a size-only mismatch even when Vault bytes match", async () => {
|
||||
const { core, current } = createCore();
|
||||
current._conflicts = [];
|
||||
current.size = 45;
|
||||
expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(true);
|
||||
});
|
||||
|
||||
it("omits matching bytes with correct size despite differing timestamps", async () => {
|
||||
const { core, current } = createCore();
|
||||
current._conflicts = [];
|
||||
current.mtime = 9000;
|
||||
expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the winner and every conflict revision independently", async () => {
|
||||
const { core } = createCore();
|
||||
|
||||
@@ -146,12 +160,10 @@ describe("file repair inspection", () => {
|
||||
it("rechecks liveness and readability before discarding an exact revision", async () => {
|
||||
const { core, deleteRevisionFromDB } = createCore();
|
||||
|
||||
await expect(
|
||||
discardUnreadableLiveRevision(core as never, "note.md", "2-conflict")
|
||||
).resolves.toBe("discarded");
|
||||
await expect(
|
||||
discardUnreadableLiveRevision(core as never, "note.md", "3-current")
|
||||
).resolves.toBe("revision-is-readable");
|
||||
await expect(discardUnreadableLiveRevision(core as never, "note.md", "2-conflict")).resolves.toBe("discarded");
|
||||
await expect(discardUnreadableLiveRevision(core as never, "note.md", "3-current")).resolves.toBe(
|
||||
"revision-is-readable"
|
||||
);
|
||||
|
||||
expect(deleteRevisionFromDB).toHaveBeenCalledOnce();
|
||||
expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "2-conflict");
|
||||
@@ -175,9 +187,7 @@ describe("file repair inspection", () => {
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
await expect(
|
||||
discardUnreadableLiveRevision(core as never, "note.md", "1-root")
|
||||
).resolves.toBe("discarded");
|
||||
await expect(discardUnreadableLiveRevision(core as never, "note.md", "1-root")).resolves.toBe("discarded");
|
||||
expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "1-root");
|
||||
});
|
||||
|
||||
@@ -188,9 +198,9 @@ describe("file repair inspection", () => {
|
||||
_conflicts: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
discardUnreadableLiveRevision(core as never, "note.md", "2-conflict")
|
||||
).resolves.toBe("no-longer-live");
|
||||
await expect(discardUnreadableLiveRevision(core as never, "note.md", "2-conflict")).resolves.toBe(
|
||||
"no-longer-live"
|
||||
);
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -198,9 +208,7 @@ describe("file repair inspection", () => {
|
||||
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");
|
||||
await expect(discardLiveBranch(core as never, "note.md", "3-current")).resolves.toBe("discarded");
|
||||
|
||||
expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current");
|
||||
});
|
||||
@@ -209,9 +217,7 @@ describe("file repair inspection", () => {
|
||||
const { core, current, deleteRevisionFromDB } = createCore();
|
||||
current._conflicts = [];
|
||||
|
||||
await expect(
|
||||
discardLiveBranch(core as never, "note.md", "3-current")
|
||||
).resolves.toBe("only-live-revision");
|
||||
await expect(discardLiveBranch(core as never, "note.md", "3-current")).resolves.toBe("only-live-revision");
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -219,10 +225,112 @@ describe("file repair inspection", () => {
|
||||
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");
|
||||
await expect(discardLiveBranch(core as never, "note.md", "1-stale")).resolves.toBe("no-longer-live");
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recorded size repair", () => {
|
||||
function repairFixture() {
|
||||
const fixture = createCore();
|
||||
fixture.current._conflicts = [];
|
||||
fixture.current.size = 45;
|
||||
return fixture;
|
||||
}
|
||||
it("writes a child changing only size and omitting response metadata", async () => {
|
||||
const { core, current } = repairFixture();
|
||||
expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("repaired");
|
||||
const corrected = core.localDatabase.putRaw.mock.calls[0][0];
|
||||
const expected = { ...current, size: 7 } as Partial<typeof current>;
|
||||
delete expected._conflicts;
|
||||
delete expected._revs_info;
|
||||
expect(corrected).toEqual(expected);
|
||||
});
|
||||
it.each(["conflict", "different-body", "missing-chunk", "deleted", "absent-vault"])("refuses %s", async (state) => {
|
||||
const { core, current } = repairFixture();
|
||||
if (state === "conflict") current._conflicts = ["2-conflict"];
|
||||
if (state === "different-body")
|
||||
core.storageAccess.readHiddenFileBinary.mockResolvedValue(new TextEncoder().encode("edit").buffer);
|
||||
if (state === "missing-chunk") core.localDatabase.allDocsRaw.mockResolvedValue({ rows: [] });
|
||||
if (state === "deleted") current.deleted = true;
|
||||
if (state === "absent-vault") core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false);
|
||||
expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unsafe");
|
||||
expect(core.localDatabase.putRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses a selected revision replaced before inspection", async () => {
|
||||
const { core } = repairFixture();
|
||||
expect(await repairRevisionSize(core as never, "note.md", "2-old")).toBe("stale");
|
||||
expect(core.localDatabase.putRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses a revision advancing between inspection and final metadata read", async () => {
|
||||
const { core, current } = repairFixture();
|
||||
const read = core.localDatabase.localDatabase.get;
|
||||
read.mockResolvedValueOnce(current)
|
||||
.mockResolvedValueOnce(current)
|
||||
.mockResolvedValueOnce({ ...current, _rev: "4-advanced" });
|
||||
expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale");
|
||||
expect(core.localDatabase.putRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses a Vault edit after inspection", async () => {
|
||||
const { core, current } = repairFixture();
|
||||
core.storageAccess.readHiddenFileBinary
|
||||
.mockResolvedValueOnce(new TextEncoder().encode("current").buffer)
|
||||
.mockResolvedValueOnce(new TextEncoder().encode("new edit").buffer);
|
||||
expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unsafe");
|
||||
expect(core.localDatabase.putRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
it("treats a document removed after inspection as stale", async () => {
|
||||
const { core, current } = repairFixture();
|
||||
core.localDatabase.localDatabase.get
|
||||
.mockResolvedValueOnce(current)
|
||||
.mockResolvedValueOnce(current)
|
||||
.mockRejectedValueOnce({ status: 404 });
|
||||
expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale");
|
||||
expect(core.localDatabase.putRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses advancement during the last Vault read through CAS", async () => {
|
||||
const { core, current } = repairFixture();
|
||||
core.localDatabase.putRaw.mockRejectedValue({ status: 409 });
|
||||
expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("stale");
|
||||
});
|
||||
it("does not repeat a correction or change mtime for matching content", async () => {
|
||||
const { core, current } = repairFixture();
|
||||
current.size = 7;
|
||||
expect(await repairRevisionSize(core as never, "note.md", current._rev)).toBe("unchanged");
|
||||
expect(core.localDatabase.putRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("size repair revision ancestry", () => {
|
||||
it("preserves metadata and chunks in a real PouchDB child and is idempotent", async () => {
|
||||
const { core, current } = createCore();
|
||||
const database = new PouchDB(`size-repair-${crypto.randomUUID()}`, { adapter: "memory" });
|
||||
try {
|
||||
const metadata = { ...current } as Partial<typeof current> & { _id: string };
|
||||
delete metadata._rev;
|
||||
delete metadata._conflicts;
|
||||
delete metadata._revs_info;
|
||||
const stored = await database.put({ ...metadata, size: 45 });
|
||||
core.localDatabase.localDatabase = database as never;
|
||||
core.localDatabase.putRaw.mockImplementation((doc) => database.put(doc as never));
|
||||
core.localDatabase.getDBEntryFromMeta.mockImplementation(async (meta) => ({ ...meta, data: ["current"] }));
|
||||
expect(await repairRevisionSize(core as never, "note.md", stored.rev)).toBe("repaired");
|
||||
const corrected = (await database.get(current._id, { revs: true })) as typeof current & {
|
||||
_revisions: { ids: string[] };
|
||||
};
|
||||
expect(corrected.size).toBe(7);
|
||||
expect(corrected.children).toEqual(current.children);
|
||||
expect(corrected.ctime).toBe(current.ctime);
|
||||
expect(corrected.mtime).toBe(current.mtime);
|
||||
expect(corrected.path).toBe(current.path);
|
||||
expect(corrected).not.toHaveProperty("data");
|
||||
expect(corrected._revisions.ids[1]).toBe(stored.rev.split("-")[1]);
|
||||
expect(await repairRevisionSize(core as never, "note.md", corrected._rev)).toBe("unchanged");
|
||||
expect((await inspectFileRepair(core as never, "note.md")).requiresAttention).toBe(false);
|
||||
expect(core.localDatabase.putRaw).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await database.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
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";
|
||||
import { canRepairRevisionSize, type FileRepairInspection, type FileRepairRevision } from "./fileRepair";
|
||||
|
||||
export type FileRepairRevisionActions = {
|
||||
compareWithVault: boolean;
|
||||
@@ -22,6 +19,7 @@ export type FileRepairRevisionActions = {
|
||||
retryRevision: boolean;
|
||||
discardBranch: boolean;
|
||||
discardRevision: boolean;
|
||||
repairSize: boolean;
|
||||
};
|
||||
|
||||
export type FileRepairTimestampRelation =
|
||||
@@ -87,6 +85,7 @@ export function getFileRepairRevisionActions(
|
||||
!hasConflictBranches &&
|
||||
!revision.metadata.deleted &&
|
||||
!revision.contentReadable,
|
||||
repairSize: canRepairRevisionSize(inspection, revision),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ describe("file repair presentation", () => {
|
||||
retryRevision: false,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
repairSize: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +105,58 @@ describe("file repair presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("offers size correction for a readable, matching, conflict-free winner", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
role: "winner",
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
current: true,
|
||||
recordedSize: 9,
|
||||
},
|
||||
contentMatchesStorage: true,
|
||||
});
|
||||
inspection.information.database.conflictCount = 0;
|
||||
inspection.information.database.conflictRevisions = [];
|
||||
inspection.information.database.currentRevision = revision.metadata.revision;
|
||||
inspection.information.storage.size = 7;
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
repairSize: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer size correction for unsafe or already-correct revisions", () => {
|
||||
const conflict = createInspection({ contentMatchesStorage: true });
|
||||
expect(getFileRepairRevisionActions(conflict.inspection, conflict.revision)).toMatchObject({
|
||||
repairSize: false,
|
||||
});
|
||||
|
||||
const unreadable = createInspection({
|
||||
role: "winner",
|
||||
contentReadable: false,
|
||||
contentMatchesStorage: true,
|
||||
});
|
||||
unreadable.inspection.information.database.conflictCount = 0;
|
||||
unreadable.inspection.information.database.conflictRevisions = [];
|
||||
unreadable.inspection.information.storage.size = 7;
|
||||
unreadable.revision.metadata.current = true;
|
||||
unreadable.inspection.information.database.currentRevision = unreadable.revision.metadata.revision;
|
||||
expect(getFileRepairRevisionActions(unreadable.inspection, unreadable.revision)).toMatchObject({
|
||||
repairSize: false,
|
||||
});
|
||||
|
||||
const unchanged = createInspection({
|
||||
role: "winner",
|
||||
contentMatchesStorage: true,
|
||||
});
|
||||
unchanged.inspection.information.database.conflictCount = 0;
|
||||
unchanged.inspection.information.database.conflictRevisions = [];
|
||||
unchanged.inspection.information.database.currentRevision = unchanged.revision.metadata.revision;
|
||||
expect(getFileRepairRevisionActions(unchanged.inspection, unchanged.revision)).toMatchObject({
|
||||
repairSize: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a text comparison for a binary file", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
inspection.information.path = "image.png";
|
||||
@@ -191,6 +244,16 @@ describe("file repair presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer size correction for legacy inline content", () => {
|
||||
const { inspection, revision } = createInspection({ role: "winner", contentMatchesStorage: true });
|
||||
revision.metadata.current = true;
|
||||
revision.metadata.storageLayout = "legacy-inline";
|
||||
inspection.information.database.currentRevision = revision.metadata.revision;
|
||||
inspection.information.database.conflictCount = 0;
|
||||
inspection.information.database.conflictRevisions = [];
|
||||
expect(getFileRepairRevisionActions(inspection, revision).repairSize).toBe(false);
|
||||
});
|
||||
|
||||
it("reports recorded, decoded, Vault-size, and timestamp differences", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
|
||||
|
||||
The Harness also measures ID generation with fixed in-memory data on desktop and mobile displays, checks that live settings remain unchanged, and verifies that the copied report includes both per-1,000-ID and per-ID timings, key derivation, and JavaScript heap availability. Mobile test mode verifies the UI and execution path; measure native device performance by running the same Harness on that device.
|
||||
|
||||
`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 configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile emulation. 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-emulation screenshots. The mobile-emulation session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state, and measures the rendered Refresh touch target at 44 CSS pixels or larger with the default application font. 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:configuration-encryption-sync` requires only the CouchDB fixture configured through `.test.env` or the process environment. It changes the configuration protection key through the real settings interface, restarts the source Vault, and sends a note with E2EE enabled. A fresh Vault receives the encrypted settings without a saved device key, cancels and retries recovery, and restarts with the validated key. Both Vaults then exchange exact note bytes through CouchDB. The scenario checks the restored connection credentials, E2EE passphrase, ID key, remote Metadata, and Chunks, and removes its temporary Vaults, profiles, and unique remote database.
|
||||
|
||||
@@ -238,7 +238,7 @@ Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixtur
|
||||
|
||||
`test:e2e:obsidian:object-storage-compatible-setup-uri-workflow` selects **Compatible (no time limit)** in the real generation dialogue and uses Persistent mode for the bootstrap tool. All three Object Storage sharing scenarios require the generated independent ID key to survive import and natural restarts on both devices, remain encrypted in local settings, and retain document and Chunk IDs during the bidirectional transfer. Before valid setup, they submit an incorrect passphrase and a URI generated in a past window, require the visible rejection, and verify unchanged runtime and persisted settings. Only an isolated fixture worker uses the past clock; Obsidian and the runner use real time.
|
||||
|
||||
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
|
||||
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, and checks the live peer pane: Stats remains visible, Diagnostic summary starts collapsed and can be expanded to show Relay, Peer, and Sync observations plus Open log, then can be collapsed again. It measures the peer-action buttons at 44 CSS pixels or larger and checks that they fit the peer item and viewport, while retaining the menu check for the three persistent per-peer actions. After reconnecting both P2P sessions in join order, it verifies the B-to-A return journey through the normal bidirectional synchronisation action and checks numeric ↑ and ↓ document counters on that current connection. Zero-document comparisons are valid; the checks do not require counters to survive a reconnect. Every started session remains tracked until teardown completes.
|
||||
|
||||
On headless Linux, run this two-device scenario without an inherited display, for example `env -u DISPLAY -u WAYLAND_DISPLAY npm run test:e2e:obsidian:p2p-setup-uri-workflow`. The shared launcher then gives each Obsidian session its own Xvfb display. Wrapping both sessions in one outer `xvfb-run` can hide the source window, trigger its normal suspension lifecycle, and close the P2P connection before the other device fetches.
|
||||
|
||||
@@ -274,7 +274,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 current 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. Its unload check requires the queue to report cancellation, closes active and waiting dialogues, and compares Vault contents, conflict leaves, and revision ancestry after reload.
|
||||
|
||||
`test:e2e:obsidian:log-lifecycle` delays the local database close and tests both successful and failed shutdown. It verifies that logs remain available during shutdown, that later logs go to the console after display subscriptions are released, and that logs from the previous instance do not reach the reloaded instance. It uses an isolated Vault and profile without a remote service.
|
||||
`test:e2e:obsidian:log-lifecycle` delays the local database close and tests both successful and failed shutdown. Through the actual core API, it registers one marker cleanup callback and verifies that it runs exactly once after plug-in disable completes. It also verifies that logs remain available during shutdown, that later logs go to the console after display subscriptions are released, and that logs from the previous instance do not reach the reloaded instance. It uses an isolated Vault and profile without a remote service.
|
||||
|
||||
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two current leaf 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 current 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.
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type FixtureLog = {
|
||||
|
||||
type ShutdownObservation = {
|
||||
closeCalls: number;
|
||||
cleanupCalls: number;
|
||||
disposeCalls: number;
|
||||
disposeCompleted: boolean;
|
||||
fixtureLogs: FixtureLog[];
|
||||
@@ -106,6 +107,7 @@ function createShutdownScript(options: {
|
||||
duringMarker: string;
|
||||
failureMarker: string;
|
||||
afterMarker: string;
|
||||
cleanupMarker: string;
|
||||
reloadMarker: string;
|
||||
oldReloadMarker: string;
|
||||
injectFailure: boolean;
|
||||
@@ -133,6 +135,7 @@ function createShutdownScript(options: {
|
||||
consoleLogs:[],
|
||||
uiAddLogCalls:0,
|
||||
events:[],
|
||||
cleanupCalls:0,
|
||||
closeStarted:false,
|
||||
closeCalls:0,
|
||||
disposeCalls:0,
|
||||
@@ -141,6 +144,10 @@ function createShutdownScript(options: {
|
||||
disableError:"",
|
||||
};
|
||||
app.__livesyncLogLifecycleState=state;
|
||||
api.registerCleanup(()=>{
|
||||
state.cleanupCalls++;
|
||||
state.events.push("cleanup:"+fixtures.cleanupMarker);
|
||||
});
|
||||
|
||||
const originalAddLog=module.__addLog;
|
||||
const originalConsoleLog=console.log;
|
||||
@@ -307,8 +314,13 @@ function createShutdownScript(options: {
|
||||
"The disposed ModuleLog UI handler received a post-disposal log");
|
||||
await state.disablePromise;
|
||||
require(!state.disableError,"Disabling the plug-in failed: "+state.disableError);
|
||||
require(state.cleanupCalls===1,
|
||||
"The registered API cleanup callback did not run exactly once after plug-in disable completed");
|
||||
require(state.events.filter(event=>event==="cleanup:"+fixtures.cleanupMarker).length===1,
|
||||
"The registered API cleanup marker was not recorded exactly once");
|
||||
return JSON.stringify({
|
||||
closeCalls:state.closeCalls,
|
||||
cleanupCalls:state.cleanupCalls,
|
||||
disposeCalls:state.disposeCalls,
|
||||
disposeCompleted:state.disposeCompleted,
|
||||
fixtureLogs:state.fixtureLogs,
|
||||
@@ -353,6 +365,7 @@ async function runCase(options: {
|
||||
const markers = {
|
||||
duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`,
|
||||
afterMarker: "log-lifecycle-" + options.caseName + "-after-disposal-" + randomUUID(),
|
||||
cleanupMarker: `log-lifecycle-${options.caseName}-api-cleanup-${randomUUID()}`,
|
||||
failureMarker: options.injectFailure
|
||||
? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}`
|
||||
: "",
|
||||
|
||||
@@ -180,6 +180,23 @@ async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<s
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
await pane.getByText("Connection:", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const refresh = pane.getByRole("button", { name: "Refresh", exact: true });
|
||||
await refresh.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (mobile) {
|
||||
const touchTarget = await refresh.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
rootFontSize: getComputedStyle(document.documentElement).fontSize,
|
||||
};
|
||||
});
|
||||
if (touchTarget.width < 44 || touchTarget.height < 44) {
|
||||
throw new Error(
|
||||
`The mobile P2P Refresh touch target was smaller than 44 CSS pixels at the default font: ${JSON.stringify(touchTarget)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
await pane.getByRole("button", { name: "Open connection" }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { assertEqual, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -602,6 +603,14 @@ async function waitForDetectedPeer(port: number): Promise<void> {
|
||||
|
||||
async function capturePeerActionsMenu(port: number): Promise<string> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const detectedPeers = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||
await detectedPeers.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = detectedPeers.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
await assertP2PDiagnosticSummary(pane);
|
||||
await assertPeerActionButtonsFit(pane);
|
||||
|
||||
const moreActions = page.getByRole("button", { name: /^More actions for /u }).first();
|
||||
await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await moreActions.click({ timeout: uiTimeoutMs });
|
||||
@@ -641,6 +650,117 @@ async function capturePeerActionsMenu(port: number): Promise<string> {
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function assertP2PDiagnosticSummary(pane: Locator): Promise<void> {
|
||||
const stats = pane.getByRole("heading", { name: "Stats", exact: true });
|
||||
await stats.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const diagnosticDetails = pane.locator("details.diag-details");
|
||||
await diagnosticDetails.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (await diagnosticDetails.evaluate((element) => (element as HTMLDetailsElement).open)) {
|
||||
throw new Error("The P2P diagnostic summary was open before the user expanded it.");
|
||||
}
|
||||
const diagnosticSummary = diagnosticDetails.locator("summary");
|
||||
await diagnosticSummary.click({ timeout: uiTimeoutMs });
|
||||
const diagnosticRows = diagnosticDetails.locator(".diag-summary > div");
|
||||
await diagnosticRows.first().waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const rowText = await diagnosticRows.allInnerTexts();
|
||||
for (const label of ["Relay:", "Peer:", "Sync:"]) {
|
||||
if (!rowText.some((text) => text.trim().startsWith(label))) {
|
||||
throw new Error(`The expanded P2P diagnostics did not show the ${label} observation.`);
|
||||
}
|
||||
}
|
||||
const openLog = diagnosticDetails.getByRole("button", { name: "Open log", exact: true });
|
||||
await openLog.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (!(await stats.isVisible())) {
|
||||
throw new Error("The P2P Stats section was hidden while diagnostic observations were expanded.");
|
||||
}
|
||||
await diagnosticSummary.click({ timeout: uiTimeoutMs });
|
||||
await diagnosticRows.first().waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
if (await diagnosticDetails.evaluate((element) => (element as HTMLDetailsElement).open)) {
|
||||
throw new Error("The P2P diagnostic summary did not collapse after it was toggled again.");
|
||||
}
|
||||
if (!(await stats.isVisible())) {
|
||||
throw new Error("The P2P Stats section was hidden after collapsing diagnostic observations.");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPeerActionButtonsFit(pane: Locator): Promise<void> {
|
||||
const peerItem = pane.locator(".peers-section .peer-item").first();
|
||||
await peerItem.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await peerItem.scrollIntoViewIfNeeded();
|
||||
const actionButtons = await peerItem.evaluate((element) => {
|
||||
const item = element.getBoundingClientRect();
|
||||
const viewport = {
|
||||
width: document.documentElement.clientWidth,
|
||||
height: document.documentElement.clientHeight,
|
||||
};
|
||||
return {
|
||||
hasHorizontalOverflow: element.scrollWidth > element.clientWidth,
|
||||
buttons: Array.from(element.querySelectorAll("button"))
|
||||
.filter((button) => button.getClientRects().length > 0)
|
||||
.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
label: button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "",
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
insidePeer:
|
||||
rect.left >= item.left &&
|
||||
rect.top >= item.top &&
|
||||
rect.right <= item.right &&
|
||||
rect.bottom <= item.bottom,
|
||||
insideViewport:
|
||||
rect.left >= 0 &&
|
||||
rect.top >= 0 &&
|
||||
rect.right <= viewport.width &&
|
||||
rect.bottom <= viewport.height,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
if (
|
||||
actionButtons.buttons.length === 0 ||
|
||||
actionButtons.hasHorizontalOverflow ||
|
||||
actionButtons.buttons.some(
|
||||
(button) =>
|
||||
button.width < 44 ||
|
||||
button.height < 44 ||
|
||||
!button.insidePeer ||
|
||||
!button.insideViewport
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`P2P peer action buttons were too small or did not fit their peer item and viewport: ${JSON.stringify(actionButtons)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPeerDocumentProgress(page: Page, directions: readonly ("↑" | "↓")[]): Promise<void> {
|
||||
const detectedPeers = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||
await detectedPeers.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = detectedPeers.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
const progress = pane.locator(".peers-section .peer-item .peer-progress").first();
|
||||
await progress.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const deadline = Date.now() + uiTimeoutMs;
|
||||
let message = "";
|
||||
while (Date.now() < deadline) {
|
||||
message = (await progress.innerText()).replace(/\s+/gu, " ").trim();
|
||||
const documentProgress = message.replace(/⏩[↑↓]\s+\d+/gu, "");
|
||||
if (
|
||||
directions.every((direction) =>
|
||||
new RegExp(`${direction}\\s+\\d+(?:\\s|$)`, "u").test(documentProgress)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
throw new Error(
|
||||
`Completed P2P transfer progress did not show document counters for ${directions.join(" and ")}: ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((notePath) => {
|
||||
@@ -740,6 +860,9 @@ async function main(): Promise<void> {
|
||||
console.log("P2P workflow: return replication requested; waiting for the second device's note.");
|
||||
await waitForPathContent(vaultA, noteFromSecond, secondContent);
|
||||
console.log("P2P workflow: return note reached the first device.");
|
||||
await withObsidianPage(portA, async (page) => {
|
||||
await assertPeerDocumentProgress(page, ["↑", "↓"]);
|
||||
});
|
||||
} finally {
|
||||
returnJourneyFinished = true;
|
||||
await returnJourneyAcceptor;
|
||||
|
||||
@@ -11,6 +11,8 @@ import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
const path = "revision-repair.md";
|
||||
const sizeRepairPath = "revision-size-repair.md";
|
||||
const sizeRepairContent = "Size repair fixture with multibyte text: 日本語\n";
|
||||
const healthyDeletedPath = "healthy-logical-deletion.md";
|
||||
const baseContent = "Revision repair\n\nShared base.\n";
|
||||
const branchContents = [
|
||||
@@ -35,6 +37,29 @@ type VaultWinnerState = {
|
||||
winnerRevision: string;
|
||||
};
|
||||
|
||||
type SizeRepairFixture = {
|
||||
revisionWithWrongSize: string;
|
||||
expectedSize: number;
|
||||
expectedPath: string;
|
||||
expectedCtime: number;
|
||||
expectedMtime: number;
|
||||
expectedChunks: string[];
|
||||
};
|
||||
|
||||
type SizeRepairState = SizeRepairFixture & {
|
||||
revision: string;
|
||||
size: number;
|
||||
path: string;
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
chunks: string[];
|
||||
bodyMatches: boolean;
|
||||
chunksAvailable: boolean;
|
||||
historyStart: number;
|
||||
historyIds: string[];
|
||||
provenanceRevision: string | null;
|
||||
};
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
@@ -95,6 +120,68 @@ async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.Proce
|
||||
);
|
||||
}
|
||||
|
||||
async function createSizeRepairFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SizeRepairFixture> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(sizeRepairPath)};`,
|
||||
`const content=${JSON.stringify(sizeRepairContent)};`,
|
||||
"const file=app.vault.getAbstractFileByPath(path)??await app.vault.create(path,content);",
|
||||
"await app.workspace.getLeaf(false).openFile(file);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
const saved = await waitForLocalDatabaseEntry(cliBinary, env, sizeRepairPath);
|
||||
return await evalObsidianJson<SizeRepairFixture>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(sizeRepairPath)};`,
|
||||
`const expectedSize=new TextEncoder().encode(${JSON.stringify(sizeRepairContent)}).length;`,
|
||||
`const savedRevision=${JSON.stringify(saved.rev)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"const original=await core.localDatabase.localDatabase.get(id);",
|
||||
"if(original._rev!==savedRevision) throw new Error('Size repair source changed before fixture setup');",
|
||||
"const wrong=await core.localDatabase.putRaw({...original,size:expectedSize+7});",
|
||||
"if(!wrong.ok) throw new Error('Could not create size-only Metadata fixture');",
|
||||
"core.localDatabase.clearCaches();",
|
||||
"const revision=await core.localDatabase.localDatabase.get(id);",
|
||||
"return JSON.stringify({revisionWithWrongSize:revision._rev,expectedSize,expectedPath:path,expectedCtime:original.ctime,expectedMtime:original.mtime,expectedChunks:original.children??[]});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readSizeRepairState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SizeRepairState> {
|
||||
return await evalObsidianJson<SizeRepairState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(sizeRepairPath)};`,
|
||||
`const expected=${JSON.stringify(sizeRepairContent)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"const raw=await core.localDatabase.localDatabase.get(id,{revs:true});",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
|
||||
"if(!entry) throw new Error('Corrected size repair entry cannot be decoded');",
|
||||
"const body=await new Blob(Array.isArray(entry.data)?entry.data:[entry.data]).text();",
|
||||
"const children=raw.children??[];",
|
||||
"const rows=children.length?await core.localDatabase.allDocsRaw({keys:children}):{rows:[]};",
|
||||
"const available=rows.rows.filter(row=>row.value?.rev&&!row.value?.deleted).length===children.length;",
|
||||
"const provenance=await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(path);",
|
||||
"const revisions=raw._revisions??{start:0,ids:[]};",
|
||||
"return JSON.stringify({revision:raw._rev,size:raw.size,path:raw.path,ctime:raw.ctime,mtime:raw.mtime,chunks:children,bodyMatches:body===expected,chunksAvailable:available,historyStart:revisions.start,historyIds:revisions.ids,provenanceRevision:provenance?.revision??null});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function createBrokenConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
@@ -210,16 +297,16 @@ async function readFileReflectionProvenance(
|
||||
);
|
||||
}
|
||||
|
||||
function repairCard(settings: Locator): Locator {
|
||||
return settings.locator(".sls-repair-result").filter({ hasText: path });
|
||||
function repairCard(settings: Locator, targetPath = path): Locator {
|
||||
return settings.locator(".sls-repair-result").filter({ hasText: targetPath });
|
||||
}
|
||||
|
||||
function revisionCard(settings: Locator, revision: string): Locator {
|
||||
return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision });
|
||||
function revisionCard(settings: Locator, revision: string, targetPath = path): Locator {
|
||||
return repairCard(settings, targetPath).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", {
|
||||
async function openRevisionActionMenu(page: Page, settings: Locator, revision: string, targetPath = path): Promise<Locator> {
|
||||
const actionButton = revisionCard(settings, revision, targetPath).getByRole("button", {
|
||||
name: `More actions for revision ${revision}`,
|
||||
exact: true,
|
||||
});
|
||||
@@ -250,8 +337,8 @@ async function openRevisionActionMenu(page: Page, settings: Locator, revision: s
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise<void> {
|
||||
const menu = await openRevisionActionMenu(page, settings, revision);
|
||||
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string, targetPath = path): Promise<void> {
|
||||
const menu = await openRevisionActionMenu(page, settings, revision, targetPath);
|
||||
const item = menu.getByText(action, { exact: true });
|
||||
await item.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await item.click({ timeout: uiTimeoutMs });
|
||||
@@ -317,6 +404,7 @@ async function main(): Promise<void> {
|
||||
await createAndOpenBaseFile(cliBinary, session.cliEnv);
|
||||
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv);
|
||||
const sizeRepairFixture = await createSizeRepairFixture(cliBinary, session.cliEnv);
|
||||
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
|
||||
const healthyDeletionProvenance = await readFileReflectionProvenance(
|
||||
cliBinary,
|
||||
@@ -347,7 +435,8 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const sizeRepairPort = session.remoteDebuggingPort;
|
||||
await withObsidianPage(sizeRepairPort, async (page) => {
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const settings = await settingsNavigator.openPage("Hatch");
|
||||
const verifySetting = settings.locator(".setting-item").filter({
|
||||
@@ -694,8 +783,73 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
await withObsidianPage(sizeRepairPort, async (page) => {
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const settings = await settingsNavigator.openPage("Hatch");
|
||||
const verifySetting = settings.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Inspect conflicts and file/database differences", {
|
||||
exact: true,
|
||||
}),
|
||||
});
|
||||
await verifySetting.getByRole("button", { name: "Begin inspection", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const card = repairCard(settings, sizeRepairPath);
|
||||
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const winner = revisionCard(settings, sizeRepairFixture.revisionWithWrongSize, sizeRepairPath);
|
||||
await winner.getByText(/^📦 DB:.*Δsize/u).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const menu = await openRevisionActionMenu(
|
||||
page,
|
||||
settings,
|
||||
sizeRepairFixture.revisionWithWrongSize,
|
||||
sizeRepairPath
|
||||
);
|
||||
await menu.getByText("Correct recorded file size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const screenshot = await captureObsidianElement(
|
||||
sizeRepairPort,
|
||||
"revision-repair-size-correction-actions.png",
|
||||
(currentPage) => currentPage.locator(".menu:visible").last()
|
||||
);
|
||||
console.log(`Size correction actions screenshot: ${screenshot}`);
|
||||
await page.keyboard.press("Escape");
|
||||
await selectRevisionAction(
|
||||
page,
|
||||
settings,
|
||||
sizeRepairFixture.revisionWithWrongSize,
|
||||
"Correct recorded file size",
|
||||
sizeRepairPath
|
||||
);
|
||||
await card.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
const sizeRepairState = await readSizeRepairState(cliBinary, session.cliEnv);
|
||||
const correctedRevisionGeneration = Number(sizeRepairState.revision.split("-")[0]);
|
||||
const expectedParentId = sizeRepairFixture.revisionWithWrongSize.split("-").slice(1).join("-");
|
||||
if (
|
||||
sizeRepairState.revision === sizeRepairFixture.revisionWithWrongSize ||
|
||||
sizeRepairState.size !== sizeRepairFixture.expectedSize ||
|
||||
sizeRepairState.path !== sizeRepairFixture.expectedPath ||
|
||||
sizeRepairState.ctime !== sizeRepairFixture.expectedCtime ||
|
||||
sizeRepairState.mtime !== sizeRepairFixture.expectedMtime ||
|
||||
JSON.stringify(sizeRepairState.chunks) !== JSON.stringify(sizeRepairFixture.expectedChunks) ||
|
||||
!sizeRepairState.bodyMatches ||
|
||||
!sizeRepairState.chunksAvailable ||
|
||||
sizeRepairState.historyStart !== correctedRevisionGeneration ||
|
||||
sizeRepairState.historyIds[1] !== expectedParentId ||
|
||||
sizeRepairState.provenanceRevision !== sizeRepairState.revision
|
||||
) {
|
||||
throw new Error(
|
||||
`Size repair did not preserve the exact file body, Chunks, Metadata fields, ancestry, and provenance: ${JSON.stringify({
|
||||
fixture: sizeRepairFixture,
|
||||
state: sizeRepairState,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
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 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; discarded only the selected branch after confirmation; and corrected a Vault-matching Metadata size through Verify while preserving its body, Chunks, timestamps, ancestry, and provenance."
|
||||
);
|
||||
console.log(`Repair card screenshot: ${repairCardScreenshot}`);
|
||||
console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# P2P diagnostic UI checks
|
||||
|
||||
These checks use Node.js 24 or newer, the existing Playwright dependency, and a
|
||||
Chromium installation.
|
||||
The Log Pane fixture starts with a buffered diagnostic message and suppresses
|
||||
ordinary logger output, modelling a report opened while ordinary INFO messages
|
||||
are hidden. It checks initial display, later events, and unmounting. The status-card fixture
|
||||
also checks three summary rows at a narrow viewport and the existing Log Pane
|
||||
link. The full status-pane fixture exercises accepted, session-accepted, new,
|
||||
and denied peers at 320, 390, and 768 px, including long names. It checks bounds,
|
||||
complete action labels, and existing sync/admission callbacks. The card check
|
||||
also verifies initially collapsed diagnostic details, keyboard toggling, and
|
||||
updates received while collapsed. Host view, Menu, and SetupManager capabilities
|
||||
are stubbed; the Svelte components and shared log store are real, while P2P and
|
||||
Obsidian capabilities are fixtures. Add `?preview` to the status-pane URL for a
|
||||
shorter illustrative peer list.
|
||||
|
||||
From the repository root, start the fixture server:
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=3072 node node_modules/vite/bin/vite.js --config test/p2p-diagnostics/vite.config.mjs
|
||||
```
|
||||
|
||||
In another terminal, run the focused browser check:
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=3072 node --test --test-concurrency=1 test/p2p-diagnostics/log-pane.test.ts test/p2p-diagnostics/status-pane.test.ts
|
||||
```
|
||||
|
||||
The default endpoint is `http://127.0.0.1:5157`. Set `DIAG_UI_BASE_URL` when using
|
||||
another endpoint. Stop the fixture server after the check. This browser fixture
|
||||
does not replace verification in Obsidian.
|
||||
@@ -0,0 +1,3 @@
|
||||
// Host-only controls are not exercised by the browser layout fixture.
|
||||
export class Menu {}
|
||||
export class SetupManager {}
|
||||
@@ -0,0 +1 @@
|
||||
export const VIEW_TYPE_LOG = "log-log";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
test("Log Pane shows diagnostics recorded before it opens and follows subsequent events", async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const base = process.env.DIAG_UI_BASE_URL ?? "http://127.0.0.1:5157";
|
||||
await page.goto(`${base}/test/p2p-diagnostics/log-pane/index.html`);
|
||||
await page.waitForSelector(".logpane");
|
||||
assert.deepEqual(errors, [], "Fixture must load without browser errors");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".log")?.textContent?.includes("Earlier connection failure"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
await page.click("#append");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".log")?.textContent?.includes("Later connection recovery"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
assert.match((await page.locator(".log").textContent()) ?? "", /Earlier connection failure/);
|
||||
await page.click("#unmount");
|
||||
await page.waitForSelector(".logpane", { state: "detached" });
|
||||
await page.click("#append");
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("P2P Stats has three diagnostic rows and opens the existing Log Pane", async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const base = process.env.DIAG_UI_BASE_URL ?? "http://127.0.0.1:5157";
|
||||
await page.goto(`${base}/test/p2p-diagnostics/status-card/index.html`);
|
||||
await page.waitForSelector(".diag-details", { state: "attached", timeout: 3_000 });
|
||||
assert.equal(await page.locator(".diag-grid").isVisible(), true, "Stats remains visible");
|
||||
assert.equal(await page.getByRole("button", { name: "Open log", exact: true }).isVisible(), false);
|
||||
await page.click("#complete");
|
||||
assert.equal(await page.locator(".diag-summary").isVisible(), false, "New events do not expand the log");
|
||||
await page.locator(".diag-details > summary").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(".diag-summary");
|
||||
assert.deepEqual(errors, [], "Fixture must load without browser errors");
|
||||
const rows = page.locator(".diag-summary > div");
|
||||
assert.equal(await rows.count(), 3);
|
||||
assert.match((await rows.nth(0).textContent()) ?? "", /Room opened; relay replies not observed/);
|
||||
assert.match((await rows.nth(1).textContent()) ?? "", /Peer left/);
|
||||
assert.match((await rows.nth(2).textContent()) ?? "", /Pull completed/);
|
||||
assert.equal(
|
||||
await page.locator(".diag-summary").evaluate((element) => getComputedStyle(element).fontSize),
|
||||
"12px"
|
||||
);
|
||||
if (process.env.DIAG_UI_SCREENSHOT) {
|
||||
await page.screenshot({ path: process.env.DIAG_UI_SCREENSHOT, fullPage: true });
|
||||
}
|
||||
await page.click("#complete");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".diag-summary")?.textContent?.includes("Pull completed"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
await page.locator(".diag-details > summary").click();
|
||||
assert.equal(await page.locator(".diag-grid").isVisible(), true);
|
||||
assert.equal(await page.locator(".diag-summary").isVisible(), false);
|
||||
await page.locator(".diag-details > summary").focus();
|
||||
await page.keyboard.press("Space");
|
||||
await page.getByRole("button", { name: "Open log", exact: true }).click();
|
||||
await page.waitForSelector(".logpane");
|
||||
assert.equal(await page.locator("#opened").textContent(), "log-log");
|
||||
assert.match((await page.locator(".log").textContent()) ?? "", /Earlier connection failure/);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Log Pane diagnostic fixture</title></head>
|
||||
<body><button id="append">Append event</button><button id="unmount">Unmount</button><div id="app"></div><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { mount, unmount } from "svelte";
|
||||
import LogPane from "../../../src/modules/features/Log/LogPane.svelte";
|
||||
import { logMessages } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
|
||||
// Simulate ordinary INFO messages being hidden while a prior diagnostic is visible.
|
||||
setGlobalLogFunction(() => undefined);
|
||||
logMessages.value = ["[P2P Diag] Earlier connection failure"];
|
||||
const component = mount(LogPane, { target: document.querySelector("#app")!, props: { close: () => undefined } });
|
||||
document.querySelector<HTMLButtonElement>("#append")!.onclick = () => {
|
||||
logMessages.value = [...logMessages.value, "[P2P Diag] Later connection recovery"];
|
||||
};
|
||||
document.querySelector<HTMLButtonElement>("#unmount")!.onclick = () => {
|
||||
void unmount(component);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>P2P diagnostic summary fixture</title>
|
||||
<style>:root { --font-ui-small: 12px; --text-muted: #666; --size-4-1: 4px; --size-4-2: 8px; } body { margin: 8px; font: 14px sans-serif; }</style></head>
|
||||
<body><button id="complete">Complete pull</button><div id="opened"></div><div id="app"></div><div id="logapp"></div><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mount } from "svelte";
|
||||
import Card from "../../../src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte";
|
||||
import LogPane from "../../../src/modules/features/Log/LogPane.svelte";
|
||||
import { eventHub } from "../../../src/common/events";
|
||||
import { EVENT_SERVER_STATUS } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { logMessages } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
import type { P2PDiagnosticEvent } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
|
||||
setGlobalLogFunction(() => undefined);
|
||||
const timestamp = 1_700_000_000_000;
|
||||
const listeners = new Set<(event: P2PDiagnosticEvent) => void>();
|
||||
const settings = { P2P_useDiagRTC: true, P2P_roomID: "fixture-room" };
|
||||
const p2p = {
|
||||
transportLifecycle: { connect: async () => undefined, disconnect: async () => undefined },
|
||||
changeRelay: { enableBroadcastChanges() {}, disableBroadcastChanges() {} },
|
||||
diagnostics: {
|
||||
getDiagnosticSnapshot: () => ({
|
||||
relay: { category: "relay", code: "room-opened", reason: "relay-details-unobserved", timestamp },
|
||||
peer: { category: "peer", code: "peer-left", peerId: "departed-peer", timestamp },
|
||||
}),
|
||||
onDiagnosticEvent: (listener: (event: P2PDiagnosticEvent) => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
requestStatus: () =>
|
||||
eventHub.emitEvent(EVENT_SERVER_STATUS, {
|
||||
isConnected: true,
|
||||
knownAdvertisements: [],
|
||||
serverPeerId: "fixture-self",
|
||||
roomId: "fixture-room",
|
||||
diag: {
|
||||
totalNewConnections: 0,
|
||||
totalSuccessfulConnections: 0,
|
||||
totalFailedConnections: 0,
|
||||
totalClosedConnections: 0,
|
||||
details: {},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
services: {
|
||||
setting: { currentSettings: () => settings },
|
||||
API: {
|
||||
showWindow: async (view: string) => {
|
||||
document.querySelector("#opened")!.textContent = view;
|
||||
logMessages.value = ["[P2P Diag] Earlier connection failure"];
|
||||
mount(LogPane, { target: document.querySelector("#logapp")!, props: { close() {} } });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mount(Card, { target: document.querySelector("#app")!, props: { p2p: p2p as never, core: core as never } });
|
||||
document.querySelector<HTMLButtonElement>("#complete")!.onclick = () => {
|
||||
for (const listener of listeners)
|
||||
listener({ category: "sync", code: "pull-completed", timestamp: timestamp + 1_000 });
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
for (const width of [320, 390, 768]) {
|
||||
test(`P2P pane controls fit a ${width}px viewport`, async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: 844 },
|
||||
colorScheme: width === 320 ? "dark" : "light",
|
||||
});
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const base = process.env.DIAG_UI_BASE_URL ?? "http://127.0.0.1:5157";
|
||||
await page.goto(`${base}/test/p2p-diagnostics/status-pane/index.html`);
|
||||
await page.waitForSelector(".peer-item");
|
||||
assert.deepEqual(errors, [], "Fixture must load without browser errors");
|
||||
const undersized = await page.locator(".peers-section button").evaluateAll((buttons) =>
|
||||
buttons
|
||||
.filter((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.width < 43.5 || rect.height < 43.5;
|
||||
})
|
||||
.map((button) => ({
|
||||
label: button.textContent?.trim(),
|
||||
width: button.getBoundingClientRect().width,
|
||||
height: button.getBoundingClientRect().height,
|
||||
}))
|
||||
);
|
||||
assert.deepEqual(undersized, [], "Peer buttons must have at least a 44px target at the default font size");
|
||||
const overflow = await page.locator(".peer-item").evaluateAll((items) =>
|
||||
items.flatMap((item, index) => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const style = getComputedStyle(item);
|
||||
const left = rect.left + parseFloat(style.paddingLeft) + parseFloat(style.borderLeftWidth);
|
||||
const right = rect.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth);
|
||||
return Array.from(item.querySelectorAll("button")).flatMap((button) => {
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return bounds.left < left - 1 || bounds.right > right + 1
|
||||
? [
|
||||
{
|
||||
peer: index,
|
||||
button: button.textContent?.trim(),
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
container: [left, right],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
})
|
||||
);
|
||||
assert.deepEqual(overflow, [], "Every peer action must stay inside its card content");
|
||||
const clipped = await page
|
||||
.locator(".peer-actions .action-button")
|
||||
.evaluateAll((buttons) =>
|
||||
buttons
|
||||
.filter((button) => button.scrollWidth > button.clientWidth + 1)
|
||||
.map((button) => button.textContent?.trim())
|
||||
);
|
||||
assert.deepEqual(clipped, [], "Peer action labels must remain readable in full");
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
"Long names must not cause horizontal page overflow"
|
||||
);
|
||||
assert.equal(await page.locator(".diag-grid").isVisible(), true);
|
||||
assert.deepEqual(await page.locator(".diag-item > span:last-child").allTextContents(), [
|
||||
"4",
|
||||
"2",
|
||||
"1",
|
||||
"1",
|
||||
]);
|
||||
if (process.env.DIAG_LAYOUT_SCREENSHOT_PREFIX) {
|
||||
await page.screenshot({
|
||||
path: `${process.env.DIAG_LAYOUT_SCREENSHOT_PREFIX}-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.locator(".peer-item").nth(0).getByRole("button", { name: "Replicate now", exact: true }).click();
|
||||
assert.deepEqual(JSON.parse((await page.locator("#action").textContent()) ?? "null"), {
|
||||
sync: "permanent-peer",
|
||||
});
|
||||
await page
|
||||
.locator(".peer-item")
|
||||
.nth(2)
|
||||
.getByRole("button", { name: "Allow in session", exact: true })
|
||||
.click();
|
||||
assert.deepEqual(JSON.parse((await page.locator("#action").textContent()) ?? "null"), {
|
||||
peerId: "new-peer",
|
||||
name: "New device",
|
||||
decision: true,
|
||||
isTemporary: true,
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>P2P status pane layout fixture</title>
|
||||
<style>
|
||||
:root { --font-ui-small: 12px; --text-muted: #666; --text-normal: #222; --divider-color: #bbb; --size-4-1: 4px; --size-4-2: 8px; --background-secondary: #f4f4f4; --background-tertiary: #ddd; --interactive-normal: #eee; --interactive-hover: #ddd; --background-modifier-success: #bdb; --background-modifier-border: #ddd; --background-modifier-error: #ecc; }
|
||||
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; --text-muted: #aaa; --text-normal: #ddd; --divider-color: #555; --background-secondary: #262626; --background-tertiary: #444; --interactive-normal: #333; --interactive-hover: #444; --background-modifier-success: #253f25; --background-modifier-border: #444; --background-modifier-error: #522; } }
|
||||
body { margin: 8px; font: 16px sans-serif; color: var(--text-normal); }
|
||||
#app { margin: 16px; }
|
||||
button, select { font: inherit; color: var(--text-normal); background: var(--interactive-normal); }
|
||||
button { padding: 4px 12px; white-space: nowrap; }
|
||||
</style></head>
|
||||
<body><div id="app"></div><output id="action"></output><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mount } from "svelte";
|
||||
import Pane from "../../../src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte";
|
||||
import { eventHub } from "../../../src/common/events";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
|
||||
setGlobalLogFunction(() => undefined);
|
||||
const settings = { P2P_useDiagRTC: true, P2P_roomID: "fixture-room", remoteConfigurations: {} };
|
||||
const knownAdvertisements = [
|
||||
{ peerId: "permanent-peer", name: "Phone", platform: "mobile", isAccepted: true },
|
||||
{
|
||||
peerId: "session-peer",
|
||||
name: "LongDeviceNameWithoutWordBreaks".repeat(4),
|
||||
platform: "desktop",
|
||||
isTemporaryAccepted: true,
|
||||
},
|
||||
{ peerId: "new-peer", name: "New device", platform: "mobile" },
|
||||
{ peerId: "denied-peer", name: "Denied device", platform: "mobile", isTemporaryAccepted: false },
|
||||
];
|
||||
if (new URLSearchParams(location.search).has("preview")) {
|
||||
knownAdvertisements.splice(1, 1);
|
||||
knownAdvertisements.splice(2, 1);
|
||||
knownAdvertisements[0].name = "Android phone";
|
||||
knownAdvertisements[1].name = "MacBook Pro (Living room)";
|
||||
}
|
||||
const record = (action: unknown) => {
|
||||
document.querySelector("#action")!.textContent = JSON.stringify(action);
|
||||
};
|
||||
const p2p = {
|
||||
transportLifecycle: { connect: async () => undefined, disconnect: async () => undefined },
|
||||
changeRelay: { enableBroadcastChanges() {}, disableBroadcastChanges() {} },
|
||||
peerAdmission: {
|
||||
makeDecision: async (decision: unknown) => record(decision),
|
||||
revokeDecision: async (decision: unknown) => record(decision),
|
||||
},
|
||||
targetedTransfer: { synchroniseWithPeer: async (peerId: string) => record({ sync: peerId }) },
|
||||
diagnostics: {
|
||||
getDiagnosticSnapshot: () => ({ peer: { category: "peer", code: "peer-left", timestamp: 1_700_000_000_000 } }),
|
||||
onDiagnosticEvent: () => () => undefined,
|
||||
requestStatus: () => {
|
||||
eventHub.emitEvent(EVENT_SERVER_STATUS, {
|
||||
isConnected: true,
|
||||
knownAdvertisements,
|
||||
serverPeerId: "fixture-self",
|
||||
roomId: "fixture-room",
|
||||
diag: {
|
||||
totalNewConnections: 4,
|
||||
totalSuccessfulConnections: 2,
|
||||
totalFailedConnections: 1,
|
||||
totalClosedConnections: 1,
|
||||
details: {},
|
||||
},
|
||||
} as never);
|
||||
eventHub.emitEvent(EVENT_P2P_REPLICATOR_STATUS, {
|
||||
replicatingFrom: [],
|
||||
replicatingTo: [],
|
||||
watchingPeers: [],
|
||||
isBroadcasting: false,
|
||||
} as never);
|
||||
},
|
||||
},
|
||||
};
|
||||
const core = { services: { setting: { currentSettings: () => settings }, API: { showWindow: async () => undefined } } };
|
||||
mount(Pane, { target: document.querySelector("#app")!, props: { p2p: p2p as never, core: core as never } });
|
||||
@@ -0,0 +1,21 @@
|
||||
import { mergeConfig } from "vite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import common from "../../vitest.config.common.ts";
|
||||
|
||||
const config = mergeConfig(common, {
|
||||
optimizeDeps: {
|
||||
entries: [
|
||||
"test/p2p-diagnostics/log-pane/index.html",
|
||||
"test/p2p-diagnostics/status-card/index.html",
|
||||
"test/p2p-diagnostics/status-pane/index.html",
|
||||
],
|
||||
},
|
||||
server: { host: "127.0.0.1", port: 5157, strictPort: true },
|
||||
});
|
||||
config.resolve.alias = {
|
||||
"@/modules/features/Log/LogPaneView": fileURLToPath(new URL("./log-pane-view.ts", import.meta.url)),
|
||||
"@/deps": fileURLToPath(new URL("./host-ui.ts", import.meta.url)),
|
||||
"@/modules/features/SetupManager": fileURLToPath(new URL("./host-ui.ts", import.meta.url)),
|
||||
...common.resolve.alias,
|
||||
};
|
||||
export default config;
|
||||
+44
@@ -12,6 +12,50 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Recovery
|
||||
|
||||
- Files saved while host size information is stale retain the correct content size, preventing an incorrect size record from blocking reflection to other devices. (#1324)
|
||||
- **Inspect conflicts and file/database differences** reports files whose recorded size differs from their readable content, and offers a guarded **Correct recorded file size** action when that exact content matches the Vault without conflicts. (#1324)
|
||||
|
||||
### Improved
|
||||
|
||||
#### P2P synchronisation
|
||||
|
||||
- P2P status shows Stats with diagnostic details collapsed by default. Expand
|
||||
'Diagnostic summary' to see the latest relay, peer, and synchronisation observations.
|
||||
With Diag enabled, their events also appear in the Log Pane without updating
|
||||
the editor status or producing notifications.
|
||||
- Unobserved information is labelled explicitly. Relay rejection messages are
|
||||
unavailable through the current transport API; an open room does not confirm
|
||||
that a relay accepted its announcements.
|
||||
|
||||
- Larger peer controls wrap onto additional lines in narrow panes, keeping action labels
|
||||
readable alongside long device names.
|
||||
|
||||
- P2P progress shows sent (↑) and received (↓) document revision counts, including
|
||||
Metadata and Chunks. Directional ⏩ labels show comparison progress even when no
|
||||
transfer is needed, and disappear three seconds after their last count change.
|
||||
- Counts appear in the P2P panes and editor status. Only P2P also uses them in
|
||||
the main status bar. Older peers retain a sequence display labelled `seq`.
|
||||
|
||||
- P2P connections include upstream improvements to peer discovery after relay
|
||||
reconnection, recovery from missed signalling messages, and stale connections
|
||||
after rejoining or waiting. Connection offers are created on demand to reduce
|
||||
idle resource use.
|
||||
- Update every device in the P2P group, including CLI and WebPeer clients,
|
||||
before resuming P2P synchronisation. The updated transport uses Trystero
|
||||
0.26.0 and cannot exchange data with the previous transport.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Synchronisation
|
||||
|
||||
- New saves record the byte length of the stored content, preventing stale host
|
||||
file-size information from causing a receiving device to reject the file. (#1324)
|
||||
- Existing incorrect Metadata needs explicit repair.
|
||||
|
||||
## 1.0.45
|
||||
|
||||
5th November, 2026
|
||||
|
||||
Reference in new issue
Block a user