chore: merge upstream main into history revision branch

This commit is contained in:
SeleiXi
2026-07-29 23:45:12 +08:00
996 changed files with 75371 additions and 43489 deletions
+160 -25
View File
@@ -1,30 +1,58 @@
# Real Obsidian E2E Runner
This directory contains the experimental real Obsidian end-to-end runner.
This directory contains the maintained real Obsidian end-to-end runner.
The current smoke runner verifies only the launch path:
The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository.
The current smoke runner verifies the launch path and the loaded plug-in's Service Context composition:
1. create a temporary vault,
2. install the built Self-hosted LiveSync plug-in artifacts,
2. install the built Self-hosted LiveSync plug-in artefacts,
3. launch real Obsidian,
4. open the temporary vault through `obsidian-cli`,
5. enable Obsidian community plug-ins for the temporary app profile,
6. reload Self-hosted LiveSync through `obsidian-cli`,
7. verify through `obsidian-cli eval` that the plug-in is loaded,
8. optionally drive a real vault or CouchDB workflow through Obsidian's own API,
9. terminate Obsidian and remove the temporary vault.
5. prepare the isolated Vault trust state and handle any Obsidian trust prompt,
6. preserve natural plug-in loading, or complete requested pre-load work before loading the plug-in once in controlled start-up,
7. verify through the active renderer that the plug-in is loaded,
8. observe event and translation results from the actual `ObsidianServiceContext`,
9. verify that the Service Hub and every exposed service retain that exact Context,
10. optionally drive a real vault or CouchDB workflow through Obsidian's own API, and
11. terminate Obsidian and remove the temporary vault.
The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. Readiness is checked from outside the plug-in through Obsidian's own CLI.
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence.
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
When a LiveSync-owned scenario must establish application state before the plug-in's first load, pass an instance-scoped `lifecycle.beforePluginStart` callback through that wrapper. For example, the P2P pane scenario calls `setObsidianMobileTestModeBeforePluginStart()` there so LiveSync observes the mobile application state while registering its command and view. Mobile emulation reopens Obsidian's workspace layout; this helper waits for both the `is-mobile` body state and `workspace.layoutReady` before controlled loading continues. The shared package owns the controlled start-up order and guarantees that the plug-in loads once; the LiveSync scenario owns the resulting command, workspace placement, and visible UI assertions. Changing the state only after loading the plug-in is not evidence of its mobile start-up behaviour.
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
On macOS, `@vrtmrz/obsidian-test-session` keeps the generated Vault and profile below `/tmp` so Obsidian's Unix-domain CLI socket remains below the platform path limit. It also gives only the isolated Obsidian process Chromium's mock-keychain flag, preventing the empty test HOME from opening a blocking login-keychain dialogue. LiveSync's deterministic fixture selects the built-in default language so a host-language translation prompt cannot pause plug-in readiness. The case-only rename check enumerates the parent directory and compares exact spellings because an old-path lookup still resolves the renamed file on the default case-insensitive macOS filesystem.
Multi-session workflows must keep each started Obsidian session tracked until its stop operation completes. If a scenario throws, teardown stops every active session before disposing its temporary Vault and profile, so a failed CLI or synchronisation operation cannot leave Obsidian using directories which have already been removed.
## Observing and diagnosing a scenario
Use externally visible behaviour as the pass condition: Vault files, remote-service state, revision data, or visible Obsidian UI. A log line can explain a failure, but should not replace an assertion about the resulting behaviour.
The maintained runner provides several complementary observation paths:
- `evalObsidianJson()` and `obsidian-cli eval` can read a small, explicitly selected piece of LiveSync or Obsidian state.
- `withObsidianPage()` can inspect the active renderer, invoke a registered command, or interact with visible UI through CDP. `captureObsidianPage()`, `captureObsidianDialogue()`, and `captureObsidianElement()` retain screenshots; the capture helpers also write a full-page `.failure.png` before rethrowing a UI assertion failure.
- `session.app.output()` returns the standard output and standard error captured from the isolated Obsidian process. This is especially useful when the renderer or CLI becomes unreachable.
- **Show log** (`obsidian-livesync:view-log`) exposes the recent LiveSync log, while **Copy full report to clipboard** (`obsidian-livesync:dump-debug-info`) opens the generated diagnostic report. `dialog-mounts.ts` verifies both surfaces, and focused scenarios may inspect the log pane and `appLifecycle.getUnresolvedMessages()` for a bounded set of expected errors.
- Renderer `console` messages and uncaught page errors are not retained automatically. A focused investigation can attach `page.on("console", ...)` and `page.on("pageerror", ...)` while it owns a `withObsidianPage()` callback. That observer ends when the callback closes its CDP connection, so use it around the action under investigation rather than treating it as a session-wide audit trail.
If a scenario times out or appears to do nothing, capture the visible page before teardown, then record a bounded state snapshot and the relevant tail of the LiveSync log, unresolved messages, and process output. If an unexplained Notice appears, retain a screenshot while it is still visible before opening or dismissing it, then use the log or full report to identify its source. A Notice alone is not enough evidence for its cause.
Set `showVerboseLog: true` only in isolated plug-in data when a focused investigation needs it. Keep captured output short and redact it before retaining or sharing it: logs and reports can contain Vault paths, document names, endpoints, credentials, Setup URIs, passphrases, or Security Seed material. Do not collect verbose logs from an ordinary user Vault.
Collect evidence before cleanup, and keep process, Vault, profile, and remote-fixture cleanup in `finally`. After `app.emulateMobile(true)`, use the active CDP renderer for fixture operations because Obsidian may remove desktop-only CLI commands. Visually inspect screenshots before copying selected images into user documentation; a passing locator assertion does not establish that a dialogue is readable or unobstructed.
## Local Setup
Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location.
Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location. Set `OBSIDIAN_CLI` as well when its companion executable is outside the built-in discovery paths.
For an AppImage on Linux without FUSE, use the helper script:
@@ -42,44 +70,133 @@ These tests are intended for local verification, not the default CI gate. Reuse
## Commands
After changing plug-in source, use the focused wrapper rather than invoking a scenario directly. It always rebuilds `main.js` before launching real Obsidian, and it builds the local CLI too when the CLI-to-Obsidian scenario needs it:
```bash
npm run test:e2e:obsidian:focused -- settings-ui
npm run test:e2e:obsidian:focused -- two-vault-sync
npm run test:e2e:obsidian:focused -- security-seed-reconnect
```
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
The principal entry points are:
```bash
npm run test:contract:contexts
npm run test:contract:context:webapp
npm run test:contract:context:cli
npm run test:contract:context:obsidian
npm run test:e2e:obsidian:runner
npm run test:e2e:obsidian:install-appimage
npm run test:e2e:obsidian:discover
npm run test:e2e:obsidian:cli-help -- vaults verbose
npm run test:e2e:obsidian:smoke
npm run test:e2e:obsidian:vault-reflection
npm run test:e2e:obsidian:couchdb-upload
npm run test:e2e:obsidian:minio-upload
npm run test:e2e:obsidian:startup-scan
npm run test:e2e:obsidian:two-vault-sync
npm run test:e2e:obsidian:hidden-file-snippet-sync
npm run test:e2e:obsidian:customisation-sync
npm run test:e2e:obsidian:setting-markdown-export
npm run test:e2e:obsidian:upgrade-from-stable -- --transport all
npm run test:e2e:obsidian:local-suite
npm run test:e2e:obsidian:local-suite:services
```
`test:e2e:obsidian:local-suite` runs `npm run build`, discovery, smoke, vault reflection, CouchDB upload, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an immediate rerun against an already built, unchanged bundle. They do not build `main.js`; do not use them as the first verification after a source change. The complete local suite performs its own build.
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, configures Self-hosted LiveSync through `obsidian-cli eval`, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
`test:contract:contexts` runs the directly observable host contract against the Obsidian, CLI, and Webapp compositions. It verifies event and translation results, host-specific capabilities, and that the CLI and Webapp Service Hubs pass one exact Context to all exposed services. `test:contract:context:webapp` runs only the Webapp part.
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix.
`test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change.
`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:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from **Self-hosted LiveSync settings****Setup** on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios.
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, rename, 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. 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: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: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.
`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 selects the Synchronisation Settings pane 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.
The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay.
`test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows.
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run.
`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents.
The same workflow checks the two remote-activity status boundaries. It first holds a real CouchDB request at the selected fetch implementation and confirms that `🌐N` is visible while `📲` is absent. It then holds the real one-shot replication immediately before its replicator call, confirms that `📲` is visible while no physical request is active, releases it, and requires the finite and bounded activity counts to return to zero, the request and response counts to balance, and both indicators to disappear. Finally, it creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same logical active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active states deterministic without replacing the remote request or operation.
`test:e2e:obsidian:couchdb-manual-setup-workflow` follows the visible onboarding path for the first device when no Setup URI is available. It enters end-to-end encryption and CouchDB details, runs the read-only `Check server requirements` step, requires the prepared fixture to pass without applying a server fix, and lets the onboarding connection test create the named database. After Rebuild completes on the first device, it creates an ordinary note, asks that working device to generate a Setup URI for a second device, completes Fetch there, and verifies a bidirectional note round-trip. The workflow captures each decision point and the expanded server-check result; password controls remain visually masked.
If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option, and the Security Seed reconnect workflow captures each significant application state. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory.
The two-Vault workflow performs the missing-marker review once for each isolated Vault. Later process launches reuse the same profile-backed acknowledgement, rather than seeding a replacement or repeatedly applying a decision for the first device. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode.
`test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise.
The isolated Obsidian session starts with its CouchDB settings and device-local compatibility acknowledgement already in place. This keeps the scenario focused on cross-runtime data compatibility; unconfigured start-up and visible CouchDB onboarding are covered by their dedicated workflows.
By default, the compatibility check runs `node src/apps/cli/dist/index.cjs`. Set `LIVESYNC_CLI_COMMAND` to test another CLI build or distribution. The value may be a quoted command line or a JSON array of executable and prefix arguments; the scenario arguments are appended without going through a shell.
For example, to test an executable on `PATH`:
```bash
LIVESYNC_CLI_COMMAND='livesync-cli' npm run test:e2e:obsidian:cli-to-obsidian-sync
```
On Linux, a multi-architecture published Docker image can run against the local CouchDB fixture by sharing the temporary directory, using host networking, preserving the host user's file ownership, and overriding the image entrypoint so that the runner can supply its explicit database path. Images published before ARM64 support remain AMD64-only and require configured Docker emulation on an ARM host.
```bash
LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --volume /tmp:/tmp --entrypoint node ghcr.io/vrtmrz/livesync-cli:edge /app/dist/index.cjs" \
npm run test:e2e:obsidian:cli-to-obsidian-sync
```
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
`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 a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
`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, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other live branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite.
`test:e2e:obsidian:security-seed-reconnect` is a focused CouchDB release-acceptance workflow. Device A first recognises an initial remote Security Seed, stops automatic replication while remaining open, and creates an unsent note. The runner replaces only the Security Seed in the managed remote synchronisation-parameter fixture. Device A must retain its deliberately stale cached value until the next one-shot synchronisation, refresh it before sending, and upload an HKDF-encrypted payload which uses the replacement value. A fresh device B must decrypt that note and send an encrypted note back; the original device A then receives the return journey with its Vault and isolated profile preserved. Desktop Obsidian may enforce a single application instance, so the two device sessions run sequentially after the same-process stale-cache assertion has completed.
The workflow creates a random dedicated database, records only SHA-256 Seed fingerprints, and never writes a Seed, passphrase, or CouchDB credentials to its result. It also requires the remote Seed and all other synchronisation parameters to remain unchanged after the replacement revision, rejects HKDF and Seed errors from either session, writes `security-seed-reconnect-result.json`, and verifies that every Obsidian process, temporary Vault, isolated profile, and database has been removed. The result file and stage screenshots are retained in `E2E_OBSIDIAN_DIAGNOSTICS_DIR`; the screenshots show ordinary Vault content, not settings or secrets. The strict cleanup workflow rejects `E2E_OBSIDIAN_KEEP_VAULT` and `E2E_OBSIDIAN_KEEP_COUCHDB`.
This proves in real Obsidian the plug-in behaviour shared by supported platforms, including the encrypted bidirectional round-trip and protection against a stale client restoring the old remote Seed. It does not verify iPadOS-specific background or reconnect lifecycle behaviour, and it does not count as Android device evidence. The workflow remains outside `test:e2e:obsidian:local-suite` because it is a focused release-acceptance check.
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
`test:e2e:obsidian: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: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:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`.
`test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use.
The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred rather than accepted; a later configured start must evaluate it. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself.
For CouchDB and Object Storage, the workflow then configures 0.25.83 from its own defaults, saves the selected remote, and restarts that release with the same profile before creating history. This both verifies that the old settings persist and lets the old release initialise its replicator from the same saved state as an ordinary existing Vault. The runner waits for that release's asynchronously initialised persistent node identity, creates, edits, renames, and deletes notes, and synchronises each transition before installing the target. Every launch of the upgraded device uses the same isolated Obsidian profile. The session layer closes the renderer before its process-tree fallback, so Chromium persists the legacy compatibility marker naturally; the target must read and migrate that actual profile state to its current namespaced key. The final target restart likewise consumes the marker persisted by the preceding target session. The runner does not reconstruct that device's Vault data, plug-in settings, local database files, device-local state, or remote state. Before the target performs any synchronisation, it must retain the same Vault profile, local database, node identity, remote profile, local checkpoint, and remote milestone. The local node-info document is the identity source of truth; a transient replicator field is used only to confirm that the old asynchronous initialisation has completed. Its first synchronisation must be a no-op: CouchDB document revisions and `update_seq` must remain unchanged, while Object Storage must neither upload nor download journal bodies. The upgraded device then sends a new delta. A separate fresh 1.0 verifier starts from an explicit fixture containing settings and compatibility state for the current version, receives the complete surviving history, and returns another delta; it is not part of the migration assertion for legacy remote settings. The upgraded Vault receives that return journey and retains it across restart.
Before creating stable-release history, the runner waits until the remote Security Seed can be read and only then marks the remote as resolved. Completion of the old release's remote-creation method alone does not prove that this asynchronous fixture boundary is ready.
Run the focused wrapper after source changes so that the target plug-in is rebuilt first:
```bash
npm run test:e2e:obsidian:focused -- upgrade-from-stable --transport all --manage-services
```
Use `--transport couchdb` or `--transport object-storage` for a focused rerun. `--manage-services` starts and stops the required local fixture or fixtures; add `--keep-services` only when they should remain available for inspection. Set `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT` to validate another already-built target directory, or `E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT` to use an explicit cache directory whose files still match the pinned release hashes.
This workflow is deliberately excluded from `local-suite`. It downloads a published historical artefact, reuses one profile across multiple application versions, and is an expensive release-acceptance gate rather than a routine current-version scenario. P2P is also excluded because cross-version P2P interoperability is a separate physical validation boundary.
Start the local fixtures first when they are not already running:
```bash
npm run test:docker-couchdb:start
npm run test:docker-s3:start
npm run test:docker-p2p:start
npm run test:e2e:obsidian:local-suite
```
@@ -92,6 +209,7 @@ npm run test:e2e:obsidian:local-suite:services
Useful environment variables:
- `OBSIDIAN_BINARY`: explicit Obsidian executable path.
- `OBSIDIAN_CLI`: explicit companion `obsidian-cli` executable path.
- `E2E_OBSIDIAN_VERSION`: Obsidian AppImage version for `test:e2e:obsidian:install-appimage`; default is `1.12.7`.
- `E2E_OBSIDIAN_APPIMAGE_ARCH`: AppImage architecture override, such as `arm64` or `x86_64`.
- `E2E_OBSIDIAN_APPIMAGE_URL`: explicit AppImage URL override.
@@ -99,13 +217,30 @@ Useful environment variables:
- `E2E_OBSIDIAN_FORCE_DOWNLOAD=true`: re-download the AppImage even when it exists.
- `E2E_OBSIDIAN_SKIP_EXTRACT=true`: download the AppImage without extracting it.
- `E2E_OBSIDIAN_SMOKE_TIMEOUT_MS`: smoke timeout in milliseconds.
- `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds.
- `E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS`: timeout for each visible revision-repair control and result; default is 15 seconds.
- `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds.
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`.
- `E2E_P2P_RELAY_PORT`: host port for the local P2P relay fixture; default is `4010`.
- `E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT`: CDP port for the second concurrent real Obsidian session; default is one greater than the primary port.
- `E2E_OBSIDIAN_READY_TIMEOUT_MS`: plug-in readiness timeout in milliseconds.
- `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS`: timeout for waiting until the vault-side Obsidian CLI exposes the plug-in catalogue.
- `E2E_OBSIDIAN_CLI_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation.
- `E2E_LIVESYNC_CLI_TIMEOUT_MS`: timeout for each official LiveSync CLI invocation in the CLI-to-Obsidian compatibility check; default is 60 seconds.
- `LIVESYNC_CLI_COMMAND`: optional LiveSync CLI executable and prefix arguments used by the CLI-to-Obsidian compatibility check. The default is the locally built CLI.
- `E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT`: optional cache directory containing the exact pinned 0.25.83 plug-in artefacts. Cached files are always checksum-verified.
- `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT`: directory containing the built 1.0 target `main.js`, `manifest.json`, and `styles.css`; default is the repository root.
- `E2E_OBSIDIAN_ARTIFACT_ROOT`: directory containing the plug-in artefact installed by a direct scenario invocation; default is the repository root.
- `E2E_OBSIDIAN_ARTIFACT_REVISION`: exact source commit recorded by the Security Seed reconnect result when `E2E_OBSIDIAN_ARTIFACT_ROOT` is a downloaded artefact rather than a Git worktree.
- `E2E_OBSIDIAN_FILE_TIMEOUT_MS`: timeout for waiting until a note created through Obsidian's vault API is reflected to disk.
- `E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS`: timeout for waiting until Self-hosted LiveSync reports that its core lifecycle and local database are ready.
- `E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS`: timeout for waiting until a file appears in Self-hosted LiveSync's local database.
- `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents.
- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds.
- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots, including the Security Seed reconnect stages; default is `/tmp/obsidian-livesync-e2e`.
- `E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS`: timeout for waiting until Object Storage contains uploaded E2E objects.
- `E2E_OBSIDIAN_KEEP_COUCHDB=true`: keep the temporary CouchDB database for inspection.
- `E2E_OBSIDIAN_KEEP_OBJECT_STORAGE=true`: keep the temporary Object Storage prefix for inspection.
+6 -103
View File
@@ -1,103 +1,6 @@
import { spawn } from "node:child_process";
export type ObsidianCliResult = {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
};
function parseEvalJson(stdout: string): unknown {
const marker = "=> ";
const markerIndex = stdout.indexOf(marker);
const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout;
return JSON.parse(text.trim());
}
export async function runObsidianCli(
cliBinary: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000)
): Promise<ObsidianCliResult> {
return await new Promise((resolve, reject) => {
const child = spawn(cliBinary, args, {
stdio: ["ignore", "pipe", "pipe"],
env,
});
let stdout = "";
let stderr = "";
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`));
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal, stdout, stderr });
});
});
}
export async function openVaultWithObsidianCli(
cliBinary: string,
vaultPath: string,
env: NodeJS.ProcessEnv = process.env
): Promise<void> {
const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env);
if (result.code !== 0) {
throw new Error(
[
`Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
export async function evalObsidianJson<T>(
cliBinary: string,
code: string,
env: NodeJS.ProcessEnv = process.env,
timeoutMs?: number
): Promise<T> {
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs);
if (result.code !== 0) {
throw new Error(
[
`Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
try {
return parseEvalJson(result.stdout) as T;
} catch (error) {
throw new Error(
[
`Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`,
error instanceof Error ? `parse error: ${error.message}` : undefined,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
export {
evalObsidianJson,
openVaultWithObsidianCli,
runObsidianCli,
type ObsidianCliResult,
} from "@vrtmrz/obsidian-test-session";
+102
View File
@@ -26,6 +26,28 @@ export type CouchDbAllDocsResponse = {
}>;
};
export type CouchDbLocalDocsResponse = {
rows: Array<{
id: string;
key: string;
value: { rev: string };
doc?: CouchDbDocument;
}>;
};
export type CouchDbDatabaseInfo = {
db_name: string;
doc_count: number;
doc_del_count: number;
update_seq: number | string;
};
export type CouchDbPutResponse = {
ok: boolean;
id: string;
rev: string;
};
function parseEnvFile(content: string): Record<string, string> {
const entries = content
.split(/\r?\n/u)
@@ -63,6 +85,14 @@ function databaseUrl(config: Pick<CouchDbConfig, "uri">, dbName: string, suffix
return `${config.uri.replace(/\/+$/u, "")}/${encodeURIComponent(dbName)}${suffix}`;
}
function documentSuffix(documentId: string): string {
const localPrefix = "_local/";
if (documentId.startsWith(localPrefix)) {
return `/_local/${encodeURIComponent(documentId.slice(localPrefix.length))}`;
}
return `/${encodeURIComponent(documentId)}`;
}
async function couchDbRequest(
config: Pick<CouchDbConfig, "uri" | "username" | "password">,
path: string,
@@ -126,6 +156,43 @@ export async function createCouchDbDatabase(config: CouchDbConfig, dbName: strin
}
}
export async function putCouchDbDocument(
config: CouchDbConfig,
dbName: string,
document: CouchDbDocument
): Promise<CouchDbPutResponse> {
const response = await fetch(databaseUrl(config, dbName, documentSuffix(document._id)), {
method: "PUT",
headers: {
authorization: authHeader(config),
"content-type": "application/json",
},
body: JSON.stringify(document),
});
if (!response.ok) {
throw new Error(
`Failed to write CouchDB document ${document._id}. HTTP ${response.status}: ${await response.text()}`
);
}
return (await response.json()) as CouchDbPutResponse;
}
export async function fetchCouchDbDocument(
config: CouchDbConfig,
dbName: string,
documentId: string
): Promise<CouchDbDocument> {
const response = await fetch(databaseUrl(config, dbName, documentSuffix(documentId)), {
headers: { authorization: authHeader(config) },
});
if (!response.ok) {
throw new Error(
`Failed to read CouchDB document ${documentId}. HTTP ${response.status}: ${await response.text()}`
);
}
return (await response.json()) as CouchDbDocument;
}
export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: string): Promise<void> {
const response = await fetch(databaseUrl(config, dbName), {
method: "DELETE",
@@ -138,6 +205,19 @@ export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: strin
}
}
export async function couchDbDatabaseExists(config: CouchDbConfig, dbName: string): Promise<boolean> {
const response = await fetch(databaseUrl(config, dbName), {
headers: { authorization: authHeader(config) },
});
if (response.status === 404) {
return false;
}
if (!response.ok) {
throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`);
}
return true;
}
export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string): Promise<CouchDbAllDocsResponse> {
const response = await fetch(databaseUrl(config, dbName, "/_all_docs?include_docs=true"), {
headers: { authorization: authHeader(config) },
@@ -150,6 +230,28 @@ export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string)
return (await response.json()) as CouchDbAllDocsResponse;
}
export async function fetchCouchDbLocalDocs(config: CouchDbConfig, dbName: string): Promise<CouchDbLocalDocsResponse> {
const response = await fetch(databaseUrl(config, dbName, "/_local_docs?include_docs=true"), {
headers: { authorization: authHeader(config) },
});
if (!response.ok) {
throw new Error(
`Failed to read CouchDB local documents from ${dbName}. HTTP ${response.status}: ${await response.text()}`
);
}
return (await response.json()) as CouchDbLocalDocsResponse;
}
export async function fetchCouchDbDatabaseInfo(config: CouchDbConfig, dbName: string): Promise<CouchDbDatabaseInfo> {
const response = await fetch(databaseUrl(config, dbName), {
headers: { authorization: authHeader(config) },
});
if (!response.ok) {
throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`);
}
return (await response.json()) as CouchDbDatabaseInfo;
}
export async function waitForCouchDbDocs(
config: CouchDbConfig,
dbName: string,
+7 -149
View File
@@ -1,149 +1,7 @@
import { accessSync, constants, existsSync } from "node:fs";
import { resolve } from "node:path";
import { platform } from "node:process";
export type ObsidianDiscoveryResult = {
binary?: string;
source?: string;
checked: string[];
};
const defaultCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
aix: [],
android: [],
darwin: [
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
],
freebsd: [],
haiku: [],
linux: [
"_testdata/obsidian/squashfs-root/obsidian",
"_testdata/obsidian/squashfs-root/AppRun",
"_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage",
"_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage",
"/usr/bin/obsidian",
"/usr/local/bin/obsidian",
"/snap/bin/obsidian",
"/opt/Obsidian/obsidian",
"/opt/obsidian/obsidian",
"/app/bin/obsidian",
],
openbsd: [],
sunos: [],
win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"],
cygwin: [],
netbsd: [],
};
const defaultCliCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
aix: [],
android: [],
darwin: [
"/Applications/Obsidian.app/Contents/MacOS/obsidian-cli",
"/Applications/Obsidian.app/Contents/Resources/obsidian-cli",
],
freebsd: [],
haiku: [],
linux: [
"_testdata/obsidian/squashfs-root/obsidian-cli",
"/usr/bin/obsidian-cli",
"/usr/local/bin/obsidian-cli",
"/snap/bin/obsidian-cli",
"/opt/Obsidian/obsidian-cli",
"/opt/obsidian/obsidian-cli",
],
openbsd: [],
sunos: [],
win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"],
cygwin: [],
netbsd: [],
};
function isUsableFile(path: string): boolean {
const resolvedPath = resolve(path);
if (!existsSync(resolvedPath)) {
return false;
}
if (platform === "win32") {
return true;
}
try {
accessSync(resolvedPath, constants.X_OK);
return true;
} catch {
return false;
}
}
export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
const checked: string[] = [];
const envBinary = env.OBSIDIAN_BINARY?.trim();
if (envBinary) {
checked.push(envBinary);
if (isUsableFile(envBinary)) {
return {
binary: resolve(envBinary),
source: "OBSIDIAN_BINARY",
checked,
};
}
}
const candidates = defaultCandidatesByPlatform[platform] ?? [];
for (const candidate of candidates) {
checked.push(candidate);
if (isUsableFile(candidate)) {
return {
binary: resolve(candidate),
source: "default-path",
checked,
};
}
}
return { checked };
}
export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string {
const result = discoverObsidianBinary(env);
if (!result.binary) {
throw new Error(
[
"Could not find an Obsidian executable.",
"Set OBSIDIAN_BINARY to the installed Obsidian executable path.",
`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`,
].join("\n")
);
}
return result.binary;
}
export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
const checked: string[] = [];
const envBinary = env.OBSIDIAN_CLI?.trim();
if (envBinary) {
checked.push(envBinary);
if (isUsableFile(envBinary)) {
return {
binary: resolve(envBinary),
source: "OBSIDIAN_CLI",
checked,
};
}
}
const candidates = defaultCliCandidatesByPlatform[platform] ?? [];
for (const candidate of candidates) {
checked.push(candidate);
if (isUsableFile(candidate)) {
return {
binary: resolve(candidate),
source: "default-path",
checked,
};
}
}
return { checked };
}
export {
discoverObsidianBinary,
discoverObsidianCli,
requireObsidianBinary,
requireObsidianCli,
type ObsidianDiscoveryResult,
} from "@vrtmrz/obsidian-test-session";
+17 -187
View File
@@ -1,196 +1,26 @@
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import { dirname } from "node:path";
import { platform } from "node:process";
import { promisify } from "node:util";
import {
cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses,
launchObsidian as launchObsidianSession,
type LaunchObsidianOptions,
type ObsidianProcess,
type ObsidianProcessOutput,
} from "@vrtmrz/obsidian-test-session";
export type ObsidianProcess = {
process: ChildProcess;
output: () => { stdout: string; stderr: string };
stop: () => Promise<void>;
};
export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput };
export type LaunchObsidianOptions = {
binary: string;
vaultPath: string;
homePath?: string;
xdgConfigPath?: string;
xdgCachePath?: string;
xdgDataPath?: string;
userDataPath?: string;
startupGraceMs?: number;
};
const execFileAsync = promisify(execFile);
function splitArgs(args: string): string[] {
return args.split(" ").filter((arg) => arg.length > 0);
}
function launchArgs(options: LaunchObsidianOptions): string[] {
const explicitArgs = process.env.E2E_OBSIDIAN_ARGS;
if (explicitArgs) {
return splitArgs(explicitArgs);
}
return [
"--no-sandbox",
"--disable-gpu",
"--disable-software-rasterizer",
...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR !== "false" && options.userDataPath
? [`--user-data-dir=${options.userDataPath}`]
: []),
...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT
? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`]
: []),
`obsidian://open?path=${encodeURIComponent(options.vaultPath)}`,
];
}
function shouldUseXvfb(): boolean {
if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") {
return false;
}
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
return false;
}
return platform === "linux" && existsSync("/usr/bin/xvfb-run");
}
async function listChildPids(pid: number): Promise<number[]> {
if (platform === "win32") {
return [];
}
const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({
stdout: "",
}));
const directChildren = stdout
.split("\n")
.map((line) => Number(line.trim()))
.filter((childPid) => Number.isInteger(childPid) && childPid > 0);
const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid)));
return [...directChildren, ...descendants.flat()];
}
async function killPids(pids: number[], signal: NodeJS.Signals): Promise<void> {
for (const pid of pids) {
if (pid === process.pid) {
continue;
}
try {
process.kill(pid, signal);
} catch {
// The process may have exited between discovery and signalling.
}
}
}
async function waitForExit(exitPromise: Promise<unknown>, timeoutMs: number): Promise<"exited" | "timeout"> {
const stopTimer = new Promise<"timeout">((resolve) => {
setTimeout(() => resolve("timeout"), timeoutMs);
});
const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]);
return stopResult;
}
const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state";
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") {
return;
}
const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({
stdout: "",
}));
const pids = stdout
.split("\n")
.map((line) => Number(line.trim()))
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
if (pids.length === 0) {
return;
}
await killPids(pids, "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1000));
await killPids(pids, "SIGKILL");
await cleanupStaleProcesses(STALE_PROCESS_PATTERN);
}
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
await cleanupStaleObsidianE2EProcesses();
const startupGraceMs = options.startupGraceMs ?? 1000;
const args = launchArgs(options);
const useXvfb = shouldUseXvfb();
const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary;
const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args;
const child = spawn(command, commandArgs, {
cwd: dirname(options.binary),
detached: true,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...(options.homePath ? { HOME: options.homePath } : {}),
...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}),
...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}),
...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}),
OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1",
},
const configuredPort =
options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT;
return await launchObsidianSession({
...options,
remoteDebuggingPort:
options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)),
staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN,
});
let stderr = "";
let stdout = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal }));
const timer = new Promise<"timeout">((resolve) => {
setTimeout(() => resolve("timeout"), startupGraceMs);
});
const firstResult = await Promise.race([exitPromise, timer]);
if (firstResult !== "timeout") {
throw new Error(
[
`Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`,
stdout ? `stdout:\n${stdout}` : undefined,
stderr ? `stderr:\n${stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
return {
process: child,
output: () => ({ stdout, stderr }),
stop: async () => {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
const descendantPids = child.pid ? await listChildPids(child.pid) : [];
if (child.pid) {
try {
process.kill(-child.pid, "SIGTERM");
} catch {
child.kill("SIGTERM");
}
} else {
child.kill("SIGTERM");
}
await killPids(descendantPids.reverse(), "SIGTERM");
const stopResult = await waitForExit(exitPromise, 5000);
if (stopResult === "timeout") {
if (child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
child.kill("SIGKILL");
}
} else {
child.kill("SIGKILL");
}
await killPids(descendantPids, "SIGKILL");
await exitPromise;
}
},
};
}
@@ -0,0 +1,95 @@
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { describe, expect, it, vi } from "vitest";
const { evalObsidianJson } = vi.hoisted(() => ({
evalObsidianJson: vi.fn(),
}));
vi.mock("./cli.ts", () => ({ evalObsidianJson }));
import {
assertE2eCompatibilityMarker,
createE2eCouchDbPluginData,
prepareRemote,
waitForLiveSyncCoreReady,
type CompatibilityMarkerState,
} from "./liveSyncWorkflow.ts";
describe("compatibility marker persistence", () => {
it("waits for an accepted review to reach device-local storage", async () => {
const pending: CompatibilityMarkerState = {
vaultName: "fixture",
additionalSuffix: "-",
expectedStorageKey: "fixture--database-compatibility-version",
rawStorageValue: null,
serviceValue: "",
versionUpFlash: "",
};
const persisted: CompatibilityMarkerState = {
...pending,
rawStorageValue: `${VER}`,
serviceValue: `${VER}`,
};
evalObsidianJson.mockResolvedValueOnce(pending).mockResolvedValueOnce(persisted);
await expect(
assertE2eCompatibilityMarker("obsidian-cli", {}, { timeoutMs: 100, intervalMs: 0 })
).resolves.toEqual(persisted);
expect(evalObsidianJson).toHaveBeenCalledTimes(2);
});
});
describe("configured CouchDB fixture", () => {
it("uses a current remote profile for ordinary configured fixtures", () => {
const pluginData = createE2eCouchDbPluginData({
uri: "https://couch.example",
username: "alice",
password: "secret",
dbName: "notes",
});
const remoteConfigurations = pluginData.remoteConfigurations as
| Record<string, { id: string; uri: string }>
| undefined;
expect(remoteConfigurations).toBeDefined();
expect(Object.keys(remoteConfigurations ?? {})).toHaveLength(1);
expect(pluginData.activeConfigurationId).toBe(Object.keys(remoteConfigurations ?? {})[0]);
});
});
describe("Real Obsidian core readiness", () => {
it("retries while the plug-in core is temporarily unavailable during reload", async () => {
evalObsidianJson.mockReset();
evalObsidianJson
.mockRejectedValueOnce(new Error("Cannot read properties of undefined (reading 'core')"))
.mockResolvedValueOnce({
databaseReady: true,
appReady: true,
configured: true,
remoteType: "",
settingVersion: 10,
suspended: false,
});
await expect(waitForLiveSyncCoreReady("obsidian-cli", {}, 1000)).resolves.toMatchObject({
databaseReady: true,
appReady: true,
});
expect(evalObsidianJson).toHaveBeenCalledTimes(2);
});
});
describe("remote fixture preparation", () => {
it("waits for the remote Security Seed after resolving a new remote", async () => {
evalObsidianJson.mockReset();
evalObsidianJson.mockResolvedValueOnce({ status: "resolved", securitySeedReady: true });
await prepareRemote("obsidian-cli", {});
const evaluatedCode = String(evalObsidianJson.mock.calls[0]?.[1] ?? "");
expect(evaluatedCode.indexOf("markRemoteResolved")).toBeLessThan(
evaluatedCode.indexOf("ensurePBKDF2Salt")
);
expect(evaluatedCode).toContain("Timed out preparing the remote Security Seed");
});
});
+372 -61
View File
@@ -1,6 +1,12 @@
import { evalObsidianJson } from "./cli.ts";
import { SERVICE_CONTEXT_MEMBERS } from "../../contracts/serviceContext.ts";
import { DATABASE_COMPATIBILITY_VERSION_KEY } from "../../../src/common/databaseCompatibility.ts";
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { type ObsidianLiveSyncSettings, VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { CouchDbConfig } from "./couchdb.ts";
import type { ObjectStorageConfig } from "./objectStorage.ts";
import { captureObsidianDialogue, withObsidianPage } from "./ui.ts";
export type ConfiguredSettings = {
isConfigured: boolean;
@@ -18,6 +24,48 @@ export type ConfiguredSettings = {
export type CoreReadiness = {
databaseReady: boolean;
appReady: boolean;
configured?: boolean;
remoteType?: string;
settingVersion?: number;
suspended?: boolean;
};
export type ReplicationAttempt = CoreReadiness & {
succeeded: boolean;
isOnline: boolean;
activeReplicator: string;
versionUpFlash: string;
unresolvedMessages: unknown[];
};
export type CompatibilityMarkerState = {
vaultName: string;
additionalSuffix: string;
expectedStorageKey: string;
rawStorageValue: string | null;
serviceValue: string;
versionUpFlash: string;
};
export type CompatibilityMarkerWaitOptions = {
timeoutMs?: number;
intervalMs?: number;
};
export type ResumeCompatibilityReviewOptions = {
verifyMissingDeviceMarkerExplanation?: boolean;
screenshotPrefix?: string;
};
export type ObsidianServiceContextContractResult = {
contextType: string;
eventResult: string[];
translationResult: string;
hubUsesContext: boolean;
serviceContextMismatches: string[];
appCapabilityMatches: boolean;
pluginCapabilityMatches: boolean;
liveSyncPluginCapabilityMatches: boolean;
};
export type LocalDatabaseEntry = {
@@ -28,28 +76,182 @@ export type LocalDatabaseEntry = {
children: string[];
};
function e2ePreferredSettingsSource(): string[] {
return [
"liveSync:false,",
"syncOnStart:false,",
"syncOnSave:false,",
"usePluginSync:false,",
"usePluginSyncV2:true,",
"useEden:false,",
"customChunkSize:60,",
"sendChunksBulk:false,",
"sendChunksBulkMaxSize:1,",
"chunkSplitterVersion:'v3-rabin-karp',",
"readChunksOnline:true,",
"disableCheckingConfigMismatch:false,",
"enableCompression:false,",
"hashAlg:'xxhash64',",
"handleFilenameCaseSensitive:false,",
"doNotUseFixedRevisionForChunks:true,",
"E2EEAlgorithm:'v2',",
"doctorProcessedVersion:'0.25.27',",
"isConfigured:true,",
];
const E2E_PREFERRED_SETTINGS = {
displayLanguage: "def",
liveSync: false,
syncOnStart: false,
syncOnSave: false,
usePluginSync: false,
usePluginSyncV2: true,
useEden: false,
customChunkSize: 60,
sendChunksBulk: false,
sendChunksBulkMaxSize: 1,
chunkSplitterVersion: "v3-rabin-karp",
readChunksOnline: true,
disableCheckingConfigMismatch: false,
enableCompression: false,
hashAlg: "xxhash64",
handleFilenameCaseSensitive: false,
doNotUseFixedRevisionForChunks: true,
E2EEAlgorithm: "v2",
doctorProcessedVersion: "0.25.27",
settingVersion: CURRENT_SETTING_VERSION,
isConfigured: true,
} as const;
export function createE2eObsidianDeviceLocalState(
vaultName: string,
additionalSuffixOfDatabaseName = ""
): Readonly<Record<string, string>> {
return {
[`${vaultName}-${additionalSuffixOfDatabaseName}-${DATABASE_COMPATIBILITY_VERSION_KEY}`]: `${VER}`,
};
}
export async function readE2eCompatibilityMarker(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<CompatibilityMarkerState> {
return await evalObsidianJson<CompatibilityMarkerState>(
cliBinary,
[
"(()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const setting=core.services.setting;",
"const settings=setting.currentSettings();",
"const vaultName=core.services.API.getSystemVaultName();",
`const markerKey=${JSON.stringify(DATABASE_COMPATIBILITY_VERSION_KEY)};`,
"const additionalSuffix=`-${settings.additionalSuffixOfDatabaseName??''}`;",
"const expectedStorageKey=`${vaultName}${additionalSuffix}-${markerKey}`;",
"return JSON.stringify({",
"vaultName,additionalSuffix,expectedStorageKey,",
"rawStorageValue:localStorage.getItem(expectedStorageKey),",
"serviceValue:setting.getSmallConfig(markerKey),",
"versionUpFlash:settings.versionUpFlash,",
"});",
"})()",
].join(""),
env
);
}
export async function assertE2eCompatibilityMarker(
cliBinary: string,
env: NodeJS.ProcessEnv,
options: CompatibilityMarkerWaitOptions = {}
): Promise<CompatibilityMarkerState> {
const timeoutMs = options.timeoutMs ?? Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
const intervalMs = options.intervalMs ?? 100;
const deadline = Date.now() + timeoutMs;
let state = await readE2eCompatibilityMarker(cliBinary, env);
while (state.serviceValue !== `${VER}` && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
state = await readE2eCompatibilityMarker(cliBinary, env);
}
if (state.serviceValue !== `${VER}`)
throw new Error(`The E2E compatibility marker was not persisted before timeout: ${JSON.stringify(state)}`);
return state;
}
export async function assertE2eCompatibilityReviewPending(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<CompatibilityMarkerState> {
const state = await readE2eCompatibilityMarker(cliBinary, env);
if (state.serviceValue !== "" || state.rawStorageValue !== null || state.versionUpFlash === "") {
throw new Error(`The copied-Vault compatibility review was not pending: ${JSON.stringify(state)}`);
}
return state;
}
export async function resumeCompatibilityReview(
port: number,
options: ResumeCompatibilityReviewOptions = {}
): Promise<void> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
const title = "Synchronisation paused for compatibility review";
const summaryLocator = (page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) =>
page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
if (options.screenshotPrefix) {
const summaryScreenshot = await captureObsidianDialogue(
port,
`${options.screenshotPrefix}-summary.png`,
async (page) => {
await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs });
}
);
console.log(`Compatibility review summary screenshot: ${summaryScreenshot}`);
}
if (options.verifyMissingDeviceMarkerExplanation === true) {
await withObsidianPage(port, async (page) => {
const summary = summaryLocator(page);
await summary.waitFor({ state: "visible", timeout: timeoutMs });
await summary.getByRole("button", { name: "Review compatibility details" }).click();
});
const detailsScreenshot = options.screenshotPrefix
? await captureObsidianDialogue(port, `${options.screenshotPrefix}-details.png`, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.waitFor({ state: "visible", timeout: timeoutMs });
await details.getByText("copied or restored", { exact: false }).waitFor({
state: "visible",
timeout: timeoutMs,
});
await details.getByText("new Obsidian profile", { exact: false }).waitFor({
state: "visible",
timeout: timeoutMs,
});
await details
.getByText("does not mean that it is safe to resume automatically", { exact: false })
.waitFor({
state: "visible",
timeout: timeoutMs,
});
})
: undefined;
if (detailsScreenshot) console.log(`Compatibility review details screenshot: ${detailsScreenshot}`);
await withObsidianPage(port, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.getByRole("button", { name: "Back to compatibility review" }).click();
await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs });
});
}
await withObsidianPage(port, async (page) => {
const summary = summaryLocator(page);
await summary.waitFor({ state: "visible", timeout: timeoutMs });
await summary.getByRole("button", { name: "Resume synchronisation" }).click();
await summary.waitFor({ state: "hidden", timeout: timeoutMs });
});
}
export function createE2eCouchDbPluginData(
settings: Pick<CouchDbConfig, "uri" | "username" | "password"> & { dbName: string },
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
const pluginData = {
couchDB_URI: settings.uri,
couchDB_USER: settings.username,
couchDB_PASSWORD: settings.password,
couchDB_DBNAME: settings.dbName,
remoteType: "",
...E2E_PREFERRED_SETTINGS,
...overrides,
};
upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "couchdb", {
id: "e2e-couchdb",
name: "E2E CouchDB",
activate: true,
});
return pluginData;
}
export function assertEqual(actual: unknown, expected: unknown, message: string): void {
@@ -64,21 +266,14 @@ export async function configureCouchDb(
settings: Pick<CouchDbConfig, "uri" | "username" | "password"> & { dbName: string },
overrides: Record<string, unknown> = {}
): Promise<ConfiguredSettings> {
const nextSettings = createE2eCouchDbPluginData(settings, overrides);
return await evalObsidianJson<ConfiguredSettings>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const core=plugin.core;",
"const nextSettings={",
`couchDB_URI:${JSON.stringify(settings.uri)},`,
`couchDB_USER:${JSON.stringify(settings.username)},`,
`couchDB_PASSWORD:${JSON.stringify(settings.password)},`,
`couchDB_DBNAME:${JSON.stringify(settings.dbName)},`,
"remoteType:'',",
...e2ePreferredSettingsSource(),
...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`),
"};",
`const nextSettings=${JSON.stringify(nextSettings)};`,
"await core.services.setting.applyExternalSettings(nextSettings,true);",
"await core.services.control.applySettings();",
"const current=core.services.setting.currentSettings();",
@@ -103,25 +298,14 @@ export async function configureObjectStorage(
settings: ObjectStorageConfig & { bucketPrefix: string },
overrides: Record<string, unknown> = {}
): Promise<ConfiguredSettings> {
const nextSettings = createE2eObjectStoragePluginData(settings, overrides);
return await evalObsidianJson<ConfiguredSettings>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const core=plugin.core;",
"const nextSettings={",
"remoteType:'MINIO',",
`endpoint:${JSON.stringify(settings.endpoint)},`,
`accessKey:${JSON.stringify(settings.accessKey)},`,
`secretKey:${JSON.stringify(settings.secretKey)},`,
`bucket:${JSON.stringify(settings.bucket)},`,
`region:${JSON.stringify(settings.region)},`,
`forcePathStyle:${JSON.stringify(settings.forcePathStyle)},`,
`bucketPrefix:${JSON.stringify(settings.bucketPrefix)},`,
"bucketCustomHeaders:'',",
...e2ePreferredSettingsSource(),
...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`),
"};",
`const nextSettings=${JSON.stringify(nextSettings)};`,
"await core.services.setting.applyExternalSettings(nextSettings,true);",
"await core.services.control.applySettings();",
"const current=core.services.setting.currentSettings();",
@@ -143,6 +327,25 @@ export async function configureObjectStorage(
);
}
export function createE2eObjectStoragePluginData(
settings: ObjectStorageConfig & { bucketPrefix: string },
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
remoteType: "MINIO",
endpoint: settings.endpoint,
accessKey: settings.accessKey,
secretKey: settings.secretKey,
bucket: settings.bucket,
region: settings.region,
forcePathStyle: settings.forcePathStyle,
bucketPrefix: settings.bucketPrefix,
bucketCustomHeaders: "",
...E2E_PREFERRED_SETTINGS,
...overrides,
};
}
export async function waitForLiveSyncCoreReady(
cliBinary: string,
env: NodeJS.ProcessEnv,
@@ -150,29 +353,116 @@ export async function waitForLiveSyncCoreReady(
): Promise<CoreReadiness> {
const deadline = Date.now() + timeoutMs;
let lastReadiness: CoreReadiness | undefined;
let lastError: unknown;
while (Date.now() < deadline) {
lastReadiness = await evalObsidianJson<CoreReadiness>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"return JSON.stringify({",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"});",
"})()",
].join(""),
env
);
try {
lastReadiness = await evalObsidianJson<CoreReadiness>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync']?.core;",
"if(!core) return JSON.stringify({databaseReady:false,appReady:false});",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"configured:settings?.isConfigured===true,",
"remoteType:settings?.remoteType??'',",
"settingVersion:settings?.settingVersion,",
"suspended:core.services.appLifecycle.isSuspended(),",
"});",
"})()",
].join(""),
env
);
lastError = undefined;
} catch (error) {
// Obsidian reloads the renderer while enabling the plug-in. During
// that short window the CLI can reach the Vault before the plug-in
// catalogue has exposed its core. This is a readiness state, not a
// failed scenario, so retain the error for the eventual timeout.
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 500));
continue;
}
if (lastReadiness.databaseReady && lastReadiness.appReady) {
return lastReadiness;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}`);
const errorSuffix =
lastError === undefined
? ""
: ` Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`;
throw new Error(
`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}${errorSuffix}`
);
}
/**
* Inspect the actual Obsidian composition through Obsidian's CLI.
*
* This observes public Context results and verifies that the Hub and every
* exposed service retain the exact Context created by the plug-in host.
*/
export async function inspectObsidianServiceContextContract(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<ObsidianServiceContextContractResult> {
return await evalObsidianJson<ObsidianServiceContextContractResult>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const services=plugin.core.services;",
"const context=services.context;",
`const serviceNames=${JSON.stringify(SERVICE_CONTEXT_MEMBERS)};`,
"const eventResult=[];",
"const unsubscribe=context.events.onEvent('hello',(value)=>eventResult.push(value));",
"try{context.events.emitEvent('hello','context-contract-event');}finally{unsubscribe();}",
"return JSON.stringify({",
"contextType:context.constructor.name,",
"eventResult,",
"translationResult:context.translate('Replicator.Message.InitialiseFatalError'),",
"hubUsesContext:services.context===context,",
"serviceContextMismatches:serviceNames.filter((name)=>services[name].context!==context),",
"appCapabilityMatches:context.app===app,",
"pluginCapabilityMatches:context.plugin===plugin,",
"liveSyncPluginCapabilityMatches:context.liveSyncPlugin===plugin,",
"});",
"})()",
].join(""),
env
);
}
export function assertObsidianServiceContextContract(result: ObsidianServiceContextContractResult): void {
assertEqual(result.contextType, "ObsidianServiceContext", "Unexpected Obsidian service Context type.");
assertEqual(result.hubUsesContext, true, "The Obsidian Service Hub substituted its host Context.");
assertEqual(
result.serviceContextMismatches.length,
0,
`Services used a different Context: ${result.serviceContextMismatches.join(", ")}`
);
assertEqual(
JSON.stringify(result.eventResult),
JSON.stringify(["context-contract-event"]),
"The Obsidian Context event API returned an unexpected result."
);
if (result.translationResult.length === 0) {
throw new Error("The Obsidian Context translator returned an empty result.");
}
assertEqual(result.appCapabilityMatches, true, "The Obsidian Context lost its App capability.");
assertEqual(result.pluginCapabilityMatches, true, "The Obsidian Context lost its Plugin capability.");
assertEqual(
result.liveSyncPluginCapabilityMatches,
true,
"The Obsidian Context lost its Self-hosted LiveSync plug-in capability."
);
}
export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_PREPARE_TIMEOUT_MS ?? 20000);
await evalObsidianJson<unknown>(
cliBinary,
[
@@ -182,8 +472,16 @@ export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv):
"const replicator=core.services.replicator.getActiveReplicator();",
"await replicator.tryCreateRemoteDatabase(settings);",
"await replicator.markRemoteResolved(settings);",
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
"let securitySeedReady=false;",
"do{",
"securitySeedReady=await replicator.ensurePBKDF2Salt(settings,false,false);",
"if(securitySeedReady) break;",
"await new Promise((resolve)=>setTimeout(resolve,250));",
"}while(Date.now()<deadline);",
"if(!securitySeedReady) throw new Error('Timed out preparing the remote Security Seed');",
"const status=await replicator.getRemoteStatus(settings);",
"return JSON.stringify({status});",
"return JSON.stringify({status,securitySeedReady});",
"})()",
].join(""),
env
@@ -191,18 +489,31 @@ export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv):
}
export async function pushLocalChanges(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
const attempt = await evalObsidianJson<ReplicationAttempt>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.fileProcessing.commitPendingFileEvents();",
"const result=await core.services.replication.replicate(true);",
"return JSON.stringify({result:!!result});",
"const settings=core.services.setting.currentSettings();",
"const activeReplicator=core.services.replicator.getActiveReplicator();",
"return JSON.stringify({",
"succeeded:!!result,",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"isOnline:core.services.API.isOnline,",
"activeReplicator:activeReplicator?.constructor?.name??'(none)',",
"versionUpFlash:settings.versionUpFlash,",
"unresolvedMessages:(await core.services.appLifecycle.getUnresolvedMessages()).flat(),",
"});",
"})()",
].join(""),
env
);
if (!attempt.succeeded) {
throw new Error(`Finite replication did not start or complete: ${JSON.stringify(attempt)}`);
}
}
export async function waitForLocalDatabaseEntry(
+163
View File
@@ -0,0 +1,163 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import type { Locator, Page } from "playwright";
import { withObsidianPage } from "./ui.ts";
export const mobileViewport = { width: 390, height: 844 } as const;
export const desktopViewport = { width: 1024, height: 768 } as const;
export const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const;
type ObsidianTestApp = {
isMobile?: boolean;
emulateMobile?: (mobile: boolean) => void;
plugins?: { plugins: Record<string, unknown> };
workspace?: { layoutReady?: boolean };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function applyObsidianMobileTestMode(
port: number,
enabled: boolean,
timeoutMs: number,
waitForLiveSync: boolean
): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.setViewportSize(enabled ? mobileViewport : desktopViewport);
await page.evaluate((nextEnabled) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
if (typeof obsidianApp?.emulateMobile !== "function") {
throw new Error("app.emulateMobile is unavailable");
}
obsidianApp.emulateMobile(nextEnabled);
}, enabled);
// Obsidian reopens its workspace layout when platform emulation
// changes. Loading a controlled plug-in before that transition has
// completed can leave the plug-in enabled but absent from the active
// renderer.
try {
await page.waitForFunction(
({ nextEnabled, waitForLiveSync }) => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
return (
document.body.classList.contains("is-mobile") === nextEnabled &&
obsidianApp?.workspace?.layoutReady === true &&
(!waitForLiveSync || obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined)
);
},
{ nextEnabled: enabled, waitForLiveSync },
{ timeout: timeoutMs }
);
} catch (error) {
const state = await page.evaluate(() => {
const obsidianApp = (globalThis as ObsidianTestGlobal).app;
return {
appIsMobile: obsidianApp?.isMobile ?? null,
bodyClasses: document.body.className,
documentReadyState: document.readyState,
liveSyncLoaded: obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined,
viewport: { width: window.innerWidth, height: window.innerHeight },
workspaceLayoutReady: obsidianApp?.workspace?.layoutReady ?? null,
};
});
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(outputDirectory, { recursive: true });
const screenshotPath = join(
outputDirectory,
waitForLiveSync
? "mobile-mode-transition.failure.png"
: "mobile-mode-before-plugin-start.failure.png"
);
await page.screenshot({ path: screenshotPath, fullPage: true });
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Obsidian mobile-mode transition did not settle: ${JSON.stringify(state)}; screenshot=${screenshotPath}; cause=${detail}`
);
}
await page.evaluate(
(safeArea) => {
for (const edge of ["top", "right", "bottom", "left"] as const) {
const property = `--safe-area-inset-${edge}`;
if (safeArea === null) document.body.style.removeProperty(property);
else document.body.style.setProperty(property, `${safeArea[edge]}px`);
}
},
enabled ? iPhoneSafeArea : null
);
});
}
/** Enters mobile emulation before LiveSync's first load in a controlled session. */
export async function setObsidianMobileTestModeBeforePluginStart(
port: number,
enabled: boolean,
timeoutMs: number
): Promise<void> {
await applyObsidianMobileTestMode(port, enabled, timeoutMs, false);
}
export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise<void> {
await applyObsidianMobileTestMode(port, enabled, timeoutMs, true);
}
export async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise<void> {
const dialogue = container.locator(".modal").last();
const closeButton = dialogue.locator(".modal-close-button");
await assertLocatorWithinViewport(page, dialogue, { label });
await assertNoHorizontalOverflow(page, dialogue, { label });
await assertLocatorWithinSafeArea(page, dialogue, {
label,
safeAreaInsets: iPhoneSafeArea,
});
await assertLocatorWithinSafeArea(page, closeButton, {
label: `${label} close button`,
safeAreaInsets: iPhoneSafeArea,
});
await assertLocatorHasMinimumTouchTarget(page, closeButton, {
label: `${label} close button`,
});
const visibleButtons = dialogue.locator("button:visible");
for (let index = 0; index < (await visibleButtons.count()); index++) {
const button = visibleButtons.nth(index);
const buttonLabel = (await button.innerText()).trim() || `button ${index + 1}`;
await assertLocatorWithinViewport(page, button, { label: `${label}: ${buttonLabel}` });
await assertLocatorWithinSafeArea(page, button, {
label: `${label}: ${buttonLabel}`,
safeAreaInsets: iPhoneSafeArea,
});
await assertNoHorizontalOverflow(page, button, { label: `${label}: ${buttonLabel}` });
await assertLocatorHasMinimumTouchTarget(page, button, { label: `${label}: ${buttonLabel}` });
}
}
export async function assertMobileNoticeLayout(
page: Page,
notice: Locator,
label: string,
reservedRightPx = 56
): Promise<void> {
await assertLocatorWithinViewport(page, notice, { label });
await assertNoHorizontalOverflow(page, notice, { label });
await assertLocatorWithinSafeArea(page, notice, {
label,
safeAreaInsets: iPhoneSafeArea,
});
const box = await notice.boundingBox();
if (box === null) {
throw new Error(`${label} did not expose a measurable viewport rectangle.`);
}
const viewportWidth = await page.evaluate(() => window.innerWidth);
const rightEdge = box.x + box.width;
if (rightEdge > viewportWidth - reservedRightPx) {
throw new Error(
`${label} overlaps the reserved close-control column: right edge ${rightEdge}, limit ${viewportWidth - reservedRightPx}.`
);
}
}
+17
View File
@@ -1,6 +1,7 @@
import {
CreateBucketCommand,
DeleteObjectsCommand,
GetObjectCommand,
ListObjectsV2Command,
S3Client,
type _Object,
@@ -120,6 +121,22 @@ export async function listObjectStorageObjects(config: ObjectStorageConfig, pref
}
}
export async function readObjectStorageObject(config: ObjectStorageConfig, key: string): Promise<Uint8Array> {
const client = createObjectStorageClient(config);
try {
const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key }));
if (!response.Body) throw new Error(`Object Storage returned an empty body for ${key}.`);
return await response.Body.transformToByteArray();
} finally {
client.destroy();
}
}
export async function readObjectStorageJson<T>(config: ObjectStorageConfig, key: string): Promise<T> {
const bytes = await readObjectStorageObject(config, key);
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
export async function deleteObjectStoragePrefix(config: ObjectStorageConfig, prefix: string): Promise<void> {
const client = createObjectStorageClient(config);
try {
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { hasExactCaseOnlyRename } from "./pathEntries.ts";
describe("case-only rename assertions", () => {
it("accepts only the exact new spelling", () => {
expect(hasExactCaseOnlyRename(["case-rename.md"], "Case-Rename.md", "case-rename.md")).toBe(true);
});
it("rejects the old spelling even when a case-insensitive lookup would resolve it", () => {
expect(hasExactCaseOnlyRename(["Case-Rename.md"], "Case-Rename.md", "case-rename.md")).toBe(false);
});
it("rejects an ambiguous directory containing both spellings", () => {
expect(hasExactCaseOnlyRename(["Case-Rename.md", "case-rename.md"], "Case-Rename.md", "case-rename.md")).toBe(
false
);
});
});
@@ -0,0 +1,30 @@
import { readdir } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { hasExactCaseOnlyRename } from "./pathEntries.ts";
export async function waitForExactCaseOnlyRename(
vaultPath: string,
oldPath: string,
newPath: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
): Promise<void> {
const oldDirectory = dirname(oldPath);
const newDirectory = dirname(newPath);
if (oldDirectory !== newDirectory) {
throw new Error(`Case-only rename paths must share one parent directory: ${oldPath} -> ${newPath}`);
}
const oldName = basename(oldPath);
const newName = basename(newPath);
const directoryPath = join(vaultPath, newDirectory);
const deadline = Date.now() + timeoutMs;
let lastEntries: string[] = [];
while (Date.now() < deadline) {
lastEntries = await readdir(directoryPath);
if (hasExactCaseOnlyRename(lastEntries, oldName, newName)) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`Timed out waiting for exact case-only rename: ${oldPath} -> ${newPath}. Directory entries: ${JSON.stringify(lastEntries)}`
);
}
+3
View File
@@ -0,0 +1,3 @@
export function hasExactCaseOnlyRename(entries: readonly string[], oldName: string, newName: string): boolean {
return entries.includes(newName) && !entries.includes(oldName);
}
+9 -35
View File
@@ -1,39 +1,13 @@
import { copyFile, mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import {
installBuiltPlugin as installGenericBuiltPlugin,
type PluginInstallResult,
} from "@vrtmrz/obsidian-test-session";
export type PluginInstallResult = {
pluginDir: string;
copied: string[];
};
const pluginId = "obsidian-livesync";
export type { PluginInstallResult };
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId);
const copied: string[] = [];
await mkdir(pluginDir, { recursive: true });
const requiredArtifacts = ["main.js", "manifest.json"];
for (const artifact of requiredArtifacts) {
const source = resolve(rootDir, artifact);
if (!existsSync(source)) {
throw new Error(`Required plug-in artifact is missing: ${source}`);
}
await copyFile(source, join(pluginDir, artifact));
copied.push(artifact);
}
const optionalArtifacts = ["styles.css"];
for (const artifact of optionalArtifacts) {
const source = resolve(rootDir, artifact);
if (!existsSync(source)) {
continue;
}
await copyFile(source, join(pluginDir, artifact));
copied.push(artifact);
}
await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4));
return { pluginDir, copied };
return await installGenericBuiltPlugin(vaultPath, {
pluginId: "obsidian-livesync",
artifactRoot: rootDir,
});
}
+1 -41
View File
@@ -1,41 +1 @@
import { evalObsidianJson } from "./cli.ts";
export type PluginReadiness = {
status: "ready";
pluginId: string;
pluginVersion: string;
vaultName: string;
};
export async function waitForPluginReady(
cliBinary: string,
env: NodeJS.ProcessEnv,
timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000)
): Promise<PluginReadiness> {
const deadline = Date.now() + timeoutMs;
let lastOutput = "";
while (Date.now() < deadline) {
try {
const readiness = await evalObsidianJson<PluginReadiness>(
cliBinary,
[
"(async()=>JSON.stringify({",
"status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',",
"pluginId:'obsidian-livesync',",
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,",
"vaultName:app.vault.getName()",
"}))()",
].join(""),
env
);
if (readiness.status === "ready") {
return readiness;
}
} catch (error) {
lastOutput = error instanceof Error ? error.message : String(error);
// Keep polling until Obsidian exposes the vault-side CLI and plug-in state.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`);
}
export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session";
@@ -0,0 +1,76 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ensurePinnedReleaseArtifact,
type PinnedPluginRelease,
} from "./releaseArtifact.ts";
const temporaryDirectories: string[] = [];
function sha256(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
function fixtureRelease(contents: Record<"main.js" | "manifest.json" | "styles.css", string>): PinnedPluginRelease {
return {
pluginId: "fixture-plugin",
version: "1.2.3",
files: (Object.keys(contents) as Array<keyof typeof contents>).map((name) => ({
name,
url: `https://example.invalid/${name}`,
sha256: sha256(contents[name]),
})),
};
}
afterEach(async () => {
for (const path of temporaryDirectories.splice(0)) {
await rm(path, { recursive: true, force: true });
}
});
describe("pinned plug-in release artefacts", () => {
it("downloads, verifies, and reuses an immutable release cache", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-release-artifact-"));
temporaryDirectories.push(root);
const contents = {
"main.js": "console.log('fixture');\n",
"manifest.json": '{"id":"fixture-plugin","version":"1.2.3"}\n',
"styles.css": ".fixture {}\n",
};
const release = fixtureRelease(contents);
const fetchImplementation = vi.fn(async (input: string | URL | Request) => {
const name = new URL(String(input)).pathname.split("/").pop() as keyof typeof contents;
return new Response(contents[name], { status: 200 });
}) as unknown as typeof fetch;
await expect(
ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation })
).resolves.toBe(root);
await expect(readFile(join(root, "main.js"), "utf8")).resolves.toBe(contents["main.js"]);
expect(fetchImplementation).toHaveBeenCalledTimes(3);
await ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation });
expect(fetchImplementation).toHaveBeenCalledTimes(3);
});
it("rejects a downloaded file before it enters the release cache when its checksum differs", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-release-artifact-"));
temporaryDirectories.push(root);
const contents = {
"main.js": "expected\n",
"manifest.json": '{"id":"fixture-plugin","version":"1.2.3"}\n',
"styles.css": ".fixture {}\n",
};
const release = fixtureRelease(contents);
const fetchImplementation = vi.fn(async () => new Response("tampered\n", { status: 200 })) as unknown as typeof fetch;
await expect(
ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation })
).rejects.toThrow("checksum mismatch");
await expect(readFile(join(root, "main.js"))).rejects.toMatchObject({ code: "ENOENT" });
});
});
+128
View File
@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
export type PinnedReleaseArtifactFile = {
name: "main.js" | "manifest.json" | "styles.css";
url: string;
sha256: string;
};
export type PinnedPluginRelease = {
pluginId: string;
version: string;
files: readonly PinnedReleaseArtifactFile[];
};
export type EnsurePinnedReleaseArtifactOptions = {
artifactRoot?: string;
fetchImplementation?: typeof fetch;
};
export const UPGRADE_SOURCE_RELEASE: PinnedPluginRelease = {
pluginId: "obsidian-livesync",
version: "0.25.83",
files: [
{
name: "main.js",
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/main.js",
sha256: "5e57f990635ab0cf2ff3879f3c6cb91ddfdbc146958d33d1e5d21f1869dff6a4",
},
{
name: "manifest.json",
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/manifest.json",
sha256: "4944f5665c94bcbb58db0e3708ec2bd8ee36118791271c01d085668876dc8ba6",
},
{
name: "styles.css",
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/styles.css",
sha256: "37d31798186d7e97ea979e6d2aae8021ea1ac1df2c3b9d2b03dce269959c27f3",
},
],
};
function digest(content: Uint8Array<ArrayBuffer>): string {
return createHash("sha256").update(content).digest("hex");
}
function assertDigest(file: PinnedReleaseArtifactFile, content: Uint8Array<ArrayBuffer>): void {
const actual = digest(content);
if (actual !== file.sha256) {
throw new Error(
`Release artefact checksum mismatch for ${file.name}. Expected ${file.sha256}, received ${actual}.`
);
}
}
async function readCachedFile(
path: string,
file: PinnedReleaseArtifactFile
): Promise<Uint8Array<ArrayBuffer> | undefined> {
try {
const content = new Uint8Array(await readFile(path));
assertDigest(file, content);
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}
async function downloadVerifiedFile(
root: string,
file: PinnedReleaseArtifactFile,
fetchImplementation: typeof fetch
): Promise<Uint8Array<ArrayBuffer>> {
const path = join(root, file.name);
const cached = await readCachedFile(path, file);
if (cached) return cached;
const response = await fetchImplementation(file.url, { redirect: "follow" });
if (!response.ok) {
throw new Error(`Could not download ${file.url}. HTTP ${response.status}: ${await response.text()}`);
}
const content = new Uint8Array(await response.arrayBuffer());
assertDigest(file, content);
const temporaryPath = `${path}.download-${process.pid}-${Date.now()}`;
try {
await writeFile(temporaryPath, content, { flag: "wx" });
await rename(temporaryPath, path);
} finally {
await rm(temporaryPath, { force: true });
}
return content;
}
/**
* Materialise one immutable published plug-in release in the ignored E2E cache.
*
* Existing files are always verified before use. A mismatched cache is left in
* place for inspection and must be removed explicitly by the operator.
*/
export async function ensurePinnedReleaseArtifact(
release: PinnedPluginRelease = UPGRADE_SOURCE_RELEASE,
options: EnsurePinnedReleaseArtifactOptions = {}
): Promise<string> {
const root = resolve(
options.artifactRoot ??
process.env.E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT?.trim() ??
join("_testdata", "releases", release.pluginId, release.version)
);
await mkdir(root, { recursive: true });
const fetched = new Map<string, Uint8Array<ArrayBuffer>>();
for (const file of release.files) {
fetched.set(file.name, await downloadVerifiedFile(root, file, options.fetchImplementation ?? fetch));
}
const manifestBytes = fetched.get("manifest.json");
if (!manifestBytes) throw new Error("The pinned release does not define manifest.json.");
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as { id?: unknown; version?: unknown };
if (manifest.id !== release.pluginId || manifest.version !== release.version) {
throw new Error(
`Release manifest identity mismatch. Expected ${release.pluginId}@${release.version}, received ${String(manifest.id)}@${String(manifest.version)}.`
);
}
return root;
}
+237
View File
@@ -0,0 +1,237 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Page } from "playwright";
import { withObsidianPage } from "./ui.ts";
import {
REMOTE_OPERATION_ACTIVITY_ICON,
REMOTE_REQUEST_ACTIVITY_ICON,
} from "../../../src/modules/features/RemoteActivityStatus.ts";
export const REMOTE_ACTIVITY_E2E_STATE_KEY = "__livesyncE2ERemoteActivity";
export const REMOTE_ACTIVITY_GATE_KIND = {
chunkFetch: "chunk-fetch",
oneShot: "one-shot",
trackedRequest: "tracked-request",
} as const;
export const REMOTE_ACTIVITY_EXPECTED_STATE = {
chunkFetchActive: "chunk-fetch-active",
finiteReplicationActive: "finite-replication-active",
idle: "idle",
trackedRequestActive: "tracked-request-active",
} as const;
export type RemoteActivityGateKind = (typeof REMOTE_ACTIVITY_GATE_KIND)[keyof typeof REMOTE_ACTIVITY_GATE_KIND];
export type RemoteActivitySnapshot = {
boundedRemoteActivityCount: number;
finiteReplicationActivityCount: number;
gateDone?: boolean;
gateEntered?: boolean;
gateError?: string;
gateKind?: RemoteActivityGateKind;
requestCount: number;
remoteOperationIndicatorVisible: boolean;
remoteRequestIndicatorVisible: boolean;
responseCount: number;
statusBarFound: boolean;
statusBarText: string;
};
export type ExpectedRemoteActivityState =
(typeof REMOTE_ACTIVITY_EXPECTED_STATE)[keyof typeof REMOTE_ACTIVITY_EXPECTED_STATE];
type RuntimeCounter = { value?: number };
type RuntimeCore = {
services?: {
API?: {
requestCount?: RuntimeCounter;
responseCount?: RuntimeCounter;
};
replicator?: {
boundedRemoteActivityCount?: RuntimeCounter;
finiteReplicationActivityCount?: RuntimeCounter;
};
};
};
type RuntimeGate = {
done?: boolean;
entered?: boolean;
error?: string;
kind?: RemoteActivityGateKind;
};
type RendererGlobals = typeof globalThis & {
app?: {
plugins?: {
plugins?: Record<string, { core?: RuntimeCore }>;
};
};
};
async function readRemoteActivitySnapshotFromPage(page: Page): Promise<RemoteActivitySnapshot> {
return await page.evaluate(
({ operationIcon, pluginId, requestIcon, stateKey }) => {
const globals = globalThis as RendererGlobals;
const core = globals.app?.plugins?.plugins?.[pluginId]?.core;
if (!core) throw new Error(`Obsidian plug-in is not loaded: ${pluginId}`);
const gate = (globalThis as unknown as Record<string, RuntimeGate | undefined>)[stateKey];
const statusBars = Array.from(document.querySelectorAll<HTMLElement>(".syncstatusbar"));
return {
boundedRemoteActivityCount: Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1),
finiteReplicationActivityCount: Number(
core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1
),
gateDone: gate?.done,
gateEntered: gate?.entered,
gateError: gate?.error,
gateKind: gate?.kind,
requestCount: Number(core.services?.API?.requestCount?.value ?? -1),
remoteOperationIndicatorVisible: statusBars.some((element) =>
(element.textContent ?? "").includes(operationIcon)
),
remoteRequestIndicatorVisible: statusBars.some((element) =>
(element.textContent ?? "").includes(requestIcon)
),
responseCount: Number(core.services?.API?.responseCount?.value ?? -1),
statusBarFound: statusBars.length > 0,
statusBarText: statusBars.map((element) => element.textContent ?? "").join("\n"),
} satisfies RemoteActivitySnapshot;
},
{
operationIcon: REMOTE_OPERATION_ACTIVITY_ICON,
pluginId: "obsidian-livesync",
requestIcon: REMOTE_REQUEST_ACTIVITY_ICON,
stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY,
}
);
}
export async function readRemoteActivitySnapshot(port: number): Promise<RemoteActivitySnapshot> {
return await withObsidianPage(port, async (page) => await readRemoteActivitySnapshotFromPage(page));
}
function formatWaitFailure(
expected: ExpectedRemoteActivityState,
snapshot: RemoteActivitySnapshot | undefined,
error: unknown
): Error {
return new Error(
[
`Timed out waiting for remote activity state: ${expected}`,
snapshot ? `Last snapshot: ${JSON.stringify(snapshot)}` : undefined,
error instanceof Error ? error.message : String(error),
]
.filter((line): line is string => line !== undefined)
.join("\n")
);
}
export async function waitForRemoteActivityState(
port: number,
expected: ExpectedRemoteActivityState,
timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000)
): Promise<RemoteActivitySnapshot> {
try {
return await withObsidianPage(port, async (page) => {
await page.waitForFunction(
({ expectedState, expectedStates, gateKinds, operationIcon, pluginId, requestIcon, stateKey }) => {
const globals = globalThis as RendererGlobals;
const core = globals.app?.plugins?.plugins?.[pluginId]?.core;
if (!core) return false;
const gate = (globalThis as unknown as Record<string, RuntimeGate | undefined>)[stateKey];
const statusBarText = Array.from(document.querySelectorAll<HTMLElement>(".syncstatusbar"))
.map((element) => element.textContent ?? "")
.join("\n");
const bounded = Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1);
const finite = Number(core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1);
const requests = Number(core.services?.API?.requestCount?.value ?? -1);
const responses = Number(core.services?.API?.responseCount?.value ?? -1);
const operationIconVisible = statusBarText.includes(operationIcon);
const requestIconVisible = statusBarText.includes(requestIcon);
const trackedRequests = Math.max(0, requests - responses);
if (expectedState === expectedStates.finiteReplicationActive) {
return (
gate?.kind === gateKinds.oneShot &&
gate.entered === true &&
bounded > 0 &&
finite > 0 &&
operationIconVisible &&
!requestIconVisible &&
trackedRequests === 0
);
}
if (expectedState === expectedStates.chunkFetchActive) {
return (
gate?.kind === gateKinds.chunkFetch &&
gate.entered === true &&
bounded > 0 &&
finite === 0 &&
operationIconVisible &&
!requestIconVisible &&
trackedRequests === 0
);
}
if (expectedState === expectedStates.trackedRequestActive) {
return (
gate?.kind === gateKinds.trackedRequest &&
gate.entered === true &&
bounded === 0 &&
finite === 0 &&
trackedRequests > 0 &&
!operationIconVisible &&
statusBarText.includes(`${requestIcon}${trackedRequests}`)
);
}
return (
bounded === 0 &&
finite === 0 &&
requests === responses &&
!operationIconVisible &&
!requestIconVisible
);
},
{
expectedState: expected,
expectedStates: REMOTE_ACTIVITY_EXPECTED_STATE,
gateKinds: REMOTE_ACTIVITY_GATE_KIND,
operationIcon: REMOTE_OPERATION_ACTIVITY_ICON,
pluginId: "obsidian-livesync",
requestIcon: REMOTE_REQUEST_ACTIVITY_ICON,
stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY,
},
{ timeout: timeoutMs }
);
return await readRemoteActivitySnapshotFromPage(page);
});
} catch (error) {
const snapshot = await readRemoteActivitySnapshot(port).catch(() => undefined);
throw formatWaitFailure(expected, snapshot, error);
}
}
export type RemoteActivityDiagnostics = {
screenshotPath: string;
snapshot: RemoteActivitySnapshot;
snapshotPath: string;
};
export async function captureRemoteActivityDiagnostics(
port: number,
label: string
): Promise<RemoteActivityDiagnostics> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(outputDirectory, { recursive: true });
const safeLabel = label.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "remote-activity";
const prefix = `${safeLabel}-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const screenshotPath = join(outputDirectory, `${prefix}.png`);
const snapshotPath = join(outputDirectory, `${prefix}.json`);
const snapshot = await withObsidianPage(port, async (page) => {
const current = await readRemoteActivitySnapshotFromPage(page);
await page.screenshot({ path: screenshotPath, fullPage: true });
return current;
});
await writeFile(snapshotPath, `${JSON.stringify(snapshot, undefined, 2)}\n`, "utf8");
return { screenshotPath, snapshot, snapshotPath };
}
@@ -0,0 +1,236 @@
import { evalObsidianJson } from "./cli.ts";
import {
REMOTE_ACTIVITY_E2E_STATE_KEY,
REMOTE_ACTIVITY_GATE_KIND,
type RemoteActivityGateKind,
} from "./remoteActivity.ts";
export type HeldRemoteActivityResult = {
done: boolean;
entered: boolean;
error?: string;
kind: RemoteActivityGateKind;
requestedIds?: string[];
result?: boolean;
resultCount?: number;
};
const stateKeySource = JSON.stringify(REMOTE_ACTIVITY_E2E_STATE_KEY);
export async function startHeldOneShotReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<{ started: boolean }>(
cliBinary,
[
"(async()=>{",
`const stateKey=${stateKeySource};`,
"const host=globalThis;",
"if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"if(!replicator) throw new Error('No active replicator is available.');",
"const original=replicator.openReplication;",
"let releaseGate;",
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
`const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.oneShot)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`,
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
"state.restore=()=>{replicator.openReplication=original;};",
"host[stateKey]=state;",
"replicator.openReplication=async function(...args){",
"state.entered=true;",
"await gate;",
"return await original.apply(this,args);",
"};",
"state.promise=(async()=>{",
"try{",
"if(!(await core.services.fileProcessing.commitPendingFileEvents())) throw new Error('Pending file events could not be committed.');",
"state.result=!!(await core.services.replication.replicate(true));",
"}catch(error){",
"state.error=error instanceof Error?error.message:String(error);",
"}finally{",
"state.restore();",
"state.done=true;",
"}",
"})();",
"return JSON.stringify({started:true});",
"})()",
].join(""),
env
);
}
export async function startHeldChunkFetch(cliBinary: string, env: NodeJS.ProcessEnv, chunkId: string): Promise<void> {
await evalObsidianJson<{ started: boolean }>(
cliBinary,
[
"(async()=>{",
`const stateKey=${stateKeySource};`,
`const chunkId=${JSON.stringify(chunkId)};`,
"const host=globalThis;",
"if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"if(!replicator) throw new Error('No active replicator is available.');",
"const localDb=core.localDatabase.localDatabase;",
"const existing=await localDb.get(chunkId).catch(()=>undefined);",
"if(existing&&!existing._deleted) throw new Error(`The remote-only chunk already exists locally: ${chunkId}`);",
"const original=replicator.fetchRemoteChunks;",
"let releaseGate;",
"let resolveDone;",
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
"const donePromise=new Promise((resolve)=>{resolveDone=resolve;});",
`const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.chunkFetch)},entered:false,done:false,released:false,error:undefined,resultCount:undefined,requestedIds:undefined,promise:donePromise,release:undefined,restore:undefined};`,
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
"state.restore=()=>{replicator.fetchRemoteChunks=original;};",
"host[stateKey]=state;",
"replicator.fetchRemoteChunks=async function(...args){",
"state.entered=true;",
"state.requestedIds=Array.isArray(args[0])?[...args[0]]:[];",
"await gate;",
"try{",
"const result=await original.apply(this,args);",
"state.resultCount=Array.isArray(result)?result.length:0;",
"return result;",
"}catch(error){",
"state.error=error instanceof Error?error.message:String(error);",
"throw error;",
"}finally{",
"state.restore();",
"state.done=true;",
"resolveDone();",
"}",
"};",
"core.localDatabase.managers.chunkFetcher.onEvent([chunkId]);",
"return JSON.stringify({started:true});",
"})()",
].join(""),
env
);
}
export async function startHeldTrackedRequest(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<{ started: boolean }>(
cliBinary,
[
"(async()=>{",
`const stateKey=${stateKeySource};`,
"const host=globalThis;",
"if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const remote=core.services.remote;",
"const api=core.services.API;",
"const settings=core.services.setting.currentSettings();",
"const original=api.webCompatFetch;",
"let releaseGate;",
"const gate=new Promise((resolve)=>{releaseGate=resolve;});",
`const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.trackedRequest)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`,
"state.release=()=>{if(!state.released){state.released=true;releaseGate();}};",
"state.restore=()=>{api.webCompatFetch=original;};",
"host[stateKey]=state;",
"api.webCompatFetch=async function(...args){",
"state.entered=true;",
"await gate;",
"return await original.apply(this,args);",
"};",
"state.promise=(async()=>{",
"try{",
"const base=String(settings.couchDB_URI).replace(/\\/$/,'');",
"const database=encodeURIComponent(settings.couchDB_DBNAME);",
"const credentials=btoa(`${settings.couchDB_USER}:${settings.couchDB_PASSWORD}`);",
"const response=await remote.performFetch(`${base}/${database}/_all_docs?limit=0`,{headers:{Authorization:`Basic ${credentials}`}});",
"state.result=response.ok;",
"}catch(error){",
"state.error=error instanceof Error?error.message:String(error);",
"}finally{",
"state.restore();",
"state.done=true;",
"}",
"})();",
"return JSON.stringify({started:true});",
"})()",
].join(""),
env
);
}
export async function finishHeldRemoteActivity(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<HeldRemoteActivityResult> {
return await evalObsidianJson<HeldRemoteActivityResult>(
cliBinary,
[
"(async()=>{",
`const stateKey=${stateKeySource};`,
"const state=globalThis[stateKey];",
"if(!state) throw new Error('No remote activity E2E gate is installed.');",
"state.release();",
"await state.promise;",
"return JSON.stringify({kind:state.kind,entered:state.entered,done:state.done,error:state.error,result:state.result,resultCount:state.resultCount,requestedIds:state.requestedIds});",
"})()",
].join(""),
env
);
}
export async function waitForRestoredChunk(
cliBinary: string,
env: NodeJS.ProcessEnv,
chunkId: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000)
): Promise<{ id: string; type: string }> {
return await evalObsidianJson<{ id: string; type: string }>(
cliBinary,
[
"(async()=>{",
`const chunkId=${JSON.stringify(chunkId)};`,
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const localDb=core.localDatabase.localDatabase;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
"const chunk=await localDb.get(chunkId).catch(()=>undefined);",
"if(chunk&&!chunk._deleted&&chunk.type==='leaf'&&typeof chunk.data==='string') return JSON.stringify({id:chunk._id,type:chunk.type});",
"await sleep(100);",
"}",
"throw new Error(`Timed out waiting for the fetched chunk to return: ${chunkId}`);",
"})()",
].join(""),
env
);
}
export async function clearHeldRemoteActivity(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<{ cleared: boolean }>(
cliBinary,
[
"(async()=>{",
`const stateKey=${stateKeySource};`,
"const state=globalThis[stateKey];",
"if(!state) return JSON.stringify({cleared:false});",
"if(!state.done) throw new Error('The remote activity E2E gate is still running.');",
"delete globalThis[stateKey];",
"return JSON.stringify({cleared:true});",
"})()",
].join(""),
env
);
}
export async function cleanUpHeldRemoteActivity(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<{ cleared: boolean }>(
cliBinary,
[
"(async()=>{",
`const stateKey=${stateKeySource};`,
"const state=globalThis[stateKey];",
"if(!state) return JSON.stringify({cleared:false});",
"state.release?.();",
"await Promise.race([Promise.resolve(state.promise),new Promise((resolve)=>setTimeout(resolve,5000))]);",
"state.restore?.();",
"delete globalThis[stateKey];",
"return JSON.stringify({cleared:true});",
"})()",
].join(""),
env
);
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
SECURITY_SEED_DOCUMENT_ID,
changedSynchronisationParameterFields,
fingerprintSecuritySeed,
replaceSecuritySeed,
requireSecuritySeedDocument,
snapshotSecuritySeedDocument,
} from "./securitySeed.ts";
const seedA = Buffer.alloc(32, 1).toString("base64");
const seedB = Buffer.alloc(32, 2).toString("base64");
describe("Security Seed E2E evidence", () => {
it("reports stable, non-secret fingerprints", () => {
expect(fingerprintSecuritySeed(seedA)).toMatch(/^sha256:[0-9a-f]{16}$/u);
expect(fingerprintSecuritySeed(seedA)).toBe(fingerprintSecuritySeed(seedA));
expect(fingerprintSecuritySeed(seedA)).not.toBe(fingerprintSecuritySeed(seedB));
});
it("redacts the Seed from the machine-readable document snapshot", () => {
const document = requireSecuritySeedDocument({
_id: SECURITY_SEED_DOCUMENT_ID,
_rev: "0-1",
type: "syncinfo",
protocolVersion: 2,
pbkdf2salt: seedA,
});
const snapshot = snapshotSecuritySeedDocument(document);
expect(snapshot).toEqual({
id: SECURITY_SEED_DOCUMENT_ID,
revision: "0-1",
fingerprint: fingerprintSecuritySeed(seedA),
fields: {
type: "syncinfo",
protocolVersion: 2,
},
});
expect(JSON.stringify(snapshot)).not.toContain(seedA);
});
it("replaces only the Seed and identifies later synchronisation-parameter changes", () => {
const before = requireSecuritySeedDocument({
_id: SECURITY_SEED_DOCUMENT_ID,
_rev: "0-1",
type: "syncinfo",
protocolVersion: 2,
pbkdf2salt: seedA,
});
const replaced = replaceSecuritySeed(before, seedB);
const laterRevision = {
...replaced,
_rev: "0-3",
};
expect(before.pbkdf2salt).toBe(seedA);
expect(replaced.pbkdf2salt).toBe(seedB);
expect(changedSynchronisationParameterFields(before, replaced)).toEqual(["pbkdf2salt"]);
expect(changedSynchronisationParameterFields(replaced, laterRevision)).toEqual([]);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Supplies the runner-owned CouchDB fixture operations for the Security Seed
* reconnect scenario. Production code remains responsible for fetching,
* caching, and applying the Seed; this helper only validates the managed
* synchronisation-parameter document, replaces the one intended field, and
* compares document snapshots.
*
* Callers expose only short SHA-256 fingerprints. The original and replacement
* Seed values must stay inside the isolated test process and must not be
* written to diagnostics, screenshots, or machine-readable results.
*/
import { createHash, randomBytes } from "node:crypto";
import type { CouchDbDocument } from "./couchdb.ts";
export const SECURITY_SEED_DOCUMENT_ID = "_local/obsidian_livesync_sync_parameters";
export type SecuritySeedDocument = CouchDbDocument & {
_id: typeof SECURITY_SEED_DOCUMENT_ID;
_rev: string;
pbkdf2salt: string;
};
export type SecuritySeedDocumentSnapshot = {
id: string;
revision: string;
fingerprint: string;
fields: Record<string, unknown>;
};
function decodeSecuritySeed(seed: string): Buffer {
const bytes = Buffer.from(seed, "base64");
if (seed.length === 0 || bytes.length === 0) {
throw new Error("The Security Seed is empty or is not valid base64.");
}
return bytes;
}
export function createSecuritySeed(): string {
return randomBytes(32).toString("base64");
}
export function fingerprintSecuritySeed(seed: string): string {
const bytes = Uint8Array.from(decodeSecuritySeed(seed));
return `sha256:${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`;
}
export function requireSecuritySeedDocument(document: CouchDbDocument): SecuritySeedDocument {
if (document._id !== SECURITY_SEED_DOCUMENT_ID) {
throw new Error(`Unexpected synchronisation-parameter document: ${document._id}`);
}
if (typeof document._rev !== "string" || document._rev.length === 0) {
throw new Error("The synchronisation-parameter document does not have a revision.");
}
if (typeof document.pbkdf2salt !== "string") {
throw new Error("The synchronisation-parameter document does not have a Security Seed.");
}
decodeSecuritySeed(document.pbkdf2salt);
return document as SecuritySeedDocument;
}
export function replaceSecuritySeed(document: SecuritySeedDocument, replacementSeed: string): SecuritySeedDocument {
decodeSecuritySeed(replacementSeed);
return {
...document,
pbkdf2salt: replacementSeed,
};
}
export function snapshotSecuritySeedDocument(document: SecuritySeedDocument): SecuritySeedDocumentSnapshot {
const { _id, _rev, pbkdf2salt, ...fields } = document;
return {
id: _id,
revision: _rev,
fingerprint: fingerprintSecuritySeed(pbkdf2salt),
fields,
};
}
export function changedSynchronisationParameterFields(
before: SecuritySeedDocument,
after: SecuritySeedDocument
): string[] {
const ignoredFields = new Set(["_rev"]);
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
.filter((key) => !ignoredFields.has(key))
.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
.sort();
}
+87
View File
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { startObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
import {
startObsidianLiveSyncSession,
type StartObsidianLiveSyncSessionOptions,
} from "./session.ts";
vi.mock("@vrtmrz/obsidian-test-session", () => ({
startObsidianPluginSession: vi.fn(async () => ({
app: {},
cliEnv: {},
install: {},
readiness: {},
pluginId: "obsidian-livesync",
remoteDebuggingPort: 28052,
})),
}));
describe("LiveSync real-Obsidian session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("installs an explicitly selected plug-in artefact while retaining the supplied Vault and profile", async () => {
const vault = {
path: "/tmp/upgrade-vault",
statePath: "/tmp/upgrade-state",
name: "upgrade-vault",
id: "upgrade-vault-id",
homePath: "/tmp/upgrade-state/home",
xdgConfigPath: "/tmp/upgrade-state/xdg-config",
xdgCachePath: "/tmp/upgrade-state/xdg-cache",
xdgDataPath: "/tmp/upgrade-state/xdg-data",
userDataPath: "/tmp/upgrade-state/user-data",
processMarker: "/tmp/upgrade-state",
dispose: vi.fn(async () => undefined),
};
const options: StartObsidianLiveSyncSessionOptions & { artifactRoot: string } = {
binary: "/Applications/Obsidian",
cliBinary: "obsidian-cli",
vault,
artifactRoot: "/tmp/obsidian-livesync-0.25.83",
};
await startObsidianLiveSyncSession(options);
expect(startObsidianPluginSession).toHaveBeenCalledWith(
expect.objectContaining({
artifactRoot: options.artifactRoot,
pluginId: "obsidian-livesync",
vault,
})
);
});
it("forwards instance-scoped lifecycle hooks and the selected plug-in start mode", async () => {
const beforePluginStart = vi.fn(async () => undefined);
const vault = {
path: "/tmp/mobile-vault",
statePath: "/tmp/mobile-state",
name: "mobile-vault",
id: "mobile-vault-id",
homePath: "/tmp/mobile-state/home",
xdgConfigPath: "/tmp/mobile-state/xdg-config",
xdgCachePath: "/tmp/mobile-state/xdg-cache",
xdgDataPath: "/tmp/mobile-state/xdg-data",
userDataPath: "/tmp/mobile-state/user-data",
processMarker: "/tmp/mobile-state",
dispose: vi.fn(async () => undefined),
};
await startObsidianLiveSyncSession({
binary: "/Applications/Obsidian",
cliBinary: "obsidian-cli",
vault,
pluginStartup: "controlled",
lifecycle: { beforePluginStart },
});
expect(startObsidianPluginSession).toHaveBeenCalledWith(
expect.objectContaining({
lifecycle: { beforePluginStart },
pluginStartup: "controlled",
})
);
});
});
+23 -102
View File
@@ -1,119 +1,40 @@
import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts";
import { launchObsidian, type ObsidianProcess } from "./launch.ts";
import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts";
import { waitForPluginReady, type PluginReadiness } from "./readiness.ts";
import {
startObsidianPluginSession,
type ObsidianPluginSession,
type ObsidianPluginSessionLifecycle,
type ObsidianPluginStartupMode,
} from "@vrtmrz/obsidian-test-session";
import type { TemporaryVault } from "./vault.ts";
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts";
export type ObsidianLiveSyncSession = {
app: ObsidianProcess;
cliEnv: NodeJS.ProcessEnv;
install: PluginInstallResult;
readiness: PluginReadiness;
};
export type ObsidianLiveSyncSession = ObsidianPluginSession;
export type StartObsidianLiveSyncSessionOptions = {
binary: string;
cliBinary: string;
vault: TemporaryVault;
artifactRoot?: string;
startupGraceMs?: number;
pluginData?: Record<string, unknown>;
localStorageEntries?: Readonly<Record<string, string>>;
pluginStartup?: ObsidianPluginStartupMode;
lifecycle?: ObsidianPluginSessionLifecycle;
env?: NodeJS.ProcessEnv;
};
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000);
let lastOutput = "";
while (Date.now() < deadline) {
try {
const result = await evalObsidianJson<{ hasLiveSync: boolean }>(
cliBinary,
["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""),
env
);
lastOutput = JSON.stringify(result);
if (result.hasLiveSync) {
return;
}
} catch (error) {
lastOutput = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`);
}
async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env);
if (result.code !== 0 || result.stdout.includes("Error:")) {
throw new Error(
[
`Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`,
result.stdout ? `stdout:\n${result.stdout}` : undefined,
result.stderr ? `stderr:\n${result.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
async function reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env);
if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) {
throw new Error(
[
`Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`,
reload.stdout ? `stdout:\n${reload.stdout}` : undefined,
reload.stderr ? `stderr:\n${reload.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
export async function startObsidianLiveSyncSession(
options: StartObsidianLiveSyncSessionOptions
): Promise<ObsidianLiveSyncSession> {
const install = await installBuiltPlugin(options.vault.path);
const remoteDebuggingPort = obsidianRemoteDebuggingPort();
const app = await launchObsidian({
return await startObsidianPluginSession({
binary: options.binary,
vaultPath: options.vault.path,
homePath: options.vault.homePath,
xdgConfigPath: options.vault.xdgConfigPath,
xdgCachePath: options.vault.xdgCachePath,
xdgDataPath: options.vault.xdgDataPath,
userDataPath: options.vault.userDataPath,
cliBinary: options.cliBinary,
vault: options.vault,
pluginId: "obsidian-livesync",
artifactRoot: options.artifactRoot ?? process.cwd(),
startupGraceMs: options.startupGraceMs,
pluginData: options.pluginData,
localStorageEntries: options.localStorageEntries,
pluginStartup: options.pluginStartup,
lifecycle: options.lifecycle,
env: options.env,
});
const cliEnv = {
...process.env,
HOME: options.vault.homePath,
XDG_CONFIG_HOME: options.vault.xdgConfigPath,
XDG_CACHE_HOME: options.vault.xdgCachePath,
XDG_DATA_HOME: options.vault.xdgDataPath,
};
try {
await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id);
await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv);
await trustVaultIfPrompted(remoteDebuggingPort);
await waitForPluginCatalogue(options.cliBinary, cliEnv);
await enableCommunityPlugins(options.cliBinary, cliEnv);
await reloadLiveSyncPlugin(options.cliBinary, cliEnv);
const readiness = await waitForPluginReady(options.cliBinary, cliEnv);
return { app, cliEnv, install, readiness };
} catch (error) {
const output = app.output();
await app.stop();
throw new Error(
[
error instanceof Error ? error.message : String(error),
output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined,
output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined,
]
.filter(Boolean)
.join("\n")
);
}
}
+439
View File
@@ -0,0 +1,439 @@
import type { Locator, Page } from "playwright";
import { evalObsidianJson } from "./cli.ts";
import { captureObsidianDialogue, captureObsidianElement, withObsidianPage } from "./ui.ts";
export type SetupArtifact = {
setupURI: string;
setupPassphrase: string;
};
export type SetupState = {
configured: boolean;
databaseReady: boolean;
appReady: boolean;
suspended: boolean;
remoteType: string;
activeConfigurationId: string;
remoteConfigurationCount: number;
endpoint: string;
bucket: string;
bucketPrefix: string;
p2pEnabled: boolean;
p2pRelays: string;
p2pRoomId: string;
};
export type SetupCaptureNames = {
scenario: string;
guide: string;
};
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000);
export function modalByTitle(page: Page, title: string): Locator {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: title }),
});
}
export async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first());
}
export async function assertVerticalActionLayout(port: number, title: string): Promise<void> {
await withObsidianPage(port, async (page) => {
const actions = modalByTitle(page, title).locator(".vpk-action-dialog__actions").first();
await actions.waitFor({ state: "visible", timeout: uiTimeoutMs });
const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection);
if (flexDirection !== "column") {
throw new Error(`Expected vertically stacked actions in '${title}', received '${flexDirection}'.`);
}
});
}
export 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 });
}
export 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 });
}
export async function enterSetupURI(
port: number,
mode: "new" | "existing",
artifact: SetupArtifact,
captures: SetupCaptureNames
): Promise<string> {
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);
});
const screenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-${mode === "new" ? "first" : "second"}-setup-uri.png`,
"Enter Setup URI"
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "Enter Setup URI")
.getByRole("button", { name: "Test Settings and Continue" })
.click({ timeout: uiTimeoutMs });
});
return screenshot;
}
export async function generateSetupURIFromDevice(
port: number,
setupPassphrase: string,
captures: SetupCaptureNames
): Promise<{ artifact: SetupArtifact; screenshots: string[] }> {
const opened = await withObsidianPage(port, async (page) => {
return await page.evaluate(
(commandId) =>
(
globalThis as typeof globalThis & {
app?: { commands?: { executeCommandById(id: string): boolean } };
}
).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:livesync-copysetupuri"
);
});
if (!opened) throw new Error("The command for generating a Setup URI was not registered.");
const promptTitle = "Encrypt your settings";
await withObsidianPage(port, async (page) => {
const prompt = modalByTitle(page, promptTitle);
await prompt.waitFor({ state: "visible", timeout: uiTimeoutMs });
await prompt.locator('input[type="password"]').fill(setupPassphrase);
});
const promptScreenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-copy-setup-uri-passphrase.png`,
promptTitle
);
await withObsidianPage(port, async (page) => {
const prompt = modalByTitle(page, promptTitle);
await prompt.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
await prompt.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const resultTitle = "Your Setup URI is ready to be copied";
const setupURI = await withObsidianPage(port, async (page) => {
const result = modalByTitle(page, resultTitle);
await result.waitFor({ state: "visible", timeout: uiTimeoutMs });
return await result.locator("textarea[readonly]").inputValue();
});
if (!setupURI.startsWith("obsidian://setuplivesync?settings=")) {
throw new Error("The first device did not generate a valid Setup URI.");
}
const resultScreenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-copy-setup-uri-result.png`,
resultTitle
);
await withObsidianPage(port, async (page) => {
const result = modalByTitle(page, resultTitle);
await result.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs });
await result.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return {
artifact: { setupURI, setupPassphrase },
screenshots: [promptScreenshot, resultScreenshot],
};
}
export async function captureAndStartInitialisation(
port: number,
mode: "new" | "existing",
captures: SetupCaptureNames
): Promise<string> {
const p2pFirstDevice = mode === "new" && captures.guide === "p2p-setup";
const p2pAdditionalDevice = mode === "existing" && captures.guide === "p2p-setup";
const title = p2pFirstDevice
? "Setup Complete: Preparing This P2P Device"
: p2pAdditionalDevice
? "Setup Complete: Preparing to Fetch from Another Device"
: mode === "new"
? "Setup Complete: Preparing to Initialise Server"
: "Setup Complete: Preparing to Fetch Synchronisation Data";
const button = p2pFirstDevice
? "Restart and Prepare This Device"
: p2pAdditionalDevice
? "Restart and Select Source Device"
: mode === "new"
? "Restart and Initialise Server"
: "Restart and Fetch Data";
if (p2pAdditionalDevice) {
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await modal
.getByText("After restarting, select an online source device for the initial Fetch.", {
exact: false,
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByText("downloaded from the server", { exact: false }).count()) !== 0) {
throw new Error("P2P additional-device setup still describes the initial Fetch as a server download.");
}
});
}
const screenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
title
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs });
});
return screenshot;
}
export async function confirmRebuild(port: number, captures: SetupCaptureNames): Promise<string> {
const isP2P = captures.guide === "p2p-setup";
const title = isP2P
? "Final Confirmation: Prepare This Device for P2P"
: "Final Confirmation: Overwrite Server Data with This Device's Files";
const screenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-first-rebuild-confirmation.png`,
title
);
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
if (isP2P) {
await selectCheckbox(
modal,
"I understand that this resets only this device's local synchronisation database."
);
await selectRadioOption(modal, "I understand the risks and will proceed without a backup.");
await modal
.getByRole("button", { name: "I Understand, Prepare This Device" })
.click({ timeout: uiTimeoutMs });
return;
}
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;
}
export async function skipMissingRemoteConfiguration(port: number, captures: SetupCaptureNames): Promise<string> {
const title = "Fetch Remote Configuration Failed";
const screenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-missing-remote-configuration.png`,
title
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, title)
.getByRole("button", { name: "Skip and proceed" })
.click({ timeout: uiTimeoutMs });
});
return screenshot;
}
export async function acknowledgeDisabledOptionalFeatures(port: number, captures: SetupCaptureNames): Promise<string> {
const title = "All optional features are disabled";
const screenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-optional-features-disabled.png`,
title
);
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await modal.getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return screenshot;
}
export async function confirmFastFetch(port: number, captures: SetupCaptureNames): Promise<string[]> {
const firstTitle = "Data retrieval scheduled";
await assertVerticalActionLayout(port, firstTitle);
const firstScreenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-retrieval-method.png`,
firstTitle
);
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?";
await assertVerticalActionLayout(port, secondTitle);
const secondScreenshot = await captureGuideDialogue(
port,
`guide-${captures.guide}-local-file-policy.png`,
secondTitle
);
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
);
}
export 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,",
"endpoint:settings.endpoint||'',",
"bucket:settings.bucket||'',",
"bucketPrefix:settings.bucketPrefix||'',",
"p2pEnabled:settings.P2P_Enabled===true,",
"p2pRelays:settings.P2P_relays||'',",
"p2pRoomId:settings.P2P_roomID||'',",
"});",
"})()",
].join(""),
environment
);
}
export 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}` : ""
}`
);
}
export async function finishInitialisation(
port: number,
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<SetupState> {
const message = "Do you want to resume file and database processing, and restart obsidian now?";
const deadline = Date.now() + initialisationTimeoutMs;
let readySince: number | undefined;
while (Date.now() < deadline) {
const resumeVisible = await withObsidianPage(port, async (page) => {
return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible();
}).catch(() => false);
if (resumeVisible) {
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, "Confirmation").filter({ hasText: message });
await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return await waitForConfiguredSetup(cliBinary, environment);
}
try {
const state = await readSetupState(cliBinary, environment);
if (isConfiguredSetupReady(state)) {
readySince ??= Date.now();
if (Date.now() - readySince >= 1000) return state;
} else {
readySince = undefined;
}
} catch {
// Obsidian may be reloading while the scheduled operation runs.
readySince = undefined;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error("Timed out waiting for Setup URI initialisation to finish.");
}
export async function resumeCompatibilityReviewIfShown(port: number): Promise<boolean> {
const title = "Synchronisation paused for compatibility review";
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
let available = false;
while (Date.now() < deadline && !available) {
available = await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
if (await modal.isVisible()) return true;
const reminder = page.locator(".notice.livesync-compatibility-review-notice");
if (!(await reminder.isVisible())) return false;
await reminder.getByRole("link", { name: "Review why" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
return true;
}).catch(() => false);
if (!available) await new Promise((resolve) => setTimeout(resolve, 200));
}
if (!available) return false;
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await modal.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return true;
}
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
events: [] as string[],
sessions: [] as Array<{ app: { stop: ReturnType<typeof vi.fn> } }>,
vaultCount: 0,
}));
vi.mock("./cli.ts", () => ({
evalObsidianJson: vi.fn(async () => ({ ok: true })),
}));
vi.mock("./couchdb.ts", () => ({
assertCouchDbReachable: vi.fn(async () => undefined),
createCouchDbDatabase: vi.fn(async () => undefined),
deleteCouchDbDatabase: vi.fn(async () => undefined),
loadCouchDbConfig: vi.fn(async () => ({
uri: "http://localhost:5984",
username: "admin",
password: "password",
dbPrefix: "e2e",
})),
makeUniqueDatabaseName: vi.fn((_prefix: string, suffix: string) => suffix),
waitForCouchDbDocs: vi.fn(async () => undefined),
}));
vi.mock("./environment.ts", () => ({
discoverObsidianCli: vi.fn(() => ({ binary: "obsidian-cli", checked: [] })),
requireObsidianBinary: vi.fn(() => "Obsidian"),
}));
vi.mock("./pathAssertions.ts", () => ({
waitForExactCaseOnlyRename: vi.fn(async () => undefined),
}));
vi.mock("./liveSyncWorkflow.ts", () => ({
assertEqual: vi.fn(),
assertE2eCompatibilityMarker: vi.fn(async () => undefined),
assertE2eCompatibilityReviewPending: vi.fn(async () => undefined),
configureCouchDb: vi.fn(async () => undefined),
createE2eCouchDbPluginData: vi.fn(() => ({})),
prepareRemote: vi.fn(async () => undefined),
pushLocalChanges: vi.fn(async () => {
throw new Error("simulated Obsidian CLI timeout");
}),
resumeCompatibilityReview: vi.fn(async () => undefined),
waitForLiveSyncCoreReady: vi.fn(async () => undefined),
waitForLocalDatabaseEntry: vi.fn(async () => ({ id: "note-id", children: [] })),
}));
vi.mock("./session.ts", () => ({
startObsidianLiveSyncSession: vi.fn(async () => {
const session = {
app: {
stop: vi.fn(async () => {
state.events.push("session:stop");
}),
},
cliEnv: {},
remoteDebuggingPort: 28052,
};
state.sessions.push(session);
return session;
}),
}));
vi.mock("./vault.ts", () => ({
createTemporaryVault: vi.fn(async () => {
state.vaultCount += 1;
const name = `vault-${state.vaultCount}`;
return {
name,
path: `/tmp/${name}`,
dispose: vi.fn(async () => {
state.events.push(`${name}:dispose`);
}),
};
}),
}));
describe("two-vault runner lifecycle", () => {
beforeEach(() => {
vi.resetModules();
state.events.length = 0;
state.sessions.length = 0;
state.vaultCount = 0;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("stops the active Obsidian session before disposing temporary Vaults when synchronisation fails", async () => {
let resolveExit!: (code: number) => void;
const exitCode = new Promise<number>((resolve) => {
resolveExit = resolve;
});
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(process, "exit").mockImplementation((code) => {
resolveExit(Number(code));
return undefined as never;
});
await import("../scripts/two-vault-sync.ts");
expect(await exitCode).toBe(1);
expect(state.sessions).toHaveLength(1);
expect(state.sessions[0].app.stop).toHaveBeenCalledOnce();
expect(state.events.indexOf("session:stop")).toBeLessThan(state.events.indexOf("vault-1:dispose"));
});
});
+72 -60
View File
@@ -1,73 +1,85 @@
import { chromium, type Page } from "playwright";
import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
import type { Locator, Page } from "playwright";
export function obsidianRemoteDebuggingPort(): number {
const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222);
process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port);
return port;
}
export {
obsidianRemoteDebuggingPort,
preseedTrustedVaultState,
trustVaultIfPrompted,
withObsidianPage,
} from "@vrtmrz/obsidian-test-session";
async function waitForCdp(port: number): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
export async function captureObsidianPage(
port: number,
filename: string,
assertReady: (page: Page) => Promise<void>
): Promise<string> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshotPath = join(outputDirectory, filename);
await mkdir(dirname(screenshotPath), { recursive: true });
await withObsidianPage(port, async (page) => {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
if (response.ok) {
return;
}
} catch {
// Keep polling until Obsidian exposes the debugging endpoint.
await assertReady(page);
} catch (error) {
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
await page.screenshot({ path: failurePath, fullPage: true });
console.error(`UI failure screenshot: ${failurePath}`);
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`);
}
export async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
await waitForCdp(port);
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
try {
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 }));
return await operation(page);
} finally {
await browser.close();
}
}
export async function preseedTrustedVaultState(port: number, vaultId: string): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate((id) => {
localStorage.setItem(`enable-plugin-${id}`, "true");
}, vaultId);
await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined);
await page.waitForTimeout(1000);
await page.screenshot({ path: screenshotPath, fullPage: true });
});
return screenshotPath;
}
export async function trustVaultIfPrompted(port: number): Promise<void> {
export async function captureObsidianDialogue(
port: number,
filename: string,
assertReady: (page: Page) => Promise<void>
): Promise<string> {
return await captureObsidianPage(port, filename, assertReady);
}
export async function captureObsidianElement(
port: number,
filename: string,
resolveElement: (page: Page) => Locator | Promise<Locator>
): Promise<string> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshotPath = join(outputDirectory, filename);
await mkdir(dirname(screenshotPath), { recursive: true });
await withObsidianPage(port, async (page) => {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
const yesButton = page.getByRole("button", { name: "Yes" });
if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await yesButton.click();
await page.waitForTimeout(500);
continue;
}
const trustButton = page.getByText("Trust author and enable plugins");
if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await trustButton.click();
await page.waitForTimeout(500);
continue;
}
const workspace = page.locator(".workspace");
if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) {
return;
}
try {
const element = await resolveElement(page);
await element.waitFor({ state: "visible", timeout: 10000 });
await element.screenshot({
path: screenshotPath,
animations: "disabled",
style: ".notice-container { visibility: hidden !important; }",
});
} catch (error) {
const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png");
await page.screenshot({ path: failurePath, fullPage: true });
console.error(`UI element failure screenshot: ${failurePath}`);
throw error;
}
});
return screenshotPath;
}
export async function captureJsonResolveDialogue(port: number): Promise<string> {
return await captureObsidianDialogue(port, "hidden-file-json-resolve-dialogue.png", async (page) => {
const optionAB = page.locator('label:has(input[name="disp"][value="AB"])');
const optionBA = page.locator('label:has(input[name="disp"][value="BA"])');
const applyButton = page.getByRole("button", { name: "Apply" });
await optionAB.waitFor({ state: "visible", timeout: 10000 });
await optionBA.waitFor({ state: "visible", timeout: 10000 });
await applyButton.waitFor({ state: "visible", timeout: 10000 });
});
}
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
assertCouchDbCheckpointContinuity,
assertJournalCheckpointLoaded,
assertNoJournalReplay,
type JournalCheckpointSnapshot,
} from "./upgradeContinuity.ts";
const journalCheckpoint: JournalCheckpointSnapshot = {
remoteKey: "remote-a",
lastLocalSeq: 42,
journalEpoch: "2:salt",
knownIDs: ["known-a"],
sentIDs: ["sent-a"],
receivedFiles: ["100-docs.jsonl.gz"],
sentFiles: ["101-docs.jsonl.gz"],
};
describe("upgrade synchronisation continuity assertions", () => {
it("rejects a fresh CouchDB checkpoint lineage even when final documents could still converge", () => {
expect(() =>
assertCouchDbCheckpointContinuity(
[{ id: "_local/original", lastSequence: 42 }],
[{ id: "_local/replacement", lastSequence: 42 }]
)
).toThrow("checkpoint identity changed");
});
it("rejects an Object Storage checkpoint which was reset to its initial state", () => {
expect(() =>
assertJournalCheckpointLoaded(journalCheckpoint, {
remoteKey: journalCheckpoint.remoteKey,
lastLocalSeq: 0,
journalEpoch: "",
knownIDs: [],
sentIDs: [],
receivedFiles: [],
sentFiles: [],
})
).toThrow(/lastLocalSeq regressed|history was lost/u);
});
it("rejects hidden Object Storage replay during an otherwise unchanged sync", () => {
expect(() =>
assertNoJournalReplay(
journalCheckpoint,
journalCheckpoint,
[{ key: "101-docs.jsonl.gz", size: 10, etag: "etag" }],
[{ key: "101-docs.jsonl.gz", size: 10, etag: "etag" }],
{ downloadedJournalKeys: ["101-docs.jsonl.gz"], uploadedJournalKeys: [] }
)
).toThrow("downloaded previously processed journals");
});
});
@@ -0,0 +1,199 @@
export type CouchDbCheckpointSnapshot = {
id: string;
lastSequence: unknown;
};
export type CouchDbDocumentRevision = {
id: string;
revision: string;
deleted: boolean;
};
export type JournalCheckpointSnapshot = {
remoteKey: string;
lastLocalSeq: number | string;
journalEpoch: string;
knownIDs: readonly string[];
sentIDs: readonly string[];
receivedFiles: readonly string[];
sentFiles: readonly string[];
};
export type JournalIoObservation = {
downloadedJournalKeys: readonly string[];
uploadedJournalKeys: readonly string[];
};
export type RemoteObjectSnapshot = {
key: string;
size: number;
etag: string;
};
export type MilestoneIdentity = {
created: unknown;
locked: boolean;
acceptedNodes: readonly string[];
};
function sorted(values: readonly string[]): string[] {
return [...values].sort((left, right) => left.localeCompare(right));
}
function assertEqualStrings(actual: readonly string[], expected: readonly string[], message: string): void {
const actualSorted = sorted(actual);
const expectedSorted = sorted(expected);
if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) {
throw new Error(`${message}\nExpected: ${JSON.stringify(expectedSorted)}\nActual: ${JSON.stringify(actualSorted)}`);
}
}
function assertSubset(previous: readonly string[], current: readonly string[], message: string): void {
const currentSet = new Set(current);
const missing = previous.filter((value) => !currentSet.has(value));
if (missing.length > 0) throw new Error(`${message}: ${missing.join(", ")}`);
}
function sequenceNumber(sequence: unknown): number | undefined {
if (typeof sequence === "number" && Number.isFinite(sequence)) return sequence;
if (typeof sequence !== "string") return undefined;
const match = /^(\d+)/u.exec(sequence);
return match ? Number(match[1]) : undefined;
}
function assertSequenceDidNotRegress(before: unknown, after: unknown, label: string): void {
const beforeNumber = sequenceNumber(before);
const afterNumber = sequenceNumber(after);
if (beforeNumber !== undefined && afterNumber !== undefined) {
if (afterNumber < beforeNumber) {
throw new Error(`${label} regressed from ${String(before)} to ${String(after)}.`);
}
return;
}
if (before !== after) {
throw new Error(`${label} changed from an opaque sequence ${String(before)} to ${String(after)}.`);
}
}
export function assertCouchDbCheckpointContinuity(
before: readonly CouchDbCheckpointSnapshot[],
after: readonly CouchDbCheckpointSnapshot[]
): void {
if (before.length === 0) throw new Error("The stable release did not create a CouchDB replication checkpoint.");
assertEqualStrings(
after.map(({ id }) => id),
before.map(({ id }) => id),
"The CouchDB replication checkpoint identity changed during the upgrade."
);
const afterById = new Map(after.map((checkpoint) => [checkpoint.id, checkpoint]));
for (const checkpoint of before) {
assertSequenceDidNotRegress(
checkpoint.lastSequence,
afterById.get(checkpoint.id)?.lastSequence,
`CouchDB checkpoint ${checkpoint.id}`
);
}
}
export function assertSomeCouchDbCheckpointAdvanced(
before: readonly CouchDbCheckpointSnapshot[],
after: readonly CouchDbCheckpointSnapshot[]
): void {
assertCouchDbCheckpointContinuity(before, after);
const afterById = new Map(after.map((checkpoint) => [checkpoint.id, checkpoint]));
const advanced = before.some((checkpoint) => {
const previous = sequenceNumber(checkpoint.lastSequence);
const current = sequenceNumber(afterById.get(checkpoint.id)?.lastSequence);
return previous !== undefined && current !== undefined && current > previous;
});
if (!advanced) throw new Error("No CouchDB replication checkpoint advanced after the post-upgrade change.");
}
export function assertCouchDbDocumentsUnchanged(
before: readonly CouchDbDocumentRevision[],
after: readonly CouchDbDocumentRevision[]
): void {
const serialise = (documents: readonly CouchDbDocumentRevision[]) =>
[...documents].sort((left, right) => left.id.localeCompare(right.id));
if (JSON.stringify(serialise(before)) !== JSON.stringify(serialise(after))) {
throw new Error("A no-op post-upgrade CouchDB synchronisation changed ordinary remote documents.");
}
}
export function assertJournalCheckpointLoaded(
before: JournalCheckpointSnapshot,
after: JournalCheckpointSnapshot
): void {
if (sequenceNumber(before.lastLocalSeq) === 0) {
throw new Error("The stable release did not advance the Object Storage local checkpoint.");
}
if (after.remoteKey !== before.remoteKey) {
throw new Error(`The Object Storage checkpoint key changed from ${before.remoteKey} to ${after.remoteKey}.`);
}
assertSequenceDidNotRegress(before.lastLocalSeq, after.lastLocalSeq, "Object Storage lastLocalSeq");
assertSubset(before.knownIDs, after.knownIDs, "Object Storage known revision history was lost");
assertSubset(before.sentIDs, after.sentIDs, "Object Storage sent revision history was lost");
assertSubset(before.receivedFiles, after.receivedFiles, "Object Storage received journal history was lost");
assertSubset(before.sentFiles, after.sentFiles, "Object Storage sent journal history was lost");
if (before.journalEpoch && after.journalEpoch !== before.journalEpoch) {
throw new Error(
`The Object Storage journal epoch changed from ${before.journalEpoch} to ${after.journalEpoch}.`
);
}
}
export function assertNoJournalReplay(
beforeCheckpoint: JournalCheckpointSnapshot,
afterCheckpoint: JournalCheckpointSnapshot,
beforeObjects: readonly RemoteObjectSnapshot[],
afterObjects: readonly RemoteObjectSnapshot[],
observation: JournalIoObservation
): void {
assertJournalCheckpointLoaded(beforeCheckpoint, afterCheckpoint);
assertEqualStrings(
afterObjects.map(({ key }) => key),
beforeObjects.map(({ key }) => key),
"A no-op post-upgrade Object Storage synchronisation changed the journal object set."
);
if (observation.downloadedJournalKeys.length > 0) {
throw new Error(
`The no-op synchronisation downloaded previously processed journals: ${observation.downloadedJournalKeys.join(", ")}`
);
}
if (observation.uploadedJournalKeys.length > 0) {
throw new Error(
`The no-op synchronisation uploaded replay journals: ${observation.uploadedJournalKeys.join(", ")}`
);
}
}
export function assertJournalCheckpointAdvanced(
before: JournalCheckpointSnapshot,
after: JournalCheckpointSnapshot,
observation: JournalIoObservation
): void {
assertJournalCheckpointLoaded(before, after);
const beforeSequence = sequenceNumber(before.lastLocalSeq);
const afterSequence = sequenceNumber(after.lastLocalSeq);
if (beforeSequence === undefined || afterSequence === undefined || afterSequence <= beforeSequence) {
throw new Error(
`The Object Storage checkpoint did not advance after the post-upgrade change (${String(before.lastLocalSeq)} -> ${String(after.lastLocalSeq)}).`
);
}
if (observation.uploadedJournalKeys.length === 0) {
throw new Error("The post-upgrade Object Storage change did not create a new journal.");
}
}
export function assertMilestoneContinuity(before: MilestoneIdentity, after: MilestoneIdentity): void {
if (before.created === undefined || before.created === null) {
throw new Error("The stable release milestone does not expose a remote generation identity.");
}
if (after.created !== before.created) {
throw new Error(`The remote milestone generation changed from ${String(before.created)} to ${String(after.created)}.`);
}
if (after.locked !== before.locked) {
throw new Error(`The remote milestone lock changed from ${String(before.locked)} to ${String(after.locked)}.`);
}
assertSubset(before.acceptedNodes, after.acceptedNodes, "The remote milestone lost an accepted device");
}
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { evalObsidianJson } = vi.hoisted(() => ({
evalObsidianJson: vi.fn(),
}));
vi.mock("./cli.ts", () => ({ evalObsidianJson }));
import { prepareStableRemote } from "./upgradeWorkflow.ts";
describe("stable remote preparation", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
it("waits for a readable Security Seed before marking the remote as resolved", async () => {
vi.stubEnv("E2E_OBSIDIAN_REMOTE_READY_INTERVAL_MS", "0");
vi.stubEnv("E2E_OBSIDIAN_REMOTE_READY_TIMEOUT_MS", "100");
evalObsidianJson
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce({ ok: true });
await prepareStableRemote("obsidian-cli", {});
expect(evalObsidianJson).toHaveBeenCalledTimes(4);
const scripts = evalObsidianJson.mock.calls.map(([, script]) => String(script));
expect(scripts[0]).toContain("tryCreateRemoteDatabase");
expect(scripts[0]).not.toContain("markRemoteResolved");
expect(scripts[1]).toContain("ensurePBKDF2Salt");
expect(scripts[2]).toContain("ensurePBKDF2Salt");
expect(scripts[3]).toContain("markRemoteResolved");
});
});
+874
View File
@@ -0,0 +1,874 @@
import { mkdir, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { evalObsidianJson } from "./cli.ts";
import type { CouchDbConfig } from "./couchdb.ts";
import type { ObjectStorageConfig } from "./objectStorage.ts";
import { withObsidianPage } from "./ui.ts";
import type {
CouchDbCheckpointSnapshot,
JournalCheckpointSnapshot,
JournalIoObservation,
} from "./upgradeContinuity.ts";
import { waitForLocalDatabaseEntry } from "./liveSyncWorkflow.ts";
import type { TemporaryVault } from "./vault.ts";
export const STABLE_RELEASE_VERSION = "0.25.83";
export type UpgradeTransportConfiguration =
| {
kind: "couchdb";
config: CouchDbConfig;
databaseName: string;
}
| {
kind: "object-storage";
config: ObjectStorageConfig;
bucketPrefix: string;
};
export type UpgradeScenarioPaths = {
original: string;
renamed: string;
deleted: string;
postUpgrade: string;
returnFromVerifier: string;
};
export type RuntimeUpgradeState = {
pluginVersion: string;
vaultName: string;
localDatabaseName: string;
localDatabaseUpdateSequence: number | string;
localDatabaseDocumentCount: number;
nodeId: string;
legacyCompatibilityMarker: string | null;
compatibilityMarker: string;
compatibilityStorageEntries: Record<string, string>;
migrationState?: {
sourceVersion: number;
targetVersion: number;
isNewVault: boolean;
isFromFutureSchema: boolean;
changed: boolean;
requiresSyncReview: boolean;
reviewReasons: Array<{ code: string; fromVersion: number; toVersion: number }>;
};
settings: {
isConfigured: boolean | undefined;
settingVersion: number;
versionUpFlash: string;
liveSync: boolean;
syncOnStart: boolean;
syncOnSave: boolean;
syncOnEditorSave: boolean;
syncOnFileOpen: boolean;
syncAfterMerge: boolean;
periodicReplication: boolean;
encrypt: boolean;
usePathObfuscation: boolean;
syncInternalFiles: boolean;
customChunkSize: number;
usePluginSyncV2: boolean;
enableCompression: boolean;
useEden: boolean;
filenameCaseType: string;
handleFilenameCaseSensitive?: boolean;
doNotUseFixedRevisionForChunks: boolean;
chunkSplitterVersion: string;
E2EEAlgorithm: string;
additionalSuffixOfDatabaseName: string;
remoteType: string;
couchDB_DBNAME: string;
endpoint: string;
bucket: string;
bucketPrefix: string;
activeConfigurationId: string;
remoteConfigurationIds: string[];
doctorProcessedVersion: string;
};
};
export type RuntimeSettingsUpgradeState = Pick<RuntimeUpgradeState, "pluginVersion" | "migrationState" | "settings"> & {
compatibilityMarker: string;
};
export type CouchDbReplicationObservation = {
succeeded: boolean;
sentDocuments: number;
arrivedDocuments: number;
};
export type JournalReplicationObservation = JournalIoObservation & {
succeeded: boolean;
};
const firstContent = "# Stable release history\n\nCreated before the 1.0 upgrade.\n";
const editedContent = "# Stable release history\n\nEdited and renamed before the 1.0 upgrade.\n";
const deletedContent = "# Deleted before upgrade\n\nThis note must not be resurrected.\n";
const postUpgradeContent = "# Post-upgrade delta\n\nCreated by the upgraded 1.0 device.\n";
const returnContent = "# Return journey\n\nCreated by a fresh 1.0 verifier device.\n";
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function assertStringArraysEqual(actual: readonly string[], expected: readonly string[], message: string): void {
const actualSorted = [...actual].sort();
const expectedSorted = [...expected].sort();
if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) {
throw new Error(
`${message}\nExpected: ${JSON.stringify(expectedSorted)}\nActual: ${JSON.stringify(actualSorted)}`
);
}
}
export function createUpgradeScenarioPaths(label: string): UpgradeScenarioPaths {
const root = `E2E/upgrade-from-${STABLE_RELEASE_VERSION}/${label}`;
return {
original: `${root}/rename-source.md`,
renamed: `${root}/renamed.md`,
deleted: `${root}/deleted.md`,
postUpgrade: `${root}/post-upgrade.md`,
returnFromVerifier: `${root}/return-from-verifier.md`,
};
}
function remoteSettings(configuration: UpgradeTransportConfiguration): Record<string, unknown> {
if (configuration.kind === "couchdb") {
return {
remoteType: "",
couchDB_URI: configuration.config.uri,
couchDB_USER: configuration.config.username,
couchDB_PASSWORD: configuration.config.password,
couchDB_DBNAME: configuration.databaseName,
isConfigured: true,
};
}
return {
remoteType: "MINIO",
endpoint: configuration.config.endpoint,
accessKey: configuration.config.accessKey,
secretKey: configuration.config.secretKey,
bucket: configuration.config.bucket,
region: configuration.config.region,
forcePathStyle: configuration.config.forcePathStyle,
bucketPrefix: configuration.bucketPrefix,
bucketCustomHeaders: "",
isConfigured: true,
};
}
export async function configureStableRelease(
cliBinary: string,
environment: NodeJS.ProcessEnv,
configuration: UpgradeTransportConfiguration
): Promise<void> {
const partial = remoteSettings(configuration);
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`const partial=${JSON.stringify(partial)};`,
"await core.services.setting.applyExternalSettings(partial,true);",
"await core.services.control.applySettings();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
export async function prepareStableRemote(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const replicator=core.services.replicator.getActiveReplicator();",
"await replicator.tryCreateRemoteDatabase(settings);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_READY_TIMEOUT_MS ?? 15000);
const intervalMs = Number(process.env.E2E_OBSIDIAN_REMOTE_READY_INTERVAL_MS ?? 250);
const deadline = Date.now() + timeoutMs;
let securitySeedReady = false;
do {
securitySeedReady = await evalObsidianJson<boolean>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const replicator=core.services.replicator.getActiveReplicator();",
"return JSON.stringify(!!(await replicator.ensurePBKDF2Salt(settings,true,false)));",
"})()",
].join(""),
environment
);
if (securitySeedReady) break;
if (Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, intervalMs));
} while (Date.now() < deadline);
if (!securitySeedReady) {
throw new Error(`Timed out waiting for the stable release Security Seed after ${timeoutMs}ms.`);
}
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const replicator=core.services.replicator.getActiveReplicator();",
"await replicator.markRemoteResolved(settings);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
export async function waitForPersistentNodeIdentity(
cliBinary: string,
environment: NodeJS.ProcessEnv,
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
): Promise<string> {
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const database=core.localDatabase.localDatabase;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let persistent='';let active='';",
"while(Date.now()<deadline){",
"const nodeInfo=await database.get('_local/obsydian_livesync_nodeinfo').catch(()=>null);",
"persistent=typeof nodeInfo?.nodeid==='string'?nodeInfo.nodeid:'';",
"active=core.services.replicator.getActiveReplicator()?.nodeid??'';",
"if(persistent!==''&&active===persistent) return JSON.stringify(persistent);",
"await sleep(100);",
"}",
"throw new Error(`Timed out waiting for persistent node identity: persistent=${persistent}, active=${active}`);",
"})()",
].join(""),
environment
);
}
export async function readRuntimeUpgradeState(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<RuntimeUpgradeState> {
return await evalObsidianJson<RuntimeUpgradeState>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const core=plugin.core;",
"const setting=core.services.setting;",
"const settings=setting.currentSettings();",
"const vaultName=core.services.vault.getVaultName();",
"const replicator=core.services.replicator.getActiveReplicator();",
"const databaseInfo=await core.localDatabase.localDatabase.info();",
"const nodeInfo=await core.localDatabase.localDatabase.get('_local/obsydian_livesync_nodeinfo').catch(()=>null);",
"const migrationState=setting.getSettingsMigrationState?.();",
"return JSON.stringify({",
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
"vaultName,",
"localDatabaseName:databaseInfo.db_name,",
"localDatabaseUpdateSequence:databaseInfo.update_seq,",
"localDatabaseDocumentCount:databaseInfo.doc_count,",
"nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',",
"legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),",
"compatibilityMarker: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'))",
".map(key=>[key,localStorage.getItem(key)??''])),",
"migrationState,",
"settings:{",
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
"versionUpFlash:settings.versionUpFlash,",
"liveSync:settings.liveSync,syncOnStart:settings.syncOnStart,syncOnSave:settings.syncOnSave,",
"syncOnEditorSave:settings.syncOnEditorSave,syncOnFileOpen:settings.syncOnFileOpen,",
"syncAfterMerge:settings.syncAfterMerge,periodicReplication:settings.periodicReplication,",
"encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,",
"syncInternalFiles:settings.syncInternalFiles,customChunkSize:settings.customChunkSize,",
"usePluginSyncV2:settings.usePluginSyncV2,enableCompression:settings.enableCompression,",
"useEden:settings.useEden,filenameCaseType:typeof settings.handleFilenameCaseSensitive,",
"handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,",
"doNotUseFixedRevisionForChunks:settings.doNotUseFixedRevisionForChunks,",
"chunkSplitterVersion:settings.chunkSplitterVersion,E2EEAlgorithm:settings.E2EEAlgorithm,",
"additionalSuffixOfDatabaseName:settings.additionalSuffixOfDatabaseName??'',",
"remoteType:settings.remoteType,couchDB_DBNAME:settings.couchDB_DBNAME??'',",
"endpoint:settings.endpoint??'',bucket:settings.bucket??'',bucketPrefix:settings.bucketPrefix??'',",
"activeConfigurationId:settings.activeConfigurationId??'',",
"remoteConfigurationIds:Object.keys(settings.remoteConfigurations??{}),",
"doctorProcessedVersion:settings.doctorProcessedVersion??'',",
"}",
"});",
"})()",
].join(""),
environment
);
}
export async function readRuntimeSettingsUpgradeState(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<RuntimeSettingsUpgradeState> {
return await evalObsidianJson<RuntimeSettingsUpgradeState>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const setting=plugin.core.services.setting;",
"const settings=setting.currentSettings();",
"const migrationState=setting.getSettingsMigrationState?.();",
"return JSON.stringify({",
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
"compatibilityMarker:setting.getSmallConfig?.('database-compatibility-version')??'',",
"migrationState,",
"settings:{",
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
"versionUpFlash:settings.versionUpFlash,",
"liveSync:settings.liveSync,syncOnStart:settings.syncOnStart,syncOnSave:settings.syncOnSave,",
"syncOnEditorSave:settings.syncOnEditorSave,syncOnFileOpen:settings.syncOnFileOpen,",
"syncAfterMerge:settings.syncAfterMerge,periodicReplication:settings.periodicReplication,",
"encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,",
"syncInternalFiles:settings.syncInternalFiles,customChunkSize:settings.customChunkSize,",
"usePluginSyncV2:settings.usePluginSyncV2,enableCompression:settings.enableCompression,",
"useEden:settings.useEden,filenameCaseType:typeof settings.handleFilenameCaseSensitive,",
"handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,",
"doNotUseFixedRevisionForChunks:settings.doNotUseFixedRevisionForChunks,",
"chunkSplitterVersion:settings.chunkSplitterVersion,E2EEAlgorithm:settings.E2EEAlgorithm,",
"additionalSuffixOfDatabaseName:settings.additionalSuffixOfDatabaseName??'',",
"remoteType:settings.remoteType,couchDB_DBNAME:settings.couchDB_DBNAME??'',",
"endpoint:settings.endpoint??'',bucket:settings.bucket??'',bucketPrefix:settings.bucketPrefix??'',",
"activeConfigurationId:settings.activeConfigurationId??'',",
"remoteConfigurationIds:Object.keys(settings.remoteConfigurations??{}),",
"doctorProcessedVersion:settings.doctorProcessedVersion??'',",
"}",
"});",
"})()",
].join(""),
environment
);
}
export function assertStableReleaseDefaults(state: RuntimeSettingsUpgradeState, configured: boolean): void {
assertEqual(
state.pluginVersion,
STABLE_RELEASE_VERSION,
"The source session did not load the pinned stable release."
);
assertEqual(state.settings.isConfigured, configured, "The stable release configuration lifecycle was unexpected.");
assertEqual(state.settings.settingVersion, 10, "The stable release settings schema was not version 10.");
assertEqual(state.settings.liveSync, false, "The stable release LiveSync default changed.");
assertEqual(state.settings.syncOnStart, false, "The stable release sync-on-start default changed.");
assertEqual(state.settings.syncOnSave, false, "The stable release sync-on-save default changed.");
assertEqual(state.settings.syncOnEditorSave, false, "The stable release editor-save default changed.");
assertEqual(state.settings.syncOnFileOpen, false, "The stable release file-open default changed.");
assertEqual(state.settings.syncAfterMerge, false, "The stable release post-merge default changed.");
assertEqual(state.settings.periodicReplication, false, "The stable release periodic default changed.");
assertEqual(state.settings.encrypt, false, "The stable release encryption default changed.");
assertEqual(state.settings.usePathObfuscation, false, "The stable release path-obfuscation default changed.");
assertEqual(state.settings.syncInternalFiles, false, "The stable release Hidden File default changed.");
assertEqual(state.settings.customChunkSize, 0, "The stable release custom chunk default changed.");
assertEqual(state.settings.usePluginSyncV2, false, "The stable release Customisation Sync V2 default changed.");
assertEqual(state.settings.enableCompression, false, "The stable release compression default changed.");
assertEqual(state.settings.useEden, false, "The stable release Eden default changed.");
assertEqual(
state.settings.filenameCaseType,
"undefined",
"The stable release filename-case decision was preselected."
);
assertEqual(
state.settings.doNotUseFixedRevisionForChunks,
true,
"The stable release fixed-revision compatibility value changed."
);
assertEqual(state.settings.chunkSplitterVersion, "v3-rabin-karp", "The stable release chunk splitter changed.");
assertEqual(state.settings.E2EEAlgorithm, "v2", "The stable release E2EE algorithm changed.");
assertEqual(
state.settings.remoteConfigurationIds.length,
configured ? 1 : 0,
"The stable release remote-profile count was unexpected."
);
}
export function assertUnconfiguredUpgradeReady(
stable: RuntimeSettingsUpgradeState,
upgraded: RuntimeSettingsUpgradeState,
targetVersion: string
): void {
assertStableReleaseDefaults(stable, false);
assertEqual(upgraded.pluginVersion, targetVersion, "The unconfigured Vault did not load the target artefact.");
assertEqual(upgraded.settings.isConfigured, false, "The upgrade changed an unconfigured Vault to configured.");
assertEqual(
upgraded.settings.usePluginSyncV2,
stable.settings.usePluginSyncV2,
"The upgrade applied a new-Vault recommendation to a non-empty legacy store."
);
assertEqual(
upgraded.settings.handleFilenameCaseSensitive,
false,
"The unconfigured legacy Vault did not retain case-insensitive handling."
);
assertEqual(upgraded.settings.versionUpFlash, "", "The unconfigured Vault was paused for compatibility review.");
assertEqual(
upgraded.compatibilityMarker,
"",
"The unconfigured Vault acknowledged database compatibility before activation."
);
if (!upgraded.migrationState) throw new Error("The unconfigured settings migration state was not available.");
assertEqual(upgraded.migrationState.isNewVault, false, "The non-empty legacy store was treated as a new store.");
// The real-session helper deliberately reloads an already enabled plug-in.
// The first target load performs and persists the migration; the observed
// post-reload state can therefore report changed=false. The workflow reads
// data.json after stopping the session to prove the persisted values.
assertEqual(
upgraded.migrationState.requiresSyncReview,
false,
"The unconfigured legacy settings unexpectedly required compatibility review."
);
assertEqual(upgraded.migrationState.reviewReasons.length, 0, "The unconfigured migration emitted a review reason.");
}
export function assertUnconfiguredUpgradeRestarted(state: RuntimeSettingsUpgradeState, targetVersion: string): void {
assertEqual(state.pluginVersion, targetVersion, "The unconfigured restart did not load the target artefact.");
assertEqual(state.settings.isConfigured, false, "The unconfigured state was not persisted across restart.");
assertEqual(state.settings.usePluginSyncV2, false, "Restart applied a new-Vault recommendation.");
assertEqual(state.settings.handleFilenameCaseSensitive, false, "Restart lost the case-insensitive policy.");
assertEqual(
state.compatibilityMarker,
"",
"Restart acknowledged database compatibility while the Vault remained unconfigured."
);
if (!state.migrationState) throw new Error("The restarted settings migration state was not available.");
assertEqual(state.migrationState.changed, false, "The settings migration was not idempotent after restart.");
assertEqual(state.migrationState.requiresSyncReview, false, "Restart introduced a compatibility review.");
}
export function assertStableRemoteSelection(
state: RuntimeUpgradeState,
configuration: UpgradeTransportConfiguration
): void {
assertEqual(state.settings.isConfigured, true, "The stable release was not marked as configured.");
if (!state.settings.activeConfigurationId) throw new Error("The stable release did not select its remote profile.");
if (!state.settings.remoteConfigurationIds.includes(state.settings.activeConfigurationId)) {
throw new Error("The stable release active remote profile was not persisted.");
}
if (configuration.kind === "couchdb") {
assertEqual(state.settings.remoteType, "", "The stable release did not select CouchDB.");
assertEqual(
state.settings.couchDB_DBNAME,
configuration.databaseName,
"The stable release CouchDB database changed."
);
} else {
assertEqual(state.settings.remoteType, "MINIO", "The stable release did not select Object Storage.");
assertEqual(state.settings.endpoint, configuration.config.endpoint, "The Object Storage endpoint changed.");
assertEqual(state.settings.bucket, configuration.config.bucket, "The Object Storage bucket changed.");
assertEqual(state.settings.bucketPrefix, configuration.bucketPrefix, "The Object Storage prefix changed.");
}
}
export function assertUpgradeCompatibilityReady(
stable: RuntimeUpgradeState,
upgraded: RuntimeUpgradeState,
targetVersion: string,
configuration: UpgradeTransportConfiguration
): void {
assertEqual(upgraded.pluginVersion, targetVersion, "The upgraded session did not load the target artefact.");
assertEqual(
upgraded.localDatabaseName,
stable.localDatabaseName,
"The upgrade opened a different local synchronisation database."
);
if (upgraded.localDatabaseDocumentCount < stable.localDatabaseDocumentCount) {
throw new Error("The upgrade lost local synchronisation documents before its first sync.");
}
if (stable.nodeId.length === 0) {
throw new Error("The stable release did not persist a device node identity.");
}
assertEqual(upgraded.nodeId, stable.nodeId, "The upgrade changed the persistent device node identity.");
assertEqual(
upgraded.settings.additionalSuffixOfDatabaseName,
stable.settings.additionalSuffixOfDatabaseName,
"The upgrade changed the local database suffix."
);
assertStringArraysEqual(
upgraded.settings.remoteConfigurationIds,
stable.settings.remoteConfigurationIds,
"The upgrade changed the stored remote-profile identities."
);
assertEqual(
upgraded.settings.activeConfigurationId,
stable.settings.activeConfigurationId,
"The upgrade changed the active remote profile."
);
assertStableRemoteSelection(upgraded, configuration);
for (const key of [
"liveSync",
"syncOnStart",
"syncOnSave",
"syncOnEditorSave",
"syncOnFileOpen",
"syncAfterMerge",
"periodicReplication",
"customChunkSize",
"usePluginSyncV2",
"enableCompression",
"useEden",
"doNotUseFixedRevisionForChunks",
] as const) {
assertEqual(upgraded.settings[key], stable.settings[key], `The upgrade rewrote the stored ${key} preference.`);
}
if (!upgraded.migrationState) throw new Error("The 1.0 settings migration state was not available.");
// The session helper reloads the enabled target after its first load has
// persisted the normalised case value. Runtime settings below and the
// later restart prove that persisted result without depending on whether
// this observation came from the first or second load.
assertEqual(
upgraded.migrationState.requiresSyncReview,
false,
"The legacy case-insensitive setting unexpectedly required compatibility review."
);
assertEqual(upgraded.migrationState.reviewReasons.length, 0, "The settings migration emitted a spurious review.");
assertEqual(stable.legacyCompatibilityMarker, "12", "The stable release did not persist its legacy marker.");
assertEqual(upgraded.legacyCompatibilityMarker, null, "The upgrade did not retire the legacy marker.");
assertEqual(
upgraded.compatibilityMarker,
"12",
[
"The upgrade did not migrate the legacy compatibility marker.",
`Vault: ${upgraded.vaultName}`,
`Database suffix: ${upgraded.settings.additionalSuffixOfDatabaseName}`,
`Device-local entries: ${JSON.stringify(upgraded.compatibilityStorageEntries)}`,
].join("\n")
);
assertEqual(upgraded.settings.versionUpFlash, "", "Synchronisation was unexpectedly paused after migration.");
assertEqual(
upgraded.settings.handleFilenameCaseSensitive,
false,
"The missing legacy filename-case value did not preserve case-insensitive handling."
);
}
export function assertUpgradeRemainsReady(state: RuntimeUpgradeState, targetVersion: string): void {
assertEqual(state.pluginVersion, targetVersion, "The upgraded session changed target artefact.");
assertEqual(state.settings.versionUpFlash, "", "A compatibility pause reappeared.");
assertEqual(state.compatibilityMarker, "12", "The compatibility acknowledgement was not persisted.");
assertEqual(
state.settings.handleFilenameCaseSensitive,
false,
"The migrated legacy case-insensitive policy was not persisted."
);
}
export async function dismissConfigDoctorIfShown(port: number): Promise<boolean> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
return await withObsidianPage(port, async (page) => {
const doctor = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
});
const visible = await doctor
.waitFor({ state: "visible", timeout: Math.min(timeoutMs, 5000) })
.then(() => true)
.catch(() => false);
if (!visible) return false;
await doctor.getByRole("button", { name: /No, and do not ask again/u }).click();
await doctor.waitFor({ state: "hidden", timeout: timeoutMs });
return true;
});
}
async function writeNote(
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 renameNote(
cliBinary: string,
environment: NodeJS.ProcessEnv,
fromPath: string,
toPath: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const fromPath=${JSON.stringify(fromPath)};`,
`const toPath=${JSON.stringify(toPath)};`,
"const folder=toPath.split('/').slice(0,-1).join('/');",
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"const existing=app.vault.getAbstractFileByPath(fromPath);",
"if(!existing) throw new Error(`Could not find note to rename: ${fromPath}`);",
"await app.vault.rename(existing,toPath);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
async function deleteNote(cliBinary: string, environment: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const existing=app.vault.getAbstractFileByPath(path);",
"if(!existing) throw new Error(`Could not find note to delete: ${path}`);",
"await app.vault.delete(existing);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
async function waitForChangedRevision(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string,
previousRevision: string
): Promise<void> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const previousRevision=${JSON.stringify(previousRevision)};`,
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
"await core.services.fileProcessing.commitPendingFileEvents();",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);",
"if(entry&&entry._rev&&entry._rev!==previousRevision) return JSON.stringify({rev:entry._rev});",
"await sleep(250);",
"}",
"throw new Error(`Timed out waiting for a changed local revision: ${path}`);",
"})()",
].join(""),
environment
);
}
export async function runStableFileHistory(
cliBinary: string,
environment: NodeJS.ProcessEnv,
paths: UpgradeScenarioPaths,
synchronise: () => Promise<void>
): Promise<void> {
await writeNote(cliBinary, environment, paths.original, firstContent);
await writeNote(cliBinary, environment, paths.deleted, deletedContent);
const originalEntry = await waitForLocalDatabaseEntry(cliBinary, environment, paths.original);
await waitForLocalDatabaseEntry(cliBinary, environment, paths.deleted);
await synchronise();
await writeNote(cliBinary, environment, paths.original, editedContent);
await waitForChangedRevision(cliBinary, environment, paths.original, originalEntry.rev);
await synchronise();
await renameNote(cliBinary, environment, paths.original, paths.renamed);
await waitForLocalDatabaseEntry(cliBinary, environment, paths.renamed);
await synchronise();
await deleteNote(cliBinary, environment, paths.deleted);
await synchronise();
}
export async function createPostUpgradeDelta(
cliBinary: string,
environment: NodeJS.ProcessEnv,
paths: UpgradeScenarioPaths
): Promise<void> {
await writeNote(cliBinary, environment, paths.postUpgrade, postUpgradeContent);
await waitForLocalDatabaseEntry(cliBinary, environment, paths.postUpgrade);
}
export async function createVerifierReturnDelta(
cliBinary: string,
environment: NodeJS.ProcessEnv,
paths: UpgradeScenarioPaths
): Promise<void> {
await writeNote(cliBinary, environment, paths.returnFromVerifier, returnContent);
await waitForLocalDatabaseEntry(cliBinary, environment, paths.returnFromVerifier);
}
async function pathExists(vault: TemporaryVault, path: string): Promise<boolean> {
try {
await readFile(join(vault.path, path));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
async function waitForPathContent(vault: TemporaryVault, path: string, content: string): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(join(vault.path, path), "utf8");
if (lastContent === content) return;
} 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}`);
}
export async function verifyPreUpgradeHistory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise<void> {
await waitForPathContent(vault, paths.renamed, editedContent);
if (await pathExists(vault, paths.original)) throw new Error(`Renamed source was resurrected: ${paths.original}`);
if (await pathExists(vault, paths.deleted)) throw new Error(`Deleted note was resurrected: ${paths.deleted}`);
}
export async function verifyPostUpgradeHistory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise<void> {
await verifyPreUpgradeHistory(vault, paths);
await waitForPathContent(vault, paths.postUpgrade, postUpgradeContent);
}
export async function verifyReturnDelta(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise<void> {
await waitForPathContent(vault, paths.returnFromVerifier, returnContent);
}
export async function ensureScenarioDirectory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise<void> {
await mkdir(dirname(join(vault.path, paths.original)), { recursive: true });
}
export async function runCouchDbReplicationObserved(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<CouchDbReplicationObservation> {
return await evalObsidianJson<CouchDbReplicationObservation>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"await core.services.fileProcessing.commitPendingFileEvents();",
"const beforeSent=Number(replicator.docSent??0);",
"const beforeArrived=Number(replicator.docArrived??0);",
"const result=await core.services.replication.replicate(true);",
"return JSON.stringify({",
"succeeded:!!result,",
"sentDocuments:Number(replicator.docSent??0)-beforeSent,",
"arrivedDocuments:Number(replicator.docArrived??0)-beforeArrived,",
"});",
"})()",
].join(""),
environment
);
}
export async function runJournalReplicationObserved(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<JournalReplicationObservation> {
return await evalObsidianJson<JournalReplicationObservation>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"const client=replicator.client;",
"const storage=client.storage;",
"const originalDownload=storage.download.bind(storage);",
"const originalUpload=storage.upload.bind(storage);",
"const downloadedJournalKeys=[];const uploadedJournalKeys=[];",
"const isJournal=(key)=>!String(key).split('/').pop().startsWith('_');",
"storage.download=async(key,...args)=>{if(isJournal(key))downloadedJournalKeys.push(String(key));return await originalDownload(key,...args);};",
"storage.upload=async(key,...args)=>{if(isJournal(key))uploadedJournalKeys.push(String(key));return await originalUpload(key,...args);};",
"let succeeded=false;",
"try{",
"await core.services.fileProcessing.commitPendingFileEvents();",
"succeeded=!!(await core.services.replication.replicate(true));",
"}finally{storage.download=originalDownload;storage.upload=originalUpload;}",
"return JSON.stringify({succeeded,downloadedJournalKeys,uploadedJournalKeys});",
"})()",
].join(""),
environment
);
}
export async function readJournalCheckpoint(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<JournalCheckpointSnapshot> {
return await evalObsidianJson<JournalCheckpointSnapshot>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"const client=replicator.client;",
"const checkpoint=await client.getCheckpointInfo();",
"const sorted=(value)=>[...(value??[])].sort();",
"return JSON.stringify({",
"remoteKey:client.getRemoteKey(),lastLocalSeq:checkpoint.lastLocalSeq,journalEpoch:checkpoint.journalEpoch,",
"knownIDs:sorted(checkpoint.knownIDs),sentIDs:sorted(checkpoint.sentIDs),",
"receivedFiles:sorted(checkpoint.receivedFiles),sentFiles:sorted(checkpoint.sentFiles),",
"});",
"})()",
].join(""),
environment
);
}
export async function readLocalCouchDbCheckpoints(
cliBinary: string,
environment: NodeJS.ProcessEnv,
checkpointIds: readonly string[]
): Promise<CouchDbCheckpointSnapshot[]> {
return await evalObsidianJson<CouchDbCheckpointSnapshot[]>(
cliBinary,
[
"(async()=>{",
`const ids=${JSON.stringify(checkpointIds)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const database=core.localDatabase.localDatabase;",
"const checkpoints=[];",
"for(const id of ids){",
"const doc=await database.get(id).catch(()=>false);",
"if(doc&&Object.prototype.hasOwnProperty.call(doc,'last_seq')) checkpoints.push({id,lastSequence:doc.last_seq});",
"}",
"return JSON.stringify(checkpoints);",
"})()",
].join(""),
environment
);
}
+10 -90
View File
@@ -1,94 +1,14 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
createTemporaryVault as createGenericTemporaryVault,
type TemporaryVault,
} from "@vrtmrz/obsidian-test-session";
export type TemporaryVault = {
path: string;
name: string;
id: string;
homePath: string;
xdgConfigPath: string;
xdgCachePath: string;
xdgDataPath: string;
userDataPath: string;
dispose: () => Promise<void>;
};
export type { TemporaryVault };
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
const vaultPath = await mkdtemp(join(tmpdir(), prefix));
const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`));
const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e";
await mkdir(join(vaultPath, ".obsidian"), { recursive: true });
const homePath = join(statePath, "home");
const xdgConfigPath = join(statePath, "xdg-config");
const xdgCachePath = join(statePath, "xdg-cache");
const xdgDataPath = join(statePath, "xdg-data");
const userDataPath = join(statePath, "user-data");
const id = `livesync-e2e-${Date.now()}`;
await mkdir(homePath, { recursive: true });
await mkdir(xdgConfigPath, { recursive: true });
await mkdir(xdgCachePath, { recursive: true });
await mkdir(xdgDataPath, { recursive: true });
await mkdir(userDataPath, { recursive: true });
await writeFile(
join(vaultPath, ".obsidian", "app.json"),
JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4)
);
await writeFile(
join(vaultPath, ".obsidian", "community-plugins.json"),
JSON.stringify(["obsidian-livesync"], null, 4)
);
await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath);
return {
path: vaultPath,
name,
id,
homePath,
xdgConfigPath,
xdgCachePath,
xdgDataPath,
userDataPath,
dispose: async () => {
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") {
console.log(`Keeping temporary vault: ${vaultPath}`);
console.log(`Keeping temporary Obsidian state: ${statePath}`);
return;
}
await Promise.all([
rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
]);
},
};
}
async function writeObsidianVaultRegistry(
vaultId: string,
vaultPath: string,
vaultName: string,
homePath: string,
xdgConfigPath: string,
userDataPath: string
): Promise<void> {
const vaultRecord = {
path: vaultPath,
ts: Date.now(),
open: true,
name: vaultName,
};
const registry = {
cli: true,
vaults: {
[vaultId]: vaultRecord,
},
};
const registryText = JSON.stringify(registry, null, 4);
for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) {
const obsidianConfigDir = join(configRoot, "obsidian");
await mkdir(obsidianConfigDir, { recursive: true });
await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText);
}
await writeFile(join(userDataPath, "obsidian.json"), registryText);
await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4));
return await createGenericTemporaryVault({
prefix,
pluginIds: ["obsidian-livesync"],
idPrefix: "livesync-e2e",
});
}
@@ -0,0 +1,355 @@
import { spawn } from "node:child_process";
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "30000";
const liveSyncCli = resolve("src/apps/cli/dist/index.cjs");
const notePath = "E2E/cli-to-obsidian.md";
const noteContent = [
"# CLI to real Obsidian",
"",
"This note was created by the Self-hosted LiveSync CLI.",
"The real Obsidian plug-in must retrieve the same content from CouchDB.",
"0123456789 abcdefghijklmnopqrstuvwxyz 0123456789 abcdefghijklmnopqrstuvwxyz",
"",
].join("\n");
const e2eePassphrase = "real-obsidian-cli-compatibility-e2e";
type LiveSyncCliCommand = {
executable: string;
prefixArgs: string[];
};
type CliResult = {
stdout: string;
stderr: string;
};
type CliFileInfo = {
id: string;
children: string[];
};
function parseCommandLine(value: string): string[] {
const trimmed = value.trim();
if (trimmed.startsWith("[")) {
const parsed = JSON.parse(trimmed) as unknown;
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((part) => typeof part !== "string")) {
throw new Error("LIVESYNC_CLI_COMMAND JSON form must be a non-empty array of strings.");
}
return parsed;
}
const parts: string[] = [];
let current = "";
let quote: "'" | '"' | undefined;
let tokenStarted = false;
for (let index = 0; index < trimmed.length; index++) {
const character = trimmed[index];
if (quote) {
if (character === quote) {
quote = undefined;
continue;
}
if (character === "\\" && quote === '"' && ['"', "\\"].includes(trimmed[index + 1] ?? "")) {
current += trimmed[++index];
continue;
}
current += character;
continue;
}
if (character === "'" || character === '"') {
quote = character;
tokenStarted = true;
continue;
}
if (character === "\\" && ["'", '"', "\\", " ", "\t"].includes(trimmed[index + 1] ?? "")) {
current += trimmed[++index];
tokenStarted = true;
continue;
}
if (/\s/u.test(character)) {
if (tokenStarted) {
parts.push(current);
current = "";
tokenStarted = false;
}
continue;
}
current += character;
tokenStarted = true;
}
if (quote) {
throw new Error("LIVESYNC_CLI_COMMAND contains an unterminated quoted value.");
}
if (tokenStarted) {
parts.push(current);
}
if (parts.length === 0) {
throw new Error("LIVESYNC_CLI_COMMAND must not be empty.");
}
return parts;
}
function resolveLiveSyncCliCommand(): LiveSyncCliCommand {
const override = process.env.LIVESYNC_CLI_COMMAND;
if (override !== undefined) {
const [executable, ...prefixArgs] = parseCommandLine(override);
return { executable, prefixArgs };
}
return { executable: process.execPath, prefixArgs: [liveSyncCli] };
}
async function runLiveSyncCli(command: LiveSyncCliCommand, args: string[]): Promise<CliResult> {
return await new Promise((resolvePromise, reject) => {
const timeoutMs = Number(process.env.E2E_LIVESYNC_CLI_TIMEOUT_MS ?? 60000);
const child = spawn(command.executable, [...command.prefixArgs, ...args], {
cwd: process.cwd(),
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8");
});
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeoutMs);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("exit", (code, signal) => {
clearTimeout(timeout);
const result = {
stdout,
stderr,
};
if (timedOut) {
reject(
new Error(
`LiveSync CLI timed out after ${timeoutMs} ms\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
)
);
return;
}
if (code === 0) {
resolvePromise(result);
return;
}
reject(
new Error(
`LiveSync CLI failed with ${signal ? `signal ${signal}` : `exit code ${String(code)}`}\n` +
`stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
)
);
});
});
}
async function configureLiveSyncCli(
command: LiveSyncCliCommand,
settingsPath: string,
couchDb: Awaited<ReturnType<typeof loadCouchDbConfig>>,
dbName: string
): Promise<void> {
await runLiveSyncCli(command, ["init-settings", "--force", settingsPath]);
const settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record<string, unknown>;
Object.assign(settings, {
couchDB_URI: couchDb.uri,
couchDB_USER: couchDb.username,
couchDB_PASSWORD: couchDb.password,
couchDB_DBNAME: dbName,
remoteType: "",
liveSync: false,
syncOnStart: false,
syncOnSave: false,
usePluginSync: false,
usePluginSyncV2: true,
useEden: false,
customChunkSize: 60,
sendChunksBulk: false,
sendChunksBulkMaxSize: 1,
chunkSplitterVersion: "v3-rabin-karp",
readChunksOnline: true,
disableCheckingConfigMismatch: false,
enableCompression: false,
hashAlg: "xxhash64",
handleFilenameCaseSensitive: false,
doNotUseFixedRevisionForChunks: true,
E2EEAlgorithm: "v2",
encrypt: true,
passphrase: e2eePassphrase,
usePathObfuscation: true,
doctorProcessedVersion: "0.25.27",
isConfigured: true,
});
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
}
async function writeCliNote(
command: LiveSyncCliCommand,
databasePath: string,
settingsPath: string,
sourcePath: string
): Promise<CliFileInfo> {
await mkdir(dirname(sourcePath), { recursive: true });
await writeFile(sourcePath, noteContent, "utf-8");
await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "push", sourcePath, notePath]);
const info = await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "info", notePath]);
const fileInfo = JSON.parse(info.stdout) as CliFileInfo;
if (!fileInfo.id || !Array.isArray(fileInfo.children) || fileInfo.children.length === 0) {
throw new Error(`LiveSync CLI did not create complete metadata for ${notePath}: ${info.stdout}`);
}
await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "sync"]);
return fileInfo;
}
async function waitForVaultContent(
vaultPath: string,
path: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS)
): Promise<string> {
const fullPath = join(vaultPath, path);
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(fullPath, "utf-8");
if (lastContent === noteContent) {
return lastContent;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
}
throw new Error(`Timed out waiting for CLI-created note at ${fullPath}. Last content:\n${lastContent}`);
}
async function main(): Promise<void> {
const liveSyncCliCommand = resolveLiveSyncCliCommand();
if (process.env.LIVESYNC_CLI_COMMAND === undefined) {
await access(liveSyncCli).catch(() => {
throw new Error(
`Built LiveSync CLI was not found at ${liveSyncCli}. Run 'npm run build -w self-hosted-livesync-cli' first, or set LIVESYNC_CLI_COMMAND.`
);
});
}
const binary = requireObsidianBinary();
const obsidianCli = discoverObsidianCli();
if (!obsidianCli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${obsidianCli.checked.join(", ")}`);
}
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "cli-to-obsidian");
const cliState = await mkdtemp(join(tmpdir(), "livesync-cli-to-obsidian-e2e-"));
const cliDatabasePath = join(cliState, "database");
const cliSettingsPath = join(cliState, "settings.json");
const cliSourcePath = join(cliState, "source", "cli-to-obsidian.md");
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
await mkdir(cliDatabasePath, { recursive: true });
await configureLiveSyncCli(liveSyncCliCommand, cliSettingsPath, couchDb, dbName);
if (process.env.LIVESYNC_CLI_COMMAND === undefined) {
console.log(`Using locally built LiveSync CLI: ${liveSyncCli}`);
} else {
console.log(
`Using LiveSync CLI command override: ${JSON.stringify(liveSyncCliCommand.executable)} ` +
`with ${liveSyncCliCommand.prefixArgs.length} prefix argument(s)`
);
}
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary Obsidian vault: ${vault.path}`);
console.log(`Temporary CouchDB database: ${dbName}`);
const cliFileInfo = await writeCliNote(liveSyncCliCommand, cliDatabasePath, cliSettingsPath, cliSourcePath);
await waitForCouchDbDocs(couchDb, dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(cliFileInfo.id) && cliFileInfo.children.every((childId) => ids.has(childId));
});
session = await startObsidianLiveSyncSession({
binary,
cliBinary: obsidianCli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(
{
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
},
{
encrypt: true,
passphrase: e2eePassphrase,
usePathObfuscation: true,
E2EEAlgorithm: "v2",
}
),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv);
await prepareRemote(obsidianCli.binary, session.cliEnv);
await pushLocalChanges(obsidianCli.binary, session.cliEnv);
const received = await waitForVaultContent(vault.path, notePath);
assertEqual(received, noteContent, "The real Obsidian plug-in did not materialise the CLI-created note.");
console.log("CLI-created encrypted note was retrieved by the real Obsidian plug-in with identical content.");
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
await rm(cliState, { recursive: true, force: true });
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);
});
@@ -0,0 +1,434 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import {
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const path = "conflict-dialog-policy.md";
const baseContent = "Conflict dialogue policy\n\nShared base.\n";
const leftContent = "Conflict dialogue policy\n\nChanged on the left.\n";
const rightContent = "Conflict dialogue policy\n\nChanged on the right.\n";
const thirdContent = "Conflict dialogue policy\n\nChanged on the third branch.\n";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_CONFLICT_DIALOG_TIMEOUT_MS ?? 10000);
type ConflictFixture = {
currentRev: string;
currentParentRev?: string;
conflicts: string[];
};
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(baseContent)};`,
"let file=app.vault.getAbstractFileByPath(path);",
"if(!file) file=await app.vault.create(path,content);",
"await app.workspace.getLeaf(false).openFile(file);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function createManualConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
baseRev: string,
contents: readonly string[]
): Promise<ConflictFixture> {
return await evalObsidianJson<ConflictFixture>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const baseRev=${JSON.stringify(baseRev)};`,
`const contents=${JSON.stringify(contents)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const id=await core.services.path.path2id(path);",
"for(const [index,content] of contents.entries()){",
" const blob=new Blob([content],{type:'text/plain'});",
" const now=Date.now()+index;",
" const result=await core.localDatabase.putDBEntry({",
" _id:id,path,data:blob,ctime:now,mtime:now,",
" size:(await blob.arrayBuffer()).byteLength,children:[],",
" datatype:'plain',type:'plain',eden:{},",
" },false,baseRev);",
" if(!result?.ok) throw new Error(`Could not create conflict branch: ${path}`);",
"}",
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);",
"if(!meta?._rev||!meta._conflicts?.length){",
" throw new Error(`Conflict fixture did not produce multiple live leaves: ${path}`);",
"}",
"return JSON.stringify({currentRev:meta._rev,conflicts:meta._conflicts});",
"})()",
].join(""),
env
);
}
async function readConflictFixture(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ConflictFixture> {
return await evalObsidianJson<ConflictFixture>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true,revs:true},true);",
"if(!meta?._rev){",
" throw new Error(`Could not read the conflict fixture: ${path}`);",
"}",
"const revisions=meta._revisions;",
"const currentParentRev=revisions?.ids?.length>1",
" ? `${revisions.start-1}-${revisions.ids[1]}`",
" : undefined;",
"return JSON.stringify({currentRev:meta._rev,currentParentRev,conflicts:meta._conflicts??[]});",
"})()",
].join(""),
env
);
}
async function waitForConflictCount(
cliBinary: string,
env: NodeJS.ProcessEnv,
expectedConflictCount: number
): Promise<ConflictFixture> {
const deadline = Date.now() + uiTimeoutMs;
let fixture = await readConflictFixture(cliBinary, env);
while (fixture.conflicts.length !== expectedConflictCount && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
fixture = await readConflictFixture(cliBinary, env);
}
if (fixture.conflicts.length !== expectedConflictCount) {
throw new Error(
`Expected ${expectedConflictCount + 1} live version(s), but found ${fixture.conflicts.length + 1}: ${JSON.stringify(fixture)}`
);
}
return fixture;
}
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv, waitForCompletion = false) {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const waitForCompletion=${JSON.stringify(waitForCompletion)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.conflict.queueCheckFor(path);",
"if(waitForCompletion){",
" await core.services.conflict.ensureAllProcessed();",
"}",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function waitForConflictChecks(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.conflict.ensureAllProcessed();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function applyReplicatedConflictResolution(
cliBinary: string,
env: NodeJS.ProcessEnv,
revisionToDelete: string,
expectedConflictCount = 0
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const revisionToDelete=${JSON.stringify(revisionToDelete)};`,
`const expectedConflictCount=${JSON.stringify(expectedConflictCount)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(!(await core.fileHandler.deleteRevisionFromDB(path,revisionToDelete))){",
" throw new Error(`Could not apply the replicated conflict resolution: ${path} ${revisionToDelete}`);",
"}",
"const entry=await core.databaseFileAccess.fetchEntryMeta(path,undefined,true);",
"if(!entry){",
" throw new Error(`Could not read the surviving revision after replicated resolution: ${path}`);",
"}",
// This is the same Commonlib consumer boundary invoked after a remote
// document has already entered the local database. Calling it here
// isolates the dialogue policy from transport and second-device setup.
"await core.fileHandler._anyProcessReplicatedDoc(entry);",
"const conflicts=await core.databaseFileAccess.getConflictedRevs(path);",
"if(conflicts.length!==expectedConflictCount){",
" throw new Error(`Replicated resolution left an unexpected conflict count: ${path} ${JSON.stringify(conflicts)}`);",
"}",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
function conflictDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
return page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Conflicting changes" }),
});
}
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 cliBinary = cli.binary;
const vault = await createTemporaryVault("obsidian-livesync-conflict-dialog-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
isConfigured: true,
liveSync: false,
remoteType: "",
couchDB_URI: "http://127.0.0.1:5984",
couchDB_DBNAME: "conflict-dialog-policy",
couchDB_USER: "",
couchDB_PASSWORD: "",
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
disableMarkdownAutoMerge: true,
showMergeDialogOnlyOnActive: true,
showStatusOnEditor: true,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
const fixture = await createManualConflict(cliBinary, session.cliEnv, base.rev, [
leftContent,
rightContent,
thirdContent,
]);
if (fixture.conflicts.length !== 2) {
throw new Error(`Expected exactly three live leaves: ${JSON.stringify(fixture)}`);
}
await requestConflictCheck(cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page
.locator(".livesync-status-messagearea")
.filter({
hasText: "This file has 3 unresolved versions. They will be reviewed one pair at a time.",
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Concat both", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const actionButtonBounds = await modal.locator(".conflict-action-button").evaluateAll((buttons) =>
buttons.map((button) => {
const bounds = button.getBoundingClientRect();
return { top: bounds.top, bottom: bounds.bottom };
})
);
if (
actionButtonBounds.length !== 4 ||
actionButtonBounds.some(
(bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom
)
) {
throw new Error(
`Conflict action buttons are not stacked vertically: ${JSON.stringify(actionButtonBounds)}`
);
}
});
const firstDialogueScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-three-versions.png",
(page) => conflictDialogue(page).locator(".modal").first()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.getByRole("button", { name: "Concat both", exact: true }).click({ timeout: uiTimeoutMs });
});
const remainingAfterConcatenation = await waitForConflictCount(cliBinary, session.cliEnv, 1);
if (
remainingAfterConcatenation.currentRev === fixture.currentRev ||
remainingAfterConcatenation.currentParentRev !== fixture.currentRev
) {
throw new Error(
`Concatenation did not extend the compared winner before retaining the remaining branch: ${JSON.stringify(
{
before: fixture,
after: remainingAfterConcatenation,
}
)}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
const warning = page.locator(".livesync-status-messagearea").filter({
hasText: "This file has unresolved conflicts.",
});
await warning.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const warningScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"conflict-dialog-postponed-warning.png",
(page) =>
page.locator(".livesync-status-messagearea").filter({
hasText: "This file has unresolved conflicts.",
})
);
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const remainingAfterRestart = await waitForConflictCount(cliBinary, session.cliEnv, 1);
await requestConflictCheck(cliBinary, session.cliEnv);
const restartedSession = session;
await withObsidianPage(restartedSession.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page
.locator(".livesync-status-messagearea")
.filter({ hasText: "This file has unresolved conflicts." })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await applyReplicatedConflictResolution(
cliBinary,
restartedSession.cliEnv,
remainingAfterRestart.conflicts[0]
);
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page
.locator(".livesync-status-messagearea")
.filter({ hasText: "This file has unresolved conflicts." })
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
// End the replicated-resolution episode before creating another
// conflict at the same path. This prevents a late cancellation event
// from the first episode from closing the later episode's dialogue.
await session.app.stop();
session = undefined;
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const resolved = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
const laterFixture = await createManualConflict(cliBinary, session.cliEnv, resolved.rev, [
leftContent,
rightContent,
]);
if (laterFixture.conflicts.length !== 1) {
throw new Error(`Expected a later conflict with exactly two live leaves: ${JSON.stringify(laterFixture)}`);
}
await requestConflictCheck(cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
await waitForConflictChecks(cliBinary, session.cliEnv);
await requestConflictCheck(cliBinary, session.cliEnv, true);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.waitForTimeout(1500);
if (await conflictDialogue(page).isVisible()) {
throw new Error("The postponed conflict dialogue reopened during an ordinary conflict check.");
}
});
await waitForConflictCount(cliBinary, session.cliEnv, 1);
const laterCommandExecuted = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
return await page.evaluate(
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:livesync-checkdoc-conflicted"
);
});
if (!laterCommandExecuted) {
throw new Error("The explicit conflict-resolution command was not registered for the active editor.");
}
const laterActiveSession = session;
await withObsidianPage(laterActiveSession.remoteDebuggingPort, async (page) => {
const modal = conflictDialogue(page);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await applyReplicatedConflictResolution(cliBinary, laterActiveSession.cliEnv, laterFixture.conflicts[0]);
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page
.locator(".livesync-status-messagearea")
.filter({ hasText: "This file has unresolved conflicts." })
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
console.log(
"Real Obsidian reviewed three versions pairwise, retained the completed stage across restart, suppressed an ordinary repeat prompt after Not now, reopened the dialogue after the explicit command, and cleared both postponed and open-dialogue states after replicated resolutions."
);
console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`);
console.log(`Postponed warning screenshot: ${warningScreenshot}`);
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,369 @@
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
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 } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
captureGuideDialogue,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
resumeCompatibilityReviewIfShown,
selectRadioOption,
skipMissingRemoteConfiguration,
type SetupArtifact,
} from "../runner/setupUri.ts";
import { 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 uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000);
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 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";
const captures = {
scenario: "couchdb-manual-setup-workflow",
guide: "couchdb-manual",
} as const;
type RunnerContext = {
binary: string;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
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 captureFailure(session: ObsidianLiveSyncSession, label: string): Promise<void> {
const screenshot = await captureObsidianPage(
session.remoteDebuggingPort,
`couchdb-manual-${label}-failure.png`,
async () => undefined
).catch(() => undefined);
if (screenshot) {
console.error(`Manual CouchDB failure screenshot: ${screenshot}`);
}
}
async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig, dbName: string): Promise<string[]> {
const screenshots: string[] = [];
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 });
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 });
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-connection-method.png", "Connection Method")
);
await withObsidianPage(port, async (page) => {
const method = modalByTitle(page, "Connection Method");
await selectRadioOption(method, "Configure a remote manually");
await method
.getByRole("button", { name: "Proceed with manual configuration" })
.click({ timeout: uiTimeoutMs });
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs });
await encryption
.locator("label.row")
.filter({ hasText: "End-to-End Encryption" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
await encryption
.locator("label.row")
.filter({ hasText: "Obfuscate Properties" })
.locator('input[type="checkbox"]')
.first()
.check({ timeout: uiTimeoutMs });
await encryption.locator('input[name="e2ee-passphrase"]').fill(randomBytes(24).toString("base64url"));
});
screenshots.push(await captureGuideDialogue(port, "guide-couchdb-manual-encryption.png", "End-to-End Encryption"));
await withObsidianPage(port, async (page) => {
const encryption = modalByTitle(page, "End-to-End Encryption");
await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs });
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-remote-selection.png", "Choose a synchronisation remote")
);
await withObsidianPage(port, async (page) => {
const remoteSelection = modalByTitle(page, "Choose a synchronisation remote");
await selectRadioOption(remoteSelection, "CouchDB");
await remoteSelection
.getByRole("button", { name: "Continue to CouchDB setup", exact: true })
.click({ timeout: uiTimeoutMs });
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB.waitFor({ state: "visible", timeout: uiTimeoutMs });
await couchDB.locator('input[name="couchdb-url"]').fill(couchDb.uri);
await couchDB.locator('input[name="couchdb-username"]').fill(couchDb.username);
await couchDB.locator('input[name="couchdb-password"]').fill(couchDb.password);
await couchDB.locator('input[name="couchdb-database"]').fill(dbName);
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-connection-details.png", "CouchDB Configuration")
);
await withObsidianPage(port, async (page) => {
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB
.getByRole("button", { name: "Check server requirements", exact: true })
.click({ timeout: uiTimeoutMs });
const summary = couchDB.locator(".check-results summary");
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await summary
.filter({ hasText: /All checks passed successfully!|issue\(s\) detected!/u })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const errors = couchDB.locator(".check-result.error");
if ((await errors.count()) > 0) {
const messages = await errors.locator(".message").allTextContents();
throw new Error(`The documented CouchDB fixture failed its server requirements: ${messages.join(" | ")}`);
}
const details = couchDB.locator(".check-results details");
if (!(await details.evaluate((element) => (element as HTMLDetailsElement).open))) {
await summary.click({ timeout: uiTimeoutMs });
}
});
screenshots.push(
await captureGuideDialogue(port, "guide-couchdb-manual-server-requirements.png", "CouchDB Configuration")
);
await withObsidianPage(port, async (page) => {
const couchDB = modalByTitle(page, "CouchDB Configuration");
await couchDB
.getByRole("button", { name: "Create or connect to database and continue", exact: true })
.click({ timeout: uiTimeoutMs });
await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
return screenshots;
}
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 waitForVaultFile(
vault: TemporaryVault,
path: string,
expected: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000)
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(join(vault.path, path), "utf8");
if (lastContent === expected) return;
} 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 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));
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
});
}
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, "manual-setup");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName,
activeSessions: new Set(),
};
const screenshots: string[] = [];
let secondDeviceArtifact: SetupArtifact | undefined;
try {
await assertCouchDbReachable(couchDb);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary Vault A: ${vaultA.path}`);
console.log(`Temporary Vault B: ${vaultB.path}`);
console.log(`CouchDB database to be created by the onboarding dialogue: ${dbName}`);
let session = await startUnconfiguredSession(context, vaultA);
try {
screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName)));
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures));
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures));
screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures));
const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
assertEqual(state.activeConfigurationId !== "", true, "Manual CouchDB setup did not activate a profile.");
assertEqual(
state.remoteConfigurationCount,
1,
"Manual CouchDB setup did not persist exactly one remote profile."
);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, entry);
const generated = await generateSetupURIFromDevice(
session.remoteDebuggingPort,
randomBytes(24).toString("base64url"),
captures
);
secondDeviceArtifact = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session, "first-device");
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultB);
try {
if (!secondDeviceArtifact) {
throw new Error("The manually configured first device did not generate a Setup URI.");
}
screenshots.push(
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact, captures)
);
screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", captures));
screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, captures)));
await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultB, notePath, noteContent);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent);
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, returnEntry);
} catch (error) {
await captureFailure(session, "second-device");
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultA);
try {
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultA, returnNotePath, returnNoteContent);
} catch (error) {
await captureFailure(session, "return-journey");
throw error;
} finally {
await stopTrackedSession(context, session);
}
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(", ")}`
);
} 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);
});
+144 -2
View File
@@ -5,17 +5,35 @@ import {
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
waitForCouchDbDocs,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
configureCouchDb,
createE2eCouchDbPluginData,
prepareRemote,
pushLocalChanges,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
REMOTE_ACTIVITY_EXPECTED_STATE,
captureRemoteActivityDiagnostics,
waitForRemoteActivityState,
} from "../runner/remoteActivity.ts";
import {
cleanUpHeldRemoteActivity,
clearHeldRemoteActivity,
finishHeldRemoteActivity,
startHeldChunkFetch,
startHeldOneShotReplication,
startHeldTrackedRequest,
waitForRestoredChunk,
} from "../runner/remoteActivityWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
@@ -72,6 +90,7 @@ async function main(): Promise<void> {
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "obsidian-upload");
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
let activityStage = "session-startup";
try {
await assertCouchDbReachable(couchDb);
@@ -86,8 +105,20 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData({
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
}),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await assertE2eCompatibilityReviewPending(cli.binary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort, {
verifyMissingDeviceMarkerExplanation: true,
screenshotPrefix: "compatibility-review-copied-vault",
});
await assertE2eCompatibilityMarker(cli.binary, session.cliEnv);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
@@ -104,8 +135,50 @@ async function main(): Promise<void> {
assertEqual(configured.syncOnSave, false, "Sync on save should remain disabled during this workflow.");
await prepareRemote(cli.binary, session.cliEnv);
activityStage = "initial-idle";
const initialIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
await pushLocalChanges(cli.binary, session.cliEnv);
activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive;
await startHeldTrackedRequest(cli.binary, session.cliEnv);
const trackedRequestActive = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive
);
const trackedRequestResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual(trackedRequestResult.error, undefined, "The observed CouchDB request failed.");
assertEqual(trackedRequestResult.result, true, "The observed CouchDB request did not report success.");
activityStage = "tracked-request-idle";
const trackedRequestIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (trackedRequestIdle.requestCount <= initialIdle.requestCount) {
throw new Error("The held CouchDB request did not advance the tracked remote-request count.");
}
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
activityStage = "one-shot-active";
await startHeldOneShotReplication(cli.binary, session.cliEnv);
const oneShotActive = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.finiteReplicationActive
);
const oneShotResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual(oneShotResult.error, undefined, "One-shot replication failed while its activity was observed.");
assertEqual(oneShotResult.result, true, "One-shot replication did not report success.");
activityStage = "one-shot-idle";
const oneShotIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (oneShotIdle.requestCount <= trackedRequestIdle.requestCount) {
throw new Error("One-shot replication did not make an observed remote request.");
}
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
const remoteDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
@@ -118,11 +191,80 @@ async function main(): Promise<void> {
"Remote metadata path did not match the local database entry."
);
const sourceChunkId = localEntry.children[0];
if (!sourceChunkId) throw new Error("The uploaded note did not produce a chunk for the fetch workflow.");
const sourceChunk = remoteDocs.find((document) => document._id === sourceChunkId);
if (!sourceChunk || sourceChunk.type !== "leaf") {
throw new Error(`The uploaded source chunk was not found in CouchDB: ${sourceChunkId}`);
}
const { _rev: _sourceRevision, ...remoteOnlyChunk } = sourceChunk;
const chunkId = `h:e2e-remote-activity-${Date.now().toString(36)}`;
await putCouchDbDocument(couchDb, dbName, { ...remoteOnlyChunk, _id: chunkId });
activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive;
await startHeldChunkFetch(cli.binary, session.cliEnv, chunkId);
const chunkFetchActive = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive
);
const chunkFetchResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv);
assertEqual(
chunkFetchResult.error,
undefined,
"On-demand chunk fetching failed while its activity was observed."
);
if (!chunkFetchResult.requestedIds?.includes(chunkId)) {
throw new Error(`The on-demand chunk request did not include the selected chunk: ${chunkId}`);
}
if ((chunkFetchResult.resultCount ?? 0) < 1) {
throw new Error(`The remote did not return the selected chunk: ${chunkId}`);
}
const restoredChunk = await waitForRestoredChunk(cli.binary, session.cliEnv, chunkId);
assertEqual(restoredChunk.id, chunkId, "The restored chunk ID did not match the requested chunk.");
activityStage = "chunk-fetch-idle";
const chunkFetchIdle = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (chunkFetchIdle.requestCount <= oneShotIdle.requestCount) {
throw new Error("On-demand chunk fetching did not make an observed remote request.");
}
await clearHeldRemoteActivity(cli.binary, session.cliEnv);
console.log(
`Uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s) to CouchDB database ${dbName}`
);
console.log(
[
`Tracked request: ${trackedRequestActive.statusBarText.trim()} -> idle`,
`One-shot activity: ${oneShotActive.statusBarText.trim()} -> idle`,
`Chunk-fetch activity: ${chunkFetchActive.statusBarText.trim()} -> idle`,
`Balanced remote requests: ${chunkFetchIdle.requestCount}/${chunkFetchIdle.responseCount}`,
].join("\n")
);
} catch (error) {
if (session) {
const diagnostics = await captureRemoteActivityDiagnostics(
session.remoteDebuggingPort,
`couchdb-upload-${activityStage}`
).catch((diagnosticError: unknown) => {
console.warn(
`Could not capture remote activity diagnostics: ${
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)
}`
);
return undefined;
});
if (diagnostics) {
console.error(`Remote activity screenshot: ${diagnostics.screenshotPath}`);
console.error(`Remote activity snapshot: ${diagnostics.snapshotPath}`);
}
}
throw error;
} finally {
if (session) {
await cleanUpHeldRemoteActivity(cli.binary, session.cliEnv).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await session.app.stop();
}
await vault.dispose();
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,11 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
@@ -13,7 +19,10 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -21,7 +30,14 @@ import {
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { clickJsonResolveOption, obsidianRemoteDebuggingPort } from "../runner/ui.ts";
import {
captureObsidianPage,
captureJsonResolveDialogue,
clickJsonResolveOption,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
@@ -43,6 +59,7 @@ const mergeJsonPath = ".obsidian/livesync-e2e-merge.json";
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";
type RunnerContext = {
binary: string;
@@ -300,31 +317,33 @@ async function startConfiguredSession(
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const hiddenFileSettings = {
syncInternalFiles: true,
syncInternalFilesBeforeReplication: true,
watchInternalFileChanges: false,
syncInternalFilesTargetPatterns: "",
...overrides,
};
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
// A fresh Vault waits for onboarding before opening its local database.
// Seed the same isolated settings used by configureCouchDb so that the
// application lifecycle can become ready without a user interaction.
pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
{
syncInternalFiles: true,
syncInternalFilesBeforeReplication: true,
watchInternalFileChanges: false,
syncInternalFilesTargetPatterns: "",
...overrides,
}
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
}
@@ -431,6 +450,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
const session = await startConfiguredSession(context, vault);
await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right);
await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath);
const screenshotPath = await captureJsonResolveDialogue(obsidianRemoteDebuggingPort());
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
@@ -442,7 +462,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
assertEqual(parsed.fromA, true, "Manual JSON conflict resolution lost the first-side value.");
assertEqual(parsed.fromB, true, "Manual JSON conflict resolution lost the second-side value.");
console.log("Hidden JSON conflict modal applied the selected merged result.");
console.log(`Hidden JSON conflict modal applied the selected merged result. Screenshot: ${screenshotPath}`);
}
async function runTargetMismatch(
@@ -489,6 +509,342 @@ async function runTargetMismatch(
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target.");
}
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ nextItemIds, nextIncludeRestart }) => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
for (const id of ["alpha", "beta", "gamma"]) {
const pluginId = `livesync-e2e-${id}`;
obsidianApp.plugins.manifests[pluginId] = {
id: pluginId,
name: `E2E ${id[0]?.toUpperCase()}${id.slice(1)}`,
version: "1.0.0",
minAppVersion: "1.0.0",
description: "E2E fixture",
author: "Self-hosted LiveSync",
isDesktopOnly: false,
dir: `.obsidian/plugins/${pluginId}`,
};
obsidianApp.plugins.enabledPlugins.add(pluginId);
}
addOn.queuedNotificationFiles.clear();
for (const id of nextItemIds) {
addOn.queuedNotificationFiles.add(`.obsidian/plugins/livesync-e2e-${id}`);
}
if (nextIncludeRestart) {
addOn.queuedNotificationFiles.add(core.services.API.getSystemConfigDir());
}
addOn.notifyConfigChange();
},
{ nextItemIds: itemIds, nextIncludeRestart: includeRestart }
);
});
}
async function clearHiddenFileNoticeFixtures(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
plugin.core.services.context.noticeGroups.hide("hidden-file-changes");
for (const id of ["alpha", "beta", "gamma"]) {
const pluginId = `livesync-e2e-${id}`;
obsidianApp.plugins.enabledPlugins.delete(pluginId);
delete obsidianApp.plugins.manifests[pluginId];
}
});
});
}
async function runInitialisationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const session = await startConfiguredSession(context, vault, {
syncInternalFiles: false,
syncInternalFilesBeforeReplication: false,
});
const port = session.remoteDebuggingPort;
const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000);
try {
await withObsidianPage(port, async (page) => {
const deadline = Date.now() + timeoutMs;
while ((await page.locator(".notice:visible").count()) > 0 && Date.now() < deadline) {
await page.locator(".notice:visible").first().click({
force: true,
position: { x: 2, y: 2 },
timeout: timeoutMs,
});
}
assertEqual(
await page.locator(".notice:visible").count(),
0,
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
);
});
await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
const setting = core.services.setting;
const originalApplyPartial = setting.applyPartial;
const originalRebuildMerging = addOn.rebuildMerging;
const state = {
done: false,
reachedPreparation: false,
reachedInitialisation: false,
maxVisibleProgressNotices: 0,
visibleProgressNoticeTexts: [] as string[],
sawStandaloneGatheringNotice: false,
sawStandaloneRestartNotice: false,
releasePreparation: undefined as (() => void) | undefined,
releaseInitialisation: undefined as (() => void) | undefined,
error: undefined as string | undefined,
};
(globalThis as unknown as Record<string, typeof state>)[stateKey] = state;
const observer = new MutationObserver(() => {
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
);
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
);
if (progressNotices.length > state.maxVisibleProgressNotices) {
state.maxVisibleProgressNotices = progressNotices.length;
state.visibleProgressNoticeTexts = progressNotices.map(
(notice) => notice.textContent?.trim() ?? ""
);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
setting.applyPartial = async (...args: unknown[]) => {
const update = args[0] as { syncInternalFiles?: unknown } | undefined;
if (update?.syncInternalFiles === true && !state.reachedPreparation) {
state.reachedPreparation = true;
await new Promise<void>((resolve) => {
state.releasePreparation = resolve;
});
}
return await originalApplyPartial.apply(setting, args);
};
addOn.rebuildMerging = async (...args: unknown[]) => {
state.reachedInitialisation = true;
await new Promise<void>((resolve) => {
state.releaseInitialisation = resolve;
});
return await originalRebuildMerging.apply(addOn, args);
};
void core.services.setting
.enableOptionalFeature("MERGE")
.then(
() => {
state.done = true;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
state.done = true;
}
)
.finally(() => {
setting.applyPartial = originalApplyPartial;
addOn.rebuildMerging = originalRebuildMerging;
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
);
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
);
if (progressNotices.length > state.maxVisibleProgressNotices) {
state.maxVisibleProgressNotices = progressNotices.length;
state.visibleProgressNoticeTexts = progressNotices.map(
(notice) => notice.textContent?.trim() ?? ""
);
}
observer.disconnect();
});
}, hiddenFileInitialisationStateKey);
});
const screenshotPath = await captureObsidianPage(
port,
"hidden-file-initial-scan-progress.png",
async (page) => {
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedPreparation?: boolean } | undefined>)[
stateKey
]?.reachedPreparation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
await progressNotices.first().waitFor({ state: "visible", timeout: timeoutMs });
assertEqual(
await progressNotices.count(),
1,
"Hidden File Sync showed more than one progress Notice before saving its enabled setting."
);
await progressNotices
.filter({ hasText: "Preparing Hidden File Sync..." })
.waitFor({ state: "visible", timeout: timeoutMs });
}
);
const result = await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const state = (globalThis as unknown as Record<
string,
{ releasePreparation?: () => void } | undefined
>)[stateKey];
state?.releasePreparation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedInitialisation?: boolean } | undefined>)[
stateKey
]?.reachedInitialisation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
assertEqual(
await progressNotices.count(),
1,
"Hidden File Sync replaced its parent progress Notice when the first child phase started."
);
await page.evaluate((stateKey) => {
const state = (
globalThis as unknown as Record<string, { releaseInitialisation?: () => void } | undefined>
)[stateKey];
state?.releaseInitialisation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { done?: boolean } | undefined>)[stateKey]?.done === true,
hiddenFileInitialisationStateKey,
{ timeout: hiddenFileCliTimeoutMs }
);
return await page.evaluate(
(stateKey) =>
(
globalThis as unknown as Record<
string,
{
error?: string;
maxVisibleProgressNotices: number;
visibleProgressNoticeTexts: string[];
sawStandaloneGatheringNotice: boolean;
sawStandaloneRestartNotice: boolean;
}
>
)[stateKey],
hiddenFileInitialisationStateKey
);
});
if (result.error) {
throw new Error(`Hidden File Sync initialisation failed: ${result.error}`);
}
assertEqual(
result.maxVisibleProgressNotices,
1,
`Hidden File Sync split initialisation across multiple progress Notices: ${JSON.stringify(
result.visibleProgressNoticeTexts
)}`
);
assertEqual(
result.sawStandaloneGatheringNotice,
false,
"Hidden File Sync showed the old standalone gathering Notice."
);
assertEqual(
result.sawStandaloneRestartNotice,
false,
"Hidden File Sync showed the old standalone restart recommendation."
);
console.log(
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
);
} finally {
await session.app.stop();
}
}
async function runConfigurationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const session = await startConfiguredSession(context, vault);
const port = session.remoteDebuggingPort;
const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000);
try {
await setObsidianMobileTestMode(port, true, timeoutMs);
await setHiddenFileNoticeFixtures(port, ["alpha", "beta"], true);
await withObsidianPage(port, async (page) => {
const visibleGroups = page.locator(".notice:has(.vpk-keyed-notice-group):visible");
await visibleGroups.first().waitFor({ state: "visible", timeout: timeoutMs });
assertEqual(await visibleGroups.count(), 1, "Hidden File Sync created more than one visible Notice group.");
const notice = visibleGroups.first();
const rows = notice.locator(".vpk-keyed-notice-group__item");
assertEqual(await rows.count(), 3, "Hidden File Sync did not group every configuration-change action.");
await notice.getByText("Files in E2E Alpha were updated.", { exact: true }).waitFor();
await notice.getByText("Files in E2E Beta were updated.", { exact: true }).waitFor();
await notice.getByText("Other Obsidian settings files were updated.", { exact: true }).waitFor();
await assertLocatorWithinViewport(page, notice, { label: "Hidden File Sync notification group" });
await assertNoHorizontalOverflow(page, notice, { label: "Hidden File Sync notification group" });
await assertLocatorWithinSafeArea(page, notice, {
label: "Hidden File Sync notification group",
safeAreaInsets: iPhoneSafeArea,
});
const buttons = notice.getByRole("button");
for (let index = 0; index < (await buttons.count()); index += 1) {
await assertLocatorHasMinimumTouchTarget(page, buttons.nth(index), {
label: `Hidden File Sync notification action ${index + 1}`,
});
}
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshotPath = join(outputDirectory, "hidden-file-notice-group-mobile.png");
await mkdir(dirname(screenshotPath), { recursive: true });
await page.screenshot({ path: screenshotPath, fullPage: true, animations: "disabled" });
await rows.first().getByText("Files in E2E Alpha were updated.", { exact: true }).click();
await notice.waitFor({ state: "hidden", timeout: timeoutMs });
});
await setHiddenFileNoticeFixtures(port, ["gamma"], false);
await withObsidianPage(port, async (page) => {
const notice = page.locator(".notice:has(.vpk-keyed-notice-group):visible").first();
await notice.waitFor({ state: "visible", timeout: timeoutMs });
const rows = notice.locator(".vpk-keyed-notice-group__item");
assertEqual(await rows.count(), 1, "A dismissed Hidden File Sync Notice repeated acknowledged rows.");
await rows.getByText("Files in E2E Gamma were updated.", { exact: true }).waitFor();
});
console.log(
"Hidden File Sync grouped configuration notifications passed the mobile regression for issue #555."
);
} finally {
await clearHiddenFileNoticeFixtures(port).catch(() => undefined);
await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined);
await session.app.stop();
}
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -516,6 +872,8 @@ async function main(): Promise<void> {
await runJsonConflictRoundTrip(context, vaultA, vaultB);
await runJsonManualConflictResolution(context, vaultB);
await runTargetMismatch(context, vaultA, vaultB);
await runInitialisationNoticeGrouping(context, vaultB);
await runConfigurationNoticeGrouping(context, vaultB);
} finally {
await vaultA.dispose();
await vaultB.dispose();
+42
View File
@@ -8,12 +8,35 @@ type Step = {
const testSteps: Step[] = [
{ name: "build", args: ["run", "build"] },
...(process.env.LIVESYNC_CLI_COMMAND === undefined
? [{ name: "CLI build", args: ["run", "build", "-w", "self-hosted-livesync-cli"] }]
: []),
{ name: "discover", args: ["run", "test:e2e:obsidian:discover"] },
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
{ name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] },
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
{ 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: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
{
name: "manual CouchDB setup workflow",
args: ["run", "test:e2e:obsidian:couchdb-manual-setup-workflow"],
},
{
name: "CLI to real Obsidian synchronisation",
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
},
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
{
name: "Object Storage Setup URI workflow",
args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"],
},
{ name: "P2P Setup URI workflow", args: ["run", "test:e2e:obsidian:p2p-setup-uri-workflow"] },
{ 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"] },
@@ -22,9 +45,11 @@ const testSteps: Step[] = [
const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services");
const manageMinio = process.argv.includes("--manage-minio") || process.argv.includes("--manage-services");
const manageP2P = process.argv.includes("--manage-p2p") || process.argv.includes("--manage-services");
const keepServices = process.argv.includes("--keep-services");
const keepCouchDb = keepServices || process.argv.includes("--keep-couchdb");
const keepMinio = keepServices || process.argv.includes("--keep-minio");
const keepP2P = keepServices || process.argv.includes("--keep-p2p");
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
@@ -71,9 +96,18 @@ async function stopManagedMinio(): Promise<void> {
});
}
async function stopManagedP2P(): Promise<void> {
await runStep({
name: "stop P2P relay fixture",
args: ["run", "test:docker-p2p:stop"],
optional: true,
});
}
async function main(): Promise<void> {
let shouldStopCouchDb = false;
let shouldStopMinio = false;
let shouldStopP2P = false;
try {
if (manageCouchDb) {
await stopManagedCouchDb();
@@ -85,11 +119,19 @@ async function main(): Promise<void> {
await runStep({ name: "start MinIO fixture", args: ["run", "test:docker-s3:start"] });
shouldStopMinio = !keepMinio;
}
if (manageP2P) {
await stopManagedP2P();
await runStep({ name: "start P2P relay fixture", args: ["run", "test:docker-p2p:start"] });
shouldStopP2P = !keepP2P;
}
for (const step of testSteps) {
await runStep(step);
}
} finally {
if (shouldStopP2P) {
await stopManagedP2P();
}
if (shouldStopMinio) {
await stopManagedMinio();
}
+42 -1
View File
@@ -1,8 +1,27 @@
/**
* Verifies one complete Object Storage upload from a real Obsidian Vault,
* through LiveSync's local database and Journal Sync, to an S3-compatible
* service observed independently through the AWS SDK.
*
* The isolated Vault starts with Object Storage settings and the device-local
* compatibility acknowledgement already in place. Unconfigured start-up is
* intentionally inert and belongs to the onboarding scenario; compatibility
* review and visible setup have their own dedicated workflows. Supplying those
* prerequisites here keeps this scenario focused on the upload boundary.
*
* Note creation, local-database observation, one-shot synchronisation, request
* accounting, remote-object inspection, and prefix cleanup remain in one
* scenario so that a pass proves the same payload crossed every boundary.
* Separate successes would not prove that those observations belonged to the
* same upload.
*/
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureObjectStorage,
createE2eObjectStoragePluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -17,6 +36,7 @@ import {
} from "../runner/objectStorage.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../runner/remoteActivity.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
@@ -99,6 +119,11 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eObjectStoragePluginData({
...objectStorage,
bucketPrefix,
}),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
@@ -115,13 +140,29 @@ async function main(): Promise<void> {
assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow.");
await prepareRemote(cli.binary, session.cliEnv);
const activityBeforeUpload = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
await pushLocalChanges(cli.binary, session.cliEnv);
const activityAfterUpload = await waitForRemoteActivityState(
session.remoteDebuggingPort,
REMOTE_ACTIVITY_EXPECTED_STATE.idle
);
if (activityAfterUpload.requestCount <= activityBeforeUpload.requestCount) {
throw new Error("Object Storage synchronisation did not advance the tracked remote-request count.");
}
assertEqual(
activityAfterUpload.responseCount,
activityAfterUpload.requestCount,
"Object Storage remote-request counters did not rebalance after synchronisation."
);
const keys = await waitForObjectStorageObjects(bucketPrefix);
console.log(
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s))`
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}`
);
} finally {
if (session) {
@@ -0,0 +1,324 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
listObjectStorageObjects,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
type ObjectStorageConfig,
} from "../runner/objectStorage.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
resumeCompatibilityReviewIfShown,
skipMissingRemoteConfiguration,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
import {
captureObsidianElement,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const execFileAsync = promisify(execFile);
const captures: SetupCaptureNames = { scenario: "object-storage-setup-uri", guide: "object-storage-setup" };
const noteFromFirst = "E2E/object-storage/from-first.md";
const noteFromSecond = "E2E/object-storage/from-second.md";
const firstContent =
"# Object Storage from the first device\n\nThis note travelled through the first device's Setup URI.\n";
const secondContent = "# Object Storage from the second device\n\nThis note completed the return journey.\n";
type RunnerContext = {
binary: string;
cliBinary: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort(process.env);
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
async function runDeno(script: 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",
"--allow-env",
script,
],
{ cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 }
);
return stdout;
}
async function generateBootstrapSetupURI(
objectStorage: ObjectStorageConfig,
bucketPrefix: string
): Promise<SetupArtifact> {
const setupPassphrase = randomBytes(24).toString("base64url");
const output = await runDeno("utils/setup/generate_setup_uri.ts", {
...process.env,
remote_type: "s3",
endpoint: objectStorage.endpoint,
access_key: objectStorage.accessKey,
secret_key: objectStorage.secretKey,
bucket: objectStorage.bucket,
region: objectStorage.region,
force_path_style: String(objectStorage.forcePathStyle),
bucket_prefix: bucketPrefix,
passphrase: randomBytes(24).toString("base64url"),
uri_passphrase: setupPassphrase,
});
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 an Object Storage Setup URI.");
return { setupURI, setupPassphrase };
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
port: number
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(port),
});
context.activeSessions.add(session);
return session;
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) {
await stopSession(context, session);
}
}
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function writeNote(
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
);
await waitForLocalDatabaseEntry(cliBinary, environment, path);
}
async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(join(vault.path, path), "utf8");
if (lastContent === expected) return;
} 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 waitForObjectStorageData(config: ObjectStorageConfig, prefix: string): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
if ((await listObjectStorageObjects(config, prefix)).length > 0) return;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Object Storage data under ${prefix}.`);
}
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((notePath) => {
const obsidian = globalThis as typeof globalThis & {
app?: {
workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
};
};
return obsidian.app?.workspace?.openLinkText(notePath, "", false);
}, path);
});
await captureObsidianPage(port, `${filename}.full.png`, async (page) => {
await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: 30000 });
});
return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first());
}
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 objectStorage = await loadObjectStorageConfig();
const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow");
const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix);
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const [portA, portB] = sessionPorts();
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
const screenshots: string[] = [];
try {
await ensureObjectStorageBucket(objectStorage);
console.log(`Temporary Object Storage target: ${objectStorage.bucket}/${bucketPrefix}`);
const sessionA = await startSession(context, vaultA, portA);
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
screenshots.push(await confirmRebuild(portA, captures));
screenshots.push(await skipMissingRemoteConfiguration(portA, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
assertEqual(
firstState.endpoint,
objectStorage.endpoint,
"The first device did not activate the Object Storage endpoint."
);
assertEqual(
firstState.bucket,
objectStorage.bucket,
"The first device did not activate the Object Storage bucket."
);
assertEqual(
firstState.bucketPrefix,
bucketPrefix,
"The first device did not activate the unique bucket prefix."
);
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await waitForObjectStorageData(objectStorage, bucketPrefix);
const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures);
if (generated.artifact.setupURI === bootstrapArtifact.setupURI) {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
screenshots.push(...generated.screenshots);
await stopSession(context, sessionA);
const sessionB = await startSession(context, vaultB, portB);
screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures));
screenshots.push(await captureAndStartInitialisation(portB, "existing", captures));
screenshots.push(...(await confirmFastFetch(portB, captures)));
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
await resumeCompatibilityReviewIfShown(portB);
assertEqual(
secondState.endpoint,
objectStorage.endpoint,
"The second device did not import the Object Storage endpoint."
);
assertEqual(
secondState.bucketPrefix,
bucketPrefix,
"The second device did not import the unique bucket prefix."
);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB, noteFromFirst, firstContent);
screenshots.push(
await captureNote(
portB,
noteFromFirst,
"Object Storage from the first device",
"guide-object-storage-setup-first-to-second.png"
)
);
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await stopSession(context, sessionB);
const returningSessionA = await startSession(context, vaultA, portA);
await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
await pushLocalChanges(context.cliBinary, returningSessionA.cliEnv);
await waitForPathContent(vaultA, noteFromSecond, secondContent);
screenshots.push(
await captureNote(
portA,
noteFromSecond,
"Object Storage from the second device",
"guide-object-storage-setup-second-to-first.png"
)
);
console.log(
`Object Storage Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`
);
} finally {
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await vaultA.dispose();
await vaultB.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") {
await deleteObjectStoragePrefix(objectStorage, bucketPrefix).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);
});
@@ -0,0 +1,246 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertMobileDialogueLayout, iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_ONBOARDING_TIMEOUT_MS ?? 15000);
const markerPath = "E2E/unconfigured-startup-must-not-scan.md";
type UnconfiguredStartupEvidence = {
configured: boolean;
markerInDatabase: boolean;
offlineScanInitialised: boolean;
recommendedDefaults: {
usePluginSyncV2: boolean;
handleFilenameCaseSensitive: boolean;
};
};
type ObsidianTestApp = {
setting?: {
open(): void;
openTabById(tabId: string): void;
};
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function writeMarker(vaultPath: string): Promise<void> {
const fullPath = join(vaultPath, markerPath);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, "# This file must remain outside the database until setup completes.\n", "utf8");
}
async function inspectUnconfiguredStartup(
cliBinary: string,
env: NodeJS.ProcessEnv
): Promise<UnconfiguredStartupEvidence> {
return await evalObsidianJson<UnconfiguredStartupEvidence>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`const markerPath=${JSON.stringify(markerPath)};`,
"let entry=false;",
"try{entry=await core.localDatabase.getDBEntry(markerPath,undefined,false,false);}catch{}",
"let initialised=false;",
"try{initialised=(await core.kvDB.get('initialized'))===true;}catch{}",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"configured:settings?.isConfigured===true,",
"markerInDatabase:Boolean(entry&&entry._id),",
"offlineScanInitialised:initialised,",
"recommendedDefaults:{",
"usePluginSyncV2:settings?.usePluginSyncV2,",
"handleFilenameCaseSensitive:settings?.handleFilenameCaseSensitive,",
"},",
"});",
"})()",
].join(""),
env
);
}
function onboardingNotice(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
return page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
}
function onboardingDialogue(page: Parameters<Parameters<typeof withObsidianPage>[1]>[0]) {
return page.locator(".modal-container").filter({ hasText: "Welcome to Self-hosted LiveSync" });
}
async function requireInvitationWithoutDialogue(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const invitation = onboardingNotice(page);
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await onboardingDialogue(page).count()) !== 0) {
throw new Error("The onboarding dialogue opened before the user selected the invitation.");
}
const compatibilityReview = page.locator(".modal-container").filter({
hasText: "Synchronisation paused for compatibility review",
});
if ((await compatibilityReview.count()) !== 0) {
throw new Error("A new unconfigured Vault was incorrectly treated as an existing compatibility state.");
}
});
}
async function captureDesktopInvitation(): Promise<string> {
return await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"onboarding-invitation-desktop.png",
async (page) => {
const invitation = onboardingNotice(page);
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, invitation, { label: "desktop onboarding invitation" });
}
);
}
async function captureAndSelectMobileInvitation(): Promise<string> {
const port = obsidianRemoteDebuggingPort();
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
const screenshot = await captureObsidianDialogue(port, "onboarding-invitation-mobile.png", async (page) => {
const invitation = onboardingNotice(page);
const action = invitation.locator(".sls-onboarding-invitation-action");
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertLocatorWithinSafeArea(page, invitation, {
label: "mobile onboarding invitation",
safeAreaInsets: iPhoneSafeArea,
});
await assertNoHorizontalOverflow(page, invitation, { label: "mobile onboarding invitation" });
await assertLocatorHasMinimumTouchTarget(page, action, {
label: "mobile onboarding invitation action",
});
});
await withObsidianPage(port, async (page) => {
await onboardingNotice(page).locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
});
return screenshot;
}
async function captureAndCloseIntro(filename: string, mobile: boolean): Promise<string> {
const port = obsidianRemoteDebuggingPort();
const screenshot = await captureObsidianDialogue(port, filename, async (page) => {
const container = onboardingDialogue(page);
await container.waitFor({ state: "visible", timeout: uiTimeoutMs });
await container.getByText("I am setting this up for the first time", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await container
.getByText("I am adding a device to an existing synchronisation setup", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (mobile) await assertMobileDialogueLayout(page, container, "mobile onboarding introduction");
});
await withObsidianPage(port, async (page) => {
const container = onboardingDialogue(page);
await container.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs });
await container.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return screenshot;
}
async function openOnboardingFromSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(() => {
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const liveSyncSettings = page.locator(".sls-setting");
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({
has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
});
await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
await onboardingSetting
.getByRole("button", { name: "Rerun Wizard", exact: true })
.click({ timeout: uiTimeoutMs });
await onboardingDialogue(page).waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function closeSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const settingsContainer = page.locator(".modal-container").filter({
has: page.locator(".sls-setting"),
});
await settingsContainer.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
await settingsContainer.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
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 vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
await writeMarker(vault.path);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await requireInvitationWithoutDialogue();
const evidence = await inspectUnconfiguredStartup(cli.binary, session.cliEnv);
if (
evidence.configured ||
evidence.markerInDatabase ||
evidence.offlineScanInitialised ||
evidence.recommendedDefaults.usePluginSyncV2 !== true ||
evidence.recommendedDefaults.handleFilenameCaseSensitive !== false
) {
throw new Error(`Fresh Vault startup state did not match its contract: ${JSON.stringify(evidence)}`);
}
console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`);
const desktopInvitation = await captureDesktopInvitation();
await openOnboardingFromSettings();
const settingsIntro = await captureAndCloseIntro("onboarding-intro-settings-desktop.png", false);
await closeSettings();
const mobileInvitation = await captureAndSelectMobileInvitation();
const mobileIntro = await captureAndCloseIntro("onboarding-intro-mobile.png", true);
console.log(
`Onboarding remained opt-in and kept unconfigured startup inert. Screenshots: ${[
desktopInvitation,
mobileInvitation,
mobileIntro,
settingsIntro,
].join(", ")}`
);
} finally {
if (session) {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs).catch(() => undefined);
await session.app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+415
View File
@@ -0,0 +1,415 @@
/**
* Verifies the complete user-visible contract of the P2P status pane in real
* Obsidian: a configured CouchDB-only Vault with no P2P profile is not
* presented with P2P controls, while configured P2P devices can deliberately
* open the current pane in the appropriate workspace area.
*
* Desktop and mobile use separate Vaults, profiles, and Obsidian processes.
* Mobile mode is enabled before LiveSync's first load so that command and view
* registration observe the mobile application state, and no desktop workspace
* state can make a misplaced or restored pane appear correct.
*
* Command registration, automatic-opening policy, ribbon availability,
* workspace ownership, layout, and screenshots are kept in one scenario
* because together they describe one navigation path. Checking them in
* isolation could miss a pane which is registered correctly but opens in the
* wrong area, or one which is visible only because another session restored it.
*/
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import type { ConsoleMessage, Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000);
type ObsidianTestLeaf = {
containerEl?: HTMLElement;
view?: { getViewType?: () => string };
};
type ObsidianTestWorkspace = {
activeLeaf?: ObsidianTestLeaf;
getLeavesOfType?: (type: string) => ObsidianTestLeaf[];
getRightLeaf?: (split: boolean) => ObsidianTestLeaf | null;
rightSplit?: { containerEl?: HTMLElement };
};
type ObsidianTestApp = {
commands?: {
commands?: Record<string, unknown>;
executeCommandById(commandId: string): boolean;
};
isMobile?: boolean;
plugins?: {
plugins?: Record<
string,
{
core?: {
services?: {
API?: {
isMobile?: () => boolean;
};
};
};
}
>;
};
workspace?: ObsidianTestWorkspace;
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function openP2PStatusPane(page: Page) {
return await page.evaluate((commandId) => {
const app = (globalThis as ObsidianTestGlobal).app;
const plugin = app?.plugins?.plugins?.["obsidian-livesync"];
return {
opened: app?.commands?.executeCommandById(commandId) === true,
appIsMobile: app?.isMobile ?? null,
apiIsMobile: plugin?.core?.services?.API?.isMobile?.() ?? null,
bodyIsMobile: document.body.classList.contains("is-mobile"),
};
}, "obsidian-livesync:open-p2p-server-status");
}
async function collectP2PWorkspaceState(page: Page) {
return await page.evaluate(() => {
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
const activeLeaf = workspace?.activeLeaf;
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
const rightLeaf = workspace?.getRightLeaf?.(false);
return {
bodyClasses: document.body.className,
activeLeaf: {
type: activeLeaf?.view?.getViewType?.() ?? null,
visible: activeLeaf?.containerEl?.checkVisibility?.() ?? null,
classes: activeLeaf?.containerEl?.className ?? null,
},
p2pLeaves: p2pLeaves.map((leaf) => ({
type: leaf.view?.getViewType?.() ?? null,
visible: leaf.containerEl?.checkVisibility?.() ?? null,
classes: leaf.containerEl?.className ?? null,
})),
rightLeaf: {
type: rightLeaf?.view?.getViewType?.() ?? null,
visible: rightLeaf?.containerEl?.checkVisibility?.() ?? null,
classes: rightLeaf?.containerEl?.className ?? null,
},
visibleP2PContents: document.querySelectorAll(
".workspace-leaf-content[data-type='p2p-server-status']:not(.is-hidden)"
).length,
};
});
}
async function assertMobileP2PPlacement(page: Page): Promise<void> {
const placement = await page.evaluate(() => {
const workspace = (globalThis as ObsidianTestGlobal).app?.workspace;
const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? [];
const rightSplit = workspace?.rightSplit?.containerEl;
const rightLeaf = workspace?.getRightLeaf?.(false);
return {
p2pLeafCount: p2pLeaves.length,
inRightSplit: p2pLeaves.some(
(leaf) =>
(rightSplit?.contains(leaf.containerEl ?? null) ?? false) ||
(leaf.containerEl?.closest(".mod-right-split, .workspace-drawer.mod-right") ?? null) !== null
),
rightLeafType: rightLeaf?.view?.getViewType?.() ?? null,
p2pLeafClasses: p2pLeaves.map((leaf) => leaf.containerEl?.className ?? null),
rightSplitClasses: rightSplit?.className ?? null,
};
});
if (!placement.inRightSplit) {
throw new Error(`The mobile P2P status view was not opened in the right leaf: ${JSON.stringify(placement)}`);
}
}
async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<string> {
return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => {
const runtimeErrors: string[] = [];
const onPageError = (error: Error) => runtimeErrors.push(`pageerror: ${error.message}`);
const onConsole = (message: ConsoleMessage) => {
if (message.type() === "error") runtimeErrors.push(`console: ${message.text()}`);
};
page.on("pageerror", onPageError);
page.on("console", onConsole);
let dispatchState: Awaited<ReturnType<typeof openP2PStatusPane>> | undefined;
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
try {
dispatchState = await openP2PStatusPane(page);
if (!dispatchState.opened) {
throw new Error("The P2P status command was not registered or could not be executed.");
}
if (
mobile &&
(dispatchState.appIsMobile !== true ||
dispatchState.apiIsMobile !== true ||
dispatchState.bodyIsMobile !== true)
) {
throw new Error(
`The mobile P2P command did not observe a fully mobile application state: ${JSON.stringify(dispatchState)}`
);
}
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
} catch (error) {
const workspaceState = await collectP2PWorkspaceState(page);
console.error(
`P2P command state after failed open: ${JSON.stringify({ dispatchState, runtimeErrors })}`
);
console.error(`P2P workspace state after failed open: ${JSON.stringify(workspaceState)}`);
throw error;
} finally {
page.off("pageerror", onPageError);
page.off("console", onConsole);
}
if (mobile) {
await assertMobileP2PPlacement(page);
}
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
await pane.getByText("Connection:", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
await pane.getByRole("button", { name: "Open connection" }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const remoteSelector = pane.getByRole("combobox", { name: "Select active P2P remote" });
await remoteSelector.waitFor({ state: "visible", timeout: uiTimeoutMs });
const remoteSelectionDeadline = Date.now() + uiTimeoutMs;
let remoteConfigurationId = "";
while (Date.now() < remoteSelectionDeadline) {
remoteConfigurationId = (await remoteSelector.inputValue()).trim();
if (remoteConfigurationId !== "") break;
await page.waitForTimeout(50);
}
if (remoteConfigurationId === "") {
throw new Error("The configured P2P status pane did not select an active P2P remote.");
}
if (
(await pane.getByText("Please select an active P2P remote configuration to change P2P sync targets.").count()) !==
0
) {
throw new Error("The configured P2P status pane still requested an active P2P remote.");
}
await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" });
if (mobile) {
await assertLocatorWithinViewport(page, pane, { label: "mobile P2P status pane" });
}
await dismissOpenNotices(page);
});
}
async function assertP2PUIIsOptIn(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const state = await page.evaluate(() => {
const commands = (globalThis as ObsidianTestGlobal).app?.commands?.commands ?? {};
return {
currentCommand: commands["obsidian-livesync:open-p2p-server-status"] !== undefined,
legacyCommand: commands["obsidian-livesync:open-p2p-replicator"] !== undefined,
};
});
if (!state.currentCommand) {
throw new Error("The current P2P status command was not registered.");
}
if (state.legacyCommand) {
throw new Error("The retired P2P pane command is still exposed.");
}
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
throw new Error("The P2P status pane opened automatically for a CouchDB user without P2P configured.");
}
if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) {
throw new Error("The P2P ribbon icon was shown without a P2P configuration.");
}
});
}
async function assertConfiguredP2PUIIsAvailable(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.locator(".livesync-ribbon-p2p-server-status").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) {
throw new Error("The configured P2P status pane opened before the user requested it.");
}
});
}
async function assertConfiguredP2PCommandIsAvailable(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const state = await page.evaluate(() => {
const app = (globalThis as ObsidianTestGlobal).app;
const commands = app?.commands?.commands ?? {};
return {
commandRegistered: commands["obsidian-livesync:open-p2p-server-status"] !== undefined,
openPaneCount: app?.workspace?.getLeavesOfType?.("p2p-server-status").length ?? 0,
};
});
if (!state.commandRegistered) {
throw new Error("The configured P2P status command was not registered in mobile mode.");
}
if (state.openPaneCount !== 0) {
throw new Error("The configured P2P status pane opened before the mobile user requested it.");
}
});
}
async function dismissOpenNotices(page: Page): Promise<void> {
const deadline = Date.now() + uiTimeoutMs;
let quietSince = Date.now();
while (Date.now() < deadline) {
const dismissed = await page.evaluate(() => {
const notices = (Array.from(document.querySelectorAll(".notice")) as HTMLElement[]).filter(
(notice) => notice.checkVisibility?.() ?? notice.offsetParent !== null
);
for (const notice of notices) {
const closeButton = notice.querySelector(".notice-close-button") as HTMLElement | null;
// Obsidian 1.12 does not render a separate close control for
// every Notice; clicking the Notice itself is its standard
// dismiss action.
(closeButton ?? notice).click();
}
return notices.length;
});
if (dismissed === 0) {
if (Date.now() - quietSince >= 500) {
return;
}
await page.waitForTimeout(100);
continue;
}
quietSince = Date.now();
await page.waitForTimeout(50);
}
throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot.");
}
function createBaseP2PPluginData(): Record<string, unknown> {
return createE2eCouchDbPluginData(
{
uri: "http://127.0.0.1:5984",
username: "",
password: "",
dbName: "p2p-pane-ui-only",
},
{
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
P2P_Enabled: false,
P2P_AutoStart: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
}
);
}
function createConfiguredP2PPluginData(): Record<string, unknown> {
const pluginData = {
...createBaseP2PPluginData(),
P2P_roomID: "configured-p2p-room",
P2P_passphrase: "configured-p2p-passphrase",
};
upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "p2p", {
id: "e2e-p2p",
name: "P2P Remote",
activateForP2P: true,
});
return pluginData;
}
async function withP2PSession(
binary: string,
cliBinary: string,
pluginData: Record<string, unknown>,
verify: () => Promise<void>,
options: { mobileBeforePluginStart?: boolean } = {}
): Promise<void> {
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData,
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
lifecycle: options.mobileBeforePluginStart
? {
beforePluginStart: async ({ remoteDebuggingPort }) => {
await setObsidianMobileTestModeBeforePluginStart(
remoteDebuggingPort,
true,
uiTimeoutMs
);
},
}
: undefined,
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await verify();
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
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(", ")}`);
}
await withP2PSession(binary, cli.binary, createBaseP2PPluginData(), async () => {
await assertP2PUIIsOptIn();
});
await withP2PSession(
binary,
cli.binary,
createConfiguredP2PPluginData(),
async () => {
await assertConfiguredP2PUIIsAvailable();
const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false);
console.log(
`Configured P2P status UI remained opt-in and was reachable on desktop. Screenshot: ${desktopScreenshot}`
);
}
);
await withP2PSession(
binary,
cli.binary,
createConfiguredP2PPluginData(),
async () => {
await assertConfiguredP2PCommandIsAvailable();
const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true);
console.log(
`Configured P2P status UI remained opt-in and was reachable on mobile. Screenshot: ${mobileScreenshot}`
);
},
{ mobileBeforePluginStart: true }
);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,589 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { connect } from "node:net";
import { join } from "node:path";
import { promisify } from "node:util";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertEqual, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
captureGuideDialogue,
confirmFastFetch,
confirmRebuild,
enterSetupURI,
finishInitialisation,
generateSetupURIFromDevice,
modalByTitle,
resumeCompatibilityReviewIfShown,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
import {
captureObsidianElement,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const execFileAsync = promisify(execFile);
const captures: SetupCaptureNames = { scenario: "p2p-setup-uri", guide: "p2p-setup" };
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS ?? 60000);
const noteFromFirst = "E2E/p2p/from-first.md";
const noteFromSecond = "E2E/p2p/from-second.md";
const firstContent = "# P2P from the first device\n\nThis note was fetched directly from the first device.\n";
const secondContent = "# P2P from the second device\n\nThis note completed the return journey.\n";
type RunnerContext = {
binary: string;
cliBinary: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort(process.env);
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
async function runDeno(script: 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",
"--allow-env",
script,
],
{ cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 }
);
return stdout;
}
async function generateBootstrapSetupURI(relay: string): Promise<SetupArtifact> {
const setupPassphrase = randomBytes(24).toString("base64url");
const output = await runDeno("utils/setup/generate_setup_uri.ts", {
...process.env,
remote_type: "p2p",
p2p_relays: relay,
p2p_room_id: `real-obsidian-${randomBytes(12).toString("hex")}`,
p2p_passphrase: randomBytes(24).toString("base64url"),
p2p_app_id: "self-hosted-livesync-real-obsidian-e2e",
p2p_auto_start: "false",
p2p_auto_broadcast: "false",
passphrase: randomBytes(24).toString("base64url"),
uri_passphrase: setupPassphrase,
});
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 P2P Setup URI.");
return { setupURI, setupPassphrase };
}
async function waitForRelay(relay: string): Promise<void> {
const endpoint = new URL(relay);
const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80));
const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname;
const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000);
let lastError: unknown;
while (Date.now() < deadline) {
try {
await new Promise<void>((resolve, reject) => {
const socket = connect({ host, port });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("timeout", () => {
socket.destroy();
reject(new Error("connection timed out"));
});
socket.once("error", reject);
});
await new Promise((resolve) => setTimeout(resolve, 1500));
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new Error(
`P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : lastError}`
);
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
port: number
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(port),
});
context.activeSessions.add(session);
return session;
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) {
await session.app.stop();
context.activeSessions.delete(session);
}
}
async function writeNote(
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
);
await waitForLocalDatabaseEntry(cliBinary, environment, path);
}
async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 60000);
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(join(vault.path, path), "utf8");
if (lastContent === expected) return;
} 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 readReflectionDiagnostics(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string
): Promise<unknown> {
return await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);",
"const chunks=entry&&Array.isArray(entry.children)?await Promise.all(entry.children.map(async(id)=>{",
"const chunk=await core.localDatabase.getDBEntry(id,undefined,false,true).catch(()=>false);",
"return {id,found:!!chunk};",
"})):[];",
"return JSON.stringify({",
"suspendFileWatching:settings.suspendFileWatching,",
"suspendParseReplicationResult:settings.suspendParseReplicationResult,",
"configured:settings.isConfigured,",
"entry:entry?{id:entry._id,path:entry.path,children:entry.children||[]}:false,",
"chunks,",
"databaseQueueCount:core.services.replication.databaseQueueCount?.value,",
"storageApplyingCount:core.services.replication.storageApplyingCount?.value,",
"replicationResultCount:core.services.replication.replicationResultCount?.value,",
"});",
"})()",
].join(""),
environment
);
}
async function executeCommand(port: number, commandId: string): Promise<void> {
const opened = await withObsidianPage(port, async (page) => {
return await page.evaluate(
(id) =>
(
globalThis as typeof globalThis & {
app?: { commands?: { executeCommandById(commandId: string): boolean } };
}
).app?.commands?.executeCommandById(id) === true,
commandId
);
});
if (!opened) throw new Error(`Obsidian command was not available: ${commandId}`);
}
async function openP2PStatus(port: number, filename: string): Promise<string> {
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
const open = pane.getByRole("button", { name: "Open connection" });
if (await open.isVisible()) {
const blockingDialogues = await page.locator(".modal-container:visible").evaluateAll((elements) =>
elements.map((element) => ({
title: element.querySelector(".modal-title")?.textContent?.trim() ?? "",
text: element.textContent?.trim().replace(/\s+/gu, " ").slice(0, 240) ?? "",
}))
);
if (blockingDialogues.length > 0) {
throw new Error(
`P2P connection control is blocked by a dialogue: ${JSON.stringify(blockingDialogues)}`
);
}
await open.click({ timeout: uiTimeoutMs });
}
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
});
return await captureObsidianElement(port, filename, (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
return heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
});
}
async function reconnectP2PStatus(port: number): Promise<void> {
await executeCommand(port, "obsidian-livesync:open-p2p-server-status");
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Signalling Status" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
const disconnect = pane.getByRole("button", { name: "Disconnect", exact: true });
if (await disconnect.isVisible()) {
await disconnect.click({ timeout: uiTimeoutMs });
}
const open = pane.getByRole("button", { name: "Open connection", exact: true });
await open.waitFor({ state: "visible", timeout: uiTimeoutMs });
await open.click({ timeout: uiTimeoutMs });
await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function acceptConnectionRequests(
ports: readonly number[],
stop: () => boolean,
screenshots: string[]
): Promise<void> {
const captured = new Set<number>();
while (!stop()) {
for (const port of ports) {
const visible = await withObsidianPage(port, async (page) => {
return await modalByTitle(page, "P2P Connection Request").isVisible();
}).catch(() => false);
if (!visible) continue;
if (!captured.has(port)) {
const requestNumber =
screenshots.filter((filename) => filename.includes("guide-p2p-setup-connection-request-")).length +
1;
screenshots.push(
await captureGuideDialogue(
port,
`guide-p2p-setup-connection-request-${requestNumber}.png`,
"P2P Connection Request"
)
);
captured.add(port);
}
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "P2P Connection Request")
.getByRole("button", { name: "Accept", exact: true })
.click({ timeout: uiTimeoutMs });
});
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
async function fetchFromFirstPeer(
sessionA: ObsidianLiveSyncSession,
portA: number,
portB: number,
screenshots: string[]
): Promise<void> {
try {
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.locator(".peer-item").first().waitFor({ state: "visible", timeout: uiTimeoutMs });
});
} catch (error) {
const firstDeviceAlive = sessionA.app.process.exitCode === null && sessionA.app.process.signalCode === null;
const firstDeviceUi = await withObsidianPage(portA, async (page) => {
return await page.locator("body").innerText();
}).catch(() => undefined);
const secondDeviceDialogue = await withObsidianPage(portB, async (page) => {
return await modalByTitle(page, "P2P Rebuild").innerText();
}).catch(() => undefined);
throw new Error(
[
error instanceof Error ? error.message : String(error),
`First Obsidian process alive: ${firstDeviceAlive}`,
`First Obsidian CDP reachable: ${firstDeviceUi !== undefined}`,
firstDeviceUi === undefined ? undefined : `First device UI: ${firstDeviceUi.slice(0, 1_500)}`,
secondDeviceDialogue === undefined
? undefined
: `Second-device P2P Rebuild dialogue: ${secondDeviceDialogue.slice(0, 1_500)}`,
sessionA.app.output().stderr
? `First Obsidian stderr: ${sessionA.app.output().stderr.slice(-2_000)}`
: undefined,
]
.filter(Boolean)
.join("\n")
);
}
screenshots.push(await captureGuideDialogue(portB, "guide-p2p-setup-select-first-device.png", "P2P Rebuild"));
let finished = false;
const acceptor = acceptConnectionRequests([portA, portB], () => finished, screenshots);
try {
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
await modal
.locator(".peer-item")
.first()
.getByRole("button", { name: "Sync", exact: true })
.click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
} finally {
finished = true;
await acceptor;
}
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "P2P Rebuild");
if (await modal.isVisible()) {
await modal.getByRole("button", { name: "Skip and close" }).click({ timeout: uiTimeoutMs });
}
});
}
async function replicateFromStatusPane(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
const replicate = pane.getByRole("button", { name: "Replicate now" }).first();
await replicate.waitFor({ state: "visible", timeout: uiTimeoutMs });
await replicate.click({ timeout: uiTimeoutMs });
});
}
async function waitForDetectedPeer(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
await heading.waitFor({ state: "visible", timeout: uiTimeoutMs });
const pane = heading.locator(
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
);
await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
await pane.getByRole("button", { name: "Replicate now" }).first().waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
}
async function capturePeerActionsMenu(port: number): Promise<string> {
await withObsidianPage(port, async (page) => {
const moreActions = page.getByRole("button", { name: /^More actions for /u }).first();
await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs });
await moreActions.click({ timeout: uiTimeoutMs });
const menu = page.locator(".menu:visible").last();
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const label of [
"Synchronise when this device connects",
"Follow whenever this device connects",
"Include in the P2P synchronisation command",
]) {
await menu.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
const layout = await menu.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
insideViewport:
rect.left >= 0 &&
rect.top >= 0 &&
rect.right <= document.documentElement.clientWidth &&
rect.bottom <= document.documentElement.clientHeight,
hasHorizontalOverflow: element.scrollWidth > element.clientWidth,
};
});
if (!layout.insideViewport || layout.hasHorizontalOverflow) {
throw new Error(`P2P peer actions menu did not fit the viewport: ${JSON.stringify(layout)}`);
}
});
const screenshot = await captureObsidianElement(
port,
"guide-p2p-setup-peer-actions-menu.png",
(page) => page.locator(".menu:visible").last()
);
await withObsidianPage(port, async (page) => {
await page.keyboard.press("Escape");
await page.locator(".menu:visible").waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
return screenshot;
}
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
await withObsidianPage(port, async (page) => {
await page.evaluate((notePath) => {
const obsidian = globalThis as typeof globalThis & {
app?: {
workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise<void> };
};
};
return obsidian.app?.workspace?.openLinkText(notePath, "", false);
}, path);
});
await captureObsidianPage(port, `${filename}.full.png`, async (page) => {
await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: uiTimeoutMs });
});
return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first());
}
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 relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`;
await waitForRelay(relay);
const bootstrapArtifact = await generateBootstrapSetupURI(relay);
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const [portA, portB] = sessionPorts();
const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() };
const screenshots: string[] = [];
try {
console.log(`Temporary P2P relay: ${relay}`);
console.log(`Temporary P2P devices: ${vaultA.name}, ${vaultB.name}`);
const sessionA = await startSession(context, vaultA, portA);
screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures));
screenshots.push(await captureAndStartInitialisation(portA, "new", captures));
screenshots.push(await confirmRebuild(portA, captures));
screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures));
const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv);
await resumeCompatibilityReviewIfShown(portA);
assertEqual(firstState.p2pEnabled, true, "The first device did not enable P2P.");
assertEqual(firstState.p2pRelays, relay, "The first device did not activate the P2P relay.");
await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent);
const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures);
if (generated.artifact.setupURI === bootstrapArtifact.setupURI) {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
screenshots.push(...generated.screenshots);
screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-first-device-connected.png"));
const sessionB = await startSession(context, vaultB, portB);
screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures));
screenshots.push(await captureAndStartInitialisation(portB, "existing", captures));
screenshots.push(...(await confirmFastFetch(portB, captures)));
await fetchFromFirstPeer(sessionA, portA, portB, screenshots);
await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromFirst, {
timeoutMs: uiTimeoutMs,
});
const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
await resumeCompatibilityReviewIfShown(portB);
assertEqual(secondState.p2pEnabled, true, "The second device did not enable P2P.");
assertEqual(secondState.p2pRelays, relay, "The second device did not import the P2P relay.");
assertEqual(secondState.p2pRoomId, firstState.p2pRoomId, "The two devices did not join the same P2P room.");
try {
await waitForPathContent(vaultB, noteFromFirst, firstContent);
} catch (error) {
const diagnostics = await readReflectionDiagnostics(context.cliBinary, sessionB.cliEnv, noteFromFirst);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nReflection diagnostics: ${JSON.stringify(diagnostics)}`
);
}
screenshots.push(
await captureNote(portB, noteFromFirst, "P2P from the first device", "guide-p2p-setup-first-to-second.png")
);
console.log("P2P workflow: initial Fetch from the first device completed.");
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
await reconnectP2PStatus(portA);
await reconnectP2PStatus(portB);
await waitForDetectedPeer(portA);
screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-devices-connected.png"));
screenshots.push(await capturePeerActionsMenu(portA));
console.log("P2P workflow: peer actions menu verified; starting the return journey.");
let returnJourneyFinished = false;
const returnJourneyAcceptor = acceptConnectionRequests(
[portA, portB],
() => returnJourneyFinished,
screenshots
);
try {
await replicateFromStatusPane(portA);
console.log("P2P workflow: return replication requested; waiting for the second device's note.");
await waitForPathContent(vaultA, noteFromSecond, secondContent);
console.log("P2P workflow: return note reached the first device.");
} finally {
returnJourneyFinished = true;
await returnJourneyAcceptor;
console.log("P2P workflow: return connection approval loop stopped.");
}
screenshots.push(
await captureNote(
portA,
noteFromSecond,
"P2P from the second device",
"guide-p2p-setup-second-to-first.png"
)
);
console.log(`P2P Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`);
} finally {
console.log("P2P workflow: stopping tracked Obsidian sessions.");
await stopSessions(context).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
console.log("P2P workflow: disposing temporary Vaults.");
await vaultA.dispose();
await vaultB.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+469
View File
@@ -0,0 +1,469 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { REVIEW_HARNESS_STATE_KEY } from "../../../src/features/ReviewHarness/reviewHarnessController.ts";
import { REVIEW_HARNESS_FIXTURE_ROOT } from "../../../src/features/ReviewHarness/reviewHarnessVaultFixture.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianDialogue,
captureObsidianPage,
obsidianRemoteDebuggingPort,
withObsidianPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS ?? 15000);
type ObsidianTestApp = {
commands?: { executeCommandById(commandId: string): boolean };
plugins?: { plugins: Record<string, unknown> };
vault?: { getAbstractFileByPath(path: string): unknown | null };
};
type ReviewHarnessTestGlobal = typeof globalThis & {
app?: ObsidianTestApp;
reviewHarnessCopiedReport?: string;
};
type ReviewHarnessReadinessSnapshot = {
coreAvailable: boolean;
databaseReady?: boolean;
appReady?: boolean;
configured?: boolean;
remoteType?: string;
settingVersion?: number;
suspended?: boolean;
unresolvedMessages: string[];
};
const sensitiveDiagnosticLine =
/security seed|passphrase|password|credential|secret|access.?key|jwt.?key|authori[sz]ation|obsidian:\/\/setuplivesync|sls\+/iu;
const interruptedStartupMessages = [
"No replicator has been activated or has not been initialised yet.",
"Self-hosted LiveSync cannot be initialised, exiting loading.",
];
function redactDiagnosticLine(line: string): string {
if (sensitiveDiagnosticLine.test(line)) return "[REDACTED SENSITIVE LOG LINE]";
return line.replace(/\bhttps?:\/\/[^/\s:@]+:[^@\s/]+@/giu, "https://[REDACTED]@");
}
async function assertNoInterruptedStartupNotice(stage: string): Promise<void> {
const notices = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.waitForTimeout(1500);
return await page.locator(".notice").allTextContents();
});
const interrupted = notices.filter((notice) =>
interruptedStartupMessages.some((message) => notice.includes(message))
);
if (interrupted.length > 0) {
throw new Error(`LiveSync emitted an interrupted-startup Notice during ${stage}: ${interrupted.join(" | ")}`);
}
console.log(`No interrupted-startup Notice observed during ${stage}.`);
}
async function captureReadinessFailure(
cliBinary: string,
session: ObsidianLiveSyncSession,
readinessError: unknown
): Promise<void> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(outputDirectory, { recursive: true });
const captureErrors: string[] = [];
let screenshotPath: string | undefined;
try {
screenshotPath = await captureObsidianPage(
obsidianRemoteDebuggingPort(),
"review-harness-core-not-ready.png",
async () => undefined
);
} catch (error) {
captureErrors.push(`screenshot: ${error instanceof Error ? error.message : String(error)}`);
}
let readiness: ReviewHarnessReadinessSnapshot | undefined;
try {
readiness = await evalObsidianJson<ReviewHarnessReadinessSnapshot>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync']?.core;",
"if(!core)return JSON.stringify({coreAvailable:false,unresolvedMessages:[]});",
"const settings=core.services.setting.currentSettings();",
"let unresolvedMessages=[];",
"try{",
"unresolvedMessages=(await core.services.appLifecycle.getUnresolvedMessages()).flat()",
".filter((message)=>message!==undefined&&message!==null)",
".map((message)=>String(message)).slice(-50);",
"}catch(error){unresolvedMessages=[`Could not inspect unresolved messages: ${String(error)}`];}",
"return JSON.stringify({",
"coreAvailable:true,",
"databaseReady:core.services.database.isDatabaseReady(),",
"appReady:core.services.appLifecycle.isReady(),",
"configured:settings?.isConfigured===true,",
"remoteType:settings?.remoteType??'',",
"settingVersion:settings?.settingVersion,",
"suspended:core.services.appLifecycle.isSuspended(),",
"unresolvedMessages,",
"});",
"})()",
].join(""),
session.cliEnv
);
readiness.unresolvedMessages = readiness.unresolvedMessages.map(redactDiagnosticLine);
} catch (error) {
captureErrors.push(`readiness snapshot: ${error instanceof Error ? error.message : String(error)}`);
}
let recentLog: string[] = [];
try {
recentLog = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const opened = await page.evaluate(
(commandId) =>
(globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:view-log"
);
if (!opened) throw new Error("The Show log command was not registered.");
const logPane = page.locator(".logpane");
await logPane.waitFor({ state: "visible", timeout: 5000 });
return (await logPane.locator(".log pre").allTextContents()).slice(-80).map(redactDiagnosticLine);
});
} catch (error) {
captureErrors.push(`recent log: ${error instanceof Error ? error.message : String(error)}`);
}
const resultPath = join(outputDirectory, "review-harness-core-not-ready.json");
await writeFile(
resultPath,
`${JSON.stringify(
{
capturedAt: new Date().toISOString(),
failure: readinessError instanceof Error ? readinessError.message : String(readinessError),
screenshotPath,
readiness,
recentLog,
captureErrors,
},
null,
2
)}\n`,
"utf8"
);
if (screenshotPath) console.error(`Review Harness core readiness screenshot: ${screenshotPath}`);
console.error(`Review Harness core readiness diagnostics: ${resultPath}`);
}
async function openHarness(): Promise<void> {
const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
return await page.evaluate(
(commandId) => (globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:open-review-harness"
);
});
if (!opened) throw new Error("The Review Harness command was not registered.");
}
async function waitForHarness(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.locator('[data-testid="review-harness"]').waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function keepCompatibilityPaused(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await summary.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs });
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function runAutomaticScenarios(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.locator('[data-testid="review-harness-run-automatic"]').click({ timeout: uiTimeoutMs });
for (const id of ["settings-lifecycle", "p2p-composition"]) {
await harness
.locator(`[data-testid="review-harness-result-${id}"]`)
.getByText("Passed:", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
}
});
}
async function runVaultFixture(): Promise<string> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page
.locator('[data-testid="review-harness-run-vault-round-trip"]')
.click({ timeout: uiTimeoutMs });
const confirmation = page.locator(".modal-container").filter({
has: page.getByText("Review Harness: Vault fixture access", { exact: true }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const screenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-vault-confirmation.png",
async (page) => {
const confirmation = page.locator(".modal-container").filter({
has: page.getByText("Review Harness: Vault fixture access", { exact: true }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, confirmation, { label: "Vault fixture confirmation" });
}
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
const confirmation = page.locator(".modal-container").filter({
has: page.getByText("Review Harness: Vault fixture access", { exact: true }),
});
await confirmation.getByRole("button", { name: "Yes" }).click({ timeout: uiTimeoutMs });
await harness
.locator('[data-testid="review-harness-result-vault-round-trip"]')
.getByText("Passed:", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const fixtureRemoved = await page.evaluate(
(root) => (globalThis as ReviewHarnessTestGlobal).app?.vault?.getAbstractFileByPath(root) === null,
REVIEW_HARNESS_FIXTURE_ROOT
);
if (!fixtureRemoved) throw new Error("The Review Harness fixture root remained after the scenario.");
});
return screenshot;
}
async function restartAndResumeHarness(): Promise<string> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-run-compatibility-review"]')
.click({ timeout: uiTimeoutMs });
await harness
.locator('[data-testid="review-harness-result-compatibility-review"]')
.getByText("Waiting for review:", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await harness.locator('[data-testid="review-harness-restart"]').click({ timeout: uiTimeoutMs });
});
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.waitForFunction(
() => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) return false;
const core = (plugin as { core: { services: { appLifecycle: { isReady(): boolean } } } }).core;
return core.services.appLifecycle.isReady();
},
undefined,
{ timeout: uiTimeoutMs * 2 }
);
});
await keepCompatibilityPaused();
await waitForHarness();
return await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-resumed.png",
async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-resumed"]')
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const continuationRemoved = await page.evaluate((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) === "";
}, 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" });
}
);
}
async function completeResumedCompatibilityStep(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness
.locator('[data-testid="review-harness-open-compatibility-review"]')
.click({ timeout: uiTimeoutMs });
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await summary.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs });
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await harness
.locator('[data-testid="review-harness-result-compatibility-review"]')
.getByText("The device-local compatibility pause was reviewed and cleared.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
}
async function copyAndReadReport(): Promise<string> {
return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(`
globalThis.reviewHarnessCopiedReport = undefined;
navigator.clipboard.writeText = function (value) {
globalThis.reviewHarnessCopiedReport = value;
return Promise.resolve();
};
`);
await page.locator('[data-testid="review-harness-copy-report"]').click({ timeout: uiTimeoutMs });
await page.waitForFunction(
() => typeof (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport === "string",
undefined,
{ timeout: uiTimeoutMs }
);
return await page.evaluate(
() => (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport ?? ""
);
});
}
async function verifyMobileHarness(): Promise<string> {
await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
if (await harness.isVisible()) return;
await page.evaluate(async (viewType) => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
throw new Error("Self-hosted LiveSync is unavailable in mobile test mode.");
}
const core = (plugin as {
core: { services: { API: { showWindow(type: string): Promise<void> } } };
}).core;
await core.services.API.showWindow(viewType);
}, "self-hosted-livesync-review-harness");
});
return await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-mobile.png",
async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, harness, { label: "mobile Review Harness" });
const heading = harness.getByRole("heading", { name: "Self-hosted LiveSync review harness" });
await assertLocatorWithinSafeArea(page, heading, {
label: "mobile Review Harness heading",
safeAreaInsets: iPhoneSafeArea,
});
for (const testId of [
"review-harness-run-automatic",
"review-harness-run-full",
"review-harness-copy-report",
]) {
await assertLocatorHasMinimumTouchTarget(page, harness.locator(`[data-testid="${testId}"]`), {
label: testId,
});
}
}
);
}
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 vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
settingVersion: CURRENT_SETTING_VERSION,
isConfigured: true,
additionalSuffixOfDatabaseName: "",
enableDebugTools: true,
notifyThresholdOfRemoteStorageSize: 0,
P2P_Enabled: false,
P2P_AutoStart: false,
liveSync: false,
syncOnSave: false,
syncOnEditorSave: true,
syncOnStart: false,
syncOnFileOpen: true,
syncAfterMerge: false,
periodicReplication: true,
},
});
await assertNoInterruptedStartupNotice("plug-in session start");
try {
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
} catch (error) {
await captureReadinessFailure(cli.binary, session, error).catch((diagnosticError: unknown) => {
console.error(
`Could not capture Review Harness readiness diagnostics: ${
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError)
}`
);
});
throw error;
}
await assertNoInterruptedStartupNotice("core readiness");
await keepCompatibilityPaused();
await openHarness();
await waitForHarness();
const initialScreenshot = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"review-harness-initial.png",
async (page) => {
const harness = page.locator('[data-testid="review-harness"]');
await harness.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertNoHorizontalOverflow(page, harness, { label: "Review Harness" });
}
);
await runAutomaticScenarios();
const vaultConfirmationScreenshot = await runVaultFixture();
const resumedScreenshot = await restartAndResumeHarness();
await completeResumedCompatibilityStep();
const report = await copyAndReadReport();
if (!report.includes("## Self-hosted LiveSync Review Harness report")) {
throw new Error("The copied Review Harness report was not Markdown evidence.");
}
for (const forbidden of [vault.name, REVIEW_HARNESS_FIXTURE_ROOT]) {
if (report.includes(forbidden)) throw new Error(`The Review Harness report exposed local state: ${forbidden}`);
}
const mobileScreenshot = await verifyMobileHarness();
console.log(
`Review Harness passed one-shot, fixture, report, and mobile checks. Screenshots: ${[
initialScreenshot,
vaultConfirmationScreenshot,
resumedScreenshot,
mobileScreenshot,
].join(", ")}`
);
} finally {
if (session) await session.app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,735 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { evalObsidianJson } from "../runner/cli.ts";
import {
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import type { Locator, Page } from "playwright";
const path = "revision-repair.md";
const healthyDeletedPath = "healthy-logical-deletion.md";
const baseContent = "Revision repair\n\nShared base.\n";
const branchContents = [
`Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`,
`Revision repair\n\nRight branch.\n${"R".repeat(4096)}\n`,
] as const;
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS ?? 15000);
type BrokenRevisionFixture = {
winnerRevision: string;
conflictRevision: string;
missingChunkId: string;
};
type RevisionTree = {
winnerRevision: string;
conflictRevisions: string[];
};
type VaultWinnerState = {
matches: boolean;
winnerRevision: string;
};
type ObsidianSettingsController = {
open(): void;
openTabById(tabId: string): void;
};
type ObsidianTestGlobal = typeof globalThis & {
app?: {
setting?: ObsidianSettingsController;
};
};
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(baseContent)};`,
"let file=app.vault.getAbstractFileByPath(path);",
"if(!file) file=await app.vault.create(path,content);",
"await app.workspace.getLeaf(false).openFile(file);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(healthyDeletedPath)};`,
`const content=${JSON.stringify(`Healthy logical deletion\n\n${"D".repeat(4096)}\n`)};`,
"let file=app.vault.getAbstractFileByPath(path);",
"if(!file) file=await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
await waitForLocalDatabaseEntry(cliBinary, env, healthyDeletedPath);
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(healthyDeletedPath)};`,
`const timeoutMs=${JSON.stringify(uiTimeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Logical-deletion fixture is missing from the Vault: ${path}`);",
"await app.vault.delete(file);",
"const id=await core.services.path.path2id(path);",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
" await core.services.fileProcessing.commitPendingFileEvents();",
" const doc=await core.localDatabase.localDatabase.get(id,{conflicts:true}).catch(()=>false);",
" if(!app.vault.getAbstractFileByPath(path)&&doc?.deleted&&(doc._conflicts??[]).length===0){",
" return JSON.stringify(doc._rev);",
" }",
" await sleep(250);",
"}",
"throw new Error(`Timed out waiting for a healthy logical deletion: ${path}`);",
"})()",
].join(""),
env
);
}
async function createBrokenConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
baseRevision: string
): Promise<BrokenRevisionFixture> {
return await evalObsidianJson<BrokenRevisionFixture>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const baseRevision=${JSON.stringify(baseRevision)};`,
`const contents=${JSON.stringify(branchContents)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const id=await core.services.path.path2id(path);",
"for(const [index,content] of contents.entries()){",
" const blob=new Blob([content],{type:'text/plain'});",
" const now=Date.now()+index;",
" const result=await core.localDatabase.putDBEntry({",
" _id:id,path,data:blob,ctime:now,mtime:now,",
" size:(await blob.arrayBuffer()).byteLength,children:[],",
" datatype:'plain',type:'plain',eden:{},",
" },false,baseRevision);",
" if(!result?.ok) throw new Error(`Could not create repair conflict: ${path}`);",
"}",
"const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});",
"const conflictRevision=tree._conflicts?.[0];",
"if(!tree._rev||!conflictRevision){",
" throw new Error(`Repair fixture did not produce two live revisions: ${path}`);",
"}",
"const conflict=await core.localDatabase.localDatabase.get(id,{rev:conflictRevision});",
"const embedded=new Set(Object.keys(conflict.eden??{}));",
"const missingChunkId=(conflict.children??[]).find((child)=>!embedded.has(child));",
"if(!missingChunkId){",
" throw new Error(`Repair fixture did not create an independent chunk: ${conflictRevision}`);",
"}",
"const chunk=await core.localDatabase.localDatabase.get(missingChunkId);",
"await core.localDatabase.localDatabase.remove(chunk);",
"core.localDatabase.clearCaches();",
"const unreadable=await core.localDatabase.getDBEntry(path,{rev:conflictRevision},false,true,true);",
"if(unreadable!==false){",
" throw new Error(`The selected revision remained readable after its chunk was removed: ${conflictRevision}`);",
"}",
"return JSON.stringify({",
" winnerRevision:tree._rev,",
" conflictRevision,",
" missingChunkId,",
"});",
"})()",
].join(""),
env
);
}
async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RevisionTree> {
return await evalObsidianJson<RevisionTree>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const id=await core.services.path.path2id(path);",
"const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});",
"return JSON.stringify({",
" winnerRevision:tree._rev,",
" conflictRevisions:tree._conflicts??[],",
"});",
"})()",
].join(""),
env
);
}
async function readVaultWinnerState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<VaultWinnerState> {
return await evalObsidianJson<VaultWinnerState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Vault file is missing: ${path}`);",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
"if(!entry||!entry._rev) throw new Error(`Database winner is missing: ${path}`);",
"const vaultContent=await app.vault.read(file);",
"const data=Array.isArray(entry.data)?entry.data:[entry.data];",
"const databaseContent=await new Blob(data).text();",
"return JSON.stringify({",
" matches:vaultContent===databaseContent,",
" winnerRevision:entry._rev,",
"});",
"})()",
].join(""),
env
);
}
async function readFileReflectionProvenance(
cliBinary: string,
env: NodeJS.ProcessEnv,
targetPath = path
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
"return JSON.stringify((await store.get(path))??null);",
"})()",
].join(""),
env
);
}
function repairCard(settings: Locator): Locator {
return settings.locator(".sls-repair-result").filter({ hasText: path });
}
function revisionCard(settings: Locator, revision: string): Locator {
return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision });
}
async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise<Locator> {
const actionButton = revisionCard(settings, revision).getByRole("button", {
name: `More actions for revision ${revision}`,
exact: true,
});
await actionButton.locator("svg.lucide-wrench").waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await actionButton.click({ timeout: uiTimeoutMs });
const menu = page.locator(".menu:visible").last();
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
const box = await menu.boundingBox();
const viewport = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
if (
box === null ||
box.y < 0 ||
box.y + box.height > viewport.height - 4
) {
throw new Error(
`Revision action menu is outside the viewport: ${JSON.stringify({
box,
viewport,
})}`
);
}
return menu;
}
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise<void> {
const menu = await openRevisionActionMenu(page, settings, revision);
const item = menu.getByText(action, { exact: true });
await item.waitFor({ state: "visible", timeout: uiTimeoutMs });
await item.click({ timeout: uiTimeoutMs });
}
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"core.localDatabase.clearCaches();",
"await core.services.conflict.queueCheckFor(path);",
"await core.services.conflict.ensureAllProcessed();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
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 cliBinary = cli.binary;
const vault = await createTemporaryVault("obsidian-livesync-revision-repair-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
isConfigured: true,
liveSync: false,
remoteType: "",
couchDB_URI: "",
couchDB_DBNAME: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
remoteConfigurations: {},
activeConfigurationId: "",
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
disableMarkdownAutoMerge: true,
showMergeDialogOnlyOnActive: true,
useEden: false,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv);
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
const healthyDeletionProvenance = await readFileReflectionProvenance(
cliBinary,
session.cliEnv,
healthyDeletedPath
);
if (healthyDeletionProvenance !== null) {
throw new Error(
`A healthy logical deletion retained Vault provenance indefinitely: ${JSON.stringify({
healthyDeletedPath,
healthyDeletionRevision,
healthyDeletionProvenance,
})}`
);
}
await requestConflictCheck(cliBinary, session.cliEnv);
const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv);
if (
afterAutomaticCheck.winnerRevision !== fixture.winnerRevision ||
!afterAutomaticCheck.conflictRevisions.includes(fixture.conflictRevision)
) {
throw new Error(
`Automatic conflict checking discarded the unreadable revision: ${JSON.stringify({
fixture,
afterAutomaticCheck,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.evaluate(() => {
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const settings = page.locator(".sls-setting");
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
const verifySetting = settings.locator(".setting-item").filter({
has: page.getByText("Inspect conflicts and file/database differences", {
exact: true,
}),
});
await verifySetting.getByRole("button", { name: "Begin inspection", exact: true }).click({
timeout: uiTimeoutMs,
});
const card = repairCard(settings);
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) {
throw new Error(
`File/database inspection reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).`
);
}
const winnerRevision = revisionCard(settings, fixture.winnerRevision);
const brokenRevision = revisionCard(settings, fixture.conflictRevision);
await brokenRevision
.getByText(/🧩 Missing chunks: 1/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await card.locator(".sls-repair-revision").count()) !== 2) {
throw new Error("Verify and Repair did not render the winner and conflict revision separately.");
}
for (const label of [
/📦 DB: recorded/u,
/📁 Vault:/u,
/Δsize vs DB/u,
/🕒 DB /u,
/Δtime /u,
/ Differs from Vault/u,
]) {
await winnerRevision.getByText(label).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
await brokenRevision.getByText(/decoded unavailable/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const winnerMenu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
for (const label of [
"Compare with Vault",
"Apply this revision to Vault",
"Store Vault file as a child of this revision",
"Discard this branch",
]) {
await winnerMenu.getByText(label, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
if (
(await winnerMenu
.getByText("Mark this revision as the Vault version", {
exact: true,
})
.count()) !== 0
) {
throw new Error("A differing revision incorrectly offered to record an exact Vault match.");
}
await page.keyboard.press("Escape");
});
const repairCardScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const card = page.locator(".sls-repair-result").filter({ hasText: path });
await card.evaluate((element) => {
const htmlElement = element as HTMLElement;
htmlElement.dataset.e2eOriginalStyle = htmlElement.getAttribute("style") ?? "";
htmlElement.style.width = "360px";
htmlElement.style.maxWidth = "100%";
});
const dimensions = await card.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
if (dimensions.scrollWidth > dimensions.clientWidth + 1) {
throw new Error(
`Revision repair card overflowed at mobile width: ${JSON.stringify(dimensions)}`
);
}
});
const mobileWidthScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-mobile-width.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const card = page.locator(".sls-repair-result").filter({ hasText: path });
await card.evaluate((element) => {
const htmlElement = element as HTMLElement;
const originalStyle = htmlElement.dataset.e2eOriginalStyle ?? "";
if (originalStyle.length > 0) {
htmlElement.setAttribute("style", originalStyle);
} else {
htmlElement.removeAttribute("style");
}
delete htmlElement.dataset.e2eOriginalStyle;
});
});
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await openRevisionActionMenu(page, settings, fixture.winnerRevision);
});
const readableMenuScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-readable-actions.png",
(page) => page.locator(".menu:visible").last()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.winnerRevision, "Compare with Vault");
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByText(path, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText(/Vault file:/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText(/Database revision:/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const actions = modal.locator(".conflict-action-container");
await actions.getByRole("button", { name: "Close", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
for (const action of ["Use Vault file", "Use Database revision", "Concat both", "Not now"]) {
if ((await actions.getByRole("button", { name: action, exact: true }).count()) !== 0) {
throw new Error(`Read-only comparison exposed the resolution action '${action}'.`);
}
}
});
const comparisonScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-read-only-comparison.png",
(page) =>
page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
})
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
});
await modal
.locator(".conflict-action-container")
.getByRole("button", { name: "Close", exact: true })
.click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const beforeApply = await readRevisionTree(cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.winnerRevision, "Apply this revision to Vault");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Apply database revision to Vault",
}),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await revisionCard(settings, fixture.winnerRevision)
.getByText("✅ Matches Vault", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const status = repairCard(settings).locator(".sls-repair-status");
await status
.getByText("✅ Vault matches winner", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await status
.getByText("⚠️ Conflicts: 1", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const matchedWinnerWithConflictScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-winner-match-with-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
const afterApply = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterApply) !== JSON.stringify(beforeApply)) {
throw new Error(
`Applying a live revision to the Vault changed the revision tree: ${JSON.stringify({
beforeApply,
afterApply,
})}`
);
}
const vaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
const appliedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
!vaultWinner.matches ||
vaultWinner.winnerRevision !== fixture.winnerRevision ||
appliedProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Applying the winner did not preserve exact Vault provenance: ${JSON.stringify({
vaultWinner,
appliedProvenance,
fixture,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const menu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
await menu
.getByText("Mark this revision as the Vault version", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await menu
.getByText("Discard this branch", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await menu
.getByText("Mark this revision as the Vault version", { exact: true })
.click({ timeout: uiTimeoutMs });
await revisionCard(settings, fixture.winnerRevision)
.getByText("✅ Matches Vault", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterExactMark = await readRevisionTree(cliBinary, session.cliEnv);
const markedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
JSON.stringify(afterExactMark) !== JSON.stringify(beforeApply) ||
markedProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Recording an exact Vault match changed the tree or lost provenance: ${JSON.stringify({
beforeApply,
afterExactMark,
markedProvenance,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const menu = await openRevisionActionMenu(page, settings, fixture.conflictRevision);
for (const label of [
"Store Vault file as a child of this revision",
"Retry reading revision",
"Discard this branch",
]) {
await menu.getByText(label, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
});
const unreadableMenuScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-actions-context.png",
(page) => page.locator("body")
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Retry reading revision");
await revisionCard(settings, fixture.conflictRevision)
.getByText(/🧩 Missing chunks:/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterRetry = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterRetry) !== JSON.stringify(beforeApply)) {
throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs });
await confirmation.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterCancellation) !== JSON.stringify(beforeApply)) {
throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await repairCard(settings).waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv);
if (afterDiscard.winnerRevision !== fixture.winnerRevision || afterDiscard.conflictRevisions.length !== 0) {
throw new Error(
`Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({
fixture,
afterDiscard,
})}`
);
}
const finalVaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
const finalProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
!finalVaultWinner.matches ||
finalVaultWinner.winnerRevision !== fixture.winnerRevision ||
finalProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Discarding the unreadable branch disturbed the healthy Vault reflection: ${JSON.stringify({
finalVaultWinner,
finalProvenance,
fixture,
})}`
);
}
console.log(
"Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation."
);
console.log(`Repair card screenshot: ${repairCardScreenshot}`);
console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`);
console.log(`Readable revision actions screenshot: ${readableMenuScreenshot}`);
console.log(`Read-only comparison screenshot: ${comparisonScreenshot}`);
console.log(`Matching winner with conflict screenshot: ${matchedWinnerWithConflictScreenshot}`);
console.log(`Unreadable revision actions screenshot: ${unreadableMenuScreenshot}`);
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+89
View File
@@ -0,0 +1,89 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
// Keep the public wrapper deliberately narrower than package.json. Discovery,
// installation, runner contracts, and the complete suite have different setup
// requirements and remain separate entry points.
const focusedScenarios = new Set([
"smoke",
"onboarding-invitation",
"dialog-mounts",
"revision-repair",
"settings-ui",
"review-harness",
"p2p-pane",
"vault-reflection",
"couchdb-upload",
"couchdb-manual-setup-workflow",
"cli-to-obsidian-sync",
"minio-upload",
"object-storage-setup-uri-workflow",
"p2p-setup-uri-workflow",
"startup-scan",
"setup-uri-workflow",
"two-vault-sync",
"security-seed-reconnect",
"hidden-file-snippet-sync",
"customisation-sync",
"setting-markdown-export",
"upgrade-from-stable",
]);
function usage(): string {
return `Usage: npm run test:e2e:obsidian:focused -- <scenario> [scenario arguments]
Builds the current Self-hosted LiveSync plug-in before running one maintained
real-Obsidian scenario. Supported scenarios:
${[...focusedScenarios].map((scenario) => ` ${scenario}`).join("\n")}
This wrapper does not start CouchDB, Object Storage, or the P2P signalling
relay. Use the documented service commands or the complete
local-suite:services wrapper when required.`;
}
// npm receives each argument directly. In particular, environment values and
// scenario arguments never pass through a shell for re-interpretation.
function runNpm(args: string[]): void {
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const result = spawnSync(npm, args, {
cwd: fileURLToPath(new URL("../../..", import.meta.url)),
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`npm ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`);
}
}
function main(): void {
const [scenario, ...scenarioArguments] = process.argv.slice(2);
if (!scenario || scenario === "-h" || scenario === "--help") {
process.stdout.write(`${usage()}\n`);
return;
}
if (!focusedScenarios.has(scenario)) {
throw new Error(`Unsupported focused real-Obsidian scenario: ${scenario}\n\n${usage()}`);
}
// Individual scenario scripts intentionally remain fast, raw entry points.
// The wrapper owns the freshness guarantee which was previously easy to
// miss after changing TypeScript source.
runNpm(["run", "build"]);
// The compatibility scenario defaults to the repository CLI. Build it only
// when the caller has not selected an external CLI distribution.
if (scenario === "cli-to-obsidian-sync" && !process.env.LIVESYNC_CLI_COMMAND) {
runNpm(["run", "build", "--workspace", "self-hosted-livesync-cli"]);
}
const script = `test:e2e:obsidian:${scenario}`;
runNpm(["run", script, ...(scenarioArguments.length > 0 ? ["--", ...scenarioArguments] : [])]);
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
@@ -0,0 +1,768 @@
/**
* Provides release evidence for the Security Seed refresh behaviour shared by
* supported platforms in real Obsidian. It verifies that an already-open
* device keeps its deliberately stale cached Seed until replication, refreshes
* from the managed CouchDB fixture before encrypting, and never restores the
* old Seed to the remote synchronisation-parameter document.
*
* The scenario uses isolated Vaults, profiles, and a random database because
* settings, the local database, the renderer process, and CouchDB must all
* participate in the result. Device A is restarted with the same Vault and
* profile, while device B is fresh. The devices run sequentially after the
* same-process stale-cache assertion because desktop Obsidian may enforce a
* single application instance; running them concurrently would test launcher
* behaviour rather than LiveSync's shared plug-in implementation.
*
* Seed replacement, A-to-B decryption, B-to-A return synchronisation, final
* remote-document comparison, error-log inspection, screenshots, and strict
* teardown remain one scenario. Together they prove that the same replacement
* Seed was used across the complete encrypted round trip and was not later
* rolled back. Independent passing checks would not establish that continuity.
* The result records fingerprints only and does not claim to cover an
* iPadOS-specific background or reconnect lifecycle.
*/
import { execFileSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
couchDbDatabaseExists,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
fetchCouchDbDocument,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
waitForCouchDbDocs,
type CouchDbConfig,
type CouchDbDocument,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
SECURITY_SEED_DOCUMENT_ID,
changedSynchronisationParameterFields,
createSecuritySeed,
fingerprintSecuritySeed,
replaceSecuritySeed,
requireSecuritySeedDocument,
snapshotSecuritySeedDocument,
type SecuritySeedDocument,
type SecuritySeedDocumentSnapshot,
} from "../runner/securitySeed.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "15000";
const outboundPath = "E2E/security-seed/device-a.md";
const returnPath = "E2E/security-seed/device-b.md";
const hkdfErrorMessages = [
"Encryption with HKDF failed",
"Decryption with HKDF failed",
"Failed to initialise the encryption key",
"Failed to obtain PBKDF2 salt",
] as const;
type RunnerContext = {
binary: string;
cliBinary: string;
artifactRoot: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
allSessions: ObsidianLiveSyncSession[];
screenshots: string[];
};
type DeviceLabel = "device-a" | "device-a-return" | "device-b";
type SourceEvidence = {
exactCommit: string;
revisionSource: "git-worktree" | "provided-artifact";
workingTreeClean: boolean | null;
pluginVersion: string;
pluginArtifactSha256: string;
};
type ReplicationSettingsState = {
liveSync: boolean;
syncOnStart: boolean;
syncOnSave: boolean;
periodicReplication: boolean;
syncOnFileOpen: boolean;
syncOnEditorSave: boolean;
};
type SessionHealth = {
matchingErrorMessages: string[];
};
type ScenarioEvidence = {
source: SourceEvidence;
securitySeed: {
initial: SecuritySeedDocumentSnapshot;
replacement: SecuritySeedDocumentSnapshot;
final: SecuritySeedDocumentSnapshot;
cachedBeforeReplacement: string;
cachedAfterRemoteReplacement: string;
cachedAfterReplication: string;
replacementChangedFields: string[];
finalChangedFields: string[];
};
synchronisation: {
deviceAToDeviceB: boolean;
deviceBToDeviceA: boolean;
deviceAEncryptedPayload: boolean;
deviceBEncryptedPayload: boolean;
};
health: {
deviceA: SessionHealth;
deviceB: SessionHealth;
};
screenshots: string[];
};
type TeardownEvidence = {
sessionsStopped: boolean;
vaultRemoved: boolean;
profileRemoved: boolean;
databaseRemoved: boolean;
remainingTrackedSessions: number;
};
class MultipleErrors extends Error {
readonly errors: unknown[];
constructor(message: string, errors: unknown[]) {
super(message);
this.name = "MultipleErrors";
this.errors = errors;
}
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function inspectGitRevision(
artifactRoot: string
): Pick<SourceEvidence, "exactCommit" | "revisionSource" | "workingTreeClean"> {
try {
const exactCommit = execFileSync("git", ["-C", artifactRoot, "rev-parse", "HEAD"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const status = execFileSync("git", ["-C", artifactRoot, "status", "--porcelain"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
exactCommit,
revisionSource: "git-worktree",
workingTreeClean: status.length === 0,
};
} catch {
const exactCommit = process.env.E2E_OBSIDIAN_ARTIFACT_REVISION?.trim();
if (!exactCommit) {
throw new Error(
"E2E_OBSIDIAN_ARTIFACT_REVISION is required when the plug-in artefact is not in a Git worktree."
);
}
return {
exactCommit,
revisionSource: "provided-artifact",
workingTreeClean: null,
};
}
}
async function inspectSourceEvidence(artifactRoot: string): Promise<SourceEvidence> {
const manifest = JSON.parse(await readFile(join(artifactRoot, "manifest.json"), "utf-8")) as {
version?: unknown;
};
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
throw new Error("The plug-in manifest does not have a version.");
}
const mainJs = await readFile(join(artifactRoot, "main.js"));
return {
...inspectGitRevision(artifactRoot),
pluginVersion: manifest.version,
pluginArtifactSha256: createHash("sha256").update(Uint8Array.from(mainJs)).digest("hex"),
};
}
function e2eeSettings(passphrase: string): Record<string, unknown> {
return {
encrypt: true,
passphrase,
usePathObfuscation: true,
E2EEAlgorithm: "v2",
};
}
async function captureStage(context: RunnerContext, session: ObsidianLiveSyncSession, filename: string): Promise<void> {
const screenshot = await captureObsidianPage(session.remoteDebuggingPort, filename, async () => undefined);
context.screenshots.push(screenshot);
console.log(`Security Seed E2E screenshot: ${screenshot}`);
}
async function startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
passphrase: string,
deviceLabel: DeviceLabel
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const overrides = e2eeSettings(passphrase);
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
artifactRoot: context.artifactRoot,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
context.activeSessions.add(session);
context.allSessions.push(session);
try {
await captureStage(context, session, `security-seed-${deviceLabel}-startup.png`);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
await captureStage(context, session, `security-seed-${deviceLabel}-configured.png`);
return session;
} catch (error) {
await captureStage(context, session, `security-seed-${deviceLabel}-setup-failure.png`).catch(() => undefined);
await stopTrackedSession(context, session);
throw error;
}
}
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> {
const errors: unknown[] = [];
for (const session of [...context.activeSessions]) {
try {
await stopTrackedSession(context, session);
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new MultipleErrors("Could not stop every Real Obsidian session.", errors);
}
}
async function pauseAutomaticReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ReplicationSettingsState> {
const state = await evalObsidianJson<ReplicationSettingsState>(
cliBinary,
[
"(()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"core.services.replicator.getActiveReplicator()?.closeReplication();",
"const settings=core.services.setting.currentSettings();",
"return JSON.stringify({",
"liveSync:Boolean(settings.liveSync),",
"syncOnStart:Boolean(settings.syncOnStart),",
"syncOnSave:Boolean(settings.syncOnSave),",
"periodicReplication:Boolean(settings.periodicReplication),",
"syncOnFileOpen:Boolean(settings.syncOnFileOpen),",
"syncOnEditorSave:Boolean(settings.syncOnEditorSave),",
"});",
"})()",
].join(""),
env
);
for (const [name, enabled] of Object.entries(state)) {
if (enabled) {
throw new Error(`Automatic replication remained enabled through ${name}.`);
}
}
return state;
}
async function cachedSecuritySeedFingerprint(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
const result = await evalObsidianJson<{ fingerprint: string }>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const replicator=core.services.replicator.getActiveReplicator();",
"const seed=await replicator.getReplicationPBKDF2Salt(settings,false);",
"const digest=await crypto.subtle.digest('SHA-256',seed);",
"const fingerprint='sha256:'+Array.from(new Uint8Array(digest))",
".map((value)=>value.toString(16).padStart(2,'0')).join('').slice(0,16);",
"return JSON.stringify({fingerprint});",
"})()",
].join(""),
env
);
return result.fingerprint;
}
async function writeNoteViaObsidian(
cliBinary: string,
env: 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(""),
env
);
}
async function openNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Could not find note to open: ${path}`);",
"await app.workspace.getLeaf(false).openFile(file);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
async function waitForPathContent(
vaultPath: string,
path: string,
expected: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 15000)
): Promise<void> {
const fullPath = join(vaultPath, path);
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
if (await pathExists(fullPath)) {
lastContent = await readFile(fullPath, "utf-8");
if (lastContent === expected) {
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}
function remoteContainsEntry(documents: CouchDbDocument[], entry: LocalDatabaseEntry): boolean {
const ids = new Set(documents.map((document) => document._id));
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
}
async function assertEntryNotRemote(context: RunnerContext, entry: LocalDatabaseEntry): Promise<void> {
const response = await fetchAllCouchDbDocs(context.couchDb, context.dbName);
const documents = response.rows.flatMap((row) => (row.doc ? [row.doc] : []));
if (remoteContainsEntry(documents, entry)) {
throw new Error("The pending device-A document reached CouchDB before the Security Seed replacement.");
}
}
async function waitForEncryptedRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise<boolean> {
const documents = await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) =>
remoteContainsEntry(docs, entry)
);
const byId = new Map(documents.map((document) => [document._id, document]));
const encrypted = entry.children.every((childId) => {
const data = byId.get(childId)?.data;
return typeof data === "string" && data.startsWith("%=");
});
if (!encrypted) {
throw new Error("A replicated chunk did not use the expected HKDF-encrypted payload format.");
}
return true;
}
async function inspectSessionHealth(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SessionHealth> {
return await evalObsidianJson<SessionHealth>(
cliBinary,
[
"(async()=>{",
`const patterns=${JSON.stringify(hkdfErrorMessages)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.API.showWindow('log-log');",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let text='';",
"for(let i=0;i<20;i++){",
"text=Array.from(document.querySelectorAll('.logpane .log pre'))",
".map((element)=>element.textContent??'').join('\\n');",
"if(text.length>0) break;",
"await sleep(50);",
"}",
"const unresolved=JSON.stringify((await core.services.appLifecycle.getUnresolvedMessages()).flat());",
"const matchingErrorMessages=patterns.filter((pattern)=>text.includes(pattern)||unresolved.includes(pattern));",
"for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();",
"return JSON.stringify({matchingErrorMessages});",
"})()",
].join(""),
env
);
}
async function fetchSecuritySeedDocument(context: RunnerContext): Promise<SecuritySeedDocument> {
return requireSecuritySeedDocument(
await fetchCouchDbDocument(context.couchDb, context.dbName, SECURITY_SEED_DOCUMENT_ID)
);
}
async function replaceRemoteSecuritySeed(
context: RunnerContext,
before: SecuritySeedDocument,
replacementSeed: string
): Promise<SecuritySeedDocument> {
const replacement = replaceSecuritySeed(before, replacementSeed);
const putResult = await putCouchDbDocument(context.couchDb, context.dbName, replacement);
const after = await fetchSecuritySeedDocument(context);
assertEqual(after._rev, putResult.rev, "The replacement Security Seed revision was not stored.");
assertEqual(
fingerprintSecuritySeed(after.pbkdf2salt),
fingerprintSecuritySeed(replacementSeed),
"The replacement Security Seed was not stored."
);
const changedFields = changedSynchronisationParameterFields(before, after);
assertEqual(
JSON.stringify(changedFields),
JSON.stringify(["pbkdf2salt"]),
"Replacing the remote Security Seed changed another synchronisation parameter."
);
return after;
}
async function runScenario(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<ScenarioEvidence> {
const passphrase = `security-seed-e2e-${randomUUID()}`;
const source = await inspectSourceEvidence(context.artifactRoot);
let sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a");
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await captureStage(context, sessionA, "security-seed-device-a-initial-sync.png");
const initialDocument = await fetchSecuritySeedDocument(context);
const initial = snapshotSecuritySeedDocument(initialDocument);
const cachedBeforeReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedBeforeReplacement,
initial.fingerprint,
"Device A did not cache the initial remote Security Seed."
);
await pauseAutomaticReplication(context.cliBinary, sessionA.cliEnv);
const outboundContent = `Encrypted from device A: ${randomUUID()}\n`;
await writeNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath, outboundContent);
const outboundEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionA.cliEnv, outboundPath);
await assertEntryNotRemote(context, outboundEntry);
const replacementSeed = createSecuritySeed();
const replacementDocument = await replaceRemoteSecuritySeed(context, initialDocument, replacementSeed);
const replacement = snapshotSecuritySeedDocument(replacementDocument);
await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath);
await captureStage(context, sessionA, "security-seed-device-a-replacement-pending.png");
const cachedAfterRemoteReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedAfterRemoteReplacement,
initial.fingerprint,
"Device A did not retain the deliberately stale Security Seed before replication."
);
assertEqual(
replacement.fingerprint,
fingerprintSecuritySeed(replacementSeed),
"The runner did not install the intended replacement Security Seed."
);
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
const cachedAfterReplication = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv);
assertEqual(
cachedAfterReplication,
replacement.fingerprint,
"Device A did not refresh the Security Seed before replication."
);
const deviceAEncryptedPayload = await waitForEncryptedRemoteEntry(context, outboundEntry);
await captureStage(context, sessionA, "security-seed-device-a-refreshed-sync.png");
const deviceAHealthBeforeRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv);
await stopTrackedSession(context, sessionA);
const sessionB = await startConfiguredSession(context, vaultB, passphrase, "device-b");
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB.path, outboundPath, outboundContent);
await openNoteViaObsidian(context.cliBinary, sessionB.cliEnv, outboundPath);
await captureStage(context, sessionB, "security-seed-device-b-received.png");
await pauseAutomaticReplication(context.cliBinary, sessionB.cliEnv);
const returnContent = `Encrypted from device B: ${randomUUID()}\n`;
await writeNoteViaObsidian(context.cliBinary, sessionB.cliEnv, returnPath, returnContent);
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, returnPath);
await pushLocalChanges(context.cliBinary, sessionB.cliEnv);
const deviceBEncryptedPayload = await waitForEncryptedRemoteEntry(context, returnEntry);
const deviceBHealth = await inspectSessionHealth(context.cliBinary, sessionB.cliEnv);
await stopTrackedSession(context, sessionB);
sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a-return");
await pushLocalChanges(context.cliBinary, sessionA.cliEnv);
await waitForPathContent(vaultA.path, returnPath, returnContent);
await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, returnPath);
await captureStage(context, sessionA, "security-seed-device-a-return-received.png");
const finalDocument = await fetchSecuritySeedDocument(context);
const final = snapshotSecuritySeedDocument(finalDocument);
assertEqual(
final.fingerprint,
replacement.fingerprint,
"A client rolled the remote Security Seed back after reconnecting."
);
const finalChangedFields = changedSynchronisationParameterFields(replacementDocument, finalDocument);
if (finalChangedFields.length > 0) {
throw new Error(
`A client rewrote unexpected synchronisation-parameter fields: ${finalChangedFields.join(", ")}`
);
}
const deviceAHealthAfterRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv);
const deviceAHealth = {
matchingErrorMessages: [
...new Set([
...deviceAHealthBeforeRestart.matchingErrorMessages,
...deviceAHealthAfterRestart.matchingErrorMessages,
]),
],
};
if (deviceAHealth.matchingErrorMessages.length > 0 || deviceBHealth.matchingErrorMessages.length > 0) {
throw new Error(
`HKDF or Security Seed errors were logged: ${JSON.stringify({
deviceA: deviceAHealth.matchingErrorMessages,
deviceB: deviceBHealth.matchingErrorMessages,
})}`
);
}
return {
source,
securitySeed: {
initial,
replacement,
final,
cachedBeforeReplacement,
cachedAfterRemoteReplacement,
cachedAfterReplication,
replacementChangedFields: changedSynchronisationParameterFields(initialDocument, replacementDocument),
finalChangedFields,
},
synchronisation: {
deviceAToDeviceB: true,
deviceBToDeviceA: true,
deviceAEncryptedPayload,
deviceBEncryptedPayload,
},
health: {
deviceA: deviceAHealth,
deviceB: deviceBHealth,
},
screenshots: [...context.screenshots],
};
}
async function cleanupResources(
context: RunnerContext,
vaults: TemporaryVault[],
databaseCreated: boolean
): Promise<TeardownEvidence> {
const errors: unknown[] = [];
try {
await stopTrackedSessions(context);
} catch (error) {
errors.push(error);
}
for (const vault of vaults) {
try {
await vault.dispose();
} catch (error) {
errors.push(error);
}
}
if (databaseCreated) {
try {
await deleteCouchDbDatabase(context.couchDb, context.dbName);
} catch (error) {
errors.push(error);
}
}
const sessionsStopped = context.allSessions.every(
(session) => session.app.process.exitCode !== null || session.app.process.signalCode !== null
);
const vaultRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.path))))).every(
Boolean
);
const profileRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.statePath))))).every(
Boolean
);
let databaseRemoved = !databaseCreated;
if (databaseCreated) {
try {
databaseRemoved = !(await couchDbDatabaseExists(context.couchDb, context.dbName));
} catch (error) {
errors.push(error);
}
}
const evidence = {
sessionsStopped,
vaultRemoved,
profileRemoved,
databaseRemoved,
remainingTrackedSessions: context.activeSessions.size,
};
if (!sessionsStopped || !vaultRemoved || !profileRemoved || !databaseRemoved || context.activeSessions.size > 0) {
errors.push(new Error(`Security Seed E2E teardown was incomplete: ${JSON.stringify(evidence)}`));
}
if (errors.length > 0) {
throw Object.assign(new MultipleErrors("Security Seed E2E teardown failed.", errors), {
evidence,
});
}
return evidence;
}
async function writeResult(result: unknown): Promise<string> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const resultPath = join(outputDirectory, "security-seed-reconnect-result.json");
await mkdir(outputDirectory, { recursive: true });
await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
return resultPath;
}
async function main(): Promise<void> {
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true" || process.env.E2E_OBSIDIAN_KEEP_COUCHDB === "true") {
throw new Error("The Security Seed reconnect scenario requires strict Vault, profile, and database cleanup.");
}
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const artifactRoot = resolve(process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd());
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "security-seed-reconnect");
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
artifactRoot,
couchDb,
dbName,
activeSessions: new Set(),
allSessions: [],
screenshots: [],
};
const vaults: TemporaryVault[] = [];
let databaseCreated = false;
let evidence: ScenarioEvidence | undefined;
let scenarioError: unknown;
let teardown: TeardownEvidence | undefined;
let teardownError: unknown;
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
databaseCreated = true;
vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-a-"));
vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-b-"));
evidence = await runScenario(context, vaults[0], vaults[1]);
} catch (error) {
scenarioError = error;
} finally {
try {
teardown = await cleanupResources(context, vaults, databaseCreated);
} catch (error) {
teardownError = error;
}
}
if (scenarioError !== undefined || teardownError !== undefined) {
const errors = [scenarioError, teardownError].filter((error) => error !== undefined);
if (errors.length === 1) {
throw errors[0];
}
throw new MultipleErrors("Security Seed reconnect scenario and teardown both failed.", errors);
}
if (!evidence || !teardown) {
throw new Error("Security Seed reconnect evidence was not produced.");
}
const result = {
scenario: "security-seed-reconnect",
...evidence,
teardown,
limitations: {
platformCommonRealObsidian: true,
iPadOsBackgroundReconnect: false,
androidDeviceLifecycle: false,
},
};
const resultPath = await writeResult(result);
console.log(`Security Seed E2E result: ${resultPath}`);
console.log(JSON.stringify(result, null, 2));
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
+294
View File
@@ -0,0 +1,294 @@
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes.";
type ObsidianSettingsController = {
open(): void;
openTabById(tabId: string): void;
};
type LiveSyncTestPlugin = {
core: {
services: {
setting: {
currentSettings(): { versionUpFlash: string };
getSmallConfig(key: string): string | null;
};
};
};
};
type ObsidianTestApp = {
setting?: ObsidianSettingsController;
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
};
type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp };
async function verifyCompatibilityReview(): Promise<void> {
const port = obsidianRemoteDebuggingPort();
const summaryScreenshot = await captureObsidianDialogue(port, "compatibility-review-summary.png", async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByText("Your automatic synchronisation preferences have not been changed.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "Review compatibility details" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", {
name: "Resume synchronisation",
})
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal
.getByRole("button", { name: "Keep synchronisation paused" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await withObsidianPage(port, async (page) => {
const markerBeforeAcknowledgement = await page.evaluate(() => {
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");
});
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") {
throw new Error(
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
);
}
});
await setObsidianMobileTestMode(port, true, uiTimeoutMs);
const mobileSummaryScreenshot = await captureObsidianDialogue(
port,
"compatibility-review-summary-mobile.png",
async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileDialogueLayout(page, summary, "compatibility review summary");
const doctor = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
});
if (await doctor.isVisible()) {
throw new Error("Config Doctor must wait until the initial compatibility review has closed.");
}
}
);
await withObsidianPage(port, async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.getByRole("button", { name: "Review compatibility details" }).click();
});
const detailsScreenshot = await captureObsidianDialogue(
port,
"compatibility-review-details-mobile.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByText("Why synchronisation is paused", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText("Remote replication is blocked before work begins.", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal
.getByRole("button", { name: "Back to compatibility review" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
if ((await modal.getByRole("button", { name: "Keep synchronisation paused" }).count()) !== 0) {
throw new Error("The explanatory details dialogue must not make the pause decision.");
}
await assertMobileDialogueLayout(page, modal, "compatibility review details");
}
);
await withObsidianPage(port, async (page) => {
const details = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }),
});
await details.getByRole("button", { name: "Back to compatibility review" }).click();
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
await setObsidianMobileTestMode(port, false, uiTimeoutMs);
await withObsidianPage(port, async (page) => {
const summary = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Synchronisation paused for compatibility review",
}),
});
await summary
.getByRole("button", {
name: "Resume synchronisation",
})
.click();
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page.waitForFunction(
(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 &&
setting.currentSettings().versionUpFlash === ""
);
},
`${VER}`,
{ timeout: uiTimeoutMs }
);
});
console.log(
`Compatibility review screenshots: ${summaryScreenshot}, ${mobileSummaryScreenshot}, ${detailsScreenshot}`
);
}
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const doctor = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
});
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
}
await doctor.getByRole("button", { name: /No, and do not ask again/u }).click();
await doctor.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function verifyEffectiveSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(() => {
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const liveSyncSettings = page.locator(".sls-setting");
await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click();
const removedAcknowledgements = liveSyncSettings.getByRole("button", {
name: /I got it and updated|OK, I have read everything/u,
});
if ((await removedAcknowledgements.count()) !== 0) {
throw new Error("The Change Log still contains a compatibility or release-note acknowledgement control.");
}
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click();
const connectionPanel = liveSyncSettings
.locator("h4.sls-setting-panel-title")
.filter({ hasText: "Connection settings" })
.locator("..");
await connectionPanel.waitFor({ state: "visible", timeout: uiTimeoutMs });
await connectionPanel.getByText("Saved connections", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click();
const deletionPanel = liveSyncSettings
.locator("h4.sls-setting-panel-title")
.filter({ hasText: "Deletion Propagation" })
.locator("..");
await deletionPanel
.getByText("Keep empty folder", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
// Retirement guard: the removed toggle must not reappear in the current settings pane.
const obsoleteToggleCount = await deletionPanel.getByText("Use the trash bin", { exact: true }).count();
if (obsoleteToggleCount !== 0) {
throw new Error(
`The obsolete LiveSync trash toggle is still present in the settings UI (${obsoleteToggleCount} found).`
);
}
});
}
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 vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "0.25.27",
isConfigured: true,
liveSync: false,
versionUpFlash: compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncAfterMerge: false,
periodicReplication: false,
handleFilenameCaseSensitive: false,
useAdvancedMode: true,
useEdgeCaseMode: true,
},
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await verifyCompatibilityReview();
await verifyConfigDoctorFollowsCompatibilityReview();
await verifyEffectiveSettings();
console.log("Compatibility review and settings expose only effective user controls.");
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
@@ -0,0 +1,859 @@
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 {
assertVerticalActionLayout,
generateSetupURIFromDevice,
resumeCompatibilityReviewIfShown,
} from "../runner/setupUri.ts";
import {
captureObsidianDialogue,
captureObsidianElement,
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 returnNotePath = "E2E/setup-uri/from-second-device.md";
const returnNoteContent =
"# CouchDB from the second device\n\nThis note completed the return journey through CouchDB.\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 }),
});
}
function settingPanelByTitle(page: Page, title: string): Locator {
return page
.locator(".sls-setting")
.locator("h4.sls-setting-panel-title:visible")
.filter({ hasText: title })
.locator("..");
}
async function captureGuideDialogue(port: number, filename: string, title: string): Promise<string> {
return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first());
}
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 captureGuideDialogue(
port,
`guide-quick-setup-${mode === "new" ? "first" : "second"}-setup-uri.png`,
"Enter Setup URI"
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "Enter Setup URI")
.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 captureGuideDialogue(
port,
`guide-quick-setup-${mode === "new" ? "first-initialise" : "second-fetch"}.png`,
title
);
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 captureGuideDialogue(port, "guide-quick-setup-first-rebuild-confirmation.png", title);
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 captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title);
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 captureGuideDialogue(port, "guide-quick-setup-optional-features-disabled.png", title);
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";
await assertVerticalActionLayout(port, firstTitle);
const firstScreenshot = await captureObsidianDialogue(
port,
"setup-uri-second-retrieval-method.png",
async (page) => {
await modalByTitle(page, firstTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
);
await captureGuideDialogue(port, "guide-quick-setup-retrieval-method.png", firstTitle);
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?";
await assertVerticalActionLayout(port, secondTitle);
const secondScreenshot = await captureObsidianDialogue(
port,
"setup-uri-second-local-file-policy.png",
async (page) => {
await modalByTitle(page, secondTitle).waitFor({ state: "visible", timeout: uiTimeoutMs });
}
);
await captureGuideDialogue(port, "guide-quick-setup-local-file-policy.png", secondTitle);
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 waitForConfiguredSetup(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 captureHiddenFileGuideSettings(
port: number,
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<string[]> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.setting.applyPartial({",
"useAdvancedMode:true,",
"syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/snippets(?:/|$))',",
"},true);",
"await core.services.control.applySettings();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const obsidian = globalThis as typeof globalThis & {
app?: {
setting?: {
open(): void;
openTabById(tabId: string): void;
};
};
};
const setting = obsidian.app?.setting;
if (!setting) throw new Error("Obsidian settings are unavailable");
setting.open();
setting.openTabById("obsidian-livesync");
});
const settings = page.locator(".sls-setting");
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
await settings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs });
});
const screenshots = [
await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) =>
settingPanelByTitle(page, "Enable extra and advanced features")
),
];
await withObsidianPage(port, async (page) => {
await page
.locator(".sls-setting")
.locator('.sls-setting-menu-btn[title="Selector"]')
.click({ timeout: uiTimeoutMs });
});
screenshots.push(
await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) =>
settingPanelByTitle(page, "Hidden Files")
)
);
await withObsidianPage(port, async (page) => {
await page
.locator(".sls-setting")
.locator('.sls-setting-menu-btn[title="Sync Settings"]')
.click({ timeout: uiTimeoutMs });
});
screenshots.push(
await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) =>
settingPanelByTitle(page, "Hidden Files")
)
);
await withObsidianPage(port, async (page) => {
await page.keyboard.press("Escape");
});
return screenshots;
}
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);
});
await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => {
await page.getByText("Provisioned Setup URI", { exact: false }).first().waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
});
return await captureObsidianElement(port, "guide-quick-setup-synchronised-note.png", (page) =>
page.locator(".workspace-leaf.mod-active").first()
);
}
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[] = [];
let secondDeviceArtifact: SetupArtifact | undefined;
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;
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
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."
);
screenshots.push(
...(await captureHiddenFileGuideSettings(
session.remoteDebuggingPort,
context.cliBinary,
session.cliEnv
))
);
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
await uploadWorkflowFiles(context, session, vaultA);
const generated = await generateSetupURIFromDevice(
session.remoteDebuggingPort,
randomBytes(24).toString("base64url"),
{ scenario: "setup-uri-workflow", guide: "quick-setup" }
);
if (generated.artifact.setupURI === artifact.setupURI) {
throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one.");
}
secondDeviceArtifact = generated.artifact;
screenshots.push(...generated.screenshots);
} catch (error) {
await captureFailure(session);
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultB);
try {
if (!secondDeviceArtifact)
throw new Error("The first device did not generate the second-device Setup URI.");
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact);
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;
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
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));
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent);
const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, returnEntry);
} catch (error) {
await captureFailure(session);
throw error;
} finally {
await stopTrackedSession(context, session);
}
session = await startUnconfiguredSession(context, vaultA);
try {
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const receivedReturnNote = await waitForPathContent(vaultA.path, returnNotePath, returnNoteContent);
assertEqual(
receivedReturnNote,
returnNoteContent,
"The second device's ordinary note did not return to the first Setup URI device."
);
} catch (error) {
await captureFailure(session);
throw error;
} finally {
await stopTrackedSession(context, session);
}
console.log(
`The public provisioning and first-device-generated Setup URI workflow configured two fresh devices, completed an ordinary-note round-trip, 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);
});
+9
View File
@@ -1,4 +1,8 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertObsidianServiceContextContract,
inspectObsidianServiceContextContract,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
@@ -25,6 +29,11 @@ async function main(): Promise<void> {
console.log(
`Obsidian plug-in ready: ${readiness.pluginId}@${readiness.pluginVersion} in ${readiness.vaultName}`
);
const contextContract = await inspectObsidianServiceContextContract(cli.binary, session.cliEnv);
assertObsidianServiceContextContract(contextContract);
console.log(
`Obsidian service Context contract passed: ${contextContract.contextType}, ${contextContract.serviceContextMismatches.length} mismatches.`
);
await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000)));
console.log("Obsidian stayed alive after the plug-in readiness check.");
} finally {
+24 -10
View File
@@ -1,3 +1,13 @@
/**
* Proves that a configured LiveSync Vault scans files created while Obsidian
* was stopped. The first launch receives a CouchDB profile using current
* settings and its acknowledged device-local compatibility marker before the
* plug-in loads.
*
* The second launch reuses the same Vault, profile, local database, and
* settings without rewriting plug-in data, so the assertion covers an
* ordinary configured restart rather than the separate onboarding flow.
*/
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
@@ -11,7 +21,8 @@ import {
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
@@ -47,6 +58,12 @@ async function main(): Promise<void> {
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "startup-scan");
const couchDbSettings = {
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
};
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
@@ -63,15 +80,11 @@ async function main(): Promise<void> {
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
});
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not configured.");
const initialReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
assertEqual(initialReadiness.configured, true, "Self-hosted LiveSync did not start configured.");
await prepareRemote(cli.binary, session.cliEnv);
await session.app.stop();
session = undefined;
@@ -84,7 +97,8 @@ async function main(): Promise<void> {
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const restartedReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
assertEqual(restartedReadiness.configured, true, "Self-hosted LiveSync lost its configuration on restart.");
const localEntry = await waitForLocalDatabaseEntry(cli.binary, session.cliEnv, notePath);
await pushLocalChanges(cli.binary, session.cliEnv);
+591 -74
View File
@@ -11,11 +11,16 @@ import {
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { waitForExactCaseOnlyRename } from "../runner/pathAssertions.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
assertE2eCompatibilityReviewPending,
configureCouchDb,
createE2eCouchDbPluginData,
prepareRemote,
pushLocalChanges,
resumeCompatibilityReview,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
@@ -31,7 +36,15 @@ const updatePath = "E2E/two-vault/update.md";
const deletePath = "E2E/two-vault/delete.md";
const renameFromPath = "E2E/two-vault/rename-source.md";
const renameToPath = "E2E/two-vault/renamed/rename-target.md";
const caseRenameFromPath = "E2E/two-vault/Case-Rename.md";
const caseRenameToPath = "E2E/two-vault/case-rename.md";
const conflictPath = "E2E/two-vault/conflict.md";
const conflictEditPath = "E2E/two-vault/conflict-operations/edit.md";
const conflictDeletePath = "E2E/two-vault/conflict-operations/delete.md";
const conflictCaseFromPath = "E2E/two-vault/conflict-operations/Case-Rename.md";
const conflictCaseToPath = "E2E/two-vault/conflict-operations/case-rename.md";
const conflictRenameFromPath = "E2E/two-vault/conflict-operations/rename-source.md";
const conflictRenameToPath = "E2E/two-vault/conflict-operations/renamed/rename-target.md";
const targetMismatchPath = "E2E/two-vault/target-mismatch.md";
const encryptedPath = "E2E/two-vault/encrypted.md";
@@ -40,6 +53,19 @@ type RunnerContext = {
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
reviewedVaults: Set<string>;
activeSessions: Set<ObsidianLiveSyncSession>;
};
type FileConflictState = {
currentRev: string;
branches: {
rev: string;
parentRev?: string;
content: string;
deleted: boolean;
path: string;
}[];
};
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
@@ -68,6 +94,18 @@ async function pathExists(vaultPath: string, path: string): Promise<boolean> {
}
}
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 waitForPathContent(
vaultPath: string,
path: string,
@@ -161,27 +199,44 @@ async function startConfiguredSession(
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const reviewAlreadyCompleted = context.reviewedVaults.has(vault.path);
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides),
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
overrides
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
context.activeSessions.add(session);
try {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
if (!reviewAlreadyCompleted) {
await assertE2eCompatibilityReviewPending(context.cliBinary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort);
}
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
if (!reviewAlreadyCompleted) context.reviewedVaults.add(vault.path);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, overrides);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
} catch (error) {
try {
await stopTrackedSession(context, session);
} catch (stopError) {
throw Object.assign(new Error("Could not stop Obsidian after session setup failed."), {
cause: error,
stopError,
});
}
throw error;
}
}
async function uploadNote(
@@ -241,25 +296,116 @@ async function storeFileRevision(
return result.rev;
}
async function createMarkdownConflict(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
path: string,
base: string,
left: string,
right: string
): Promise<void> {
const baseRev = await storeFileRevision(context.cliBinary, session.cliEnv, path, base);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
await storeFileRevision(context.cliBinary, session.cliEnv, path, left, baseRev);
await storeFileRevision(context.cliBinary, session.cliEnv, path, right, baseRev);
await writeVaultFile(vault.path, path, right);
async function readFileConflictState(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string
): Promise<FileConflictState> {
return await evalObsidianJson<FileConflictState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);",
"if(!meta) throw new Error(`Could not find conflict metadata: ${path}`);",
"const revisions=[meta._rev,...(meta._conflicts??[])];",
"const branches=[];",
"for(const rev of revisions){",
" const branchMeta=await core.localDatabase.getDBEntryMeta(path,{rev,revs:true},true);",
" const entry=await core.localDatabase.getDBEntry(path,{rev},false,true,true);",
" if(!branchMeta||!entry) throw new Error(`Could not read conflict revision: ${path} ${rev}`);",
" const content=Array.isArray(entry.data)?entry.data.join(''):entry.data;",
" if(typeof content!=='string') throw new Error(`Conflict revision was not text: ${path} ${rev}`);",
" const ids=branchMeta._revisions?.ids??[];",
" const parentRev=ids[1]?`${branchMeta._revisions.start-1}-${ids[1]}`:undefined;",
" branches.push({rev,parentRev,content,deleted:Boolean(branchMeta.deleted||branchMeta._deleted),path:branchMeta.path});",
"}",
"return JSON.stringify({currentRev:meta._rev,branches});",
"})()",
].join(""),
env
);
}
async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
async function waitForFileConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string
): Promise<FileConflictState> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
let state = await readFileConflictState(cliBinary, env, path);
while (state.branches.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250));
state = await readFileConflictState(cliBinary, env, path);
}
if (state.branches.length < 2) {
throw new Error(`Timed out waiting for a file conflict: ${path}`);
}
return state;
}
async function waitForConflictBranch(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
predicate: (branch: FileConflictState["branches"][number]) => boolean
): Promise<FileConflictState["branches"][number]> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
let state = await readFileConflictState(cliBinary, env, path);
while (Date.now() < deadline) {
const branch = state.branches.find(predicate);
if (branch) return branch;
await new Promise((resolve) => setTimeout(resolve, 250));
state = await readFileConflictState(cliBinary, env, path);
}
throw new Error(`Timed out waiting for the expected conflict branch: ${path}; ${JSON.stringify(state)}`);
}
async function readFileReflectionProvenance(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
"return JSON.stringify((await store.get(path))??null);",
"})()",
].join(""),
env
);
}
async function readPathIdentity(
cliBinary: string,
env: NodeJS.ProcessEnv,
paths: readonly string[]
): Promise<{ caseSensitive: boolean; ids: Record<string, string> }> {
return await evalObsidianJson<{ caseSensitive: boolean; ids: Record<string, string> }>(
cliBinary,
[
"(async()=>{",
`const paths=${JSON.stringify(paths)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const ids={};",
"for(const path of paths) ids[path]=await core.services.path.path2id(path);",
"return JSON.stringify({",
" caseSensitive:Boolean(core.services.setting.currentSettings().handleFilenameCaseSensitive),",
" ids,",
"});",
"})()",
].join(""),
env
);
}
async function calculateMarkdownAutoMerge(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<string> {
const result = await evalObsidianJson<{ content: string }>(
cliBinary,
[
"(async()=>{",
@@ -269,11 +415,29 @@ async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessE
"if(!('result' in result)){",
" throw new Error(`Markdown conflict was not auto-mergeable: ${path}; ${JSON.stringify(result)}`);",
"}",
"if(!(await core.databaseFileAccess.storeContent(path,result.result))){",
" throw new Error(`Could not store merged Markdown content: ${path}`);",
"}",
"if(!(await core.fileHandler.deleteRevisionFromDB(path,result.conflictedRev))){",
" throw new Error(`Could not delete conflicted revision: ${path}`);",
"return JSON.stringify({content:result.result});",
"})()",
].join(""),
env
);
return result.content;
}
async function deleteRevisionAndReflect(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
revision: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const revision=${JSON.stringify(revision)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(!(await core.fileHandler.deleteRevisionFromDB(path,revision))){",
" throw new Error(`Could not delete conflicted revision: ${path} ${revision}`);",
"}",
"if(!(await core.fileHandler.dbToStorage(path,path,true))){",
" throw new Error(`Could not reflect merged Markdown content: ${path}`);",
@@ -294,12 +458,12 @@ async function runCreateUpdateDelete(
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, createPath, createdContent);
await uploadNote(context, session, createPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const createdOnB = await waitForPathContent(vaultB.path, createPath, (content) => content === createdContent);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(createdOnB, createdContent, "Created note did not round-trip to the second vault.");
const initialUpdateContent = "# Update target\n\nInitial content.\n";
@@ -309,34 +473,34 @@ async function runCreateUpdateDelete(
await uploadNote(context, session, updatePath);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, updatedContent);
await uploadNote(context, session, updatePath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const updatedOnB = await waitForPathContent(vaultB.path, updatePath, (content) => content === updatedContent);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(updatedOnB, updatedContent, "Updated note content did not round-trip to the second vault.");
const deleteContent = "# Delete target\n\nThis note should be removed from B.\n";
session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath, deleteContent);
await uploadNote(context, session, deletePath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, deletePath, (content) => content === deleteContent);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA);
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathDeleted(vaultB.path, deletePath);
await session.app.stop();
await stopTrackedSession(context, session);
console.log("Two-vault note creation, update, and deletion round-tripped.");
}
@@ -350,18 +514,51 @@ async function runRename(context: RunnerContext, vaultA: TemporaryVault, vaultB:
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, renameFromPath, renameToPath);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, renameToPath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const renamedOnB = await waitForPathContent(vaultB.path, renameToPath, (content) => content === renamedContent);
await waitForPathDeleted(vaultB.path, renameFromPath);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(renamedOnB, renamedContent, "Renamed note content did not round-trip to the second vault.");
console.log("Two-vault note rename round-tripped.");
}
async function runCaseOnlyRename(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const fileContent = "# Case-only rename\n\nThe document ID should remain live.\n";
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, fileContent);
await uploadNote(context, session, caseRenameFromPath);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, caseRenameFromPath, (content) => content === fileContent);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA);
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, caseRenameToPath);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, caseRenameToPath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const renamedOnB = await waitForPathContent(vaultB.path, caseRenameToPath, (content) => content === fileContent);
await waitForExactCaseOnlyRename(vaultB.path, caseRenameFromPath, caseRenameToPath);
await stopTrackedSession(context, session);
assertEqual(renamedOnB, fileContent, "Case-only note rename did not round-trip to the second vault.");
console.log("Two-vault case-only note rename round-tripped without a tombstone.");
}
async function runEncryptedRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
@@ -378,12 +575,12 @@ async function runEncryptedRoundTrip(
let session = await startConfiguredSession(context, vaultA, encryptedOverrides);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, encryptedPath, encryptedContent);
await uploadNote(context, session, encryptedPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, encryptedOverrides);
await syncAndApply(context, session);
const received = await waitForPathContent(vaultB.path, encryptedPath, (content) => content === encryptedContent);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(received, encryptedContent, "Encrypted note did not round-trip to the second vault.");
console.log("Two-vault encrypted note synchronisation round-tripped.");
@@ -397,31 +594,290 @@ async function runMarkdownAutoMerge(
const base = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nBottom anchor\n";
const left = "# Conflict\n\nTop anchor\n\nLeft line\n\nMiddle anchor\n\nBottom anchor\n";
const right = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nRight tail\n\nBottom anchor\n";
const conflictOverrides = {
disableMarkdownAutoMerge: true,
checkConflictOnlyOnOpen: true,
showMergeDialogOnlyOnActive: true,
};
let session = await startConfiguredSession(context, vaultB);
await createMarkdownConflict(context, session, vaultB, conflictPath, base, left, right);
await autoMergeMarkdownConflict(context.cliBinary, session.cliEnv, conflictPath);
let session = await startConfiguredSession(context, vaultA, conflictOverrides);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictPath, base);
await uploadNote(context, session, conflictPath);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, conflictOverrides);
await syncAndApply(context, session);
const baseOnB = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath);
await waitForPathContent(vaultB.path, conflictPath, (content) => content === base);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, conflictOverrides);
const baseOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath);
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, left, baseOnA.rev);
await writeVaultFile(vaultA.path, conflictPath, left);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, conflictOverrides);
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, right, baseOnB.rev);
await writeVaultFile(vaultB.path, conflictPath, right);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const conflict = await waitForFileConflict(context.cliBinary, session.cliEnv, conflictPath);
const leftBranch = conflict.branches.find((branch) => branch.content === left);
const rightBranch = conflict.branches.find((branch) => branch.content === right);
if (!leftBranch || !rightBranch) {
throw new Error(`The two Vault edits did not form the expected conflict: ${JSON.stringify(conflict)}`);
}
const merged = await calculateMarkdownAutoMerge(context.cliBinary, session.cliEnv, conflictPath);
if (!merged.includes("Left line") || !merged.includes("Right tail")) {
throw new Error(`Markdown auto-merge discarded a non-overlapping edit: ${JSON.stringify({ merged })}`);
}
const mergedRev = await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, merged, rightBranch.rev);
await deleteRevisionAndReflect(context.cliBinary, session.cliEnv, conflictPath, leftBranch.rev);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const mergedOnB = await waitForPathContent(
vaultB.path,
conflictPath,
(content) => content.includes("Left line") && content.includes("Right tail"),
(content) => content === merged,
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
);
await session.app.stop();
session = await startConfiguredSession(context, vaultA);
const afterResolution = `${merged.trimEnd()}\n\nPost-resolution edit on B.\n`;
await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, afterResolution, mergedRev);
await writeVaultFile(vaultB.path, conflictPath, afterResolution);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, conflictOverrides);
await syncAndApply(context, session);
const mergedOnA = await waitForPathContent(
const resolvedOnA = await waitForPathContent(
vaultA.path,
conflictPath,
(content) => content.includes("Left line") && content.includes("Right tail"),
(content) => content === afterResolution,
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
);
await session.app.stop();
const resolvedState = await readFileConflictState(context.cliBinary, session.cliEnv, conflictPath);
await stopTrackedSession(context, session);
assertEqual(mergedOnA, mergedOnB, "Merged Markdown content was not consistent across both vaults.");
console.log("Markdown conflict was automatically merged and propagated by the next synchronisation.");
assertEqual(mergedOnB, merged, "The resolving Vault did not reflect the merged Markdown content.");
assertEqual(
resolvedOnA,
afterResolution,
"The resolved Markdown content did not replace the known losing revision."
);
assertEqual(
resolvedState.branches.length,
1,
"The receiving Vault recreated a conflict from the known losing revision."
);
console.log(
"A two-Vault Markdown conflict was merged, edited again, and propagated to the Vault holding the resolved losing revision."
);
}
async function runConflictTimeStorageOperations(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const paths = [conflictEditPath, conflictDeletePath, conflictCaseFromPath, conflictRenameFromPath] as const;
const conflictOverrides = {
disableMarkdownAutoMerge: true,
checkConflictOnlyOnOpen: true,
showMergeDialogOnlyOnActive: true,
handleFilenameCaseSensitive: false,
};
const baseContent = Object.fromEntries(paths.map((path) => [path, `# Conflict operation\n\nBase for ${path}.\n`])) as Record<
(typeof paths)[number],
string
>;
const leftContent = Object.fromEntries(
paths.map((path) => [path, `${baseContent[path]}\nEdit made on Vault A.\n`])
) as Record<(typeof paths)[number], string>;
const rightContent = Object.fromEntries(
paths.map((path) => [path, `${baseContent[path]}\nDisplayed edit made on Vault B.\n`])
) as Record<(typeof paths)[number], string>;
let session = await startConfiguredSession(context, vaultA, conflictOverrides);
for (const path of paths) {
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, baseContent[path]);
await uploadNote(context, session, path);
}
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, conflictOverrides);
await syncAndApply(context, session);
for (const path of paths) {
await waitForPathContent(vaultB.path, path, (content) => content === baseContent[path]);
}
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, conflictOverrides);
for (const path of paths) {
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, leftContent[path]);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
}
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, conflictOverrides);
for (const path of paths) {
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, rightContent[path]);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
}
await pushLocalChanges(context.cliBinary, session.cliEnv);
const displayedRevisions = new Map<string, string>();
const initialBranchRevisions = new Map<string, Set<string>>();
for (const path of paths) {
const state = await waitForFileConflict(context.cliBinary, session.cliEnv, path);
const displayedBranch = state.branches.find((branch) => branch.content === rightContent[path] && !branch.deleted);
if (!displayedBranch) {
throw new Error(`Could not identify the branch displayed by Vault B: ${path}; ${JSON.stringify(state)}`);
}
const provenance = await readFileReflectionProvenance(context.cliBinary, session.cliEnv, path);
assertEqual(
provenance?.revision,
displayedBranch.rev,
`Vault B did not retain the exact displayed revision for ${path}.`
);
displayedRevisions.set(path, displayedBranch.rev);
initialBranchRevisions.set(path, new Set(state.branches.map((branch) => branch.rev)));
}
const editedAgain = `${rightContent[conflictEditPath]}\nSecond edit while the conflict is active.\n`;
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictEditPath, editedAgain);
const editedBranch = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
conflictEditPath,
(branch) => branch.content === editedAgain
);
assertEqual(
editedBranch.parentRev,
displayedRevisions.get(conflictEditPath),
"A conflict-time edit did not extend the displayed revision."
);
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, conflictDeletePath);
const deletedBranch = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
conflictDeletePath,
(branch) => branch.deleted
);
assertEqual(
deletedBranch.parentRev,
displayedRevisions.get(conflictDeletePath),
"A conflict-time deletion did not extend the displayed revision."
);
await renameNoteViaObsidian(
context.cliBinary,
session.cliEnv,
conflictCaseFromPath,
conflictCaseToPath
);
const caseRenamedBranch = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
conflictCaseToPath,
(branch) =>
!initialBranchRevisions.get(conflictCaseFromPath)?.has(branch.rev) &&
branch.path === conflictCaseToPath &&
branch.content === rightContent[conflictCaseFromPath] &&
!branch.deleted
);
const expectedCaseParent = displayedRevisions.get(conflictCaseFromPath);
if (caseRenamedBranch.parentRev !== expectedCaseParent) {
const [state, oldProvenance, newProvenance, identity] = await Promise.all([
readFileConflictState(context.cliBinary, session.cliEnv, conflictCaseToPath),
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath),
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath),
readPathIdentity(context.cliBinary, session.cliEnv, [conflictCaseFromPath, conflictCaseToPath]),
]);
throw new Error(
`A conflict-time case-only rename did not extend the displayed revision: ${JSON.stringify({
expectedCaseParent,
caseRenamedBranch,
state,
oldProvenance,
newProvenance,
identity,
})}`
);
}
const [oldCaseProvenance, newCaseProvenance] = await Promise.all([
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath),
readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath),
]);
assertEqual(oldCaseProvenance, null, "A conflict-time case-only rename retained the old provenance path.");
assertEqual(
newCaseProvenance?.revision,
caseRenamedBranch.rev,
"A conflict-time case-only rename did not record the new displayed revision."
);
await renameNoteViaObsidian(
context.cliBinary,
session.cliEnv,
conflictRenameFromPath,
conflictRenameToPath
);
const renamedTarget = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath);
const renamedSourceDeletion = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
conflictRenameFromPath,
(branch) => branch.deleted
);
assertEqual(
renamedSourceDeletion.parentRev,
displayedRevisions.get(conflictRenameFromPath),
"A conflict-time cross-path rename did not soft-delete the displayed source revision."
);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA, conflictOverrides);
await syncAndApply(context, session);
const replicatedBranches = [
[conflictEditPath, editedBranch],
[conflictDeletePath, deletedBranch],
[conflictCaseToPath, caseRenamedBranch],
[conflictRenameFromPath, renamedSourceDeletion],
] as const;
for (const [path, expectedBranch] of replicatedBranches) {
const replicated = await waitForConflictBranch(
context.cliBinary,
session.cliEnv,
path,
(branch) => branch.rev === expectedBranch.rev
);
assertEqual(
replicated.parentRev,
expectedBranch.parentRev,
`The exact conflict-operation revision tree did not replicate for ${path}.`
);
}
await waitForPathContent(
vaultA.path,
conflictRenameToPath,
(content) => content === rightContent[conflictRenameFromPath]
);
const targetOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath);
assertEqual(targetOnA.id, renamedTarget.id, "The cross-path rename target did not replicate as the same document.");
assertEqual(
await readVaultFile(vaultA.path, conflictDeletePath),
leftContent[conflictDeletePath],
"A logical deletion from one conflict branch removed the other Vault's live branch."
);
await stopTrackedSession(context, session);
console.log(
"Conflict-time edit, logical deletion, case-only rename, and cross-path rename extended the displayed branches and replicated their revision trees."
);
}
async function runTargetMismatch(
@@ -435,23 +891,57 @@ async function runTargetMismatch(
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, ignoredContent);
await uploadNote(context, session, targetMismatchPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, {
syncOnlyRegEx: "^E2E/two-vault/allowed/.*",
});
await syncAndApply(context, session);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, targetMismatchPath);
assertEqual(
await pathExists(vaultB.path, targetMismatchPath),
false,
"A note was reflected on a device where it was not a target file."
);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, {
syncOnlyRegEx: "^E2E/two-vault/allowed/.*",
});
assertEqual(
await pathExists(vaultB.path, targetMismatchPath),
false,
"A checkpointed non-target note was reflected before its target filter changed."
);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
{ syncOnlyRegEx: "" }
);
await syncAndApply(context, session);
const reflectedAfterEnabling = await waitForPathContent(
vaultB.path,
targetMismatchPath,
(content) => content === ignoredContent
);
await stopTrackedSession(context, session);
assertEqual(
reflectedAfterEnabling,
ignoredContent,
"Target file was not reflected after the device accepted the path."
);
session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, acceptedContent);
await uploadNote(context, session, targetMismatchPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB, {
syncOnlyRegEx: "",
@@ -462,10 +952,12 @@ async function runTargetMismatch(
targetMismatchPath,
(content) => content === acceptedContent
);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(received, acceptedContent, "Target file was not reflected after the device accepted the path.");
console.log("Two-vault target mismatch skipped a non-target note, then reflected it after enabling the target.");
assertEqual(received, acceptedContent, "Target file update was not reflected after the device accepted the path.");
console.log(
"Two-vault target mismatch skipped a non-target note, reflected it after enabling the target, and accepted a later update."
);
}
async function main(): Promise<void> {
@@ -482,8 +974,22 @@ async function main(): Promise<void> {
const vaultB = await createTemporaryVault();
const encryptedVaultA = await createTemporaryVault();
const encryptedVaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
const encryptedContext: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName: encryptedDbName };
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
const encryptedContext: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName: encryptedDbName,
reviewedVaults: new Set(),
activeSessions: new Set(),
};
try {
await assertCouchDbReachable(couchDb);
@@ -496,14 +1002,25 @@ async function main(): Promise<void> {
console.log(`Temporary CouchDB database: ${dbName}`);
console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`);
await runCreateUpdateDelete(context, vaultA, vaultB);
await runRename(context, vaultA, vaultB);
if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") {
await runMarkdownAutoMerge(context, vaultA, vaultB);
const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true";
if (!onlyConflictOperations) {
await runCreateUpdateDelete(context, vaultA, vaultB);
await runRename(context, vaultA, vaultB);
await runCaseOnlyRename(context, vaultA, vaultB);
if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") {
await runMarkdownAutoMerge(context, vaultA, vaultB);
}
}
if (onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true") {
await runConflictTimeStorageOperations(context, vaultA, vaultB);
}
if (!onlyConflictOperations) {
await runTargetMismatch(context, vaultA, vaultB);
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
}
await runTargetMismatch(context, vaultA, vaultB);
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
} finally {
await stopTrackedSessions(context);
await stopTrackedSessions(encryptedContext);
await vaultA.dispose();
await vaultB.dispose();
await encryptedVaultA.dispose();
@@ -0,0 +1,756 @@
import { spawn } from "node:child_process";
import { access, readFile, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
fetchCouchDbDatabaseInfo,
fetchCouchDbLocalDocs,
loadCouchDbConfig,
makeUniqueDatabaseName,
type CouchDbConfig,
type CouchDbDatabaseInfo,
type CouchDbDocument,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
configureCouchDb,
configureObjectStorage,
createE2eCouchDbPluginData,
createE2eObjectStoragePluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import {
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
listObjectStorageObjects,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
readObjectStorageJson,
type ObjectStorageConfig,
} from "../runner/objectStorage.ts";
import { ensurePinnedReleaseArtifact, UPGRADE_SOURCE_RELEASE } from "../runner/releaseArtifact.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
assertCouchDbCheckpointContinuity,
assertCouchDbDocumentsUnchanged,
assertJournalCheckpointAdvanced,
assertJournalCheckpointLoaded,
assertMilestoneContinuity,
assertNoJournalReplay,
assertSomeCouchDbCheckpointAdvanced,
type CouchDbCheckpointSnapshot,
type CouchDbDocumentRevision,
type MilestoneIdentity,
type RemoteObjectSnapshot,
} from "../runner/upgradeContinuity.ts";
import {
assertStableReleaseDefaults,
assertStableRemoteSelection,
assertUnconfiguredUpgradeReady,
assertUnconfiguredUpgradeRestarted,
assertUpgradeCompatibilityReady,
assertUpgradeRemainsReady,
configureStableRelease,
createPostUpgradeDelta,
createUpgradeScenarioPaths,
createVerifierReturnDelta,
dismissConfigDoctorIfShown,
prepareStableRemote,
readJournalCheckpoint,
readLocalCouchDbCheckpoints,
readRuntimeUpgradeState,
readRuntimeSettingsUpgradeState,
runCouchDbReplicationObserved,
runJournalReplicationObserved,
runStableFileHistory,
STABLE_RELEASE_VERSION,
verifyPostUpgradeHistory,
verifyPreUpgradeHistory,
verifyReturnDelta,
waitForPersistentNodeIdentity,
type CouchDbReplicationObservation,
type RuntimeUpgradeState,
type UpgradeTransportConfiguration,
} from "../runner/upgradeWorkflow.ts";
import { obsidianRemoteDebuggingPort } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
type Transport = "couchdb" | "object-storage";
type RemoteMilestone = CouchDbDocument & {
created?: unknown;
locked?: unknown;
accepted_nodes?: unknown;
tweak_values?: unknown;
};
type CouchDbRemoteSnapshot = {
checkpoints: CouchDbCheckpointSnapshot[];
documents: CouchDbDocumentRevision[];
info: CouchDbDatabaseInfo;
milestone: MilestoneIdentity;
preferredTweaks: Record<string, unknown>;
};
type ObjectStorageRemoteSnapshot = {
journalObjects: RemoteObjectSnapshot[];
milestone: MilestoneIdentity;
preferredTweaks: Record<string, unknown>;
};
type RunnerContext = {
binary: string;
cliBinary: string;
sourceArtifactRoot: string;
targetArtifactRoot: string;
targetVersion: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
type ParsedArguments = {
transports: Transport[];
manageServices: boolean;
keepServices: boolean;
};
type StartSessionOptions = {
pluginData?: Record<string, unknown>;
localStorageEntries?: Readonly<Record<string, string>>;
waitForCoreReady?: boolean;
};
const MILESTONE_ID = "_local/obsydian_livesync_milestone";
const JOURNAL_MILESTONE_NAME = "_00000000-milestone.json";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function parseArguments(argv: readonly string[]): ParsedArguments {
let transportValue = "all";
for (let index = 0; index < argv.length; index++) {
const argument = argv[index];
if (argument === "--transport") {
transportValue = argv[index + 1] ?? "";
index++;
} else if (argument.startsWith("--transport=")) {
transportValue = argument.slice("--transport=".length);
}
}
const transports: Transport[] =
transportValue === "all"
? ["couchdb", "object-storage"]
: transportValue === "couchdb" || transportValue === "object-storage"
? [transportValue]
: (() => {
throw new Error(`Unsupported transport '${transportValue}'. Use couchdb, object-storage, or all.`);
})();
return {
transports,
manageServices: argv.includes("--manage-services"),
keepServices: argv.includes("--keep-services"),
};
}
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort(process.env);
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function runNpmScript(name: string, optional = false): Promise<void> {
return new Promise((resolvePromise, reject) => {
console.log(`\n# ${name}`);
const child = spawn(npmBinary(), ["run", name], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0 || optional) {
if (code !== 0) {
console.warn(`${name} did not complete cleanly (${signal ? `signal ${signal}` : `exit ${code}`}).`);
}
resolvePromise();
return;
}
reject(new Error(`${name} failed (${signal ? `signal ${signal}` : `exit ${code}`}).`));
});
});
}
async function validateTargetArtifact(root: string): Promise<string> {
await Promise.all(
["main.js", "manifest.json", "styles.css"].map(async (name) => await access(resolve(root, name)))
);
const manifest = JSON.parse(await readFile(resolve(root, "manifest.json"), "utf8")) as {
id?: unknown;
version?: unknown;
};
assertEqual(manifest.id, UPGRADE_SOURCE_RELEASE.pluginId, "The target artefact has an unexpected plug-in id.");
assert(typeof manifest.version === "string" && manifest.version.length > 0, "The target manifest has no version.");
assert(
manifest.version !== STABLE_RELEASE_VERSION,
`The target artefact is still the source release ${STABLE_RELEASE_VERSION}.`
);
return manifest.version;
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
port: number,
artifactRoot: string,
options: StartSessionOptions = {}
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
artifactRoot,
pluginData: options.pluginData,
localStorageEntries: options.localStorageEntries,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(port),
});
context.activeSessions.add(session);
try {
if (options.waitForCoreReady !== false) {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
}
return session;
} catch (error) {
await stopSession(context, session).catch(() => undefined);
throw error;
}
}
async function readStoredPluginData(vault: TemporaryVault): Promise<Record<string, unknown>> {
const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json");
return JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
}
async function writeStoredPluginData(vault: TemporaryVault, data: Record<string, unknown>): Promise<void> {
const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json");
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`);
}
async function runUnconfiguredSettingsUpgrade(context: RunnerContext, port: number): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: unconfigured legacy settings`);
const vault = await createTemporaryVault("obsidian-livesync-upgrade-unconfigured-");
try {
let session = await startSession(context, vault, port, context.sourceArtifactRoot, {
pluginData: { liveSync: false },
waitForCoreReady: false,
});
const stableState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(stableState, false);
await stopSession(context, session);
const stableData = await readStoredPluginData(vault);
if (stableData.isConfigured !== undefined) {
assertEqual(
stableData.isConfigured,
false,
"The stable release persisted a configured state for its default-equivalent settings."
);
}
// 0.25.83 infers the runtime boolean, but persistence depends on an
// unrelated settings-save event. Restore the pre-flag document
// explicitly so the target proves the direct legacy migration in
// either case rather than depending on that timing.
await writeStoredPluginData(vault, { liveSync: false });
session = await startSession(context, vault, port, context.targetArtifactRoot, {
waitForCoreReady: false,
});
const upgradedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertUnconfiguredUpgradeReady(stableState, upgradedState, context.targetVersion);
await stopSession(context, session);
const migratedData = await readStoredPluginData(vault);
assertEqual(migratedData.isConfigured, false, "The inferred unconfigured state was not saved.");
assertEqual(
migratedData.handleFilenameCaseSensitive,
false,
"The inferred case-insensitive setting was not saved."
);
session = await startSession(context, vault, port, context.targetArtifactRoot, {
waitForCoreReady: false,
});
const restartedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertUnconfiguredUpgradeRestarted(restartedState, context.targetVersion);
await stopSession(context, session);
console.log(
`PASS unconfigured settings: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; legacy inference, persistence, and restart idempotence verified.`
);
} finally {
await stopSessions(context);
await vault.dispose();
}
}
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) await stopSession(context, session);
}
function milestoneIdentity(document: RemoteMilestone): MilestoneIdentity {
assert(document.created !== undefined && document.created !== null, "The remote milestone has no generation.");
assert(typeof document.locked === "boolean", "The remote milestone has no lock state.");
assert(Array.isArray(document.accepted_nodes), "The remote milestone has no accepted-device list.");
assert(
document.accepted_nodes.every((value) => typeof value === "string"),
"The remote milestone accepted-device list is malformed."
);
return {
created: document.created,
locked: document.locked,
acceptedNodes: document.accepted_nodes,
};
}
function preferredTweaks(document: RemoteMilestone): Record<string, unknown> {
const values = document.tweak_values;
assert(values !== null && typeof values === "object" && !Array.isArray(values), "The remote has no tweak map.");
const preferred = (values as Record<string, unknown>).PREFERRED;
assert(
preferred !== null && typeof preferred === "object" && !Array.isArray(preferred),
"The remote has no preferred tweak settings."
);
return { ...(preferred as Record<string, unknown>) };
}
async function readCouchDbRemoteSnapshot(config: CouchDbConfig, databaseName: string): Promise<CouchDbRemoteSnapshot> {
const [allDocs, localDocs, info] = await Promise.all([
fetchAllCouchDbDocs(config, databaseName),
fetchCouchDbLocalDocs(config, databaseName),
fetchCouchDbDatabaseInfo(config, databaseName),
]);
const milestone = localDocs.rows.find(({ id }) => id === MILESTONE_ID)?.doc as RemoteMilestone | undefined;
assert(milestone, "The CouchDB remote milestone is missing.");
const checkpoints = localDocs.rows.flatMap(({ id, doc }) =>
doc && Object.prototype.hasOwnProperty.call(doc, "last_seq") ? [{ id, lastSequence: doc.last_seq }] : []
);
const documents = allDocs.rows.map(({ id, value }) => ({
id,
revision: value.rev,
deleted: value.deleted === true,
}));
return {
checkpoints,
documents,
info,
milestone: milestoneIdentity(milestone),
preferredTweaks: preferredTweaks(milestone),
};
}
async function readObjectStorageRemoteSnapshot(
config: ObjectStorageConfig,
prefix: string
): Promise<ObjectStorageRemoteSnapshot> {
const [objects, milestone] = await Promise.all([
listObjectStorageObjects(config, prefix),
readObjectStorageJson<RemoteMilestone>(config, `${prefix}${JOURNAL_MILESTONE_NAME}`),
]);
const journalObjects = objects.flatMap((object) => {
if (!object.Key || basename(object.Key).startsWith("_")) return [];
return [
{
key: object.Key,
size: object.Size ?? 0,
etag: object.ETag ?? "",
},
];
});
return {
journalObjects,
milestone: milestoneIdentity(milestone),
preferredTweaks: preferredTweaks(milestone),
};
}
function assertNoOpCouchDbObservation(observation: CouchDbReplicationObservation): void {
assert(observation.succeeded, "The first post-upgrade CouchDB synchronisation failed.");
assertEqual(observation.sentDocuments, 0, "The no-op CouchDB synchronisation resent documents.");
assertEqual(observation.arrivedDocuments, 0, "The no-op CouchDB synchronisation refetched documents.");
}
function assertNoOpCouchDbDatabase(before: CouchDbRemoteSnapshot, after: CouchDbRemoteSnapshot): void {
assertCouchDbCheckpointContinuity(before.checkpoints, after.checkpoints);
assertCouchDbDocumentsUnchanged(before.documents, after.documents);
assertEqual(
after.info.update_seq,
before.info.update_seq,
"The no-op CouchDB synchronisation advanced update_seq."
);
assertEqual(after.info.doc_count, before.info.doc_count, "The no-op CouchDB synchronisation changed doc_count.");
assertMilestoneContinuity(before.milestone, after.milestone);
}
function assertRestartContinuity(before: RuntimeUpgradeState, after: RuntimeUpgradeState): void {
assertEqual(after.localDatabaseName, before.localDatabaseName, "Restart opened a different local database.");
assertEqual(after.nodeId, before.nodeId, "Restart changed the device node identity.");
assertEqual(
after.settings.activeConfigurationId,
before.settings.activeConfigurationId,
"Restart changed the active remote profile."
);
}
async function configureFreshCouchDbVerifier(
context: RunnerContext,
session: ObsidianLiveSyncSession,
config: CouchDbConfig,
databaseName: string,
tweaks: Record<string, unknown>
): Promise<void> {
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{ uri: config.uri, username: config.username, password: config.password, dbName: databaseName },
tweaks
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
}
async function configureFreshObjectStorageVerifier(
context: RunnerContext,
session: ObsidianLiveSyncSession,
config: ObjectStorageConfig,
prefix: string,
tweaks: Record<string, unknown>
): Promise<void> {
await configureObjectStorage(context.cliBinary, session.cliEnv, { ...config, bucketPrefix: prefix }, tweaks);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
}
async function runCouchDbUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: CouchDB`);
const config = await loadCouchDbConfig();
const databaseName = makeUniqueDatabaseName(config.dbPrefix, "upgrade-from-stable");
const remote: UpgradeTransportConfiguration = { kind: "couchdb", config, databaseName };
const paths = createUpgradeScenarioPaths("couchdb");
const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-");
const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-verifier-");
let upgradedSession: ObsidianLiveSyncSession | undefined;
try {
await assertCouchDbReachable(config);
await createCouchDbDatabase(config, databaseName);
let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false);
await configureStableRelease(context.cliBinary, session.cliEnv, remote);
const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(configuredStable, true);
assertStableRemoteSelection(configuredStable, remote);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(restartedStable, true);
assertStableRemoteSelection(restartedStable, remote);
await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv);
await prepareStableRemote(context.cliBinary, session.cliEnv);
await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => {
const result = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(result.succeeded, "The stable CouchDB synchronisation failed.");
});
await verifyPreUpgradeHistory(upgradeVault, paths);
const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
const stableRemote = await readCouchDbRemoteSnapshot(config, databaseName);
const stableLocalCheckpoints = await readLocalCouchDbCheckpoints(
context.cliBinary,
session.cliEnv,
stableRemote.checkpoints.map(({ id }) => id)
);
assertCouchDbCheckpointContinuity(stableRemote.checkpoints, stableLocalCheckpoints);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
upgradedSession = session;
await dismissConfigDoctorIfShown(session.remoteDebuggingPort);
const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote);
await verifyPreUpgradeHistory(upgradeVault, paths);
const loadedLocalCheckpoints = await readLocalCouchDbCheckpoints(
context.cliBinary,
session.cliEnv,
stableRemote.checkpoints.map(({ id }) => id)
);
assertCouchDbCheckpointContinuity(stableLocalCheckpoints, loadedLocalCheckpoints);
const noOpObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assertNoOpCouchDbObservation(noOpObservation);
const noOpRemote = await readCouchDbRemoteSnapshot(config, databaseName);
assertNoOpCouchDbDatabase(stableRemote, noOpRemote);
await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths);
const deltaObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(deltaObservation.succeeded, "The post-upgrade CouchDB delta failed.");
assert(deltaObservation.sentDocuments > 0, "The post-upgrade CouchDB delta sent no documents.");
const deltaRemote = await readCouchDbRemoteSnapshot(config, databaseName);
assertSomeCouchDbCheckpointAdvanced(noOpRemote.checkpoints, deltaRemote.checkpoints);
assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone);
const verifierSettings = {
uri: config.uri,
username: config.username,
password: config.password,
dbName: databaseName,
};
const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, {
pluginData: createE2eCouchDbPluginData(verifierSettings, deltaRemote.preferredTweaks),
localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name),
});
await configureFreshCouchDbVerifier(context, verifier, config, databaseName, deltaRemote.preferredTweaks);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
await verifyPostUpgradeHistory(verifierVault, paths);
await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
const returnObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(returnObservation.succeeded, "The upgraded CouchDB device could not receive the verifier delta.");
assert(returnObservation.arrivedDocuments > 0, "The verifier CouchDB delta did not arrive.");
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, verifier);
await stopSession(context, session);
upgradedSession = undefined;
const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv);
assertUpgradeRemainsReady(restartedState, context.targetVersion);
assertRestartContinuity(upgradedState, restartedState);
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, restarted);
console.log(
`PASS CouchDB: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no-op sync, delta sync, fresh-device round-trip, and restart continuity verified.`
);
} finally {
if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined);
await stopSessions(context);
await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]);
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(config, databaseName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
async function runObjectStorageUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: Object Storage`);
const config = await loadObjectStorageConfig();
const prefix = makeUniqueBucketPrefix("upgrade-from-stable");
const remote: UpgradeTransportConfiguration = { kind: "object-storage", config, bucketPrefix: prefix };
const paths = createUpgradeScenarioPaths("object-storage");
const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-");
const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-verifier-");
let upgradedSession: ObsidianLiveSyncSession | undefined;
try {
await ensureObjectStorageBucket(config);
let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false);
await configureStableRelease(context.cliBinary, session.cliEnv, remote);
const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(configuredStable, true);
assertStableRemoteSelection(configuredStable, remote);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(restartedStable, true);
assertStableRemoteSelection(restartedStable, remote);
await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv);
await prepareStableRemote(context.cliBinary, session.cliEnv);
await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => {
const result = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(
result.succeeded,
`The stable Object Storage synchronisation failed.\nObservation: ${JSON.stringify(result)}`
);
});
await verifyPreUpgradeHistory(upgradeVault, paths);
const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
const stableCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
const stableRemote = await readObjectStorageRemoteSnapshot(config, prefix);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
upgradedSession = session;
await dismissConfigDoctorIfShown(session.remoteDebuggingPort);
const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote);
await verifyPreUpgradeHistory(upgradeVault, paths);
const loadedCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
assertJournalCheckpointLoaded(stableCheckpoint, loadedCheckpoint);
const noOpObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(noOpObservation.succeeded, "The first post-upgrade Object Storage synchronisation failed.");
const noOpCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
const noOpRemote = await readObjectStorageRemoteSnapshot(config, prefix);
assertNoJournalReplay(
stableCheckpoint,
noOpCheckpoint,
stableRemote.journalObjects,
noOpRemote.journalObjects,
noOpObservation
);
assertMilestoneContinuity(stableRemote.milestone, noOpRemote.milestone);
await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths);
const deltaObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(deltaObservation.succeeded, "The post-upgrade Object Storage delta failed.");
const deltaCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
assertJournalCheckpointAdvanced(noOpCheckpoint, deltaCheckpoint, deltaObservation);
const deltaRemote = await readObjectStorageRemoteSnapshot(config, prefix);
assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone);
const verifierSettings = { ...config, bucketPrefix: prefix };
const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, {
pluginData: createE2eObjectStoragePluginData(verifierSettings, deltaRemote.preferredTweaks),
localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name),
});
await configureFreshObjectStorageVerifier(context, verifier, config, prefix, deltaRemote.preferredTweaks);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
await verifyPostUpgradeHistory(verifierVault, paths);
await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
const returnObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(returnObservation.succeeded, "The upgraded Object Storage device could not receive the verifier delta.");
assert(returnObservation.downloadedJournalKeys.length > 0, "The verifier Object Storage delta did not arrive.");
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, verifier);
await stopSession(context, session);
upgradedSession = undefined;
const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv);
assertUpgradeRemainsReady(restartedState, context.targetVersion);
assertRestartContinuity(upgradedState, restartedState);
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, restarted);
console.log(
`PASS Object Storage: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no replay, delta sync, fresh-device round-trip, and restart continuity verified.`
);
} finally {
if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined);
await stopSessions(context);
await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]);
if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") {
await deleteObjectStoragePrefix(config, prefix).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
async function startManagedServices(transports: readonly Transport[]): Promise<void> {
if (transports.includes("couchdb")) {
await runNpmScript("test:docker-couchdb:stop", true);
await runNpmScript("test:docker-couchdb:start");
}
if (transports.includes("object-storage")) {
await runNpmScript("test:docker-s3:stop", true);
await runNpmScript("test:docker-s3:start");
}
}
async function stopManagedServices(transports: readonly Transport[]): Promise<void> {
if (transports.includes("object-storage")) await runNpmScript("test:docker-s3:stop", true);
if (transports.includes("couchdb")) await runNpmScript("test:docker-couchdb:stop", true);
}
async function main(): Promise<void> {
const arguments_ = parseArguments(process.argv.slice(2));
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const targetArtifactRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd());
const targetVersion = await validateTargetArtifact(targetArtifactRoot);
const sourceArtifactRoot = await ensurePinnedReleaseArtifact();
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
sourceArtifactRoot,
targetArtifactRoot,
targetVersion,
activeSessions: new Set(),
};
const ports = sessionPorts();
let managedServicesStarted = false;
console.log(`Using exact source release: ${STABLE_RELEASE_VERSION}`);
console.log(`Using target release candidate: ${targetVersion}`);
console.log(`Source artefact cache: ${sourceArtifactRoot}`);
console.log(`Target artefact root: ${targetArtifactRoot}`);
try {
await runUnconfiguredSettingsUpgrade(context, ports[0]);
if (arguments_.manageServices) {
await startManagedServices(arguments_.transports);
managedServicesStarted = true;
}
for (const transport of arguments_.transports) {
if (transport === "couchdb") await runCouchDbUpgrade(context, ports);
else await runObjectStorageUpgrade(context, ports);
}
} finally {
await stopSessions(context);
if (managedServicesStarted && !arguments_.keepServices) {
await stopManagedServices(arguments_.transports);
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});