mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52:58 +00:00
Prove the provisioned Setup URI workflow
This commit is contained in:
@@ -18,7 +18,7 @@ Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling yo
|
||||
- Use open-source solutions for the server.
|
||||
- Compatible solutions are supported.
|
||||
- Support end-to-end encryption.
|
||||
- Synchronise settings, snippets, themes, and plug-ins via [Customisation Sync (Beta)](docs/settings.md#6-customization-sync-advanced) or [Hidden File Sync](docs/settings.md#7-hidden-files-advanced).
|
||||
- Synchronise settings, snippets, themes, and plug-ins via [Customisation Sync (Beta)](docs/settings.md#6-customisation-sync-advanced) or [Hidden File Sync](docs/tips/hidden-file-sync.md).
|
||||
- Enable WebRTC peer-to-peer synchronisation without requiring a `host` (Experimental).
|
||||
- This feature is still in the experimental stage. Please exercise caution when using it.
|
||||
- WebRTC is a peer-to-peer synchronisation method, so **at least one device must be online to synchronise**.
|
||||
@@ -89,6 +89,7 @@ To prevent file and database corruption, please avoid closing Obsidian until all
|
||||
|
||||
## Tips and Troubleshooting
|
||||
- If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md).
|
||||
- Configure [Hidden File Sync](docs/tips/hidden-file-sync.md) only after ordinary note synchronisation works.
|
||||
- If you are having problems getting the plug-in working, see [Tips and Troubleshooting](docs/troubleshooting.md).
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
+42
-109
@@ -2,132 +2,65 @@
|
||||
|
||||
[Japanese docs](./quick_setup_ja.md) - [Chinese docs](./quick_setup_cn.md).
|
||||
|
||||
The plug-in has so many configuration options to deal with different circumstances. However, only a few settings are required in the normal cases. Therefore, `The Setup wizard` has been implemented to simplify the setup.
|
||||
This guide establishes ordinary note synchronisation on the first device and then adds another device. Optional features are configured only after this basic path works.
|
||||
|
||||

|
||||
Before starting:
|
||||
|
||||
There are three methods to set up Self-hosted LiveSync.
|
||||
- back up every Vault involved;
|
||||
- disable Obsidian Sync, iCloud synchronisation, and any other service which writes to the same Vault;
|
||||
- prepare the remote service and a Setup URI; and
|
||||
- keep the Setup URI and its passphrase separate from each other.
|
||||
|
||||
1. [Using setup URIs](#1-using-setup-uris) *(Recommended)*
|
||||
2. [Minimal setup](#2-minimal-setup)
|
||||
3. [Fully manual setup and enabling on this dialogue](#3-manually-setup)
|
||||
This walkthrough covers the recommended provisioned CouchDB path. Follow [Set up a CouchDB server](./setup_own_server.md) to prepare the server and Setup URI.
|
||||
|
||||
## At the first device
|
||||
## What a Setup URI contains
|
||||
|
||||
### 1. Using setup URIs
|
||||
A Setup URI starts with `obsidian://setuplivesync?settings=`. It contains encrypted connection settings, including credentials, and must be protected even though it is encrypted.
|
||||
|
||||
> [!TIP]
|
||||
> What is the setup URI? Why is it required?
|
||||
> The setup URI is the encrypted representation of Self-hosted LiveSync configuration as a URI. This starts `obsidian://setuplivesync?settings=`. This is encrypted with a passphrase, so that it can be shared relatively securely between devices. It is a bit long, but it is one line. This allows a series of settings to be set at once without any inconsistencies.
|
||||
>
|
||||
> If you have configured the remote database by [Automated setup on Fly.io](./setup_flyio.md#a-very-automated-setup) or [set up your server with the tool](./setup_own_server.md#1-generate-the-setup-uri-on-a-desktop-device-or-server), **you should have one of them**
|
||||
The Setup URI passphrase decrypts the URI. It is different from the Vault encryption passphrase which protects synchronised data. Store both securely, and do not send the Setup URI and its passphrase through the same channel.
|
||||
|
||||
In this procedure, [this video](https://youtu.be/7sa_I1832Xc?t=146) may help us.
|
||||
## Set up the first device
|
||||
|
||||
1. Click the `Use` button (or launch the `Use the copied setup URI (Formerly Open setup URI)` command from the command palette).
|
||||
2. Paste the Setup URI into the dialogue
|
||||
3. Type the passphrase of the Setup URI
|
||||
4. Answer `yes` for `Importing LiveSync's conf, OK?`.
|
||||
5. Answer `Set it up as secondary or subsequent device` for `How would you like to set it up?`.
|
||||
6. Initialisation will begin, please hold a while.
|
||||
7. You will asked about the hidden file synchronisation, answer as you like.
|
||||
1. If you are new to Self-hosted LiveSync, we can configure it later so leave it once.
|
||||
8. Synchronisation has been started! `Reload app without saving` is recommended after the indicators of Self-hosted LiveSync disappear.
|
||||
Use this path only when the remote database is new, or when this device is intentionally the source of truth for a full server rebuild.
|
||||
|
||||
OK, we can proceed the [next step](#).
|
||||
1. Install and enable Self-hosted LiveSync in the intended Vault.
|
||||
2. Select the `Welcome to Self-hosted LiveSync` Notice to open onboarding.
|
||||
3. Select `I am setting this up for the first time`, then confirm that you want to set up a new synchronisation.
|
||||
4. On `Connection Method`, select `Use a Setup URI (Recommended)`.
|
||||
5. Paste the Setup URI, enter its Setup URI passphrase, and select `Test Settings and Continue`.
|
||||
6. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`.
|
||||
7. Read the final overwrite warning carefully. Select `I Understand, Overwrite Server` only after checking that backups exist and that replacing the remote data is intended.
|
||||
8. A newly provisioned database may show `Fetch Remote Configuration Failed` because it does not contain a saved preferred configuration yet. If this is a genuinely new setup, select `Skip and proceed`. Otherwise, stop and investigate before continuing.
|
||||
9. Acknowledge `All optional features are disabled`. Optional features remain off until the ordinary synchronisation path has been verified.
|
||||
10. Allow initialisation and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared.
|
||||
|
||||
### 2. Minimal setup
|
||||
Create an ordinary test note and allow it to upload before adding another device.
|
||||
|
||||
If you do not have any setup URI, Press the `start` button. The setting dialogue turns into the wizard mode and will display only minimal items.
|
||||
## Add another device
|
||||
|
||||
>[!TIP]
|
||||
> We can generate the setup URI with the tool in any time. Please use [this tool](./setup_own_server.md#1-generate-the-setup-uri-on-a-desktop-device-or-server).
|
||||
Start with a new or separately backed-up Vault. Do not use a production Vault containing unsynchronised notes unless you have reviewed the [Fast Setup choices](./tips/fast-setup.md).
|
||||
|
||||

|
||||
1. Install and enable Self-hosted LiveSync.
|
||||
2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice.
|
||||
3. Select `I am adding a device to an existing synchronisation setup`, then confirm that you want to add the device.
|
||||
4. On `Device Setup Method`, select `Use a Setup URI (Recommended)`.
|
||||
5. Paste the same Setup URI, enter its Setup URI passphrase, and select `Test Settings and Continue`.
|
||||
6. Review `Setup Complete: Preparing to Fetch Synchronisation Data`, then select `Restart and Fetch Data`.
|
||||
7. For a new or empty Vault, select `Overwrite all with remote files`. For a Vault with local work, stop and choose the appropriate strategy from the [Fast Setup guide](./tips/fast-setup.md).
|
||||
8. When asked how to handle extra local files, the conservative choice is `Keep local files even if not on remote`. Select the delete option only when the local Vault is disposable and an exact remote copy is intended.
|
||||
9. Allow retrieval, file reflection, and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared.
|
||||
|
||||
Confirm that the ordinary test note from the first device appears unchanged. Then edit or create a second ordinary note on the new device, and confirm that it reaches the first device.
|
||||
|
||||
#### Select the remote type
|
||||
## After ordinary synchronisation works
|
||||
|
||||
1. Select the Remote Type from dropdown list.
|
||||
We now have a choice between CouchDB (and its compatibles) and object storage (MinIO, S3, R2). CouchDB is the first choice and is also recommended. And supporting Object Storage is an experimental feature.
|
||||
Add optional features separately so that their ownership and initialisation direction are explicit:
|
||||
|
||||
#### Remote configuration
|
||||
- [Hidden File Sync](./tips/hidden-file-sync.md) for selected hidden files and folders; or
|
||||
- [Customisation Sync](./settings.md#6-customisation-sync-advanced) for managed Obsidian customisations.
|
||||
|
||||
##### CouchDB
|
||||
Do not enable both features for the same files.
|
||||
|
||||
Enter the information for the database we have set up.
|
||||
## Manual configuration
|
||||
|
||||

|
||||
|
||||
##### Object Storage
|
||||
|
||||
1. Enter the information for the S3 API and bucket.
|
||||
|
||||

|
||||
|
||||
Note 1: if you use S3, you can leave the Endpoint URL empty.
|
||||
Note 2: if your Object Storage cannot configure the CORS setting fully, you may able to connect to the server by enabling the `Use Custom HTTP Handler` toggle.
|
||||
|
||||
2. Press `Test` of `Test Connection` once and ensure you can connect to the Object Storage.
|
||||
|
||||
#### Only CouchDB: Test database connection and Check database configuration
|
||||
|
||||
We can check the connectivity to the database, and the database settings.
|
||||
|
||||

|
||||
|
||||
#### Only CouchDB: Check and Fix database configuration
|
||||
|
||||
Check the database settings and fix any problems on the spot.
|
||||
|
||||

|
||||
|
||||
This item may vary depending on the connection. In the above case, press all three Fix buttons.
|
||||
If the Fix buttons disappear and all become check marks, we are done.
|
||||
|
||||
#### Confidentiality configuration (Optional but very preferred)
|
||||
|
||||

|
||||
|
||||
Enable End-to-end encryption and the contents of your notes will be encrypted at the moment it leaves the device. We strongly recommend enabling it. And `Path Obfuscation` also obfuscates filenames. Now stable and recommended.
|
||||
|
||||
These setting can be disabled if you are inside a closed network and it is clear that you will not be accessed by third parties.
|
||||
|
||||
> [!TIP]
|
||||
> Encryption is based on 256-bit AES-GCM.
|
||||
|
||||
We should proceed to the Next step.
|
||||
|
||||
#### Sync Settings
|
||||
Finally, finish the wizard by selecting a preset for synchronisation.
|
||||
|
||||
Note: If you are going to use Object Storage, you cannot select `LiveSync`.
|
||||
|
||||

|
||||
|
||||
Select any synchronisation methods we want to use and `Apply`. If database initialisation is required, it will be performed at this time. When `All done!` is displayed, we are ready to synchronise.
|
||||
|
||||
The dialogue of `Copy current settings as a new setup URI` will open automatically. Please input a passphrase to encrypt the new `Setup URI`. (This passphrase is to encrypt the setup URI, not the vault).
|
||||
|
||||

|
||||
|
||||
The Setup URI will be copied to the clipboard, please make a note(Not in Obsidian) of this.
|
||||
|
||||
>[!TIP]
|
||||
We can copy this at any time by running the "Copy settings as a new setup URI" command from the command palette (or clicking the "Copy the current settings to a Setup URI" button in the settings UI).
|
||||
|
||||
### 3. Manually setup
|
||||
|
||||
It is strongly recommended to perform a "minimal set-up" first and set up the other contents after making sure has been synchronised.
|
||||
|
||||
However, if you have some specific reasons to configure it manually, please click the `Enable` button of `Enable LiveSync on this device as the set-up was completed manually`.
|
||||
And, please copy the setup URI by running the "Copy settings as a new setup URI" command (or using the "Copy the current settings to a Setup URI" button) and make a note(Not in Obsidian) of this.
|
||||
|
||||
## At the subsequent device
|
||||
After installing Self-hosted LiveSync on the first device, we should have a setup URI. **The first choice is to use it**. Please share it with the device you want to setup.
|
||||
|
||||
It is completely same as [Using setup URIs on the first device](#1-using-setup-uris). Please refer it.
|
||||
|
||||
> [!TIP]
|
||||
> **Fast Setup (Simple Fetch)**
|
||||
> In recent versions, when you import a Setup URI or trigger a Fetch All, the plug-in boots in scheduled fetch mode and runs a simplified **Fast Setup** process. This allows you to choose your sync strategy with a single dialogue and performs initial synchronisation in one step. Refer to the [Fast Setup Guide](./tips/fast-setup.md) for more details.
|
||||
If a Setup URI is unavailable, choose `Enter the server information manually` during onboarding. Manual configuration is an advanced path: verify the connection, encryption, remote profile, and synchronisation preset before initialising either side. After the first device works, use `Copy settings as a new Setup URI` from the command palette to add later devices through the recommended path.
|
||||
|
||||
@@ -577,6 +577,8 @@ Setting key: notifyAllSettingSyncFile
|
||||
|
||||
### 7. Hidden Files (Advanced)
|
||||
|
||||
See the [Hidden File Sync guide](./tips/hidden-file-sync.md) before enabling this feature. Rebuild and Fetch setup operations deliberately leave optional features disabled; establish ordinary note synchronisation first, then initialise Hidden File Sync independently on each device.
|
||||
|
||||
#### Enable Hidden files sync
|
||||
|
||||
Setting key: syncInternalFiles
|
||||
|
||||
+18
-19
@@ -171,39 +171,38 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
|
||||
|
||||
### 1. Generate the setup URI on a desktop device or server
|
||||
```bash
|
||||
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com #Point to your vault
|
||||
export database=obsidiannotes #Please change as you like
|
||||
export passphrase=<INSERT A STRONG VAULT ENCRYPTION PASSPHRASE>
|
||||
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com
|
||||
export database=obsidiannotes
|
||||
export username=johndoe
|
||||
export password=<INSERT THE COUCHDB PASSWORD>
|
||||
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/flyio/generate_setupuri.ts
|
||||
export 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]
|
||||
> What is the `passphrase`? Is it different from `uri_passphrase`?
|
||||
> Yes, the `passphrase` we have exported now is for an End-to-End Encryption passphrase.
|
||||
> And, `uri_passphrase` that used in the `generate_setupuri.ts` is a different one; for decrypting Set-up URI at using that.
|
||||
> Why: I (vorotamoroz) think that the passphrase of the Setup-URI should be different from the E2EE passphrase to prevent exposure caused by operational errors or the possibility of evil in our environment. On top of that, I believe that it is desirable for the Setup-URI to be random. Setup-URI is inevitably long, so it goes through the clipboard. I think that its passphrase should not go through the same path, so it should essentially be typed manually.
|
||||
> If `uri_passphrase` is empty, `generate_setupuri.ts` generates a cryptographically random passphrase and prints it once.
|
||||
> `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.
|
||||
|
||||
The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current new-Vault defaults, and encodes them with Commonlib's Setup URI contract.
|
||||
|
||||
You will then get the following output:
|
||||
|
||||
```bash
|
||||
obsidian://setuplivesync?settings=%5B%22tm2DpsOE74nJAryprZO2M93wF%2Fvg.......4b26ed33230729%22%5D
|
||||
|
||||
Generated couchdb Setup URI.
|
||||
Your passphrase for the Setup URI is: H7vX...a-random-32-character-value
|
||||
This passphrase is never shown again, so please note it in a safe place.
|
||||
This passphrase is never shown again, so store it safely.
|
||||
obsidian://setuplivesync?settings=%5B%22tm2DpsOE74nJAryprZO2M93wF%2Fvg.......4b26ed33230729%22%5D
|
||||
```
|
||||
|
||||
Please keep your passphrase of Setup-URI.
|
||||
Store the Setup URI and its passphrase separately.
|
||||
|
||||
### 2. Setup Self-hosted LiveSync to Obsidian
|
||||
[This video](https://youtu.be/7sa_I1832Xc?t=146) may help us.
|
||||
1. Install Self-hosted LiveSync
|
||||
2. Choose `Use the copied setup URI` from the command palette and paste the setup URI. (obsidian://setuplivesync?settings=.....).
|
||||
3. Type the Setup URI passphrase printed by the generator.
|
||||
4. Answer `yes` and `Set it up...`, and finish the first dialogue with `Keep them disabled`.
|
||||
5. `Reload app without save` once.
|
||||
|
||||
Follow [Quick setup](./quick_setup.md#set-up-the-first-device) for the first device. It covers the current onboarding Notice, Setup URI import, server initialisation, and the safety prompts shown for a newly provisioned database.
|
||||
|
||||
After ordinary note synchronisation works, follow [Add another device](./quick_setup.md#add-another-device). Configure optional features only after the normal path is verified; [Hidden File Sync has its own guide](./tips/hidden-file-sync.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Hidden File Sync
|
||||
|
||||
Hidden File Sync is an advanced, optional feature for synchronising hidden files and folders, including selected content below `.obsidian`. It is separate from ordinary note synchronisation and is deliberately left disabled during Rebuild and Fetch setup operations.
|
||||
|
||||
Enable it only after ordinary notes synchronise correctly in both directions on every device.
|
||||
|
||||
> [!WARNING]
|
||||
> Hidden files can control active Obsidian settings, themes, snippets, and plug-ins. Back up every Vault before enabling this feature. Do not use Hidden File Sync and Customisation Sync to manage the same files.
|
||||
|
||||
## Choose the initial source
|
||||
|
||||
Enabling Hidden File Sync requires an initialisation direction:
|
||||
|
||||
- `Merge` compares the local and remote hidden-file state without declaring either side authoritative. Use this when both sides contain changes which must be retained, and review any conflicts.
|
||||
- `Fetch` treats the remote hidden-file state as authoritative and applies it locally. Use this on an additional device after the desired files have been uploaded.
|
||||
- `Overwrite` treats this device's hidden files as authoritative and writes them to the remote database. Use this first on the device whose hidden files you intend to distribute.
|
||||
|
||||
`Fetch` and `Overwrite` can replace files on one side. Check the direction and the backup before continuing.
|
||||
|
||||
## Review the file selection
|
||||
|
||||
1. Open Self-hosted LiveSync settings.
|
||||
2. Open `Setup`, find `Extra features`, and enable `Advanced features`.
|
||||
3. Open `Selector`, then review the `Hidden Files` section.
|
||||
4. Use `Target patterns` to limit the feature to the hidden files you intend to synchronise. An empty target list includes every otherwise eligible hidden file.
|
||||
5. Review `Ignore patterns`. The default excludes `node_modules`, `.git`, and Self-hosted LiveSync's own plug-in data. `Add default patterns` offers a `Cross-platform` set which also excludes Obsidian workspace files.
|
||||
|
||||
Prefer a narrow target list. Device-specific workspace state and another plug-in's credentials are usually poor candidates for cross-device synchronisation.
|
||||
|
||||
## Enable the first device
|
||||
|
||||
1. Open `Sync Settings`, then find the advanced `Hidden Files` section.
|
||||
2. Under `Enable Hidden File Sync`, select the initialisation direction chosen above.
|
||||
3. Keep Obsidian open while the initial scan and synchronisation finish.
|
||||
4. Restart Obsidian when the completion Notice recommends it.
|
||||
5. Confirm that the expected hidden files, and only those files, are present in the remote synchronisation state.
|
||||
|
||||
For the common source-of-truth rollout, select `Overwrite` on the authoritative device first.
|
||||
|
||||
## Enable each additional device
|
||||
|
||||
Hidden File Sync must be enabled independently after ordinary setup on each device.
|
||||
|
||||
1. Back up the additional device's Vault.
|
||||
2. Apply the same target and ignore patterns.
|
||||
3. Select `Fetch` if the remote hidden files are authoritative. Select `Merge` only when local hidden-file changes must also be retained.
|
||||
4. Wait for the initial scan and file application to finish, then restart Obsidian if requested.
|
||||
5. Verify representative files before enabling the feature on the next device.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
@@ -51,6 +51,7 @@
|
||||
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
|
||||
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
|
||||
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
|
||||
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
|
||||
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
|
||||
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||
|
||||
@@ -70,6 +70,7 @@ npm run test:e2e:obsidian:couchdb-upload
|
||||
npm run test:e2e:obsidian:cli-to-obsidian-sync
|
||||
npm run test:e2e:obsidian:minio-upload
|
||||
npm run test:e2e:obsidian:startup-scan
|
||||
npm run test:e2e:obsidian:setup-uri-workflow
|
||||
npm run test:e2e:obsidian:two-vault-sync
|
||||
npm run test:e2e:obsidian:hidden-file-snippet-sync
|
||||
npm run test:e2e:obsidian:customisation-sync
|
||||
@@ -125,6 +126,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
|
||||
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
|
||||
|
||||
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures two data-less real Obsidian Vaults through the visible onboarding wizard, uses Rebuild for the first device and Fetch for the second, and verifies an ordinary note. After ordinary setup has completed, it independently enables Hidden File Sync on each device and verifies a snippet. Credential-bearing input is not captured; the retained screenshots cover the user decisions, initialisation confirmations, and the synchronised note.
|
||||
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, per-device target filters where one vault ignores a note that the other vault synchronises, and a separate encrypted round-trip with Path Obfuscation enabled. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict automatic merge check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`, but it is not part of the default local suite.
|
||||
|
||||
`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. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one mobile-safe Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
@@ -26,6 +26,7 @@ const testSteps: Step[] = [
|
||||
},
|
||||
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
|
||||
{ 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 snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
|
||||
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
deleteCouchDbDatabase,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
waitForCouchDbDocs,
|
||||
type CouchDbConfig,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
pushLocalChanges,
|
||||
waitForLocalDatabaseEntry,
|
||||
type LocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianDialogue, captureObsidianPage, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
|
||||
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
|
||||
const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000);
|
||||
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
|
||||
const notePath = "E2E/setup-uri/provisioned-workflow.md";
|
||||
const noteContent = "# Provisioned Setup URI\n\nThis note travelled through the generated CouchDB Setup URI.\n";
|
||||
const snippetPath = ".obsidian/snippets/setup-uri-workflow.css";
|
||||
const snippetContent = [
|
||||
"body {",
|
||||
" --setup-uri-workflow-colour: #245a70;",
|
||||
"}",
|
||||
"",
|
||||
".setup-uri-workflow {",
|
||||
" color: var(--setup-uri-workflow-colour);",
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
type SetupArtifact = {
|
||||
setupURI: string;
|
||||
setupPassphrase: string;
|
||||
};
|
||||
|
||||
type SetupState = {
|
||||
configured: boolean;
|
||||
databaseReady: boolean;
|
||||
appReady: boolean;
|
||||
suspended: boolean;
|
||||
remoteType: string;
|
||||
activeConfigurationId: string;
|
||||
remoteConfigurationCount: number;
|
||||
syncInternalFiles: boolean;
|
||||
syncInternalFilesBeforeReplication: boolean;
|
||||
};
|
||||
|
||||
type RunnerContext = {
|
||||
binary: string;
|
||||
cliBinary: string;
|
||||
couchDb: CouchDbConfig;
|
||||
dbName: string;
|
||||
activeSessions: Set<ObsidianLiveSyncSession>;
|
||||
};
|
||||
|
||||
function modalByTitle(page: Page, title: string): Locator {
|
||||
return page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: title }),
|
||||
});
|
||||
}
|
||||
|
||||
async function selectRadioOption(modal: Locator, title: string): Promise<void> {
|
||||
const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first();
|
||||
await radio.check({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
async function selectCheckbox(modal: Locator, title: string): Promise<void> {
|
||||
const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first();
|
||||
await checkbox.check({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, path);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
await writeFile(fullPath, content, "utf8");
|
||||
}
|
||||
|
||||
async function readVaultFile(vaultPath: string, path: string): Promise<string> {
|
||||
return await readFile(join(vaultPath, path), "utf8");
|
||||
}
|
||||
|
||||
async function waitForPathContent(
|
||||
vaultPath: string,
|
||||
path: string,
|
||||
expected: string,
|
||||
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000)
|
||||
): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastContent = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
lastContent = await readVaultFile(vaultPath, path);
|
||||
if (lastContent === expected) return lastContent;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
|
||||
}
|
||||
|
||||
async function runDeno(script: string, permissions: string[], environment: NodeJS.ProcessEnv): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"deno",
|
||||
[
|
||||
"run",
|
||||
"--minimum-dependency-age=0",
|
||||
"--config=utils/flyio/deno.jsonc",
|
||||
"--frozen",
|
||||
"--lock=utils/flyio/deno.lock",
|
||||
...permissions,
|
||||
script,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function provisionAndGenerateSetupURI(couchDb: CouchDbConfig, dbName: string): Promise<SetupArtifact> {
|
||||
const setupPassphrase = randomBytes(24).toString("base64url");
|
||||
const environment = {
|
||||
...process.env,
|
||||
hostname: couchDb.uri,
|
||||
username: couchDb.username,
|
||||
password: couchDb.password,
|
||||
database: dbName,
|
||||
passphrase: randomBytes(24).toString("base64url"),
|
||||
uri_passphrase: setupPassphrase,
|
||||
remote_type: "couchdb",
|
||||
retry_count: "3",
|
||||
retry_delay_ms: "250",
|
||||
};
|
||||
|
||||
await runDeno("utils/couchdb/provision.ts", ["--allow-env", "--allow-net"], environment);
|
||||
const output = await runDeno("utils/setup/generate_setup_uri.ts", ["--allow-env"], environment);
|
||||
const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings="));
|
||||
if (!setupURI) throw new Error("The public Setup URI generator did not emit a Setup URI.");
|
||||
return { setupURI, setupPassphrase };
|
||||
}
|
||||
|
||||
async function startUnconfiguredSession(
|
||||
context: RunnerContext,
|
||||
vault: TemporaryVault
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startObsidianLiveSyncSession({
|
||||
binary: context.binary,
|
||||
cliBinary: context.cliBinary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
});
|
||||
context.activeSessions.add(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
||||
if (!context.activeSessions.has(session)) return;
|
||||
await session.app.stop();
|
||||
context.activeSessions.delete(session);
|
||||
}
|
||||
|
||||
async function stopTrackedSessions(context: RunnerContext): Promise<void> {
|
||||
for (const session of [...context.activeSessions]) {
|
||||
await stopTrackedSession(context, session);
|
||||
}
|
||||
}
|
||||
|
||||
async function enterSetupURI(port: number, mode: "new" | "existing", artifact: SetupArtifact): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
|
||||
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
|
||||
|
||||
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
|
||||
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (mode === "new") {
|
||||
await selectRadioOption(intro, "I am setting this up for the first time");
|
||||
await intro
|
||||
.getByRole("button", { name: "Yes, I want to set up a new synchronisation" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
} else {
|
||||
await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup");
|
||||
await intro
|
||||
.getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method");
|
||||
await method.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await selectRadioOption(method, "Use a Setup URI (Recommended)");
|
||||
await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs });
|
||||
|
||||
const setup = modalByTitle(page, "Enter Setup URI");
|
||||
await setup.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI);
|
||||
await setup.locator('input[name="password"]').fill(artifact.setupPassphrase);
|
||||
await setup.getByRole("button", { name: "Test Settings and Continue" }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function captureAndStartInitialisation(port: number, mode: "new" | "existing"): Promise<string> {
|
||||
const title =
|
||||
mode === "new"
|
||||
? "Setup Complete: Preparing to Initialise Server"
|
||||
: "Setup Complete: Preparing to Fetch Synchronisation Data";
|
||||
const button = mode === "new" ? "Restart and Initialise Server" : "Restart and Fetch Data";
|
||||
const screenshot = await captureObsidianDialogue(
|
||||
port,
|
||||
`setup-uri-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
|
||||
async (page) => {
|
||||
await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function confirmRebuild(port: number): Promise<string> {
|
||||
const title = "Final Confirmation: Overwrite Server Data with This Device's Files";
|
||||
const screenshot = await captureObsidianDialogue(port, "setup-uri-first-rebuild-confirmation.png", async (page) => {
|
||||
await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
await selectCheckbox(
|
||||
modal,
|
||||
"I understand that all changes made on other smartphones or computers possibly could be lost."
|
||||
);
|
||||
await selectCheckbox(
|
||||
modal,
|
||||
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information."
|
||||
);
|
||||
await selectCheckbox(modal, "I understand that this action is irreversible once performed.");
|
||||
await selectRadioOption(modal, "I understand the risks and will proceed without a backup.");
|
||||
await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function skipMissingRemoteConfiguration(port: number): Promise<string> {
|
||||
const title = "Fetch Remote Configuration Failed";
|
||||
const screenshot = await captureObsidianDialogue(
|
||||
port,
|
||||
"setup-uri-first-missing-remote-configuration.png",
|
||||
async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal
|
||||
.getByText("If you are new to the Self-hosted LiveSync, this might be expected.", {
|
||||
exact: false,
|
||||
})
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title)
|
||||
.getByRole("button", { name: "Skip and proceed" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function acknowledgeDisabledOptionalFeatures(port: number): Promise<string> {
|
||||
const title = "All optional features are disabled";
|
||||
const screenshot = await captureObsidianDialogue(
|
||||
port,
|
||||
"setup-uri-first-optional-features-disabled.png",
|
||||
async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
await modal.waitFor({ state: "visible", timeout: initialisationTimeoutMs });
|
||||
await modal
|
||||
.getByText("Please enable them from the settings screen after setup is complete.", {
|
||||
exact: false,
|
||||
})
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title).getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function confirmFastFetch(port: number): Promise<string[]> {
|
||||
const firstTitle = "Data retrieval scheduled";
|
||||
const firstScreenshot = await captureObsidianDialogue(
|
||||
port,
|
||||
"setup-uri-second-retrieval-method.png",
|
||||
async (page) => {
|
||||
await modalByTitle(page, firstTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, firstTitle)
|
||||
.getByRole("button", { name: "Overwrite all with remote files" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const secondTitle = "How to handle extra existing local files?";
|
||||
const secondScreenshot = await captureObsidianDialogue(
|
||||
port,
|
||||
"setup-uri-second-local-file-policy.png",
|
||||
async (page) => {
|
||||
await modalByTitle(page, secondTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
}
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, secondTitle)
|
||||
.getByRole("button", { name: "Keep local files even if not on remote" })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return [firstScreenshot, secondScreenshot];
|
||||
}
|
||||
|
||||
function isConfiguredSetupReady(state: SetupState): boolean {
|
||||
return (
|
||||
state.configured &&
|
||||
state.databaseReady &&
|
||||
state.appReady &&
|
||||
!state.suspended &&
|
||||
state.activeConfigurationId !== "" &&
|
||||
state.remoteConfigurationCount === 1
|
||||
);
|
||||
}
|
||||
|
||||
async function finishInitialisation(
|
||||
port: number,
|
||||
filename: string,
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv
|
||||
): Promise<{ state: SetupState; screenshot?: string }> {
|
||||
const message = "Do you want to resume file and database processing, and restart obsidian now?";
|
||||
const deadline = Date.now() + initialisationTimeoutMs;
|
||||
let lastState: SetupState | undefined;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
const resumeVisible = await withObsidianPage(port, async (page) => {
|
||||
return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible();
|
||||
}).catch(() => false);
|
||||
if (resumeVisible) {
|
||||
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
|
||||
await modalByTitle(page, "Confirmation")
|
||||
.filter({ hasText: message })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, "Confirmation").filter({ hasText: message });
|
||||
await modal.getByText(message, { exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
return {
|
||||
state: await waitForConfiguredSetup(cliBinary, environment, initialisationTimeoutMs),
|
||||
screenshot,
|
||||
};
|
||||
}
|
||||
try {
|
||||
lastState = await readSetupState(cliBinary, environment);
|
||||
if (isConfiguredSetupReady(lastState)) return { state: lastState };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for Setup URI initialisation to finish: ${JSON.stringify(lastState)}${
|
||||
lastError instanceof Error ? `; last error: ${lastError.message}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<SetupState> {
|
||||
return await evalObsidianJson<SetupState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({",
|
||||
"configured:settings.isConfigured===true,",
|
||||
"databaseReady:core.services.database.isDatabaseReady(),",
|
||||
"appReady:core.services.appLifecycle.isReady(),",
|
||||
"suspended:core.services.appLifecycle.isSuspended(),",
|
||||
"remoteType:settings.remoteType,",
|
||||
"activeConfigurationId:settings.activeConfigurationId||'',",
|
||||
"remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,",
|
||||
"syncInternalFiles:settings.syncInternalFiles===true,",
|
||||
"syncInternalFilesBeforeReplication:settings.syncInternalFilesBeforeReplication===true,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForConfiguredSetup(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
timeoutMs = initialisationTimeoutMs
|
||||
): Promise<SetupState> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastState: SetupState | undefined;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
lastState = await readSetupState(cliBinary, environment);
|
||||
if (isConfiguredSetupReady(lastState)) {
|
||||
return lastState;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${
|
||||
lastError instanceof Error ? `; last error: ${lastError.message}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
async function enableHiddenFileSync(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<SetupState> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"await core.services.setting.applyPartial({",
|
||||
"syncInternalFiles:true,",
|
||||
"syncInternalFilesBeforeReplication:true,",
|
||||
"},true);",
|
||||
"await core.services.control.applySettings();",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
const state = await readSetupState(cliBinary, environment);
|
||||
if (!state.syncInternalFiles || !state.syncInternalFilesBeforeReplication) {
|
||||
throw new Error(`Hidden File Sync was not enabled after setup: ${JSON.stringify(state)}`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function writeNoteViaObsidian(
|
||||
cliBinary: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
path: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const content=${JSON.stringify(content)};`,
|
||||
"const folder=path.split('/').slice(0,-1).join('/');",
|
||||
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
|
||||
"const existing=app.vault.getAbstractFileByPath(path);",
|
||||
"if(existing) await app.vault.modify(existing,content);",
|
||||
"else await app.vault.create(path,content);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
async function scanHiddenStorage(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
"await addOn.scanAllStorageChanges(true);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
async function scanHiddenDatabase(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const addOn=core.getAddOn('HiddenFileSync');",
|
||||
"await addOn.scanAllDatabaseChanges(true);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
environment,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise<void> {
|
||||
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadWorkflowFiles(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault
|
||||
): Promise<void> {
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
|
||||
await writeVaultFile(vault.path, snippetPath, snippetContent);
|
||||
await scanHiddenStorage(context.cliBinary, session.cliEnv);
|
||||
const noteEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
|
||||
const snippetEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, snippetPath, {
|
||||
hidden: true,
|
||||
});
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForRemoteEntry(context, noteEntry);
|
||||
await waitForRemoteEntry(context, snippetEntry);
|
||||
}
|
||||
|
||||
async function captureSynchronisedNote(port: number): Promise<string> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((path) => {
|
||||
const obsidian = globalThis as typeof globalThis & {
|
||||
app?: {
|
||||
workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
|
||||
};
|
||||
};
|
||||
return obsidian.app?.workspace?.openLinkText(path, "", false);
|
||||
}, notePath);
|
||||
});
|
||||
return await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => {
|
||||
await page.getByText("Provisioned Setup URI", { exact: false }).first().waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function captureFailure(session: ObsidianLiveSyncSession): Promise<void> {
|
||||
await captureObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
"setup-uri-workflow.failure.png",
|
||||
async () => undefined
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
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 couchDb = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "setup-uri-workflow");
|
||||
const vaultA = await createTemporaryVault();
|
||||
const vaultB = await createTemporaryVault();
|
||||
const context: RunnerContext = {
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
couchDb,
|
||||
dbName,
|
||||
activeSessions: new Set(),
|
||||
};
|
||||
const screenshots: string[] = [];
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
const artifact = await provisionAndGenerateSetupURI(couchDb, dbName);
|
||||
const provisionedDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) =>
|
||||
docs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)
|
||||
);
|
||||
if (!provisionedDocs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)) {
|
||||
throw new Error("The public provisioning tool did not initialise the Commonlib database version.");
|
||||
}
|
||||
|
||||
console.log(`Using Obsidian executable: ${binary}`);
|
||||
console.log(`Temporary vault A: ${vaultA.path}`);
|
||||
console.log(`Temporary vault B: ${vaultB.path}`);
|
||||
console.log(`Temporary provisioned CouchDB database: ${dbName}`);
|
||||
|
||||
let session = await startUnconfiguredSession(context, vaultA);
|
||||
try {
|
||||
await enterSetupURI(session.remoteDebuggingPort, "new", artifact);
|
||||
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new"));
|
||||
screenshots.push(await confirmRebuild(session.remoteDebuggingPort));
|
||||
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort));
|
||||
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort));
|
||||
const firstCompletion = await finishInitialisation(
|
||||
session.remoteDebuggingPort,
|
||||
"setup-uri-first-initialisation-complete.png",
|
||||
context.cliBinary,
|
||||
session.cliEnv
|
||||
);
|
||||
if (firstCompletion.screenshot) screenshots.push(firstCompletion.screenshot);
|
||||
const firstState = firstCompletion.state;
|
||||
assertEqual(firstState.remoteType, "", "The first device did not activate the CouchDB remote profile.");
|
||||
assertEqual(
|
||||
firstState.syncInternalFiles,
|
||||
false,
|
||||
"Rebuild did not retain the documented optional-feature safety boundary."
|
||||
);
|
||||
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
|
||||
await uploadWorkflowFiles(context, session, vaultA);
|
||||
} catch (error) {
|
||||
await captureFailure(session);
|
||||
throw error;
|
||||
} finally {
|
||||
await stopTrackedSession(context, session);
|
||||
}
|
||||
|
||||
session = await startUnconfiguredSession(context, vaultB);
|
||||
try {
|
||||
await enterSetupURI(session.remoteDebuggingPort, "existing", artifact);
|
||||
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing"));
|
||||
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort)));
|
||||
const secondCompletion = await finishInitialisation(
|
||||
session.remoteDebuggingPort,
|
||||
"setup-uri-second-initialisation-complete.png",
|
||||
context.cliBinary,
|
||||
session.cliEnv
|
||||
);
|
||||
if (secondCompletion.screenshot) screenshots.push(secondCompletion.screenshot);
|
||||
const secondState = secondCompletion.state;
|
||||
assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile.");
|
||||
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await scanHiddenDatabase(context.cliBinary, session.cliEnv);
|
||||
const receivedNote = await waitForPathContent(vaultB.path, notePath, noteContent);
|
||||
const receivedSnippet = await waitForPathContent(vaultB.path, snippetPath, snippetContent);
|
||||
assertEqual(receivedNote, noteContent, "The ordinary note did not reach the second Setup URI device.");
|
||||
assertEqual(
|
||||
receivedSnippet,
|
||||
snippetContent,
|
||||
"The hidden snippet did not reach the second Setup URI device."
|
||||
);
|
||||
screenshots.push(await captureSynchronisedNote(session.remoteDebuggingPort));
|
||||
} catch (error) {
|
||||
await captureFailure(session);
|
||||
throw error;
|
||||
} finally {
|
||||
await stopTrackedSession(context, session);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`The public provisioning and Setup URI workflow configured two fresh devices, synchronised a note, and synchronised a hidden snippet. Screenshots: ${screenshots.join(", ")}`
|
||||
);
|
||||
} finally {
|
||||
await stopTrackedSessions(context).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
await vaultA.dispose();
|
||||
await vaultB.dispose();
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -33,6 +33,10 @@ Deno.test("generates a current self-hosted Setup URI through the published Commo
|
||||
const decoded = await decodeSettingsFromSetupURI(setupURI, "setup-secret");
|
||||
assert(decoded, "Commonlib could not decode the generated Setup URI");
|
||||
const effectiveSettings = { ...DEFAULT_SETTINGS, ...decoded };
|
||||
assert(
|
||||
effectiveSettings.isConfigured,
|
||||
"the CouchDB Setup URI left the imported device unconfigured",
|
||||
);
|
||||
assert(
|
||||
effectiveSettings.customChunkSize === 60,
|
||||
"the Setup URI did not use the current self-hosted chunk-size recommendation",
|
||||
|
||||
@@ -24,6 +24,10 @@ Deno.test("generates an Object Storage Setup URI with a selected S3 profile", as
|
||||
);
|
||||
assert(decoded, "Commonlib could not decode the Object Storage Setup URI");
|
||||
const effective = { ...DEFAULT_SETTINGS, ...decoded };
|
||||
assert(
|
||||
effective.isConfigured,
|
||||
"the Setup URI left the imported device unconfigured",
|
||||
);
|
||||
assert(
|
||||
effective.customChunkSize === 10,
|
||||
"the journal chunk-size preset was not applied",
|
||||
|
||||
@@ -55,6 +55,7 @@ function applyEncryptedVaultSettings(
|
||||
environment: SetupGeneratorEnvironment,
|
||||
): void {
|
||||
Object.assign(settings, {
|
||||
isConfigured: true,
|
||||
encrypt: true,
|
||||
passphrase: requireValue(environment, "passphrase"),
|
||||
usePathObfuscation: true,
|
||||
|
||||
Reference in New Issue
Block a user