mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-26 19:13:20 +00:00
Merge main and verify catalogue Reload and Apply in Obsidian
This commit is contained in:
commit
b2fc839584
117 files changed
+7215
-744
No files matched your search
@@ -166,6 +166,14 @@ Legacy Modules remain grouped by directory:
|
||||
- `features/` contains optional features; and
|
||||
- `extras/` contains development and testing tools.
|
||||
|
||||
### UI commands and dialogue ownership
|
||||
|
||||
Declare shared UI requests with `declareUICommand<Request, Result>` from `@vrtmrz/livesync-commonlib/ui-commands`. The host's `services.UI.commands` registry owns one handler per declaration. A serviceFeature registers the handler, releases it on unload, and owns the associated dialogue lifetime. Callers receive a typed result or an explicit `unavailable` result; handler failures propagate.
|
||||
|
||||
`OPEN_JOINED_DEVICES` is shared by Remote Configuration, version Notices, and the command palette. `useJoinedDevicesFeature` registers it once and gives the dialogue narrow refresh, forget, and cancellation callbacks. Each remote operation uses a settings snapshot and releases its finite resource in `finally`. Results from an old remote configuration or a closed dialogue are discarded. A write already sent to the captured remote can still complete after the dialogue closes.
|
||||
|
||||
The registry belongs to one UIService instance. Reuse the same declaration object at each call site, and keep active UI cleanup in the feature and dialogue owner. `UIService` disposes the registry when the host's context emits the plug-in-unloaded event.
|
||||
|
||||
### Key Architectural Components
|
||||
|
||||
- **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper
|
||||
@@ -204,6 +212,9 @@ The [multiple-device conflict test procedure](test/README.md#multiple-device-con
|
||||
|
||||
### File Structure Conventions
|
||||
|
||||
- **Class modules**: Start a new or renamed TypeScript or JavaScript filename with an upper-case letter when the file contains a class with the same name.
|
||||
- **Function modules**: Start the filename with a lower-case letter when the module primarily exports functions, such as `initialFetchFastForward.ts`. Follow the surrounding filename style for the rest of the basename. Corresponding test files use the same basename.
|
||||
- **Existing files**: Retain established names for unrelated files and separate conventions for components or other file types.
|
||||
- **Platform-specific code**: Use `.platform.ts` suffix (replaced with `.obsidian.ts` in production builds via esbuild)
|
||||
- **Development code**: Use `.dev.ts` suffix (replaced with `.prod.ts` in production)
|
||||
- **Path aliases**: `@/*` maps to `src/*`; Commonlib uses package exports rather than a source alias
|
||||
@@ -309,6 +320,8 @@ Existing legacy Modules continue to register their handlers in `onBindFunction()
|
||||
database and Replicator readiness. See
|
||||
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
|
||||
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
|
||||
- Inject `@vrtmrz/value-store` at host composition. `setting.deviceValues` uses exact device-local keys; `setting.config` and the Service Hub `config` share the existing Vault and database-suffix scope. Await reads, writes, and deletions before advancing the workflow. Browser and Obsidian hosts retain their Web Storage keys, and the CLI retains its existing flat JSON string map.
|
||||
- Seed the settings tab with `await settingTab.loadSettings()` before registration. Its editing buffer remains synchronous for Obsidian rendering; storage failures must not replace a previously loaded value with an empty default.
|
||||
|
||||
### Database Operations
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ recovery guidance, or diagnostics intended for users.
|
||||
- **ID recovery code:** A versioned text form of the saved ID key which can be
|
||||
shown on the current device and imported without deriving a different key.
|
||||
Treat it as a secret.
|
||||
- **Joined devices:** The dialogue showing device records held by the active
|
||||
CouchDB or Object Storage remote, with the current device first. Records
|
||||
describe participation, not current online activity. **Forget device** removes
|
||||
an obsolete device record; it does not delete Vault files or revoke credentials.
|
||||
- **JWT Authentication:** An experimental CouchDB authentication option which
|
||||
uses a JSON Web Token instead of standard credentials. It requires a private
|
||||
key or secret, algorithm, expiry duration, subject, and key ID.
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
|
||||
|
||||
## 1.0.35
|
||||
|
||||
5th October, 2026
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can continue synchronising after rebuilding an encrypted CouchDB remote, including when Hidden File Sync and Customisation Sync are enabled. (#1234)
|
||||
- This plug-in reads the rebuilt data without reusing HTTP responses encrypted with the previous Security Seed.
|
||||
|
||||
### Customisation Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Customisation Sync now waits until this plug-in is ready before handling configuration file events, and applying settings while synchronisation is suspended no longer scans configuration files. (#1220)
|
||||
- Resuming synchronisation scans once and restarts the periodic scan when enabled.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Thank you for your contributions!
|
||||
|
||||
- @kimjansheden (#1220)
|
||||
|
||||
## 1.0.34
|
||||
|
||||
2nd October, 2026
|
||||
|
||||
+40
-5
@@ -234,6 +234,16 @@ Setting key: remoteType
|
||||
|
||||
The active connection type. This is automatically projected to the legacy configuration when you activate a connection profile.
|
||||
|
||||
### Joined devices
|
||||
|
||||
Choose **Review devices** under **Joined devices** to read the device records on the active CouchDB or Object Storage connection. The same **Review devices** action is available from the command palette and version Notices. **Refresh** reads the remote again. This device appears first, including when its record is absent from the remote. Opening or refreshing this dialogue does not create a missing CouchDB database.
|
||||
|
||||
The list shows each device ID, recorded platform, plug-in version, app version, and last recorded connection time. Older clients may have incomplete information. These records do not establish whether a device is online. On a locked remote, a device outside the accepted list is shown as **Locked out**.
|
||||
|
||||
Before synchronisation starts, and when this dialogue refreshes, Self-hosted LiveSync checks for recorded plug-in versions newer or older than its own. These Notices recommend reviewing and aligning plug-in versions across devices; they are advisory and do not stop synchronisation. The dialogue explains that synchronisation between different versions is generally safe unless the connection is explicitly blocked, but may be less efficient or use more storage. P2P synchronisation may also be unavailable. Unknown development version labels are left unclassified. Automatic checks run at most once per minute when synchronisation starts; they do not poll a continuously running connection.
|
||||
|
||||
**Forget device** removes another device's participation records after confirmation. It preserves the remote lock state and every other device. It does not delete Vault files or revoke credentials. A device can register again on an unlocked remote; on a locked remote, the existing recovery process applies. The result is checked after saving to CouchDB; if it cannot be confirmed, refresh the list and try again. CouchDB stores these records together in a local document which does not reject simultaneous updates, so changes from other devices can be overwritten. Pausing synchronisation on the other devices avoids that overlap. Object Storage uses conditional updates; if the server cannot support them, the record is retained.
|
||||
|
||||
### 2. Notification
|
||||
|
||||
#### Notify when the estimated remote storage size exceeds on start up
|
||||
@@ -750,11 +760,13 @@ If enabled, the notification of hidden files change will be suppressed.
|
||||
|
||||
#### Synchronising files
|
||||
|
||||
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.
|
||||
Setting key: syncOnlyRegEx
|
||||
(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.
|
||||
|
||||
#### Non-Synchronising files
|
||||
|
||||
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.
|
||||
Setting key: syncIgnoreRegEx
|
||||
(RegExp) Skip changes to matching normal and hidden files, locally and remotely.
|
||||
|
||||
#### Maximum file size
|
||||
|
||||
@@ -783,6 +795,8 @@ When a saved setting changes whether a normal file can be reflected, LiveSync re
|
||||
|
||||
Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files.
|
||||
|
||||
**Automatic** delegates an item's files to Hidden File Sync. Enable and initialise Hidden File Sync in **Sync Settings** on this device first, after reviewing its [target and ignore patterns](./tips/hidden-file-sync.md#review-the-file-selection). Choosing Automatic while Hidden File Sync is disabled displays a Notice and leaves the current mode unchanged. Existing Automatic assignments, including those imported from another device, remain saved while Hidden File Sync is disabled.
|
||||
|
||||
### 1. Customisation Sync
|
||||
|
||||
#### Device name
|
||||
@@ -936,6 +950,18 @@ Setting key: hashCacheMaxAmount
|
||||
|
||||
Setting key: customChunkSize
|
||||
|
||||
New setup uses 0 for every remote type. With V3 Rabin–Karp, this gives binary
|
||||
Chunks a 100 KiB maximum, a 25 KiB minimum, and an approximate 25 KiB candidate
|
||||
gap. The candidate gap is not the mean Chunk length. Text below 4 MiB retains
|
||||
adaptive smaller boundaries; large text uses the binary thresholds with
|
||||
UTF-8-safe cuts.
|
||||
|
||||
Larger values can reduce document counts for mostly immutable binary files,
|
||||
but can increase the new content stored after an edit. Existing explicit and
|
||||
agreed values are retained. Updating the plug-in does not re-chunk the Vault;
|
||||
editing, forced writes, Chunk recreation, or a full Rebuild can create new
|
||||
boundaries while existing content remains readable.
|
||||
|
||||
#### Chunk Splitter
|
||||
|
||||
Setting key: chunkSplitterVersion
|
||||
@@ -957,7 +983,10 @@ If this enabled, chunks will be split into semantically meaningful segments. Not
|
||||
#### Fetch chunks on demand
|
||||
|
||||
Setting key: readChunksOnline
|
||||
(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.
|
||||
If enabled, this plug-in obtains missing Chunks when they are needed instead
|
||||
of including all Chunks in ordinary pull replication. New setup retains the
|
||||
same Chunk-size recommendation of 0. This setting does not re-chunk content
|
||||
already stored by another device.
|
||||
|
||||
#### Batch size of on-demand fetching
|
||||
|
||||
@@ -1010,12 +1039,18 @@ The setting remains disabled by default because the measured storage and transfe
|
||||
#### Batch size
|
||||
|
||||
Setting key: batch_size
|
||||
Number of changes to sync at a time. Defaults to 50. Minimum is 2.
|
||||
Number of changes in an ordinary CouchDB replication batch. The schema default
|
||||
is 25, and the minimum is 2. Changing this value alone preserves the
|
||||
replication checkpoint. Larger counts can reduce round trips, but do not bound
|
||||
the final request size or memory use.
|
||||
|
||||
#### Batch limit
|
||||
|
||||
Setting key: batches_limit
|
||||
Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.
|
||||
Number of changes batches which PouchDB can read ahead. The schema default is
|
||||
25, and the minimum is 2. This does not create parallel body transfers;
|
||||
PouchDB processes one current replication batch at a time. It does not control
|
||||
the separate P2P or Object Storage transfer units.
|
||||
|
||||
#### Use timeouts instead of heartbeats
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export secret_key=<SECRET KEY>
|
||||
export bucket=vault-data
|
||||
export region=auto
|
||||
export bucket_prefix=my-vault
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
@@ -128,6 +128,9 @@ export database=obsidiannotes
|
||||
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> In fish, `hostname` is read-only and cannot be exported. Pass the values through `env` instead: `curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | env hostname=http://localhost:5984 username=<...> password=<...> database=obsidiannotes bash`.
|
||||
|
||||
If it results like the following:
|
||||
```
|
||||
CouchDB provisioning completed.
|
||||
@@ -173,20 +176,20 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
|
||||
|
||||
### 1. Generate the setup URI on a desktop device or server
|
||||
|
||||
The `username` and `password` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
|
||||
The `LIVESYNC_USER` and `LIVESYNC_PASS` here are the credentials which Self-hosted LiveSync will store for routine access to this database. They may be the administrator credentials from step 2. If you have separately configured a CouchDB account with the required access to this database, use that account instead. Neither the provisioning wrapper nor the Setup URI generator creates that separate account.
|
||||
|
||||
```bash
|
||||
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com
|
||||
export database=obsidiannotes
|
||||
export username=<INSERT COUCHDB USERNAME FOR LIVESYNC>
|
||||
export password=<INSERT THE COUCHDB PASSWORD>
|
||||
export passphrase=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_HOST=https://tiles-photograph-routine-groundwater.trycloudflare.com
|
||||
export LIVESYNC_DB=obsidiannotes
|
||||
export LIVESYNC_USER=<INSERT COUCHDB USERNAME FOR LIVESYNC>
|
||||
export LIVESYNC_PASS=<INSERT THE COUCHDB PASSWORD>
|
||||
export LIVESYNC_PASSPHRASE=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<INSERT A SEPARATE SETUP URI PASSPHRASE> # Optional
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> `passphrase` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel.
|
||||
> `LIVESYNC_PASSPHRASE` protects the synchronised Vault data with end-to-end encryption. `uri_passphrase` protects only the Setup URI. Use different values, store both securely, and do not send the Setup URI and its passphrase through the same channel.
|
||||
>
|
||||
> If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once.
|
||||
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ export remote_type=p2p
|
||||
export p2p_relays=wss://relay.example.com
|
||||
export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted
|
||||
export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted
|
||||
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export LIVESYNC_PASSPHRASE=<A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
|
||||
```
|
||||
|
||||
@@ -32,6 +32,10 @@ Enabling Hidden File Sync requires an initialisation direction:
|
||||
|
||||
Prefer a narrow target list. Device-specific workspace state and another plug-in's credentials are usually poor candidates for cross-device synchronisation.
|
||||
|
||||
`Normal Files` → `Synchronising files` limits ordinary files only. Hidden File Sync uses its own `Target patterns`. The `Non-Synchronising files` patterns under `Normal Files` also exclude matching hidden files, so review them alongside the hidden-file selectors.
|
||||
|
||||
If an earlier version skipped hidden files because of the normal-file allow-list, repeat Hidden File Sync initialisation after updating. A routine scan can skip unchanged files which were already checked. Back up the Vault and choose the intended source direction as described above.
|
||||
|
||||
Target patterns also control directory traversal. A pattern must therefore match each parent directory as well as the intended files. For example, the following pattern admits the `.obsidian` parent and only its `snippets` subtree:
|
||||
|
||||
```text
|
||||
@@ -68,6 +72,12 @@ Hidden File Sync must be enabled independently after ordinary setup on each devi
|
||||
|
||||
If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately.
|
||||
|
||||
## Automatic in Customisation Sync
|
||||
|
||||
**Automatic** delegates an item's files to Hidden File Sync. Enable Hidden File Sync on this device through the steps above before choosing Automatic. If Hidden File Sync is disabled, the choice displays a Notice without changing the item's mode or starting initialisation. Previously saved Automatic assignments remain saved, so this prerequisite also applies after importing settings from another device.
|
||||
|
||||
Once Hidden File Sync is enabled, choose the item's initial action in Customisation Sync. Selecting a whole plug-in applies that action to both its **MAIN** and **DATA** selections.
|
||||
|
||||
## Related settings
|
||||
|
||||
The [settings reference](../settings.md#7-hidden-files-advanced) describes periodic scanning, scanning before replication, notification suppression, selectors, and overwrite patterns. Change those controls only after the basic Hidden File Sync path is working.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.39",
|
||||
"version": "1.0.40",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+23
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.39",
|
||||
"version": "1.0.40",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.39",
|
||||
"version": "1.0.40",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,12 +23,13 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.44",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.0",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it": "^14.3.3",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
@@ -4115,9 +4116,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.44",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.44.tgz",
|
||||
"integrity": "sha512-GjXNSLTnWLZzdgrhW3sIOsLqGLP4iMgypPMtcBc2Ju2vMq64TRA1Llfoya1KRIeu6m0G7Xu3ZtkWARTRcHkYYg==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.0.tgz",
|
||||
"integrity": "sha512-K24Pkke+FTjZI7DjIV7qkZk+BmC9VebN3da+2cVD9SWOgoGl7OvDyXTHDafbrmmM4vi4ydaydAEh9glO1xHF6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
@@ -4128,6 +4129,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@trystero-p2p/nostr": "0.25.3",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"events": "^3.3.0",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -4139,9 +4141,11 @@
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||
"pouchdb-adapter-memory": "^9.0.0",
|
||||
"pouchdb-checkpointer": "9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
"pouchdb-errors": "^9.0.0",
|
||||
"pouchdb-find": "^9.0.0",
|
||||
"pouchdb-generate-replication-id": "9.0.0",
|
||||
"pouchdb-mapreduce": "^9.0.0",
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
@@ -4210,6 +4214,12 @@
|
||||
"integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vrtmrz/value-store": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/value-store/-/value-store-0.1.0.tgz",
|
||||
"integrity": "sha512-M62f1j0bnJkqpgI5sYxnkXUAOSDEwQbYrLf+w58gCCm4sjIXDmFzDJd2duXabRbktBlACt9P99yjN/9gOAPxrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abstract-leveldown": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz",
|
||||
@@ -8253,9 +8263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.3.2",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz",
|
||||
"integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==",
|
||||
"version": "14.3.3",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz",
|
||||
"integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11398,7 +11408,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.39-cli",
|
||||
"version": "1.0.40-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -11423,7 +11433,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.39-webapp",
|
||||
"version": "1.0.40-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
},
|
||||
@@ -11435,7 +11445,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.39-webpeer",
|
||||
"version": "1.0.40-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
},
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.39",
|
||||
"version": "1.0.40",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -57,6 +57,8 @@
|
||||
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
|
||||
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
|
||||
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||
"test:e2e:obsidian:joined-devices": "tsx test/e2e-obsidian/scripts/joined-devices.ts",
|
||||
"test:e2e:obsidian:joined-devices-object-storage": "tsx test/e2e-obsidian/scripts/joined-devices.ts --object-storage",
|
||||
"test:e2e:obsidian:database-diagnostics": "tsx test/e2e-obsidian/scripts/database-diagnostics.ts",
|
||||
"test:e2e:obsidian:database-diagnostics-couchdb": "tsx test/e2e-obsidian/scripts/database-diagnostics-couchdb.ts",
|
||||
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
|
||||
@@ -92,6 +94,7 @@
|
||||
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
|
||||
"test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts",
|
||||
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
||||
"test:e2e:obsidian:hidden-file-selectors": "tsx test/e2e-obsidian/scripts/hidden-file-selectors.ts",
|
||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||
"test:e2e:obsidian:customisation-catalogue-reload": "tsx test/e2e-obsidian/scripts/customisation-catalogue-reload.ts",
|
||||
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
||||
@@ -194,12 +197,13 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.44",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.0",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.3.2",
|
||||
"markdown-it": "^14.3.3",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
|
||||
@@ -11,7 +11,7 @@ import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||
API: InjectableAPIService<T>;
|
||||
appLifecycle: AppLifecycleService<T>;
|
||||
config: ConfigService<T>;
|
||||
config: IConfigService;
|
||||
control: ControlService<T>;
|
||||
replicator: ReplicatorService<T>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
@@ -24,10 +23,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||
import {
|
||||
LiveSyncBrowserAPIService,
|
||||
type LiveSyncBrowserAPIServiceOptions,
|
||||
} from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
|
||||
export interface LiveSyncBrowserSettingsPersistence {
|
||||
@@ -43,6 +39,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
||||
}
|
||||
|
||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
deviceValues: ValueStore & ValueDeleter;
|
||||
context?: T;
|
||||
getSystemVaultName?: () => string;
|
||||
settings?: LiveSyncBrowserSettingsPersistence;
|
||||
@@ -51,35 +48,12 @@ export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
|
||||
}
|
||||
|
||||
class LiveSyncBrowserAppLifecycleService<
|
||||
T extends ServiceContext,
|
||||
> extends InjectableAppLifecycleService<T> {}
|
||||
class LiveSyncBrowserAppLifecycleService<T extends ServiceContext> extends InjectableAppLifecycleService<T> {}
|
||||
|
||||
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
|
||||
|
||||
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
|
||||
|
||||
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
|
||||
constructor(
|
||||
context: T,
|
||||
private readonly setting: ISettingService
|
||||
) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
getSmallConfig(key: string): string | null {
|
||||
return this.setting.getSmallConfig(key);
|
||||
}
|
||||
|
||||
setSmallConfig(key: string, value: string): void {
|
||||
this.setting.setSmallConfig(key, value);
|
||||
}
|
||||
|
||||
deleteSmallConfig(key: string): void {
|
||||
this.setting.deleteSmallConfig(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||
@@ -98,17 +72,14 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
const setting = new InjectableSettingService(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: options.deviceValues,
|
||||
});
|
||||
const settingsPersistence = options.settings;
|
||||
setting.loadData.setHandler(
|
||||
settingsPersistence
|
||||
? () => settingsPersistence.load()
|
||||
: () => Promise.resolve(undefined)
|
||||
settingsPersistence ? () => settingsPersistence.load() : () => Promise.resolve(undefined)
|
||||
);
|
||||
setting.saveData.setHandler(
|
||||
settingsPersistence
|
||||
? (settings) => settingsPersistence.save(settings)
|
||||
: () => Promise.resolve()
|
||||
settingsPersistence ? (settings) => settingsPersistence.save(settings) : () => Promise.resolve()
|
||||
);
|
||||
|
||||
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
|
||||
@@ -142,7 +113,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
setting,
|
||||
API,
|
||||
});
|
||||
const config = new LiveSyncBrowserConfigService(context, setting);
|
||||
const config = setting.config;
|
||||
const replicator = new InjectableReplicatorService(context, {
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
@@ -163,8 +134,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
databaseService: database,
|
||||
});
|
||||
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
|
||||
openKeyValueDatabase:
|
||||
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
||||
openKeyValueDatabase: options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
||||
appLifecycle,
|
||||
databaseEvents,
|
||||
vault,
|
||||
@@ -209,7 +179,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
}
|
||||
|
||||
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
|
||||
options: LiveSyncBrowserServiceHubOptions<T> = {}
|
||||
options: LiveSyncBrowserServiceHubOptions<T>
|
||||
): LiveSyncBrowserServiceHub<T> {
|
||||
return new LiveSyncBrowserServiceHub(options);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -21,7 +22,8 @@ describe("LiveSync browser service context contract", () => {
|
||||
const context = createServiceContext({
|
||||
translate: (key) => `webapp:${key}`,
|
||||
});
|
||||
const hub = createLiveSyncBrowserServiceHub({ context });
|
||||
const deviceValues = new MemoryValueStore();
|
||||
const hub = createLiveSyncBrowserServiceHub({ context, deviceValues });
|
||||
|
||||
expect(observeServiceContext(context, "moduleLocalDatabase.logWaitingForReady")).toEqual({
|
||||
translation: "webapp:moduleLocalDatabase.logWaitingForReady",
|
||||
@@ -32,20 +34,17 @@ describe("LiveSync browser service context contract", () => {
|
||||
expect(SERVICE_CONTEXT_MEMBERS.filter((member) => !composition.servicesUsingExpectedContext[member])).toEqual(
|
||||
[]
|
||||
);
|
||||
expect(hub.setting.deviceValues).toBe(deviceValues);
|
||||
expect(hub.config).toBe(hub.setting.config);
|
||||
});
|
||||
|
||||
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
|
||||
const deviceLocalSettings = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
|
||||
removeItem: (key: string) => deviceLocalSettings.delete(key),
|
||||
clear: () => deviceLocalSettings.clear(),
|
||||
});
|
||||
const deviceValues = new MemoryValueStore();
|
||||
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
|
||||
const restartCalls: string[] = [];
|
||||
const nativeFetch = vi.fn(async () => new Response("ok"));
|
||||
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
|
||||
deviceValues,
|
||||
getSystemVaultName: () => "browser-vault",
|
||||
settings: {
|
||||
load: async () => ({
|
||||
@@ -69,6 +68,8 @@ describe("LiveSync browser service context contract", () => {
|
||||
|
||||
const hub = createLiveSyncBrowserServiceHub(options);
|
||||
|
||||
expect(hub.setting.deviceValues).toBe(deviceValues);
|
||||
expect(hub.config).toBe(hub.setting.config);
|
||||
expect(hub.API.getSystemVaultName()).toBe("browser-vault");
|
||||
expect(hub.API.getPlatform()).toBe("browser");
|
||||
const response = await hub.API.nativeFetch("https://example.invalid/");
|
||||
@@ -86,4 +87,30 @@ describe("LiveSync browser service context contract", () => {
|
||||
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
|
||||
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
|
||||
});
|
||||
|
||||
it("keeps each browser composition bound to its own device value store", async () => {
|
||||
const firstValues = new MemoryValueStore();
|
||||
const secondValues = new MemoryValueStore();
|
||||
const firstHub = createLiveSyncBrowserServiceHub({
|
||||
deviceValues: firstValues,
|
||||
getSystemVaultName: () => "first-vault",
|
||||
});
|
||||
const secondHub = createLiveSyncBrowserServiceHub({
|
||||
deviceValues: secondValues,
|
||||
getSystemVaultName: () => "second-vault",
|
||||
});
|
||||
|
||||
await firstHub.config.setValue("probe", "first");
|
||||
await secondHub.config.setValue("probe", "second");
|
||||
|
||||
expect(firstHub.setting.deviceValues).toBe(firstValues);
|
||||
expect(secondHub.setting.deviceValues).toBe(secondValues);
|
||||
expect(firstHub.config).toBe(firstHub.setting.config);
|
||||
expect(secondHub.config).toBe(secondHub.setting.config);
|
||||
expect(firstHub.config).not.toBe(secondHub.config);
|
||||
await expect(firstHub.config.getValue("probe")).resolves.toBe("first");
|
||||
await expect(secondHub.config.getValue("probe")).resolves.toBe("second");
|
||||
await expect(firstValues.getValue("first-vault-probe")).resolves.toBe("first");
|
||||
await expect(secondValues.getValue("second-vault-probe")).resolves.toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -719,8 +719,8 @@ src/apps/cli/
|
||||
│ └── ServiceFileAccessImpl.ts
|
||||
├── services/
|
||||
│ ├── NodeKeyValueDBService.ts
|
||||
│ ├── NodeServiceHub.ts
|
||||
│ └── NodeSettingService.ts
|
||||
│ ├── NodeLocalStorage.ts
|
||||
│ └── NodeServiceHub.ts
|
||||
├── test/
|
||||
│ ├── test-e2e-two-vaults-common.sh
|
||||
│ ├── test-e2e-two-vaults-matrix.sh
|
||||
|
||||
@@ -88,6 +88,27 @@ describe("CLI database preparation", () => {
|
||||
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||
}
|
||||
|
||||
it("loads legacy device values and persists configuration through the service-owned store", async () => {
|
||||
const localStoragePath = join(directory, ".livesync", "runtime", "local-storage.json");
|
||||
await mkdir(join(directory, ".livesync", "runtime"), { recursive: true });
|
||||
await writeFile(localStoragePath, JSON.stringify({ ["__proto__"]: "legacy value" }));
|
||||
let legacyValue: string | null | undefined;
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
legacyValue = await core.services.setting.deviceValues.getValue("__proto__");
|
||||
expect(core.services.config).toBe(core.services.setting.config);
|
||||
await core.services.config.setValue("bootstrap-probe", "config value");
|
||||
return false;
|
||||
});
|
||||
|
||||
await start("ls", runner);
|
||||
|
||||
expect(legacyValue).toBe("legacy value");
|
||||
const stored = JSON.parse(await readFile(localStoragePath, "utf-8")) as Record<string, string>;
|
||||
expect(Object.prototype.hasOwnProperty.call(stored, "__proto__")).toBe(true);
|
||||
expect(stored["__proto__"]).toBe("legacy value");
|
||||
expect(Object.values(stored)).toContain("config value");
|
||||
});
|
||||
|
||||
it.each([REMOTE_P2P, REMOTE_COUCHDB])(
|
||||
"restores configured P2P automation before the host connects (%s)",
|
||||
async (remoteType) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||
import { LiveSyncBaseCore, type StartupDatabaseOptions } from "@/LiveSyncBaseCore";
|
||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||
import {
|
||||
@@ -78,7 +77,6 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
|
||||
},
|
||||
};
|
||||
|
||||
ensureGlobalNodeLocalStorage();
|
||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
||||
|
||||
/** Injectable command boundary used by CLI integration probes. */
|
||||
@@ -400,8 +398,6 @@ export async function main(
|
||||
const settingsPath = options.settingsPath
|
||||
? path.resolve(options.settingsPath)
|
||||
: path.join(databasePath, SETTINGS_FILE);
|
||||
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
|
||||
|
||||
// Resolve vault path: mirror positional argument takes priority,
|
||||
// then --vault flag, otherwise fall back to databasePath.
|
||||
const vaultPath =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.39-cli",
|
||||
"version": "1.0.40-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "tsc -p tsconfig.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
|
||||
@@ -1,115 +1,86 @@
|
||||
import { createDeletableValueStore, sanitiseValueStoreError, ValueStoreError } from "@vrtmrz/value-store";
|
||||
import type { ValueDeleter, ValueStore } from "@vrtmrz/value-store";
|
||||
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type LocalStorageShape = {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
clear(): void;
|
||||
};
|
||||
type NodeLocalStorage = ValueStore & ValueDeleter;
|
||||
|
||||
class PersistentNodeLocalStorage {
|
||||
private storagePath: string | undefined;
|
||||
private localStore: Record<string, string> = {};
|
||||
class JsonFileValueBackend {
|
||||
private values: Map<string, string>;
|
||||
|
||||
configure(storagePath: string) {
|
||||
if (this.storagePath === storagePath) {
|
||||
return;
|
||||
}
|
||||
this.storagePath = storagePath;
|
||||
this.loadFromFile();
|
||||
constructor(private readonly storagePath: string) {
|
||||
this.values = this.loadValues();
|
||||
}
|
||||
|
||||
private loadFromFile() {
|
||||
if (!this.storagePath) {
|
||||
this.localStore = {};
|
||||
get(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
const nextValues = new Map(this.values);
|
||||
nextValues.set(key, value);
|
||||
this.writeValues(nextValues);
|
||||
this.values = nextValues;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
if (!this.values.has(key)) {
|
||||
return;
|
||||
}
|
||||
const nextValues = new Map(this.values);
|
||||
nextValues.delete(key);
|
||||
this.writeValues(nextValues);
|
||||
this.values = nextValues;
|
||||
}
|
||||
|
||||
private loadValues(): Map<string, string> {
|
||||
let contents: string;
|
||||
try {
|
||||
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>;
|
||||
this.localStore = { ...loaded };
|
||||
contents = nodeFs.readFileSync(this.storagePath, "utf-8");
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return new Map();
|
||||
}
|
||||
throw sanitiseValueStoreError(error);
|
||||
}
|
||||
|
||||
let document: unknown;
|
||||
try {
|
||||
document = JSON.parse(contents);
|
||||
} catch {
|
||||
this.localStore = {};
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
if (document === null || typeof document !== "object" || Array.isArray(document)) {
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
|
||||
const values = new Map<string, string>();
|
||||
for (const [key, value] of Object.entries(document)) {
|
||||
if (typeof value !== "string") {
|
||||
throw new ValueStoreError("invalid-data");
|
||||
}
|
||||
values.set(key, value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private flushToFile() {
|
||||
if (!this.storagePath) {
|
||||
return;
|
||||
}
|
||||
private writeValues(values: ReadonlyMap<string, string>): void {
|
||||
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
|
||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.localStore[key] ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.localStore[key] = value;
|
||||
this.flushToFile();
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
if (!(key in this.localStore)) {
|
||||
return;
|
||||
}
|
||||
delete this.localStore[key];
|
||||
this.flushToFile();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.localStore = {};
|
||||
this.flushToFile();
|
||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(Object.fromEntries(values), null, 2), "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
const persistentNodeLocalStorage = new PersistentNodeLocalStorage();
|
||||
|
||||
function createNodeLocalStorageShim(): LocalStorageShape {
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return persistentNodeLocalStorage.getItem(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
persistentNodeLocalStorage.setItem(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
persistentNodeLocalStorage.removeItem(key);
|
||||
},
|
||||
clear() {
|
||||
persistentNodeLocalStorage.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureGlobalNodeLocalStorage() {
|
||||
const g = compatGlobal as unknown as Record<string, unknown>;
|
||||
if (
|
||||
!("localStorage" in g) ||
|
||||
typeof (g.localStorage as Record<string, unknown> | undefined)?.getItem !== "function"
|
||||
) {
|
||||
g.localStorage = createNodeLocalStorageShim();
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const code = Object.getOwnPropertyDescriptor(error, "code");
|
||||
return code !== undefined && Object.prototype.hasOwnProperty.call(code, "value") && code.value === "ENOENT";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function configureNodeLocalStorage(storagePath: string) {
|
||||
persistentNodeLocalStorage.configure(storagePath);
|
||||
ensureGlobalNodeLocalStorage();
|
||||
}
|
||||
|
||||
export function getNodeLocalStorageItem(key: string): string {
|
||||
return persistentNodeLocalStorage.getItem(key) ?? "";
|
||||
}
|
||||
|
||||
export function setNodeLocalStorageItem(key: string, value: string) {
|
||||
persistentNodeLocalStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export function deleteNodeLocalStorageItem(key: string) {
|
||||
persistentNodeLocalStorage.removeItem(key);
|
||||
}
|
||||
|
||||
export function clearNodeLocalStorage() {
|
||||
persistentNodeLocalStorage.clear();
|
||||
export function createNodeLocalStorage(storagePath: string): NodeLocalStorage {
|
||||
return createDeletableValueStore(new JsonFileValueBackend(storagePath));
|
||||
}
|
||||
@@ -1,58 +1,127 @@
|
||||
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { ValueStoreError } from "@vrtmrz/value-store";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearNodeLocalStorage,
|
||||
configureNodeLocalStorage,
|
||||
ensureGlobalNodeLocalStorage,
|
||||
getNodeLocalStorageItem,
|
||||
setNodeLocalStorageItem,
|
||||
} from "./NodeLocalStorage";
|
||||
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||
|
||||
describe("NodeLocalStorage", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
clearNodeLocalStorage();
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists values to the configured file", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
setNodeLocalStorageItem("checkpoint", "42");
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved.checkpoint).toBe("42");
|
||||
});
|
||||
|
||||
it("reloads persisted values when configured again", () => {
|
||||
it("loads and preserves legacy flat string data, including empty values and exact keys", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const specialKey = "__proto__";
|
||||
const specialValue = "line one\n雪";
|
||||
|
||||
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
||||
fs.writeFileSync(storagePath, JSON.stringify({ persisted: "value" }, null, 2), "utf-8");
|
||||
fs.writeFileSync(storagePath, JSON.stringify({ [specialKey]: specialValue, empty: "" }), "utf-8");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
|
||||
expect(getNodeLocalStorageItem("persisted")).toBe("value");
|
||||
await expect(store.getValue(specialKey)).resolves.toBe(specialValue);
|
||||
await expect(store.getValue("empty", "default")).resolves.toBe("");
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved[specialKey]).toBe(specialValue);
|
||||
expect(saved.empty).toBe("");
|
||||
});
|
||||
|
||||
it("installs a global localStorage shim backed by the same store", () => {
|
||||
it("persists strings and objects, reloads them, and deletes keys", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
const object = { enabled: false, entries: ["value", null, 0] };
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
ensureGlobalNodeLocalStorage();
|
||||
await store.setValue("empty", "");
|
||||
await store.setValue("text", "stored value");
|
||||
await store.setObject("object", object);
|
||||
|
||||
globalThis.localStorage.setItem("shared", "state");
|
||||
const reloaded = createNodeLocalStorage(storagePath);
|
||||
await expect(reloaded.getValue("empty", "default")).resolves.toBe("");
|
||||
await expect(reloaded.getValue("text")).resolves.toBe("stored value");
|
||||
await expect(reloaded.getObject("object")).resolves.toEqual(object);
|
||||
|
||||
expect(getNodeLocalStorageItem("shared")).toBe("state");
|
||||
await reloaded.delete("text");
|
||||
await expect(reloaded.getValue("text")).resolves.toBeNull();
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved.text).toBeUndefined();
|
||||
expect(saved.object).toBe(JSON.stringify(object));
|
||||
});
|
||||
|
||||
it("treats a missing file as empty and creates its parent on the first write", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
|
||||
await expect(store.getValue("absent")).resolves.toBeNull();
|
||||
expect(fs.existsSync(storagePath)).toBe(false);
|
||||
await store.setValue("created", "value");
|
||||
expect(fs.existsSync(storagePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps separate store instances isolated by their file paths", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const first = createNodeLocalStorage(path.join(tempDir, "first", "local-storage.json"));
|
||||
const second = createNodeLocalStorage(path.join(tempDir, "second", "local-storage.json"));
|
||||
|
||||
await first.setValue("shared-key", "first");
|
||||
await second.setValue("shared-key", "second");
|
||||
|
||||
await expect(first.getValue("shared-key")).resolves.toBe("first");
|
||||
await expect(second.getValue("shared-key")).resolves.toBe("second");
|
||||
});
|
||||
|
||||
it("surfaces malformed and unreadable files as sanitised value-store errors", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const malformedPath = path.join(tempDir, "malformed.json");
|
||||
fs.writeFileSync(malformedPath, "{ malformed", "utf-8");
|
||||
|
||||
let malformedError: unknown;
|
||||
try {
|
||||
createNodeLocalStorage(malformedPath);
|
||||
} catch (error) {
|
||||
malformedError = error;
|
||||
}
|
||||
expect(malformedError).toBeInstanceOf(ValueStoreError);
|
||||
expect(malformedError).toMatchObject({ code: "invalid-data" });
|
||||
expect(fs.readFileSync(malformedPath, "utf-8")).toBe("{ malformed");
|
||||
|
||||
const unreadablePath = path.join(tempDir, "unreadable.json");
|
||||
fs.mkdirSync(unreadablePath);
|
||||
expect(() => createNodeLocalStorage(unreadablePath)).toThrowError(ValueStoreError);
|
||||
});
|
||||
|
||||
it("does not change its visible values when a write fails", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
const store = createNodeLocalStorage(storagePath);
|
||||
await store.setValue("retained", "before failure");
|
||||
|
||||
fs.rmSync(storagePath);
|
||||
fs.mkdirSync(storagePath);
|
||||
|
||||
await expect(store.setValue("new", "write fails")).rejects.toBeInstanceOf(ValueStoreError);
|
||||
await expect(store.getValue("retained")).resolves.toBe("before failure");
|
||||
await expect(store.getValue("new")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not install or modify a global localStorage value", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const before = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
|
||||
createNodeLocalStorage(path.join(tempDir, "runtime", "local-storage.json"));
|
||||
|
||||
expect(Object.getOwnPropertyDescriptor(globalThis, "localStorage")).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AppLifecycleServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
|
||||
import type {
|
||||
ComponentHasResult,
|
||||
SvelteDialogManager,
|
||||
@@ -11,6 +10,7 @@ import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
|
||||
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
|
||||
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
|
||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
|
||||
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
|
||||
@@ -21,7 +21,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { HeadlessAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/headless/HeadlessAPIService";
|
||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||
import { NodeSettingService } from "./NodeSettingService";
|
||||
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -96,11 +96,11 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const fileProcessing = new InjectableFileProcessingService(context);
|
||||
|
||||
const setting = new NodeSettingService(
|
||||
context,
|
||||
{ APIService: API, onDisplayLanguageChanged: setLang },
|
||||
localStoragePath
|
||||
);
|
||||
const setting = new InjectableSettingService<T>(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: createNodeLocalStorage(localStoragePath),
|
||||
});
|
||||
|
||||
const appLifecycle = new NodeAppLifecycleService<T>(context, {
|
||||
settingService: setting,
|
||||
@@ -132,10 +132,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
||||
setting,
|
||||
});
|
||||
|
||||
const config = new ConfigServiceBrowserCompat<T>(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const config = setting.config;
|
||||
|
||||
const replicator = new InjectableReplicatorService(context, {
|
||||
settingService: setting,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB } from "@/common/events";
|
||||
import { handlers } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import {
|
||||
configureNodeLocalStorage,
|
||||
deleteNodeLocalStorageItem,
|
||||
getNodeLocalStorageItem,
|
||||
setNodeLocalStorageItem,
|
||||
} from "./NodeLocalStorage";
|
||||
|
||||
export class NodeSettingService<T extends ServiceContext> extends SettingService<T> {
|
||||
constructor(context: T, dependencies: SettingServiceDependencies, storagePath: string) {
|
||||
super(context, dependencies);
|
||||
configureNodeLocalStorage(storagePath);
|
||||
this.onSettingSaved.addHandler((settings) => {
|
||||
this.context.events.emitEvent(EVENT_SETTING_SAVED, settings);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
this.onSettingLoaded.addHandler((settings) => {
|
||||
this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
protected setItem(key: string, value: string) {
|
||||
setNodeLocalStorageItem(key, value);
|
||||
}
|
||||
|
||||
protected getItem(key: string): string {
|
||||
return getNodeLocalStorageItem(key);
|
||||
}
|
||||
|
||||
protected deleteItem(key: string): void {
|
||||
deleteNodeLocalStorageItem(key);
|
||||
}
|
||||
|
||||
public saveData = handlers<{ saveData: (data: ObsidianLiveSyncSettings) => Promise<void> }>().binder("saveData");
|
||||
public loadData = handlers<{ loadData: () => Promise<ObsidianLiveSyncSettings | undefined> }>().binder("loadData");
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFea
|
||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
createLiveSyncBrowserServiceHub,
|
||||
@@ -56,6 +58,7 @@ const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
||||
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
|
||||
|
||||
export interface WebAppRuntimeOptions {
|
||||
deviceValues?: ValueStore & ValueDeleter;
|
||||
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
|
||||
scheduleReload?: (delayMilliseconds: number) => void;
|
||||
}
|
||||
@@ -71,7 +74,10 @@ export class WebAppRuntime {
|
||||
private paneHost: P2PReplicatorPaneHost | null = null;
|
||||
private restartScheduled = false;
|
||||
|
||||
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) {
|
||||
constructor(
|
||||
rootHandle: FileSystemDirectoryHandle,
|
||||
private readonly options: WebAppRuntimeOptions = {}
|
||||
) {
|
||||
this.rootHandle = rootHandle;
|
||||
this.reportStatus = options.reportStatus ?? (() => {});
|
||||
this.scheduleReload =
|
||||
@@ -89,6 +95,8 @@ export class WebAppRuntime {
|
||||
|
||||
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
|
||||
return {
|
||||
deviceValues:
|
||||
this.options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
|
||||
settings: {
|
||||
save: async (data) => {
|
||||
@@ -219,7 +227,9 @@ export class WebAppRuntime {
|
||||
useCheckRemoteSize(core);
|
||||
useRemoteConfiguration(core);
|
||||
this.p2p = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
|
||||
prepareP2PSettings: useP2PSettingsPreparation(
|
||||
core.services.API.webCompatFetch.bind(core.services.API)
|
||||
),
|
||||
});
|
||||
this.paneHost = {
|
||||
services: core.services,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.39-webapp",
|
||||
"version": "1.0.40-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.39-webpeer",
|
||||
"version": "1.0.40-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -7,6 +7,8 @@ import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/t
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
@@ -20,6 +22,7 @@ import { translateLiveSyncMessage } from "@/common/translation";
|
||||
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
|
||||
|
||||
export interface WebPeerRuntimeOptions {
|
||||
deviceValues?: ValueStore & ValueDeleter;
|
||||
context?: ServiceContext;
|
||||
store?: SimpleStore<unknown>;
|
||||
deviceName?: string;
|
||||
@@ -62,6 +65,7 @@ export class WebPeerRuntime {
|
||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||
context: this.context,
|
||||
deviceValues: options.deviceValues ?? createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
getSystemVaultName: () => options.systemVaultName ?? WEBPEER_STORE_NAME,
|
||||
settings: persistence.settings,
|
||||
restart: {
|
||||
@@ -103,7 +107,7 @@ export class WebPeerRuntime {
|
||||
await this.services.setting.loadSettings();
|
||||
const deviceName = this.options.deviceName?.trim();
|
||||
if (deviceName) {
|
||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||
await this.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, deviceName);
|
||||
}
|
||||
const opened = await this.services.database.openDatabase({
|
||||
replicator: this.services.replicator,
|
||||
@@ -192,7 +196,7 @@ export class WebPeerRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
getDeviceName(): string {
|
||||
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
|
||||
async getDeviceName(): Promise<string> {
|
||||
return (await this.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME)) || this.services.vault.getVaultName();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,74 @@
|
||||
* remove it from this map in the same change.
|
||||
*/
|
||||
export const liveSyncProvisionalEnglishMessages = {
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
|
||||
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
|
||||
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
|
||||
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.",
|
||||
"Unknown platform": "Unknown platform",
|
||||
"This removes only this device's record from the remote.":
|
||||
"This removes only this device's record from the remote.",
|
||||
"This remote is unlocked. A device that returns can register again.":
|
||||
"This remote is unlocked. A device that returns can register again.",
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again.":
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again.",
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again.":
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again.",
|
||||
"This remote does not support conditional updates, so the device record could not be removed.":
|
||||
"This remote does not support conditional updates, so the device record could not be removed.",
|
||||
"This remote does not currently have a record of joined devices.":
|
||||
"This remote does not currently have a record of joined devices.",
|
||||
"This list shows device information recorded by the remote. It does not show whether a device is online now.":
|
||||
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
|
||||
"This device has been locked out. Use the existing recovery process to join again.":
|
||||
"This device has been locked out. Use the existing recovery process to join again.",
|
||||
"This device": "This device",
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.":
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.",
|
||||
"The device record was forgotten.": "The device record was forgotten.",
|
||||
"The device record was already absent.": "The device record was already absent.",
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again.":
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again.",
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.":
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.",
|
||||
"Review device records for the active CouchDB or Object Storage connection.":
|
||||
"Review device records for the active CouchDB or Object Storage connection.",
|
||||
Refresh: "Refresh",
|
||||
"Plug-in version": "Plug-in version",
|
||||
Platform: "Platform",
|
||||
"Older Self-hosted LiveSync versions detected: ${versions}.":
|
||||
"Older Self-hosted LiveSync versions detected: ${versions}.",
|
||||
"Newer Self-hosted LiveSync versions detected: ${versions}.":
|
||||
"Newer Self-hosted LiveSync versions detected: ${versions}.",
|
||||
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.":
|
||||
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.",
|
||||
Open: "Open",
|
||||
"Not recorded": "Not recorded",
|
||||
"Not accepted": "Not accepted",
|
||||
"No joined devices are recorded on this remote.": "No joined devices are recorded on this remote.",
|
||||
"Locked out": "Locked out",
|
||||
"Loading joined devices…": "Loading joined devices…",
|
||||
"Last recorded": "Last recorded",
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.":
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.",
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.":
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.",
|
||||
"Joined devices": "Joined devices",
|
||||
"Review devices": "Review devices",
|
||||
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.":
|
||||
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
|
||||
"Forgetting…": "Forgetting…",
|
||||
"Forgetting the record does not delete Vault files or revoke credentials.":
|
||||
"Forgetting the record does not delete Vault files or revoke credentials.",
|
||||
"Forget this device record?": "Forget this device record?",
|
||||
"Forget device": "Forget device",
|
||||
Close: "Close",
|
||||
Cancel: "Cancel",
|
||||
"App version": "App version",
|
||||
Accepted: "Accepted",
|
||||
"Preparing follow-up report…": "Preparing follow-up report…",
|
||||
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
|
||||
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
import {
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBInitialFetchFastForwardResourceFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
@@ -115,6 +118,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
|
||||
createCouchDBJoinedDevicesResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createCouchDBPreferredTweakProbeFactory(host)
|
||||
@@ -125,6 +131,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
|
||||
createCouchDBSynchronisationInformationResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: supportedCapability(
|
||||
createCouchDBInitialFetchFastForwardResourceFactory(host)
|
||||
),
|
||||
},
|
||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||
@@ -141,6 +150,9 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
|
||||
createObjectStorageJoinedDevicesResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
||||
createObjectStorageConnectionProbeFactory(host)
|
||||
),
|
||||
@@ -151,6 +163,7 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
createObjectStorageSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
|
||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
||||
},
|
||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||
|
||||
@@ -44,7 +44,16 @@ describe("central Replicator provider definitions", () => {
|
||||
it("keeps the retained remote-resource catalogue bounded", () => {
|
||||
expect
|
||||
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
|
||||
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
||||
.toEqual(
|
||||
[
|
||||
"connection",
|
||||
"initial-fetch-fast-forward",
|
||||
"joined-devices",
|
||||
"preferred-tweak",
|
||||
"security-seed",
|
||||
"synchronisation-information",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
|
||||
@@ -106,18 +115,27 @@ describe("central Replicator provider definitions", () => {
|
||||
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
|
||||
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
|
||||
|
||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
const resources = Object.values(REMOTE_RESOURCE_KINDS).sort();
|
||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(resources);
|
||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(resources);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.SUPPORTED
|
||||
);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.SUPPORTED
|
||||
);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||
);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||
);
|
||||
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
logger: vi.fn(),
|
||||
initialFetchFastForwardResource: undefined as unknown,
|
||||
couchDB: [] as Array<{
|
||||
host: unknown;
|
||||
isMobile: ReturnType<typeof vi.fn>;
|
||||
@@ -12,6 +13,9 @@ const mocks = vi.hoisted(() => ({
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
getJoinedDevices: ReturnType<typeof vi.fn>;
|
||||
forgetJoinedDevice: ReturnType<typeof vi.fn>;
|
||||
createInitialFetchFastForwardResource: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
objectStorage: [] as Array<{
|
||||
@@ -20,6 +24,8 @@ const mocks = vi.hoisted(() => ({
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
getJoinedDevices: ReturnType<typeof vi.fn>;
|
||||
forgetJoinedDevice: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
checkSyncInfo: vi.fn(async () => true),
|
||||
@@ -42,6 +48,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
getJoinedDevices = vi.fn();
|
||||
forgetJoinedDevice = vi.fn();
|
||||
createInitialFetchFastForwardResource = vi.fn(() => mocks.initialFetchFastForwardResource);
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
@@ -58,6 +67,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
getJoinedDevices = vi.fn();
|
||||
forgetJoinedDevice = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
@@ -69,10 +80,13 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
||||
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createCouchDBInitialFetchFastForwardResourceFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
@@ -92,6 +106,7 @@ describe("replicator probe factories", () => {
|
||||
beforeEach(() => {
|
||||
mocks.couchDB.length = 0;
|
||||
mocks.objectStorage.length = 0;
|
||||
mocks.initialFetchFastForwardResource = undefined;
|
||||
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
|
||||
mocks.logger.mockClear();
|
||||
});
|
||||
@@ -226,6 +241,65 @@ describe("replicator probe factories", () => {
|
||||
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: "CouchDB",
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
createFactory: createCouchDBJoinedDevicesResourceFactory,
|
||||
instances: () => mocks.couchDB,
|
||||
},
|
||||
{
|
||||
kind: "Object Storage",
|
||||
remoteType: REMOTE_MINIO,
|
||||
createFactory: createObjectStorageJoinedDevicesResourceFactory,
|
||||
instances: () => mocks.objectStorage,
|
||||
},
|
||||
])(
|
||||
"keeps $kind joined-device operations on an owned settings snapshot",
|
||||
async ({ remoteType, createFactory, instances }) => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings({ remoteType });
|
||||
const snapshot = { ...source };
|
||||
const factory = createFactory(host as never);
|
||||
const resource = await factory(source);
|
||||
const nextResource = await factory(source);
|
||||
const [replicator, nextReplicator] = instances();
|
||||
expect(instances()).toHaveLength(2);
|
||||
expect(replicator.host).toBe(host);
|
||||
expect(replicator.getJoinedDevices).not.toHaveBeenCalled();
|
||||
expect(replicator.forgetJoinedDevice).not.toHaveBeenCalled();
|
||||
const devices = {
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true }],
|
||||
};
|
||||
replicator.getJoinedDevices.mockResolvedValue(devices);
|
||||
replicator.forgetJoinedDevice.mockResolvedValue("conflict");
|
||||
|
||||
source.couchDB_DBNAME = "changed-vault";
|
||||
source.bucket = "changed-bucket";
|
||||
await expect(resource.read()).resolves.toBe(devices);
|
||||
await expect(resource.forget("other")).resolves.toBe("conflict");
|
||||
expect(replicator.getJoinedDevices).toHaveBeenCalledWith(snapshot);
|
||||
expect(replicator.getJoinedDevices.mock.calls[0][0]).not.toBe(source);
|
||||
expect(replicator.forgetJoinedDevice).toHaveBeenCalledWith(snapshot, "other");
|
||||
|
||||
const failure = new Error("remote unavailable");
|
||||
replicator.getJoinedDevices.mockRejectedValue(failure);
|
||||
await expect(resource.read()).rejects.toBe(failure);
|
||||
replicator.forgetJoinedDevice.mockRejectedValue(failure);
|
||||
await expect(resource.forget("other")).rejects.toBe(failure);
|
||||
const disposal = resource.dispose();
|
||||
expect(resource.dispose()).toBe(disposal);
|
||||
await disposal;
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(nextReplicator.closeReplication).not.toHaveBeenCalled();
|
||||
await nextResource.dispose();
|
||||
expect(nextReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it("shares one successful asynchronous disposal promise for every probe kind", async () => {
|
||||
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
|
||||
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
|
||||
@@ -370,4 +444,29 @@ describe("replicator probe factories", () => {
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("creates an Initial Fetch Fast Forward resource from a settings snapshot and leaves ownership to the resource", async () => {
|
||||
const host = { name: "host" };
|
||||
const settings = createSettings();
|
||||
const snapshot = { ...settings };
|
||||
const prepare = vi.fn(async () => undefined);
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const resource = { prepare, dispose };
|
||||
const factory = createCouchDBInitialFetchFastForwardResourceFactory(host as never);
|
||||
mocks.initialFetchFastForwardResource = resource;
|
||||
|
||||
const result = await factory(settings);
|
||||
const replicator = mocks.couchDB[0];
|
||||
|
||||
settings.couchDB_URI = "https://changed.example.test";
|
||||
expect(replicator.createInitialFetchFastForwardResource).toHaveBeenCalledWith(snapshot);
|
||||
expect(replicator.createInitialFetchFastForwardResource.mock.calls[0][0]).not.toBe(settings);
|
||||
expect(replicator.host).toBe(host);
|
||||
expect(result).toBe(resource);
|
||||
|
||||
await expect(result.prepare()).resolves.toBeUndefined();
|
||||
await result.dispose();
|
||||
expect(prepare).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -14,3 +14,9 @@ export {
|
||||
type SecuritySeedResourceHost,
|
||||
} from "./securitySeed";
|
||||
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
|
||||
|
||||
export {
|
||||
createCouchDBJoinedDevicesResourceFactory,
|
||||
createObjectStorageJoinedDevicesResourceFactory,
|
||||
} from "./joinedDevices";
|
||||
export { createCouchDBInitialFetchFastForwardResourceFactory } from "./initialFetchFastForward";
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { InitialFetchFastForwardResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { snapshotRemoteSettings } from "./shared";
|
||||
|
||||
/** Create a private CouchDB Replicator for a fresh Fast Fetch checkpoint handoff. */
|
||||
export function createCouchDBInitialFetchFastForwardResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): InitialFetchFastForwardResourceFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
const replicator = new LiveSyncCouchDBReplicator(host);
|
||||
return Promise.resolve(replicator.createInitialFetchFastForwardResource(snapshot));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
JoinedDevicesResource,
|
||||
JoinedDevicesResourceFactory,
|
||||
JoinedDevicesSnapshot,
|
||||
ForgetJoinedDeviceResult,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
|
||||
|
||||
interface JoinedDevicesReplicator extends ResourceReplicator {
|
||||
getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
|
||||
forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
|
||||
}
|
||||
|
||||
function createResource(replicator: JoinedDevicesReplicator, snapshot: RemoteDBSettings): JoinedDevicesResource {
|
||||
return {
|
||||
read: () => replicator.getJoinedDevices(snapshot),
|
||||
forget: (deviceId) => replicator.forgetJoinedDevice(snapshot, deviceId),
|
||||
dispose: createReplicatorDisposer(replicator),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCouchDBJoinedDevicesResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): JoinedDevicesResourceFactory {
|
||||
return (setting) =>
|
||||
Promise.resolve(createResource(new LiveSyncCouchDBReplicator(host), snapshotRemoteSettings(setting)));
|
||||
}
|
||||
|
||||
export function createObjectStorageJoinedDevicesResourceFactory(
|
||||
host: LiveSyncCouchDBReplicatorEnv
|
||||
): JoinedDevicesResourceFactory {
|
||||
return (setting) =>
|
||||
Promise.resolve(createResource(new LiveSyncJournalReplicator(host), snapshotRemoteSettings(setting)));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -18,17 +19,7 @@ import {
|
||||
} from "./turnSettingsPrivacy";
|
||||
|
||||
class MemorySettingService extends SettingService {
|
||||
readonly items = new Map<string, string>();
|
||||
saved?: ObsidianLiveSyncSettings;
|
||||
protected setItem(key: string, value: string) {
|
||||
this.items.set(key, value);
|
||||
}
|
||||
protected getItem(key: string) {
|
||||
return this.items.get(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string) {
|
||||
this.items.delete(key);
|
||||
}
|
||||
protected saveData(settings: ObsidianLiveSyncSettings) {
|
||||
this.saved = structuredClone(settings);
|
||||
return Promise.resolve();
|
||||
@@ -70,6 +61,7 @@ describe("managed TURN settings privacy", () => {
|
||||
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
|
||||
current.remoteConfigurations.managed.uri = originalURI;
|
||||
const service = new MemorySettingService(new ServiceContext(), {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
APIService: {
|
||||
getSystemVaultName: () => "test-vault",
|
||||
getAppID: () => "test-app",
|
||||
|
||||
@@ -20,10 +20,14 @@
|
||||
MODE_SHINY,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { normalizePath } from "@/deps";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import {
|
||||
HiddenFileSync,
|
||||
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
|
||||
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -139,6 +143,11 @@
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.deleteData(data);
|
||||
}
|
||||
function checkAutomaticSyncAvailable() {
|
||||
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
|
||||
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
function askMode(evt: MouseEvent, title: string, key: string) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
||||
@@ -149,6 +158,7 @@
|
||||
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
||||
.onClick((e) => {
|
||||
if (mode === MODE_AUTOMATIC) {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
askOverwriteModeForAutomatic(evt, key);
|
||||
} else {
|
||||
setMode(key, mode as SYNC_MODE);
|
||||
@@ -161,10 +171,11 @@
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
setMode(key, MODE_AUTOMATIC);
|
||||
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
|
||||
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
|
||||
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
|
||||
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
const menu = new Menu();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export function getAutomaticSyncTargetFiles(
|
||||
key: string,
|
||||
configDir: string,
|
||||
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
|
||||
): string[] {
|
||||
const keys = key.startsWith("PLUGIN_ALL/")
|
||||
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
|
||||
: [key];
|
||||
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
|
||||
return [...files].map((file) => `${configDir}/${file}`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
|
||||
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
|
||||
return {
|
||||
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
|
||||
"PLUGIN_MAIN/example": {
|
||||
key: "PLUGIN_MAIN/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
|
||||
},
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/data.json"],
|
||||
},
|
||||
"PLUGIN_ETC/example": {
|
||||
key: "PLUGIN_ETC/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/extra.json"],
|
||||
},
|
||||
"PLUGIN_MAIN/other": {
|
||||
key: "PLUGIN_MAIN/other",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/other/main.js"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getAutomaticSyncTargetFiles", () => {
|
||||
it("initialises a single selected item in the current configuration directory", () => {
|
||||
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
|
||||
".custom-config/app.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("initialises both saved child selections when the whole plug-in is selected", () => {
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
|
||||
".obsidian/plugins/example/data.json",
|
||||
".obsidian/plugins/example/main.js",
|
||||
".obsidian/plugins/example/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains the available child files when the other child has no saved entry", () => {
|
||||
const entries = createEntries();
|
||||
delete entries["PLUGIN_DATA/example"];
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
|
||||
".obsidian/plugins/example/main.js",
|
||||
".obsidian/plugins/example/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
|
||||
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||
|
||||
const hiddenPath = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const prefixedPath = `i:${hiddenPath}`;
|
||||
const body = "module.exports = class ExamplePlugin {};";
|
||||
const allowList = "\\.(?:md|png|pdf)$";
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function hiddenFiles() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.usePluginSync = false;
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: fixture.settings,
|
||||
services: {
|
||||
...fixture.services,
|
||||
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
|
||||
},
|
||||
},
|
||||
cacheFileRegExps: new Map(),
|
||||
cacheCustomisationSyncIgnoredFiles: new Map(),
|
||||
});
|
||||
return { ...fixture, hiddenFileSync };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("issue #1272: Hidden File Sync with normal-file selectors", () => {
|
||||
it("stores a selected hidden file while the normal-file allow-list is set", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const stored = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
|
||||
expect(stored).not.toBe(false);
|
||||
if (stored) expect(await readContent(stored)).toBe(body);
|
||||
});
|
||||
|
||||
it("reads existing hidden-file content while the normal-file allow-list is set", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const meta = await fixture.localDatabase.getDBEntryMeta(prefixedPath as FilePath);
|
||||
if (!meta) throw new Error("The seeded hidden-file Metadata is unavailable");
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
|
||||
const entry = await fixture.localDatabase.getDBEntryFromMeta(meta);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("stores and reads the same hidden file when the normal-file allow-list is empty", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const entry = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("continues to apply the normal-file allow-list to ordinary files", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
expect(fixture.localDatabase.isTargetFile("notes/example.md" as FilePath)).toBe(true);
|
||||
expect(fixture.localDatabase.isTargetFile("notes/example.js" as FilePath)).toBe(false);
|
||||
expect(fixture.localDatabase.isTargetFile("i:.obsidian/example.md" as FilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Customisation Sync documents usable with the same normal-file allow-list", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
const path = "ix:device/PLUGIN_MAIN/example%main.js" as FilePath;
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const entry = await fixture.localDatabase.getDBEntry(path);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("keeps Hidden File Sync target and ignore patterns effective", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncInternalFilesTargetPatterns =
|
||||
"^\\.obsidian/plugins/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(".obsidian/snippets/example.css" as FilePath)).resolves.toBe(
|
||||
false
|
||||
);
|
||||
fixture.settings.syncInternalFilesIgnorePatterns =
|
||||
"main\\.js$" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("compatibility: applies the normal-file ignore-list to hidden documents", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
fixture.settings.syncIgnoreRegEx = "^\\.obsidian/" as typeof fixture.settings.syncIgnoreRegEx;
|
||||
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects hidden documents when Hidden File Sync is disabled", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncInternalFiles = false;
|
||||
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,9 @@ type HiddenFileInitialisationProgress = {
|
||||
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
||||
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
|
||||
|
||||
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
|
||||
|
||||
function getComparingMTime(
|
||||
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
|
||||
includeDeleted = false
|
||||
@@ -1444,6 +1447,11 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?: HiddenFileInitialisationProgress
|
||||
) {
|
||||
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
|
||||
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
|
||||
return false;
|
||||
}
|
||||
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
|
||||
// p.log("Resolving conflicts before starting...");
|
||||
// await this.resolveConflictOnInternalFiles();
|
||||
@@ -1494,6 +1502,7 @@ Offline Changed files: ${files.length}`;
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
p.done();
|
||||
return true;
|
||||
}
|
||||
// <-- Initialization functions
|
||||
|
||||
@@ -1925,6 +1934,7 @@ Offline Changed files: ${files.length}`;
|
||||
|
||||
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
|
||||
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
|
||||
let initialised = false;
|
||||
let result: ConfigureHiddenFileSyncResult;
|
||||
try {
|
||||
result = await configureHiddenFileSyncMode(mode, {
|
||||
@@ -1954,7 +1964,7 @@ Offline Changed files: ${files.length}`;
|
||||
);
|
||||
},
|
||||
initialise: async (direction) => {
|
||||
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialisationProgress = undefined;
|
||||
},
|
||||
});
|
||||
@@ -1962,7 +1972,7 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?.done("Failed");
|
||||
throw error;
|
||||
}
|
||||
if (result == "ignored" || result == "disabled") {
|
||||
if (result == "ignored" || result == "disabled" || !initialised) {
|
||||
return;
|
||||
}
|
||||
// this.plugin.settings.useAdvancedMode = true;
|
||||
|
||||
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
|
||||
}
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
||||
async (direction) => {
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const path = ".obsidian/example.json" as FilePath;
|
||||
const operations = {
|
||||
rebuildFromStorage: vi.fn(async () => [path]),
|
||||
rebuildFromDatabase: vi.fn(async () => []),
|
||||
rebuildMerging: vi.fn(async () => [path]),
|
||||
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
|
||||
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
|
||||
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
const settings = { syncInternalFiles: false };
|
||||
const createProgress = vi.fn(() => progress);
|
||||
const log = vi.fn();
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings },
|
||||
_progress: createProgress,
|
||||
_log: log,
|
||||
...operations,
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
|
||||
|
||||
for (const operation of Object.values(operations)) {
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(result).toBe(false);
|
||||
expect(settings.syncInternalFiles).toBe(false);
|
||||
expect(createProgress).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
|
||||
expect(progress.done).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const createProgress = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: false } },
|
||||
_progress: createProgress,
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(createProgress).not.toHaveBeenCalled();
|
||||
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
|
||||
});
|
||||
|
||||
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
|
||||
const path = ".obsidian/example.json" as FilePath;
|
||||
const deleted: MetaEntry = {
|
||||
_id: "i:example" as DocumentID,
|
||||
_rev: "2-deleted",
|
||||
path: `i:${path}` as FilePathWithPrefix,
|
||||
type: "plain",
|
||||
ctime: 1,
|
||||
mtime: 10,
|
||||
size: 0,
|
||||
deleted: true,
|
||||
children: [],
|
||||
eden: {},
|
||||
};
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const deleteFile = vi.fn(async () => "OK" as const);
|
||||
const updateDeletion = vi.fn();
|
||||
const getFiles = vi.fn(async () => [deleted]);
|
||||
const processedStorage = new Map([[path, "storage-processed"]]);
|
||||
const processedDatabase = new Map([[path, "database-processed"]]);
|
||||
const resetProcessed = vi.fn(() => processedDatabase.clear());
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: { syncInternalFiles: false },
|
||||
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
|
||||
},
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: vi.fn(),
|
||||
_verbose: vi.fn(),
|
||||
_fileInfoLastProcessed: processedStorage,
|
||||
_databaseInfoLastProcessed: processedDatabase,
|
||||
getAllDatabaseFiles: getFiles,
|
||||
getPath: (doc: MetaEntry) => doc.path,
|
||||
resetLastProcessedDatabase: resetProcessed,
|
||||
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
|
||||
__deleteFile: deleteFile,
|
||||
updateLastProcessedDeletion: updateDeletion,
|
||||
adoptCurrentStorageFilesAsProcessed: vi.fn(),
|
||||
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
|
||||
scanAllStorageChanges: vi.fn(),
|
||||
scanAllDatabaseChanges: vi.fn(),
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
|
||||
|
||||
expect(deleteFile).not.toHaveBeenCalled();
|
||||
expect(updateDeletion).not.toHaveBeenCalled();
|
||||
expect(getFiles).not.toHaveBeenCalled();
|
||||
expect(resetProcessed).not.toHaveBeenCalled();
|
||||
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
|
||||
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
|
||||
});
|
||||
|
||||
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
}
|
||||
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
const command = commands.find(({ id }) => id === commandId);
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
expect(command?.checkCallback?.(false)).toBe(false);
|
||||
}
|
||||
|
||||
settings.syncInternalFiles = true;
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed,
|
||||
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
scanAllDatabaseChanges,
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"retains the selected files and completes %s initialisation when enabled",
|
||||
async (direction) => {
|
||||
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
|
||||
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const rebuildFromStorage = vi.fn(async () => files);
|
||||
const rebuildFromDatabase = vi.fn(async () => entries);
|
||||
const rebuildMerging = vi.fn(async () => files);
|
||||
const adoptStorage = vi.fn();
|
||||
const adoptDatabase = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
|
||||
rebuildFromStorage,
|
||||
rebuildFromDatabase,
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed: adoptStorage,
|
||||
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
|
||||
scanAllStorageChanges: vi.fn(),
|
||||
scanAllDatabaseChanges: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
|
||||
expect(result).toBe(true);
|
||||
if (direction === "pushForce") {
|
||||
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||
} else if (direction === "pullForce") {
|
||||
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
|
||||
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
|
||||
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it("does not report completion when the initialisation entry point refuses to start", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
await handlers.initialise("safe");
|
||||
return "enabled";
|
||||
});
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { services: { setting: { applyPartial: vi.fn() } } },
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: log,
|
||||
initialiseInternalFileSync: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await hiddenFileSync.configureHiddenFileSync("MERGE");
|
||||
|
||||
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
|
||||
});
|
||||
|
||||
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const applyPartial = vi.fn(async () => {
|
||||
events.push("apply-settings");
|
||||
});
|
||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
||||
const initialiseInternalFileSync = vi.fn(async () => true);
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { ValueStoreError } from "@vrtmrz/value-store";
|
||||
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
AcceptedStatus,
|
||||
@@ -33,6 +34,14 @@
|
||||
let settings = $state<P2PSyncSetting>(initialSettings);
|
||||
|
||||
let deviceName = $state<string>("");
|
||||
let deviceNameReady = $state(false);
|
||||
let dialogStatusReady = $state(false);
|
||||
type StorageErrorKey = "deviceNameLoad" | "dialogStatusLoad" | "deviceNameWrite" | "dialogStatusWrite";
|
||||
let storageErrors = $state<Partial<Record<StorageErrorKey, string>>>({});
|
||||
let mounted = false;
|
||||
let deviceNameLoadSequence = 0;
|
||||
let deviceNameWriteQueue: Promise<void> = Promise.resolve();
|
||||
let dialogStatusWriteQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
|
||||
let eRelay = $state<string>(initialSettings.P2P_relays);
|
||||
@@ -68,6 +77,7 @@
|
||||
);
|
||||
|
||||
async function saveAndApply() {
|
||||
const savedDeviceName = eDeviceName;
|
||||
// const newSettings = {
|
||||
// ...currentSettings(),
|
||||
// P2P_Enabled: eP2PEnabled,
|
||||
@@ -92,8 +102,39 @@
|
||||
},
|
||||
true
|
||||
);
|
||||
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
|
||||
deviceName = eDeviceName;
|
||||
try {
|
||||
await queueDeviceNameWrite(savedDeviceName);
|
||||
if (!mounted) return;
|
||||
deviceName = savedDeviceName;
|
||||
clearStorageError("deviceNameWrite");
|
||||
} catch (error) {
|
||||
setStorageError("deviceNameWrite", error);
|
||||
}
|
||||
}
|
||||
|
||||
function setStorageError(key: StorageErrorKey, error: unknown) {
|
||||
if (mounted) {
|
||||
storageErrors = {
|
||||
...storageErrors,
|
||||
[key]: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clearStorageError(key: StorageErrorKey) {
|
||||
if (!mounted) return;
|
||||
const nextErrors = { ...storageErrors };
|
||||
delete nextErrors[key];
|
||||
storageErrors = nextErrors;
|
||||
}
|
||||
|
||||
function queueDeviceNameWrite(name: string): Promise<void> {
|
||||
const write = deviceNameWriteQueue.catch(() => undefined).then(async () => {
|
||||
if (!mounted) return;
|
||||
await services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, name);
|
||||
});
|
||||
deviceNameWriteQueue = write;
|
||||
return write;
|
||||
}
|
||||
async function revert() {
|
||||
eP2PEnabled = settings.P2P_Enabled;
|
||||
@@ -110,10 +151,7 @@
|
||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
|
||||
if (force) {
|
||||
const initDeviceName =
|
||||
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
|
||||
deviceName = initDeviceName;
|
||||
eDeviceName = initDeviceName;
|
||||
void loadDeviceName(true).catch(() => undefined);
|
||||
}
|
||||
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
|
||||
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
|
||||
@@ -129,6 +167,7 @@
|
||||
settings = d;
|
||||
};
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const r = events.onEvent("setting-saved", async (d) => {
|
||||
applyLoadSettings(d, false);
|
||||
closeServer();
|
||||
@@ -143,15 +182,52 @@
|
||||
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
replicatorInfo = status;
|
||||
});
|
||||
applyLoadSettings(currentSettings(), true);
|
||||
applyLoadSettings(currentSettings(), false);
|
||||
void loadInitialPreferences().catch(() => undefined);
|
||||
host.p2p.diagnostics.requestStatus();
|
||||
return () => {
|
||||
mounted = false;
|
||||
r();
|
||||
rx();
|
||||
r2();
|
||||
r3();
|
||||
};
|
||||
});
|
||||
|
||||
async function loadInitialPreferences() {
|
||||
const deviceNameLoad = loadDeviceName(true).catch(() => undefined);
|
||||
const dialogStatusLoad = readDialogStatus().then(
|
||||
(dialogStatus) => {
|
||||
if (!mounted) return;
|
||||
isNoticeOpened = dialogStatus.notice ?? true;
|
||||
isSettingOpened = dialogStatus.setting ?? true;
|
||||
dialogStatusReady = true;
|
||||
clearStorageError("dialogStatusLoad");
|
||||
},
|
||||
(error: unknown) => {
|
||||
setStorageError("dialogStatusLoad", error);
|
||||
}
|
||||
);
|
||||
await Promise.all([deviceNameLoad, dialogStatusLoad]);
|
||||
}
|
||||
|
||||
async function loadDeviceName(force: boolean) {
|
||||
const request = ++deviceNameLoadSequence;
|
||||
try {
|
||||
const savedDeviceName = await services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME, "");
|
||||
if (!mounted || request !== deviceNameLoadSequence) return;
|
||||
const initialDeviceName = savedDeviceName;
|
||||
if (force || !isDeviceNameModified) {
|
||||
deviceName = initialDeviceName;
|
||||
eDeviceName = initialDeviceName;
|
||||
}
|
||||
deviceNameReady = true;
|
||||
clearStorageError("deviceNameLoad");
|
||||
} catch (error) {
|
||||
if (mounted && request === deviceNameLoadSequence) setStorageError("deviceNameLoad", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let isConnected = $derived.by(() => {
|
||||
return serverInfo?.isConnected ?? false;
|
||||
});
|
||||
@@ -234,26 +310,42 @@
|
||||
}
|
||||
|
||||
const initialDialogStatusKey = `p2p-dialog-status`;
|
||||
const getDialogStatus = () => {
|
||||
const readDialogStatus = async (): Promise<{ notice?: boolean; setting?: boolean }> => {
|
||||
try {
|
||||
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
|
||||
notice?: boolean;
|
||||
setting?: boolean;
|
||||
const initialDialogStatus = await services.config.getObject(initialDialogStatusKey);
|
||||
if (typeof initialDialogStatus !== "object" || initialDialogStatus === null || Array.isArray(initialDialogStatus)) {
|
||||
return {};
|
||||
}
|
||||
const status = initialDialogStatus as Record<string, unknown>;
|
||||
return {
|
||||
...(typeof status.notice === "boolean" ? { notice: status.notice } : {}),
|
||||
...(typeof status.setting === "boolean" ? { setting: status.setting } : {}),
|
||||
};
|
||||
return initialDialogStatus;
|
||||
} catch {
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error instanceof ValueStoreError && error.code === "invalid-data") {
|
||||
// Invalid optional UI state is recovered with the historical defaults.
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const initialDialogStatus = getDialogStatus();
|
||||
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true);
|
||||
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true);
|
||||
let isNoticeOpened = $state<boolean>(true);
|
||||
let isSettingOpened = $state<boolean>(true);
|
||||
$effect(() => {
|
||||
if (!mounted || !dialogStatusReady) return;
|
||||
const dialogStatus = {
|
||||
notice: isNoticeOpened,
|
||||
setting: isSettingOpened,
|
||||
};
|
||||
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
|
||||
const write = dialogStatusWriteQueue.catch(() => undefined).then(async () => {
|
||||
if (!mounted) return;
|
||||
await services.config.setObject(initialDialogStatusKey, dialogStatus);
|
||||
});
|
||||
dialogStatusWriteQueue = write;
|
||||
void write.then(
|
||||
() => clearStorageError("dialogStatusWrite"),
|
||||
(error: unknown) => setStorageError("dialogStatusWrite", error)
|
||||
);
|
||||
});
|
||||
let isObsidian = $derived.by(() => {
|
||||
return services.API.getPlatform() === "obsidian";
|
||||
@@ -261,6 +353,9 @@
|
||||
</script>
|
||||
|
||||
<article>
|
||||
{#each Object.values(storageErrors) as storageError}
|
||||
<p role="alert">{storageError}</p>
|
||||
{/each}
|
||||
<h1>{_msg("Peer to Peer Replicator")}</h1>
|
||||
<details bind:open={isNoticeOpened}>
|
||||
<summary>{_msg("P2P.Note.Summary")}</summary>
|
||||
@@ -345,6 +440,7 @@
|
||||
type="text"
|
||||
placeholder="iphone-16"
|
||||
bind:value={eDeviceName}
|
||||
disabled={!deviceNameReady}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
@@ -383,7 +479,7 @@
|
||||
</tr> -->
|
||||
</tbody>
|
||||
</table>
|
||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}
|
||||
<button disabled={!isAnyModified || !deviceNameReady} class="button mod-cta" onclick={saveAndApply}
|
||||
>{_msg("Save and Apply")}</button
|
||||
>
|
||||
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
REVIEW_HARNESS_SCENARIOS,
|
||||
type ReviewHarnessScenarioId,
|
||||
@@ -101,7 +102,7 @@ export class ReviewHarnessView extends ItemView {
|
||||
true
|
||||
);
|
||||
this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
|
||||
this.controller.prepareCompatibilityReviewRestart()
|
||||
this.controller.prepareCompatibilityReviewRestart().catch((error) => Logger(error, LOG_LEVEL_NOTICE))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ export interface ReviewHarnessRuntime {
|
||||
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
||||
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||
readContinuation(): string | null;
|
||||
writeContinuation(value: string): void;
|
||||
deleteContinuation(): void;
|
||||
readContinuation(): Promise<string | null>;
|
||||
writeContinuation(value: string): Promise<void>;
|
||||
deleteContinuation(): Promise<void>;
|
||||
restart(): void;
|
||||
reportError(error: unknown): void;
|
||||
copyText(value: string): Promise<void>;
|
||||
@@ -68,6 +68,7 @@ export class ReviewHarnessController {
|
||||
private current: ReviewHarnessScenarioId | null = null;
|
||||
private resumedRequestId: string | null = null;
|
||||
private continuationError: string | null = null;
|
||||
private continuationConsumption: Promise<void> | undefined;
|
||||
|
||||
constructor(private readonly runtime: ReviewHarnessRuntime) {}
|
||||
|
||||
@@ -102,10 +103,20 @@ export class ReviewHarnessController {
|
||||
};
|
||||
}
|
||||
|
||||
consumeContinuation(): void {
|
||||
const serialised = this.runtime.readContinuation();
|
||||
consumeContinuation(): Promise<void> {
|
||||
if (this.continuationConsumption) return this.continuationConsumption;
|
||||
const consumption = this.consumeContinuationFromStore();
|
||||
const trackedConsumption = consumption.finally(() => {
|
||||
if (this.continuationConsumption === trackedConsumption) this.continuationConsumption = undefined;
|
||||
});
|
||||
this.continuationConsumption = trackedConsumption;
|
||||
return trackedConsumption;
|
||||
}
|
||||
|
||||
private async consumeContinuationFromStore(): Promise<void> {
|
||||
const serialised = await this.runtime.readContinuation();
|
||||
if (!serialised) return;
|
||||
this.runtime.deleteContinuation();
|
||||
await this.runtime.deleteContinuation();
|
||||
const parsed = parsePendingReviewRun(serialised);
|
||||
if (!parsed.pendingRun) {
|
||||
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
|
||||
@@ -232,7 +243,7 @@ export class ReviewHarnessController {
|
||||
this.record("scenario-failed", id);
|
||||
}
|
||||
|
||||
prepareCompatibilityReviewRestart(): void {
|
||||
async prepareCompatibilityReviewRestart(): Promise<void> {
|
||||
const requestedAt = this.runtime.now().toISOString();
|
||||
const pending = {
|
||||
formatVersion: 1,
|
||||
@@ -241,7 +252,7 @@ export class ReviewHarnessController {
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
} as const;
|
||||
this.runtime.writeContinuation(JSON.stringify(pending));
|
||||
await this.runtime.writeContinuation(JSON.stringify(pending));
|
||||
this.results["compatibility-review"] = {
|
||||
status: "waiting-for-user",
|
||||
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
|
||||
|
||||
@@ -90,14 +90,14 @@ function createRuntime(): ReviewHarnessRuntime & {
|
||||
detail: "File content hash measurements completed.",
|
||||
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
|
||||
})),
|
||||
readContinuation() {
|
||||
async readContinuation() {
|
||||
return this.continuation;
|
||||
},
|
||||
writeContinuation(value) {
|
||||
async writeContinuation(value) {
|
||||
this.events.push("write");
|
||||
this.continuation = value;
|
||||
},
|
||||
deleteContinuation() {
|
||||
async deleteContinuation() {
|
||||
this.events.push("delete");
|
||||
this.continuation = null;
|
||||
},
|
||||
@@ -251,7 +251,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.snapshot().running).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
|
||||
it("deletes a one-shot continuation before exposing the resumed guided step", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -262,7 +262,7 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(runtime.events).toEqual(["delete"]);
|
||||
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
||||
@@ -271,7 +271,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
});
|
||||
|
||||
it("does not copy rejected continuation values into the report", () => {
|
||||
it("does not copy rejected continuation values into the report", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -282,7 +282,7 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(controller.snapshot().continuationError).toContain("private-vault");
|
||||
expect(controller.snapshot().results["compatibility-review"].detail).toBe(
|
||||
@@ -292,7 +292,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||
});
|
||||
|
||||
it("does not accept an injected request ID which could enter the report transcript", () => {
|
||||
it("does not accept an injected request ID which could enter the report transcript", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -303,18 +303,18 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
|
||||
expect(controller.createReport()).not.toContain("private-vault");
|
||||
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||
});
|
||||
|
||||
it("persists the fixed continuation before requesting restart", () => {
|
||||
it("persists the fixed continuation before requesting restart", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.prepareCompatibilityReviewRestart();
|
||||
await controller.prepareCompatibilityReviewRestart();
|
||||
|
||||
expect(runtime.events).toEqual(["write", "restart"]);
|
||||
expect(JSON.parse(runtime.continuation!)).toMatchObject({
|
||||
@@ -324,6 +324,135 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the continuation save before requesting restart", async () => {
|
||||
const runtime = createRuntime();
|
||||
let markSaveStarted!: () => void;
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
markSaveStarted = resolve;
|
||||
});
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
runtime.writeContinuation = vi.fn(async (value) => {
|
||||
runtime.events.push("write-started");
|
||||
markSaveStarted();
|
||||
await saving;
|
||||
runtime.continuation = value;
|
||||
runtime.events.push("write-saved");
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
const preparing = controller.prepareCompatibilityReviewRestart();
|
||||
await saveStarted;
|
||||
|
||||
expect(runtime.events).toEqual(["write-started"]);
|
||||
expect(runtime.restart).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
finishSave();
|
||||
await preparing;
|
||||
|
||||
expect(runtime.events).toEqual(["write-started", "write-saved", "restart"]);
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("keeps the guided review retryable when saving its continuation fails", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
await controller.runScenario("compatibility-review");
|
||||
runtime.writeContinuation = vi.fn(async () => {
|
||||
runtime.events.push("write-failed");
|
||||
throw new Error("continuation save failed");
|
||||
});
|
||||
|
||||
await expect(controller.prepareCompatibilityReviewRestart()).rejects.toThrow("continuation save failed");
|
||||
|
||||
expect(runtime.events).toEqual(["write-failed"]);
|
||||
expect(runtime.restart).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("waits for one continuation deletion before exposing it and coalesces concurrent consumption", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||
});
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deleting = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
runtime.readContinuation = vi.fn(async () => {
|
||||
runtime.events.push("read");
|
||||
return runtime.continuation;
|
||||
});
|
||||
runtime.deleteContinuation = vi.fn(async () => {
|
||||
runtime.events.push("delete-started");
|
||||
markDeleteStarted();
|
||||
await deleting;
|
||||
runtime.continuation = null;
|
||||
runtime.events.push("delete-finished");
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
const firstConsumption = controller.consumeContinuation();
|
||||
const duplicateConsumption = controller.consumeContinuation();
|
||||
await deleteStarted;
|
||||
|
||||
expect(duplicateConsumption).toBe(firstConsumption);
|
||||
expect(runtime.events).toEqual(["read", "delete-started"]);
|
||||
expect(controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
finishDelete();
|
||||
await firstConsumption;
|
||||
|
||||
expect(runtime.events).toEqual(["read", "delete-started", "delete-finished"]);
|
||||
expect(runtime.readContinuation).toHaveBeenCalledOnce();
|
||||
expect(runtime.deleteContinuation).toHaveBeenCalledOnce();
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("keeps a failed continuation deletion available for retry", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||
});
|
||||
runtime.deleteContinuation = vi
|
||||
.fn<ReviewHarnessRuntime["deleteContinuation"]>()
|
||||
.mockRejectedValueOnce(new Error("continuation delete failed"))
|
||||
.mockImplementation(async () => {
|
||||
runtime.events.push("delete");
|
||||
runtime.continuation = null;
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
await expect(controller.consumeContinuation()).rejects.toThrow("continuation delete failed");
|
||||
|
||||
expect(runtime.continuation).not.toBeNull();
|
||||
expect(controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(runtime.continuation).toBeNull();
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("passes only after the actual compatibility review clears its pause", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
@@ -51,6 +51,7 @@ import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/inter
|
||||
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
|
||||
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
|
||||
import { useJoinedDevicesFeature } from "./serviceFeatures/useJoinedDevices";
|
||||
import { useDatabaseDiagnosticsFeature } from "./serviceFeatures/useDatabaseDiagnostics";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
@@ -197,6 +198,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
|
||||
useObsidianReplicationRibbonFeature(core);
|
||||
useDatabaseDiagnosticsFeature(core);
|
||||
useJoinedDevicesFeature(core, serviceHub.context.noticeGroups);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
|
||||
@@ -8,13 +8,13 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||
settingTab!: ObsidianLiveSyncSettingTab;
|
||||
|
||||
_everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
async _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
|
||||
this.settingTab.reloadAllSettings(true);
|
||||
await this.settingTab.loadSettings();
|
||||
this.plugin.addSettingTab(this.settingTab);
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
|
||||
|
||||
return Promise.resolve(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
openSetting() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const settingTabState = vi.hoisted(() => ({
|
||||
callOrder: [] as string[],
|
||||
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
|
||||
loadSettings: vi.fn<() => Promise<void>>(),
|
||||
}));
|
||||
|
||||
const eventHubState = vi.hoisted(() => ({
|
||||
@@ -11,9 +11,9 @@ const eventHubState = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
|
||||
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
|
||||
reloadAllSettings(skipUpdate?: boolean) {
|
||||
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
|
||||
settingTabState.reloadAllSettings(skipUpdate);
|
||||
async loadSettings() {
|
||||
await settingTabState.loadSettings();
|
||||
settingTabState.callOrder.push("settings-loaded");
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -65,7 +65,7 @@ function createModuleHarness() {
|
||||
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
settingTabState.callOrder.length = 0;
|
||||
settingTabState.reloadAllSettings.mockClear();
|
||||
settingTabState.loadSettings.mockReset().mockResolvedValue(undefined);
|
||||
eventHubState.onEvent.mockClear();
|
||||
});
|
||||
|
||||
@@ -79,13 +79,29 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
});
|
||||
|
||||
it("seeds the setting editor without requesting a render before registration", async () => {
|
||||
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
|
||||
const { initialisationHandler, settingsLoadedHandler, plugin } = createModuleHarness();
|
||||
let finishRead!: () => void;
|
||||
settingTabState.loadSettings.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishRead = resolve;
|
||||
})
|
||||
);
|
||||
const handler = settingsLoadedHandler() ?? initialisationHandler();
|
||||
|
||||
expect(handler).toBeTypeOf("function");
|
||||
await handler!();
|
||||
const loading = handler!();
|
||||
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||
finishRead();
|
||||
await loading;
|
||||
|
||||
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
|
||||
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
|
||||
expect(settingTabState.loadSettings).toHaveBeenCalledOnce();
|
||||
expect(settingTabState.callOrder).toEqual(["settings-loaded", "add-setting-tab"]);
|
||||
});
|
||||
it("does not register an editor when reading device values fails", async () => {
|
||||
const { settingsLoadedHandler, plugin } = createModuleHarness();
|
||||
const failure = new Error("device storage unavailable");
|
||||
settingTabState.loadSettings.mockRejectedValueOnce(failure);
|
||||
await expect(settingsLoadedHandler()!()).rejects.toBe(failure);
|
||||
expect(plugin.addSettingTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+8
-6
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
|
||||
@@ -158,6 +159,7 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
@@ -457,7 +459,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
expect(runtime.pageCleanup).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => {
|
||||
it("rebuilds the catalogue when an externally loaded setting changes page visibility", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -466,7 +468,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
changeLog.page().display();
|
||||
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -480,7 +482,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds translated catalogue names when the display language changes externally", () => {
|
||||
it("rebuilds translated catalogue names when the display language changes externally", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -489,12 +491,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
changeLog.page().display();
|
||||
tab.core.settings.displayLanguage = "ja";
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
|
||||
expect(tab.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => {
|
||||
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", async () => {
|
||||
const tab = createSettingsTab();
|
||||
const changeLog = findPage(tab, "Change Log");
|
||||
if (!changeLog.page) {
|
||||
@@ -505,7 +507,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
tab.editingSettings.usePowerUserMode = true;
|
||||
tab.core.settings.usePowerUserMode = true;
|
||||
|
||||
tab.requestReload();
|
||||
await tab.requestReload();
|
||||
const configureAnchor = vi.mocked(tab.core.confirm.askInPopup).mock.calls[0]?.[2];
|
||||
expect(configureAnchor).toBeTypeOf("function");
|
||||
let acceptExternalSetting: (() => void) | undefined;
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type PageFunctions,
|
||||
type UpdateFunction,
|
||||
} from "./SettingPane.ts";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
|
||||
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
@@ -100,6 +99,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
selectedScreen = "";
|
||||
|
||||
private localConfigurationPassphrase = "";
|
||||
private localSettingsReadVersion = 0;
|
||||
|
||||
_editingSettings?: AllSettings;
|
||||
// Buffered Settings for editing
|
||||
get editingSettings(): AllSettings {
|
||||
@@ -162,8 +164,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
|
||||
if (key == "deviceAndVaultName") {
|
||||
this.services.setting.setDeviceAndVaultName(this.editingSettings?.[key] ?? "");
|
||||
this.services.setting.saveDeviceAndVaultName();
|
||||
return await Promise.resolve();
|
||||
await this.services.setting.saveDeviceAndVaultName();
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -225,6 +226,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
appliedConfigurationEncryption.configPassphrase
|
||||
);
|
||||
|
||||
if (appliedConfigurationEncryption.configPassphraseStore === "LOCALSTORAGE") {
|
||||
this.localSettingsReadVersion++;
|
||||
this.localConfigurationPassphrase = appliedConfigurationEncryption.configPassphrase;
|
||||
}
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphraseStore");
|
||||
this.copySettingValue(this.initialSettings, appliedConfigurationEncryption, "configPassphrase");
|
||||
}
|
||||
@@ -285,9 +290,21 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
}
|
||||
|
||||
private async readLocalSettings(): Promise<boolean> {
|
||||
const version = ++this.localSettingsReadVersion;
|
||||
const passphrase = await this.services.setting.deviceValues.getValue("ls-setting-passphrase", "");
|
||||
if (version !== this.localSettingsReadVersion) return false;
|
||||
this.localConfigurationPassphrase = passphrase;
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
if (await this.readLocalSettings()) this.reloadAllSettings(true);
|
||||
}
|
||||
|
||||
reloadAllLocalSettings() {
|
||||
const ret = { ...OnDialogSettingsDefault };
|
||||
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
|
||||
ret.configPassphrase = this.localConfigurationPassphrase;
|
||||
ret.preset = "";
|
||||
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
||||
return ret;
|
||||
@@ -402,7 +419,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
this.plugin = plugin;
|
||||
Setting.env = this;
|
||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
||||
this.requestReload();
|
||||
void this.requestReload().catch((error: unknown) => Logger(error, LOG_LEVEL_NOTICE));
|
||||
});
|
||||
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
|
||||
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
|
||||
@@ -545,7 +562,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
);
|
||||
}
|
||||
|
||||
requestReload() {
|
||||
async requestReload(): Promise<void> {
|
||||
if (!(await this.readLocalSettings())) return;
|
||||
const nativeTabIsShown =
|
||||
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
|
||||
if (this.isShown || nativeTabIsShown) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
|
||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||
applySettingsWithInitialisationChoice: vi.fn(),
|
||||
@@ -76,8 +76,6 @@ beforeEach(() => {
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||
loggerMocks.Logger.mockClear();
|
||||
vi.mocked($msg).mockClear();
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReset().mockReturnValue(null);
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockReset();
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
@@ -280,6 +278,7 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
suspendAllSync,
|
||||
@@ -665,6 +664,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
},
|
||||
@@ -692,6 +692,54 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
return { tab, saveSettingData };
|
||||
}
|
||||
|
||||
it("awaits the stored passphrase before initialising the editing buffer", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
tab.resetEditingSettings();
|
||||
let resolveRead!: (value: string) => void;
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const loading = tab.loadSettings();
|
||||
expect(tab.initialSettings).toBeUndefined();
|
||||
resolveRead("stored passphrase");
|
||||
await loading;
|
||||
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||
});
|
||||
|
||||
it("preserves the loaded settings when device storage cannot be read", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "stored passphrase");
|
||||
await tab.loadSettings();
|
||||
const failure = new Error("device storage unavailable");
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockRejectedValueOnce(failure);
|
||||
await expect(tab.requestReload()).rejects.toBe(failure);
|
||||
expect(tab.editingSettings.configPassphrase).toBe("stored passphrase");
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("stored passphrase");
|
||||
});
|
||||
|
||||
it("does not let a delayed read overwrite a successfully saved passphrase", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
await tab.loadSettings();
|
||||
let resolveRead!: (value: string) => void;
|
||||
vi.spyOn(tab.services.setting.deviceValues, "getValue").mockReturnValueOnce(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
})
|
||||
);
|
||||
const loading = tab.loadSettings();
|
||||
Object.assign(tab.services.setting, { changeConfigurationEncryption: vi.fn(async () => undefined) });
|
||||
tab.editingSettings.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.editingSettings.configPassphrase = "new passphrase";
|
||||
await tab.saveSettings(["configPassphraseStore", "configPassphrase"]);
|
||||
resolveRead("old passphrase");
|
||||
await loading;
|
||||
tab.reloadAllSettings();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new passphrase");
|
||||
});
|
||||
|
||||
it("loads the imperative fallback without a SettingPage runtime export", () => {
|
||||
const { tab } = createSettingsTab();
|
||||
|
||||
@@ -779,7 +827,7 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
it("saves a custom configuration passphrase edit through the setting service", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
const changeConfigurationEncryption = vi.fn(async () => {
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("new custom passphrase");
|
||||
await tab.services.setting.deviceValues.setValue("ls-setting-passphrase", "new custom passphrase");
|
||||
});
|
||||
Object.assign(tab.core.services.setting, { changeConfigurationEncryption });
|
||||
const store = "LOCALSTORAGE";
|
||||
@@ -787,13 +835,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.initialSettings!.configPassphraseStore = store;
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||
|
||||
await tab.saveSettings(["configPassphrase"]);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(store, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(setValue).toHaveBeenCalledOnce();
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("new custom passphrase");
|
||||
});
|
||||
|
||||
@@ -811,7 +859,6 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.initialSettings!.configPassphraseStore = "LOCALSTORAGE";
|
||||
tab.initialSettings!.configPassphrase = "previous passphrase";
|
||||
tab.editingSettings.configPassphrase = "passphrase A";
|
||||
vi.mocked(compatGlobal.localStorage.getItem).mockReturnValue("passphrase A");
|
||||
|
||||
const apply = tab.saveSettings(["configPassphrase"]);
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith("LOCALSTORAGE", "passphrase A");
|
||||
@@ -840,13 +887,13 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
tab.editingSettings.configPassphraseStore = editedStore;
|
||||
tab.initialSettings!.configPassphrase = "old custom passphrase";
|
||||
tab.editingSettings.configPassphrase = "new custom passphrase";
|
||||
vi.mocked(compatGlobal.localStorage.setItem).mockClear();
|
||||
const setValue = vi.spyOn(tab.services.setting.deviceValues, "setValue");
|
||||
|
||||
await expect(tab.saveSettings(["configPassphraseStore", "configPassphrase"])).rejects.toBe(failure);
|
||||
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledOnce();
|
||||
expect(changeConfigurationEncryption).toHaveBeenCalledWith(editedStore, "new custom passphrase");
|
||||
expect(compatGlobal.localStorage.setItem).not.toHaveBeenCalled();
|
||||
expect(setValue).not.toHaveBeenCalled();
|
||||
expect(tab.core.settings.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphraseStore).toBe(savedStore);
|
||||
expect(tab.initialSettings!.configPassphrase).toBe("old custom passphrase");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
|
||||
import { Menu, type ButtonComponent } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -101,6 +102,22 @@ export function paneRemoteConfig(
|
||||
paneEl: HTMLElement,
|
||||
{ addPanel }: PageFunctions
|
||||
): void {
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Joined devices"))
|
||||
.setDesc($msg("Review device records for the active CouchDB or Object Storage connection."))
|
||||
.addButton((button) =>
|
||||
button.setButtonText($msg("Review devices")).onClick(async () => {
|
||||
const result = await this.services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
if (result.status === "unavailable" || result.value === "unavailable") {
|
||||
this.services.API.addLog(
|
||||
$msg(
|
||||
"Joined devices are available after database startup with an active CouchDB or Object Storage connection."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
{
|
||||
/* E2EE */
|
||||
const E2EEInitialProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
buttonClasses: [] as string[],
|
||||
clickHandlers: [] as Array<() => Promise<void> | void>,
|
||||
clickHandlers: [] as Array<{ setting: string; run: () => Promise<void> | void }>,
|
||||
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
|
||||
settingClasses: [] as string[],
|
||||
}));
|
||||
@@ -32,7 +32,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
},
|
||||
};
|
||||
|
||||
setName() {
|
||||
settingName = "";
|
||||
|
||||
setName(name: string) {
|
||||
this.settingName = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -41,6 +44,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
}
|
||||
|
||||
addButton(callback: (button: unknown) => void) {
|
||||
const setting = this.settingName;
|
||||
const button = {
|
||||
buttonEl: {
|
||||
classList: {
|
||||
@@ -53,7 +57,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
},
|
||||
onClick(callback: () => Promise<void> | void) {
|
||||
runtime.clickHandlers.push(callback);
|
||||
runtime.clickHandlers.push({ setting, run: callback });
|
||||
return this;
|
||||
},
|
||||
setButtonText() {
|
||||
@@ -107,6 +111,7 @@ vi.mock("./remoteConfigBuffer.ts", () => ({
|
||||
syncActivatedRemoteSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
|
||||
import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
|
||||
|
||||
function createPanelElement(): HTMLElement {
|
||||
@@ -124,6 +129,18 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("paneRemoteConfig", () => {
|
||||
it("opens joined devices through the shared UI command", async () => {
|
||||
const invoke = vi.fn(async () => ({ status: "handled", value: "closed" }));
|
||||
const host = {
|
||||
editingSettings: { remoteConfigurations: {} },
|
||||
services: { UI: { commands: { invoke } } },
|
||||
};
|
||||
const addPanel = vi.fn(() => ({ then: vi.fn() }));
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Joined devices")!.run();
|
||||
expect(invoke).toHaveBeenCalledWith(OPEN_JOINED_DEVICES, undefined);
|
||||
});
|
||||
|
||||
it("destroys the E2EE info panel when the settings page lifetime unloads", async () => {
|
||||
const callbacks: Array<() => unknown> = [];
|
||||
const lifetimeComponent = {
|
||||
@@ -187,7 +204,7 @@ describe("paneRemoteConfig", () => {
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
|
||||
|
||||
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
|
||||
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
|
||||
@@ -233,7 +250,7 @@ describe("paneRemoteConfig", () => {
|
||||
}));
|
||||
|
||||
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await runtime.clickHandlers[0]();
|
||||
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
|
||||
|
||||
expect(host.editingSettings.idDerivationVersion).toBe(1);
|
||||
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
|
||||
|
||||
@@ -13,7 +13,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
.setName($msg("Synchronising files"))
|
||||
.setDesc(
|
||||
$msg(
|
||||
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files."
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
|
||||
)
|
||||
);
|
||||
const syncFilesControl = mount(MultipleRegExpControl, {
|
||||
@@ -32,9 +32,7 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
|
||||
const nonSyncFilesSetting = new Setting(paneEl)
|
||||
.setName($msg("Non-Synchronising files"))
|
||||
.setDesc(
|
||||
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
|
||||
);
|
||||
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
||||
|
||||
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
||||
target: nonSyncFilesSetting.controlEl,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
@@ -37,11 +38,6 @@ import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { SetupManager, UserMode } from "./SetupManager";
|
||||
|
||||
class TestSettingService extends SettingService<ServiceContext> {
|
||||
protected setItem(_key: string, _value: string): void {}
|
||||
protected getItem(_key: string): string {
|
||||
return "";
|
||||
}
|
||||
protected deleteItem(_key: string): void {}
|
||||
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -65,6 +61,7 @@ function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
|
||||
|
||||
function createSetupManager() {
|
||||
const setting = new TestSettingService(new ServiceContext(), {
|
||||
deviceValues: new MemoryValueStore(),
|
||||
APIService: {
|
||||
getSystemVaultName: vi.fn(() => "vault"),
|
||||
getAppID: vi.fn(() => "app"),
|
||||
@@ -172,10 +169,12 @@ describe("SetupManager", () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
|
||||
const newVaultSettings = createNewVaultSettings();
|
||||
|
||||
await manager.onOnboard(UserMode.NewUser);
|
||||
|
||||
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
|
||||
expect(newVaultSettings.customChunkSize).toBe(0);
|
||||
expect(configureManually).toHaveBeenCalledWith(newVaultSettings, UserMode.NewUser);
|
||||
});
|
||||
|
||||
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
|
||||
@@ -487,8 +486,15 @@ describe("SetupManager", () => {
|
||||
expect(applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
|
||||
it.each([
|
||||
{ importedSize: 0, expectedSize: 0 },
|
||||
{ importedSize: undefined, expectedSize: 60 },
|
||||
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
@@ -507,6 +513,8 @@ describe("SetupManager", () => {
|
||||
},
|
||||
activeConfigurationId: "archive",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
|
||||
else imported.customChunkSize = importedSize;
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(imported)
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
@@ -514,6 +522,7 @@ describe("SetupManager", () => {
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(expectedSize);
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
@@ -523,6 +532,7 @@ describe("SetupManager", () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
@@ -554,6 +564,7 @@ describe("SetupManager", () => {
|
||||
await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(60);
|
||||
expect(current.remoteConfigurations.existing).toBeDefined();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
expect(current.activeConfigurationId).not.toBe("existing");
|
||||
@@ -662,6 +673,7 @@ describe("SetupManager", () => {
|
||||
await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(0);
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(1);
|
||||
expect(current.activeConfigurationId).not.toBe("");
|
||||
expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId);
|
||||
|
||||
@@ -52,26 +52,48 @@
|
||||
const context = getDialogContext();
|
||||
let error = $state("");
|
||||
let connectionPathResetNotice = $state(false);
|
||||
let peerNameReady = $state(false);
|
||||
let mounted = false;
|
||||
const hasValidTurnServer = $derived(hasP2PTurnConfiguration(syncSetting));
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, SetupRemoteP2PInitialData>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let connectionProbe: SetupRemoteP2PInitialData["connectionProbe"] | undefined;
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const initialData = getInitialData?.();
|
||||
connectionProbe = initialData?.connectionProbe;
|
||||
const initialSettings = initialData?.settings;
|
||||
if (initialSettings) {
|
||||
syncSetting = pickP2PSyncSettings(initialSettings);
|
||||
}
|
||||
const initialPeerName = (initialSettings?.P2P_DevicePeerName ?? "").trim();
|
||||
const savedPeerName = initialSettings?.P2P_DevicePeerName;
|
||||
const initialPeerName = typeof savedPeerName === "string" ? savedPeerName.trim() : "";
|
||||
if (initialPeerName !== "") {
|
||||
return;
|
||||
}
|
||||
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME);
|
||||
if (cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
|
||||
peerNameReady = true;
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}
|
||||
void context.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME).then(
|
||||
(cachedPeerName) => {
|
||||
if (mounted) {
|
||||
if (typeof cachedPeerName === "string" && cachedPeerName) {
|
||||
syncSetting.P2P_DevicePeerName = cachedPeerName;
|
||||
}
|
||||
peerNameReady = true;
|
||||
}
|
||||
},
|
||||
(loadError: unknown) => {
|
||||
if (mounted) {
|
||||
error = loadError instanceof Error ? loadError.message : String(loadError);
|
||||
peerNameReady = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
});
|
||||
function generateSetting() {
|
||||
const connSetting: P2PSyncSetting = {
|
||||
@@ -193,6 +215,7 @@
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
if (!peerNameReady) return;
|
||||
error = "";
|
||||
try {
|
||||
error = (await checkConnection()) || "";
|
||||
@@ -207,6 +230,7 @@
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
if (!peerNameReady) return;
|
||||
error = validateManagedTurnSettings(syncSetting) ?? "";
|
||||
if (error) return;
|
||||
const setting = pickP2PSyncSettings(generateSetting());
|
||||
@@ -217,6 +241,7 @@
|
||||
}
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
peerNameReady &&
|
||||
syncSetting.P2P_relays.trim() !== "" &&
|
||||
syncSetting.P2P_roomID.trim() !== "" &&
|
||||
syncSetting.P2P_passphrase.trim() !== "" &&
|
||||
@@ -291,6 +316,7 @@
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
disabled={!peerNameReady}
|
||||
bind:value={syncSetting.P2P_DevicePeerName}
|
||||
/>
|
||||
</InputRow>
|
||||
@@ -385,7 +411,11 @@
|
||||
disabled={!canProceed}
|
||||
commit={() => checkAndCommit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} />
|
||||
<Decision
|
||||
title={translateMessage("Continue anyway")}
|
||||
disabled={!peerNameReady}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -0,0 +1,396 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import { $msg as translate } from "@/common/translation";
|
||||
import type {
|
||||
JoinedDeviceRow,
|
||||
JoinedDevicesDialogOptions,
|
||||
JoinedDevicesView,
|
||||
} from "@/serviceFeatures/joinedDevicesDialogTypes";
|
||||
|
||||
const { setResult, getInitialData }: GuestDialogProps<"close", JoinedDevicesDialogOptions> = $props();
|
||||
let options = $state<JoinedDevicesDialogOptions>();
|
||||
let view = $state<JoinedDevicesView>();
|
||||
let isRefreshing = $state(false);
|
||||
let confirmingId = $state<string>();
|
||||
let forgettingId = $state<string>();
|
||||
let statusMessage = $state("");
|
||||
let errorMessage = $state("");
|
||||
let disposed = false;
|
||||
const busy = $derived(isRefreshing || forgettingId !== undefined);
|
||||
const title = translate("Joined devices");
|
||||
|
||||
onMount(() => {
|
||||
options = getInitialData?.();
|
||||
if (!options) {
|
||||
errorMessage = translate(
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
void refreshView();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disposed = true;
|
||||
options?.cancel();
|
||||
});
|
||||
|
||||
async function refreshView(afterForget = false) {
|
||||
const dialogOptions = options;
|
||||
if (!dialogOptions || disposed || isRefreshing || (forgettingId !== undefined && !afterForget)) return;
|
||||
|
||||
isRefreshing = true;
|
||||
errorMessage = "";
|
||||
if (!afterForget) {
|
||||
statusMessage = "";
|
||||
confirmingId = undefined;
|
||||
}
|
||||
try {
|
||||
const refreshedView = await dialogOptions.refresh();
|
||||
if (disposed) return;
|
||||
view = refreshedView;
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
errorMessage = translate(
|
||||
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConfirmation(device: JoinedDeviceRow) {
|
||||
if (busy || device.self) return;
|
||||
confirmingId = confirmingId === device.id ? undefined : device.id;
|
||||
errorMessage = "";
|
||||
statusMessage = "";
|
||||
}
|
||||
|
||||
async function forgetDevice(device: JoinedDeviceRow) {
|
||||
const dialogOptions = options;
|
||||
if (!dialogOptions || disposed || busy || device.self || confirmingId !== device.id) return;
|
||||
|
||||
forgettingId = device.id;
|
||||
confirmingId = undefined;
|
||||
errorMessage = "";
|
||||
statusMessage = "";
|
||||
try {
|
||||
const result = await dialogOptions.forget(device.id);
|
||||
if (disposed) return;
|
||||
|
||||
if (result === "forgotten") {
|
||||
statusMessage = translate("The device record was forgotten.");
|
||||
await refreshView(true);
|
||||
} else if (result === "not-found") {
|
||||
statusMessage = translate("The device record was already absent.");
|
||||
await refreshView(true);
|
||||
} else if (result === "conflict") {
|
||||
errorMessage = translate(
|
||||
"The device list changed while you were trying to forget this device. Refresh the list before trying again."
|
||||
);
|
||||
} else {
|
||||
errorMessage = translate(
|
||||
"This remote does not support conditional updates, so the device record could not be removed."
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
errorMessage = translate(
|
||||
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) forgettingId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLastConnected(timestamp: number | undefined) {
|
||||
if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp <= 0) return translate("Not recorded");
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return translate("Not recorded");
|
||||
return date.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader {title} />
|
||||
<div class="joined-devices-dialog">
|
||||
<p>
|
||||
{translate("This list shows device information recorded by the remote. It does not show whether a device is online now.")}
|
||||
</p>
|
||||
|
||||
<InfoNote
|
||||
message="Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable."
|
||||
/>
|
||||
|
||||
{#if view}
|
||||
{#if !view.milestonePresent}
|
||||
<p class="state-message">{translate("This remote does not currently have a record of joined devices.")}</p>
|
||||
{/if}
|
||||
{#if view.cleaned}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This remote has been cleaned. Use the existing recovery process to join devices again as needed."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if view.locked}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This remote is locked. Use the existing recovery process for devices that need to join again."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="error-message" role="alert">{errorMessage}</p>
|
||||
{/if}
|
||||
{#if statusMessage}
|
||||
<p class="status-message" role="status">{statusMessage}</p>
|
||||
{/if}
|
||||
{#if isRefreshing && !view}
|
||||
<p role="status">{translate("Loading joined devices…")}</p>
|
||||
{/if}
|
||||
|
||||
{#if view}
|
||||
{#if view.devices.length > 0}
|
||||
<ul class="device-list">
|
||||
{#each view.devices as device (device.id)}
|
||||
<li class="device-card">
|
||||
<div class="device-heading">
|
||||
<div class="device-identity">
|
||||
{#if device.self}
|
||||
<span class="device-badge">{translate("This device")}</span>
|
||||
{/if}
|
||||
<code class="device-id">{device.id}</code>
|
||||
</div>
|
||||
<div class="device-badges">
|
||||
<span class="device-badge">
|
||||
{device.accepted ? translate("Accepted") : translate("Not accepted")}
|
||||
</span>
|
||||
{#if device.lockedOut}
|
||||
<span class="device-badge device-badge-warning">{translate("Locked out")}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if device.self && device.lockedOut}
|
||||
<p class="state-message">
|
||||
{translate(
|
||||
"This device has been locked out. Use the existing recovery process to join again."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<dl class="device-details">
|
||||
<div>
|
||||
<dt>{translate("Platform")}</dt>
|
||||
<dd>{device.platform || translate("Unknown platform")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("Plug-in version")}</dt>
|
||||
<dd>{device.pluginVersion || translate("Not recorded")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("App version")}</dt>
|
||||
<dd>{device.appVersion || translate("Not recorded")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{translate("Last recorded")}</dt>
|
||||
<dd>{formatLastConnected(device.lastConnected)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if !device.self}
|
||||
{#if confirmingId === device.id}
|
||||
<div class="forget-confirmation">
|
||||
<p><strong>{translate("Forget this device record?")}</strong></p>
|
||||
<p>
|
||||
{translate("This removes only this device's record from the remote.")}
|
||||
</p>
|
||||
<p>
|
||||
{translate(
|
||||
"Forgetting the record does not delete Vault files or revoke credentials."
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{view.locked
|
||||
? translate(
|
||||
"This remote is locked. If the device returns, use the existing recovery process to join it again."
|
||||
)
|
||||
: translate("This remote is unlocked. A device that returns can register again.")}
|
||||
</p>
|
||||
<div class="device-actions">
|
||||
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
|
||||
{translate("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
class="mod-warning"
|
||||
disabled={busy}
|
||||
onclick={() => void forgetDevice(device)}
|
||||
>
|
||||
{translate("Forget device")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="device-actions">
|
||||
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
|
||||
{forgettingId === device.id
|
||||
? translate("Forgetting…")
|
||||
: translate("Forget device")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if !isRefreshing}
|
||||
<p>{translate("No joined devices are recorded on this remote.")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="dialog-controls">
|
||||
<button disabled={busy} onclick={() => void refreshView()}>{translate("Refresh")}</button>
|
||||
<button onclick={() => setResult("close")}>{translate("Close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.joined-devices-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.state-message,
|
||||
.forget-confirmation {
|
||||
border-left: 3px solid var(--interactive-accent);
|
||||
padding: var(--size-4-2);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.device-list {
|
||||
display: grid;
|
||||
gap: var(--size-4-2);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: var(--size-4-3);
|
||||
}
|
||||
|
||||
.device-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-identity,
|
||||
.device-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-id {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-secondary);
|
||||
padding: 0 var(--size-4-2);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.device-badge-warning {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.device-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: var(--size-4-2) var(--size-4-4);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-details > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.forget-confirmation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-actions,
|
||||
.dialog-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.device-card {
|
||||
padding: var(--size-4-2);
|
||||
}
|
||||
|
||||
.device-details {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,6 @@
|
||||
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createWebStorageValueStore } from "@vrtmrz/value-store/web-storage";
|
||||
import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
@@ -11,7 +13,6 @@ import {
|
||||
ObsidianTweakValueService,
|
||||
ObsidianTestService,
|
||||
ObsidianDatabaseEventService,
|
||||
ObsidianConfigService,
|
||||
ObsidianKeyValueDBService,
|
||||
ObsidianControlService,
|
||||
} from "./ObsidianServices";
|
||||
@@ -46,6 +47,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
const setting = new ObsidianSettingService(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: createWebStorageValueStore(compatGlobal.localStorage, { prefix: "" }),
|
||||
});
|
||||
const appLifecycle = new ObsidianAppLifecycleService(context, {
|
||||
settingService: setting,
|
||||
@@ -85,10 +87,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
databaseEvents: databaseEvents,
|
||||
vault: vault,
|
||||
});
|
||||
const config = new ObsidianConfigService(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const config = setting.config;
|
||||
const replicator = new ObsidianReplicatorService(context, {
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/
|
||||
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
|
||||
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
|
||||
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
|
||||
import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/ConfigServiceBrowserCompat";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
@@ -58,10 +57,7 @@ export class ObsidianReplicatorService extends InjectableReplicatorService<Obsid
|
||||
super(context, withSleepPreference(dependencies));
|
||||
}
|
||||
|
||||
async runBoundedLocalApplicationActivity<T>(
|
||||
task: () => T | PromiseLike<T>,
|
||||
options?: ActivityOptions
|
||||
): Promise<T> {
|
||||
async runBoundedLocalApplicationActivity<T>(task: () => T | PromiseLike<T>, options?: ActivityOptions): Promise<T> {
|
||||
this.boundedLocalApplicationActivityCount.value++;
|
||||
try {
|
||||
return this.dependencies.activityRunner
|
||||
@@ -84,7 +80,6 @@ export class ObsidianConflictService extends InjectableConflictService<ObsidianS
|
||||
export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {}
|
||||
// InjectableTestService
|
||||
export class ObsidianTestService extends InjectableTestService<ObsidianServiceContext> {}
|
||||
export class ObsidianConfigService extends ConfigServiceBrowserCompat<ObsidianServiceContext> {}
|
||||
|
||||
export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB,
|
||||
@@ -27,19 +26,6 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
protected setItem(key: string, value: string) {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
return compatGlobal.localStorage.setItem(key, value);
|
||||
}
|
||||
protected getItem(key: string): string {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
return compatGlobal.localStorage.getItem(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string): void {
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
compatGlobal.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return await this.context.liveSyncPlugin.saveData(data);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
|
||||
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
|
||||
|
||||
function createSettingService(
|
||||
vaultName: string,
|
||||
deviceValues: MemoryValueStore
|
||||
): ObsidianSettingService<ObsidianServiceContext> {
|
||||
const context = createServiceContext() as unknown as ObsidianServiceContext;
|
||||
const APIService = {
|
||||
addLog: () => undefined,
|
||||
getSystemVaultName: () => vaultName,
|
||||
} as unknown as IAPIService;
|
||||
return new ObsidianSettingService(context, { APIService, deviceValues });
|
||||
}
|
||||
|
||||
describe("normaliseObsidianSettingsData", () => {
|
||||
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
|
||||
@@ -12,4 +29,26 @@ describe("normaliseObsidianSettingsData", () => {
|
||||
|
||||
expect(normaliseObsidianSettingsData(settings)).toBe(settings);
|
||||
});
|
||||
|
||||
it("keeps injected device values independent from each Vault-scoped configuration store", async () => {
|
||||
const firstValues = new MemoryValueStore();
|
||||
const secondValues = new MemoryValueStore();
|
||||
const firstSetting = createSettingService("first-vault", firstValues);
|
||||
const secondSetting = createSettingService("second-vault", secondValues);
|
||||
|
||||
await firstSetting.deviceValues.setValue("shared-key", "device value");
|
||||
await firstSetting.config.setValue("shared-key", "first config");
|
||||
await secondSetting.config.setValue("shared-key", "second config");
|
||||
|
||||
expect(firstSetting.deviceValues).toBe(firstValues);
|
||||
expect(secondSetting.deviceValues).toBe(secondValues);
|
||||
expect(firstSetting.deviceValues).not.toBe(secondSetting.deviceValues);
|
||||
expect(firstSetting.config).not.toBe(firstValues);
|
||||
expect(firstSetting.config).not.toBe(secondSetting.config);
|
||||
await expect(firstSetting.deviceValues.getValue("shared-key")).resolves.toBe("device value");
|
||||
await expect(firstSetting.config.getValue("shared-key")).resolves.toBe("first config");
|
||||
await expect(secondSetting.config.getValue("shared-key")).resolves.toBe("second config");
|
||||
await expect(firstValues.getValue("first-vault-shared-key")).resolves.toBe("first config");
|
||||
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import type { IConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||
@@ -8,7 +8,7 @@ import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy
|
||||
import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = {
|
||||
appLifecycle: AppLifecycleService<T>;
|
||||
config: ConfigService<T>;
|
||||
config: IConfigService;
|
||||
replicator: ReplicatorService<T>;
|
||||
APIService: IAPIService;
|
||||
control: IControlService;
|
||||
|
||||
@@ -44,17 +44,17 @@ export class CompatibilityReviewController {
|
||||
return this._initialised;
|
||||
}
|
||||
|
||||
private readAcknowledgedVersion(): string | null {
|
||||
private async readAcknowledgedVersion(): Promise<string | null> {
|
||||
const setting = this.core.services.setting;
|
||||
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
|
||||
const currentMarker = await this.core.services.config.getValue(DATABASE_COMPATIBILITY_VERSION_KEY, "");
|
||||
if (currentMarker) return currentMarker;
|
||||
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
const legacyMarker = setting.getDeviceLocalConfig(legacyKey);
|
||||
const legacyMarker = await setting.deviceValues.getValue(legacyKey, "");
|
||||
if (!legacyMarker) return null;
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
await setting.deviceValues.delete(legacyKey);
|
||||
return legacyMarker;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class CompatibilityReviewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acknowledgedVersion = this.readAcknowledgedVersion();
|
||||
const acknowledgedVersion = await this.readAcknowledgedVersion();
|
||||
const evaluation = evaluateCompatibilityPause({
|
||||
acknowledgedVersion,
|
||||
currentVersion: this.currentVersion,
|
||||
@@ -85,7 +85,7 @@ export class CompatibilityReviewController {
|
||||
|
||||
this.pause = evaluation.pause;
|
||||
if (evaluation.initialiseAcknowledgedVersion) {
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
this._initialised = true;
|
||||
return true;
|
||||
}
|
||||
@@ -115,9 +115,9 @@ export class CompatibilityReviewController {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
await setting.deviceValues.delete(legacyKey);
|
||||
this.pause = undefined;
|
||||
this.ui.clearReminder();
|
||||
await this.core.services.control.applySettings();
|
||||
|
||||
@@ -33,13 +33,32 @@ function createFixture(
|
||||
} = {}
|
||||
) {
|
||||
const local = new Map<string, string>();
|
||||
const deviceValues = new Map<string, string>();
|
||||
if (options.marker !== undefined && options.marker !== null) {
|
||||
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
|
||||
}
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
|
||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||
local.set(legacyKey, options.legacyMarker);
|
||||
deviceValues.set(legacyKey, options.legacyMarker);
|
||||
}
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
local.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
local.delete(key);
|
||||
}),
|
||||
};
|
||||
const deviceValueStore = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => deviceValues.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
deviceValues.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
deviceValues.delete(key);
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
versionUpFlash: options.versionUpFlash ?? "",
|
||||
isConfigured: options.isConfigured ?? true,
|
||||
@@ -49,15 +68,13 @@ function createFixture(
|
||||
const setting = {
|
||||
currentSettings: () => settings,
|
||||
getSettingsMigrationState: () => migrationState(options.migration),
|
||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
||||
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
|
||||
deleteDeviceLocalConfig: (key: string) => local.delete(key),
|
||||
deviceValues: deviceValueStore,
|
||||
saveSettingData,
|
||||
};
|
||||
const core = {
|
||||
services: {
|
||||
setting,
|
||||
config,
|
||||
vault: { getVaultName: () => "Test Vault" },
|
||||
control: { applySettings },
|
||||
},
|
||||
@@ -69,7 +86,18 @@ function createFixture(
|
||||
clearReminder: vi.fn(),
|
||||
};
|
||||
const controller = new CompatibilityReviewController(core, ui, 12);
|
||||
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings };
|
||||
return {
|
||||
controller,
|
||||
ui,
|
||||
local,
|
||||
deviceValues,
|
||||
config,
|
||||
deviceValueStore,
|
||||
legacyKey,
|
||||
settings,
|
||||
saveSettingData,
|
||||
applySettings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compatibility review controller", () => {
|
||||
@@ -196,10 +224,96 @@ describe("compatibility review controller", () => {
|
||||
await fixture.controller.initialise();
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.local.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
});
|
||||
|
||||
it("saves the scoped marker before deleting the legacy device value", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||
await saving;
|
||||
fixture.local.set(key, value);
|
||||
});
|
||||
|
||||
const initialising = fixture.controller.initialise();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||
expect(fixture.deviceValueStore.delete).not.toHaveBeenCalled();
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
|
||||
finishSave();
|
||||
await initialising;
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.initialised).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a failed marker read as an absent value", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
fixture.config.getValue.mockRejectedValueOnce(new Error("marker read failed"));
|
||||
|
||||
await expect(fixture.controller.initialise()).rejects.toThrow("marker read failed");
|
||||
|
||||
expect(fixture.deviceValueStore.getValue).not.toHaveBeenCalled();
|
||||
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the compatibility pause blocked until its async marker write succeeds", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||
await saving;
|
||||
fixture.local.set(key, value);
|
||||
});
|
||||
|
||||
const reviewing = fixture.controller.openReview();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
|
||||
finishSave();
|
||||
await reviewing;
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a storage-failed acknowledgement blocked and retryable", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
fixture.config.setValue.mockRejectedValueOnce(new Error("marker write failed"));
|
||||
|
||||
await expect(fixture.controller.openReview()).rejects.toThrow("marker write failed");
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JoinedDevice, JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { JoinedDevicesView } from "./joinedDevicesDialogTypes";
|
||||
|
||||
/** Compare recognised SemVer values; unknown development labels have no ordering. */
|
||||
export function comparePluginVersions(left: string, right: string): number | undefined {
|
||||
const parse = (value: string) => {
|
||||
const match =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
||||
value
|
||||
);
|
||||
if (!match) return undefined;
|
||||
const prerelease = match[4]?.split(".");
|
||||
if (prerelease?.some((part) => /^0\d+$/.test(part))) return undefined;
|
||||
return { core: match.slice(1, 4).map(BigInt), prerelease };
|
||||
};
|
||||
const a = parse(left);
|
||||
const b = parse(right);
|
||||
if (!a || !b) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
|
||||
}
|
||||
if (!a.prerelease || !b.prerelease) return a.prerelease ? -1 : b.prerelease ? 1 : 0;
|
||||
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
||||
const x = a.prerelease[i];
|
||||
const y = b.prerelease[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
if (x === y) continue;
|
||||
const xn = /^\d+$/.test(x);
|
||||
const yn = /^\d+$/.test(y);
|
||||
if (xn && yn) return BigInt(x) > BigInt(y) ? 1 : -1;
|
||||
if (xn !== yn) return xn ? -1 : 1;
|
||||
return x > y ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function joinedDevicesView(
|
||||
snapshot: JoinedDevicesSnapshot,
|
||||
self: Omit<JoinedDevice, "accepted">
|
||||
): JoinedDevicesView {
|
||||
const ownRecord = snapshot.devices.find((device) => device.id === self.id);
|
||||
const ownDevice: JoinedDevice = { ...ownRecord, ...self, accepted: ownRecord?.accepted ?? false };
|
||||
return {
|
||||
...snapshot,
|
||||
devices: [ownDevice, ...snapshot.devices.filter((device) => device.id !== self.id)].map((device) => ({
|
||||
...device,
|
||||
self: device.id === self.id,
|
||||
lockedOut: snapshot.locked && !device.accepted,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function differentDeviceVersions(snapshot: JoinedDevicesSnapshot, ownId: string, ownVersion: string) {
|
||||
const newer = new Set<string>();
|
||||
const older = new Set<string>();
|
||||
for (const device of snapshot.devices) {
|
||||
if (device.id === ownId || !device.pluginVersion) continue;
|
||||
const comparison = comparePluginVersions(device.pluginVersion, ownVersion);
|
||||
if (comparison !== undefined && comparison > 0) newer.add(device.pluginVersion);
|
||||
if (comparison !== undefined && comparison < 0) older.add(device.pluginVersion);
|
||||
}
|
||||
return { newer: [...newer].sort(), older: [...older].sort() };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { comparePluginVersions, differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
|
||||
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const empty: JoinedDevicesSnapshot = { milestonePresent: false, locked: false, cleaned: false, devices: [] };
|
||||
|
||||
describe("joined device presentation", () => {
|
||||
it("always shows this device first, including before its remote record exists", () => {
|
||||
const view = joinedDevicesView(
|
||||
{ ...empty, devices: [{ id: "another", accepted: true }] },
|
||||
{ id: "self", platform: "ios", pluginVersion: "1.0.39" }
|
||||
);
|
||||
expect(view.devices.map((device) => device.id)).toEqual(["self", "another"]);
|
||||
expect(view.devices[0]).toMatchObject({ self: true, accepted: false, lockedOut: false, platform: "ios" });
|
||||
});
|
||||
it("keeps the remote last-recorded date and acceptance while showing current local versions", () => {
|
||||
const view = joinedDevicesView(
|
||||
{
|
||||
...empty,
|
||||
milestonePresent: true,
|
||||
locked: true,
|
||||
devices: [
|
||||
{ id: "other", accepted: true },
|
||||
{ id: "self", accepted: false, lastConnected: 123, pluginVersion: "1.0.0" },
|
||||
],
|
||||
},
|
||||
{ id: "self", pluginVersion: "1.1.0" }
|
||||
);
|
||||
expect(view.devices[0]).toMatchObject({
|
||||
self: true,
|
||||
lockedOut: true,
|
||||
lastConnected: 123,
|
||||
pluginVersion: "1.1.0",
|
||||
});
|
||||
expect(view.devices[1].lockedOut).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version advisories", () => {
|
||||
it.each([
|
||||
["1.10.0", "1.9.9", 1],
|
||||
["1.0.0", "1.0.0-rc.1", 1],
|
||||
["1.0.0-beta.2", "1.0.0-beta.10", -1],
|
||||
["1.0.0-1", "1.0.0-alpha", -1],
|
||||
["1.0.0-alpha", "1.0.0-alpha.1", -1],
|
||||
["1.0.0+first", "1.0.0+second", 0],
|
||||
["99999999999999999.0.0", "99999999999999998.0.0", 1],
|
||||
])("compares %s with %s", (a, b, result) => expect(comparePluginVersions(a, b)).toBe(result));
|
||||
it.each(["", "development", "1.0", "01.0.0", "1.0.0-01", "v1.0.0"])(
|
||||
"does not order unknown version %s",
|
||||
(version) => {
|
||||
expect(comparePluginVersions(version, "1.0.0")).toBeUndefined();
|
||||
expect(comparePluginVersions("1.0.0", version)).toBeUndefined();
|
||||
}
|
||||
);
|
||||
it("excludes this device and unknown versions, and deduplicates versions", () => {
|
||||
const snapshot = {
|
||||
...empty,
|
||||
devices: [
|
||||
{ id: "self", accepted: false, pluginVersion: "9.0.0" },
|
||||
{ id: "a", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "b", accepted: false, pluginVersion: "2.0.0" },
|
||||
{ id: "c", accepted: false, pluginVersion: "0.9.0" },
|
||||
{ id: "d", accepted: true, pluginVersion: "custom" },
|
||||
],
|
||||
};
|
||||
expect(differentDeviceVersions(snapshot, "self", "1.0.0")).toEqual({ newer: ["2.0.0"], older: ["0.9.0"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { declareUICommand } from "@vrtmrz/livesync-commonlib/ui-commands";
|
||||
|
||||
export const OPEN_JOINED_DEVICES = declareUICommand<void, "closed" | "already-open" | "unavailable">(
|
||||
"livesync:joined-devices"
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ForgetJoinedDeviceResult, JoinedDevice } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export interface JoinedDeviceRow extends JoinedDevice {
|
||||
readonly self: boolean;
|
||||
readonly lockedOut: boolean;
|
||||
}
|
||||
|
||||
export interface JoinedDevicesView {
|
||||
readonly milestonePresent: boolean;
|
||||
readonly locked: boolean;
|
||||
readonly cleaned: boolean;
|
||||
readonly devices: readonly JoinedDeviceRow[];
|
||||
}
|
||||
|
||||
export interface JoinedDevicesDialogOptions {
|
||||
refresh(): Promise<JoinedDevicesView>;
|
||||
forget(id: string): Promise<ForgetJoinedDeviceResult>;
|
||||
cancel(): void;
|
||||
}
|
||||
@@ -63,28 +63,27 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||
host.services.setting.setSmallConfig(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||
async function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||
await host.services.setting.config.setValue(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||
}
|
||||
|
||||
function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
const saved = host.services.setting.getSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
async function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
const saved = await host.services.setting.config.getValue(SIMPLE_FETCH_MODE_KEY, "");
|
||||
if (!saved) return undefined;
|
||||
let remembered: ReturnType<typeof buildSimpleFetchResult> = undefined;
|
||||
try {
|
||||
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
|
||||
if (stage1) {
|
||||
const remembered = buildSimpleFetchResult(stage1, stage2);
|
||||
if (remembered) return remembered;
|
||||
}
|
||||
if (stage1) remembered = buildSimpleFetchResult(stage1, stage2);
|
||||
} catch {
|
||||
// Clear below; the saved choice is optional and can be rebuilt by asking again.
|
||||
}
|
||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
if (remembered) return remembered;
|
||||
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||
}
|
||||
|
||||
async function confirmTranslatedChoice(
|
||||
@@ -104,7 +103,7 @@ async function confirmTranslatedChoice(
|
||||
export async function askSimpleFetchMode(
|
||||
host: NecessaryServices<"UI" | "setting", never>
|
||||
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
|
||||
const remembered = getRememberedSimpleFetchMode(host);
|
||||
const remembered = await getRememberedSimpleFetchMode(host);
|
||||
if (remembered) return remembered;
|
||||
|
||||
const msg = $msg(
|
||||
@@ -169,7 +168,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
||||
if (stage2 === STAGE2_ABORT) {
|
||||
return "aborted";
|
||||
}
|
||||
rememberSimpleFetchMode(host, stage1, stage2);
|
||||
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||
return buildSimpleFetchResult(stage1, stage2)!;
|
||||
}
|
||||
|
||||
@@ -198,7 +197,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
||||
if (stage2 === STAGE2_ABORT) {
|
||||
return "aborted";
|
||||
}
|
||||
rememberSimpleFetchMode(host, stage1, stage2);
|
||||
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||
return buildSimpleFetchResult(stage1, stage2)!;
|
||||
}
|
||||
|
||||
@@ -234,18 +233,18 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return undefined;
|
||||
}
|
||||
const result = await askSimpleFetchMode(host);
|
||||
if (result === "cancelled") {
|
||||
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
if (result === "aborted") {
|
||||
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
@@ -256,7 +255,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
|
||||
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
|
||||
@@ -295,7 +294,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
}
|
||||
await host.serviceModules.rebuilder.finishRebuild();
|
||||
await cleanupFlag();
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -10,19 +10,26 @@ const KEEP_LOCAL = "Keep local files even if not on remote";
|
||||
function createHost(savedChoice?: string) {
|
||||
const smallConfig = new Map<string, string>();
|
||||
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
};
|
||||
const confirmWithMessage =
|
||||
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
|
||||
const host = {
|
||||
services: {
|
||||
UI: { confirm: { confirmWithMessage } },
|
||||
setting: {
|
||||
getSmallConfig: (key: string) => smallConfig.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => smallConfig.set(key, value),
|
||||
deleteSmallConfig: (key: string) => smallConfig.delete(key),
|
||||
config,
|
||||
},
|
||||
},
|
||||
} as unknown as NecessaryServices<"UI" | "setting", never>;
|
||||
return { host, confirmWithMessage, smallConfig };
|
||||
return { host, confirmWithMessage, smallConfig, config };
|
||||
}
|
||||
|
||||
afterEach(() => setLang("def"));
|
||||
@@ -50,6 +57,75 @@ describe("Fast Setup translated choices", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the saved choice before returning it to the fetch workflow", async () => {
|
||||
const { host, confirmWithMessage, smallConfig, config } = createHost();
|
||||
let markSaveStarted!: () => void;
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
markSaveStarted = resolve;
|
||||
});
|
||||
let finishSave!: () => void;
|
||||
const save = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
config.setValue.mockImplementation(async (key, value) => {
|
||||
markSaveStarted();
|
||||
await save;
|
||||
smallConfig.set(key, value);
|
||||
});
|
||||
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
|
||||
|
||||
const asking = askSimpleFetchMode(host);
|
||||
await saveStarted;
|
||||
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
|
||||
|
||||
finishSave();
|
||||
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
|
||||
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
|
||||
stage1: REMOTE_WINS,
|
||||
stage2: KEEP_LOCAL,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates a stored-choice read failure instead of treating it as missing", async () => {
|
||||
const { host, confirmWithMessage, config } = createHost();
|
||||
config.getValue.mockRejectedValueOnce(new Error("choice read failed"));
|
||||
|
||||
await expect(askSimpleFetchMode(host)).rejects.toThrow("choice read failed");
|
||||
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for an invalid optional choice to be deleted before asking again", async () => {
|
||||
const { host, confirmWithMessage, smallConfig, config } = createHost("not-json");
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deletion = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
config.delete.mockImplementation(async (key) => {
|
||||
markDeleteStarted();
|
||||
await deletion;
|
||||
smallConfig.delete(key);
|
||||
});
|
||||
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
|
||||
|
||||
const asking = askSimpleFetchMode(host);
|
||||
await deleteStarted;
|
||||
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(true);
|
||||
|
||||
finishDelete();
|
||||
await expect(asking).resolves.toBe("cancelled");
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
|
||||
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
|
||||
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
|
||||
|
||||
@@ -107,13 +107,15 @@ const createSettingServiceMock = () => {
|
||||
}),
|
||||
suspendAllSync: vi.fn(() => Promise.resolve()),
|
||||
suspendExtraSync: vi.fn(() => Promise.resolve()),
|
||||
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""),
|
||||
setSmallConfig: vi.fn((key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
deleteSmallConfig: vi.fn((key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
config: {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -785,7 +787,7 @@ describe("Red Flag Feature", () => {
|
||||
);
|
||||
|
||||
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -830,7 +832,7 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
|
||||
@@ -892,7 +894,7 @@ describe("Red Flag Feature", () => {
|
||||
|
||||
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
||||
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("should return false and cleanup when quick flow is cancelled", async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface IncompleteDocumentsDependencies {
|
||||
readonly localDatabase: Pick<LiveSyncLocalDB, "findAllNormalDocs" | "getDBEntryFromMeta">;
|
||||
readonly getPath: StartupPathReader;
|
||||
readonly isTargetFile: (path: string) => Promise<boolean>;
|
||||
readonly storageAccess: Pick<StorageAccess, "readHiddenFileBinary" | "getFileStub">;
|
||||
readonly storageAccess: Pick<StorageAccess, "readHiddenFileBinary" | "statHidden" | "getFileStub">;
|
||||
readonly fileHandler: Pick<IFileHandler, "storeFileToDB">;
|
||||
readonly keyValueDB: Pick<KeyValueDatabase, "get" | "set">;
|
||||
readonly noticeGroups: NoticeGroups;
|
||||
@@ -94,16 +94,11 @@ export async function checkIncompleteDocuments(
|
||||
}
|
||||
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
|
||||
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await dependencies.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
const storageStat = await dependencies.storageAccess.statHidden(path);
|
||||
if (!storageStat || storageStat.type !== "file") {
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
const sizeOnStorage = storageFileContent.byteLength;
|
||||
let sizeOnStorage = storageStat.size;
|
||||
const recordedSize = doc.size;
|
||||
const docBlob = readAsBlob(doc);
|
||||
const actualSize = docBlob.size;
|
||||
@@ -113,6 +108,19 @@ export async function checkIncompleteDocuments(
|
||||
sizeOnStorage !== recordedSize ||
|
||||
isConflicted
|
||||
) {
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await dependencies.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
sizeOnStorage = storageFileContent.byteLength;
|
||||
// The file may have changed since stat, so recheck using the bytes read.
|
||||
if (recordedSize === actualSize && sizeOnStorage === actualSize && !isConflicted) {
|
||||
continue;
|
||||
}
|
||||
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
|
||||
errorFiles.push({
|
||||
path,
|
||||
@@ -125,6 +133,17 @@ export async function checkIncompleteDocuments(
|
||||
Logger(
|
||||
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
|
||||
);
|
||||
} else {
|
||||
const currentStat = await dependencies.storageAccess.statHidden(path);
|
||||
if (
|
||||
!currentStat ||
|
||||
currentStat.type !== "file" ||
|
||||
currentStat.size !== storageStat.size ||
|
||||
currentStat.mtime !== storageStat.mtime ||
|
||||
currentStat.ctime !== storageStat.ctime
|
||||
) {
|
||||
throw new Error("Storage changed during the incomplete document check");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errorFiles.length == 0) {
|
||||
|
||||
@@ -99,6 +99,7 @@ function createDependencies(findAllNormalDocs: FindAllNormalDocs = noDocuments,
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
storageAccess: {
|
||||
readHiddenFileBinary,
|
||||
statHidden: vi.fn(async (path: string) => getFixture(path)?.stub.stat ?? null),
|
||||
getFileStub,
|
||||
},
|
||||
fileHandler: { storeFileToDB },
|
||||
@@ -157,6 +158,74 @@ describe("checkIncompleteDocuments", () => {
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("does not read healthy storage contents only to determine their size", async () => {
|
||||
const healthy = documentFixture("healthy.md", { storageContent: "abc" });
|
||||
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.readHiddenFileBinary).not.toHaveBeenCalled();
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("does not mark a scan complete when storage changes during the size check", async () => {
|
||||
const healthy = documentFixture("changing.md", { storageContent: "abc" });
|
||||
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
||||
vi.mocked(fixture.dependencies.storageAccess.statHidden)
|
||||
.mockResolvedValueOnce(healthy.stub.stat)
|
||||
.mockResolvedValueOnce({ ...healthy.stub.stat, mtime: 3 });
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).rejects.toThrow("Storage changed");
|
||||
|
||||
expect(fixture.readHiddenFileBinary).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer repair when all sizes agree after reading storage", async () => {
|
||||
const healthy = documentFixture("settled.md", { storageContent: "abc" });
|
||||
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
||||
vi.mocked(fixture.dependencies.storageAccess.statHidden).mockResolvedValueOnce({ ...healthy.stub.stat, size: 4 });
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("settled.md");
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("uses the bytes read from storage when a size mismatch remains", async () => {
|
||||
const mismatched = documentFixture("changing.md", { recordedSize: 3, storageContent: "abcde" });
|
||||
const fixture = createDependencies(documentsFrom([mismatched]), [mismatched]);
|
||||
vi.mocked(fixture.dependencies.storageAccess.statHidden).mockResolvedValueOnce({
|
||||
...mismatched.stub.stat,
|
||||
size: 4,
|
||||
});
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("changing.md");
|
||||
expect(fixture.askSelectStringDialogue).toHaveBeenCalledOnce();
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(
|
||||
expect.stringContaining("changing.md (M: 3, A: 3, S: 5)")
|
||||
);
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still reads conflicted storage for the existing repair dialogue", async () => {
|
||||
const conflicted = documentFixture("conflicted.md", { storageContent: "abc", conflicts: ["2-other"] });
|
||||
const fixture = createDependencies(documentsFrom([conflicted]), [conflicted]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("conflicted.md");
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("repairs a recoverable document when FIX is selected", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
NODEINFO_DOCID,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
type EntryNodeInfo,
|
||||
type RemoteDBSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS, type JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
|
||||
import JoinedDevicesDialog from "@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "@/common/replicatorConfigurationIdentity";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
|
||||
import { differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
|
||||
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
|
||||
|
||||
type JoinedDevicesHost = NecessaryServices<
|
||||
"API" | "UI" | "setting" | "database" | "replicator" | "replication" | "appLifecycle",
|
||||
never
|
||||
>;
|
||||
const noticeGroup = "joined-device-versions";
|
||||
|
||||
function remoteIdentity(settings: RemoteDBSettings): string | undefined {
|
||||
if (settings.remoteType === REMOTE_COUCHDB && settings.couchDB_URI && settings.couchDB_DBNAME) {
|
||||
return getCouchDBReplicatorConfigurationIdentity(settings);
|
||||
}
|
||||
if (settings.remoteType === REMOTE_MINIO && settings.endpoint && settings.bucket) {
|
||||
return getObjectStorageReplicatorConfigurationIdentity(settings);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Own the dialogue, advisory Notices, and their per-session state for one host. */
|
||||
export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: ObsidianNoticeGroups): void {
|
||||
const { services } = host;
|
||||
let disposed = false;
|
||||
let active: AbortController | undefined;
|
||||
let checking = false;
|
||||
let lastCheck: { identity: string; at: number } | undefined;
|
||||
const notified = new Map<string, Set<string>>();
|
||||
const available = () =>
|
||||
!disposed &&
|
||||
services.database.isDatabaseReady() &&
|
||||
remoteIdentity(services.setting.currentSettings()) !== undefined;
|
||||
|
||||
const notify = (snapshot: JoinedDevicesSnapshot, ownId: string, identity: string) => {
|
||||
if (disposed || identity !== remoteIdentity(services.setting.currentSettings())) return;
|
||||
const version = services.API.getPluginVersion();
|
||||
const differences = differentDeviceVersions(snapshot, ownId, version);
|
||||
const seen = notified.get(identity) ?? new Set<string>();
|
||||
notified.set(identity, seen);
|
||||
let updated = false;
|
||||
for (const direction of ["newer", "older"] as const) {
|
||||
const versions = differences[direction];
|
||||
if (versions.length === 0) continue;
|
||||
const key = JSON.stringify([version, direction, versions]);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
notices.setItem(noticeGroup, direction, {
|
||||
message:
|
||||
direction === "newer"
|
||||
? $msg("Newer Self-hosted LiveSync versions detected: ${versions}.", {
|
||||
versions: versions.join(", "),
|
||||
})
|
||||
: $msg("Older Self-hosted LiveSync versions detected: ${versions}.", {
|
||||
versions: versions.join(", "),
|
||||
}),
|
||||
});
|
||||
updated = true;
|
||||
}
|
||||
if (!updated) return;
|
||||
notices.removeItem(noticeGroup, "review");
|
||||
notices.setItem(noticeGroup, "review", {
|
||||
message: $msg("Reviewing and aligning Self-hosted LiveSync versions across devices is recommended."),
|
||||
action: {
|
||||
label: $msg("Review devices"),
|
||||
onSelect: () => {
|
||||
if (!disposed && identity === remoteIdentity(services.setting.currentSettings())) {
|
||||
void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
notices.finish(noticeGroup, { durationMs: 15000 });
|
||||
};
|
||||
|
||||
const createOperations = (controller: AbortController): JoinedDevicesDialogOptions => {
|
||||
const settings = { ...services.setting.currentSettings() };
|
||||
const identity = remoteIdentity(settings);
|
||||
const database = services.database.localDatabase;
|
||||
const assertCurrent = () => {
|
||||
if (
|
||||
disposed ||
|
||||
controller.signal.aborted ||
|
||||
!identity ||
|
||||
identity !== remoteIdentity(services.setting.currentSettings()) ||
|
||||
!services.database.isDatabaseReady() ||
|
||||
services.database.localDatabase !== database
|
||||
) {
|
||||
throw new Error("Joined devices are no longer available for this dialogue");
|
||||
}
|
||||
};
|
||||
const ownId = async () => {
|
||||
assertCurrent();
|
||||
const info = await database.localDatabase.get<EntryNodeInfo>(NODEINFO_DOCID);
|
||||
assertCurrent();
|
||||
if (typeof info.nodeid !== "string" || !info.nodeid) throw new Error("Device identity is unavailable");
|
||||
return info.nodeid;
|
||||
};
|
||||
return {
|
||||
refresh: async () => {
|
||||
const id = await ownId();
|
||||
const resource = await services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.JOINED_DEVICES,
|
||||
settings
|
||||
);
|
||||
if (!resource) throw new Error("Joined devices are unavailable");
|
||||
try {
|
||||
assertCurrent();
|
||||
const snapshot = await resource.read();
|
||||
assertCurrent();
|
||||
notify(snapshot, id, identity!);
|
||||
return joinedDevicesView(snapshot, {
|
||||
id,
|
||||
platform: services.API.getPlatform(),
|
||||
pluginVersion: services.API.getPluginVersion(),
|
||||
appVersion: services.API.getAppVersion(),
|
||||
});
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
},
|
||||
forget: async (id) => {
|
||||
if (id === (await ownId())) throw new Error("This device cannot be forgotten");
|
||||
const resource = await services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.JOINED_DEVICES,
|
||||
settings
|
||||
);
|
||||
if (!resource) throw new Error("Joined devices are unavailable");
|
||||
try {
|
||||
assertCurrent();
|
||||
const result = await resource.forget(id);
|
||||
assertCurrent();
|
||||
return result;
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
},
|
||||
cancel: () => controller.abort(),
|
||||
};
|
||||
};
|
||||
|
||||
const unregister = services.UI.commands.register(OPEN_JOINED_DEVICES, async () => {
|
||||
if (!available()) return "unavailable";
|
||||
if (active) return "already-open";
|
||||
const controller = new AbortController();
|
||||
active = controller;
|
||||
try {
|
||||
await services.UI.dialogManager.open<"close", JoinedDevicesDialogOptions>(
|
||||
JoinedDevicesDialog,
|
||||
createOperations(controller)
|
||||
);
|
||||
} catch {
|
||||
// The host may close its dialogues during unload.
|
||||
} finally {
|
||||
controller.abort();
|
||||
if (active === controller) active = undefined;
|
||||
}
|
||||
return "closed";
|
||||
});
|
||||
|
||||
const checkVersions = () => {
|
||||
if (!available() || checking) return;
|
||||
const identity = remoteIdentity(services.setting.currentSettings())!;
|
||||
if (lastCheck?.identity === identity && Date.now() - lastCheck.at < 60000) return;
|
||||
lastCheck = { identity, at: Date.now() };
|
||||
checking = true;
|
||||
const controller = new AbortController();
|
||||
void Promise.resolve()
|
||||
.then(() => createOperations(controller).refresh())
|
||||
.catch(() => {
|
||||
// Advisory inspection never changes the outcome of synchronisation.
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort();
|
||||
checking = false;
|
||||
});
|
||||
};
|
||||
const stopBeforeReplicate = services.replication.onBeforeReplicate.addHandler(() => {
|
||||
checkVersions();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "joined-devices",
|
||||
name: $msg("Review devices"),
|
||||
icon: "monitor-smartphone",
|
||||
checkCallback: (checking) => {
|
||||
if (!available()) return false;
|
||||
if (!checking) void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
disposed = true;
|
||||
active?.abort();
|
||||
unregister();
|
||||
stopBeforeReplicate();
|
||||
notices.hide(noticeGroup);
|
||||
notified.clear();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UICommandRegistry } from "@vrtmrz/livesync-commonlib/ui-commands";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
|
||||
vi.mock("@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string, values: Record<string, string> = {}) =>
|
||||
message.replace(/\$\{([^}]+)\}/g, (placeholder, key: string) => values[key] ?? placeholder),
|
||||
}));
|
||||
import { useJoinedDevicesFeature } from "./useJoinedDevices";
|
||||
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
|
||||
|
||||
function fixture() {
|
||||
const settings = {
|
||||
...createNewVaultSettings(),
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
};
|
||||
const initialise: Array<() => Promise<boolean>> = [];
|
||||
const unload: Array<() => Promise<boolean>> = [];
|
||||
const before: Array<() => Promise<boolean>> = [];
|
||||
const stopBefore = vi.fn();
|
||||
const commands = new UICommandRegistry();
|
||||
const read = vi.fn<() => Promise<JoinedDevicesSnapshot>>(async () => ({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true, pluginVersion: "2.0.0" }],
|
||||
}));
|
||||
const forget = vi.fn(async (_id: string) => "forgotten" as const);
|
||||
const dispose = vi.fn(async () => {});
|
||||
const createResource = vi.fn(async () => ({ read, forget, dispose }));
|
||||
let close!: () => void;
|
||||
const dialog = vi.fn(
|
||||
(_component: unknown, _options: JoinedDevicesDialogOptions) =>
|
||||
new Promise<void>((resolve) => {
|
||||
close = resolve;
|
||||
})
|
||||
);
|
||||
const notices = { setItem: vi.fn(), removeItem: vi.fn(), finish: vi.fn(), hide: vi.fn() };
|
||||
const palette: Array<{ id: string; checkCallback: (checking: boolean) => boolean }> = [];
|
||||
const database = { localDatabase: { get: vi.fn(async () => ({ nodeid: "self" })) } };
|
||||
const host = {
|
||||
services: {
|
||||
API: {
|
||||
getPluginVersion: () => "1.0.0",
|
||||
getAppVersion: () => "1.9.0",
|
||||
getPlatform: () => "desktop",
|
||||
addCommand: (command: (typeof palette)[number]) => palette.push(command),
|
||||
},
|
||||
UI: { commands, dialogManager: { open: dialog } },
|
||||
setting: { currentSettings: () => settings },
|
||||
database: { isDatabaseReady: () => true, localDatabase: database },
|
||||
replicator: { createRemoteResource: createResource },
|
||||
replication: {
|
||||
onBeforeReplicate: {
|
||||
addHandler: (handler: (typeof before)[number]) => {
|
||||
before.push(handler);
|
||||
return stopBefore;
|
||||
},
|
||||
},
|
||||
},
|
||||
appLifecycle: {
|
||||
onInitialise: { addHandler: (handler: (typeof initialise)[number]) => initialise.push(handler) },
|
||||
onUnload: { addHandler: (handler: (typeof unload)[number]) => unload.push(handler) },
|
||||
},
|
||||
},
|
||||
serviceModules: {},
|
||||
} as unknown as Parameters<typeof useJoinedDevicesFeature>[0];
|
||||
useJoinedDevicesFeature(host, notices as unknown as Parameters<typeof useJoinedDevicesFeature>[1]);
|
||||
return {
|
||||
settings,
|
||||
initialise,
|
||||
unload,
|
||||
before,
|
||||
stopBefore,
|
||||
commands,
|
||||
read,
|
||||
forget,
|
||||
dispose,
|
||||
createResource,
|
||||
dialog,
|
||||
notices,
|
||||
palette,
|
||||
database,
|
||||
close: () => close(),
|
||||
};
|
||||
}
|
||||
|
||||
async function open(data: ReturnType<typeof fixture>) {
|
||||
const pending = data.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
const options = data.dialog.mock.calls[0][1];
|
||||
return { options, pending };
|
||||
}
|
||||
|
||||
describe("joined devices service feature", () => {
|
||||
it("opens one dialogue through the command and disposes each completed read", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({
|
||||
status: "handled",
|
||||
value: "already-open",
|
||||
});
|
||||
expect((await options.refresh()).devices[0]).toMatchObject({ id: "self", self: true });
|
||||
expect(data.dispose).toHaveBeenCalledTimes(1);
|
||||
data.close();
|
||||
expect(await pending).toEqual({ status: "handled", value: "closed" });
|
||||
await expect(options.refresh()).rejects.toThrow();
|
||||
});
|
||||
it("prevents self removal and fences an old dialogue after a remote switch", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
await expect(options.forget("self")).rejects.toThrow();
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
await expect(options.forget("other")).rejects.toThrow();
|
||||
expect(data.forget).not.toHaveBeenCalled();
|
||||
expect(data.createResource).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("discards a stale read, releases the resource, and emits no stale Notice", async () => {
|
||||
const data = fixture();
|
||||
let resolve!: (value: JoinedDevicesSnapshot) => void;
|
||||
data.read.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const reading = options.refresh();
|
||||
await vi.waitFor(() => expect(data.read).toHaveBeenCalled());
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
resolve({ milestonePresent: true, locked: false, cleaned: false, devices: [] });
|
||||
await expect(reading).rejects.toThrow();
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
expect(data.notices.setItem).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it.each(["dialogue cancellation", "remote switch", "unload"] as const)(
|
||||
"does not send a pending forget operation after %s",
|
||||
async (invalidation) => {
|
||||
const data = fixture();
|
||||
const resource = { read: data.read, forget: data.forget, dispose: data.dispose };
|
||||
let resolve!: (value: typeof resource) => void;
|
||||
data.createResource.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const forgetting = options.forget("other");
|
||||
const rejected = expect(forgetting).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(data.createResource).toHaveBeenCalledOnce());
|
||||
|
||||
if (invalidation === "dialogue cancellation") options.cancel();
|
||||
if (invalidation === "remote switch") data.settings.couchDB_DBNAME = "new-vault";
|
||||
if (invalidation === "unload") for (const unload of data.unload) await unload();
|
||||
resolve(resource);
|
||||
|
||||
await rejected;
|
||||
expect(data.forget).not.toHaveBeenCalled();
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
await pending;
|
||||
}
|
||||
);
|
||||
it("discards a completed forget result after the dialogue closes and releases its resource", async () => {
|
||||
const data = fixture();
|
||||
let resolve!: (value: "forgotten") => void;
|
||||
data.forget.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const forgetting = options.forget("other");
|
||||
const rejected = expect(forgetting).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(data.forget).toHaveBeenCalledWith("other"));
|
||||
data.close();
|
||||
await pending;
|
||||
resolve("forgotten");
|
||||
await rejected;
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("propagates a failed forget operation after releasing its resource", async () => {
|
||||
const data = fixture();
|
||||
const failure = new Error("conditional write failed");
|
||||
data.forget.mockRejectedValueOnce(failure);
|
||||
const { options, pending } = await open(data);
|
||||
await expect(options.forget("other")).rejects.toBe(failure);
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("shares one review action across newer and older device versions", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "newer-a", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "newer-b", accepted: true, pluginVersion: "3.0.0" },
|
||||
{ id: "older-a", accepted: true, pluginVersion: "0.9.0" },
|
||||
{ id: "older-b", accepted: true, pluginVersion: "0.8.0" },
|
||||
{ id: "older-duplicate", accepted: true, pluginVersion: "0.8.0" },
|
||||
],
|
||||
});
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
const items = data.notices.setItem.mock.calls.map(([, , item]) => item);
|
||||
expect(items.filter((item) => item.action)).toHaveLength(1);
|
||||
const messages = items.map((item) => item.message).join("\n");
|
||||
expect(messages).toContain("Self-hosted LiveSync");
|
||||
expect(messages).toMatch(/Newer.*2\.0\.0, 3\.0\.0/);
|
||||
expect(messages).toMatch(/Older.*0\.8\.0, 0\.9\.0/);
|
||||
expect(messages.match(/0\.8\.0/g)).toHaveLength(1);
|
||||
expect(messages.match(/Reviewing and aligning/g)).toHaveLength(1);
|
||||
expect(items[items.length - 1]?.action?.label).toBe("Review devices");
|
||||
expect(data.notices.finish).toHaveBeenCalledExactlyOnceWith("joined-device-versions", {
|
||||
durationMs: 15000,
|
||||
});
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("keeps the shared action last when another version direction arrives", async () => {
|
||||
const data = fixture();
|
||||
const visible = new Map<string, { message: string; action?: unknown }>();
|
||||
data.notices.setItem.mockImplementation((_group, key, item) => visible.set(key, item));
|
||||
data.notices.removeItem.mockImplementation((_group, key) => visible.delete(key));
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["newer", "review"]);
|
||||
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "other", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "older", accepted: true, pluginVersion: "0.9.0" },
|
||||
],
|
||||
});
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["newer", "older", "review"]);
|
||||
expect([...visible.values()].filter((item) => item.action)).toHaveLength(1);
|
||||
|
||||
visible.clear();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "other", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "older", accepted: true, pluginVersion: "0.8.0" },
|
||||
],
|
||||
});
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["older", "review"]);
|
||||
expect([...visible.values()].filter((item) => item.action)).toHaveLength(1);
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("does not show a review action when no different version is reported", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true, pluginVersion: "1.0.0" }],
|
||||
});
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
expect(data.notices.setItem).not.toHaveBeenCalled();
|
||||
expect(data.notices.removeItem).not.toHaveBeenCalled();
|
||||
expect(data.notices.finish).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("deduplicates advisory Notices and fences their actions after configuration changes", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
await options.refresh();
|
||||
expect(data.notices.setItem).toHaveBeenCalledTimes(2);
|
||||
const actions = data.notices.setItem.mock.calls.map(([, , item]) => item.action).filter(Boolean);
|
||||
expect(actions).toHaveLength(1);
|
||||
const action = actions[0];
|
||||
data.close();
|
||||
await pending;
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
action.onSelect();
|
||||
expect(data.dialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("keeps inspection failures out of the replication result and unregisters on unload", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockRejectedValue(new Error("unavailable"));
|
||||
expect(await data.before[0]()).toBe(true);
|
||||
await vi.waitFor(() => expect(data.dispose).toHaveBeenCalledOnce());
|
||||
expect(await data.before[0]()).toBe(true);
|
||||
expect(data.read).toHaveBeenCalledTimes(1);
|
||||
for (const unload of data.unload) await unload();
|
||||
expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({ status: "unavailable" });
|
||||
expect(data.stopBefore).toHaveBeenCalledOnce();
|
||||
expect(data.notices.hide).toHaveBeenCalled();
|
||||
});
|
||||
it("uses the same UI command from the command palette", async () => {
|
||||
const data = fixture();
|
||||
for (const initialise of data.initialise) await initialise();
|
||||
expect(data.palette[0].checkCallback(true)).toBe(true);
|
||||
expect(data.dialog).not.toHaveBeenCalled();
|
||||
data.palette[0].checkCallback(false);
|
||||
expect(data.dialog).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
});
|
||||
});
|
||||
@@ -69,9 +69,9 @@ export function useReviewHarness(
|
||||
await createFileContentHashBenchmarkOperations(activeWindow.performance),
|
||||
activeWindow.performance
|
||||
),
|
||||
readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
||||
writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value),
|
||||
deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
||||
readContinuation: () => services.config.getValue(REVIEW_HARNESS_STATE_KEY, ""),
|
||||
writeContinuation: (value) => services.config.setValue(REVIEW_HARNESS_STATE_KEY, value),
|
||||
deleteContinuation: () => services.config.delete(REVIEW_HARNESS_STATE_KEY),
|
||||
restart: () => services.appLifecycle.performRestart(),
|
||||
reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE),
|
||||
copyText: async (value) => {
|
||||
@@ -94,14 +94,14 @@ export function useReviewHarness(
|
||||
let registered = false;
|
||||
let openAfterLayout = false;
|
||||
|
||||
services.appLifecycle.onSettingLoaded.addHandler(() => {
|
||||
services.appLifecycle.onSettingLoaded.addHandler(async () => {
|
||||
if (!continuationConsumed) {
|
||||
await controller.consumeContinuation();
|
||||
continuationConsumed = true;
|
||||
controller.consumeContinuation();
|
||||
}
|
||||
const snapshot = controller.snapshot();
|
||||
openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null;
|
||||
if (!services.setting.currentSettings().enableDebugTools || registered) return Promise.resolve(true);
|
||||
if (!services.setting.currentSettings().enableDebugTools || registered) return true;
|
||||
|
||||
registered = true;
|
||||
services.API.registerWindow(
|
||||
@@ -115,7 +115,7 @@ export function useReviewHarness(
|
||||
void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS);
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
return true;
|
||||
});
|
||||
|
||||
services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||
|
||||
@@ -31,6 +31,15 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
const layoutReadyHandlers: Array<() => Promise<boolean>> = [];
|
||||
const local = new Map<string, string>();
|
||||
if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation);
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
local.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
local.delete(key);
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
enableDebugTools: options.enableDebugTools ?? true,
|
||||
liveSync: false,
|
||||
@@ -63,10 +72,8 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
requiresSyncReview: true,
|
||||
reviewReasons: [],
|
||||
}),
|
||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
||||
deleteSmallConfig: (key: string) => local.delete(key),
|
||||
},
|
||||
config,
|
||||
appLifecycle: {
|
||||
onSettingLoaded: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)),
|
||||
@@ -106,6 +113,7 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
api,
|
||||
settings,
|
||||
local,
|
||||
config,
|
||||
settingLoadedHandlers,
|
||||
layoutReadyHandlers,
|
||||
compatibilityReview,
|
||||
@@ -144,6 +152,74 @@ describe("Review Harness composition", () => {
|
||||
expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS);
|
||||
});
|
||||
|
||||
it("holds the setting-loaded lifecycle until continuation deletion has completed", async () => {
|
||||
const requestedAt = "2026-07-18T11:59:00.000Z";
|
||||
const continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: `compatibility-review-${requestedAt}`,
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
});
|
||||
const fixture = createFixture({ continuation });
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deleting = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
fixture.config.delete.mockImplementation(async (key) => {
|
||||
markDeleteStarted();
|
||||
await deleting;
|
||||
fixture.local.delete(key);
|
||||
});
|
||||
|
||||
const loading = fixture.settingLoadedHandlers[0]();
|
||||
await deleteStarted;
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
|
||||
|
||||
finishDelete();
|
||||
await loading;
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
|
||||
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("can retry one-shot continuation consumption after a deletion failure", async () => {
|
||||
const requestedAt = "2026-07-18T11:59:00.000Z";
|
||||
const continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: `compatibility-review-${requestedAt}`,
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
});
|
||||
const fixture = createFixture({ continuation });
|
||||
fixture.config.delete
|
||||
.mockRejectedValueOnce(new Error("continuation delete failed"))
|
||||
.mockImplementation(async (key) => {
|
||||
fixture.local.delete(key);
|
||||
});
|
||||
|
||||
await expect(fixture.settingLoadedHandlers[0]()).rejects.toThrow("continuation delete failed");
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.settingLoadedHandlers[0]();
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
|
||||
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the actual compatibility controller as the guided review boundary", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
@@ -168,7 +169,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
|
||||
it("owns core start, directory scan, status reporting, and shutdown", async () => {
|
||||
const reportStatus = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
|
||||
|
||||
await runtime.start();
|
||||
|
||||
@@ -193,7 +194,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("finalises application readiness after importing local files for optional P2P", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
|
||||
@@ -239,7 +240,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("does not publish readiness after a partial local-file import", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
|
||||
@@ -269,7 +270,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("does not publish readiness when database initialisation rejects manual preparation", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.onDatabaseInitialised.mockResolvedValue(false);
|
||||
@@ -282,7 +283,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("does not publish readiness when pending file events cannot be committed", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.commitPendingFileEvents.mockResolvedValue(false);
|
||||
@@ -295,7 +296,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("does not repeat readiness finalisation after a successful manual scan", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -311,7 +312,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
it("rejects a failed core start after cleaning up the partial runtime", async () => {
|
||||
runtimeMocks.onLoad.mockResolvedValue(false);
|
||||
const reportStatus = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus, deviceValues: new MemoryValueStore() });
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync");
|
||||
|
||||
@@ -325,7 +326,7 @@ describe("WebAppRuntime lifecycle", () => {
|
||||
|
||||
it("shuts down once before scheduling a host reload", async () => {
|
||||
const scheduleReload = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload });
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload, deviceValues: new MemoryValueStore() });
|
||||
await runtime.start();
|
||||
|
||||
runtimeMocks.options?.restart?.schedule();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { MemoryValueStore } from "@vrtmrz/value-store";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WebPeerRuntime } from "@/apps/webpeer/src/WebPeerRuntime";
|
||||
import { WebPeerRuntime, type WebPeerRuntimeOptions } from "@/apps/webpeer/src/WebPeerRuntime";
|
||||
|
||||
function createMemoryStore(): SimpleStore<unknown> {
|
||||
const values = new Map<string, unknown>();
|
||||
@@ -22,11 +23,31 @@ function createMemoryStore(): SimpleStore<unknown> {
|
||||
}
|
||||
|
||||
describe("WebPeer runtime composition", () => {
|
||||
const runtimes = new Set<WebPeerRuntime>();
|
||||
|
||||
function createRuntime(options: WebPeerRuntimeOptions): WebPeerRuntime {
|
||||
const runtime = new WebPeerRuntime(options);
|
||||
runtimes.add(runtime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
for (const runtime of runtimes) {
|
||||
await runtime.shutdown();
|
||||
}
|
||||
} finally {
|
||||
runtimes.clear();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves one context and live P2P result across the service graph and pane host", () => {
|
||||
const context = createServiceContext({
|
||||
translate: (key) => `webpeer:${key}`,
|
||||
});
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
context,
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
@@ -42,7 +63,8 @@ describe("WebPeer runtime composition", () => {
|
||||
});
|
||||
|
||||
it("loads settings and opens the local database before announcing layout readiness", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
@@ -67,7 +89,8 @@ describe("WebPeer runtime composition", () => {
|
||||
it("delegates automatic P2P startup to the resumed lifecycle handler", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
@@ -77,9 +100,7 @@ describe("WebPeer runtime composition", () => {
|
||||
P2P_Enabled: true,
|
||||
});
|
||||
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
|
||||
const onResumed = vi
|
||||
.spyOn(runtime.services.appLifecycle, "onResumed")
|
||||
.mockResolvedValue(true);
|
||||
const onResumed = vi.spyOn(runtime.services.appLifecycle, "onResumed").mockResolvedValue(true);
|
||||
const open = vi.spyOn(runtime.p2p.transportLifecycle, "connect").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.start();
|
||||
@@ -93,17 +114,20 @@ describe("WebPeer runtime composition", () => {
|
||||
});
|
||||
|
||||
it("rejects start when the browser-local database cannot be opened", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ ...DEFAULT_SETTINGS });
|
||||
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false);
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
|
||||
});
|
||||
|
||||
it("isolates a specialised runtime and applies its device name before opening the database", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
deviceName: " p2p-check-browser-desktop-abc ",
|
||||
systemVaultName: "p2p-check-vault",
|
||||
@@ -114,15 +138,39 @@ describe("WebPeer runtime composition", () => {
|
||||
P2P_AutoStart: false,
|
||||
P2P_Enabled: false,
|
||||
});
|
||||
const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined);
|
||||
let finishWrite!: () => void;
|
||||
const setValue = vi.spyOn(runtime.services.config, "setValue").mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishWrite = resolve;
|
||||
})
|
||||
);
|
||||
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => {
|
||||
expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
|
||||
expect(setValue).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
const starting = runtime.start();
|
||||
await vi.waitFor(() => expect(setValue).toHaveBeenCalledOnce());
|
||||
expect(openDatabase).not.toHaveBeenCalled();
|
||||
finishWrite();
|
||||
await starting;
|
||||
|
||||
expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault");
|
||||
expect(openDatabase).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the current device name after the pane updates the store", async () => {
|
||||
const runtime = createRuntime({
|
||||
deviceValues: new MemoryValueStore(),
|
||||
store: createMemoryStore(),
|
||||
systemVaultName: "webpeer-vault",
|
||||
});
|
||||
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({ ...DEFAULT_SETTINGS });
|
||||
expect(await runtime.getDeviceName()).toBe("webpeer-vault");
|
||||
await runtime.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, "first-name");
|
||||
expect(await runtime.getDeviceName()).toBe("first-name");
|
||||
await runtime.services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, "renamed-device");
|
||||
expect(await runtime.getDeviceName()).toBe("renamed-device");
|
||||
});
|
||||
});
|
||||
@@ -17,13 +17,12 @@ export const SERVICE_CONTEXT_MEMBERS = [
|
||||
"vault",
|
||||
"test",
|
||||
"UI",
|
||||
"config",
|
||||
"keyValueDB",
|
||||
"control",
|
||||
] as const satisfies readonly Exclude<keyof ServiceHub, "context">[];
|
||||
] as const satisfies readonly Exclude<keyof ServiceHub, "context" | "config">[];
|
||||
|
||||
export type ServiceContextMember = (typeof SERVICE_CONTEXT_MEMBERS)[number];
|
||||
type MissingServiceContextMember = Exclude<Exclude<keyof ServiceHub, "context">, ServiceContextMember>;
|
||||
type MissingServiceContextMember = Exclude<Exclude<keyof ServiceHub, "context" | "config">, ServiceContextMember>;
|
||||
const serviceContextMembersAreExhaustive: [MissingServiceContextMember] extends [never] ? true : never = true;
|
||||
void serviceContextMembersAreExhaustive;
|
||||
|
||||
|
||||
@@ -123,7 +123,9 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
|
||||
|
||||
`configuration-encryption` needs no remote service. It applies a custom configuration key through the settings interface, restarts the same isolated Vault, and applies a change to only the custom string. It then copies the encrypted settings into a fresh profile without that key and checks masked input, wrong-input retry, cancellation without a settings write or database initialisation, re-enabling for another attempt, access to the settings tab, and restoration after a further restart. Connection, E2EE, profile, and independent ID values must remain unchanged. These checks establish ordinary Apply and restart behaviour, without simulating process termination between the device-key and settings writes. It also checks changes from custom and ASK modes to Default through the settings interface, and recovery from a wrong saved device key, including cancellation, the validated replacement key, and another restart.
|
||||
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
|
||||
`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The P2P setup check delays the saved device-name read, requires the name field and both continuation controls to remain disabled, then verifies the saved name and selected connection settings reach the setup workflow. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database.
|
||||
|
||||
`test:e2e:obsidian:joined-devices` uses a temporary Vault and isolated CouchDB database to open Joined devices from Remote Configuration, a version Notice, and its registered command. `test:e2e:obsidian:joined-devices-object-storage` runs the same assertions against a unique Object Storage prefix, including the conditional removal supported by the test S3 service. It checks that newer and older Self-hosted LiveSync versions share one Notice with one recommendation and review action, that the current device appears first with current metadata, and that locked-out and other-device records are shown. It confirms that a manual refresh reads an updated remote record and that cancelling the inline confirmation leaves the remote unchanged before forgetting one device. It verifies the raw milestone retains the other device, remote state, and preferred-ID tweaks, then checks mobile scrolling, safe-area bounds, and touch targets. It captures the version Notice, desktop list, forget confirmation, completed removal, and mobile dialogue; unrelated Notices are dismissed before the list captures.
|
||||
|
||||
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
|
||||
|
||||
@@ -246,14 +248,20 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`test:e2e:obsidian:document-history-restore` creates a normal note, records a logical deletion while retaining readable chunks, and restores the deleted content through the visible Document History dialogue. It requires the action itself to create and reflect a new non-deleted successor revision, reopens the history at that successor, and captures the file picker, readable deleted revision, restored Vault file, and new successor revision. This scenario owns the ordinary-history restoration boundary; conflict resolution remains with **Inspect conflicts and file/database differences**.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. That target-pattern case keeps a normal-file allow-list for Markdown and PDF files enabled on both devices, then requires exact hidden JSON content after the receiving device includes it. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database, and also runs in the complete local suite. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path.
|
||||
|
||||
Before enablement, the scenario directly requests initialisation while Hidden File Sync is disabled. It requires a refused result, unchanged local files, Metadata revisions, and processed markers, and no initialisation progress Notice.
|
||||
|
||||
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
|
||||
|
||||
`test:e2e:obsidian:customisation-catalogue-reload` verifies that the first Reload lists all three files of a sample plug-in and that Apply restores their contents. It also checks a second enumeration and Apply in one temporary Vault with a local database. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_CUSTOMISATION_CATALOGUE_OUTPUT` to choose the JSON report path.
|
||||
`test:e2e:obsidian:customisation-catalogue-reload` opens the Customisation Sync dialogue, enables Maintenance mode, and clicks Reload. It verifies that this first Reload lists all three files of a sample plug-in, then selects the source copy and clicks Apply to restore their contents. It also checks a second enumeration and Apply through the existing methods in one temporary Vault with a local database. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_CUSTOMISATION_CATALOGUE_OUTPUT` to choose the JSON report path.
|
||||
|
||||
The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
|
||||
|
||||
`--case=automatic` separately checks that choosing **Automatic** while Hidden File Sync is disabled displays a Notice without changing modes, files, Metadata revisions, or processed markers. The fixture starts with a saved Automatic selection and requires it to survive start-up and both rejected actions. It also checks disabling the feature after opening **Initial Action**. After enablement, selecting a whole plug-in must initialise both **MAIN** and **DATA**, and transfer their files to the other Vault. Separate temporary Vaults exercise **Overwrite Remote**, **Overwrite Local**, and **Use newer**. The latter two start with different local and database contents; deterministic per-file modification times require **Use newer** to keep the newer database MAIN content and the newer local DATA content.
|
||||
|
||||
`test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`.
|
||||
|
||||
`test:e2e:obsidian:remote-feature-change` starts real Obsidian with continuous CouchDB replication, then changes the remote version document from generation 12 to generation 13 with an unknown feature. It waits for the control document to reach the local database and the active Replicator to retire, checks that another replication is refused, and verifies that an already accepted Vault note remains intact. After restarting the same Vault, the current remote declaration still blocks finite replication and the actual continuous connection attempt. No KV feature history is involved. It is a focused test outside `test:e2e:obsidian:local-suite`; recovery with a future compatible client remains a separate validation boundary.
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function readE2eCompatibilityMarker(
|
||||
return await evalObsidianJson<CompatibilityMarkerState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const setting=core.services.setting;",
|
||||
"const settings=setting.currentSettings();",
|
||||
@@ -122,7 +122,7 @@ export async function readE2eCompatibilityMarker(
|
||||
"return JSON.stringify({",
|
||||
"vaultName,additionalSuffix,expectedStorageKey,",
|
||||
"rawStorageValue:localStorage.getItem(expectedStorageKey),",
|
||||
"serviceValue:setting.getSmallConfig(markerKey),",
|
||||
"serviceValue:await setting.config.getValue(markerKey,''),",
|
||||
"versionUpFlash:settings.versionUpFlash,",
|
||||
"});",
|
||||
"})()",
|
||||
|
||||
@@ -288,7 +288,7 @@ export async function readRuntimeUpgradeState(
|
||||
"localDatabaseDocumentCount:databaseInfo.doc_count,",
|
||||
"nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',",
|
||||
"legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),",
|
||||
"compatibilityMarker:setting.getSmallConfig('database-compatibility-version')??'',",
|
||||
"compatibilityMarker:await (setting.config?.getValue?.('database-compatibility-version','')??Promise.resolve(setting.getSmallConfig?.('database-compatibility-version')??'')),",
|
||||
"compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))",
|
||||
".filter((key)=>key!==null)",
|
||||
".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))",
|
||||
@@ -335,7 +335,7 @@ export async function readRuntimeSettingsUpgradeState(
|
||||
"const migrationState=setting.getSettingsMigrationState?.();",
|
||||
"return JSON.stringify({",
|
||||
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
|
||||
"compatibilityMarker:setting.getSmallConfig?.('database-compatibility-version')??'',",
|
||||
"compatibilityMarker:await (setting.config?.getValue?.('database-compatibility-version','')??Promise.resolve(setting.getSmallConfig?.('database-compatibility-version')??'')),",
|
||||
"migrationState,",
|
||||
"settings:{",
|
||||
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
|
||||
|
||||
@@ -47,6 +47,16 @@ const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 3000
|
||||
const e2eePassphrase = `%${randomBytes(24).toString("base64url")}`;
|
||||
const notePath = "E2E/manual-couchdb/from-first-device.md";
|
||||
const noteContent = "# Manual CouchDB setup\n\nThis note was sent by the manually configured first device.\n";
|
||||
const binaryPath = "E2E/manual-couchdb/content-boundaries.png";
|
||||
const binaryBlockSize = 32 * 1024;
|
||||
function binaryFixture(): Uint8Array {
|
||||
const bytes = new Uint8Array(binaryBlockSize * 8);
|
||||
for (let index = 0; index < 8; index++) {
|
||||
bytes[index * binaryBlockSize] = index + 2;
|
||||
bytes[(index + 1) * binaryBlockSize - 1] = 1;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
const returnNotePath = "E2E/manual-couchdb/from-second-device.md";
|
||||
const returnNoteContent =
|
||||
"# Manual CouchDB return journey\n\nThis note returned through a Setup URI generated by the first device.\n";
|
||||
@@ -395,6 +405,32 @@ async function waitForVaultFile(
|
||||
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
|
||||
}
|
||||
|
||||
async function waitForBinaryFile(vault: TemporaryVault): Promise<void> {
|
||||
const expected = Buffer.from(binaryFixture());
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if ((await readFile(join(vault.path, binaryPath))).equals(expected)) return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error("The fetched binary did not match its exact original bytes.");
|
||||
}
|
||||
|
||||
async function readChunkInventory(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<string[]> {
|
||||
return await evalObsidianJson<string[]>(
|
||||
context.cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;
|
||||
return JSON.stringify(rows.filter(row=>row.doc?.type==='leaf').map(row=>row.id).sort());
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; children: string[] }): Promise<void> {
|
||||
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
@@ -681,6 +717,8 @@ async function main(): Promise<void> {
|
||||
process.env.E2E_OBSIDIAN_INDEPENDENT_IDS === "true" ? randomBytes(32).toString("base64url") : undefined;
|
||||
let firstEntryId: string | undefined;
|
||||
let returnEntryId: string | undefined;
|
||||
let binaryRevision: string | undefined;
|
||||
let chunkInventory: string[] = [];
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -712,10 +750,34 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
|
||||
const size = await evalObsidianJson<number>(
|
||||
context.cliBinary,
|
||||
`JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.customChunkSize)`,
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(size, 0, "New manual setup did not retain the common Chunk-size recommendation.");
|
||||
await evalObsidianJson(
|
||||
context.cliBinary,
|
||||
`(async()=>{
|
||||
const bytes=new Uint8Array(${binaryBlockSize}*8);
|
||||
for(let index=0;index<8;index++){
|
||||
bytes[index*${binaryBlockSize}]=index+2;
|
||||
bytes[(index+1)*${binaryBlockSize}-1]=1;
|
||||
}
|
||||
await app.vault.createBinary(${JSON.stringify(binaryPath)},bytes.buffer);
|
||||
return JSON.stringify(true);
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
const binaryEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, binaryPath);
|
||||
assertEqual(binaryEntry.children.length, 8, "V3 size 0 did not use the expected content boundaries.");
|
||||
binaryRevision = binaryEntry.rev;
|
||||
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
||||
firstEntryId = entry.id;
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForRemoteEntry(context, entry);
|
||||
await waitForRemoteEntry(context, binaryEntry);
|
||||
chunkInventory = await readChunkInventory(context, session);
|
||||
} catch (error) {
|
||||
await captureFailure(session, "first-device");
|
||||
throw error;
|
||||
@@ -729,6 +791,13 @@ async function main(): Promise<void> {
|
||||
session = await startUnconfiguredSession(context, vaultA);
|
||||
try {
|
||||
await assertRestoredE2EEPassphrase(session, context.cliBinary);
|
||||
const unchanged = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, binaryPath);
|
||||
assertEqual(unchanged.rev, binaryRevision, "Restart rewrote unchanged binary Metadata.");
|
||||
assertEqual(
|
||||
JSON.stringify(await readChunkInventory(context, session)),
|
||||
JSON.stringify(chunkInventory),
|
||||
"Restart created Chunks for unchanged Vault files."
|
||||
);
|
||||
await scheduleRemoteOverwrite(session.remoteDebuggingPort);
|
||||
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures));
|
||||
screenshots.push(
|
||||
@@ -775,6 +844,7 @@ async function main(): Promise<void> {
|
||||
await assertPersistedE2EE(vaultB, independentIdSource);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForVaultFile(vaultB, notePath, noteContent);
|
||||
await waitForBinaryFile(vaultB);
|
||||
if (independentIdSource) {
|
||||
assertEqual(
|
||||
await readDocumentId(context.cliBinary, session.cliEnv, notePath),
|
||||
@@ -815,7 +885,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Manual CouchDB onboarding created and tested its database, generated a second-device Setup URI, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
|
||||
`Manual CouchDB onboarding retained size 0, used binary content boundaries, preserved unchanged revisions and Chunks across restart, fetched exact binary bytes, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
|
||||
);
|
||||
} finally {
|
||||
await stopTrackedSessions(context).catch((error: unknown) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const pluginName = "livesync-catalogue-example";
|
||||
@@ -73,6 +74,16 @@ async function matchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||
return matches.sort();
|
||||
}
|
||||
|
||||
async function waitForMatchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
let matches = await matchingFiles(vaultPath, files);
|
||||
while (matches.length !== Object.keys(files).length && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
matches = await matchingFiles(vaultPath, files);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -148,8 +159,20 @@ async function main() {
|
||||
await settle();
|
||||
return JSON.stringify({seeded:true});
|
||||
`);
|
||||
const opened = await evaluate<{ opened: boolean }>(
|
||||
`return JSON.stringify({opened:app.commands.executeCommandById('obsidian-livesync:livesync-plugin-dialog-ex')});`
|
||||
);
|
||||
if (!opened.opened) throw new Error("Could not open the Customisation Sync dialogue command");
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||
await dialogue
|
||||
.locator("label")
|
||||
.filter({ hasText: "Maintenance mode" })
|
||||
.locator('input[type="checkbox"]')
|
||||
.check();
|
||||
await dialogue.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
});
|
||||
const catalogue = await evaluate<{ files: string[]; documents: number }>(`${configCode}${settleCode}
|
||||
await addOn.reloadPluginList(false);
|
||||
await settle();
|
||||
${sourceRowCode}
|
||||
let documents=0;
|
||||
@@ -167,13 +190,16 @@ async function main() {
|
||||
.sort()
|
||||
);
|
||||
await removeFixtures(vault.path, pluginFiles);
|
||||
const firstApply = await evaluate(
|
||||
`${configCode}${sourceRowCode}return JSON.stringify({applied:await addOn.applyDataV2(row)});`
|
||||
);
|
||||
report.firstApply = firstApply;
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||
const row = dialogue.locator(`.filerow:has(select option[value="${sourceTerm}"])`);
|
||||
await row.locator("select").selectOption(sourceTerm);
|
||||
await row.locator(".body").getByRole("button", { name: "✓", exact: true }).click();
|
||||
});
|
||||
report.firstApply = { action: "UI Apply", sourceTerm };
|
||||
record(
|
||||
"#1271: first Apply restores all file contents",
|
||||
await matchingFiles(vault.path, pluginFiles),
|
||||
await waitForMatchingFiles(vault.path, pluginFiles),
|
||||
Object.keys(pluginFiles).sort()
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
MODE_AUTOMATIC,
|
||||
VERSIONING_DOCID,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type LoadedEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
@@ -23,11 +29,12 @@ import {
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator } from "playwright";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||
@@ -59,6 +66,18 @@ const pluginDir = ".obsidian/plugins/livesync-e2e-sample";
|
||||
const pluginManifestPath = `${pluginDir}/manifest.json`;
|
||||
const pluginMainPath = `${pluginDir}/main.js`;
|
||||
const pluginStylesPath = `${pluginDir}/styles.css`;
|
||||
const pluginDisplayName = "LiveSync E2E Sample";
|
||||
const pluginDataPath = `${pluginDir}/data.json`;
|
||||
const pluginDataContent = JSON.stringify({ source: "customisation-sync-a", enabled: true }, null, 4) + "\n";
|
||||
const targetPluginDataContent = JSON.stringify({ source: "customisation-sync-b", enabled: true }, null, 4) + "\n";
|
||||
const existingAutomaticKey = "SNIPPET/livesync-e2e-existing-automatic.css";
|
||||
const existingAutomaticSelections: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] = {
|
||||
[existingAutomaticKey]: {
|
||||
key: existingAutomaticKey,
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["snippets/livesync-e2e-existing-automatic.css"],
|
||||
},
|
||||
};
|
||||
const identicalSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-identical.css";
|
||||
const identicalSnippetContent = ".livesync-customisation-identical { color: #3d6f54; }\n";
|
||||
const differentSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-different.css";
|
||||
@@ -104,7 +123,8 @@ const pluginFixtures = [
|
||||
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
|
||||
] as const;
|
||||
|
||||
type CustomisationSyncCase = "all" | "visibility" | "mtime";
|
||||
type CustomisationSyncCase = "all" | "visibility" | "mtime" | "automatic";
|
||||
type AutomaticInitialAction = "Overwrite Remote" | "Overwrite Local" | "Use newer";
|
||||
|
||||
type CustomisationSyncTestGlobal = typeof globalThis & {
|
||||
app?: { commands?: { executeCommandById(commandId: string): boolean } };
|
||||
@@ -131,6 +151,14 @@ type CustomisationScanResult = {
|
||||
files: string[];
|
||||
};
|
||||
|
||||
type AutomaticSyncSnapshot = {
|
||||
syncInternalFiles: boolean;
|
||||
pluginSyncExtendedSetting: Record<string, { key: string; mode: number; files: string[] }>;
|
||||
hiddenFiles: { path: string; rev: string; deleted: boolean }[];
|
||||
processedFiles: [string, string | undefined][];
|
||||
processedDatabaseFiles: [string, string | undefined][];
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, path);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
@@ -147,10 +175,11 @@ function selectedCase(): CustomisationSyncCase {
|
||||
if (args.length === 0) return "all";
|
||||
const arg = args[0];
|
||||
if (args.length !== 1 || !arg?.startsWith("--case=")) {
|
||||
throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime]");
|
||||
throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime|automatic]");
|
||||
}
|
||||
const selected = arg.slice("--case=".length);
|
||||
if (selected === "all" || selected === "visibility" || selected === "mtime") return selected;
|
||||
if (selected === "all" || selected === "visibility" || selected === "mtime" || selected === "automatic")
|
||||
return selected;
|
||||
throw new Error(`Unknown Customisation Sync E2E case: ${selected}`);
|
||||
}
|
||||
|
||||
@@ -244,6 +273,282 @@ async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyn
|
||||
});
|
||||
}
|
||||
|
||||
async function openAutomaticModeForPlugin(page: Page, pluginName: string): Promise<Locator> {
|
||||
const opened = await page.evaluate(() =>
|
||||
(globalThis as CustomisationSyncTestGlobal).app?.commands?.executeCommandById(
|
||||
"obsidian-livesync:livesync-plugin-dialog-ex"
|
||||
)
|
||||
);
|
||||
if (opened !== true) throw new Error("Could not open the Customisation Sync dialogue command.");
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||
const pluginRow = dialogue.locator(".labelrow").filter({ hasText: pluginName }).first();
|
||||
await pluginRow.waitFor({ state: "visible", timeout: 10000 });
|
||||
await pluginRow.locator("button.status").click();
|
||||
const automaticOption = page.locator(".menu-item").filter({ hasText: "Automatic" }).last();
|
||||
await automaticOption.waitFor({ state: "visible", timeout: 10000 });
|
||||
await automaticOption.click();
|
||||
return dialogue;
|
||||
}
|
||||
|
||||
async function selectAutomaticInitialAction(page: Page, label: AutomaticInitialAction): Promise<void> {
|
||||
const action = page.locator(".menu-item").filter({ hasText: label }).last();
|
||||
await action.waitFor({ state: "visible", timeout: 10000 });
|
||||
await action.click();
|
||||
}
|
||||
|
||||
async function readAutomaticSyncSnapshot(cliBinary: string, env: NodeJS.ProcessEnv): Promise<AutomaticSyncSnapshot> {
|
||||
return await evalObsidianJson<AutomaticSyncSnapshot>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const hidden=core.getAddOn('HiddenFileSync');",
|
||||
"const files=await hidden.getAllDatabaseFiles();",
|
||||
"const processed=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
|
||||
"return JSON.stringify({",
|
||||
"syncInternalFiles:core.settings.syncInternalFiles,",
|
||||
"pluginSyncExtendedSetting:core.settings.pluginSyncExtendedSetting,",
|
||||
"hiddenFiles:files.map((entry)=>{const path=hidden.getPath(entry);return {path:path.startsWith('i:')?path.slice(2):path,rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)};}).sort((a,b)=>a.path.localeCompare(b.path)),",
|
||||
"processedFiles:processed(hidden._fileInfoLastProcessed),",
|
||||
"processedDatabaseFiles:processed(hidden._databaseInfoLastProcessed),",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readAutomaticFileContents(context: RunnerContext, session: ObsidianLiveSyncSession) {
|
||||
const files = await evalObsidianJson<
|
||||
{ path: string; entry: LoadedEntry | false; local: string; localMtime: number }[]
|
||||
>(
|
||||
context.cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const paths=${JSON.stringify([pluginStylesPath, pluginDataPath])};`,
|
||||
"const files=await Promise.all(paths.map(async(path)=>{",
|
||||
"const entry=await core.localDatabase.getDBEntry('i:'+path,undefined,false,true);",
|
||||
"const stat=await app.vault.adapter.stat(path);",
|
||||
"if(!stat) throw new Error('Missing Automatic fixture file: '+path);",
|
||||
"return {path,entry,local:await app.vault.adapter.read(path),localMtime:stat.mtime};",
|
||||
"}));",
|
||||
"return JSON.stringify(files);",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
return await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
path: file.path,
|
||||
stored: file.entry ? await readAsBlob(file.entry).text() : null,
|
||||
storedMtime: file.entry ? file.entry.mtime : 0,
|
||||
local: file.local,
|
||||
localMtime: file.localMtime,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function setHiddenSyncEnabledForFixture(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`core.settings.syncInternalFiles=${JSON.stringify(enabled)};`,
|
||||
"return JSON.stringify({syncInternalFiles:core.settings.syncInternalFiles});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectAutomaticSyncSelection(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
pluginName: string,
|
||||
initialAction: AutomaticInitialAction
|
||||
): Promise<void> {
|
||||
const noticeText =
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
|
||||
const trackedFiles = [pluginStylesPath, pluginDataPath];
|
||||
const contentsBefore = await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)));
|
||||
const disabledBefore = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
assertEqual(
|
||||
JSON.stringify(disabledBefore.pluginSyncExtendedSetting[existingAutomaticKey]),
|
||||
JSON.stringify(existingAutomaticSelections[existingAutomaticKey]),
|
||||
"The fixture did not retain the saved Automatic selection while Hidden File Sync was disabled."
|
||||
);
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||
const notice = page.locator(".notice:visible").filter({ hasText: noticeText }).last();
|
||||
await notice.waitFor({ state: "visible", timeout: 10000 });
|
||||
const initialActionMenu = page.locator(".menu:visible").filter({ hasText: "Initial Action" });
|
||||
if ((await initialActionMenu.count()) !== 0) {
|
||||
throw new Error("Automatic displayed an Initial Action menu while Hidden File Sync was disabled.");
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
|
||||
await notice.click({ force: true });
|
||||
await notice.waitFor({ state: "hidden", timeout: 10000 });
|
||||
});
|
||||
|
||||
const disabledAfter = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
const contentsAfter = await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)));
|
||||
if (JSON.stringify(disabledAfter) !== JSON.stringify(disabledBefore)) {
|
||||
throw new Error(
|
||||
`Automatic changed Hidden File Sync state while it was disabled: ${JSON.stringify({
|
||||
before: disabledBefore,
|
||||
after: disabledAfter,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
assertEqual(
|
||||
JSON.stringify(contentsAfter),
|
||||
JSON.stringify(contentsBefore),
|
||||
"Automatic changed plug-in files while Hidden File Sync was disabled."
|
||||
);
|
||||
|
||||
// Exercise the second guard: the user may open Initial Action while enabled,
|
||||
// then disable Hidden File Sync before choosing a direction.
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||
const initialActionMenu = page.locator(".menu:visible").filter({ hasText: "Initial Action" });
|
||||
await initialActionMenu.waitFor({ state: "visible", timeout: 10000 });
|
||||
});
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, false);
|
||||
const beforeDisabledAction = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await selectAutomaticInitialAction(page, "Overwrite Remote");
|
||||
const notice = page.locator(".notice:visible").filter({ hasText: noticeText }).last();
|
||||
await notice.waitFor({ state: "visible", timeout: 10000 });
|
||||
await notice.click({ force: true });
|
||||
await notice.waitFor({ state: "hidden", timeout: 10000 });
|
||||
});
|
||||
const afterDisabledAction = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
if (JSON.stringify(afterDisabledAction) !== JSON.stringify(beforeDisabledAction)) {
|
||||
throw new Error(
|
||||
`Automatic changed Hidden File Sync state after it was disabled from Initial Action: ${JSON.stringify({
|
||||
before: beforeDisabledAction,
|
||||
after: afterDisabledAction,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
assertEqual(
|
||||
JSON.stringify(await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)))),
|
||||
JSON.stringify(contentsBefore),
|
||||
"Automatic changed plug-in files after Hidden File Sync was disabled from Initial Action."
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||
if (initialAction !== "Overwrite Remote") {
|
||||
const fixtures = [
|
||||
{
|
||||
path: pluginStylesPath,
|
||||
stored: pluginStylesContent,
|
||||
local: targetPluginStylesContent,
|
||||
storedIsNewer: true,
|
||||
},
|
||||
{
|
||||
path: pluginDataPath,
|
||||
stored: pluginDataContent,
|
||||
local: targetPluginDataContent,
|
||||
storedIsNewer: false,
|
||||
},
|
||||
];
|
||||
const actualFiles = await readAutomaticFileContents(context, session);
|
||||
for (const fixture of fixtures) {
|
||||
const actual = actualFiles.find((file) => file.path === fixture.path);
|
||||
if (
|
||||
!actual ||
|
||||
actual.stored !== fixture.stored ||
|
||||
actual.local !== fixture.local ||
|
||||
actual.storedMtime > actual.localMtime !== fixture.storedIsNewer ||
|
||||
actual.storedMtime === actual.localMtime
|
||||
) {
|
||||
throw new Error(
|
||||
`Automatic direction fixture did not retain distinct contents and modification times: ${JSON.stringify(actual)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
|
||||
await selectAutomaticInitialAction(page, initialAction);
|
||||
for (const label of ["MAIN", "DATA"]) {
|
||||
const row = dialogue.locator(".filerow").filter({ hasText: label }).first();
|
||||
await row.locator(".statusnote").filter({ hasText: "Automatic" }).waitFor({ state: "visible" });
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
|
||||
});
|
||||
|
||||
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true });
|
||||
}
|
||||
const enabled = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
|
||||
const mainMode = enabled.pluginSyncExtendedSetting[`PLUGIN_MAIN/${pluginName}`];
|
||||
const dataMode = enabled.pluginSyncExtendedSetting[`PLUGIN_DATA/${pluginName}`];
|
||||
if (mainMode?.mode !== MODE_AUTOMATIC || dataMode?.mode !== MODE_AUTOMATIC) {
|
||||
throw new Error(
|
||||
`Automatic did not save both plug-in group child settings: ${JSON.stringify({ mainMode, dataMode })}`
|
||||
);
|
||||
}
|
||||
assertEqual(
|
||||
JSON.stringify([...mainMode.files].sort()),
|
||||
JSON.stringify([
|
||||
`plugins/${pluginName}/main.js`,
|
||||
`plugins/${pluginName}/manifest.json`,
|
||||
`plugins/${pluginName}/styles.css`,
|
||||
]),
|
||||
"Automatic did not select the PLUGIN_MAIN files."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify([...dataMode.files].sort()),
|
||||
JSON.stringify([`plugins/${pluginName}/data.json`]),
|
||||
"Automatic did not select the PLUGIN_DATA file."
|
||||
);
|
||||
assertEqual(
|
||||
enabled.hiddenFiles.filter((entry) => entry.path.startsWith(pluginDir + "/")).length,
|
||||
4,
|
||||
"Automatic did not initialise all files in the PLUGIN_ALL group."
|
||||
);
|
||||
const expectedFiles = [
|
||||
{
|
||||
path: pluginStylesPath,
|
||||
content: initialAction === "Overwrite Remote" ? targetPluginStylesContent : pluginStylesContent,
|
||||
},
|
||||
{
|
||||
path: pluginDataPath,
|
||||
content: initialAction === "Overwrite Local" ? pluginDataContent : targetPluginDataContent,
|
||||
},
|
||||
];
|
||||
const deadline = Date.now() + 15000;
|
||||
let actualFiles = await readAutomaticFileContents(context, session);
|
||||
const contentsMatch = () =>
|
||||
actualFiles.every(
|
||||
(file, index) => file.stored === expectedFiles[index].content && file.local === expectedFiles[index].content
|
||||
);
|
||||
while (!contentsMatch() && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
actualFiles = await readAutomaticFileContents(context, session);
|
||||
}
|
||||
if (!contentsMatch()) {
|
||||
throw new Error(`${initialAction} did not apply both MAIN and DATA: ${JSON.stringify(actualFiles)}`);
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
}
|
||||
|
||||
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
|
||||
await rm(join(vaultPath, path), { force: true });
|
||||
}
|
||||
@@ -287,7 +592,8 @@ async function waitForPathContent(
|
||||
async function startConfiguredSession(
|
||||
context: RunnerContext,
|
||||
vault: TemporaryVault,
|
||||
deviceName: string
|
||||
deviceName: string,
|
||||
settings: Partial<Pick<ObsidianLiveSyncSettings, "syncInternalFiles" | "pluginSyncExtendedSetting">> = {}
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const couchDbSettings = {
|
||||
uri: context.couchDb.uri,
|
||||
@@ -306,6 +612,7 @@ async function startConfiguredSession(
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
syncInternalFiles: false,
|
||||
...settings,
|
||||
};
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary: context.binary,
|
||||
@@ -319,6 +626,18 @@ async function startConfiguredSession(
|
||||
});
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
|
||||
if (Object.keys(settings.pluginSyncExtendedSetting ?? {}).length > 0) {
|
||||
const restoredSelections = await evalObsidianJson<ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]>(
|
||||
context.cliBinary,
|
||||
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.pluginSyncExtendedSetting)",
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(restoredSelections),
|
||||
JSON.stringify(settings.pluginSyncExtendedSetting),
|
||||
"The saved Automatic selection changed during start-up."
|
||||
);
|
||||
}
|
||||
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, customisationSettings);
|
||||
await evalObsidianJson<unknown>(
|
||||
context.cliBinary,
|
||||
@@ -389,7 +708,7 @@ async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv,
|
||||
async function deleteCustomisationSyncEntry(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
term?: string
|
||||
): Promise<void> {
|
||||
@@ -418,7 +737,7 @@ async function deleteCustomisationSyncEntry(
|
||||
async function waitForCustomisationEntry(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
term?: string,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
|
||||
@@ -430,7 +749,7 @@ async function waitForCustomisationEntry(
|
||||
async function waitForCustomisationEntries(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
|
||||
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
|
||||
name: string,
|
||||
count: number,
|
||||
term?: string,
|
||||
@@ -570,10 +889,13 @@ async function applyRemoteCustomisationGroup(
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const testCase = selectedCase();
|
||||
async function runWorkflow(
|
||||
testCase: CustomisationSyncCase,
|
||||
initialAction: AutomaticInitialAction = "Overwrite Remote"
|
||||
): Promise<void> {
|
||||
const testVisibility = testCase === "all" || testCase === "visibility";
|
||||
const testMtime = testCase === "all" || testCase === "mtime";
|
||||
const testAutomatic = testCase === "automatic";
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
@@ -604,6 +926,11 @@ async function main(): Promise<void> {
|
||||
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
|
||||
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
|
||||
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
|
||||
if (testAutomatic) {
|
||||
await writeVaultFile(vaultA.path, pluginDataPath, pluginDataContent);
|
||||
await setVaultFileMtime(vaultA.path, pluginStylesPath, targetPluginMtime);
|
||||
await setVaultFileMtime(vaultA.path, pluginDataPath, sourcePluginMtime);
|
||||
}
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await writeVaultFile(vaultA.path, fixture.path, fixture.source);
|
||||
@@ -627,6 +954,14 @@ async function main(): Promise<void> {
|
||||
await setVaultFileMtime(vaultB.path, fixture.path, targetPluginMtime);
|
||||
}
|
||||
}
|
||||
if (testAutomatic) {
|
||||
for (const fixture of pluginFixtures) {
|
||||
await writeVaultFile(vaultB.path, fixture.path, fixture.target);
|
||||
}
|
||||
await writeVaultFile(vaultB.path, pluginDataPath, targetPluginDataContent);
|
||||
await setVaultFileMtime(vaultB.path, pluginStylesPath, sourcePluginMtime);
|
||||
await setVaultFileMtime(vaultB.path, pluginDataPath, targetPluginMtime);
|
||||
}
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
|
||||
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
|
||||
@@ -636,6 +971,9 @@ async function main(): Promise<void> {
|
||||
for (const fixture of pluginFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
}
|
||||
if (testAutomatic) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginDataPath);
|
||||
}
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
@@ -650,15 +988,35 @@ async function main(): Promise<void> {
|
||||
pluginName,
|
||||
3
|
||||
);
|
||||
const pluginDataEntry = testAutomatic
|
||||
? await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "PLUGIN_DATA", pluginName)
|
||||
: undefined;
|
||||
if (testAutomatic && initialAction !== "Overwrite Remote") {
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
|
||||
await evalObsidianJson<unknown>(
|
||||
context.cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const hidden=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');",
|
||||
`const paths=${JSON.stringify([pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath])};`,
|
||||
"const result=await hidden.initialiseInternalFileSync('pushForce',false,paths);",
|
||||
"if(result!==true) throw new Error('Could not prepare source hidden-file documents.');",
|
||||
"return JSON.stringify({initialised:result});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, false);
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
const entries = [entry, configEntry, ...pluginEntries];
|
||||
const entries = [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])];
|
||||
return entries.every(
|
||||
(target) => ids.has(target.id) && target.children.every((childId) => ids.has(childId))
|
||||
);
|
||||
});
|
||||
for (const target of [entry, configEntry, ...pluginEntries]) {
|
||||
for (const target of [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])]) {
|
||||
const remoteEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, target.id);
|
||||
if (
|
||||
!remoteEntry.path?.startsWith("/\\:") ||
|
||||
@@ -678,8 +1036,15 @@ async function main(): Promise<void> {
|
||||
throw new Error("The remote feature list does not declare encrypted internal Metadata.");
|
||||
}
|
||||
await session.app.stop();
|
||||
if (testAutomatic && initialAction === "Overwrite Remote") {
|
||||
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
|
||||
await removeVaultFile(vaultA.path, path);
|
||||
}
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, targetDeviceName);
|
||||
session = await startConfiguredSession(context, vaultB, targetDeviceName, {
|
||||
pluginSyncExtendedSetting: testAutomatic ? existingAutomaticSelections : {},
|
||||
});
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
|
||||
@@ -692,6 +1057,15 @@ async function main(): Promise<void> {
|
||||
}
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
|
||||
if (testAutomatic) {
|
||||
await waitForCustomisationEntry(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
"PLUGIN_DATA",
|
||||
pluginName,
|
||||
sourceDeviceName
|
||||
);
|
||||
}
|
||||
if (testVisibility) {
|
||||
for (const fixture of visibilityFixtures) {
|
||||
const name = fixture.path.split("/").pop() ?? fixture.path;
|
||||
@@ -721,10 +1095,43 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
if (testVisibility) await inspectCustomisationVisibility(session);
|
||||
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
|
||||
if (testAutomatic) {
|
||||
await inspectAutomaticSyncSelection(context, session, vaultB, pluginName, initialAction);
|
||||
}
|
||||
} catch (error) {
|
||||
await session.app.stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
if (testAutomatic) {
|
||||
await session.app.stop();
|
||||
const receivingVault = initialAction === "Overwrite Remote" ? vaultA : await createTemporaryVault();
|
||||
session = await startConfiguredSession(context, receivingVault, sourceDeviceName, {
|
||||
syncInternalFiles: true,
|
||||
});
|
||||
try {
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
const expectedStyles =
|
||||
initialAction === "Overwrite Remote" ? targetPluginStylesContent : pluginStylesContent;
|
||||
const expectedData = initialAction === "Overwrite Local" ? pluginDataContent : targetPluginDataContent;
|
||||
const receivedStyles = await waitForPathContent(
|
||||
receivingVault.path,
|
||||
pluginStylesPath,
|
||||
(content) => content === expectedStyles
|
||||
);
|
||||
const receivedData = await waitForPathContent(
|
||||
receivingVault.path,
|
||||
pluginDataPath,
|
||||
(content) => content === expectedData
|
||||
);
|
||||
assertEqual(receivedStyles, expectedStyles, "Automatic plug-in group lost PLUGIN_MAIN files.");
|
||||
assertEqual(receivedData, expectedData, "Automatic plug-in group lost the PLUGIN_DATA file.");
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
if (receivingVault !== vaultA) await receivingVault.dispose();
|
||||
}
|
||||
console.log(`Automatic ${initialAction} initialised and transferred both plug-in group children.`);
|
||||
return;
|
||||
}
|
||||
if (testCase !== "all") {
|
||||
await session.app.stop();
|
||||
console.log(`Customisation Sync ${testCase} regression case passed.`);
|
||||
@@ -841,6 +1248,18 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const testCase = selectedCase();
|
||||
if (testCase === "automatic") {
|
||||
for (const action of ["Overwrite Remote", "Overwrite Local", "Use newer"] as const) {
|
||||
console.log(`Checking Automatic initial action: ${action}`);
|
||||
await runWorkflow(testCase, action);
|
||||
}
|
||||
} else {
|
||||
await runWorkflow(testCase);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -31,6 +31,18 @@ type DiagnosticPageGlobal = typeof globalThis & {
|
||||
diagnosticDownload?: { name: string; content: string };
|
||||
};
|
||||
|
||||
async function findVisibleElement(page: Page, selector: string): Promise<Locator> {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
for (const candidate of page.context().pages()) {
|
||||
const element = candidate.locator(selector);
|
||||
if (await element.isVisible()) return element;
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
throw new Error(`No Obsidian renderer contains the visible element: ${selector}`);
|
||||
}
|
||||
|
||||
async function openCommand(page: Page, kind: "usage" | "integrity") {
|
||||
const id = kind === "usage" ? "analyse-database" : "inspect-database-integrity";
|
||||
const opened = await page.evaluate(
|
||||
@@ -243,11 +255,14 @@ async function assertDiagnosticFileLookup(
|
||||
session: ObsidianLiveSyncSession,
|
||||
mobile = false
|
||||
): Promise<void> {
|
||||
const dialoguePage = hatch.page();
|
||||
const viewport = page.viewportSize();
|
||||
if (viewport) await dialoguePage.setViewportSize(viewport);
|
||||
const setting = hatch.locator(".setting-item").filter({
|
||||
has: page.getByText("Find a file from a report pseudonym", { exact: true }),
|
||||
has: hatch.page().getByText("Find a file from a report pseudonym", { exact: true }),
|
||||
});
|
||||
await setting.getByRole("button", { name: "Find file", exact: true }).click();
|
||||
const dialogue = page.locator(".diagnostic-file-lookup");
|
||||
const dialogue = dialoguePage.locator(".diagnostic-file-lookup");
|
||||
await dialogue.waitFor({ state: "visible", timeout });
|
||||
const input = dialogue.getByLabel("Report path pseudonym", { exact: true });
|
||||
await input.fill("file-001");
|
||||
@@ -259,8 +274,8 @@ async function assertDiagnosticFileLookup(
|
||||
await candidates.first().waitFor({ timeout });
|
||||
assert.equal(await candidates.count(), 2, "Distinct Metadata IDs claiming the same path must remain selectable");
|
||||
if (mobile) {
|
||||
await assertNoHorizontalOverflow(page, dialogue, { label: "Pseudonym file lookup matches" });
|
||||
await assertNoHorizontalOverflow(page, dialogue.locator(".lookup-candidates"), {
|
||||
await assertNoHorizontalOverflow(dialoguePage, dialogue, { label: "Pseudonym file lookup matches" });
|
||||
await assertNoHorizontalOverflow(dialoguePage, dialogue.locator(".lookup-candidates"), {
|
||||
label: "Pseudonym file lookup candidate list",
|
||||
});
|
||||
assert.ok(
|
||||
@@ -270,7 +285,7 @@ async function assertDiagnosticFileLookup(
|
||||
await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"database-diagnostics-file-matches-mobile.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".diagnostic-file-lookup") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.diagnostic-file-lookup)")
|
||||
);
|
||||
}
|
||||
const shadow = candidates.filter({ hasText: shadowId });
|
||||
@@ -294,28 +309,30 @@ async function assertDiagnosticFileLookup(
|
||||
for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()]) {
|
||||
assert.ok(!json.includes(privateValue), "A follow-up report must preserve the original report's pseudonyms");
|
||||
}
|
||||
await installDiagnosticExportInterception(page);
|
||||
await installDiagnosticExportInterception(dialoguePage);
|
||||
await dialogue.getByRole("button", { name: "Copy report", exact: true }).click();
|
||||
assert.equal(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard), json);
|
||||
assert.equal(await dialoguePage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard), json);
|
||||
await dialogue.getByRole("button", { name: "Save report", exact: true }).click();
|
||||
await page.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, { timeout });
|
||||
assert.deepEqual(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
|
||||
await dialoguePage.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, {
|
||||
timeout,
|
||||
});
|
||||
assert.deepEqual(await dialoguePage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
|
||||
name: "diagnostic-file-report.json",
|
||||
content: json,
|
||||
});
|
||||
await assertDesktopDiagnosticResultLayout(page, dialogue, "saving a follow-up report");
|
||||
await assertDesktopDiagnosticResultLayout(dialoguePage, dialogue, "saving a follow-up report");
|
||||
if (mobile) {
|
||||
const modal = page.locator(".modal-container").filter({ has: dialogue });
|
||||
await assertMobileDialogueLayout(page, modal, "Pseudonym file follow-up report");
|
||||
await assertNoHorizontalOverflow(page, dialogue, { label: "Pseudonym file follow-up content" });
|
||||
await assertNoHorizontalOverflow(page, dialogue.locator(".report-token"), {
|
||||
const modal = dialoguePage.locator(".modal-container").filter({ has: dialogue });
|
||||
await assertMobileDialogueLayout(dialoguePage, modal, "Pseudonym file follow-up report");
|
||||
await assertNoHorizontalOverflow(dialoguePage, dialogue, { label: "Pseudonym file follow-up content" });
|
||||
await assertNoHorizontalOverflow(dialoguePage, dialogue.locator(".report-token"), {
|
||||
label: "Pseudonym file follow-up token",
|
||||
});
|
||||
}
|
||||
await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
mobile ? "database-diagnostics-file-follow-up-mobile.png" : "database-diagnostics-file-follow-up.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".diagnostic-file-lookup") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.diagnostic-file-lookup)")
|
||||
);
|
||||
await dialogue.getByRole("radio", { name: "MARKDOWN", exact: true }).check();
|
||||
const markdown = await dialogue.locator("textarea").inputValue();
|
||||
@@ -379,10 +396,10 @@ async function assertFollowUpStopsAfterClose(page: Page, hatch: Locator, token:
|
||||
})()`);
|
||||
try {
|
||||
const setting = hatch.locator(".setting-item").filter({
|
||||
has: page.getByText("Find a file from a report pseudonym", { exact: true }),
|
||||
has: hatch.page().getByText("Find a file from a report pseudonym", { exact: true }),
|
||||
});
|
||||
await setting.getByRole("button", { name: "Find file", exact: true }).click();
|
||||
const dialogue = page.locator(".diagnostic-file-lookup");
|
||||
const dialogue = hatch.page().locator(".diagnostic-file-lookup");
|
||||
await dialogue.getByLabel("Report path pseudonym", { exact: true }).fill(token);
|
||||
await dialogue.getByRole("button", { name: "Find file", exact: true }).click();
|
||||
const candidate = dialogue.locator("[data-diagnostic-candidate]").filter({ hasText: shadowId });
|
||||
@@ -466,7 +483,7 @@ async function main(): Promise<void> {
|
||||
const activeSession = session;
|
||||
let usageJSON = "";
|
||||
let usageFixtureRow: { path: string; id: string; revision: string } | undefined;
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const desktopScreenshot = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 });
|
||||
const choice = await openCommand(page, "usage");
|
||||
await choice.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).focus();
|
||||
@@ -560,13 +577,15 @@ async function main(): Promise<void> {
|
||||
hatch = await (await openLiveSyncSettings(page, timeout)).openPage("Hatch");
|
||||
await assertFollowUpStopsAfterClose(page, hatch, usageFixtureRow.path);
|
||||
await captureObsidianElement(activeSession.remoteDebuggingPort, "database-diagnostics-hatch.png", (page) =>
|
||||
page.locator(".modal.mod-settings")
|
||||
findVisibleElement(page, ".modal.mod-settings")
|
||||
);
|
||||
const setting = hatch
|
||||
.locator(".setting-item")
|
||||
.filter({ has: page.getByText("Inspect database integrity", { exact: true }) });
|
||||
.filter({ has: hatch.page().getByText("Inspect database integrity", { exact: true }) });
|
||||
await setting.getByRole("button", { name: "Inspect", exact: true }).click();
|
||||
const progressDialogue = page.locator(".database-diagnostic");
|
||||
const progressDialogue = await findVisibleElement(page, ".database-diagnostic");
|
||||
const progressPage = progressDialogue.page();
|
||||
await progressPage.setViewportSize({ width: 800, height: 600 });
|
||||
assert.equal(
|
||||
await progressDialogue
|
||||
.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true })
|
||||
@@ -579,7 +598,7 @@ async function main(): Promise<void> {
|
||||
.isChecked(),
|
||||
true
|
||||
);
|
||||
await assertDesktopDiagnosticChoiceLayout(page, progressDialogue);
|
||||
await assertDesktopDiagnosticChoiceLayout(progressPage, progressDialogue);
|
||||
await installDiagnosticReadGate(cli, activeSession, shadowId, "metadata");
|
||||
try {
|
||||
await progressDialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
|
||||
@@ -593,7 +612,7 @@ async function main(): Promise<void> {
|
||||
await captureObsidianElement(
|
||||
activeSession.remoteDebuggingPort,
|
||||
"database-diagnostics-progress.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await progressDialogue.getByRole("button", { name: "Cancel inspection", exact: true }).click();
|
||||
await evalObsidianJson(
|
||||
@@ -601,7 +620,7 @@ async function main(): Promise<void> {
|
||||
`(async()=>{globalThis.databaseDiagnosticReadGate?.release();return JSON.stringify(true);})()`,
|
||||
activeSession.cliEnv
|
||||
);
|
||||
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
||||
await progressPage.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
||||
await assertFormatChoices(progressDialogue, ["MARKDOWN", "JSON"]);
|
||||
await chooseFormat(progressDialogue, "json");
|
||||
assert.equal(JSON.parse(await progressDialogue.locator("textarea").inputValue()).status, "cancelled");
|
||||
@@ -612,33 +631,41 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await setting.getByRole("button", { name: "Inspect", exact: true }).click();
|
||||
const integrity = page.locator(".database-diagnostic");
|
||||
const integrity = await findVisibleElement(page, ".database-diagnostic");
|
||||
const integrityPage = integrity.page();
|
||||
assert.equal(
|
||||
await integrity.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).isChecked(),
|
||||
true
|
||||
);
|
||||
await integrity.getByRole("button", { name: "Begin inspection", exact: true }).click();
|
||||
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
||||
await integrityPage.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
|
||||
await assertFormatChoices(integrity, ["MARKDOWN", "JSON"]);
|
||||
assert.equal(await integrity.getByRole("radio", { name: "MARKDOWN", exact: true }).isChecked(), true);
|
||||
const markdownReport = await integrity.locator("textarea").inputValue();
|
||||
const markdownDetailsWithCodeBlock =
|
||||
/<details>\s*<summary>Database integrity report \(partial\)<\/summary>\s*(`{3,})text\r?\n[\s\S]*?\1\s*<\/details>/;
|
||||
assert.match(markdownReport, markdownDetailsWithCodeBlock);
|
||||
await installDiagnosticExportInterception(page);
|
||||
await installDiagnosticExportInterception(integrityPage);
|
||||
await integrity.getByRole("button", { name: "Copy report", exact: true }).click();
|
||||
assert.equal(
|
||||
await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
|
||||
await integrityPage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
|
||||
markdownReport
|
||||
);
|
||||
await integrity.getByRole("button", { name: "Save report", exact: true }).click();
|
||||
await page.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, {
|
||||
timeout,
|
||||
});
|
||||
assert.deepEqual(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
|
||||
name: "database-integrity-partial.md",
|
||||
content: markdownReport,
|
||||
});
|
||||
await integrityPage.waitForFunction(
|
||||
() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload,
|
||||
undefined,
|
||||
{
|
||||
timeout,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
await integrityPage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload),
|
||||
{
|
||||
name: "database-integrity-partial.md",
|
||||
content: markdownReport,
|
||||
}
|
||||
);
|
||||
await chooseFormat(integrity, "json");
|
||||
const reportText = await integrity.locator("textarea").inputValue();
|
||||
const integrityReport = JSON.parse(reportText);
|
||||
@@ -670,15 +697,17 @@ async function main(): Promise<void> {
|
||||
);
|
||||
for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()])
|
||||
assert.ok(!reportText.includes(privateValue));
|
||||
const screenshot = await captureObsidianElement(
|
||||
activeSession.remoteDebuggingPort,
|
||||
"database-diagnostics-desktop.png",
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await integrity.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await integrity.waitFor({ state: "hidden", timeout });
|
||||
await settingsNavigator.close();
|
||||
return screenshot;
|
||||
});
|
||||
const desktopScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"database-diagnostics-desktop.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.locator(".database-diagnostic").getByRole("button", { name: "Close", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
const localOnly = await openCommand(page, "integrity");
|
||||
await localOnly.getByRole("checkbox", { name: "Include remote database", exact: true }).uncheck();
|
||||
await localOnly.getByRole("button", { name: "Begin inspection", exact: true }).click();
|
||||
@@ -780,7 +809,7 @@ async function main(): Promise<void> {
|
||||
const mobileScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"database-diagnostics-mobile.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
@@ -828,7 +857,7 @@ async function main(): Promise<void> {
|
||||
const mobileResultScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"database-diagnostics-mobile-result.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
@@ -857,7 +886,7 @@ async function main(): Promise<void> {
|
||||
await captureObsidianElement(
|
||||
activeSession.remoteDebuggingPort,
|
||||
"database-diagnostics-mobile-saved.png",
|
||||
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
|
||||
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
|
||||
);
|
||||
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await dialogue.waitFor({ state: "hidden", timeout });
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
|
||||
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { $msg } from "../../../src/common/translation.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -13,18 +15,37 @@ import {
|
||||
captureObsidianPage,
|
||||
obsidianRemoteDebuggingPort,
|
||||
openLiveSyncSettings,
|
||||
withObsidianPage,
|
||||
waitForVisibleObsidianDialogue,
|
||||
withObsidianPage as withObsidianSessionPage,
|
||||
} from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const dialogRunStateKey = "__livesyncE2EDialogMount";
|
||||
const savedP2PDeviceName = "e2e-p2p-saved-device";
|
||||
const repairRunStateKey = "__livesyncE2ETroubleshootingRepair";
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
|
||||
|
||||
async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
|
||||
return await withObsidianSessionPage(port, async (page) => {
|
||||
for (const candidate of page.context().pages()) {
|
||||
const hasPlugin = await candidate
|
||||
.evaluate(
|
||||
() => (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"] !== undefined
|
||||
)
|
||||
.catch(() => false);
|
||||
if (hasPlugin) return await operation(candidate);
|
||||
}
|
||||
throw new Error("The LiveSync Vault renderer is unavailable.");
|
||||
});
|
||||
}
|
||||
|
||||
type DialogueMode = "desktop" | "mobile";
|
||||
|
||||
type DialogueRunState = {
|
||||
done: boolean;
|
||||
deviceNameRead?: boolean;
|
||||
deviceNameReadPending?: boolean;
|
||||
releaseDeviceNameRead?: () => void;
|
||||
error?: string;
|
||||
expected?: unknown;
|
||||
kind: string;
|
||||
@@ -46,6 +67,14 @@ type LiveSyncTestPlugin = {
|
||||
createAllChunks(force: boolean): Promise<unknown>;
|
||||
};
|
||||
modules: SetupManagerHandle[];
|
||||
services: {
|
||||
setting: {
|
||||
config: {
|
||||
getValue(key: string, defaultValue?: string): Promise<string>;
|
||||
setValue(key: string, value: string): Promise<void>;
|
||||
};
|
||||
};
|
||||
};
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
@@ -91,38 +120,81 @@ async function openRemoteSelectionDialogue(): Promise<void> {
|
||||
|
||||
async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
||||
if (
|
||||
typeof manager?.onSelectServer !== "function" ||
|
||||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
|
||||
) {
|
||||
throw new Error("Could not find the P2P setup workflow");
|
||||
}
|
||||
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
|
||||
const state: DialogueRunState = { kind: "p2p-compatibility-settings", done: false };
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
|
||||
state.expected = settings;
|
||||
return true;
|
||||
};
|
||||
void manager
|
||||
.onSelectServer(plugin.core.settings, "unknown")
|
||||
.then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
state.done = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.done = true;
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
manager.onConfirmApplySettingsFromWizard = originalConfirm;
|
||||
await page.evaluate(
|
||||
async ({ stateKey, deviceNameKey, deviceName }) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
|
||||
const initialPeerName = plugin.core.settings.P2P_DevicePeerName;
|
||||
if (typeof initialPeerName === "string" && initialPeerName.trim() !== "") {
|
||||
throw new Error("The P2P setup fixture unexpectedly has an initial device name.");
|
||||
}
|
||||
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
||||
if (
|
||||
typeof manager?.onSelectServer !== "function" ||
|
||||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
|
||||
) {
|
||||
throw new Error("Could not find the P2P setup workflow");
|
||||
}
|
||||
const config = plugin.core.services.setting.config;
|
||||
await config.setValue(deviceNameKey, deviceName);
|
||||
const originalGetValue = config.getValue;
|
||||
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
|
||||
const state: DialogueRunState = {
|
||||
kind: "p2p-compatibility-settings",
|
||||
done: false,
|
||||
deviceNameRead: false,
|
||||
deviceNameReadPending: false,
|
||||
};
|
||||
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
|
||||
const deviceNameReadGate = new Promise<void>((resolve) => {
|
||||
state.releaseDeviceNameRead = resolve;
|
||||
});
|
||||
config.getValue = async (key, defaultValue) => {
|
||||
const value = await originalGetValue.call(config, key, defaultValue);
|
||||
if (key === deviceNameKey) {
|
||||
state.deviceNameReadPending = true;
|
||||
await deviceNameReadGate;
|
||||
state.deviceNameReadPending = false;
|
||||
if (value === deviceName) state.deviceNameRead = true;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
|
||||
state.expected = settings;
|
||||
return true;
|
||||
};
|
||||
void manager
|
||||
.onSelectServer(plugin.core.settings, "unknown")
|
||||
.then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
state.releaseDeviceNameRead?.();
|
||||
state.releaseDeviceNameRead = undefined;
|
||||
manager.onConfirmApplySettingsFromWizard = originalConfirm;
|
||||
config.getValue = originalGetValue;
|
||||
state.done = true;
|
||||
});
|
||||
},
|
||||
{
|
||||
stateKey: dialogRunStateKey,
|
||||
deviceNameKey: SETTING_KEY_P2P_DEVICE_NAME,
|
||||
deviceName: savedP2PDeviceName,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function releasePendingP2PDeviceNameRead(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const state = (globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey];
|
||||
state?.releaseDeviceNameRead?.();
|
||||
}, dialogRunStateKey);
|
||||
});
|
||||
}
|
||||
@@ -388,7 +460,7 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
|
||||
if (previousDialogue === null) {
|
||||
throw new Error("The remote selection dialogue did not expose a close control.");
|
||||
}
|
||||
await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
|
||||
await modal.locator(".modal-close-button, .modal-header-button").last().click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
@@ -473,9 +545,11 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string
|
||||
async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
await openP2PRemoteSelectionDialogueForInspection();
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const remoteSelection = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
|
||||
});
|
||||
const remoteSelection = await waitForVisibleObsidianDialogue(
|
||||
page,
|
||||
"Choose a synchronisation remote",
|
||||
uiTimeoutMs
|
||||
);
|
||||
await remoteSelection
|
||||
.locator("label")
|
||||
.filter({ hasText: "Peer-to-Peer (P2P)" })
|
||||
@@ -491,16 +565,51 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
obsidianRemoteDebuggingPort(),
|
||||
"setup-p2p-compatibility-dialogue.png",
|
||||
async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||
});
|
||||
const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
|
||||
const modalPage = modal.page();
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const peerName = modal.getByLabel("Device Peer ID", { exact: true });
|
||||
await peerName.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (statePage) => {
|
||||
await statePage.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]
|
||||
?.deviceNameReadPending === true,
|
||||
dialogRunStateKey,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
});
|
||||
const testAndContinue = modal.getByRole("button", { name: "Test Settings and Continue", exact: true });
|
||||
const continueAnyway = modal.getByRole("button", { name: "Continue anyway", exact: true });
|
||||
await testAndContinue.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await continueAnyway.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (
|
||||
!(await peerName.isDisabled()) ||
|
||||
!(await testAndContinue.isDisabled()) ||
|
||||
!(await continueAnyway.isDisabled())
|
||||
) {
|
||||
throw new Error(
|
||||
"The P2P device name and setup actions must remain disabled while the saved device name is loading."
|
||||
);
|
||||
}
|
||||
|
||||
await releasePendingP2PDeviceNameRead();
|
||||
await modalPage.waitForFunction(
|
||||
(expectedName) => {
|
||||
const input = document.querySelector('input[name="p2p-device-peer-id"]');
|
||||
return input instanceof HTMLInputElement && !input.disabled && input.value === expectedName;
|
||||
},
|
||||
savedP2PDeviceName,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
if ((await peerName.inputValue()) !== savedP2PDeviceName) {
|
||||
throw new Error("The saved P2P device name was not loaded before submitting the setup dialogue.");
|
||||
}
|
||||
await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility");
|
||||
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
|
||||
await modal.locator('input[name="p2p-device-peer-id"]').fill("e2e-device");
|
||||
|
||||
const compatibility = modal.locator("details").filter({
|
||||
has: page.locator("summary").filter({ hasText: "Connection compatibility" }),
|
||||
has: modalPage.locator("summary").filter({ hasText: "Connection compatibility" }),
|
||||
});
|
||||
await compatibility.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (!(await compatibility.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
||||
@@ -541,7 +650,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
}
|
||||
|
||||
const advanced = modal.locator("details").filter({
|
||||
has: page.locator("summary").filter({ hasText: "Advanced Settings" }),
|
||||
has: modalPage.locator("summary").filter({ hasText: "Advanced Settings" }),
|
||||
});
|
||||
if (!(await advanced.evaluate((element) => (element as HTMLDetailsElement).open))) {
|
||||
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
@@ -553,20 +662,19 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
if (relayOnlyElement === null || connectionPathElement === null) {
|
||||
throw new Error("P2P connection-path controls were removed whilst validating TURN settings.");
|
||||
}
|
||||
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await connectionPath.selectOption("relay");
|
||||
|
||||
await turnServers.fill("");
|
||||
await page.waitForFunction(
|
||||
await modalPage.waitForFunction(
|
||||
(element) => (element as HTMLSelectElement).value === "automatic",
|
||||
connectionPathElement,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
|
||||
hasText:
|
||||
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
|
||||
hasText: "TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
|
||||
});
|
||||
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await resetNotice
|
||||
@@ -575,7 +683,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
|
||||
await turnServers.fill("turn:turn.example.com:3478");
|
||||
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await connectionPath.selectOption("relay");
|
||||
@@ -583,21 +691,25 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
|
||||
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
|
||||
await compatibility.scrollIntoViewIfNeeded();
|
||||
}
|
||||
);
|
||||
).finally(releasePendingP2PDeviceNameRead);
|
||||
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
|
||||
});
|
||||
const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
|
||||
await modal.getByRole("button", { name: "Continue anyway", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const state = await assertDialogueRunCompleted();
|
||||
if (state.deviceNameRead !== true) {
|
||||
throw new Error("The saved P2P device name was not read through setting.config.");
|
||||
}
|
||||
if (state.result !== true || typeof state.expected !== "object" || state.expected === null) {
|
||||
throw new Error("The P2P compatibility settings were not passed to the setup workflow.");
|
||||
}
|
||||
const settings = state.expected as Record<string, unknown>;
|
||||
if (settings.P2P_DevicePeerName !== savedP2PDeviceName) {
|
||||
throw new Error("The saved P2P device name was not passed through the setup workflow.");
|
||||
}
|
||||
if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") {
|
||||
throw new Error(
|
||||
`The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({
|
||||
@@ -672,15 +784,14 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
|
||||
const app = (globalThis as ObsidianTestGlobal).app;
|
||||
return Boolean(
|
||||
app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured &&
|
||||
app.commands?.commands?.[commandId]
|
||||
app.commands?.commands?.[commandId]
|
||||
);
|
||||
},
|
||||
"obsidian-livesync:livesync-copysetupuri",
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
return await page.evaluate(
|
||||
(commandId) =>
|
||||
(globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
|
||||
"obsidian-livesync:livesync-copysetupuri"
|
||||
);
|
||||
});
|
||||
@@ -714,7 +825,8 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
|
||||
state: "visible", timeout: uiTimeoutMs,
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue");
|
||||
await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs });
|
||||
@@ -866,6 +978,7 @@ async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
|
||||
if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) {
|
||||
throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined.");
|
||||
}
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1094,9 +1207,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
);
|
||||
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const liveSyncSettings = page.locator(".sls-setting");
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const liveSyncSettings = await settingsNavigator.openPage("Hatch");
|
||||
const logSetting = liveSyncSettings.locator(".setting-item").filter({
|
||||
has: page.getByText("Write logs into the file", { exact: true }),
|
||||
has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
|
||||
});
|
||||
await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
@@ -1130,10 +1244,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
|
||||
// Saving a toggle refreshes the settings pane. Resolve the visible control again so the
|
||||
// second action does not target the detached pre-save element.
|
||||
const refreshedLogToggle = page
|
||||
.locator(".sls-setting:visible .setting-item:visible")
|
||||
const refreshedLogToggle = liveSyncSettings
|
||||
.locator(".setting-item:visible")
|
||||
.filter({
|
||||
has: page.getByText("Write logs into the file", { exact: true }),
|
||||
has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
|
||||
})
|
||||
.locator(".checkbox-container:visible")
|
||||
.last();
|
||||
@@ -1179,13 +1293,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
}
|
||||
};
|
||||
}, repairRunStateKey);
|
||||
await page
|
||||
.locator(".sls-setting:visible")
|
||||
.last()
|
||||
.getByRole("button", { name: "Recreate current chunks", exact: true })
|
||||
.click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true,
|
||||
@@ -1200,13 +1310,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
throw new Error(`Recreate missing chunks failed: ${repairState.error}`);
|
||||
}
|
||||
|
||||
await page
|
||||
.locator(".sls-setting:visible")
|
||||
.last()
|
||||
.getByRole("button", { name: "Begin inspection", exact: true })
|
||||
.click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings.getByRole("button", { name: "Begin inspection", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await page
|
||||
.locator(".notice")
|
||||
.filter({ hasText: /^done$/u })
|
||||
@@ -1214,6 +1320,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await settingsNavigator.close();
|
||||
});
|
||||
|
||||
return screenshotPath;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const hiddenRoot = ".obsidian/livesync-selector-example";
|
||||
const hiddenFiles = {
|
||||
[`${hiddenRoot}/main.js`]: "console.log('hidden fixture');\n",
|
||||
[`${hiddenRoot}/styles.css`]: ".hidden-fixture { color: blue; }\n",
|
||||
[`${hiddenRoot}/settings.json`]: '{"fixture":true}\n',
|
||||
};
|
||||
const controlRoot = ".obsidian/livesync-selector-control";
|
||||
const controlFiles = {
|
||||
[`${controlRoot}/main.js`]: "console.log('control fixture');\n",
|
||||
[`${controlRoot}/styles.css`]: ".control-fixture { color: red; }\n",
|
||||
[`${controlRoot}/settings.json`]: '{"control":true}\n',
|
||||
};
|
||||
const allowList = "\\.(?:md|png|pdf)$";
|
||||
const coreCode = "const core=app.plugins.plugins['obsidian-livesync'].core;";
|
||||
const hiddenCode = `${coreCode}const addOn=core.getAddOn('HiddenFileSync');`;
|
||||
|
||||
type Observation = { name: string; expected: unknown; actual: unknown; passed: boolean };
|
||||
|
||||
async function writeFixtures(vaultPath: string, files: Record<string, string>) {
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
await mkdir(dirname(join(vaultPath, path)), { recursive: true });
|
||||
await writeFile(join(vaultPath, path), content);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFixtures(vaultPath: string, files: Record<string, string>) {
|
||||
for (const path of Object.keys(files)) await rm(join(vaultPath, path), { force: true });
|
||||
}
|
||||
|
||||
async function matchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||
const matches: string[] = [];
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
try {
|
||||
if ((await readFile(join(vaultPath, path), "utf8")) === content) matches.push(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return matches.sort();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error("The Obsidian CLI is unavailable");
|
||||
const cliBinary = cli.binary;
|
||||
const output =
|
||||
process.env.E2E_HIDDEN_FILE_SELECTORS_OUTPUT ?? join(tmpdir(), "livesync-hidden-file-selectors.json");
|
||||
const observations: Observation[] = [];
|
||||
const report: Record<string, unknown> = {
|
||||
revision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||
sourceDiffSha256: createHash("sha256")
|
||||
.update(new Uint8Array(execFileSync("git", ["diff", "--binary", "HEAD"])))
|
||||
.digest("hex"),
|
||||
scenarioSha256: createHash("sha256")
|
||||
.update(new Uint8Array(await readFile(fileURLToPath(import.meta.url))))
|
||||
.digest("hex"),
|
||||
commonlibEntrySha256: createHash("sha256")
|
||||
.update(
|
||||
new Uint8Array(
|
||||
await readFile(
|
||||
"node_modules/@vrtmrz/livesync-commonlib/dist/managers/EntryManager/EntryManagerImpls.js"
|
||||
)
|
||||
)
|
||||
)
|
||||
.digest("hex"),
|
||||
bundleSha256: createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex"),
|
||||
commonlibVersion: JSON.parse(await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8"))
|
||||
.version,
|
||||
observations,
|
||||
};
|
||||
const vault = await createTemporaryVault("livesync-hidden-file-selectors-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const record = (name: string, actual: unknown, expected: unknown) => {
|
||||
const observation = { name, expected, actual, passed: isDeepStrictEqual(actual, expected) };
|
||||
observations.push(observation);
|
||||
console.log(JSON.stringify(observation));
|
||||
};
|
||||
const evaluate = async <T>(code: string): Promise<T> => {
|
||||
if (!session) throw new Error("The isolated Obsidian session has not started");
|
||||
return await evalObsidianJson<T>(cliBinary, `(async()=>{${code}})()`, session.cliEnv, 60_000);
|
||||
};
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
pluginData: createE2eCouchDbPluginData(
|
||||
{ uri: "http://127.0.0.1:9", username: "", password: "", dbName: "selector-local-only" },
|
||||
{ periodicReplication: false, syncOnFileOpen: false, useOnlyLocalChunk: true }
|
||||
),
|
||||
});
|
||||
report.pluginVersion = session.readiness.pluginVersion;
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
report.runtime = await evaluate(`${coreCode}
|
||||
core.services.setting.setDeviceAndVaultName('selector-local');
|
||||
Object.assign(core.settings,{
|
||||
isConfigured:true,usePluginSync:false,usePluginSyncV2:true,syncInternalFiles:false,
|
||||
autoSweepPlugins:false,autoSweepPluginsPeriodic:false,watchInternalFileChanges:false,
|
||||
syncOnlyRegEx:${JSON.stringify(allowList)},syncIgnoreRegEx:''
|
||||
});
|
||||
return JSON.stringify({userAgent:navigator.userAgent,databaseReady:core.localDatabase.isReady});
|
||||
`);
|
||||
|
||||
await writeFixtures(vault.path, hiddenFiles);
|
||||
const uploadCode = (normalAllowList: string, files: Record<string, string>) => `${hiddenCode}
|
||||
Object.assign(core.settings,{
|
||||
syncInternalFiles:true,syncOnlyRegEx:${JSON.stringify(normalAllowList)},
|
||||
syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/livesync-selector-(?:example|control)(?:/|$))',syncInternalFilesIgnorePatterns:''
|
||||
});
|
||||
const candidates=await addOn.scanInternalFileNames();
|
||||
const selected=candidates.filter(path=>${JSON.stringify(Object.keys(files))}.includes(path));
|
||||
if(selected.length!==3)throw new Error('Hidden-file fixture selection failed: '+JSON.stringify({selected,target:core.settings.syncInternalFilesTargetPatterns}));
|
||||
for(const path of selected)await addOn.updateLastProcessedAsActualFile(path);
|
||||
await addOn.rebuildFromStorage(false,${JSON.stringify(Object.keys(files))},false);
|
||||
const paths=[];
|
||||
for await(const doc of core.localDatabase.findEntries('i:','i;')){
|
||||
if(${JSON.stringify(Object.keys(files).map((path) => `i:${path}`))}.includes(doc.path)&&!doc.deleted&&!doc._deleted)paths.push(doc.path.substring(2));
|
||||
}
|
||||
return JSON.stringify(paths.sort());
|
||||
`;
|
||||
const hiddenUpload = await evaluate<string[]>(uploadCode(allowList, hiddenFiles));
|
||||
record(
|
||||
"#1272: a normal allow-list permits selected hidden uploads",
|
||||
hiddenUpload,
|
||||
Object.keys(hiddenFiles).sort()
|
||||
);
|
||||
await writeFixtures(vault.path, controlFiles);
|
||||
const uploadControl = await evaluate<string[]>(uploadCode("", controlFiles));
|
||||
record(
|
||||
"#1272 control: clearing the normal allow-list permits hidden uploads",
|
||||
uploadControl,
|
||||
Object.keys(controlFiles).sort()
|
||||
);
|
||||
|
||||
const pullCode = (normalAllowList: string, files: Record<string, string>) => `${hiddenCode}
|
||||
core.settings.syncOnlyRegEx=${JSON.stringify(normalAllowList)};
|
||||
const results=[];
|
||||
for await(const doc of core.localDatabase.findEntries('i:','i;')){
|
||||
if(${JSON.stringify(Object.keys(files).map((path) => `i:${path}`))}.includes(doc.path)&&!doc.deleted&&!doc._deleted){
|
||||
results.push(await addOn.extractInternalFileFromDatabase(doc.path.substring(2),true,doc,false));
|
||||
}
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
`;
|
||||
await removeFixtures(vault.path, hiddenFiles);
|
||||
report.hiddenPullWithAllowList = await evaluate(pullCode(allowList, hiddenFiles));
|
||||
record(
|
||||
"#1272: a normal allow-list permits selected hidden downloads",
|
||||
await matchingFiles(vault.path, hiddenFiles),
|
||||
Object.keys(hiddenFiles).sort()
|
||||
);
|
||||
await removeFixtures(vault.path, controlFiles);
|
||||
report.hiddenPullWithoutAllowList = await evaluate(pullCode("", controlFiles));
|
||||
record(
|
||||
"#1272 control: clearing the normal allow-list permits hidden downloads",
|
||||
await matchingFiles(vault.path, controlFiles),
|
||||
Object.keys(controlFiles).sort()
|
||||
);
|
||||
} catch (error) {
|
||||
report.runtimeError = error instanceof Error ? error.stack : String(error);
|
||||
if (session) report.processOutput = JSON.stringify(session.app.output()).slice(-4_000);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await writeFile(output, JSON.stringify(report, null, 2) + "\n");
|
||||
console.log(`Verification report: ${output}`);
|
||||
} finally {
|
||||
try {
|
||||
if (session) await session.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (observations.some((observation) => !observation.passed)) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -63,12 +63,28 @@ const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
|
||||
const targetPath = ".obsidian/livesync-targeted/only-a.json";
|
||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
|
||||
const disabledInitialisationProbeKey = "__livesyncE2EDisabledHiddenInitialisation";
|
||||
|
||||
type DisabledInitialisationSnapshot = {
|
||||
syncInternalFiles: boolean;
|
||||
hiddenFiles: { id: string; path: string; rev: string; deleted: boolean }[];
|
||||
processedFiles: [string, string | undefined][];
|
||||
processedDatabaseFiles: [string, string | undefined][];
|
||||
};
|
||||
|
||||
type DisabledInitialisationObservation = {
|
||||
result: boolean | null;
|
||||
before: DisabledInitialisationSnapshot;
|
||||
after: DisabledInitialisationSnapshot;
|
||||
progressNotices: string[];
|
||||
};
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
cliBinary: string;
|
||||
couchDb: CouchDbConfig;
|
||||
dbName: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
};
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
@@ -132,6 +148,114 @@ async function waitForPathDeleted(
|
||||
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
|
||||
}
|
||||
|
||||
async function assertDisabledInitialisationIsNoOp(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault
|
||||
): Promise<void> {
|
||||
const snippetExisted = await pathExists(vault.path, snippetPath);
|
||||
const originalSnippet = snippetExisted ? await readVaultFile(vault.path, snippetPath) : undefined;
|
||||
const originalJson = await readVaultFile(vault.path, mergeJsonPath);
|
||||
const preservedSnippet = "body { --disabled-hidden-initialisation: preserved; }\n";
|
||||
const preservedJson = JSON.stringify({ disabledInitialisation: "preserved" }, null, 4) + "\n";
|
||||
await writeVaultFile(vault.path, snippetPath, preservedSnippet);
|
||||
await writeVaultFile(vault.path, mergeJsonPath, preservedJson);
|
||||
|
||||
try {
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const state = { progressNotices: [] as string[] };
|
||||
const observer = new MutationObserver(() => {
|
||||
for (const notice of document.querySelectorAll<HTMLElement>(".notice")) {
|
||||
const text = notice.textContent?.trim() ?? "";
|
||||
if (text.includes("[⚙") && !state.progressNotices.includes(text)) {
|
||||
state.progressNotices.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
(globalThis as unknown as Record<string, { progressNotices: string[]; observer: MutationObserver }>)[
|
||||
stateKey
|
||||
] = { ...state, observer };
|
||||
}, disabledInitialisationProbeKey);
|
||||
});
|
||||
|
||||
const observation = await evalObsidianJson<DisabledInitialisationObservation>(
|
||||
context.cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const targetFiles=${JSON.stringify([snippetPath, mergeJsonPath])};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
"const snapshot=async()=>{",
|
||||
"const files=await addOn.getAllDatabaseFiles();",
|
||||
"const markers=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
|
||||
"return {",
|
||||
"syncInternalFiles:core.settings.syncInternalFiles,",
|
||||
"hiddenFiles:files.map((entry)=>({id:entry._id,path:addOn.getPath(entry),rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)})).sort((a,b)=>a.path.localeCompare(b.path)),",
|
||||
"processedFiles:markers(addOn._fileInfoLastProcessed),",
|
||||
"processedDatabaseFiles:markers(addOn._databaseInfoLastProcessed),",
|
||||
"};};",
|
||||
"const before=await snapshot();",
|
||||
"const result=await addOn.initialiseInternalFileSync('pullForce',true,targetFiles);",
|
||||
"const after=await snapshot();",
|
||||
"return JSON.stringify({result:result===undefined?null:result,before,after});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
const progressNotices = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
return await page.evaluate((stateKey) => {
|
||||
const state = (
|
||||
globalThis as unknown as Record<
|
||||
string,
|
||||
{ progressNotices: string[]; observer: MutationObserver } | undefined
|
||||
>
|
||||
)[stateKey];
|
||||
state?.observer.disconnect();
|
||||
return state?.progressNotices ?? [];
|
||||
}, disabledInitialisationProbeKey);
|
||||
});
|
||||
|
||||
const failures: string[] = [];
|
||||
if (observation.result !== false)
|
||||
failures.push(`expected false result, received ${String(observation.result)}`);
|
||||
if (observation.before.syncInternalFiles !== false || observation.after.syncInternalFiles !== false) {
|
||||
failures.push("Hidden File Sync setting changed while initialisation was disabled");
|
||||
}
|
||||
if (JSON.stringify(observation.before) !== JSON.stringify(observation.after)) {
|
||||
failures.push("local hidden database state or processed markers changed");
|
||||
}
|
||||
if ((await readVaultFile(vault.path, snippetPath)) !== preservedSnippet) {
|
||||
failures.push("the existing local snippet was deleted or replaced");
|
||||
}
|
||||
if ((await readVaultFile(vault.path, mergeJsonPath)) !== preservedJson) {
|
||||
failures.push("the existing local JSON file was replaced");
|
||||
}
|
||||
if (progressNotices.length > 0)
|
||||
failures.push(`initialisation created progress Notices: ${JSON.stringify(progressNotices)}`);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Disabled Hidden File Sync initialisation was not a no-op: ${failures.join("; ")}\n${JSON.stringify({
|
||||
observation,
|
||||
progressNotices,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
"Disabled Hidden File Sync initialisation returned false without changing hidden files or cached state."
|
||||
);
|
||||
} finally {
|
||||
await writeVaultFile(vault.path, mergeJsonPath, originalJson);
|
||||
if (snippetExisted && originalSnippet !== undefined) {
|
||||
await writeVaultFile(vault.path, snippetPath, originalSnippet);
|
||||
} else {
|
||||
await removeVaultFile(vault.path, snippetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasJsonValues(content: string, values: Record<string, unknown>): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
@@ -348,6 +472,7 @@ async function startConfiguredSession(
|
||||
pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
context.activeSessions.add(session);
|
||||
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
|
||||
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
|
||||
@@ -355,6 +480,11 @@ async function startConfiguredSession(
|
||||
return session;
|
||||
}
|
||||
|
||||
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
||||
await session.app.stop();
|
||||
context.activeSessions.delete(session);
|
||||
}
|
||||
|
||||
async function uploadHiddenFile(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
@@ -430,12 +560,12 @@ async function runCreateRoundTrip(
|
||||
throw new Error("Remote Rebuild did not declare and encrypt internal Metadata for its accepted writer.");
|
||||
}
|
||||
console.log("Remote Rebuild declared the feature and preserved encrypted Hidden File Sync Metadata.");
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
assertEqual(received, snippetContent, "Hidden snippet content did not round-trip to the second vault.");
|
||||
console.log(`Hidden create round-trip copied ${entry.id} to the second vault.`);
|
||||
@@ -450,12 +580,12 @@ async function runDeleteRoundTrip(
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
await scanHiddenStorage(context.cliBinary, session.cliEnv);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
await waitForPathDeleted(vaultB.path, snippetPath);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
console.log("Hidden delete round-trip removed the snippet from the second vault.");
|
||||
}
|
||||
@@ -476,14 +606,14 @@ async function runJsonConflictRoundTrip(
|
||||
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults.");
|
||||
console.log("Hidden JSON conflict was automatically merged and round-tripped.");
|
||||
@@ -503,7 +633,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
|
||||
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
|
||||
);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
|
||||
const parsed = JSON.parse(merged);
|
||||
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
|
||||
@@ -517,17 +647,19 @@ async function runTargetMismatch(
|
||||
vaultA: TemporaryVault,
|
||||
vaultB: TemporaryVault
|
||||
): Promise<void> {
|
||||
const selectors = { syncOnlyRegEx: String.raw`\.(md|pdf)$` };
|
||||
const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n";
|
||||
await writeVaultFile(vaultA.path, targetPath, targetContent);
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
let session = await startConfiguredSession(context, vaultA, selectors);
|
||||
try {
|
||||
await uploadHiddenFile(context, session, targetPath);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, {
|
||||
...selectors,
|
||||
syncInternalFilesTargetPatterns: "snippets",
|
||||
});
|
||||
try {
|
||||
@@ -538,10 +670,11 @@ async function runTargetMismatch(
|
||||
"Hidden file was applied on a device where it was not a target file."
|
||||
);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, {
|
||||
...selectors,
|
||||
syncInternalFilesTargetPatterns: "",
|
||||
});
|
||||
let received = "";
|
||||
@@ -549,11 +682,17 @@ async function runTargetMismatch(
|
||||
await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false });
|
||||
received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
|
||||
assertEqual(received, targetContent, "Hidden file was not applied after it became a target file.");
|
||||
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target.");
|
||||
assertEqual(
|
||||
received,
|
||||
targetContent,
|
||||
"The normal file allow-list blocked a selected hidden file from the other device."
|
||||
);
|
||||
console.log(
|
||||
"A normal Markdown/PDF allow-list preserved hidden target exclusion and exact remote restoration after enabling the target."
|
||||
);
|
||||
}
|
||||
|
||||
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
|
||||
@@ -633,6 +772,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
|
||||
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
|
||||
);
|
||||
});
|
||||
await assertDisabledInitialisationIsNoOp(context, session, vault);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((stateKey) => {
|
||||
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
|
||||
@@ -828,7 +968,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
|
||||
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
|
||||
);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,7 +1029,7 @@ async function runConfigurationNoticeGrouping(context: RunnerContext, vault: Tem
|
||||
} finally {
|
||||
await clearHiddenFileNoticeFixtures(port).catch(() => undefined);
|
||||
await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined);
|
||||
await session.app.stop();
|
||||
await stopSession(context, session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,7 +1044,7 @@ async function main(): Promise<void> {
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip");
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
|
||||
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, activeSessions: new Set() };
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
@@ -923,6 +1063,7 @@ async function main(): Promise<void> {
|
||||
await runInitialisationNoticeGrouping(context, vaultB);
|
||||
await runConfigurationNoticeGrouping(context, vaultB);
|
||||
} finally {
|
||||
for (const session of context.activeSessions) await stopSession(context, session);
|
||||
await vaultA.dispose();
|
||||
await vaultB.dispose();
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID, NODEINFO_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
fetchCouchDbDocument,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
putCouchDbDocument,
|
||||
type CouchDbDocument,
|
||||
} from "../runner/couchdb.ts";
|
||||
import {
|
||||
createObjectStorageClient,
|
||||
deleteObjectStoragePrefix,
|
||||
ensureObjectStorageBucket,
|
||||
loadObjectStorageConfig,
|
||||
makeUniqueBucketPrefix,
|
||||
readObjectStorageJson,
|
||||
} from "../runner/objectStorage.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
assertE2eCompatibilityUnpaused,
|
||||
configureCouchDb,
|
||||
configureObjectStorage,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObjectStoragePluginData,
|
||||
prepareRemote,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import {
|
||||
assertLocatorHasMinimumTouchTarget,
|
||||
assertLocatorWithinSafeArea,
|
||||
assertLocatorWithinViewport,
|
||||
assertNoHorizontalOverflow,
|
||||
} from "@vrtmrz/obsidian-test-session";
|
||||
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { openLiveSyncSettings, waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_JOINED_DEVICES_TIMEOUT_MS ?? 10000);
|
||||
const commandId = "obsidian-livesync:joined-devices";
|
||||
const targetId = "joined-devices-target";
|
||||
const retainedId = "joined-devices-retained";
|
||||
const initialPlatform = "E2E Android tablet";
|
||||
const refreshedPlatform = "E2E refreshed platform";
|
||||
const connectedAt = Date.UTC(2026, 0, 12, 9, 30, 0);
|
||||
|
||||
type SelfDeviceInfo = {
|
||||
id: string;
|
||||
platform: string;
|
||||
pluginVersion: string;
|
||||
appVersion: string;
|
||||
};
|
||||
|
||||
type DeviceInfo = {
|
||||
device_name: string;
|
||||
vault_name: string;
|
||||
app_version: string;
|
||||
plugin_version: string;
|
||||
platform: string;
|
||||
progress: string;
|
||||
last_connected: number;
|
||||
};
|
||||
|
||||
type JoinedDevicesMilestone = CouchDbDocument & {
|
||||
accepted_nodes: string[];
|
||||
node_info: Record<string, DeviceInfo>;
|
||||
locked: boolean;
|
||||
cleaned: boolean;
|
||||
node_chunk_info: Record<string, { min: number; max: number; current: number }>;
|
||||
tweak_values: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type DeviceFixture = {
|
||||
milestone: JoinedDevicesMilestone;
|
||||
preferredTweaks: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type JoinedDevicesRemote = {
|
||||
label: string;
|
||||
pluginData: Record<string, unknown>;
|
||||
prepare(): Promise<void>;
|
||||
configure(cliBinary: string, env: NodeJS.ProcessEnv): Promise<unknown>;
|
||||
read(): Promise<JoinedDevicesMilestone>;
|
||||
write(milestone: JoinedDevicesMilestone): Promise<void>;
|
||||
cleanup(): Promise<void>;
|
||||
};
|
||||
|
||||
async function createRemoteFixture(): Promise<JoinedDevicesRemote> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.some((argument) => argument !== "--object-storage")) {
|
||||
throw new Error("Usage: joined-devices.ts [--object-storage]");
|
||||
}
|
||||
if (args.includes("--object-storage")) {
|
||||
const config = await loadObjectStorageConfig();
|
||||
const bucketPrefix = makeUniqueBucketPrefix("joined-devices");
|
||||
const settings = { ...config, bucketPrefix };
|
||||
const key = `${bucketPrefix}_00000000-milestone.json`;
|
||||
return {
|
||||
label: "Object Storage",
|
||||
pluginData: createE2eObjectStoragePluginData(settings),
|
||||
prepare: () => ensureObjectStorageBucket(config),
|
||||
configure: (cliBinary, env) => configureObjectStorage(cliBinary, env, settings),
|
||||
read: () => readObjectStorageJson<JoinedDevicesMilestone>(config, key),
|
||||
write: async (milestone) => {
|
||||
const client = createObjectStorageClient(config);
|
||||
try {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
Body: JSON.stringify(milestone),
|
||||
ContentType: "application/json",
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
},
|
||||
cleanup: () => deleteObjectStoragePrefix(config, bucketPrefix),
|
||||
};
|
||||
}
|
||||
const config = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(config.dbPrefix, "joined-devices");
|
||||
const settings = { uri: config.uri, username: config.username, password: config.password, dbName };
|
||||
return {
|
||||
label: "CouchDB",
|
||||
pluginData: createE2eCouchDbPluginData(settings),
|
||||
prepare: async () => {
|
||||
await assertCouchDbReachable(config);
|
||||
await createCouchDbDatabase(config, dbName);
|
||||
},
|
||||
configure: (cliBinary, env) => configureCouchDb(cliBinary, env, settings),
|
||||
read: async () => {
|
||||
const milestone = await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID);
|
||||
assert(Boolean(milestone._rev), "The remote milestone did not have a revision.");
|
||||
return milestone as JoinedDevicesMilestone;
|
||||
},
|
||||
write: async (milestone) => {
|
||||
await putCouchDbDocument(config, dbName, milestone);
|
||||
},
|
||||
cleanup: () => deleteCouchDbDatabase(config, dbName),
|
||||
};
|
||||
}
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function deviceCard(dialogue: Locator, id: string): Locator {
|
||||
return dialogue.locator(".device-card").filter({ hasText: id });
|
||||
}
|
||||
|
||||
async function assertDetail(card: Locator, label: string, expected: string): Promise<void> {
|
||||
const rows = card.locator(".device-details > div");
|
||||
for (let index = 0; index < (await rows.count()); index++) {
|
||||
const row = rows.nth(index);
|
||||
if ((await row.locator("dt").innerText()).trim() === label) {
|
||||
assertEqual((await row.locator("dd").innerText()).trim(), expected, `${label} did not match.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`The device details did not contain '${label}'.`);
|
||||
}
|
||||
|
||||
async function readSelfDeviceInfo(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SelfDeviceInfo> {
|
||||
return await evalObsidianJson<SelfDeviceInfo>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
`const nodeInfo=await core.services.database.localDatabase.localDatabase.get(${JSON.stringify(NODEINFO_DOCID)});`,
|
||||
"return JSON.stringify({",
|
||||
"id:nodeInfo.nodeid,",
|
||||
"platform:core.services.API.getPlatform(),",
|
||||
"pluginVersion:core.services.API.getPluginVersion(),",
|
||||
"appVersion:core.services.API.getAppVersion(),",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
function createFixture(self: SelfDeviceInfo): DeviceFixture {
|
||||
const preferredTweaks = { customChunkSize: 60, chunkSplitterVersion: "v3-rabin-karp" };
|
||||
const milestone: JoinedDevicesMilestone = {
|
||||
_id: MILESTONE_DOCID,
|
||||
type: "milestoneinfo",
|
||||
created: Date.now(),
|
||||
// The current device is deliberately absent: it must still be listed first and identified as locked out.
|
||||
accepted_nodes: [targetId, retainedId],
|
||||
node_info: {
|
||||
[targetId]: {
|
||||
device_name: "E2E target device",
|
||||
vault_name: "Joined devices target fixture",
|
||||
app_version: "1.99.0-test",
|
||||
plugin_version: "999.0.0",
|
||||
platform: initialPlatform,
|
||||
progress: "ready",
|
||||
last_connected: connectedAt,
|
||||
},
|
||||
[retainedId]: {
|
||||
device_name: "E2E retained device",
|
||||
vault_name: "Joined devices retained fixture",
|
||||
app_version: "1.88.0-test",
|
||||
plugin_version: "0.1.0",
|
||||
platform: "E2E Linux desktop",
|
||||
progress: "ready",
|
||||
last_connected: connectedAt - 86400000,
|
||||
},
|
||||
},
|
||||
locked: true,
|
||||
cleaned: true,
|
||||
node_chunk_info: {
|
||||
[targetId]: { min: 1, max: 3, current: 3 },
|
||||
[retainedId]: { min: 1, max: 2, current: 2 },
|
||||
},
|
||||
tweak_values: {
|
||||
[targetId]: { customChunkSize: 48 },
|
||||
[retainedId]: { customChunkSize: 60 },
|
||||
[DEVICE_ID_PREFERRED]: preferredTweaks,
|
||||
},
|
||||
};
|
||||
assert(self.id !== targetId && self.id !== retainedId, "The local device identity collided with a fixture ID.");
|
||||
return { milestone, preferredTweaks };
|
||||
}
|
||||
|
||||
async function openFromRemoteSettings(page: Page): Promise<Locator> {
|
||||
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const remoteConfiguration = await settings.openPage("Remote Configuration");
|
||||
await remoteConfiguration
|
||||
.getByRole("button", { name: "Review devices", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
return dialogue;
|
||||
}
|
||||
|
||||
async function assertInitialDialogue(dialogue: Locator, self: SelfDeviceInfo, page: Page): Promise<void> {
|
||||
const cards = dialogue.locator(".device-card");
|
||||
assertEqual(
|
||||
await cards.count(),
|
||||
3,
|
||||
"The joined devices dialogue did not show the self, target, and retained records."
|
||||
);
|
||||
const selfCard = cards.nth(0);
|
||||
assert(Boolean(self.id), "The current device identity was not read from the local NODEINFO_DOCID record.");
|
||||
assertEqual(
|
||||
(await selfCard.locator("code.device-id").innerText()).trim(),
|
||||
self.id,
|
||||
"The current device was not listed first."
|
||||
);
|
||||
await selfCard.getByText("This device", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await selfCard.getByText("Locked out", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await selfCard.getByText("Not accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await selfCard
|
||||
.getByText("This device has been locked out.", { exact: false })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await assertDetail(selfCard, "Platform", self.platform);
|
||||
await assertDetail(selfCard, "Plug-in version", self.pluginVersion);
|
||||
await assertDetail(selfCard, "App version", self.appVersion);
|
||||
assertEqual(
|
||||
await selfCard.getByRole("button", { name: "Forget device", exact: true }).count(),
|
||||
0,
|
||||
"The current device exposed a forget control."
|
||||
);
|
||||
|
||||
const targetCard = deviceCard(dialogue, targetId);
|
||||
await assertDetail(targetCard, "Platform", initialPlatform);
|
||||
await assertDetail(targetCard, "Plug-in version", "999.0.0");
|
||||
await assertDetail(targetCard, "App version", "1.99.0-test");
|
||||
const targetDate = await page.evaluate((timestamp) => new Date(timestamp).toLocaleString(), connectedAt);
|
||||
await assertDetail(targetCard, "Last recorded", targetDate);
|
||||
await targetCard.getByText("Accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
|
||||
const retainedCard = deviceCard(dialogue, retainedId);
|
||||
await assertDetail(retainedCard, "Platform", "E2E Linux desktop");
|
||||
await assertDetail(retainedCard, "Plug-in version", "0.1.0");
|
||||
await assertDetail(retainedCard, "App version", "1.88.0-test");
|
||||
await dialogue
|
||||
.getByText(
|
||||
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
|
||||
{ exact: true }
|
||||
)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await dialogue
|
||||
.locator(".sls-info-note")
|
||||
.getByText(
|
||||
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
|
||||
{ exact: true }
|
||||
)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await dialogue
|
||||
.getByText("This remote is locked.", { exact: false })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await dialogue
|
||||
.getByText("This remote has been cleaned.", { exact: false })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
async function refreshRemotePlatform(page: Page, remote: JoinedDevicesRemote): Promise<void> {
|
||||
const milestone = await remote.read();
|
||||
await remote.write({
|
||||
...milestone,
|
||||
node_info: {
|
||||
...milestone.node_info,
|
||||
[targetId]: { ...milestone.node_info[targetId], platform: refreshedPlatform },
|
||||
},
|
||||
});
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await deviceCard(dialogue, targetId).getByText(refreshedPlatform, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function showForgetConfirmation(dialogue: Locator): Promise<void> {
|
||||
const targetCard = deviceCard(dialogue, targetId);
|
||||
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await targetCard.getByText("Forget this device record?", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCancelledForget(dialogue: Locator, remote: JoinedDevicesRemote): Promise<void> {
|
||||
const before = await remote.read();
|
||||
await showForgetConfirmation(dialogue);
|
||||
const confirmation = deviceCard(dialogue, targetId).locator(".forget-confirmation");
|
||||
await confirmation.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await confirmation.waitFor({ state: "detached", timeout: uiTimeoutMs });
|
||||
assertEqual(JSON.stringify(await remote.read()), JSON.stringify(before), "Cancelling Forget changed the remote.");
|
||||
}
|
||||
|
||||
async function confirmForgetTarget(dialogue: Locator): Promise<void> {
|
||||
const targetCard = deviceCard(dialogue, targetId);
|
||||
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await dialogue.getByText("The device record was forgotten.", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await targetCard.waitFor({ state: "detached", timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
async function assertTargetForgotten(remote: JoinedDevicesRemote, fixture: DeviceFixture): Promise<void> {
|
||||
const milestone = await remote.read();
|
||||
assertEqual(
|
||||
JSON.stringify(milestone.accepted_nodes),
|
||||
JSON.stringify([retainedId]),
|
||||
"Forgetting changed the wrong accepted device records."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(Object.keys(milestone.node_info)),
|
||||
JSON.stringify([retainedId]),
|
||||
"Forgetting changed the wrong device metadata records."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(Object.keys(milestone.node_chunk_info)),
|
||||
JSON.stringify([retainedId]),
|
||||
"Forgetting changed the wrong chunk metadata records."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(Object.keys(milestone.tweak_values).sort()),
|
||||
JSON.stringify([DEVICE_ID_PREFERRED, retainedId].sort()),
|
||||
"Forgetting changed the wrong tweak metadata records."
|
||||
);
|
||||
assertEqual(milestone.locked, true, "Forgetting changed the remote lock state.");
|
||||
assertEqual(milestone.cleaned, true, "Forgetting changed the remote cleaned state.");
|
||||
assertEqual(
|
||||
JSON.stringify(milestone.tweak_values[DEVICE_ID_PREFERRED]),
|
||||
JSON.stringify(fixture.preferredTweaks),
|
||||
"Forgetting changed the preferred-ID tweak record."
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(milestone.node_info[retainedId]),
|
||||
JSON.stringify(fixture.milestone.node_info[retainedId]),
|
||||
"Forgetting changed the retained device metadata."
|
||||
);
|
||||
}
|
||||
|
||||
async function assertScrollableMobileDialogue(page: Page, container: Locator): Promise<void> {
|
||||
const dialogue = container.locator(".modal").last();
|
||||
const closeButton = container.locator(".modal-close-button, .modal-header-button").last();
|
||||
await assertLocatorWithinViewport(page, dialogue, { label: "Joined devices dialogue" });
|
||||
await assertNoHorizontalOverflow(page, dialogue, { label: "Joined devices dialogue" });
|
||||
await assertLocatorWithinSafeArea(page, dialogue, {
|
||||
label: "Joined devices dialogue",
|
||||
safeAreaInsets: iPhoneSafeArea,
|
||||
});
|
||||
const buttons = [closeButton, ...(await dialogue.locator("button:visible").all())];
|
||||
for (const button of buttons) {
|
||||
// Device records form a scrollable list; every control must be reachable after scrolling.
|
||||
await button.scrollIntoViewIfNeeded();
|
||||
const label = (await button.innerText()).trim() || "Close dialogue";
|
||||
await assertLocatorWithinViewport(page, button, { label });
|
||||
await assertLocatorWithinSafeArea(page, button, { label, safeAreaInsets: iPhoneSafeArea });
|
||||
await assertNoHorizontalOverflow(page, button, { label });
|
||||
await assertLocatorHasMinimumTouchTarget(page, button, { label });
|
||||
}
|
||||
await container.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0));
|
||||
}
|
||||
|
||||
async function dismissNotices(page: Page): Promise<void> {
|
||||
const notices = page.locator(".notice:visible");
|
||||
for (let remaining = 20; remaining > 0 && (await notices.count()) > 0; remaining--) {
|
||||
const notice = await notices.first().elementHandle();
|
||||
if (!notice) continue;
|
||||
try {
|
||||
await notice.click({ position: { x: 20, y: 20 }, timeout: uiTimeoutMs });
|
||||
await notice.waitForElementState("hidden", { timeout: uiTimeoutMs });
|
||||
} finally {
|
||||
await notice.dispose();
|
||||
}
|
||||
}
|
||||
assertEqual(await notices.count(), 0, "Notices still obscured the dialogue screenshot.");
|
||||
}
|
||||
|
||||
async function captureJoinedDevices(
|
||||
port: number,
|
||||
filename: string,
|
||||
assertReady: (page: Page) => Promise<Locator>
|
||||
): Promise<void> {
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
await withObsidianPage(port, async (page) => {
|
||||
try {
|
||||
const dialogue = await assertReady(page);
|
||||
await dialogue.page().screenshot({ path: join(outputDirectory, filename), fullPage: true });
|
||||
} catch (error) {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", 500).catch(() => undefined);
|
||||
const failurePath = join(outputDirectory, filename.replace(/\.png$/u, ".failure.png"));
|
||||
await (dialogue?.page() ?? page).screenshot({ path: failurePath, fullPage: true }).catch(() => undefined);
|
||||
console.error(`Joined devices UI failure screenshot: ${failurePath}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
|
||||
const remote = await createRemoteFixture();
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
await remote.prepare();
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: remote.pluginData,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort);
|
||||
await remote.configure(cli.binary, session.cliEnv);
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await prepareRemote(cli.binary, session.cliEnv);
|
||||
|
||||
const self = await readSelfDeviceInfo(cli.binary, session.cliEnv);
|
||||
const fixture = createFixture(self);
|
||||
const preparedMilestone = await remote.read();
|
||||
await remote.write({
|
||||
...fixture.milestone,
|
||||
...(preparedMilestone._rev ? { _rev: preparedMilestone._rev } : {}),
|
||||
});
|
||||
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-notice.png", async (page) => {
|
||||
const dialogue = await openFromRemoteSettings(page);
|
||||
const dialoguePage = dialogue.page();
|
||||
await dialoguePage.setViewportSize({ width: 1280, height: 1100 });
|
||||
await assertInitialDialogue(dialogue, self, dialoguePage);
|
||||
const notice = dialoguePage.locator(".notice").filter({
|
||||
hasText: "Newer Self-hosted LiveSync versions detected: 999.0.0.",
|
||||
});
|
||||
await notice.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await notice
|
||||
.getByText("Older Self-hosted LiveSync versions detected: 0.1.0.", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
assertEqual(
|
||||
await notice
|
||||
.getByText("Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.", {
|
||||
exact: true,
|
||||
})
|
||||
.count(),
|
||||
1,
|
||||
"The version Notice did not contain exactly one recommendation."
|
||||
);
|
||||
assertEqual(
|
||||
await notice.getByRole("button", { name: "Review devices", exact: true }).count(),
|
||||
1,
|
||||
"The version Notice did not contain exactly one shared review action."
|
||||
);
|
||||
return dialogue;
|
||||
});
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-desktop.png", async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await dialogue
|
||||
.page()
|
||||
.locator(".notice")
|
||||
.getByRole("button", { name: "Review devices", exact: true })
|
||||
.click();
|
||||
const reopened = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await reopened.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await dismissNotices(reopened.page());
|
||||
return reopened;
|
||||
});
|
||||
await captureJoinedDevices(
|
||||
session.remoteDebuggingPort,
|
||||
"joined-devices-forget-confirmation.png",
|
||||
async (page) => {
|
||||
await refreshRemotePlatform(page, remote);
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await assertCancelledForget(dialogue, remote);
|
||||
await showForgetConfirmation(dialogue);
|
||||
await deviceCard(dialogue, targetId).locator(".forget-confirmation").scrollIntoViewIfNeeded();
|
||||
return dialogue;
|
||||
}
|
||||
);
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-after-forget.png", async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await confirmForgetTarget(dialogue);
|
||||
await dialogue.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0));
|
||||
return dialogue;
|
||||
});
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
await settings.close();
|
||||
});
|
||||
await assertTargetForgotten(remote, fixture);
|
||||
|
||||
await setObsidianMobileTestMode(session.remoteDebuggingPort, true, uiTimeoutMs);
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-mobile.png", async (page) => {
|
||||
const opened = await page.evaluate((id) => {
|
||||
const app = (
|
||||
window as typeof window & {
|
||||
app?: { commands?: { executeCommandById?(commandId: string): boolean } };
|
||||
}
|
||||
).app;
|
||||
return app?.commands?.executeCommandById?.(id) === true;
|
||||
}, commandId);
|
||||
assert(opened, "The Joined devices command did not open the dialogue.");
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
|
||||
await dialogue.getByText(retainedId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await assertScrollableMobileDialogue(dialogue.page(), dialogue);
|
||||
assertEqual(
|
||||
await dialogue.locator(".device-card").count(),
|
||||
2,
|
||||
"The refreshed mobile list did not retain both remaining devices."
|
||||
);
|
||||
await dismissNotices(dialogue.page());
|
||||
return dialogue;
|
||||
});
|
||||
|
||||
console.log(`Joined devices scenario passed against ${remote.label}.`);
|
||||
} finally {
|
||||
try {
|
||||
if (session) await session.app.stop();
|
||||
} finally {
|
||||
try {
|
||||
await vault.dispose();
|
||||
} finally {
|
||||
await remote.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ const testSteps: Step[] = [
|
||||
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
|
||||
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
|
||||
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
|
||||
{ name: "joined devices with CouchDB", args: ["run", "test:e2e:obsidian:joined-devices"] },
|
||||
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
|
||||
{
|
||||
name: "manual CouchDB setup workflow",
|
||||
@@ -35,6 +36,7 @@ const testSteps: Step[] = [
|
||||
name: "CLI to real Obsidian synchronisation",
|
||||
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
|
||||
},
|
||||
{ name: "joined devices with Object Storage", args: ["run", "test:e2e:obsidian:joined-devices-object-storage"] },
|
||||
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
|
||||
{
|
||||
name: "Object Storage Setup URI workflow",
|
||||
@@ -56,6 +58,7 @@ const testSteps: Step[] = [
|
||||
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
|
||||
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] },
|
||||
{ name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
|
||||
{ name: "hidden file selectors", args: ["run", "test:e2e:obsidian:hidden-file-selectors"] },
|
||||
{ name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
|
||||
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
|
||||
{ name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] },
|
||||
|
||||
@@ -372,13 +372,21 @@ async function restartAndResumeHarness(): Promise<string> {
|
||||
await harness
|
||||
.locator('[data-testid="review-harness-resumed"]')
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const continuationRemoved = await page.evaluate((stateKey) => {
|
||||
const continuationRemoved = await page.evaluate(async (stateKey) => {
|
||||
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
|
||||
throw new Error("Self-hosted LiveSync is unavailable after restart.");
|
||||
}
|
||||
const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } }).core;
|
||||
return core.services.setting.getSmallConfig(stateKey) === "";
|
||||
const core = (
|
||||
plugin as {
|
||||
core: {
|
||||
services: {
|
||||
setting: { config: { getValue(key: string, defaultValue: string): Promise<string> } };
|
||||
};
|
||||
};
|
||||
}
|
||||
).core;
|
||||
return (await core.services.setting.config.getValue(stateKey, "")) === "";
|
||||
}, REVIEW_HARNESS_STATE_KEY);
|
||||
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
|
||||
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
|
||||
|
||||
@@ -8,6 +8,8 @@ const focusedScenarios = new Set([
|
||||
"smoke",
|
||||
"onboarding-invitation",
|
||||
"dialog-mounts",
|
||||
"joined-devices",
|
||||
"joined-devices-object-storage",
|
||||
"database-diagnostics",
|
||||
"database-diagnostics-couchdb",
|
||||
"conflict-dialog-policy",
|
||||
@@ -39,6 +41,7 @@ const focusedScenarios = new Set([
|
||||
"two-vault-sync",
|
||||
"security-seed-reconnect",
|
||||
"hidden-file-snippet-sync",
|
||||
"hidden-file-selectors",
|
||||
"customisation-sync",
|
||||
"customisation-catalogue-reload",
|
||||
"received-change-readiness",
|
||||
|
||||
@@ -58,8 +58,9 @@ type LiveSyncTestPlugin = {
|
||||
usePowerUserMode: boolean;
|
||||
useEdgeCaseMode: boolean;
|
||||
hashCacheMaxCount: number;
|
||||
customChunkSize: number;
|
||||
};
|
||||
getSmallConfig(key: string): string | null;
|
||||
config: { getValue(key: string, defaultValue: string): Promise<string> };
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -484,12 +485,12 @@ async function verifyCompatibilityReview(): Promise<void> {
|
||||
});
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const markerBeforeAcknowledgement = await page.evaluate(() => {
|
||||
const markerBeforeAcknowledgement = await page.evaluate(async () => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
|
||||
return plugin.core.services.setting.getSmallConfig("database-compatibility-version");
|
||||
return await plugin.core.services.setting.config.getValue("database-compatibility-version", "");
|
||||
});
|
||||
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") {
|
||||
if (markerBeforeAcknowledgement !== "") {
|
||||
throw new Error(
|
||||
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
|
||||
);
|
||||
@@ -579,12 +580,12 @@ async function verifyCompatibilityReview(): Promise<void> {
|
||||
.click();
|
||||
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
(expectedVersion) => {
|
||||
async (expectedVersion) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) return false;
|
||||
const setting = plugin.core.services.setting;
|
||||
return (
|
||||
setting.getSmallConfig("database-compatibility-version") === expectedVersion &&
|
||||
(await setting.config.getValue("database-compatibility-version", "")) === expectedVersion &&
|
||||
setting.currentSettings().versionUpFlash === ""
|
||||
);
|
||||
},
|
||||
@@ -611,10 +612,16 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
const chunkSize = await page.evaluate(() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.services.setting.currentSettings().customChunkSize;
|
||||
});
|
||||
if (chunkSize !== 0) {
|
||||
throw new Error("Compatibility review must retain the fixture's zero chunk-size value.");
|
||||
}
|
||||
if ((await doctor.getByText("Enhance chunk size", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor must accept the recommended zero chunk-size value.");
|
||||
}
|
||||
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
|
||||
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
|
||||
}
|
||||
@@ -978,6 +985,7 @@ function createSettingsPluginData(settingsOnlyRun: boolean): Record<string, unkn
|
||||
liveSync: false,
|
||||
versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage,
|
||||
notifyThresholdOfRemoteStorageSize: 0,
|
||||
customChunkSize: 0,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnEditorSave: false,
|
||||
|
||||
+57
-24
@@ -19,6 +19,63 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
- We can reload the customisation list and apply every file in a multi-file item on the first attempt. (#1271)
|
||||
- Reloading or clearing the list also prevents earlier pending updates from restoring removed files and items.
|
||||
|
||||
### Setup tools
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can generate a Setup URI with prefixed connection and encryption variables, avoiding collisions with shell or system variables such as `hostname` and `USERNAME`. (#817, #1274)
|
||||
- The generator reads `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE`. The previous names remain supported when the corresponding prefixed value is unset or blank.
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We receive a clear Notice when **Automatic** requires Hidden File Sync to be enabled on this device. Our existing mode remains unchanged, and hidden-file initialisation cannot start while the feature is disabled. (#1270)
|
||||
- We can initialise a whole plug-in's **MAIN** and **DATA** selections using the chosen Automatic initial action.
|
||||
- We can run startup integrity checks with lower memory use for healthy Vault files. Files that change between the size check and content reading no longer trigger an unnecessary repair warning when their actual sizes agree and there are no conflicts. (#1283)
|
||||
|
||||
## 1.0.40
|
||||
|
||||
24th October, 2026
|
||||
|
||||
This has turned into a slightly larger set of changes overall, but I have finally been able to implement the features I felt we would need.
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### New Feature
|
||||
|
||||
- We can choose **Review devices** in Remote Configuration, the command palette, or a version Notice to review participating devices.
|
||||
- Our device appears first, with its lockout state. Other records show the platform, plug-in version, app version, and last recorded connection time when available.
|
||||
- An advisory Notice identifies Self-hosted LiveSync and lists newer and older versions separately, with one shared recommendation and **Review devices** action. The dialogue explains possible storage costs and P2P compatibility limits.
|
||||
- We can forget records belonging to lost or retired devices while preserving the remote lock state and other devices.
|
||||
- Forgetting a record does not delete Vault files or revoke credentials. A returning device can register again on an unlocked remote.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can reuse unchanged content more reliably after edits with the V3 Rabin–Karp chunk splitter.
|
||||
- Corrected rolling-window processing lets boundary detection recover after an insertion. Existing content remains readable, and installing this update does not trigger a full re-chunk of our Vaults.
|
||||
- Chunk-size values 0 and 1 now use content-defined binary boundaries below their maximum lengths, improving reuse after an insertion instead of producing fixed-length pieces.
|
||||
- Editing or explicitly rewriting a file, recreating its chunks, or rebuilding from our files can produce different chunk boundaries and additional chunks. Older and updated devices can retain different chunk layouts; this affects reuse and storage, not the file contents.
|
||||
- We can preserve all conflict branches, including deleted branches, during Fast Setup.
|
||||
- A fresh, complete Fast Fetch can also hand its verified progress to ordinary synchronisation before file watching resumes, reducing the first subsequent identifier scan. Existing resume cursors and On Demand chunk retrieval retain their existing behaviour.
|
||||
|
||||
#### Changed
|
||||
|
||||
- New setup uses the same Chunk-size recommendation of 0 across remote types, and Setting Doctor no longer recommends increasing every self-hosted V3 configuration to 60.
|
||||
- Setting Doctor no longer flags the saved **Enhance chunk size** value. When rebuilding with V3 Rabin–Karp, we recommend setting it to 0. This is now an efficient choice across all supported remote types.
|
||||
- Existing explicit and agreed values are retained. Smaller Chunks favour reuse during editing, but increase document counts and can make first uploads and large rebuilds slower. Mostly immutable binary Vaults can still benefit from a larger explicit value.
|
||||
|
||||
### Hidden File Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can synchronise selected hidden files while the normal-file allow-list is set. (#1272)
|
||||
- Normal-file exclusion patterns still apply to hidden files, and the Selector descriptions now explain their scope.
|
||||
|
||||
### CLI
|
||||
|
||||
- Unreadable or malformed device-local state now stops initialisation instead of being treated as empty. Existing valid state files keep their format.
|
||||
|
||||
## 1.0.39
|
||||
|
||||
20th October, 2026
|
||||
@@ -133,27 +190,3 @@ Thank you for your contributions!
|
||||
|
||||
- @zeedif (#1213, #1214)
|
||||
- @lingting0 (#1242)
|
||||
|
||||
## 1.0.35
|
||||
|
||||
5th October, 2026
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can continue synchronising after rebuilding an encrypted CouchDB remote, including when Hidden File Sync and Customisation Sync are enabled. (#1234)
|
||||
- This plug-in reads the rebuilt data without reusing HTTP responses encrypted with the previous Security Seed.
|
||||
|
||||
### Customisation Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Customisation Sync now waits until this plug-in is ready before handling configuration file events, and applying settings while synchronisation is suspended no longer scans configuration files. (#1220)
|
||||
- Resuming synchronisation scans once and restarts the periodic scan when enabled.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Thank you for your contributions!
|
||||
|
||||
- @kimjansheden (#1220)
|
||||
@@ -0,0 +1,72 @@
|
||||
# Chunk-size research harness
|
||||
|
||||
These scripts support the October 2026 Chunk-size investigation. The decision,
|
||||
measurements, and their limits are recorded in Commonlib's
|
||||
`docs/adr/2026_10_chunking_and_transport_batching.md` and its evidence companion.
|
||||
They are research tools with experiment-specific assertions, separate from
|
||||
the maintained unit and integration suites.
|
||||
|
||||
The original experiments used Commonlib 0.1.44 and unpublished candidates with
|
||||
the same version label. `local.mjs` checks that version explicitly. Record the
|
||||
installed package's source revision and tarball digest when comparing results;
|
||||
the version label alone does not identify the tested implementation. Scripts
|
||||
which import internal `dist` paths also depend on that package layout.
|
||||
|
||||
## Local measurements
|
||||
|
||||
Install the exact Commonlib candidate to assess, then run from the repository
|
||||
root with Node.js 24:
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=3072 node utils/chunk-size-benchmark/local.mjs --output .bench/chunk-size --sizes 0,1,4 --repeats 1
|
||||
node --test utils/chunk-size-benchmark/rabin-window.test.mjs
|
||||
```
|
||||
|
||||
The local run generates a deterministic corpus from repository Markdown,
|
||||
images, and seeded binary data. It records initial, overwrite, and insertion
|
||||
stages, Chunk identifiers, reconstructed content checks, and timings. Keep
|
||||
the generated corpus when comparing revisions: source Markdown may change.
|
||||
Generated Vaults, logs, and results belong under `.bench/` and are not source
|
||||
files for the normal test runner.
|
||||
|
||||
## Transport measurements
|
||||
|
||||
Build the CLI and start the disposable fixtures required for the chosen
|
||||
transport. These use loopback ports and public benchmark-only credentials.
|
||||
The scripts create and delete their own test databases and buckets.
|
||||
GNU `time`, Docker Compose, and a Linux CLI runtime are required.
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=3072 npm run build --workspace self-hosted-livesync-cli
|
||||
docker compose -p livesync-chunk-benchmark -f utils/chunk-size-benchmark/compose.yml up -d couchdb object-storage
|
||||
node utils/chunk-size-benchmark/remote.mjs --output .bench/chunk-size --sizes 0,1,4 --repeats 1 --rtt-ms 0
|
||||
docker compose -p livesync-chunk-benchmark -f utils/chunk-size-benchmark/compose.yml down --volumes
|
||||
```
|
||||
|
||||
`remote.mjs` accepts `--providers couchdb`, `--providers s3`, or both, plus
|
||||
`--corpus`, `--source-cli`, and `--target-cli`. Its HTTP proxy adds the requested
|
||||
RTT across the request and response legs. This models per-request delay rather
|
||||
than network bandwidth, packet loss, or a production service.
|
||||
|
||||
For P2P, start the `relay` fixture instead and run `p2p.mjs` with the same
|
||||
output, corpus, size, repeat, and CLI options, omitting `--rtt-ms`. Its
|
||||
DataChannel counters measure application payloads and record the selected
|
||||
connection path. An archived CLI entry point needs all of its generated
|
||||
CommonJS chunks beside it. Run transports sequentially and stop the named
|
||||
Compose project afterwards.
|
||||
|
||||
## Historical analysis helpers
|
||||
|
||||
- `summarise.mjs`, `report.mjs`, and `verify-results.mjs` consume the original
|
||||
result layouts. The verifier requires that experiment's exact variants,
|
||||
configuration counts, and files; it is not a general mixed-version verifier.
|
||||
- `cloudant-limit.mjs` temporarily changes the disposable CouchDB document
|
||||
limit and restores it afterwards. `cloudant-json-boundary.mjs` probes final
|
||||
serialised document sizes against the same fixture. These scripts model
|
||||
published Cloudant constraints; they do not connect to IBM Cloudant or
|
||||
certify its acceptance of a Vault.
|
||||
|
||||
Some helpers retain historical output-directory and size defaults. Supply
|
||||
explicit paths and inspect their assertions before reusing them for a new
|
||||
experiment. The recorded implementation matrix checks file content in both
|
||||
writer directions; revision-conflict retention has separate Commonlib tests.
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const packageRoot = dirname(fileURLToPath(import.meta.resolve('@vrtmrz/livesync-commonlib/package.json')));
|
||||
const { splitPiecesRabinKarp } = await import(pathToFileURL(join(packageRoot, 'dist/string_and_binary/chunks.js')).href);
|
||||
const { getConfiguredFunctionsForEncryption } = await import(pathToFileURL(join(packageRoot, 'dist/pouchdb/encryption.js')).href);
|
||||
const { compressDoc } = await import(pathToFileURL(join(packageRoot, 'dist/pouchdb/compress.js')).href);
|
||||
const output = resolve(process.argv[2] || '.bench/rabin-window-fixed/cloudant-json-boundary');
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
const uri = 'http://127.0.0.1:15994';
|
||||
const headers = { Authorization: `Basic ${Buffer.from('bench:benchmark-only-password').toString('base64')}`, 'Content-Type': 'application/json' };
|
||||
const configPath = '/_node/_local/_config/couchdb/max_document_size';
|
||||
const previousResponse = await fetch(uri + configPath, { headers });
|
||||
assert(previousResponse.ok || previousResponse.status === 404);
|
||||
const previous = previousResponse.ok ? await previousResponse.json() : undefined;
|
||||
const requestConfigPath = '/_node/_local/_config/chttpd/max_http_request_size';
|
||||
const previousRequestResponse = await fetch(uri + requestConfigPath, { headers });
|
||||
assert(previousRequestResponse.ok || previousRequestResponse.status === 404);
|
||||
const previousRequestLimit = previousRequestResponse.ok ? await previousRequestResponse.json() : undefined;
|
||||
const database = `chunkbench-json-budget-${Date.now()}`;
|
||||
const inputs = {
|
||||
binary: new Blob([new Uint8Array(4 * 1024 * 1024)], { type: 'application/octet-stream' }),
|
||||
nulText: new Blob(['\0'.repeat(4 * 1024 * 1024)], { type: 'text/plain' }),
|
||||
escapedText: new Blob(['\\'.repeat(4 * 1024 * 1024)], { type: 'text/plain' }),
|
||||
};
|
||||
const cases = [
|
||||
{ kind: 'binary', customChunkSize: 4, encryption: 'v1', compression: false, expectedAccepted: true },
|
||||
{ kind: 'binary', customChunkSize: 4, encryption: 'v2', compression: false, expectedAccepted: true },
|
||||
{ kind: 'binary', customChunkSize: 5, encryption: 'v2', compression: false, expectedAccepted: false },
|
||||
{ kind: 'nulText', customChunkSize: 0, encryption: 'none', compression: false, expectedAccepted: true },
|
||||
{ kind: 'nulText', customChunkSize: 4, encryption: 'none', compression: false },
|
||||
{ kind: 'escapedText', customChunkSize: 4, encryption: 'none', compression: false },
|
||||
{ kind: 'nulText', customChunkSize: 4, encryption: 'v2', compression: false, expectedAccepted: true },
|
||||
{ kind: 'nulText', customChunkSize: 4, encryption: 'none', compression: true, expectedAccepted: true },
|
||||
];
|
||||
const records = [];
|
||||
const record = { scope: 'Synthetic leaf-document probes using the installed splitter, compression, encryption, and JSON serialisation, sent to the owned CouchDB fixture with a 1,000,000-byte document limit and 64,000,000-byte request limit. This is not an IBM Cloudant service measurement or a whole-file synchronisation test. CouchDB 3.5.0 estimates document size from parsed strings and does not count JSON escapes; Cloudant internal size accounting has not been verified.', source: 'https://cloud.ibm.com/docs/Cloudant?topic=Cloudant-limits', sizeCalculationSource: 'https://github.com/apache/couchdb/blob/3.5.0/src/couch/src/couch_ejson_size.erl', documentLimitBytes: 1000000, requestLimitBytes: 64000000, previousDocumentLimit: previous ?? null, previousRequestLimit: previousRequestLimit ?? null, cases: records, batches: [] };
|
||||
try {
|
||||
assert((await fetch(uri + configPath, { method: 'PUT', headers, body: JSON.stringify('1000000') })).ok);
|
||||
assert((await fetch(uri + requestConfigPath, { method: 'PUT', headers, body: JSON.stringify('64000000') })).ok);
|
||||
assert((await fetch(`${uri}/${database}`, { method: 'PUT', headers })).ok);
|
||||
for (const condition of cases) {
|
||||
const input = inputs[condition.kind];
|
||||
const chunks = await splitPiecesRabinKarp(input, 102400 * (condition.customChunkSize + 1), condition.kind !== 'binary', 20);
|
||||
let largest = '';
|
||||
for await (const chunk of chunks()) if (chunk.length > largest.length) largest = chunk;
|
||||
const id = `h:+${createHash('sha256').update(JSON.stringify(condition)).digest('hex')}`;
|
||||
let doc = { _id: id, type: 'leaf', data: largest };
|
||||
if (condition.compression) doc = await compressDoc(doc);
|
||||
if (condition.encryption !== 'none') {
|
||||
const { incoming, outgoing } = getConfiguredFunctionsForEncryption('public-json-budget-probe-passphrase', false, false, async () => new Uint8Array(16), condition.encryption);
|
||||
const encrypted = await incoming(doc);
|
||||
assert.equal(encrypted.e_, true);
|
||||
const decoded = await outgoing(structuredClone(encrypted));
|
||||
assert.equal(decoded.data, doc.data);
|
||||
doc = encrypted;
|
||||
}
|
||||
doc = { ...doc, _rev: `1-${'0'.repeat(32)}`, _revisions: { start: 1, ids: ['0'.repeat(32)] } };
|
||||
const jsonBytes = Buffer.byteLength(JSON.stringify(doc));
|
||||
const body = JSON.stringify({ docs: [doc], new_edits: false });
|
||||
const response = await fetch(`${uri}/${database}/_bulk_docs`, { method: 'POST', headers, body });
|
||||
const result = await response.json();
|
||||
const accepted = response.ok && Array.isArray(result) && result.every(entry => !entry.error);
|
||||
const measurement = { ...condition, largestPieceUtf8Bytes: Buffer.byteLength(largest), jsonBytes, jsonBytesUnderLimit: jsonBytes < record.documentLimitBytes, requestBodyBytes: Buffer.byteLength(body), status: response.status, accepted, response: result };
|
||||
records.push(measurement);
|
||||
await fs.writeFile(join(output, 'results.json'), JSON.stringify(record, null, 2));
|
||||
if (condition.expectedAccepted !== undefined) assert.equal(accepted, condition.expectedAccepted, JSON.stringify(measurement));
|
||||
assert(Buffer.byteLength(body) < record.requestLimitBytes);
|
||||
console.log(JSON.stringify({ ...measurement, response: undefined }));
|
||||
}
|
||||
const batchInput = new Uint8Array(512000 * 25);
|
||||
for (let index = 0; index < 25; index++) batchInput[(index + 1) * 512000 - 1] = index + 1;
|
||||
const splitBatch = await splitPiecesRabinKarp(new Blob([batchInput], { type: 'text/plain' }), 512000, true, 20);
|
||||
const batchPieces = [];
|
||||
for await (const piece of splitBatch()) batchPieces.push(piece);
|
||||
assert.equal(batchPieces.length, 25);
|
||||
assert(batchPieces.every(piece => Buffer.byteLength(piece) === 512000));
|
||||
assert.equal(new Set(batchPieces).size, 25);
|
||||
for (const count of [25, 20]) {
|
||||
const docs = batchPieces.slice(0, count).map((piece, index) => ({
|
||||
_id: `h:+${createHash('sha256').update(`escaped-batch-${count}-${index}`).digest('hex')}`,
|
||||
type: 'leaf', data: piece,
|
||||
_rev: `1-${'0'.repeat(32)}`, _revisions: { start: 1, ids: ['0'.repeat(32)] },
|
||||
}));
|
||||
const body = JSON.stringify({ docs, new_edits: false });
|
||||
const response = await fetch(`${uri}/${database}/_bulk_docs`, { method: 'POST', headers, body });
|
||||
const result = await response.json();
|
||||
const accepted = response.ok && Array.isArray(result) && result.every(entry => !entry.error);
|
||||
const measurement = { count, rawBytesPerLeaf: 512000, requestBodyBytes: Buffer.byteLength(body), largestDocumentJsonBytes: Math.max(...docs.map(doc => Buffer.byteLength(JSON.stringify(doc)))), status: response.status, accepted, response: result };
|
||||
record.batches.push(measurement);
|
||||
await fs.writeFile(join(output, 'results.json'), JSON.stringify(record, null, 2));
|
||||
assert.equal(accepted, count === 20, JSON.stringify(measurement));
|
||||
if (count === 25) assert.equal(response.status, 413);
|
||||
console.log(JSON.stringify({ ...measurement, response: undefined }));
|
||||
}
|
||||
} finally {
|
||||
await fetch(`${uri}/${database}`, { method: 'DELETE', headers });
|
||||
const restored = await fetch(uri + configPath, previous === undefined ? { method: 'DELETE', headers } : { method: 'PUT', headers, body: JSON.stringify(previous) });
|
||||
record.configurationRestored = restored.ok;
|
||||
const requestRestored = await fetch(uri + requestConfigPath, previousRequestLimit === undefined ? { method: 'DELETE', headers } : { method: 'PUT', headers, body: JSON.stringify(previousRequestLimit) });
|
||||
record.requestConfigurationRestored = requestRestored.ok;
|
||||
await fs.writeFile(join(output, 'results.json'), JSON.stringify(record, null, 2));
|
||||
assert(record.configurationRestored);
|
||||
assert(record.requestConfigurationRestored);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const options = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 2) {
|
||||
assert(['--output', '--corpus'].includes(process.argv[index]), `Unknown option: ${process.argv[index]}`);
|
||||
assert(process.argv[index + 1], `Missing value for ${process.argv[index]}`);
|
||||
options.set(process.argv[index], process.argv[index + 1]);
|
||||
}
|
||||
const output = resolve(options.get('--output') || '.bench/chunk-size-cloudant-model');
|
||||
const corpusPath = resolve(options.get('--corpus') || '.bench/chunk-size/corpus/manifest.json');
|
||||
const configUri = 'http://127.0.0.1:15994/_node/_local/_config/couchdb/max_document_size';
|
||||
const headers = { Authorization: `Basic ${Buffer.from('bench:benchmark-only-password').toString('base64')}`, 'Content-Type': 'application/json' };
|
||||
const previousResponse = await fetch(configUri, { headers });
|
||||
assert(previousResponse.ok || previousResponse.status === 404);
|
||||
const previous = previousResponse.ok ? await previousResponse.json() : undefined;
|
||||
const manifest = JSON.parse(await fs.readFile(corpusPath, 'utf8'));
|
||||
manifest.stages = manifest.stages.filter((stage) => stage.name === 'initial');
|
||||
for (const stage of manifest.stages) stage.directory = resolve(dirname(corpusPath), stage.directory);
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
const initialManifest = join(output, 'initial-only-manifest.json');
|
||||
await fs.writeFile(initialManifest, JSON.stringify(manifest, null, 2));
|
||||
const record = { scope: 'CouchDB with a 1,000,000-byte document limit, modelling the published Cloudant document-size constraint. The IBM Cloudant service itself is not measured.', source: 'https://cloud.ibm.com/docs/Cloudant?topic=Cloudant-limits', limitBytes: 1000000, publishedRequestLimitBytes: 64000000, variant: process.env.LIVESYNC_BENCH_VARIANT || 'unlabelled', previousConfiguredValue: previous ?? null };
|
||||
try {
|
||||
const configured = await fetch(configUri, { method: 'PUT', headers, body: JSON.stringify(String(record.limitBytes)) });
|
||||
assert(configured.ok, `Could not set fixture document limit: ${configured.status}`);
|
||||
const child = spawn(process.execPath, [join(root, 'utils/chunk-size-benchmark/remote.mjs'), '--output', output, '--corpus', initialManifest, '--providers', 'couchdb', '--sizes', '4,10', '--repeats', '1'], {
|
||||
cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=3072' },
|
||||
});
|
||||
let log = ''; child.stdout.on('data', (data) => { log += data; process.stdout.write(data); }); child.stderr.on('data', (data) => { log += data; });
|
||||
record.exitCode = await new Promise((accept, reject) => { child.once('error', reject); child.once('close', accept); });
|
||||
await fs.writeFile(join(output, 'run.log'), log);
|
||||
const measured = JSON.parse(await fs.readFile(join(output, 'remote/results-rtt0.json'), 'utf8'));
|
||||
record.cases = measured.results.map((entry) => ({ name: entry.name, customChunkSize: entry.customChunkSize, complete: entry.complete, error: entry.error, stages: entry.stages.map((stage) => ({ name: stage.name, largestDocumentJsonBytes: stage.remote.largestDocumentJsonBytes, maxUploadRequestBodyBytes: stage.upload.http.maxRequestBodyBytes, verifiedFiles: stage.verifiedFiles })) }));
|
||||
assert(record.cases.some((entry) => entry.customChunkSize === 4 && entry.complete), 'The 500 KiB candidate must complete under the modelled limit');
|
||||
assert(record.cases.some((entry) => entry.customChunkSize === 10 && !entry.complete), 'The 1,100 KiB candidate must hit the modelled document limit');
|
||||
const rejectionLog = await fs.readFile(join(output, 'remote/couchdb-c10-r1/logs/initial-upload.txt'), 'utf8');
|
||||
record.sizeRejectionObserved = /413|document_too_large|document.*too large|too_large|request entity too large/iu.test(rejectionLog);
|
||||
assert(record.sizeRejectionObserved, 'The rejection log must identify the document-size limit');
|
||||
} finally {
|
||||
const restored = await fetch(configUri, previous === undefined ? { method: 'DELETE', headers } : { method: 'PUT', headers, body: JSON.stringify(previous) });
|
||||
record.configurationRestored = restored.ok;
|
||||
await fs.writeFile(join(output, 'constraint-result.json'), JSON.stringify(record, null, 2));
|
||||
assert(record.configurationRestored, 'Fixture document limit restoration failed');
|
||||
}
|
||||
console.log(JSON.stringify(record));
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
relay:
|
||||
image: ghcr.io/hoytech/strfry@sha256:36f1886d185a88ca57c66ebe52e6e9e8428dac2486eea0a5d50ff934f18b60c3
|
||||
command: ["--config", "/etc/strfry.conf", "relay"]
|
||||
volumes:
|
||||
- ../../test/fixtures/p2p-relay/strfry.conf:/etc/strfry.conf:ro
|
||||
tmpfs:
|
||||
- /app/strfry-db:rw,size=128m,mode=1777
|
||||
ports:
|
||||
- "127.0.0.1:14094:7777"
|
||||
mem_limit: 256m
|
||||
couchdb:
|
||||
image: couchdb:3.5.0@sha256:1c0a30a54535377bc9ae23f42efb1ca4e92abe796f78ec98b768ac1d5874cc1b
|
||||
environment:
|
||||
COUCHDB_USER: bench
|
||||
COUCHDB_PASSWORD: benchmark-only-password
|
||||
COUCHDB_SINGLE_NODE: "true"
|
||||
ports:
|
||||
- "127.0.0.1:15994:5984"
|
||||
mem_limit: 768m
|
||||
object-storage:
|
||||
image: rustfs/rustfs:1.0.0-rc.6@sha256:97171b3d72cd47dc81000f92ea84de25608bfc35a94c965501afaeb5d99f6035
|
||||
environment:
|
||||
RUSTFS_ACCESS_KEY: bench-access-key
|
||||
RUSTFS_SECRET_KEY: benchmark-only-secret-key
|
||||
RUSTFS_CONSOLE_ENABLE: "false"
|
||||
command: ["/data"]
|
||||
ports:
|
||||
- "127.0.0.1:19094:9000"
|
||||
mem_limit: 1g
|
||||
@@ -0,0 +1,310 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, readFile, readdir, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_REPO_ROOT = path.resolve(MODULE_DIR, "../..");
|
||||
const SEED = 0x4c53594e;
|
||||
const KIB = 1024;
|
||||
const MIB = 1024 * KIB;
|
||||
const BINARY_BYTES = 8 * MIB;
|
||||
const SMALL_MARKDOWN_BYTES = 512 * KIB;
|
||||
const LARGE_MARKDOWN_BYTES = 5 * MIB;
|
||||
const BINARY_EDIT_BYTES = 4 * KIB;
|
||||
const INITIAL_MTIME = new Date("2020-01-01T00:00:00.000Z");
|
||||
const STAGE_MTIMES = {
|
||||
overwrite: new Date("2020-01-02T00:00:00.000Z"),
|
||||
insert: new Date("2020-01-03T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const MARKDOWN_FILES = [
|
||||
"README.md",
|
||||
"devs.md",
|
||||
"docs/settings.md",
|
||||
"docs/troubleshooting.md",
|
||||
"docs/quick_setup.md",
|
||||
"docs/datastructure.md",
|
||||
"docs/p2p.md",
|
||||
"docs/tips/fast-setup.md",
|
||||
"docs/design_docs/intention_of_chunks.md",
|
||||
"docs/specs_data_compression.md",
|
||||
];
|
||||
|
||||
const SNIPPET_FILES = [
|
||||
"docs/terms.md",
|
||||
"docs/glossary.md",
|
||||
"docs/quick_setup_ja.md",
|
||||
"docs/design_docs/unicode_path_identity.md",
|
||||
"docs/settings.md",
|
||||
];
|
||||
|
||||
const CORPUS_PATHS = {
|
||||
markdownSmall: "benchmark/generated-small.md",
|
||||
markdownLarge: "benchmark/generated-large.md",
|
||||
binary: "benchmark/random-8m.bin",
|
||||
cloudantImage: "assets/cloudant-setup.png",
|
||||
setupImage: "assets/quick-setup-first-setup-uri.png",
|
||||
};
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function createPrng(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return state >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
function makeRandomBytes(length, seed) {
|
||||
const next = createPrng(seed);
|
||||
const output = Buffer.allocUnsafe(length);
|
||||
for (let offset = 0; offset < length; offset += 4) {
|
||||
const value = next();
|
||||
output[offset] = value & 0xff;
|
||||
if (offset + 1 < length) output[offset + 1] = (value >>> 8) & 0xff;
|
||||
if (offset + 2 < length) output[offset + 2] = (value >>> 16) & 0xff;
|
||||
if (offset + 3 < length) output[offset + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function extractParagraphs(source) {
|
||||
const withoutFrontmatter = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, "");
|
||||
return withoutFrontmatter
|
||||
.replace(/\r\n/gu, "\n")
|
||||
.split(/\n{2,}/u)
|
||||
.map((paragraph) => paragraph.trim())
|
||||
.filter((paragraph) => {
|
||||
if (paragraph.length < 80) return false;
|
||||
if (/^(?:#{1,6}\s|!\[|\|)/u.test(paragraph)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function readSnippetParagraphs(repoRoot) {
|
||||
const excerpts = [];
|
||||
for (const file of SNIPPET_FILES) {
|
||||
const source = await readFile(path.join(repoRoot, file), "utf8");
|
||||
for (const paragraph of extractParagraphs(source)) {
|
||||
const excerpt = Array.from(paragraph).slice(0, 1000).join("");
|
||||
excerpts.push({ source: file, text: excerpt });
|
||||
}
|
||||
}
|
||||
if (excerpts.length === 0) throw new Error("No repository Markdown snippets were available.");
|
||||
return excerpts;
|
||||
}
|
||||
|
||||
function buildGeneratedMarkdown({ targetBytes, title, variant, excerpts }) {
|
||||
const lines = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
"A deterministic UTF-8 fixture contains varied passages from the project documentation.",
|
||||
"",
|
||||
];
|
||||
let byteLength = Buffer.byteLength(lines.join("\n"), "utf8");
|
||||
const profiles = ["replication", "metadata", "recovery", "integrity", "configuration", "history", "incremental", "transport"];
|
||||
const unicodeLabels = ["résumé", "café", "日本語", "中文", "Δοκιμή", "ångström", "naïve", "雪", "λ", "mañana"];
|
||||
let record = 0;
|
||||
while (byteLength < targetBytes) {
|
||||
const excerpt = excerpts[(record * 7 + variant.length) % excerpts.length];
|
||||
const profile = profiles[(record * 3 + variant.length) % profiles.length];
|
||||
const unicode = unicodeLabels[(record * 5 + variant.length) % unicodeLabels.length];
|
||||
const tag = ((SEED ^ (record * 0x9e3779b1)) >>> 0).toString(16).padStart(8, "0");
|
||||
const paragraph = [
|
||||
`## Record ${String(record + 1).padStart(6, "0")} · ${profile} · ${variant}`,
|
||||
"",
|
||||
excerpt.text,
|
||||
"",
|
||||
`Fixture label ${tag} uses ${unicode}; source excerpt: ${excerpt.source}; revision ${record % 97}.`,
|
||||
"",
|
||||
].join("\n");
|
||||
lines.push(paragraph);
|
||||
byteLength += Buffer.byteLength(paragraph, "utf8");
|
||||
record += 1;
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function insertNearStart(markdown, paragraph) {
|
||||
const firstGap = markdown.indexOf("\n\n");
|
||||
const introGap = markdown.indexOf("\n\n", firstGap + 2);
|
||||
if (firstGap < 0 || introGap < 0) throw new Error("Generated Markdown fixture has no introductory paragraph.");
|
||||
const insertionPoint = introGap + 2;
|
||||
return `${markdown.slice(0, insertionPoint)}${paragraph}\n\n${markdown.slice(insertionPoint)}`;
|
||||
}
|
||||
|
||||
async function writeFileWithMtime(directory, relativePath, contents, mtime) {
|
||||
const outputPath = path.join(directory, relativePath);
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, contents);
|
||||
await utimes(outputPath, mtime, mtime);
|
||||
}
|
||||
|
||||
async function copyStage(sourceDirectory, targetDirectory, relativePaths) {
|
||||
for (const relativePath of relativePaths) {
|
||||
const sourcePath = path.join(sourceDirectory, relativePath);
|
||||
const targetPath = path.join(targetDirectory, relativePath);
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
const sourceStat = await stat(sourcePath);
|
||||
await copyFile(sourcePath, targetPath);
|
||||
await utimes(targetPath, sourceStat.atime, sourceStat.mtime);
|
||||
}
|
||||
}
|
||||
|
||||
async function setMtime(directory, relativePath, mtime) {
|
||||
const outputPath = path.join(directory, relativePath);
|
||||
await utimes(outputPath, mtime, mtime);
|
||||
}
|
||||
|
||||
async function listFiles(directory) {
|
||||
const files = [];
|
||||
async function visit(currentDirectory) {
|
||||
const entries = await readdir(currentDirectory, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDirectory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(path.relative(directory, fullPath).split(path.sep).join("/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(directory);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
async function describeStage(stageName, stageDirectory, paths) {
|
||||
const files = [];
|
||||
for (const relativePath of paths) {
|
||||
const bytes = await readFile(path.join(stageDirectory, relativePath));
|
||||
files.push({ path: relativePath, bytes: bytes.byteLength, sha256: sha256(bytes) });
|
||||
}
|
||||
return { name: stageName, directory: stageName, files };
|
||||
}
|
||||
|
||||
export async function createCorpus(outDir, repoRoot = DEFAULT_REPO_ROOT) {
|
||||
const outputDirectory = path.resolve(outDir);
|
||||
const sourceRoot = path.resolve(repoRoot);
|
||||
const initialDirectory = path.join(outputDirectory, "initial");
|
||||
const overwriteDirectory = path.join(outputDirectory, "overwrite");
|
||||
const insertDirectory = path.join(outputDirectory, "insert");
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const initialContents = new Map();
|
||||
for (const relativePath of MARKDOWN_FILES) {
|
||||
initialContents.set(relativePath, await readFile(path.join(sourceRoot, relativePath)));
|
||||
}
|
||||
|
||||
const excerpts = await readSnippetParagraphs(sourceRoot);
|
||||
initialContents.set(
|
||||
CORPUS_PATHS.markdownSmall,
|
||||
Buffer.from(
|
||||
buildGeneratedMarkdown({
|
||||
targetBytes: SMALL_MARKDOWN_BYTES,
|
||||
title: "Generated Markdown · 512 KiB",
|
||||
variant: "small",
|
||||
excerpts,
|
||||
}),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
initialContents.set(
|
||||
CORPUS_PATHS.markdownLarge,
|
||||
Buffer.from(
|
||||
buildGeneratedMarkdown({
|
||||
targetBytes: LARGE_MARKDOWN_BYTES,
|
||||
title: "Generated Markdown · 5 MiB",
|
||||
variant: "large",
|
||||
excerpts,
|
||||
}),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
initialContents.set(CORPUS_PATHS.binary, makeRandomBytes(BINARY_BYTES, SEED));
|
||||
initialContents.set(CORPUS_PATHS.cloudantImage, await readFile(path.join(sourceRoot, "instruction_images/cloudant_1.png")));
|
||||
initialContents.set(CORPUS_PATHS.setupImage, await readFile(path.join(sourceRoot, "images/quick-setup/guide-quick-setup-first-setup-uri.png")));
|
||||
|
||||
const relativePaths = [...initialContents.keys()].sort();
|
||||
for (const [relativePath, contents] of initialContents) {
|
||||
await writeFileWithMtime(initialDirectory, relativePath, contents, INITIAL_MTIME);
|
||||
}
|
||||
|
||||
await copyStage(initialDirectory, overwriteDirectory, relativePaths);
|
||||
const smallMarkdownPath = path.join(overwriteDirectory, CORPUS_PATHS.markdownSmall);
|
||||
const smallMarkdownBefore = await readFile(smallMarkdownPath, "utf8");
|
||||
const originalIntro = "A deterministic UTF-8 fixture contains varied passages from the project documentation.";
|
||||
const changedIntro = "A deterministic UTF-8 fixture records a short content update using varied project passages.";
|
||||
if (!smallMarkdownBefore.includes(originalIntro)) throw new Error("Could not locate the generated Markdown paragraph to overwrite.");
|
||||
await writeFile(smallMarkdownPath, smallMarkdownBefore.replace(originalIntro, changedIntro));
|
||||
const binaryPath = path.join(overwriteDirectory, CORPUS_PATHS.binary);
|
||||
const initialBinary = await readFile(binaryPath);
|
||||
const binaryOverwriteOffset = Math.floor(initialBinary.byteLength * 0.25);
|
||||
const overwrittenBinary = Buffer.from(initialBinary);
|
||||
makeRandomBytes(BINARY_EDIT_BYTES, (SEED ^ 0x00a55a5a) >>> 0).copy(overwrittenBinary, binaryOverwriteOffset);
|
||||
await writeFile(binaryPath, overwrittenBinary);
|
||||
const alternatePng = await readFile(path.join(sourceRoot, "instruction_images/cloudant_2.png"));
|
||||
await writeFile(path.join(overwriteDirectory, CORPUS_PATHS.cloudantImage), alternatePng);
|
||||
for (const relativePath of [CORPUS_PATHS.markdownSmall, CORPUS_PATHS.binary, CORPUS_PATHS.cloudantImage]) {
|
||||
await setMtime(overwriteDirectory, relativePath, STAGE_MTIMES.overwrite);
|
||||
}
|
||||
|
||||
await copyStage(overwriteDirectory, insertDirectory, relativePaths);
|
||||
const insertedBinaryPath = path.join(insertDirectory, CORPUS_PATHS.binary);
|
||||
const overwrittenBinaryForInsert = await readFile(insertedBinaryPath);
|
||||
const binaryInsertOffset = Math.floor(overwrittenBinaryForInsert.byteLength * 0.25);
|
||||
const insertedBinary = Buffer.concat([
|
||||
overwrittenBinaryForInsert.subarray(0, binaryInsertOffset),
|
||||
makeRandomBytes(BINARY_EDIT_BYTES, (SEED ^ 0x005aa5a5) >>> 0),
|
||||
overwrittenBinaryForInsert.subarray(binaryInsertOffset),
|
||||
]);
|
||||
await writeFile(insertedBinaryPath, insertedBinary);
|
||||
|
||||
const insertedParagraphs = [
|
||||
[CORPUS_PATHS.markdownSmall, "The insert stage adds record 0047, profile delta-small, while retaining Unicode labels such as 日本語 and café."],
|
||||
[CORPUS_PATHS.markdownLarge, "The insert stage adds record 0913, profile delta-large, while retaining Unicode labels such as 中文 and Δοκιμή."],
|
||||
];
|
||||
for (const [relativePath, paragraph] of insertedParagraphs) {
|
||||
const markdownPath = path.join(insertDirectory, relativePath);
|
||||
const markdown = await readFile(markdownPath, "utf8");
|
||||
await writeFile(markdownPath, insertNearStart(markdown, paragraph));
|
||||
}
|
||||
for (const relativePath of [CORPUS_PATHS.binary, ...insertedParagraphs.map(([relativePath]) => relativePath)]) {
|
||||
await setMtime(insertDirectory, relativePath, STAGE_MTIMES.insert);
|
||||
}
|
||||
|
||||
const actualPaths = await listFiles(initialDirectory);
|
||||
for (const directory of [overwriteDirectory, insertDirectory]) {
|
||||
const stagePaths = await listFiles(directory);
|
||||
if (JSON.stringify(stagePaths) !== JSON.stringify(actualPaths)) {
|
||||
throw new Error(`Corpus stage ${path.basename(directory)} has different relative paths.`);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceForPath = new Map(MARKDOWN_FILES.map((file) => [file, `repo:${file}`]));
|
||||
sourceForPath.set(CORPUS_PATHS.markdownSmall, "generated:utf8-markdown-512KiB-from-repository-snippets");
|
||||
sourceForPath.set(CORPUS_PATHS.markdownLarge, "generated:utf8-markdown-5MiB-from-repository-snippets");
|
||||
sourceForPath.set(CORPUS_PATHS.binary, `generated:seeded-high-entropy-binary-seed-${SEED.toString(16)}`);
|
||||
sourceForPath.set(CORPUS_PATHS.cloudantImage, "repo:instruction_images/cloudant_1.png");
|
||||
sourceForPath.set(CORPUS_PATHS.setupImage, "repo:images/quick-setup/guide-quick-setup-first-setup-uri.png");
|
||||
|
||||
const files = actualPaths.map((relativePath) => {
|
||||
const kind = relativePath.endsWith(".md") ? "markdown" : "binary";
|
||||
return { path: relativePath, kind, source: sourceForPath.get(relativePath) };
|
||||
});
|
||||
const stages = [
|
||||
await describeStage("initial", initialDirectory, actualPaths),
|
||||
await describeStage("overwrite", overwriteDirectory, actualPaths),
|
||||
await describeStage("insert", insertDirectory, actualPaths),
|
||||
];
|
||||
const manifest = { seed: SEED, files, stages };
|
||||
await writeFile(path.join(outputDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
return manifest;
|
||||
}
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user