mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 19:43:20 +00:00
30 files changed
+2686
-323
No files matched your search
@@ -13,6 +13,7 @@ on:
|
||||
- 'src/**'
|
||||
- 'test/**'
|
||||
- '_tools/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
@@ -51,6 +52,7 @@ on:
|
||||
- 'src/**'
|
||||
- 'test/**'
|
||||
- '_tools/**'
|
||||
- 'scripts/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
|
||||
@@ -353,6 +353,7 @@ This policy governs plug-in pre-releases and their accompanying CLI images. For
|
||||
- Add only user-facing changes that help users understand what they gain, what has changed, or what they may need to do after updating.
|
||||
- Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included.
|
||||
- When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle.
|
||||
- For a stable release, review the complete user-facing changes since the previous stable release. Classify new features and changed behaviour relative to that stable release. Include changes from intervening pre-releases which never became stable, even when their original notes have already moved into a release history.
|
||||
- Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes.
|
||||
- Keep approximately the five most recent published plug-in versions in the embedded `updates.md`. Move older published sections unchanged into the appropriate release-line archive under `docs/releases/`, and update the history references when rotating them.
|
||||
|
||||
@@ -372,8 +373,9 @@ For a fix needed only by the CLI, use [CLI-only patch releases](#cli-only-patch-
|
||||
- Validate installation or update through BRAT, and cold-start Obsidian to confirm the plug-in loads. Verify ordinary bidirectional synchronisation and relevant regression scenarios in real Obsidian against the published artefacts, or a build with matching hashes. When automated scenarios establish that coverage, BRAT acceptance need only cover installation or update and start-up; manually check any remaining release-specific behaviour. Record the artefact hashes and validation evidence as described in the [real Obsidian test guide](test/e2e-obsidian/README.md#combined-setup-and-security-regression-checks).
|
||||
- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action.
|
||||
- After a stable version passes, remove the GitHub pre-release designation and make that exact release the latest stable release. Confirm that the release is no longer a pre-release, then mark its release pull request ready, merge the exact release commit into the selected base branch with a merge commit, and integrate that exact commit through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
|
||||
- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show <tag>:updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history.
|
||||
- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action.
|
||||
- If BRAT validation fails, keep the release PR in draft and leave its published tag and artefacts unchanged. Prepare the correction on a new branch descended from that exact release commit, retaining its existing changes and `versions.json` entry. Prepare the next patch or pre-release version on that branch, with its own version, tag, and release PR. The selected base branch remains unchanged until a successor stable release passes validation and is promoted.
|
||||
- For the successor stable release, consolidate all user-facing changes since the last stable release, including changes from intervening pre-releases which never became stable. Preserve each published pre-release's exact tagged notes in the appropriate history under `docs/releases/`, and compare the archived section with `git show <tag>:updates.md`. The new release notes must describe the whole stable upgrade, including the correction.
|
||||
- After the successor stable release is validated and promoted, merge its exact release commit and ancestry through the reviewed branch chain. Leave the failed release PR in draft until it is deliberately closed as superseded under a separate maintainer action.
|
||||
- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
|
||||
|
||||
### Release Cheat Sheet
|
||||
@@ -403,7 +405,7 @@ For a fix needed only by the CLI, use [CLI-only patch releases](#cli-only-patch-
|
||||
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
|
||||
10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action.
|
||||
11. After a stable version passes, remove the pre-release designation and make the exact release the latest stable release. Confirm that promotion, then mark the release PR ready, merge the exact release commit into the selected base branch, and integrate it through the reviewed branch chain into the repository's default branch. Confirm that the default branch contains the matching stable metadata, then publish stable CLI tags through a separate maintainer gate if selected.
|
||||
12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
|
||||
12. If validation fails, leave the PR in draft and keep its published tag and artefacts unchanged. Start a new branch from that release commit, add the correction, and prepare the next patch or pre-release version. Retain the published version's `versions.json` entry and exact archived notes, and consolidate the successor stable release notes from the last stable release. Keep the base branch unchanged until the successor stable release is validated and promoted.
|
||||
|
||||
### CLI-only patch releases
|
||||
|
||||
|
||||
@@ -59,10 +59,12 @@ that evidence. The inspected Commonlib source is commit
|
||||
|
||||
## Decision
|
||||
|
||||
Make local file processing recoverable until its result is established, use
|
||||
content and provenance to reconcile ambiguous timestamps, and enable batching
|
||||
in LiveSync mode only through an explicit choice after those contracts pass
|
||||
verification. Apply the recovery contract with batching enabled or disabled.
|
||||
Make local file processing recoverable until its result is established, retain
|
||||
strict content and provenance reconciliation for manual and recovery scans, and
|
||||
enable batching in LiveSync mode only through an explicit choice after those
|
||||
contracts pass verification. Ordinary Obsidian start-up may skip a body read
|
||||
only under the exact-match conditions below. Apply the recovery contract with
|
||||
batching enabled or disabled.
|
||||
|
||||
The content-reconciliation change targets selected ordinary files in the
|
||||
Offline Scanner's `NEWER_WINS` / `sync-newer` action. Keep the existing action
|
||||
@@ -143,9 +145,30 @@ policy-approved skip before the manager can acknowledge either safely.
|
||||
|
||||
A crash after the database write but before snapshot removal can replay an
|
||||
operation. Replay must check current storage and revision provenance, retain
|
||||
the existing protection against stale restored events, and avoid duplicate
|
||||
revisions or deletion of a newer same-path file. It must be safe to interrupt
|
||||
replay again. Existing saved snapshots remain readable.
|
||||
the existing protection against stale restored events, and never delete a
|
||||
newer same-path file. Once both the database write and provenance write have
|
||||
completed, replay must not add another revision. If the database write
|
||||
succeeded before provenance was recorded, replay may first create an identical
|
||||
sibling even when no remote descendant has arrived. In the tested case
|
||||
without a remote edit, replay creates that sibling. Duplicate handling then
|
||||
tombstones one of the identical branches and converges to one live leaf with
|
||||
the exact saved content.
|
||||
Another restart must add no revision. Replay must remain safe to interrupt;
|
||||
existing saved snapshots remain readable.
|
||||
|
||||
The database write and the device-local provenance write are separate,
|
||||
sequential operations. A stop between them can leave the previous provenance
|
||||
record in place. This pre-provenance gap is the known exception to the
|
||||
no-extra-revision result after both writes complete: replay can first create a
|
||||
duplicate sibling; one of the identical branches is then tombstoned while
|
||||
one live leaf retains the saved bytes when no remote descendant has arrived. If continuous
|
||||
replication advances the local database during the gap, replay can instead
|
||||
expose the local content as a sibling of that descendant. The [conflict
|
||||
resolution specification](../specs_conflict_resolution.md#known-potentially-problematic-behaviour)
|
||||
records the required mergeable and manual-resolution outcomes. The per-file
|
||||
lock does not fence replication writes. The interruption window is expected
|
||||
to be very rare, but its field frequency is unmeasured; this decision adds no
|
||||
transaction or second journal to close the interval.
|
||||
|
||||
Boolean failure, an exception, and an unavailable comparison are not successful
|
||||
completion. Keep unresolved work recoverable and report the failure through the
|
||||
@@ -187,12 +210,14 @@ finishes; correctness must also follow from the persistent record and start-up
|
||||
reconciliation. Remote availability must not determine whether local pending
|
||||
work survives shutdown.
|
||||
|
||||
### 4. Reconcile content when timestamps are inconclusive
|
||||
### 4. Preserve strict content reconciliation
|
||||
|
||||
Retain the existing timestamp tolerance for platforms with coarse modification
|
||||
times. Timestamp equality, including exactly equal raw timestamps, does not
|
||||
prove content equality. The Offline Scanner must establish one of the following
|
||||
outcomes through the ordinary non-forced file handler and its provenance rules:
|
||||
Strict content checking retains the timestamp and provenance reconciliation
|
||||
contract for platforms with coarse modification times. Timestamp equality,
|
||||
including exactly equal raw timestamps, does not prove content equality. With
|
||||
strict checking enabled, the Offline Scanner must establish one of the
|
||||
following outcomes through the ordinary non-forced file handler and its
|
||||
provenance rules:
|
||||
|
||||
| Evidence | Required outcome |
|
||||
| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -202,6 +227,26 @@ outcomes through the ordinary non-forced file handler and its provenance rules:
|
||||
| Vault content has no provable origin and matches no current live leaf | Preserve it according to the existing independent-branch rules. Do not attach it to an arbitrary database winner. |
|
||||
| Required content or provenance cannot be read, or state changes during comparison | Retain unresolved work and report or re-evaluate it. Do not mark it reconciled merely because the timestamp comparison is `EVEN`. |
|
||||
|
||||
The `strictContentCheck` scan option is strict when omitted. Ordinary Obsidian
|
||||
start-up can opt out through **Strict content check on startup**, which is
|
||||
disabled by default. With the option disabled, a body read may be skipped only
|
||||
when the file's raw modification time, byte size, and valid device-local
|
||||
provenance exactly match a conflict-free current revision and no file
|
||||
processing is pending. All other files retain strict content reconciliation.
|
||||
An unrecorded edit that preserves its exact size and modification time can
|
||||
therefore remain undetected by ordinary start-up while the option is disabled.
|
||||
Manual scans, Fetch, Rebuild, and other explicit recovery operations remain
|
||||
strict. Pending-event replay, incoming-change protection, and deletion guards
|
||||
remain active in both modes.
|
||||
|
||||
The table describes stable-state strict reconciliation, not atomic database and
|
||||
provenance persistence. Replay in the pre-provenance gap may create an
|
||||
identical sibling; with no remote descendant, one of the identical branches
|
||||
is tombstoned, leaving one live leaf with the exact saved content. If replication has
|
||||
already added a descendant, preserve its exact content through an existing
|
||||
safe merge, or retain both branches for manual resolution. See [Known
|
||||
potentially problematic behaviour](../specs_conflict_resolution.md#known-potentially-problematic-behaviour).
|
||||
|
||||
Revalidate the relevant file and database state before acting on a comparison.
|
||||
Reuse the existing content and provenance checks; avoid a separate conflict
|
||||
algorithm in the scanner. Metadata continues to reference Chunks containing the
|
||||
@@ -220,12 +265,14 @@ Obsidian start-up can report `completed-with-file-failures` and allow unaffected
|
||||
files to proceed; an unresolved pair must not become `completed` to achieve
|
||||
that result. Keep the stricter callers' existing completion requirements.
|
||||
|
||||
This deliberately extends the current equal-time scanner contract. Existing
|
||||
provenance tests expect no database write during that scan, including for an
|
||||
edited file. Preserve their unchanged-file and later incoming-protection
|
||||
scenarios; change the edited-file expectation to require preservation during
|
||||
the scan itself. Test both identical timestamps and distinct raw timestamps
|
||||
within one comparison bucket.
|
||||
Keep the original unchanged-file, edited-file, untracked-provenance,
|
||||
incoming-protection, and concurrent-recheck fixtures under strict content
|
||||
checking, including identical timestamps and distinct raw timestamps within
|
||||
one comparison bucket. Do not weaken those strict-mode assertions to describe
|
||||
the ordinary start-up opt-out. Test the opt-out separately: only the exact
|
||||
stat, provenance, conflict-free, and no-pending-processing match may skip a
|
||||
body read. Pending-event replay and incoming or deletion guards remain covered
|
||||
with the opt-out enabled.
|
||||
|
||||
### 5. Make batch timing and activation explicit
|
||||
|
||||
@@ -333,12 +380,16 @@ matrix is:
|
||||
| Scenario | Required observation |
|
||||
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Stop after dequeue, timer release, or during a database write | Pending work remains recoverable; restart eventually represents the saved Vault edit. |
|
||||
| Database write succeeds before snapshot cleanup; restart twice | Replay creates no duplicate revision and preserves later edits. |
|
||||
| Database and provenance writes are complete before replay; restart twice | Replay adds no extra revision. |
|
||||
| Stop after the database write but before provenance; no remote descendant; replay twice | The tested replay creates an identical sibling and tombstones one of the two branches; one live leaf retains exact saved content, and the second restart adds no revision. |
|
||||
| A remote descendant arrives during the pre-provenance gap | The mergeable case preserves its exact content and ancestry; the overlapping case retains both bodies for manual resolution. |
|
||||
| Old and new events share a path; snapshot writes overlap | Completing old work cannot remove new work or regress the durable snapshot. |
|
||||
| Handler returns false, throws, or snapshot persistence fails | Flush reports failure; pending work is retained; unrelated recoverable work can continue. |
|
||||
| Buffered or in-progress work exists with no waiting timer | Flush still waits for actual completion. |
|
||||
| A flush overlaps new typing or a rename/delete barrier | The captured work completes without waiting for later work or deadlocking. |
|
||||
| Equal timestamps: unchanged, edited, untracked, pruned provenance, unavailable Chunks, or concurrent recheck changes | Preserve correct ancestry and bytes, avoid duplicate revisions, and distinguish unresolved comparisons from successful reconciliation. |
|
||||
| Strict check: equal timestamps, unchanged or edited files, untracked or pruned provenance, unavailable Chunks, or concurrent recheck changes | Preserve correct ancestry and bytes, avoid duplicate revisions, and distinguish unresolved comparisons from successful reconciliation. |
|
||||
| Start-up check disabled: exact raw time, size, and provenance match with no conflict or pending processing | The body read may be skipped; an unrecorded edit which preserves exact size and modification time remains an accepted limitation. |
|
||||
| Pending-event replay and incoming or deletion guards, with either start-up setting | Keep replay and protection active; do not skip work or remove a file based only on matching statistics. |
|
||||
| Settings save followed by stop; ordinary unload; interruption during replay | No operation is forgotten because a settings callback or shutdown released its timer. |
|
||||
| Sustained edits and more paths than available processing slots | Observe the intentional-delay deadline and progress of unrelated ready files. |
|
||||
| Old settings, mode changes, explicit opt-in, and settings import | Existing LiveSync mode users retain immediate saving until they choose batching. |
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
# 1.0 preview release history
|
||||
|
||||
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](1.0.md#100).
|
||||
This document records opt-in pre-release builds in the 1.0 release line. Consolidated notes for stable upgrades are available in the [current change log](../../updates.md) and the [1.0 release history](1.0.md).
|
||||
|
||||
The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted.
|
||||
|
||||
Version 1.0.41 was published for validation and remains a GitHub pre-release. Its changes and the subsequent start-up correction are consolidated in the [1.0.42 release notes](../../updates.md#1042).
|
||||
|
||||
## 1.0.41
|
||||
|
||||
26th October, 2026
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### New Feature
|
||||
|
||||
- Rapid edits can produce fewer intermediate versions on other devices while LiveSync mode remains active.
|
||||
- Enable **Batch database updates in LiveSync mode** to group successive local edits.
|
||||
- Disabled by default; each synchronisation mode keeps its own choice.
|
||||
- Updates reach other devices later. Batching does not detect template completion or prevent genuine concurrent-edit conflicts.
|
||||
- CouchDB synchronisation no longer looks idle while it is comparing change history.
|
||||
- A ⏩ icon and comparison count show activity before documents are transferred.
|
||||
- This count represents history entries, not files remaining to synchronise.
|
||||
- Status counts are easier to follow, with less movement as values change.
|
||||
- CSS snippets can hide unwanted items or style individual `livesync-stat-` items, including inactive ones.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Recent Vault edits remain recoverable after an interrupted local database save.
|
||||
- This includes edits whose modification times compare equal to the database copy, and pending Hidden File Sync and Customisation Sync changes.
|
||||
- Manual synchronisation no longer starts before already accepted local changes finish saving to the database.
|
||||
- Settings application also waits for those saves. Failed saves stay pending and report a failure.
|
||||
- Text from third-party tools is handled more robustly.
|
||||
- Characters split across Chunks no longer trigger false corruption warnings.
|
||||
- Start-up integrity checks use less memory for healthy Vault files. (#1283)
|
||||
- Start-up checks avoid false repair warnings for files that change during inspection. (#1283)
|
||||
- This applies when the final size matches the database and there are no conflicts.
|
||||
|
||||
### Customisation Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Applying a multi-file customisation no longer skips files after the list is refreshed. (#1271)
|
||||
- Choosing **Automatic** now explains a missing Hidden File Sync prerequisite instead of proceeding into a failed initialisation. (#1270)
|
||||
- Enable and initialise Hidden File Sync on this device first. The current mode is kept when the prerequisite is missing.
|
||||
- Initialising a whole plug-in in **Automatic** mode no longer omits its selected **MAIN** or **DATA** files.
|
||||
|
||||
### Setup tools
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Setup URI generation can avoid shell-variable collisions and unintended use of the operating-system username. (#817, #1274)
|
||||
- Use `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE` for connection and encryption values.
|
||||
- Existing scripts remain supported: the previous names are used when the corresponding prefixed value is unset or blank.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Dependencies have been updated, including security fixes.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Thank you for your contributions!
|
||||
|
||||
- @rwbru, for the original Setup URI environment-variable fix ([#817](https://github.com/vrtmrz/obsidian-livesync/pull/817)).
|
||||
- @junkerderprovinz, for adapting that fix to the current generator while preserving compatibility with existing scripts ([#1274](https://github.com/vrtmrz/obsidian-livesync/pull/1274)).
|
||||
- @s-qin, for reducing memory use during start-up checks and avoiding repeated content decoding in Commonlib ([#1283](https://github.com/vrtmrz/obsidian-livesync/pull/1283), [Commonlib #184](https://github.com/vrtmrz/livesync-commonlib/pull/184)).
|
||||
|
||||
## 1.0.0-rc.1
|
||||
|
||||
27th July, 2026
|
||||
|
||||
@@ -989,6 +989,12 @@ If enabled, chunks will be split into no more than 100 items. However, dedupe is
|
||||
Setting key: useSegmenter
|
||||
If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.
|
||||
|
||||
#### Strict content check on startup
|
||||
|
||||
Setting key: strictContentCheckOnStartup
|
||||
|
||||
When enabled, ordinary start-up compares file content with the local database even when the file's exact modification time and size match. When disabled (the default), this comparison can be skipped only when the modification time, size, and recorded device-local provenance match a conflict-free current revision, and no file processing is pending. An edit made outside the normal file-event path which preserves its exact modification time and size can therefore go undetected by ordinary start-up. Manual scans and explicit Fetch or Rebuild operations keep strict content checking.
|
||||
|
||||
### 3. Transfer Tweak
|
||||
|
||||
#### Fetch chunks on demand
|
||||
|
||||
@@ -166,6 +166,49 @@ For ordinary saves and incoming reflection, a missing or unreadable recorded bas
|
||||
|
||||
Provenance keys follow the selected [Unicode path identity](design_docs/unicode_path_identity.md#state-which-follows-the-selected-identity). In NFC mode, equivalent path spellings address the same record; the content evidence continues to describe file bytes, independently of that path spelling.
|
||||
|
||||
## Known potentially problematic behaviour
|
||||
|
||||
Saving a local file writes its revision to the local database, then writes
|
||||
device-local provenance in a separate operation. The writes are not atomic.
|
||||
If the process stops between them, provenance can still identify the revision
|
||||
which preceded the local edit. Continuous replication which was already
|
||||
running can add a remote descendant to the local database during this
|
||||
interval. The per-file lock covers file handling and Vault reflection; it
|
||||
does not block raw replication writes to the database.
|
||||
|
||||
For example, local content `2-B` may have been saved as a child of
|
||||
displayed revision `1`, while provenance still identifies `1`.
|
||||
On restart, replay of the pending save can treat `2-B` as an edit of
|
||||
`1` and write sibling `2-D`. The tested case demonstrates that this
|
||||
pre-provenance gap alone can produce the duplicate. Without a remote
|
||||
descendant, replay creates this sibling; duplicate handling tombstones one
|
||||
of the identical branches and converges to one live leaf whose exact content
|
||||
matches the saved local file. A second restart must add no revision.
|
||||
|
||||
If replication has already added descendant `3-C` during the gap, replay
|
||||
can expose an additional branch. When the revisions have a complete shared
|
||||
base and their text changes can be combined unambiguously, sensible merge can retain the
|
||||
exact content of the incoming descendant. In the tested fixture, merge
|
||||
produced revision `4` as a child of `3-C`; its file bytes were exactly
|
||||
those of `3-C`, `3-C` remained in its ancestry, and sibling
|
||||
`2-D` was tombstoned. The acceptance condition is retaining
|
||||
`3-C`'s exact content; it need not remain the current leaf. Both Vaults
|
||||
converged to those bytes. This observed outcome is limited to that mergeable
|
||||
fixture and is not a universal no-data-loss guarantee.
|
||||
|
||||
When changes cannot be combined safely, as in the tested competing whole-line
|
||||
replacements, sensible merge does not combine the bodies. Both versions remain available for the existing
|
||||
manual conflict dialogue, even when sensible merge is enabled.
|
||||
|
||||
An interruption in the database/provenance interval is expected to be very
|
||||
rare, especially when combined with a remote descendant arriving in that
|
||||
interval; the field frequency is unmeasured. Start-up replays restored work,
|
||||
scans, and commits pending local file work before marking the plug-in ready;
|
||||
ordinary replication proceeds after that ready boundary. This does not claim
|
||||
remote synchronisation before start-up readiness. The accepted design adds no
|
||||
cross-store transaction, second journal, or new persistent state to close the
|
||||
interval.
|
||||
|
||||
## Operations while a conflict exists
|
||||
|
||||
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.42",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+189
-228
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.42",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.1",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.2",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
@@ -90,8 +90,8 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
},
|
||||
@@ -1945,22 +1945,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz",
|
||||
"integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
"@emnapi/core": "^1.7.1 || 2.0.0-beta.2",
|
||||
"@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
@@ -2012,9 +2015,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2222,9 +2225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2239,9 +2242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2256,9 +2259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2273,9 +2276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2290,9 +2293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2307,13 +2310,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2324,13 +2330,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2341,13 +2350,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2358,13 +2370,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2375,13 +2390,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2392,13 +2410,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2409,9 +2430,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2426,9 +2447,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2436,52 +2457,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2496,9 +2483,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3347,9 +3334,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -3963,14 +3950,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
|
||||
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -3984,8 +3971,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
"@vitest/browser": "4.1.12",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -3994,16 +3981,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -4012,13 +3999,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -4039,9 +4026,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4052,13 +4039,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -4066,14 +4053,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -4082,9 +4069,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -4092,13 +4079,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -4116,9 +4103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.1.tgz",
|
||||
"integrity": "sha512-fnQb2FcEG+NqIQX9KhTstea4B7C4VAL0g9ry5rp41qnJDuLaR5zfCz995L+l3Khyrp/9kcPw4VX5RUmV+8GVog==",
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.2.2.tgz",
|
||||
"integrity": "sha512-+nl4ntmRtUmSUuh9mOpicE0U/wpzhSPSQQXy9eg/2xdTBL58jkrQ6bQjbVKEhqUj0bUvg0ESjZm8YX/x+4zXiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
@@ -6377,9 +6364,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8814,6 +8801,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-dir": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
||||
@@ -9586,13 +9586,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -9602,21 +9602,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sade": {
|
||||
@@ -10465,19 +10465,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
@@ -10847,16 +10834,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"version": "8.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
|
||||
"integrity": "sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -10873,7 +10860,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -10939,19 +10926,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
|
||||
@@ -10973,19 +10947,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -11013,12 +10987,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -11069,19 +11043,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vuvuzela": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vuvuzela/-/vuvuzela-1.0.3.tgz",
|
||||
@@ -11408,7 +11369,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.40-cli",
|
||||
"version": "1.0.42-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -11427,13 +11388,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.40-webapp",
|
||||
"version": "1.0.42-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
},
|
||||
@@ -11441,11 +11402,11 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.40-webpeer",
|
||||
"version": "1.0.42-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
},
|
||||
@@ -11456,7 +11417,7 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.42",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -88,6 +88,7 @@
|
||||
"test:e2e:obsidian:p2p-connection-check": "tsx test/e2e-obsidian/scripts/p2p-connection-check.ts",
|
||||
"test:e2e:obsidian:p2p-connection-check:services": "npm run test:e2e:obsidian:p2p-connection-check -- --manage-p2p",
|
||||
"test:e2e:obsidian:partial-startup-file-failure": "tsx test/e2e-obsidian/scripts/partial-startup-file-failure.ts",
|
||||
"test:e2e:obsidian:startup-content-check": "tsx test/e2e-obsidian/scripts/startup-content-check.ts",
|
||||
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
|
||||
"test:e2e:obsidian:stale-file-restart": "tsx test/e2e-obsidian/scripts/stale-file-restart.ts",
|
||||
"test:e2e:obsidian:folder-batch": "tsx test/e2e-obsidian/scripts/folder-batch.ts",
|
||||
@@ -127,7 +128,10 @@
|
||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
||||
"test:docker-all:stop": "npm run test:docker-all:down",
|
||||
"update-workspaces": "node update-workspaces.mjs",
|
||||
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json"
|
||||
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json",
|
||||
"test:e2e:obsidian:startup-integrity": "tsx test/e2e-obsidian/scripts/startup-integrity.ts",
|
||||
"test:e2e:obsidian:third-party-chunks": "tsx test/e2e-obsidian/scripts/third-party-chunks.ts",
|
||||
"test:e2e:obsidian:upgrade-livesync-batching": "tsx test/e2e-obsidian/scripts/upgrade-livesync-batching.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "vorotamoroz",
|
||||
@@ -151,7 +155,7 @@
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.12",
|
||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.28.1",
|
||||
@@ -186,8 +190,8 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11",
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -200,7 +204,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.1",
|
||||
"@vrtmrz/livesync-commonlib": "0.2.2",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"@vrtmrz/value-store": "0.1.0",
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.40-cli",
|
||||
"version": "1.0.42-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -52,7 +52,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^8.1.6",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.40-webapp",
|
||||
"version": "1.0.42-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -21,6 +21,6 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"svelte": "5.56.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.40-webpeer",
|
||||
"version": "1.0.42-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -24,6 +24,6 @@
|
||||
"svelte": "5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function createAdvancedSettingSpecGroups({
|
||||
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
|
||||
},
|
||||
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
|
||||
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
|
||||
{
|
||||
key: "pathNormalizationVersion",
|
||||
holdValue: true,
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
|
||||
};
|
||||
|
||||
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
|
||||
|
||||
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
|
||||
"Memory cache",
|
||||
@@ -56,6 +56,7 @@ describe("paneAdvanced", () => {
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
@@ -67,6 +68,7 @@ describe("paneAdvanced", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
|
||||
@@ -17,7 +17,7 @@ const rangeMessages = {
|
||||
};
|
||||
|
||||
describe("Advanced setting specifications", () => {
|
||||
it("lists only the ten controls currently exposed by the Advanced page", () => {
|
||||
it("lists only the eleven controls currently exposed by the Advanced page", () => {
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
|
||||
|
||||
expect(groups.map(({ heading }) => heading)).toEqual([
|
||||
@@ -30,6 +30,7 @@ describe("Advanced setting specifications", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
@@ -40,6 +41,17 @@ describe("Advanced setting specifications", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes strict content checking as an ungated Advanced toggle", () => {
|
||||
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => false })
|
||||
.find(({ heading }) => heading === "Local Database Tweak")
|
||||
?.items.find(({ key }) => key === "strictContentCheckOnStartup");
|
||||
if (!spec) throw new Error("Strict content checking is not exposed on the Advanced page");
|
||||
|
||||
expect(spec.control).toEqual({ type: "toggle" });
|
||||
expect(spec.visible).toBeUndefined();
|
||||
expect(spec.disabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
|
||||
let couchDB = false;
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("settings page catalogue", () => {
|
||||
"hashCacheMaxCount",
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
|
||||
@@ -19,6 +19,22 @@ import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker
|
||||
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
|
||||
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
|
||||
const OBSIDIAN_PLATFORMS = new Set([
|
||||
"android-app",
|
||||
"ios",
|
||||
"macos",
|
||||
"mobile-app",
|
||||
"mobile",
|
||||
"safari",
|
||||
"desktop",
|
||||
"desktop-app",
|
||||
"unknown-obsidian",
|
||||
]);
|
||||
|
||||
function isObsidianPlatform(platform: string): boolean {
|
||||
return OBSIDIAN_PLATFORMS.has(platform);
|
||||
}
|
||||
|
||||
export class ModuleLiveSyncMain extends AbstractModule {
|
||||
async _onLiveSyncReady() {
|
||||
if (!(await this.core.services.appLifecycle.onLayoutReady())) return false;
|
||||
@@ -44,13 +60,20 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
}
|
||||
}
|
||||
// Ordinary start-up may continue when individual files could not be
|
||||
// processed. Explicit Fetch and Rebuild flows retain the strict default.
|
||||
// processed. Explicit Fetch and Rebuild flows remain strict.
|
||||
const { ignoreSuspending = false, continueOnFileFailure = true } = this.core.startupDatabaseOptions;
|
||||
// Only ordinary Obsidian start-up honours the opt-out. Keep other hosts
|
||||
// and explicit operation modes strict.
|
||||
const strictContentCheck =
|
||||
!isObsidianPlatform(this.services.API.getPlatform()) ||
|
||||
this.settings.strictContentCheckOnStartup === true ||
|
||||
this.core.startupDatabaseOptions.continueOnFileFailure === false;
|
||||
const initialisationResult = await this.services.databaseEvents.initialiseDatabase(
|
||||
false,
|
||||
false,
|
||||
ignoreSuspending,
|
||||
continueOnFileFailure
|
||||
continueOnFileFailure,
|
||||
{ strictContentCheck }
|
||||
);
|
||||
if (initialisationResult === VaultScanResults.FAILED) {
|
||||
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
|
||||
@@ -90,9 +90,11 @@ describe("ModuleLiveSyncMain", () => {
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
@@ -102,7 +104,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: false });
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
@@ -120,6 +122,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
services: { appLifecycle },
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
appLifecycle,
|
||||
control: { applySettings: vi.fn(async () => undefined) },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
@@ -152,6 +155,7 @@ describe("ModuleLiveSyncMain", () => {
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
@@ -163,6 +167,96 @@ describe("ModuleLiveSyncMain", () => {
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, true, false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, true, false, { strictContentCheck: true });
|
||||
});
|
||||
|
||||
it("passes the strict content check setting for ordinary Obsidian start-up", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: {},
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: true,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: true });
|
||||
});
|
||||
|
||||
it.each(["server", "browser"])(
|
||||
"keeps the strict content check on non-Obsidian host start-up (%s)",
|
||||
async (platform) => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: true },
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => platform },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true, { strictContentCheck: true });
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps explicit recovery startup options strict when the setting is off", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const host = {
|
||||
core: {
|
||||
startupDatabaseOptions: {
|
||||
ignoreSuspending: false,
|
||||
continueOnFileFailure: false,
|
||||
},
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
API: { getPlatform: () => "desktop" },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
strictContentCheckOnStartup: false,
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: vi.fn(),
|
||||
};
|
||||
|
||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, false, { strictContentCheck: true });
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,12 @@ The same scenario checks the five actions in Reporting Tools, the separate Check
|
||||
|
||||
`folder-batch` needs no remote service. It creates 24 notes and imports three notes with colons in their names into nested folders, reflects the database content into the existing files, then renames and deletes the parent through the Obsidian Vault API. It checks exact paths, descendant events, content, Chunks, deletion markers, provenance, and the absence of unexpected files. A note outside the parent must remain writable. The colon fixtures use the adapter to represent externally created files because Obsidian's Vault creation API rejects those names. The scenario also seeds a database-only colon-named note and verifies that Obsidian's refusal to create it preserves its Metadata without writing a differently named Vault file. It does not establish successful restoration of that absent note or behaviour on other operating systems.
|
||||
|
||||
`startup-integrity` uses the registered Hatch integrity-check event with real Obsidian storage and a local database. It verifies that healthy files are inspected without reading their contents, that a controlled stale stat followed by matching real bytes does not produce a repair dialogue, and that a changed final stat leaves the check incomplete until a settled retry. The stat variations are injected; this scenario exercises the checker used during start-up rather than restarting the process. Natural configured start-up remains covered by `startup-scan`.
|
||||
|
||||
`startup-content-check` needs no remote service. It pre-creates a configurable set of Vault files, lets the plug-in populate the real local database and content provenance, then makes a same-size offline edit with the exact recorded raw mtime. A natural default-off cold restart must preserve the Vault edit without changing its database revision or branch; an explicit `scanVault(true)` must then detect it. After saving strict content checking as enabled, a second natural cold restart must detect another same-stat offline edit and update the current branch and provenance. The fixture reports a clear precision mismatch if the file system cannot restore the exact recorded mtime. Output includes the `main.js` SHA-256 and elapsed start-up timings; launch-to-core-ready timings include Obsidian and runner start-up, rather than measuring only the database scan. Run it with `npm run test:e2e:obsidian:focused -- startup-content-check`; `E2E_STARTUP_CHECK_FILE_COUNT` defaults to 20 and can be set to 5000 for a larger startup measurement.
|
||||
|
||||
`third-party-chunks` reflects synthetic externally generated text through the installed Commonlib and the real Obsidian file handler. It supplies a split surrogate pair at the file-access read boundary, with size checking enabled, then verifies exact Vault and on-disk text, unchanged Metadata, and matching persisted provenance. Complete-pair text and a binary attachment provide controls. The underlying Metadata and Chunks are real local database entries; this does not establish that CouchDB or a particular third-party application generates this chunk layout. No remote service is required.
|
||||
|
||||
`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file whose recorded revision has been pruned receives the newer database content using persisted content evidence without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. A third file begins with no provenance while its bytes still match the current database revision; start-up must record that revision without creating a new one, and a later incoming revision must reflect without a conflict. A fourth file has legacy revision-only provenance; start-up must verify its exact content and add a hash without creating a database revision or changing storage. The database advances and pending snapshot are controlled fixtures; start-up processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application.
|
||||
|
||||
The principal entry points are:
|
||||
@@ -265,7 +271,13 @@ The workflow also opens the Customisation Sync dialogue. `--case=visibility` che
|
||||
|
||||
`--case=automatic` separately checks that choosing **Automatic** while Hidden File Sync is disabled displays a Notice without changing modes, files, Metadata revisions, or processed markers. The fixture starts with a saved Automatic selection and requires it to survive start-up and both rejected actions. It also checks disabling the feature after opening **Initial Action**. After enablement, selecting a whole plug-in must initialise both **MAIN** and **DATA**, and transfer their files to the other Vault. Separate temporary Vaults exercise **Overwrite Remote**, **Overwrite Local**, and **Use newer**. The latter two start with different local and database contents; deterministic per-file modification times require **Use newer** to keep the newer database MAIN content and the newer local DATA content.
|
||||
|
||||
`test:e2e:obsidian:livesync-batch` uses two real Obsidian Vaults and a disposable local CouchDB to compare batching disabled, the older `batchSave` choice alone, and the explicit LiveSync batching choice. It covers repeated edits in both directions, template-like writes, deletion and recreation, incoming changes, maximum delay, concurrent paths, manual flush, and restart recovery before and after a database write. Restarts use the saved profile with natural plug-in start-up. `ISSUE1207_CASE_FILTER` selects case names; `ISSUE1207_RESTART_BUCKET=same|exact|different` exercises interrupted processing at the selected timestamp condition. `ISSUE1207_RESTART_SAVE_AFTER_EDIT=true` separately checks a settings save after the pending edit. Use `npm run test:e2e:obsidian:focused -- livesync-batch` to build and run the default matrix. This scenario requires a localhost CouchDB and is outside the complete local suite.
|
||||
`test:e2e:obsidian:livesync-batch` uses two real Obsidian Vaults and a disposable local CouchDB to compare batching disabled, the older `batchSave` choice alone, and the explicit LiveSync batching choice. It covers repeated edits in both directions, template-like writes, deletion and recreation, incoming changes, maximum delay, concurrent paths, manual flush, and restart recovery before and after a local database write. Restarts use the saved profile with natural plug-in start-up.
|
||||
|
||||
`ISSUE1207_CASE_FILTER` selects case names; `ISSUE1207_RESTART_BUCKET=same|exact|different` exercises interrupted processing at the selected timestamp condition. `ISSUE1207_RESTART_SAVE_AFTER_EDIT=true` separately checks a settings save after the pending edit.
|
||||
|
||||
`ISSUE1207_RESTART_BEFORE_PROVENANCE=true` deliberately stops after the local database write and before device-local provenance is recorded. The case without a remote edit demonstrates replay creating an identical sibling and tombstoning one of the two branches; it must converge to one live leaf with exact saved content, and a second restart must add no revision.
|
||||
|
||||
`ISSUE1207_RESTART_REMOTE_EDIT=mergeable|conflicting` requires `ISSUE1207_RESTART_BEFORE_PROVENANCE=true`. These cases persist the enabled test settings before editing so continuous replication remains active, receive a remote descendant before stopping, and restart the same profile. Both remote cases enable sensible merge. The pause is deliberately injected to observe the ordering; it is not a measure of field frequency. The `mergeable` case requires the final bytes to equal the incoming descendant exactly, with that revision retained in ancestry and further restart stable. The `conflicting` case uses a whole-line replacement and requires both bodies to remain for the manual conflict dialogue. The cases are named `enabled-before-provenance-mergeable-restart` and `enabled-before-provenance-conflicting-restart`. Use `npm run test:e2e:obsidian:focused -- livesync-batch` to build and run the default matrix. This scenario requires a localhost CouchDB and is outside the complete local suite.
|
||||
|
||||
`test:e2e:obsidian:optional-file-save-completion` exercises Hidden File Sync and both Customisation Sync formats in one isolated real Obsidian Vault. It holds a local database write to verify that its event stays in the snapshot and that flush waits for the write, then checks unchanged notifications, subsequent edits, and repeated deletions. Injected save failures remain pending and must replay successfully before the next start reaches readiness. This local scenario disables replication and requires no remote service. It is outside `test:e2e:obsidian:local-suite`.
|
||||
|
||||
@@ -281,6 +293,8 @@ The workflow also opens the Customisation Sync dialogue. `--case=visibility` che
|
||||
|
||||
`test:e2e:obsidian:setting-markdown-roundtrip` generates Markdown with and without credentials through the real export command, copies each original file into a separate fresh Vault, and imports it through **Parse setting file** and the visible **Apply settings** dialogue. Each receiver starts with default plug-in settings and no connection profiles, with its own configuration encryption passphrase provided independently of the source through localStorage. The scenario verifies ordinary settings, conditional credentials and complete profiles, encrypted local persistence, and restoration after restarting the same Vault and profile without reseeding settings or localStorage. Imports without credentials may create new profiles through the existing legacy migration; those profiles must contain none of the omitted source credentials. This scenario requires no remote service and is included in the focused wrapper and local suite.
|
||||
|
||||
`test:e2e:obsidian:upgrade-livesync-batching` verifies the introduction of the separate LiveSync batching choice using the pinned, published 1.0.40 artefacts. It first saves and restarts the old release with legacy batching enabled and LiveSync mode selected. Replacing only the plug-in artefacts must retain the Vault, local database, device identity, remote profile, and existing Metadata revision, while leaving the new LiveSync batching choice disabled. The upgraded Vault and an independent current-version Vault exchange notes through CouchDB. Natural restarts retain both the disabled LiveSync choice and the original batching choice when switching to periodic mode. The source release is downloaded into the verified release cache; `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT` selects an exact target build. This tests the update behaviour, while installation through BRAT remains a separate acceptance step.
|
||||
|
||||
`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 while onboarding is pending; a later configured start records the current version if no other review is required. 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.
|
||||
|
||||
@@ -180,12 +180,17 @@ async function waitFor(
|
||||
throw new Error(`State did not settle: ${JSON.stringify(state)}`);
|
||||
}
|
||||
|
||||
async function settled(pair: Pair, path: string, body: string): Promise<[Snapshot, Snapshot]> {
|
||||
async function settled(
|
||||
pair: Pair,
|
||||
path: string,
|
||||
body: string,
|
||||
allowDeletedBranches = false
|
||||
): Promise<[Snapshot, Snapshot]> {
|
||||
const ready = (state: Snapshot) =>
|
||||
state.content === body &&
|
||||
liveLeaves(state).length === 1 &&
|
||||
liveLeaves(state)[0].body === body &&
|
||||
state.leaves.length === 1 &&
|
||||
(allowDeletedBranches || state.leaves.length === 1) &&
|
||||
!state.waiting.some((item) => item.path === path);
|
||||
const deadline = Date.now() + 20000;
|
||||
let states: [Snapshot, Snapshot];
|
||||
@@ -234,6 +239,15 @@ async function main(): Promise<void> {
|
||||
await assertCouchDbReachable(config);
|
||||
const dbName = makeUniqueDatabaseName(config.dbPrefix, "live-batch");
|
||||
const couch = { ...config, dbName };
|
||||
const remoteRestartEdit = process.env.ISSUE1207_RESTART_REMOTE_EDIT;
|
||||
check(
|
||||
!remoteRestartEdit || ["mergeable", "conflicting"].includes(remoteRestartEdit),
|
||||
"Unknown remote edit for the provenance restart case"
|
||||
);
|
||||
check(
|
||||
!remoteRestartEdit || process.env.ISSUE1207_RESTART_BEFORE_PROVENANCE === "true",
|
||||
"A remote restart edit requires the provenance gate"
|
||||
);
|
||||
const overrides = {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
@@ -248,7 +262,7 @@ async function main(): Promise<void> {
|
||||
syncAfterMerge: false,
|
||||
checkConflictOnlyOnOpen: true,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
disableMarkdownAutoMerge: true,
|
||||
disableMarkdownAutoMerge: !remoteRestartEdit,
|
||||
resolveConflictsByNewerFile: false,
|
||||
writeDocumentsIfConflicted: false,
|
||||
writeLogToTheFile: false,
|
||||
@@ -259,8 +273,6 @@ async function main(): Promise<void> {
|
||||
evidence.commonlibVersion = JSON.parse(
|
||||
await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8")
|
||||
).version;
|
||||
evidence.sourceBaseRevision = "73a14d4e4b0b414a1acfe170d01500abb3dca2e9";
|
||||
evidence.unreleasedImplementation = true;
|
||||
if (process.env.ISSUE1207_COMMONLIB_ARTEFACT) {
|
||||
evidence.commonlibArtefactSha256 = createHash("sha256")
|
||||
.update(new Uint8Array(readFileSync(process.env.ISSUE1207_COMMONLIB_ARTEFACT)))
|
||||
@@ -370,6 +382,31 @@ async function main(): Promise<void> {
|
||||
type:item.type,path:item.args?.file?.path,oldPath:item.args?.oldPath}))};`
|
||||
),
|
||||
});
|
||||
if (name === "enabled-settings-save-restart" || name.startsWith("enabled-before-provenance")) {
|
||||
observations.push({
|
||||
name,
|
||||
device,
|
||||
revisionEvidence: await evaluate(
|
||||
page,
|
||||
`
|
||||
const path=${JSON.stringify(`${prefix}restart.md`)};
|
||||
const id=await core.services.path.path2id(path);
|
||||
const leaves=await core.localDatabase.getRaw(id,{open_revs:'all',revs:true});
|
||||
const revisions=new Set();
|
||||
for(const leaf of leaves){const h=leaf.ok?._revisions;
|
||||
for(let i=0;i<(h?.ids.length??0);i++)revisions.add((h.start-i)+'-'+h.ids[i]);}
|
||||
const result=[];
|
||||
for(const rev of revisions){
|
||||
const raw=await core.localDatabase.getRaw(id,{rev,revs:true}).catch(error=>({error:String(error)}));
|
||||
const doc=await core.localDatabase.getDBEntry(path,{rev,revs:true},false,true,true).catch(()=>false);
|
||||
result.push({rev,deleted:raw._deleted??false,ctime:raw.ctime,mtime:raw.mtime,
|
||||
size:raw.size,error:raw.error,body:doc?(Array.isArray(doc.data)?doc.data.join(''):doc.data):null});
|
||||
}
|
||||
return {revisions:result,provenance:await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(path)};
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
await page.screenshot({ path: join(diagnostics, `${name}-${device}-failure.png`) });
|
||||
try {
|
||||
await evaluate(page, "await manager.waitForIdle();");
|
||||
@@ -663,27 +700,61 @@ async function main(): Promise<void> {
|
||||
|
||||
const saveAfterEdit = process.env.ISSUE1207_RESTART_SAVE_AFTER_EDIT === "true";
|
||||
const restartBucket = process.env.ISSUE1207_RESTART_BUCKET;
|
||||
const stopBeforeProvenance = process.env.ISSUE1207_RESTART_BEFORE_PROVENANCE === "true";
|
||||
check(!stopBeforeProvenance || !restartBucket, "The provenance and timestamp gates are separate cases");
|
||||
check(
|
||||
!restartBucket || ["same", "exact", "different"].includes(restartBucket),
|
||||
"Unknown restart timestamp condition"
|
||||
);
|
||||
const restartName = restartBucket
|
||||
? `enabled-inflight-restart-${restartBucket}`
|
||||
: saveAfterEdit
|
||||
? "enabled-settings-save-restart"
|
||||
: "enabled-restart";
|
||||
const restartName = stopBeforeProvenance
|
||||
? `enabled-before-provenance${remoteRestartEdit ? `-${remoteRestartEdit}` : ""}-restart`
|
||||
: restartBucket
|
||||
? `enabled-inflight-restart-${restartBucket}`
|
||||
: saveAfterEdit
|
||||
? "enabled-settings-save-restart"
|
||||
: "enabled-restart";
|
||||
await run(restartName, async () => {
|
||||
const path = `${prefix}restart.md`;
|
||||
const beforeContent =
|
||||
remoteRestartEdit === "mergeable"
|
||||
? "Heading\n\nLocal: original\n\nRemote: original\n\nFooter\n"
|
||||
: "Before restart\n";
|
||||
const localContent =
|
||||
remoteRestartEdit === "mergeable"
|
||||
? "Heading\n\nLocal: saved\n\nRemote: original\n\nFooter\n"
|
||||
: "Retained after restart\n";
|
||||
const remoteContent =
|
||||
remoteRestartEdit === "mergeable"
|
||||
? "Heading\n\nLocal: saved\n\nRemote: changed remotely\n\nFooter\n"
|
||||
: "Remote edit after the durable save\n";
|
||||
const beforeRestart = await withPair(async (pair) => {
|
||||
await options(pair.a, "enabled-batch", 5, 60);
|
||||
await options(pair.b, "normal");
|
||||
if (!saveAfterEdit)
|
||||
// Remote cases keep continuous replication running while the local save is held.
|
||||
if (!saveAfterEdit || remoteRestartEdit)
|
||||
await evaluate(
|
||||
pair.a,
|
||||
"await core.services.setting.saveSettingData(); await core.services.control.applySettings();"
|
||||
);
|
||||
await create(pair.b, path, "Before restart\n");
|
||||
const initial = await settled(pair, path, "Before restart\n");
|
||||
await create(pair.b, path, beforeContent);
|
||||
const initial = await settled(pair, path, beforeContent);
|
||||
if (stopBeforeProvenance) {
|
||||
await evaluate(
|
||||
pair.a,
|
||||
`
|
||||
const access=core.serviceModules.databaseFileAccess;
|
||||
const original=access.storeWithBaseRevision.bind(access);
|
||||
access.storeWithBaseRevision=async(file,...args)=>{
|
||||
const revision=await original(file,...args);
|
||||
if(file.path===${JSON.stringify(path)}&&revision){
|
||||
globalThis.__issue1207Batch.beforeProvenanceRevision=revision;
|
||||
await new Promise(resolve=>setTimeout(resolve,30000));
|
||||
}
|
||||
return revision;
|
||||
};
|
||||
`
|
||||
);
|
||||
}
|
||||
if (restartBucket) {
|
||||
check(initial[0].databaseMtime !== null, "Missing initial database mtime");
|
||||
const mtime =
|
||||
@@ -703,11 +774,27 @@ async function main(): Promise<void> {
|
||||
};
|
||||
await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),"Retained after restart\\n",{mtime:${mtime}});`
|
||||
);
|
||||
} else await modify(pair.a, path, "Retained after restart\n");
|
||||
} else await modify(pair.a, path, localContent);
|
||||
const pending = await waitFor(pair.a, path, (state) =>
|
||||
state.waiting.some((item) => item.path === path)
|
||||
);
|
||||
if (saveAfterEdit) await evaluate(pair.a, "await core.services.setting.saveSettingData();");
|
||||
if (saveAfterEdit && !remoteRestartEdit) {
|
||||
await evaluate(pair.a, "await core.services.setting.saveSettingData();");
|
||||
observations.push({
|
||||
phase: "settings-save-before-stop",
|
||||
initial,
|
||||
pending,
|
||||
current: await Promise.all([snapshot(pair.a, path), snapshot(pair.b, path)]),
|
||||
sourceProbe: await evaluate(
|
||||
pair.a,
|
||||
`return {
|
||||
events:globalThis.__issue1207Batch.events,
|
||||
changes:globalThis.__issue1207Batch.changes,
|
||||
provenance:await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(${JSON.stringify(path)})
|
||||
};`
|
||||
),
|
||||
});
|
||||
}
|
||||
if (restartBucket)
|
||||
await evaluate(
|
||||
pair.a,
|
||||
@@ -715,6 +802,82 @@ async function main(): Promise<void> {
|
||||
while(!globalThis.__issue1207Batch.inflightPaused&&Date.now()<deadline) await new Promise(resolve=>setTimeout(resolve,10));
|
||||
if(!globalThis.__issue1207Batch.inflightPaused) throw new Error('The in-flight test gate did not start');`
|
||||
);
|
||||
let durableBeforeProvenance: Snapshot | undefined;
|
||||
let remoteAdvanced: Snapshot | undefined;
|
||||
if (stopBeforeProvenance) {
|
||||
await evaluate(
|
||||
pair.a,
|
||||
`
|
||||
const deadline=Date.now()+core.services.setting.currentSettings().batchSaveMinimumDelay*1000+5000;
|
||||
while(!globalThis.__issue1207Batch.beforeProvenanceRevision&&Date.now()<deadline)
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
if(!globalThis.__issue1207Batch.beforeProvenanceRevision)throw new Error('The durable-write provenance gate did not start');
|
||||
`
|
||||
);
|
||||
durableBeforeProvenance = await snapshot(pair.a, path);
|
||||
const provenance = await evaluate<{ revision: string }>(
|
||||
pair.a,
|
||||
`return await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(${JSON.stringify(path)});`
|
||||
);
|
||||
check(
|
||||
durableBeforeProvenance.rev !== initial[0].rev &&
|
||||
liveLeaves(durableBeforeProvenance)[0]?.body === localContent,
|
||||
"The gated database write is not durable"
|
||||
);
|
||||
check(
|
||||
provenance?.revision === initial[0].rev,
|
||||
"The fixture did not stop before provenance was updated"
|
||||
);
|
||||
observations.push({
|
||||
phase: "durable-before-provenance",
|
||||
state: durableBeforeProvenance,
|
||||
provenance,
|
||||
});
|
||||
if (remoteRestartEdit) {
|
||||
const receivedOriginal = await waitFor(
|
||||
pair.b,
|
||||
path,
|
||||
(state) => state.rev === durableBeforeProvenance!.rev && state.content === localContent
|
||||
);
|
||||
await modify(pair.b, path, remoteContent);
|
||||
remoteAdvanced = await waitFor(
|
||||
pair.b,
|
||||
path,
|
||||
(state) =>
|
||||
liveLeaves(state).length === 1 &&
|
||||
liveLeaves(state)[0].body === remoteContent &&
|
||||
state.rev === liveLeaves(state)[0].rev &&
|
||||
state.content === remoteContent
|
||||
);
|
||||
check(
|
||||
liveLeaves(remoteAdvanced)[0].ancestry.includes(durableBeforeProvenance.rev!),
|
||||
"The remote edit did not descend from the already durable local save"
|
||||
);
|
||||
const localDbAdvanced = await waitFor(
|
||||
pair.a,
|
||||
path,
|
||||
(state) =>
|
||||
state.rev === remoteAdvanced!.rev &&
|
||||
liveLeaves(state).length === 1 &&
|
||||
liveLeaves(state)[0].body === remoteContent
|
||||
);
|
||||
const stillOldProvenance = await evaluate<{ revision: string }>(
|
||||
pair.a,
|
||||
`return await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(${JSON.stringify(path)});`
|
||||
);
|
||||
check(
|
||||
localDbAdvanced.content === localContent && stillOldProvenance.revision === initial[0].rev,
|
||||
"The incoming DB change did not precede Vault reflection and provenance persistence"
|
||||
);
|
||||
observations.push({
|
||||
phase: "remote-edit-local-db-ahead-of-storage",
|
||||
receivedOriginal,
|
||||
remoteAdvanced,
|
||||
localDbAdvanced,
|
||||
stillOldProvenance,
|
||||
});
|
||||
}
|
||||
}
|
||||
await evaluate(pair.a, "await manager._takeSnapshot();");
|
||||
check(pending.rev === initial[0].rev, "Restart test did not retain a pending edit");
|
||||
if (restartBucket) {
|
||||
@@ -746,16 +909,18 @@ async function main(): Promise<void> {
|
||||
sameBucket,
|
||||
});
|
||||
}
|
||||
if (!saveAfterEdit && !restartBucket) {
|
||||
if (stopBeforeProvenance || (!saveAfterEdit && !restartBucket)) {
|
||||
const strictState = await snapshot(pair.a, path);
|
||||
const saved = await evaluate<Array<{ path: string }>>(
|
||||
pair.a,
|
||||
`return (await manager.adapter.persistence.loadSnapshot()).map(item=>({path:item.args?.file?.path}));`
|
||||
);
|
||||
check(
|
||||
strictState.rev === initial[0].rev && strictState.waiting.some((item) => item.path === path),
|
||||
"Restart fixture flushed before shutdown"
|
||||
);
|
||||
if (!stopBeforeProvenance)
|
||||
check(
|
||||
strictState.rev === initial[0].rev &&
|
||||
strictState.waiting.some((item) => item.path === path),
|
||||
"Restart fixture flushed before shutdown"
|
||||
);
|
||||
check(
|
||||
saved.some((item) => item.path === path),
|
||||
"Restart fixture did not persist the pending event"
|
||||
@@ -778,12 +943,12 @@ async function main(): Promise<void> {
|
||||
const settings=core.services.setting.currentSettings();
|
||||
return {initialized:await core.kvDB.get('initialized'),restorationRequested:manager.snapShotRestored!==null,
|
||||
settings:{isConfigured:settings.isConfigured,liveSync:settings.liveSync,batchSave:settings.batchSave,batchSaveInLiveSync:settings.batchSaveInLiveSync,
|
||||
suspendFileWatching:settings.suspendFileWatching},
|
||||
suspendFileWatching:settings.suspendFileWatching,disableMarkdownAutoMerge:settings.disableMarkdownAutoMerge},
|
||||
events:events?.map(item=>({type:item.type,path:item.args?.file?.path,
|
||||
stat:item.args?.file?.stat,skipBatchWait:item.skipBatchWait}))};`
|
||||
),
|
||||
});
|
||||
return { initial, pending };
|
||||
return { initial, pending, durableBeforeProvenance, remoteAdvanced };
|
||||
});
|
||||
await stop(slots[0]);
|
||||
const persistedSettings = JSON.parse(
|
||||
@@ -817,11 +982,82 @@ async function main(): Promise<void> {
|
||||
events:events?.map(item=>({type:item.type,path:item.args?.file?.path,stat:item.args?.file?.stat}))};`
|
||||
),
|
||||
});
|
||||
const result = await settled(pair, path, "Retained after restart\n");
|
||||
if (remoteRestartEdit === "conflicting") {
|
||||
const preservesBothBodies = (state: Snapshot) => {
|
||||
const leaves = liveLeaves(state);
|
||||
return (
|
||||
leaves.length === 2 &&
|
||||
leaves.some((leaf) => leaf.body === localContent) &&
|
||||
leaves.some(
|
||||
(leaf) => leaf.body === remoteContent && leaf.rev === beforeRestart.remoteAdvanced!.rev
|
||||
)
|
||||
);
|
||||
};
|
||||
const result = await Promise.all([
|
||||
waitFor(pair.a, path, preservesBothBodies),
|
||||
waitFor(pair.b, path, preservesBothBodies),
|
||||
]);
|
||||
check(
|
||||
result[0].content === localContent && result[1].content === remoteContent,
|
||||
"An unresolved conflict overwrote one of the preserved Vault contents"
|
||||
);
|
||||
const manualDialogue = pair.a.locator(".modal").filter({ hasText: "Conflicting changes" });
|
||||
await manualDialogue.waitFor({ state: "visible", timeout: 10000 });
|
||||
await pair.a.screenshot({ path: join(diagnostics, `${restartName}-manual-conflict.png`) });
|
||||
return { result, manualConflict: true, restoredGuard: result[0].shouldBatchSave };
|
||||
}
|
||||
const expectedContent = remoteRestartEdit ? remoteContent : localContent;
|
||||
const result = await settled(pair, path, expectedContent, saveAfterEdit || stopBeforeProvenance);
|
||||
check(result[0].shouldBatchSave, "The saved LiveSync batching choice was not restored");
|
||||
if (beforeRestart.remoteAdvanced) {
|
||||
for (const state of result)
|
||||
check(
|
||||
liveLeaves(state)[0].ancestry.includes(beforeRestart.remoteAdvanced.rev!),
|
||||
"Sensible merge lost the later remote revision's ancestry"
|
||||
);
|
||||
check(
|
||||
(await pair.a.locator(".modal").filter({ hasText: "Conflicting changes" }).count()) === 0,
|
||||
"The mergeable recovery left a manual conflict dialogue"
|
||||
);
|
||||
}
|
||||
for (const [index, page] of [pair.a, pair.b].entries()) {
|
||||
const provenance = await evaluate<{ revision: string }>(
|
||||
page,
|
||||
`const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');
|
||||
const deadline=Date.now()+5000;
|
||||
let record;
|
||||
do {
|
||||
record=await store.get(${JSON.stringify(path)});
|
||||
if(record?.revision===${JSON.stringify(result[index].rev)})return record;
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
}while(Date.now()<deadline);
|
||||
return record;`
|
||||
);
|
||||
check(
|
||||
provenance?.revision === result[index].rev,
|
||||
"Recovery did not record the converged file revision"
|
||||
);
|
||||
}
|
||||
return { result, restoredGuard: result[0].shouldBatchSave };
|
||||
});
|
||||
return { beforeRestart, afterRestart };
|
||||
let repeatedRestart: Snapshot[] | undefined;
|
||||
if (stopBeforeProvenance && remoteRestartEdit !== "conflicting") {
|
||||
await stop(slots[0]);
|
||||
await start(slots[0], true);
|
||||
repeatedRestart = await withPair(async (pair) => {
|
||||
const result = await settled(pair, path, remoteRestartEdit ? remoteContent : localContent, true);
|
||||
check(
|
||||
result[0].rev === afterRestart.result[0].rev,
|
||||
"A repeated restart created another revision after recovery had converged"
|
||||
);
|
||||
check(
|
||||
result[0].leaves.length === afterRestart.result[0].leaves.length,
|
||||
"A repeated restart added another branch after recovery had converged"
|
||||
);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
return { beforeRestart, afterRestart, repeatedRestart };
|
||||
});
|
||||
|
||||
await run("enabled-after-write-restart", async () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ const focusedScenarios = new Set([
|
||||
"p2p-pane",
|
||||
"replication-status",
|
||||
"vault-reflection",
|
||||
"third-party-chunks",
|
||||
"couchdb-upload",
|
||||
"couchdb-http-cache",
|
||||
"chunk-fetch-retry",
|
||||
@@ -36,6 +37,8 @@ const focusedScenarios = new Set([
|
||||
"p2p-setup-uri-workflow",
|
||||
"partial-startup-file-failure",
|
||||
"startup-scan",
|
||||
"startup-integrity",
|
||||
"startup-content-check",
|
||||
"stale-file-restart",
|
||||
"folder-batch",
|
||||
"setup-uri-workflow",
|
||||
@@ -52,6 +55,7 @@ const focusedScenarios = new Set([
|
||||
"setting-markdown-export",
|
||||
"setting-markdown-roundtrip",
|
||||
"upgrade-from-stable",
|
||||
"upgrade-livesync-batching",
|
||||
]);
|
||||
|
||||
function usage(): string {
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
/** Checks the default and strict body checks across natural Obsidian restarts. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, captureObsidianPage, openLiveSyncSettings, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
const targetPath = "startup-content-check-00000.md";
|
||||
const versions = [
|
||||
"Startup content check fixture body A\n",
|
||||
"Startup content check fixture body B\n",
|
||||
"Startup content check fixture body C\n",
|
||||
] as const;
|
||||
|
||||
type TargetState = {
|
||||
rev: string;
|
||||
rawMtime: number | null;
|
||||
metadataMtime: number;
|
||||
size: number;
|
||||
documentSize: number;
|
||||
dbContent: string;
|
||||
vaultContent: string;
|
||||
branchCount: number;
|
||||
conflictCount: number;
|
||||
provenanceRevision: string | null;
|
||||
contentHash: string | null;
|
||||
contentSize: number | null;
|
||||
fileMtime: number | null;
|
||||
adapterMtime: number | null;
|
||||
storageMtime: number | null;
|
||||
adapterSize: number | null;
|
||||
storageSize: number | null;
|
||||
};
|
||||
|
||||
type InitialState = {
|
||||
fileCount: number;
|
||||
indexedCount: number;
|
||||
conflictCount: number;
|
||||
pendingCount: number;
|
||||
strictContentCheckOnStartup: boolean;
|
||||
targetProvenanceValid: boolean;
|
||||
target: TargetState;
|
||||
};
|
||||
|
||||
type ManualScanState = TargetState & {
|
||||
scanResult: boolean | string;
|
||||
scanMs: number;
|
||||
strictContentCheckOnStartup: boolean;
|
||||
};
|
||||
|
||||
type StartupFailureCore = {
|
||||
services?: {
|
||||
database?: { isDatabaseReady?: () => boolean };
|
||||
appLifecycle?: {
|
||||
isReady?: () => boolean;
|
||||
isSuspended?: () => boolean;
|
||||
getUnresolvedMessages?: () => Promise<unknown[][]>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type StartupFailureGlobal = typeof globalThis & {
|
||||
app?: {
|
||||
commands?: { executeCommandById?: (commandId: string) => boolean };
|
||||
plugins?: { plugins?: Record<string, { core?: StartupFailureCore }> };
|
||||
};
|
||||
};
|
||||
|
||||
const sensitiveDiagnosticLine =
|
||||
/security seed|passphrase|password|credential|secret|access.?key|jwt.?key|authori[sz]ation|obsidian:\/\/setuplivesync|sls\+/iu;
|
||||
|
||||
function redactDiagnosticLine(line: string): string {
|
||||
if (sensitiveDiagnosticLine.test(line)) return "[REDACTED SENSITIVE LOG LINE]";
|
||||
return line.replace(/\bhttps?:\/\/[^/\s:@]+:[^@\s/]+@/giu, "https://[REDACTED]@");
|
||||
}
|
||||
|
||||
function requireCondition(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 fileCountFromEnvironment(): number {
|
||||
const raw = process.env.E2E_STARTUP_CHECK_FILE_COUNT ?? "20";
|
||||
if (!/^[1-9]\d*$/.test(raw)) {
|
||||
throw new Error("E2E_STARTUP_CHECK_FILE_COUNT must be a positive integer; received " + raw + ".");
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value > 5000) {
|
||||
throw new Error("E2E_STARTUP_CHECK_FILE_COUNT must be between 1 and 5000; received " + raw + ".");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fixturePath(index: number): string {
|
||||
return "startup-content-check-" + String(index).padStart(5, "0") + ".md";
|
||||
}
|
||||
|
||||
function fixtureContent(index: number): string {
|
||||
if (index === 0) return versions[0];
|
||||
return "Startup content check fixture " + String(index).padStart(5, "0") + "\n";
|
||||
}
|
||||
|
||||
function sha256(content: string): string {
|
||||
return "sha256:" + createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
function assertExactMtime(state: TargetState, label: string): void {
|
||||
const expected = state.rawMtime;
|
||||
if (expected === null || !Number.isFinite(expected)) {
|
||||
throw new Error(
|
||||
"Fixture precision mismatch during " + label + ": persisted provenance raw mtime was unavailable."
|
||||
);
|
||||
}
|
||||
for (const [name, actual] of [
|
||||
["TFile", state.fileMtime],
|
||||
["Vault adapter", state.adapterMtime],
|
||||
["StorageAccess", state.storageMtime],
|
||||
] as const) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
"Fixture precision mismatch during " +
|
||||
label +
|
||||
": " +
|
||||
name +
|
||||
" mtime " +
|
||||
String(actual) +
|
||||
" did not exactly match persisted raw mtime " +
|
||||
expected +
|
||||
"."
|
||||
);
|
||||
}
|
||||
}
|
||||
assertEqual(state.metadataMtime, expected, label + ": Metadata and raw mtimes differed.");
|
||||
}
|
||||
|
||||
function requireRecordedMtime(state: TargetState, label: string): number {
|
||||
if (state.rawMtime === null || !Number.isFinite(state.rawMtime)) {
|
||||
throw new Error(
|
||||
"Fixture precision mismatch before " + label + ": persisted provenance raw mtime was unavailable."
|
||||
);
|
||||
}
|
||||
return state.rawMtime;
|
||||
}
|
||||
|
||||
async function createFixtures(vaultPath: string, fileCount: number, fixtureMtime: number): Promise<string[]> {
|
||||
const paths = Array.from({ length: fileCount }, (_, index) => fixturePath(index));
|
||||
const exactMtime = new Date(fixtureMtime);
|
||||
const batchSize = 100;
|
||||
for (let offset = 0; offset < paths.length; offset += batchSize) {
|
||||
await Promise.all(
|
||||
paths.slice(offset, offset + batchSize).map(async (path, index) => {
|
||||
const fileIndex = offset + index;
|
||||
const fullPath = join(vaultPath, path);
|
||||
await writeFile(fullPath, fixtureContent(fileIndex), "utf-8");
|
||||
await utimes(fullPath, exactMtime, exactMtime);
|
||||
const created = await stat(fullPath);
|
||||
if (created.mtimeMs !== fixtureMtime) {
|
||||
throw new Error(
|
||||
"Fixture precision mismatch before bootstrap: filesystem mtimeMs " +
|
||||
created.mtimeMs +
|
||||
" did not exactly match requested integer-millisecond mtime " +
|
||||
fixtureMtime +
|
||||
"."
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function restoreOfflineEdit(
|
||||
vaultPath: string,
|
||||
content: string,
|
||||
expectedMtime: number,
|
||||
expectedSize: number,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const fullPath = join(vaultPath, targetPath);
|
||||
if (Buffer.byteLength(content, "utf-8") !== expectedSize) {
|
||||
throw new Error(label + ": the offline edit did not preserve the recorded byte size.");
|
||||
}
|
||||
if (!Number.isFinite(expectedMtime)) {
|
||||
throw new Error(label + ": the persisted raw mtime was not a finite number.");
|
||||
}
|
||||
await writeFile(fullPath, content, "utf-8");
|
||||
const exactMtime = new Date(expectedMtime);
|
||||
await utimes(fullPath, exactMtime, exactMtime);
|
||||
const restored = await stat(fullPath);
|
||||
if (restored.mtimeMs !== expectedMtime) {
|
||||
throw new Error(
|
||||
"Fixture precision mismatch before " +
|
||||
label +
|
||||
": filesystem mtimeMs " +
|
||||
restored.mtimeMs +
|
||||
" did not exactly match persisted raw mtime " +
|
||||
expectedMtime +
|
||||
"."
|
||||
);
|
||||
}
|
||||
assertEqual(restored.size, expectedSize, label + ": the restored fixture byte size differed from Metadata.");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const fileCount = fileCountFromEnvironment();
|
||||
if (fileCount >= 1000) {
|
||||
process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ??= "120000";
|
||||
process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ??= "120000";
|
||||
}
|
||||
for (const content of versions) {
|
||||
assertEqual(
|
||||
Buffer.byteLength(content, "utf-8"),
|
||||
Buffer.byteLength(versions[0], "utf-8"),
|
||||
"The same-stat offline edit fixtures must have identical byte lengths."
|
||||
);
|
||||
}
|
||||
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
const vault = await createTemporaryVault("obsidian-startup-content-check-");
|
||||
const fixtureMtime = Math.floor(Date.now());
|
||||
const sessionState: { current: ObsidianLiveSyncSession | undefined } = { current: undefined };
|
||||
const pluginData = createE2eCouchDbPluginData(
|
||||
// Replication is disabled; the scenario uses only the persisted local database.
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName: "startup-content-check-local" },
|
||||
{
|
||||
deviceAndVaultName: "startup-content-check-device",
|
||||
strictContentCheckOnStartup: false,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
}
|
||||
);
|
||||
|
||||
async function launch(pluginStartup: "controlled" | "natural", label: string, bootstrap = false): Promise<number> {
|
||||
console.log("Starting " + label + " with the real Obsidian process.");
|
||||
const startedAt = Date.now();
|
||||
const options = {
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginStartup,
|
||||
...(bootstrap
|
||||
? {
|
||||
pluginData,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const currentSession = await startObsidianLiveSyncSession(options);
|
||||
sessionState.current = currentSession;
|
||||
const readiness = await waitForLiveSyncCoreReady(cliBinary, currentSession.cliEnv);
|
||||
requireCondition(
|
||||
readiness.databaseReady && readiness.appReady,
|
||||
"Self-hosted LiveSync did not reach core readiness: " + JSON.stringify(readiness)
|
||||
);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
console.log("Reached LiveSync core readiness for " + label + " in " + elapsedMs + " ms.");
|
||||
return elapsedMs;
|
||||
}
|
||||
|
||||
async function evaluate<T>(body: string): Promise<T> {
|
||||
const currentSession = sessionState.current;
|
||||
if (!currentSession) throw new Error("The Obsidian session is not running.");
|
||||
const script = [
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync']?.core;",
|
||||
"const require=(condition,message)=>{if(!condition)throw new Error(message);};",
|
||||
"const contentOf=value=>Array.isArray(value)?value.join(''):value;",
|
||||
"const targetState=async path=>{",
|
||||
" require(core,'Self-hosted LiveSync did not load');",
|
||||
" const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);",
|
||||
" require(meta&&meta._rev,'The target Metadata was missing');",
|
||||
" const document=await core.localDatabase.getDBEntryFromMeta(meta);",
|
||||
" require(document&&document.data!==undefined,'The target Chunks could not be loaded');",
|
||||
" const dbContent=contentOf(document.data);",
|
||||
" require(typeof dbContent==='string','The text fixture did not load as text');",
|
||||
" const file=app.vault.getAbstractFileByPath(path);",
|
||||
" require(file&&file.stat,'The target Vault file was missing');",
|
||||
" const adapterStat=await app.vault.adapter.stat(path);",
|
||||
" const storage=core.serviceModules.storageAccess;",
|
||||
" const storageStat=await storage.statHidden(path);",
|
||||
" const provenance=await core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1').get(path);",
|
||||
" return {rev:meta._rev,rawMtime:provenance?.observedStorageMtime??null,metadataMtime:Number(meta.mtime),",
|
||||
" size:Number(meta.size),documentSize:Number(document.size),dbContent,",
|
||||
" vaultContent:await app.vault.adapter.read(path),",
|
||||
" branchCount:1+(meta._conflicts??[]).length,conflictCount:(meta._conflicts??[]).length,",
|
||||
" provenanceRevision:provenance?.revision??null,contentHash:provenance?.contentHash??null,",
|
||||
" contentSize:provenance?.contentSize??null,fileMtime:file.stat.mtime??null,",
|
||||
" adapterMtime:adapterStat?.mtime??null,storageMtime:storageStat?.mtime??null,",
|
||||
" adapterSize:adapterStat?.size??null,storageSize:storageStat?.size??null};",
|
||||
"};",
|
||||
body,
|
||||
"})()",
|
||||
].join("\n");
|
||||
return await evalObsidianJson<T>(cliBinary, script, currentSession.cliEnv);
|
||||
}
|
||||
|
||||
async function stopSession(): Promise<void> {
|
||||
const currentSession = sessionState.current;
|
||||
if (!currentSession) return;
|
||||
await currentSession.app.stop();
|
||||
sessionState.current = undefined;
|
||||
}
|
||||
|
||||
async function captureSettingScreenshots(): Promise<{ advancedPage: string; settingRow: string }> {
|
||||
const currentSession = sessionState.current;
|
||||
if (!currentSession) throw new Error("The Obsidian session is not running.");
|
||||
const port = currentSession.remoteDebuggingPort;
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
const advancedPagePath = join(outputDirectory, "startup-content-check-setting-location.png");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const navigator = await openLiveSyncSettings(page);
|
||||
const advanced = await navigator.openPage("Advanced");
|
||||
const section = advanced.getByText("Local Database Tweak", { exact: true }).first();
|
||||
await section.waitFor({ state: "visible" });
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
const row = advanced.locator(".setting-item").filter({
|
||||
has: navigator.page.getByText("Strict content check on startup", { exact: true }),
|
||||
});
|
||||
await row.waitFor({ state: "visible" });
|
||||
await row
|
||||
.getByText(
|
||||
"Compare file contents even when modification time and size match. This can slow startup in large vaults.",
|
||||
{ exact: true }
|
||||
)
|
||||
.waitFor({ state: "visible" });
|
||||
assertEqual(
|
||||
await row.locator(".checkbox-container.is-enabled").count(),
|
||||
0,
|
||||
"The screenshot fixture toggle was not visibly OFF."
|
||||
);
|
||||
await navigator.dialogue.screenshot({
|
||||
path: advancedPagePath,
|
||||
animations: "disabled",
|
||||
style: ".notice-container { visibility: hidden !important; }",
|
||||
});
|
||||
});
|
||||
|
||||
const settingRowPath = await captureObsidianElement(
|
||||
port,
|
||||
"startup-content-check-setting-row.png",
|
||||
async (page) => {
|
||||
const navigator = await openLiveSyncSettings(page);
|
||||
const advanced = await navigator.openPage("Advanced");
|
||||
const row = advanced.locator(".setting-item").filter({
|
||||
has: navigator.page.getByText("Strict content check on startup", { exact: true }),
|
||||
});
|
||||
await row.waitFor({ state: "visible" });
|
||||
await row
|
||||
.getByText(
|
||||
"Compare file contents even when modification time and size match. This can slow startup in large vaults.",
|
||||
{ exact: true }
|
||||
)
|
||||
.waitFor({ state: "visible" });
|
||||
assertEqual(
|
||||
await row.locator(".checkbox-container.is-enabled").count(),
|
||||
0,
|
||||
"The cropped screenshot fixture toggle was not visibly OFF."
|
||||
);
|
||||
return row;
|
||||
}
|
||||
);
|
||||
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const navigator = await openLiveSyncSettings(page);
|
||||
await navigator.close();
|
||||
});
|
||||
return { advancedPage: advancedPagePath, settingRow: settingRowPath };
|
||||
}
|
||||
|
||||
async function captureFailureEvidence(error: unknown): Promise<void> {
|
||||
const currentSession = sessionState.current;
|
||||
if (!currentSession) return;
|
||||
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
const visibleScreenshotPath = join(outputDirectory, "startup-content-check-failure-visible.png");
|
||||
const screenshotPath = join(outputDirectory, "startup-content-check-failure.png");
|
||||
const snapshotPath = join(outputDirectory, "startup-content-check-failure.json");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const processOutput = currentSession.app.output();
|
||||
const snapshot: {
|
||||
capturedAt: string;
|
||||
failure: string;
|
||||
screenshotPath: string;
|
||||
visibleScreenshotPath: string;
|
||||
rendererState: unknown;
|
||||
recentLog: string[];
|
||||
logCaptureError?: string;
|
||||
processOutput: { stdout: string; stderr: string };
|
||||
} = {
|
||||
capturedAt: new Date().toISOString(),
|
||||
failure: redactDiagnosticLine(
|
||||
(error instanceof Error ? (error.stack ?? error.message) : String(error)).slice(0, 2000)
|
||||
),
|
||||
screenshotPath,
|
||||
visibleScreenshotPath,
|
||||
rendererState: { unavailable: true },
|
||||
recentLog: [],
|
||||
processOutput: {
|
||||
stdout: processOutput.stdout
|
||||
.split(/\r?\n/u)
|
||||
.slice(-30)
|
||||
.map(redactDiagnosticLine)
|
||||
.join("\n")
|
||||
.slice(-3000),
|
||||
stderr: processOutput.stderr
|
||||
.split(/\r?\n/u)
|
||||
.slice(-30)
|
||||
.map(redactDiagnosticLine)
|
||||
.join("\n")
|
||||
.slice(-3000),
|
||||
},
|
||||
};
|
||||
|
||||
const capturedScreenshot = await captureObsidianPage(
|
||||
currentSession.remoteDebuggingPort,
|
||||
"startup-content-check-failure.png",
|
||||
async (page) => {
|
||||
await page.screenshot({ path: visibleScreenshotPath, fullPage: true });
|
||||
try {
|
||||
snapshot.rendererState = await page.evaluate(async () => {
|
||||
const host = globalThis as StartupFailureGlobal;
|
||||
const core = host.app?.plugins?.plugins?.["obsidian-livesync"]?.core;
|
||||
let unresolvedMessages: string[] = [];
|
||||
let unresolvedMessagesError: string | undefined;
|
||||
try {
|
||||
const messages = await core?.services?.appLifecycle?.getUnresolvedMessages?.();
|
||||
unresolvedMessages = (messages ?? [])
|
||||
.flat()
|
||||
.filter((message) => message !== undefined && message !== null)
|
||||
.map((message) => String(message).slice(0, 300))
|
||||
.slice(-20);
|
||||
} catch (diagnosticError) {
|
||||
unresolvedMessagesError =
|
||||
diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError);
|
||||
}
|
||||
return {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
bodyText: (document.body?.innerText ?? "").slice(0, 1200),
|
||||
notices: Array.from(document.querySelectorAll(".notice"))
|
||||
.slice(0, 10)
|
||||
.map((notice) => (notice.textContent ?? "").trim().slice(0, 300)),
|
||||
coreAvailable: core !== undefined,
|
||||
databaseReady: core?.services?.database?.isDatabaseReady?.() ?? false,
|
||||
appReady: core?.services?.appLifecycle?.isReady?.() ?? false,
|
||||
suspended: core?.services?.appLifecycle?.isSuspended?.() ?? false,
|
||||
unresolvedMessages,
|
||||
unresolvedMessagesError,
|
||||
};
|
||||
});
|
||||
} catch (snapshotError) {
|
||||
snapshot.rendererState = {
|
||||
error: snapshotError instanceof Error ? snapshotError.message : String(snapshotError),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const opened = await page.evaluate(
|
||||
(commandId) =>
|
||||
(globalThis as StartupFailureGlobal).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: 3000 });
|
||||
snapshot.recentLog = (await logPane.locator(".log pre").allTextContents())
|
||||
.slice(-30)
|
||||
.map((line) => redactDiagnosticLine(line.slice(0, 800)));
|
||||
} catch (logError) {
|
||||
snapshot.logCaptureError = logError instanceof Error ? logError.message : String(logError);
|
||||
}
|
||||
await writeFile(snapshotPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
||||
console.error("Startup content check failure diagnostics: " + snapshotPath);
|
||||
}
|
||||
);
|
||||
console.error("Startup content check failure screenshot: " + capturedScreenshot);
|
||||
console.error("Startup content check visible-page screenshot: " + visibleScreenshotPath);
|
||||
}
|
||||
|
||||
const countJson = JSON.stringify(fileCount);
|
||||
const targetPathJson = JSON.stringify(targetPath);
|
||||
|
||||
try {
|
||||
await createFixtures(vault.path, fileCount, fixtureMtime);
|
||||
// The controlled bootstrap establishes the isolated profile and compatibility marker.
|
||||
// The two compared starts below are natural Obsidian cold restarts.
|
||||
const bootstrapMs = await launch("controlled", "controlled bootstrap", true);
|
||||
const initial = await evaluate<InitialState>(
|
||||
[
|
||||
"require(core,'Self-hosted LiveSync did not load');",
|
||||
"require(core.services.setting.currentSettings().strictContentCheckOnStartup===false,",
|
||||
" 'The bootstrap did not use strictContentCheckOnStartup=false');",
|
||||
"require(await core.services.fileProcessing.commitPendingFileEvents(),",
|
||||
" 'The bootstrap local file events did not flush');",
|
||||
"const fixturePaths=new Set(Array.from({length:",
|
||||
countJson,
|
||||
"},(_,index)=>'startup-content-check-'+String(index).padStart(5,'0')+'.md'));",
|
||||
"let indexedCount=0,conflictCount=0;",
|
||||
"for await(const meta of core.localDatabase.findAllNormalDocs({conflicts:true})||[]){",
|
||||
" if(!fixturePaths.has(meta.path))continue;",
|
||||
" indexedCount++;",
|
||||
" if((meta._conflicts??[]).length>0)conflictCount++;",
|
||||
"}",
|
||||
"const pending=await core.serviceModules.storageAccess.vaultManager.adapter.persistence.loadSnapshot()??[];",
|
||||
"const target=await targetState(",
|
||||
targetPathJson,
|
||||
");",
|
||||
"return JSON.stringify({fileCount:",
|
||||
countJson,
|
||||
",indexedCount,conflictCount,pendingCount:pending.length,",
|
||||
"strictContentCheckOnStartup:core.services.setting.currentSettings().strictContentCheckOnStartup===true,",
|
||||
"targetProvenanceValid:target.provenanceRevision===target.rev&&",
|
||||
" typeof target.contentHash==='string'&&target.contentHash!==''&&target.contentSize===target.size,",
|
||||
"target});",
|
||||
].join("\n")
|
||||
);
|
||||
assertEqual(initial.fileCount, fileCount, "The reported initial fixture count differed.");
|
||||
assertEqual(initial.indexedCount, fileCount, "The pre-created files were not all indexed on bootstrap.");
|
||||
assertEqual(initial.conflictCount, 0, "The initial fixture unexpectedly contained conflict branches.");
|
||||
assertEqual(initial.pendingCount, 0, "Pending storage work remained before the cold restart.");
|
||||
assertEqual(
|
||||
initial.strictContentCheckOnStartup,
|
||||
false,
|
||||
"The initial strict startup setting was unexpectedly enabled."
|
||||
);
|
||||
assertEqual(
|
||||
initial.targetProvenanceValid,
|
||||
true,
|
||||
"The target did not have current persisted content provenance."
|
||||
);
|
||||
assertEqual(
|
||||
initial.target.rawMtime,
|
||||
fixtureMtime,
|
||||
"The initial provenance did not retain the fixture's exact integer-millisecond mtime."
|
||||
);
|
||||
assertEqual(initial.target.dbContent, versions[0], "The initial target body was not stored.");
|
||||
assertEqual(initial.target.vaultContent, versions[0], "The initial target Vault body differed.");
|
||||
assertEqual(initial.target.documentSize, initial.target.size, "The initial Chunk size differed from Metadata.");
|
||||
assertEqual(
|
||||
initial.target.adapterSize,
|
||||
initial.target.size,
|
||||
"The initial adapter byte size differed from Metadata."
|
||||
);
|
||||
assertEqual(
|
||||
initial.target.storageSize,
|
||||
initial.target.size,
|
||||
"The initial StorageAccess byte size differed from Metadata."
|
||||
);
|
||||
assertEqual(
|
||||
initial.target.contentHash,
|
||||
sha256(versions[0]),
|
||||
"The initial provenance hash did not match the body."
|
||||
);
|
||||
assertEqual(
|
||||
initial.target.contentSize,
|
||||
Buffer.byteLength(versions[0], "utf-8"),
|
||||
"The initial provenance byte size was wrong."
|
||||
);
|
||||
assertEqual(
|
||||
initial.target.provenanceRevision,
|
||||
initial.target.rev,
|
||||
"Initial provenance did not match the current branch."
|
||||
);
|
||||
assertEqual(initial.target.conflictCount, 0, "The initial target had a conflict branch.");
|
||||
assertEqual(initial.target.branchCount, 1, "The initial target did not have exactly one branch.");
|
||||
assertExactMtime(initial.target, "bootstrap");
|
||||
|
||||
await stopSession();
|
||||
await restoreOfflineEdit(
|
||||
vault.path,
|
||||
versions[1],
|
||||
requireRecordedMtime(initial.target, "default-off cold restart"),
|
||||
initial.target.size,
|
||||
"default-off cold restart"
|
||||
);
|
||||
|
||||
const ordinaryStartupMs = await launch("natural", "default-off natural cold restart");
|
||||
const ordinary = await evaluate<TargetState & { strictContentCheckOnStartup: boolean; pendingCount: number }>(
|
||||
[
|
||||
"const state=await targetState(",
|
||||
targetPathJson,
|
||||
");",
|
||||
"const pending=await core.serviceModules.storageAccess.vaultManager.adapter.persistence.loadSnapshot()??[];",
|
||||
"return JSON.stringify({...state,pendingCount:pending.length,strictContentCheckOnStartup:",
|
||||
"core.services.setting.currentSettings().strictContentCheckOnStartup===true});",
|
||||
].join("\n")
|
||||
);
|
||||
assertEqual(
|
||||
ordinary.strictContentCheckOnStartup,
|
||||
false,
|
||||
"The natural default-off restart enabled strict body checks."
|
||||
);
|
||||
assertEqual(ordinary.pendingCount, 0, "Pending storage work remained during the default-off restart.");
|
||||
assertEqual(ordinary.rev, initial.target.rev, "The default-off restart changed the local Metadata revision.");
|
||||
assertEqual(ordinary.dbContent, versions[0], "The default-off restart changed the local database body.");
|
||||
assertEqual(ordinary.vaultContent, versions[1], "The default-off restart overwrote the offline Vault edit.");
|
||||
assertEqual(ordinary.size, initial.target.size, "The default-off restart changed the Metadata byte size.");
|
||||
assertEqual(ordinary.documentSize, initial.target.size, "The default-off restart changed the Chunk byte size.");
|
||||
assertEqual(
|
||||
ordinary.adapterSize,
|
||||
initial.target.size,
|
||||
"The default-off restart changed the adapter byte size."
|
||||
);
|
||||
assertEqual(
|
||||
ordinary.storageSize,
|
||||
initial.target.size,
|
||||
"The default-off restart changed the StorageAccess byte size."
|
||||
);
|
||||
assertEqual(
|
||||
ordinary.provenanceRevision,
|
||||
initial.target.rev,
|
||||
"The default-off restart changed persisted provenance."
|
||||
);
|
||||
assertEqual(
|
||||
ordinary.contentHash,
|
||||
initial.target.contentHash,
|
||||
"The default-off restart changed the recorded content hash."
|
||||
);
|
||||
assertEqual(
|
||||
ordinary.contentSize,
|
||||
initial.target.contentSize,
|
||||
"The default-off restart changed the recorded byte size."
|
||||
);
|
||||
assertEqual(ordinary.conflictCount, 0, "The default-off restart created a conflict branch.");
|
||||
assertEqual(ordinary.branchCount, 1, "The default-off restart changed the branch count.");
|
||||
assertExactMtime(ordinary, "default-off natural restart");
|
||||
|
||||
const manual = await evaluate<ManualScanState>(
|
||||
[
|
||||
"const startedAt=Date.now();",
|
||||
"const scanResult=await core.services.vault.scanVault(true);",
|
||||
"const scanMs=Date.now()-startedAt;",
|
||||
"require(scanResult===true,'The explicit strict manual Vault scan did not complete');",
|
||||
"const state=await targetState(",
|
||||
targetPathJson,
|
||||
");",
|
||||
"core.settings.strictContentCheckOnStartup=true;",
|
||||
"await core.services.setting.saveSettingData();",
|
||||
"require(core.services.setting.currentSettings().strictContentCheckOnStartup===true,",
|
||||
" 'The strict startup setting did not persist in the active settings');",
|
||||
"return JSON.stringify({...state,scanResult,scanMs,strictContentCheckOnStartup:",
|
||||
"core.services.setting.currentSettings().strictContentCheckOnStartup===true});",
|
||||
].join("\n")
|
||||
);
|
||||
assertEqual(manual.scanResult, true, "The explicit strict manual scan returned an unexpected result.");
|
||||
assertEqual(manual.dbContent, versions[1], "The manual strict scan did not detect the same-stat offline edit.");
|
||||
assertEqual(manual.vaultContent, versions[1], "The manual strict scan changed the Vault body.");
|
||||
assertEqual(manual.size, initial.target.size, "The manual strict scan changed the Metadata byte size.");
|
||||
assertEqual(manual.documentSize, initial.target.size, "The manual strict scan changed the Chunk byte size.");
|
||||
assertEqual(manual.adapterSize, initial.target.size, "The manual strict scan changed the adapter byte size.");
|
||||
assertEqual(
|
||||
manual.storageSize,
|
||||
initial.target.size,
|
||||
"The manual strict scan changed the StorageAccess byte size."
|
||||
);
|
||||
requireCondition(
|
||||
manual.rev !== ordinary.rev,
|
||||
"The manual strict scan did not create a revision for the offline edit."
|
||||
);
|
||||
assertEqual(
|
||||
manual.provenanceRevision,
|
||||
manual.rev,
|
||||
"The manual scan did not record provenance for the detected edit."
|
||||
);
|
||||
assertEqual(manual.contentHash, sha256(versions[1]), "The manual scan recorded the wrong content hash.");
|
||||
assertEqual(manual.contentSize, initial.target.size, "The manual scan recorded the wrong content size.");
|
||||
assertEqual(manual.conflictCount, 0, "The manual strict scan created a conflict branch.");
|
||||
assertEqual(manual.branchCount, 1, "The manual strict scan changed the branch count.");
|
||||
assertEqual(manual.strictContentCheckOnStartup, true, "The strict startup setting was not saved.");
|
||||
assertExactMtime(manual, "manual strict scan");
|
||||
|
||||
await stopSession();
|
||||
await restoreOfflineEdit(
|
||||
vault.path,
|
||||
versions[2],
|
||||
requireRecordedMtime(manual, "strict cold restart"),
|
||||
manual.size,
|
||||
"strict cold restart"
|
||||
);
|
||||
|
||||
const strictStartupMs = await launch("natural", "strict natural cold restart");
|
||||
const strict = await evaluate<TargetState & { strictContentCheckOnStartup: boolean; pendingCount: number }>(
|
||||
[
|
||||
"const state=await targetState(",
|
||||
targetPathJson,
|
||||
");",
|
||||
"const pending=await core.serviceModules.storageAccess.vaultManager.adapter.persistence.loadSnapshot()??[];",
|
||||
"return JSON.stringify({...state,pendingCount:pending.length,strictContentCheckOnStartup:",
|
||||
"core.services.setting.currentSettings().strictContentCheckOnStartup===true});",
|
||||
].join("\n")
|
||||
);
|
||||
assertEqual(strict.strictContentCheckOnStartup, true, "The natural strict restart did not retain its setting.");
|
||||
assertEqual(strict.pendingCount, 0, "Pending storage work remained during the strict restart.");
|
||||
requireCondition(
|
||||
strict.rev !== manual.rev,
|
||||
"The strict natural restart did not detect the same-stat offline edit."
|
||||
);
|
||||
assertEqual(strict.dbContent, versions[2], "The strict natural restart did not store the offline Vault body.");
|
||||
assertEqual(strict.vaultContent, versions[2], "The strict natural restart changed the offline Vault body.");
|
||||
assertEqual(strict.size, manual.size, "The strict natural restart changed the Metadata byte size.");
|
||||
assertEqual(strict.documentSize, manual.size, "The strict natural restart changed the Chunk byte size.");
|
||||
assertEqual(strict.adapterSize, manual.size, "The strict natural restart changed the adapter byte size.");
|
||||
assertEqual(strict.storageSize, manual.size, "The strict natural restart changed the StorageAccess byte size.");
|
||||
assertEqual(
|
||||
strict.provenanceRevision,
|
||||
strict.rev,
|
||||
"The strict restart did not persist current file provenance."
|
||||
);
|
||||
assertEqual(strict.contentHash, sha256(versions[2]), "The strict restart recorded the wrong content hash.");
|
||||
assertEqual(strict.contentSize, manual.size, "The strict restart recorded the wrong byte size.");
|
||||
assertEqual(strict.conflictCount, 0, "The strict natural restart created a conflict branch.");
|
||||
assertEqual(strict.branchCount, 1, "The strict natural restart changed the branch count.");
|
||||
assertExactMtime(strict, "strict natural restart");
|
||||
|
||||
const screenshotSettings = await evaluate<{ strictContentCheckOnStartup: boolean; useAdvancedMode: boolean }>(
|
||||
[
|
||||
"core.settings.strictContentCheckOnStartup=false;",
|
||||
"core.settings.useAdvancedMode=true;",
|
||||
"await core.services.setting.saveSettingData();",
|
||||
"const settings=core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({strictContentCheckOnStartup:settings.strictContentCheckOnStartup===true,",
|
||||
"useAdvancedMode:settings.useAdvancedMode===true});",
|
||||
].join("\n")
|
||||
);
|
||||
assertEqual(
|
||||
screenshotSettings.strictContentCheckOnStartup,
|
||||
false,
|
||||
"The screenshot fixture did not reset strict content checking to OFF after the startup assertions."
|
||||
);
|
||||
assertEqual(
|
||||
screenshotSettings.useAdvancedMode,
|
||||
true,
|
||||
"The screenshot fixture did not expose the Advanced page."
|
||||
);
|
||||
const settingScreenshots = await captureSettingScreenshots();
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
bundleSha256: bundleHash,
|
||||
fixtureFileCount: fileCount,
|
||||
localOnly: true,
|
||||
bootstrapPluginStartup: "controlled",
|
||||
measuredRestartsPluginStartup: "natural",
|
||||
observations: {
|
||||
bootstrap: {
|
||||
indexedCount: initial.indexedCount,
|
||||
targetProvenanceValid: initial.targetProvenanceValid,
|
||||
conflictCount: initial.conflictCount,
|
||||
pendingCount: initial.pendingCount,
|
||||
},
|
||||
defaultOffRestart: {
|
||||
revisionUnchanged: ordinary.rev === initial.target.rev,
|
||||
databaseBodyUnchanged: ordinary.dbContent === versions[0],
|
||||
offlineVaultBodyPreserved: ordinary.vaultContent === versions[1],
|
||||
branchCount: ordinary.branchCount,
|
||||
provenanceRevision: ordinary.provenanceRevision,
|
||||
},
|
||||
manualStrictScan: {
|
||||
detectedOfflineEdit: manual.dbContent === versions[1],
|
||||
revisionChanged: manual.rev !== ordinary.rev,
|
||||
branchCount: manual.branchCount,
|
||||
provenanceRevision: manual.provenanceRevision,
|
||||
scanMs: manual.scanMs,
|
||||
},
|
||||
strictRestart: {
|
||||
detectedOfflineEdit: strict.dbContent === versions[2],
|
||||
revisionChanged: strict.rev !== manual.rev,
|
||||
branchCount: strict.branchCount,
|
||||
provenanceRevision: strict.provenanceRevision,
|
||||
},
|
||||
},
|
||||
timing: {
|
||||
scope: "Bootstrap and natural restart timings run from startObsidianLiveSyncSession() invocation through waitForLiveSyncCoreReady() completion. They include Obsidian launch, Vault open, plug-in startup, and runner readiness; they are not isolated scanner timings. manualStrictScanMs measures only the scanVault(true) call inside the running renderer.",
|
||||
bootstrapMs,
|
||||
ordinaryStartupMs,
|
||||
manualStrictScanMs: manual.scanMs,
|
||||
strictStartupMs,
|
||||
},
|
||||
settingScreenshots,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
await captureFailureEvidence(error);
|
||||
} catch (captureError) {
|
||||
console.warn(
|
||||
"Could not capture startup content check failure evidence: " +
|
||||
(captureError instanceof Error ? captureError.message : String(captureError))
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await stopSession();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
/** Checks incomplete-document integrity against real Vault storage in Obsidian. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} 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 ??= "60000";
|
||||
|
||||
const filePath = "startup-integrity.md";
|
||||
const fileContent = "startup integrity remains healthy\n";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const vault = await createTemporaryVault("obsidian-startup-integrity-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
const settings = createE2eCouchDbPluginData(
|
||||
// No replication is enabled; this scenario uses only the local database.
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName: "startup-integrity-local" },
|
||||
{
|
||||
deviceAndVaultName: "startup-integrity-device",
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
}
|
||||
);
|
||||
|
||||
async function evaluate<T>(body: string): Promise<T> {
|
||||
if (!session) throw new Error("The Obsidian session is not running");
|
||||
return await evalObsidianJson<T>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync']?.core;
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
const require=(condition,message)=>{if(!condition)throw new Error(message);};
|
||||
const waitFor=async(predicate,message,timeoutMs=15000)=>{
|
||||
const deadline=Date.now()+timeoutMs;
|
||||
while(Date.now()<deadline){
|
||||
if(await predicate())return;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
${body}
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginData: settings,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await evaluate(`
|
||||
require(core, 'Self-hosted LiveSync did not load');
|
||||
require(app.vault.getAbstractFileByPath(${JSON.stringify(filePath)})===null,
|
||||
'The integrity fixture already exists in the Vault');
|
||||
await app.vault.create(${JSON.stringify(filePath)},${JSON.stringify(fileContent)});
|
||||
return JSON.stringify({created:${JSON.stringify(filePath)}});
|
||||
`);
|
||||
await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, filePath);
|
||||
|
||||
const observations = await evaluate<unknown[]>(`
|
||||
require(await core.services.fileProcessing.commitPendingFileEvents(), 'Initial local flush failed');
|
||||
const path=${JSON.stringify(filePath)};
|
||||
const expectedContent=${JSON.stringify(fileContent)};
|
||||
const storage=core.serviceModules.storageAccess;
|
||||
const fileHandler=core.serviceModules.fileHandler;
|
||||
const keyValueDB=core.services.keyValueDB.kvDB;
|
||||
const confirm=core.services.UI.confirm;
|
||||
const noticeGroups=core.services.context.noticeGroups;
|
||||
const events=core.services.context.events;
|
||||
require(storage&&typeof storage.statHidden==='function'&&
|
||||
typeof storage.readHiddenFileBinary==='function',
|
||||
'The real StorageAccess methods were not available');
|
||||
require(fileHandler&&typeof fileHandler.storeFileToDB==='function',
|
||||
'The real file handler was not available');
|
||||
|
||||
const snapshot=async()=>{
|
||||
const meta=await core.localDatabase.getDBEntryMeta(path,undefined,true);
|
||||
require(meta&&meta._rev,'The integrity fixture Metadata was missing');
|
||||
const doc=await core.localDatabase.getDBEntryFromMeta(meta);
|
||||
require(doc&&doc.data!==undefined,'The integrity fixture Chunks could not be loaded');
|
||||
const content=Array.isArray(doc.data)?doc.data.join(''):doc.data;
|
||||
require(typeof content==='string','The Markdown fixture did not load as text');
|
||||
const bytes=Array.from(new TextEncoder().encode(content));
|
||||
require(meta.size===bytes.length,'Metadata size did not match the stored bytes');
|
||||
require(doc.size===bytes.length,'Loaded document size did not match the stored bytes');
|
||||
return {revision:meta._rev,metadataSize:meta.size,documentSize:doc.size,content,bytes};
|
||||
};
|
||||
const assertUnchanged=(before,after,label)=>{
|
||||
require(before.revision===after.revision,label+': the Metadata revision changed');
|
||||
require(before.metadataSize===after.metadataSize,label+': the Metadata size changed');
|
||||
require(before.content===after.content,label+': the stored content changed');
|
||||
require(JSON.stringify(before.bytes)===JSON.stringify(after.bytes),
|
||||
label+': the stored bytes changed');
|
||||
};
|
||||
const activeBytes=async()=>{
|
||||
const content=await app.vault.adapter.read(path);
|
||||
return {content,bytes:Array.from(new TextEncoder().encode(content))};
|
||||
};
|
||||
const marker=async()=>await keyValueDB.get('checkIncompleteDocs')===true;
|
||||
const activeState={current:null};
|
||||
const originalStat=storage.statHidden;
|
||||
const originalRead=storage.readHiddenFileBinary;
|
||||
const originalStore=fileHandler.storeFileToDB;
|
||||
const originalDialogue=confirm.askSelectStringDialogue;
|
||||
const ownStat=Object.prototype.hasOwnProperty.call(storage,'statHidden');
|
||||
const ownRead=Object.prototype.hasOwnProperty.call(storage,'readHiddenFileBinary');
|
||||
const ownStore=Object.prototype.hasOwnProperty.call(fileHandler,'storeFileToDB');
|
||||
const ownDialogue=Object.prototype.hasOwnProperty.call(confirm,'askSelectStringDialogue');
|
||||
const unhandledErrors=[];
|
||||
const onUnhandled=event=>{
|
||||
const reason=event.reason;
|
||||
unhandledErrors.push(String(reason&&reason.message?reason.message:reason));
|
||||
event.preventDefault();
|
||||
};
|
||||
let installedStat=false;
|
||||
let installedRead=false;
|
||||
let installedStore=false;
|
||||
let installedDialogue=false;
|
||||
const restore=(owner,key,hadOwn,original)=>{
|
||||
if(hadOwn)owner[key]=original;
|
||||
else delete owner[key];
|
||||
};
|
||||
|
||||
const onStat=async function(candidatePath){
|
||||
const actual=await originalStat.call(this,candidatePath);
|
||||
const current=activeState.current;
|
||||
if(!current||candidatePath!==path)return actual;
|
||||
current.statCalls++;
|
||||
require(actual&&actual.type==='file','StorageAccess did not report the fixture as a file');
|
||||
current.actualStats.push({size:actual.size,mtime:actual.mtime,ctime:actual.ctime});
|
||||
if(current.mode==='stat-read-race'&¤t.statCalls===1){
|
||||
const oldStat={...actual,size:actual.size+1};
|
||||
current.returnedStats.push({size:oldStat.size,mtime:oldStat.mtime,ctime:oldStat.ctime});
|
||||
return oldStat;
|
||||
}
|
||||
if(current.mode==='unstable-final-stat'&¤t.statCalls===2){
|
||||
const changedStat={...actual,mtime:actual.mtime+1};
|
||||
current.returnedStats.push({size:changedStat.size,mtime:changedStat.mtime,ctime:changedStat.ctime});
|
||||
return changedStat;
|
||||
}
|
||||
current.returnedStats.push({size:actual.size,mtime:actual.mtime,ctime:actual.ctime});
|
||||
return actual;
|
||||
};
|
||||
const onRead=async function(candidatePath){
|
||||
const bytes=await originalRead.call(this,candidatePath);
|
||||
const current=activeState.current;
|
||||
if(current&&candidatePath===path){
|
||||
current.readCalls++;
|
||||
current.readBytes=Array.from(new Uint8Array(bytes));
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
const onStore=async function(file,...args){
|
||||
const current=activeState.current;
|
||||
if(current&&file?.path===path)current.repairCalls++;
|
||||
return await originalStore.call(this,file,...args);
|
||||
};
|
||||
const onDialogue=async function(message,buttons,...args){
|
||||
const current=activeState.current;
|
||||
if(current){
|
||||
current.dialogueCalls++;
|
||||
current.dialogueMessages.push(String(message));
|
||||
return Array.isArray(buttons)&&buttons.length>0?buttons[0]:false;
|
||||
}
|
||||
return await originalDialogue.call(this,message,buttons,...args);
|
||||
};
|
||||
|
||||
const runRegisteredCheck=async current=>{
|
||||
const originalFinish=noticeGroups.finish;
|
||||
const ownFinish=Object.prototype.hasOwnProperty.call(noticeGroups,'finish');
|
||||
let finished=false;
|
||||
noticeGroups.finish=function(...args){
|
||||
try{return originalFinish.apply(this,args);}
|
||||
finally{
|
||||
if(args[0]==='startup-integrity-check')finished=true;
|
||||
}
|
||||
};
|
||||
activeState.current=current;
|
||||
try{
|
||||
events.emitEvent('request-run-fix-incomplete');
|
||||
await waitFor(()=>finished,'The registered incomplete-document check did not finish');
|
||||
}finally{
|
||||
activeState.current=null;
|
||||
restore(noticeGroups,'finish',ownFinish,originalFinish);
|
||||
}
|
||||
};
|
||||
const makeObservation=mode=>({
|
||||
mode,
|
||||
statCalls:0,
|
||||
actualStats:[],
|
||||
returnedStats:[],
|
||||
readCalls:0,
|
||||
readBytes:[],
|
||||
dialogueCalls:0,
|
||||
dialogueMessages:[],
|
||||
repairCalls:0,
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection',onUnhandled);
|
||||
try{
|
||||
storage.statHidden=onStat;
|
||||
installedStat=true;
|
||||
storage.readHiddenFileBinary=onRead;
|
||||
installedRead=true;
|
||||
fileHandler.storeFileToDB=onStore;
|
||||
installedStore=true;
|
||||
confirm.askSelectStringDialogue=onDialogue;
|
||||
installedDialogue=true;
|
||||
|
||||
const firstStorageStat=await storage.statHidden(path);
|
||||
require(firstStorageStat&&firstStorageStat.type==='file',
|
||||
'The real Vault file was not visible through StorageAccess');
|
||||
const firstVault=await activeBytes();
|
||||
const first=snapshot();
|
||||
const healthyBefore=await first;
|
||||
require(firstVault.content===expectedContent&&healthyBefore.content===expectedContent,
|
||||
'The fixture content did not match between the Vault and Metadata');
|
||||
require(firstStorageStat.size===healthyBefore.bytes.length,
|
||||
'The real Vault size did not match the Metadata bytes');
|
||||
|
||||
await keyValueDB.set('checkIncompleteDocs',false);
|
||||
const healthy=makeObservation('healthy');
|
||||
const healthyUnhandledBefore=unhandledErrors.length;
|
||||
await runRegisteredCheck(healthy);
|
||||
const healthyAfter=await snapshot();
|
||||
const healthyMarker=await marker();
|
||||
const healthyVaultAfter=await activeBytes();
|
||||
require(healthy.statCalls===2,'The healthy fixture was not checked with both StorageAccess stats');
|
||||
require(healthy.readCalls===0,'The healthy fixture was read only to determine its size');
|
||||
require(healthy.dialogueCalls===0,'The healthy fixture opened a repair dialogue');
|
||||
require(healthy.repairCalls===0,'The healthy fixture invoked repair');
|
||||
require(healthyMarker,'The healthy integrity check did not set its completion marker');
|
||||
require(unhandledErrors.length===healthyUnhandledBefore,
|
||||
'The healthy integrity check raised an unhandled error');
|
||||
require(healthyVaultAfter.content===expectedContent,'The healthy Vault file changed');
|
||||
assertUnchanged(healthyBefore,healthyAfter,'Healthy scan');
|
||||
const observations=[
|
||||
{
|
||||
case:'healthy',
|
||||
revision:healthyAfter.revision,
|
||||
storedBytes:healthyAfter.bytes.length,
|
||||
readCalls:healthy.readCalls,
|
||||
statCalls:healthy.statCalls,
|
||||
dialogueCalls:healthy.dialogueCalls,
|
||||
repairCalls:healthy.repairCalls,
|
||||
completionMarker:healthyMarker,
|
||||
storedBytesUnchanged:true,
|
||||
},
|
||||
];
|
||||
|
||||
const raceBefore=await snapshot();
|
||||
await keyValueDB.set('checkIncompleteDocs',false);
|
||||
const race=makeObservation('stat-read-race');
|
||||
const raceUnhandledBefore=unhandledErrors.length;
|
||||
await runRegisteredCheck(race);
|
||||
const raceAfter=await snapshot();
|
||||
const raceMarker=await marker();
|
||||
const raceVaultAfter=await activeBytes();
|
||||
require(race.statCalls===1,'The controlled stat/read race did not use one initial stat');
|
||||
require(race.actualStats[0]?.size===raceBefore.bytes.length,
|
||||
'The real StorageAccess stat did not see the Metadata size');
|
||||
require(race.returnedStats[0]?.size===raceBefore.bytes.length+1,
|
||||
'The checker did not see the controlled old mismatched size');
|
||||
require(race.readCalls===1,'The controlled mismatch did not read real Vault bytes');
|
||||
require(JSON.stringify(race.readBytes)===JSON.stringify(raceBefore.bytes),
|
||||
'The actual StorageAccess read did not match the database bytes');
|
||||
require(race.dialogueCalls===0,'The settled stat/read race opened a repair dialogue');
|
||||
require(race.repairCalls===0,'The settled stat/read race invoked repair');
|
||||
require(raceMarker,'The settled stat/read race did not set its completion marker');
|
||||
require(unhandledErrors.length===raceUnhandledBefore,
|
||||
'The settled stat/read race raised an unhandled error');
|
||||
require(raceVaultAfter.content===expectedContent,'The Vault file changed during the stat/read race');
|
||||
assertUnchanged(raceBefore,raceAfter,'Settled stat/read race');
|
||||
observations.push({
|
||||
case:'stat-read-race',
|
||||
revision:raceAfter.revision,
|
||||
storedBytes:raceAfter.bytes.length,
|
||||
observedStatSize:race.returnedStats[0]?.size,
|
||||
readCalls:race.readCalls,
|
||||
readMatchedDatabase:true,
|
||||
dialogueCalls:race.dialogueCalls,
|
||||
repairCalls:race.repairCalls,
|
||||
completionMarker:raceMarker,
|
||||
storedBytesUnchanged:true,
|
||||
});
|
||||
|
||||
const unstableBefore=await snapshot();
|
||||
await keyValueDB.set('checkIncompleteDocs',false);
|
||||
const unstable=makeObservation('unstable-final-stat');
|
||||
const unstableUnhandledBefore=unhandledErrors.length;
|
||||
await runRegisteredCheck(unstable);
|
||||
await waitFor(()=>unhandledErrors.length>unstableUnhandledBefore,
|
||||
'The changed final stat did not reject the incomplete-document check');
|
||||
const unstableError=unhandledErrors.slice(unstableUnhandledBefore).join(' | ');
|
||||
const unstableAfter=await snapshot();
|
||||
const unstableMarker=await marker();
|
||||
require(unstable.statCalls===2,'The changed final stat was not checked');
|
||||
require(unstable.returnedStats[1]?.mtime!==unstable.actualStats[1]?.mtime,
|
||||
'The final StorageAccess stat was not changed by the race fixture');
|
||||
require(unstable.readCalls===0,'The healthy path was read during the final-stat race');
|
||||
require(unstableError.includes('Storage changed during the incomplete document check'),
|
||||
'The final-stat race did not report the expected incomplete-check error');
|
||||
require(unstableMarker===false,'The incomplete check was marked complete after storage changed');
|
||||
require(unstable.dialogueCalls===0,'The final-stat race opened a repair dialogue');
|
||||
require(unstable.repairCalls===0,'The final-stat race invoked repair');
|
||||
assertUnchanged(unstableBefore,unstableAfter,'Final-stat race');
|
||||
observations.push({
|
||||
case:'unstable-final-stat',
|
||||
revision:unstableAfter.revision,
|
||||
statCalls:unstable.statCalls,
|
||||
readCalls:unstable.readCalls,
|
||||
reportedError:unstableError,
|
||||
completionMarker:unstableMarker,
|
||||
storedBytesUnchanged:true,
|
||||
});
|
||||
|
||||
await keyValueDB.set('checkIncompleteDocs',false);
|
||||
const retry=makeObservation('settled-retry');
|
||||
const retryUnhandledBefore=unhandledErrors.length;
|
||||
await runRegisteredCheck(retry);
|
||||
const retryAfter=await snapshot();
|
||||
const retryMarker=await marker();
|
||||
require(retry.statCalls===2,'The settled retry did not complete both StorageAccess stats');
|
||||
require(retry.readCalls===0,'The settled retry read healthy storage');
|
||||
require(retry.dialogueCalls===0,'The settled retry opened a repair dialogue');
|
||||
require(retry.repairCalls===0,'The settled retry invoked repair');
|
||||
require(retryMarker,'The settled retry did not set its completion marker');
|
||||
require(unhandledErrors.length===retryUnhandledBefore,
|
||||
'The settled retry raised an unhandled error');
|
||||
assertUnchanged(unstableBefore,retryAfter,'Settled retry');
|
||||
observations.push({
|
||||
case:'settled-retry',
|
||||
revision:retryAfter.revision,
|
||||
statCalls:retry.statCalls,
|
||||
readCalls:retry.readCalls,
|
||||
dialogueCalls:retry.dialogueCalls,
|
||||
repairCalls:retry.repairCalls,
|
||||
completionMarker:retryMarker,
|
||||
storedBytesUnchanged:true,
|
||||
});
|
||||
return JSON.stringify(observations);
|
||||
}finally{
|
||||
window.removeEventListener('unhandledrejection',onUnhandled);
|
||||
if(installedDialogue)restore(confirm,'askSelectStringDialogue',ownDialogue,originalDialogue);
|
||||
if(installedStore)restore(fileHandler,'storeFileToDB',ownStore,originalStore);
|
||||
if(installedRead)restore(storage,'readHiddenFileBinary',ownRead,originalRead);
|
||||
if(installedStat)restore(storage,'statHidden',ownStat,originalStat);
|
||||
}
|
||||
`);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
bundleHash,
|
||||
trigger: "registered-request-run-fix-incomplete-event",
|
||||
naturalRestart: false,
|
||||
observations,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await session?.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,269 @@
|
||||
/** Exercises synthetic split-surrogate input through real Obsidian file reflection. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
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 ??= "60000";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const textPath = "synthetic-split-pair.md";
|
||||
const controlPath = "complete-pair.md";
|
||||
const binaryPath = "attachment.bin";
|
||||
const textContent = "prefix 🙂 suffix\n" + "normal chunk payload\n".repeat(50_000);
|
||||
const controlContent = "prefix 🙂 suffix";
|
||||
const binaryBytes = Buffer.from([0, 255, 31, 128]);
|
||||
const textSize = Buffer.byteLength(textContent, "utf8");
|
||||
const textHash = `sha256:${createHash("sha256").update(textContent, "utf8").digest("hex")}`;
|
||||
const controlSize = Buffer.byteLength(controlContent, "utf8");
|
||||
const controlHash = `sha256:${createHash("sha256").update(controlContent, "utf8").digest("hex")}`;
|
||||
const settings = createE2eCouchDbPluginData(
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName: "third-party-chunks-local" },
|
||||
{
|
||||
customChunkSize: 0,
|
||||
processSizeMismatchedFiles: false,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
}
|
||||
);
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
const vault = await createTemporaryVault("obsidian-third-party-chunks-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginData: settings,
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
lifecycle: {
|
||||
beforeLaunch: async () => {
|
||||
const registryPath = join(vault.userDataPath, "obsidian.json");
|
||||
const registry = JSON.parse(await readFile(registryPath, "utf8"));
|
||||
registry.updateDisabled = true;
|
||||
await writeFile(registryPath, JSON.stringify(registry));
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
|
||||
const reflection = await evalObsidianJson<{
|
||||
sizeCheckingEnabled: boolean;
|
||||
split: {
|
||||
reflected: boolean;
|
||||
metadataRevisionUnchanged: boolean;
|
||||
chunkCount: number;
|
||||
provenanceRevision: string;
|
||||
contentSize: number;
|
||||
contentHash: string;
|
||||
};
|
||||
control: {
|
||||
reflected: boolean;
|
||||
content: string;
|
||||
metadataRevisionUnchanged: boolean;
|
||||
provenanceRevision: string;
|
||||
contentSize: number;
|
||||
contentHash: string;
|
||||
};
|
||||
binary: { reflected: boolean; bytes: number[]; metadataRevisionUnchanged: boolean };
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const require=(condition,message)=>{if(!condition)throw new Error(message);};",
|
||||
"const textPath='synthetic-split-pair.md';",
|
||||
"const controlPath='complete-pair.md';",
|
||||
"const binaryPath='attachment.bin';",
|
||||
"const textContent='prefix 🙂 suffix\\n'+'normal chunk payload\\n'.repeat(50000);",
|
||||
"const controlContent='prefix 🙂 suffix';",
|
||||
"const binaryBytes=[0,255,31,128];",
|
||||
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"require(core.settings.processSizeMismatchedFiles===false,'Size mismatch checking must remain enabled');",
|
||||
"const seed=async(path,blob,type,datatype)=>{",
|
||||
" require(!app.vault.getAbstractFileByPath(path),'Fixture already exists in the Vault: '+path);",
|
||||
" const id=await core.services.path.path2id(path);",
|
||||
" const now=Date.now();",
|
||||
" const saved=await core.localDatabase.putDBEntry({_id:id,path,data:blob,ctime:now,mtime:now,",
|
||||
" size:blob.size,children:[],datatype,type,eden:{} });",
|
||||
" require(saved?.ok,'Could not create Metadata and Chunks: '+path);",
|
||||
" const meta=await core.localDatabase.getDBEntryMeta(path,undefined,true);",
|
||||
" require(meta&&meta._rev===saved.rev,'Could not reload the created Metadata: '+path);",
|
||||
" return meta;",
|
||||
"};",
|
||||
"const textMeta=await seed(textPath,new Blob([textContent],{type:'text/plain'}),'plain','plain');",
|
||||
"require((textMeta.children??[]).length>0,'The primary fixture did not create normal Chunks');",
|
||||
"const savedText=await core.localDatabase.getDBEntry(textPath,{rev:textMeta._rev,revs:true},false,true,true);",
|
||||
"const storedText=Array.isArray(savedText?.data)?savedText.data.join(''):savedText?.data;",
|
||||
"require(storedText===textContent,'The seeded local database content did not match the joined text');",
|
||||
"const controlMeta=await seed(controlPath,new Blob([controlContent],{type:'text/plain'}),'plain','plain');",
|
||||
"const binaryMeta=await seed(binaryPath,new Blob([new Uint8Array(binaryBytes)],{type:'application/octet-stream'}),'newnote','newnote');",
|
||||
"const initialTextRevision=textMeta._rev;",
|
||||
"const initialControlRevision=controlMeta._rev;",
|
||||
"const database=core.serviceModules.databaseFileAccess;",
|
||||
"require(typeof database?.fetchEntryFromMeta==='function','The database file-access provider is missing');",
|
||||
"const descriptor=Object.getOwnPropertyDescriptor(database,'fetchEntryFromMeta');",
|
||||
"const original=database.fetchEntryFromMeta;",
|
||||
"let supplied=0;",
|
||||
"const pairIndex=textContent.indexOf('🙂');",
|
||||
"const syntheticChunks=[textContent.slice(0,pairIndex+1),textContent.slice(pairIndex+1)];",
|
||||
"require(syntheticChunks[0]==='prefix \\uD83D','The synthetic first chunk did not end with the high surrogate');",
|
||||
"require(syntheticChunks[1].startsWith('\\uDE42 suffix'),'The synthetic second chunk did not start with the low surrogate');",
|
||||
"database.fetchEntryFromMeta=async function(meta,...args){",
|
||||
" const loaded=await original.call(database,meta,...args);",
|
||||
" if(meta?._id!==textMeta._id||meta?.path!==textPath||!loaded)return loaded;",
|
||||
" supplied++;",
|
||||
" return {...loaded,data:syntheticChunks};",
|
||||
"};",
|
||||
"let reflectedText=false;",
|
||||
"try{reflectedText=await core.serviceModules.fileHandler.dbToStorage(textMeta,null,true);}",
|
||||
"finally{if(descriptor)Object.defineProperty(database,'fetchEntryFromMeta',descriptor);else delete database.fetchEntryFromMeta;}",
|
||||
"require(supplied===1,'The synthetic provider was not used exactly once');",
|
||||
"require(reflectedText,'Real Obsidian reflection rejected the joined text');",
|
||||
"const textFile=app.vault.getAbstractFileByPath(textPath);",
|
||||
"require(textFile,'The reflected text file is missing from the Vault');",
|
||||
"const vaultText=await app.vault.read(textFile);",
|
||||
"require(vaultText===textContent,'The Vault text does not match the joined input');",
|
||||
"const textMetaAfter=await core.localDatabase.getDBEntryMeta(textPath,undefined,true);",
|
||||
"require(textMetaAfter?._rev===initialTextRevision,'Text reflection changed the Metadata revision');",
|
||||
"const textProvenance=await store.get(textPath);",
|
||||
"require(textProvenance?.revision===initialTextRevision,'Text provenance has the wrong revision');",
|
||||
`require(textProvenance?.contentSize===${textSize},'Text provenance has the wrong UTF-8 byte size');`,
|
||||
`require(textProvenance?.contentHash===${JSON.stringify(textHash)},'Text provenance has the wrong content hash');`,
|
||||
"const reflectedControl=await core.serviceModules.fileHandler.dbToStorage(controlMeta,null,true);",
|
||||
"require(reflectedControl,'Real Obsidian reflection rejected the complete-pair control');",
|
||||
"const controlFile=app.vault.getAbstractFileByPath(controlPath);",
|
||||
"require(controlFile&&await app.vault.read(controlFile)===controlContent,'The complete-pair control content changed');",
|
||||
"const controlVaultText=await app.vault.read(controlFile);",
|
||||
"const controlMetaAfter=await core.localDatabase.getDBEntryMeta(controlPath,undefined,true);",
|
||||
"require(controlMetaAfter?._rev===initialControlRevision,'Control reflection changed the Metadata revision');",
|
||||
"const controlProvenance=await store.get(controlPath);",
|
||||
"require(controlProvenance?.revision===initialControlRevision,'Control provenance has the wrong revision');",
|
||||
`require(controlProvenance?.contentSize===${controlSize},'Control provenance has the wrong UTF-8 byte size');`,
|
||||
`require(controlProvenance?.contentHash===${JSON.stringify(controlHash)},'Control provenance has the wrong content hash');`,
|
||||
"const reflectedBinary=await core.serviceModules.fileHandler.dbToStorage(binaryMeta,null,true);",
|
||||
"require(reflectedBinary,'Real Obsidian reflection rejected the binary attachment');",
|
||||
"const binaryFile=app.vault.getAbstractFileByPath(binaryPath);",
|
||||
"require(binaryFile,'The reflected binary attachment is missing from the Vault');",
|
||||
"const binaryVaultBytes=Array.from(new Uint8Array(await app.vault.readBinary(binaryFile)));",
|
||||
"require(JSON.stringify(binaryVaultBytes)===JSON.stringify(binaryBytes),'The Vault binary bytes changed');",
|
||||
"const binaryMetaAfter=await core.localDatabase.getDBEntryMeta(binaryPath,undefined,true);",
|
||||
"require(binaryMetaAfter?._rev===binaryMeta._rev,'Binary reflection changed the Metadata revision');",
|
||||
"return JSON.stringify({sizeCheckingEnabled:!core.settings.processSizeMismatchedFiles,",
|
||||
" split:{reflected:reflectedText,metadataRevisionUnchanged:textMetaAfter._rev===initialTextRevision,chunkCount:textMeta.children.length,",
|
||||
" provenanceRevision:textProvenance.revision,contentSize:textProvenance.contentSize,contentHash:textProvenance.contentHash},",
|
||||
" control:{reflected:reflectedControl,content:controlVaultText,",
|
||||
" metadataRevisionUnchanged:controlMetaAfter._rev===initialControlRevision,provenanceRevision:controlProvenance.revision,",
|
||||
" contentSize:controlProvenance.contentSize,contentHash:controlProvenance.contentHash},",
|
||||
" binary:{reflected:reflectedBinary,bytes:binaryVaultBytes,metadataRevisionUnchanged:binaryMetaAfter._rev===binaryMeta._rev}});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
|
||||
assertEqual(reflection.sizeCheckingEnabled, true, "Size mismatch checking was disabled.");
|
||||
assertEqual(reflection.split.reflected, true, "Split-pair text was not reflected.");
|
||||
assertEqual(
|
||||
reflection.split.metadataRevisionUnchanged,
|
||||
true,
|
||||
"Split-pair reflection changed its Metadata revision."
|
||||
);
|
||||
assertEqual(reflection.split.provenanceRevision.length > 0, true, "Split-pair provenance is missing.");
|
||||
assertEqual(reflection.split.contentSize, textSize, "Split-pair provenance has the wrong UTF-8 byte size.");
|
||||
assertEqual(reflection.split.contentHash, textHash, "Split-pair provenance has the wrong content hash.");
|
||||
assertEqual(reflection.control.reflected, true, "Complete-pair control was not reflected.");
|
||||
assertEqual(reflection.control.content, controlContent, "Complete-pair control content changed.");
|
||||
assertEqual(
|
||||
reflection.control.metadataRevisionUnchanged,
|
||||
true,
|
||||
"Control reflection changed its Metadata revision."
|
||||
);
|
||||
assertEqual(reflection.control.contentSize, controlSize, "Control provenance has the wrong UTF-8 byte size.");
|
||||
assertEqual(reflection.control.contentHash, controlHash, "Control provenance has the wrong content hash.");
|
||||
assertEqual(reflection.binary.reflected, true, "Binary attachment was not reflected.");
|
||||
if (!Buffer.from(reflection.binary.bytes).equals(binaryBytes)) {
|
||||
throw new Error("The Obsidian Vault API returned different binary attachment bytes.");
|
||||
}
|
||||
assertEqual(
|
||||
reflection.binary.metadataRevisionUnchanged,
|
||||
true,
|
||||
"Binary reflection changed its Metadata revision."
|
||||
);
|
||||
|
||||
const diskText = await readFile(join(vault.path, textPath));
|
||||
assertEqual(diskText.toString("utf8"), textContent, "On-disk UTF-8 text differs from the joined input.");
|
||||
assertEqual(diskText.byteLength, textSize, "On-disk UTF-8 text has the wrong byte length.");
|
||||
const diskControl = await readFile(join(vault.path, controlPath));
|
||||
assertEqual(
|
||||
diskControl.toString("utf8"),
|
||||
controlContent,
|
||||
"On-disk complete-pair control differs from the input."
|
||||
);
|
||||
const diskBinary = await readFile(join(vault.path, binaryPath));
|
||||
if (!diskBinary.equals(binaryBytes)) throw new Error("On-disk binary attachment bytes changed.");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
bundleHash,
|
||||
syntheticInput:
|
||||
"A local fetchEntryFromMeta result split the joined text between the emoji surrogates; this does not establish that CouchDB or any named third-party producer emits lone-surrogate chunks.",
|
||||
sizeCheckingEnabled: reflection.sizeCheckingEnabled,
|
||||
splitText: {
|
||||
reflected: reflection.split.reflected,
|
||||
metadataRevisionUnchanged: reflection.split.metadataRevisionUnchanged,
|
||||
realChunkCount: reflection.split.chunkCount,
|
||||
vaultTextMatches: true,
|
||||
onDiskUtf8Bytes: diskText.byteLength,
|
||||
provenanceRevision: reflection.split.provenanceRevision,
|
||||
contentSize: reflection.split.contentSize,
|
||||
contentHash: reflection.split.contentHash,
|
||||
},
|
||||
completePairControl: {
|
||||
reflected: reflection.control.reflected,
|
||||
metadataRevisionUnchanged: reflection.control.metadataRevisionUnchanged,
|
||||
vaultAndDiskContentMatch: true,
|
||||
provenanceContentSize: reflection.control.contentSize,
|
||||
provenanceContentHash: reflection.control.contentHash,
|
||||
},
|
||||
binaryAttachment: {
|
||||
reflected: reflection.binary.reflected,
|
||||
exactVaultBytes: true,
|
||||
exactDiskBytes: true,
|
||||
metadataRevisionUnchanged: reflection.binary.metadataRevisionUnchanged,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await session?.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,312 @@
|
||||
/** Keeps the 1.0.40 batching choice and Vault state when installing the current release. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
waitForCouchDbDocs,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts";
|
||||
import {
|
||||
assertEqual,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
prepareRemote,
|
||||
pushLocalChanges,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { ensurePinnedReleaseArtifact, type PinnedPluginRelease } from "../runner/releaseArtifact.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { readRuntimeUpgradeState, waitForPersistentNodeIdentity } from "../runner/upgradeWorkflow.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const sourceRelease: PinnedPluginRelease = {
|
||||
pluginId: "obsidian-livesync",
|
||||
version: "1.0.40",
|
||||
files: [
|
||||
{
|
||||
name: "main.js",
|
||||
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/1.0.40/main.js",
|
||||
sha256: "bdd6b18ffe36a6a72ce774e857889b70d379559e4a5a3ccafee357a42daca6e9",
|
||||
},
|
||||
{
|
||||
name: "manifest.json",
|
||||
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/1.0.40/manifest.json",
|
||||
sha256: "8f8f57fb105aba0ce7f9ea5104dbed92fea837d49fdf27b4f02b6099ddf1ba3b",
|
||||
},
|
||||
{
|
||||
name: "styles.css",
|
||||
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/1.0.40/styles.css",
|
||||
sha256: "4ba00bef417ee98d9a62dd9febb792dc6555949cfc9e8e069450ad699ab2b9f0",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type BatchState = {
|
||||
batchSave: boolean;
|
||||
batchSaveInLiveSync: boolean | null;
|
||||
liveSync: boolean;
|
||||
periodicReplication: boolean;
|
||||
shouldBatchSave: boolean;
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
};
|
||||
|
||||
const beforePath = "upgrade-batching-before.md";
|
||||
const afterPath = "upgrade-batching-after.md";
|
||||
const returnPath = "upgrade-batching-return.md";
|
||||
const beforeContent = "Saved by the published 1.0.40 plug-in.\n";
|
||||
const afterContent = "Saved after upgrading with LiveSync batching still disabled.\n";
|
||||
const returnContent = "Returned by an independent current-release Vault.\n";
|
||||
|
||||
async function artefactHashes(root: string): Promise<Record<string, string>> {
|
||||
return Object.fromEntries(
|
||||
await Promise.all(
|
||||
sourceRelease.files.map(async ({ name }) => [
|
||||
name,
|
||||
createHash("sha256")
|
||||
.update(new Uint8Array(await readFile(join(root, name))))
|
||||
.digest("hex"),
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const sourceRoot = await ensurePinnedReleaseArtifact(sourceRelease);
|
||||
const targetRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd());
|
||||
const targetManifest = JSON.parse(await readFile(join(targetRoot, "manifest.json"), "utf8"));
|
||||
if (targetManifest.id !== sourceRelease.pluginId || targetManifest.version === sourceRelease.version) {
|
||||
throw new Error("The target must be a newer Self-hosted LiveSync build.");
|
||||
}
|
||||
const hashes = await artefactHashes(targetRoot);
|
||||
const config = await loadCouchDbConfig();
|
||||
if (!["127.0.0.1", "localhost"].includes(new URL(config.uri).hostname)) {
|
||||
throw new Error("Use a disposable local CouchDB for the upgrade scenario.");
|
||||
}
|
||||
const dbName = makeUniqueDatabaseName(config.dbPrefix, "upgrade-batching");
|
||||
const vault = await createTemporaryVault("obsidian-upgrade-batching-");
|
||||
const verifier = await createTemporaryVault("obsidian-upgrade-verifier-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
let ownsDatabase = false;
|
||||
const pluginData = createE2eCouchDbPluginData(
|
||||
{ ...config, dbName },
|
||||
{
|
||||
batchSave: true,
|
||||
batchSaveMinimumDelay: 3,
|
||||
batchSaveMaximumDelay: 7,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
}
|
||||
);
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
if (session) await session.app.stop();
|
||||
session = undefined;
|
||||
}
|
||||
|
||||
async function start(currentVault: TemporaryVault, artifactRoot: string, seed = false): Promise<void> {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault: currentVault,
|
||||
artifactRoot,
|
||||
pluginStartup: seed ? undefined : "natural",
|
||||
pluginData: seed ? pluginData : undefined,
|
||||
localStorageEntries: seed ? createE2eObsidianDeviceLocalState(currentVault.name) : undefined,
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
}
|
||||
|
||||
async function evaluate<T>(body: string): Promise<T> {
|
||||
if (!session) throw new Error("No active Obsidian session");
|
||||
return await evalObsidianJson<T>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
return JSON.stringify(await(async()=>{${body}})());
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
async function batching(): Promise<BatchState> {
|
||||
return await evaluate<BatchState>(`
|
||||
const s=core.services.setting.currentSettings();
|
||||
return {batchSave:s.batchSave,batchSaveInLiveSync:s.batchSaveInLiveSync??null,
|
||||
liveSync:s.liveSync,periodicReplication:s.periodicReplication,
|
||||
shouldBatchSave:core.serviceModules.storageAccess.vaultManager.shouldBatchSave,
|
||||
minimum:s.batchSaveMinimumDelay,maximum:s.batchSaveMaximumDelay};
|
||||
`);
|
||||
}
|
||||
|
||||
async function createNote(path: string, content: string): Promise<void> {
|
||||
await evaluate(`await app.vault.create(${JSON.stringify(path)},${JSON.stringify(content)}); return true;`);
|
||||
const entry = await waitForLocalDatabaseEntry(cliBinary, session!.cliEnv, path);
|
||||
if ((await batching()).liveSync) {
|
||||
await waitForCouchDbDocs(config, dbName, (docs) =>
|
||||
docs.some((doc) => doc._id === entry.id && doc._rev === entry.rev)
|
||||
);
|
||||
} else {
|
||||
await pushLocalChanges(cliBinary, session!.cliEnv);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNote(currentVault: TemporaryVault, path: string, content: string): Promise<void> {
|
||||
const actual = await evaluate(`
|
||||
const until=Date.now()+15000;
|
||||
while(Date.now()<until){
|
||||
const file=app.vault.getAbstractFileByPath(${JSON.stringify(path)});
|
||||
if(file?.stat){const value=await app.vault.read(file);if(value===${JSON.stringify(content)})return value;}
|
||||
await new Promise(resolve=>setTimeout(resolve,100));
|
||||
}
|
||||
throw new Error('Expected Vault contents did not arrive: '+${JSON.stringify(path)});
|
||||
`);
|
||||
assertEqual(actual, content, "The Obsidian Vault did not contain the expected text.");
|
||||
assertEqual(await readFile(join(currentVault.path, path), "utf8"), content, "On-disk text differed.");
|
||||
}
|
||||
|
||||
function assertUpgradedBatching(state: BatchState, live: boolean): void {
|
||||
assertEqual(state.batchSave, true, "The legacy batching choice changed during upgrade.");
|
||||
assertEqual(state.batchSaveInLiveSync, false, "Upgrade unexpectedly enabled LiveSync batching.");
|
||||
assertEqual(state.liveSync, live, "The selected synchronisation mode changed.");
|
||||
assertEqual(state.shouldBatchSave, !live, "The retained choice applied to the wrong mode.");
|
||||
assertEqual(state.minimum, 3, "The minimum batch delay changed.");
|
||||
assertEqual(state.maximum, 7, "The maximum batch delay changed.");
|
||||
}
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(config);
|
||||
await createCouchDbDatabase(config, dbName);
|
||||
ownsDatabase = true;
|
||||
await start(vault, sourceRoot, true);
|
||||
await prepareRemote(cliBinary, session!.cliEnv);
|
||||
await createNote(beforePath, beforeContent);
|
||||
await evaluate(`
|
||||
const s=core.services.setting.currentSettings();
|
||||
s.liveSync=true;
|
||||
await core.services.setting.saveSettingData();
|
||||
await core.services.control.applySettings();
|
||||
return true;
|
||||
`);
|
||||
await stop();
|
||||
|
||||
// Confirm that the old release itself persisted this state before replacing any artefacts.
|
||||
await start(vault, sourceRoot);
|
||||
const oldBatch = await batching();
|
||||
assertEqual(oldBatch.batchSave, true, "The old release did not retain its batching choice.");
|
||||
assertEqual(oldBatch.batchSaveInLiveSync, null, "The old release unexpectedly owns the new setting.");
|
||||
assertEqual(oldBatch.liveSync, true, "The old release did not retain LiveSync mode.");
|
||||
assertEqual(oldBatch.shouldBatchSave, false, "The old release unexpectedly batches LiveSync mode.");
|
||||
await waitForPersistentNodeIdentity(cliBinary, session!.cliEnv);
|
||||
const before = await readRuntimeUpgradeState(cliBinary, session!.cliEnv);
|
||||
const originalEntry = await waitForLocalDatabaseEntry(cliBinary, session!.cliEnv, beforePath);
|
||||
await assertNote(vault, beforePath, beforeContent);
|
||||
await stop();
|
||||
const savedOldData = JSON.parse(
|
||||
await readFile(join(vault.path, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8")
|
||||
);
|
||||
assertEqual(
|
||||
Object.prototype.hasOwnProperty.call(savedOldData, "batchSaveInLiveSync"),
|
||||
false,
|
||||
"The old settings fixture contains the new option."
|
||||
);
|
||||
|
||||
await start(vault, targetRoot);
|
||||
const upgraded = await readRuntimeUpgradeState(cliBinary, session!.cliEnv);
|
||||
assertEqual(upgraded.pluginVersion, targetManifest.version, "The requested target build was not loaded.");
|
||||
assertEqual(upgraded.localDatabaseName, before.localDatabaseName, "Upgrade replaced the local database.");
|
||||
assertEqual(upgraded.nodeId, before.nodeId, "Upgrade replaced the device identity.");
|
||||
assertEqual(
|
||||
upgraded.settings.activeConfigurationId,
|
||||
before.settings.activeConfigurationId,
|
||||
"Upgrade changed the remote profile."
|
||||
);
|
||||
assertEqual(upgraded.settings.couchDB_DBNAME, dbName, "Upgrade selected another remote database.");
|
||||
assertEqual(upgraded.settings.versionUpFlash, "", "Upgrade unexpectedly paused for compatibility review.");
|
||||
const firstBatch = await batching();
|
||||
assertUpgradedBatching(firstBatch, true);
|
||||
assertEqual(
|
||||
(await waitForLocalDatabaseEntry(cliBinary, session!.cliEnv, beforePath)).rev,
|
||||
originalEntry.rev,
|
||||
"Upgrade rewrote existing Metadata."
|
||||
);
|
||||
await assertNote(vault, beforePath, beforeContent);
|
||||
await createNote(afterPath, afterContent);
|
||||
await stop();
|
||||
|
||||
await start(verifier, targetRoot, true);
|
||||
await prepareRemote(cliBinary, session!.cliEnv);
|
||||
await pushLocalChanges(cliBinary, session!.cliEnv);
|
||||
await assertNote(verifier, beforePath, beforeContent);
|
||||
await assertNote(verifier, afterPath, afterContent);
|
||||
await createNote(returnPath, returnContent);
|
||||
await stop();
|
||||
|
||||
await start(vault, targetRoot);
|
||||
assertUpgradedBatching(await batching(), true);
|
||||
await assertNote(vault, returnPath, returnContent);
|
||||
await evaluate(`
|
||||
const s=core.services.setting.currentSettings();
|
||||
s.liveSync=false;s.periodicReplication=true;s.periodicReplicationInterval=60;
|
||||
await core.services.setting.saveSettingData();
|
||||
await core.services.control.applySettings();
|
||||
return true;
|
||||
`);
|
||||
assertUpgradedBatching(await batching(), false);
|
||||
await stop();
|
||||
await start(vault, targetRoot);
|
||||
const finalBatch = await batching();
|
||||
assertUpgradedBatching(finalBatch, false);
|
||||
assertEqual(finalBatch.periodicReplication, true, "The periodic mode choice did not survive restart.");
|
||||
await assertNote(vault, returnPath, returnContent);
|
||||
assertEqual(
|
||||
JSON.stringify(await artefactHashes(targetRoot)),
|
||||
JSON.stringify(hashes),
|
||||
"Target artefacts changed during the test."
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: true,
|
||||
sourceVersion: sourceRelease.version,
|
||||
targetVersion: targetManifest.version,
|
||||
sourceHashes: Object.fromEntries(sourceRelease.files.map((file) => [file.name, file.sha256])),
|
||||
targetHashes: hashes,
|
||||
oldBatch,
|
||||
firstBatch,
|
||||
finalBatch,
|
||||
retainedDatabase: true,
|
||||
retainedNodeIdentity: true,
|
||||
retainedMetadataRevision: true,
|
||||
twoWayTransfer: true,
|
||||
naturalRestarts: true,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await stop();
|
||||
await vault.dispose();
|
||||
await verifier.dispose();
|
||||
if (ownsDatabase) await deleteCouchDbDatabase(config, dbName);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+55
-24
@@ -12,40 +12,71 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Customisation Sync
|
||||
## 1.0.42
|
||||
|
||||
#### Fixed
|
||||
27th October, 2026
|
||||
|
||||
- We can reload the customisation list and apply every file in a multi-file item on the first attempt. (#1271)
|
||||
- Reloading or clearing the list also prevents earlier pending updates from restoring removed files and items.
|
||||
|
||||
### Setup tools
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can generate a Setup URI with prefixed connection and encryption variables, avoiding collisions with shell or system variables such as `hostname` and `USERNAME`. (#817, #1274)
|
||||
- The generator reads `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE`. The previous names remain supported when the corresponding prefixed value is unset or blank.
|
||||
Making content checks mandatory in the 1.0.41 pre-release came with a little too much overhead, so 1.0.42 makes them optional. This has turned into a slightly larger release.
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### New Feature
|
||||
|
||||
- We can see CouchDB change-history comparison progressing before new documents arrive, with a ⏩ status icon and a comparison count before the upload count. An unchanged count disappears after three seconds.
|
||||
- We can hide or style individual status items with CSS snippets using the `livesync-stat-` classes. Tabular, right-aligned numbers retain their width while visible, reducing movement as counts change. Wider gaps between editor status items make the counts easier to read. The `livesync-stat-inactive` class lets CSS snippets control how inactive items disappear.
|
||||
- We can enable **Batch database updates in LiveSync mode** to combine repeated local edits before sending them to other devices.
|
||||
- The choice starts disabled, including when older settings contain the previously ignored batch option. Each mode retains its own choice and uses the same minimum and maximum delays.
|
||||
- Batching delays remote visibility and reduces intermediate updates. It does not establish when a template has finished or prevent genuine concurrent edits.
|
||||
- Rapid edits can produce fewer intermediate versions on other devices while LiveSync mode remains active.
|
||||
- Enable **Batch database updates in LiveSync mode** to group successive local edits.
|
||||
- Disabled by default; each synchronisation mode keeps its own choice.
|
||||
- Updates reach other devices later. Batching does not detect template completion or prevent genuine concurrent-edit conflicts.
|
||||
- CouchDB synchronisation no longer looks idle while it is comparing change history.
|
||||
- A ⏩ icon and comparison count show activity before documents are transferred.
|
||||
- This count represents history entries, not files remaining to synchronise.
|
||||
- Status counts are easier to follow, with less movement as values change.
|
||||
- CSS snippets can hide unwanted items or style individual `livesync-stat-` items, including inactive ones.
|
||||
|
||||
- We can choose stricter content checks at start-up.
|
||||
- Enable **Strict content check on startup** to compare file contents even when their modification times and sizes match the recorded local database state. This can increase start-up time in large Vaults.
|
||||
- The setting is disabled by default. Ordinary start-up can skip content reads when the exact modification time, file size, and recorded database revision match, with no conflicts or pending file changes.
|
||||
- While the setting is disabled, an edit which preserves its exact size and modification time can go undetected during start-up. Manual scans and explicit recovery operations remain strict.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Local changes remain available for recovery while their database save is running, including when the application stops after a batch delay ends.
|
||||
- Manual synchronisation and settings application now wait for the accepted local saves to finish and report failures. Pending events retain the existing snapshot format.
|
||||
- Hidden File Sync and Customisation Sync now report completion after saving, including delayed customisation updates. Failed saves remain pending, while unchanged notifications allow later edits to proceed.
|
||||
- Start-up reconciliation checks content when modification times are equal, so an edit within the same timestamp interval is not mistaken for an unchanged file.
|
||||
- Existing revision provenance preserves the correct edit branch. Unreadable or continually changing content remains unresolved rather than being reported as successfully reconciled.
|
||||
- We receive a clear Notice when **Automatic** requires Hidden File Sync to be enabled on this device. Our existing mode remains unchanged, and hidden-file initialisation cannot start while the feature is disabled. (#1270)
|
||||
- We can initialise a whole plug-in's **MAIN** and **DATA** selections using the chosen Automatic initial action.
|
||||
- We can run startup integrity checks with lower memory use for healthy Vault files. Files that change between the size check and content reading no longer trigger an unnecessary repair warning when their actual sizes agree and there are no conflicts. (#1283)
|
||||
- Recent Vault edits remain recoverable after an interrupted local database save.
|
||||
- This includes edits whose modification times compare equal to the database copy, and pending Hidden File Sync and Customisation Sync changes.
|
||||
- Manual synchronisation no longer starts before already accepted local changes finish saving to the database.
|
||||
- Settings application also waits for those saves. Failed saves stay pending and report a failure.
|
||||
- Text from third-party tools is handled more robustly.
|
||||
- Characters split across Chunks no longer trigger false corruption warnings.
|
||||
- Start-up integrity checks use less memory for healthy Vault files. (#1283)
|
||||
- Start-up checks avoid false repair warnings for files that change during inspection. (#1283)
|
||||
- This applies when the final size matches the database and there are no conflicts.
|
||||
|
||||
### Customisation Sync
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Applying a multi-file customisation no longer skips files after the list is refreshed. (#1271)
|
||||
- Choosing **Automatic** now explains a missing Hidden File Sync prerequisite instead of proceeding into a failed initialisation. (#1270)
|
||||
- Enable and initialise Hidden File Sync on this device first. The current mode is kept when the prerequisite is missing.
|
||||
- Initialising a whole plug-in in **Automatic** mode no longer omits its selected **MAIN** or **DATA** files.
|
||||
|
||||
### Setup tools
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Setup URI generation can avoid shell-variable collisions and unintended use of the operating-system username. (#817, #1274)
|
||||
- Use `LIVESYNC_HOST`, `LIVESYNC_USER`, `LIVESYNC_PASS`, `LIVESYNC_DB`, and `LIVESYNC_PASSPHRASE` for connection and encryption values.
|
||||
- Existing scripts remain supported: the previous names are used when the corresponding prefixed value is unset or blank.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Dependencies have been updated, including security fixes.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Thank you for your contributions!
|
||||
|
||||
- @rwbru, for the original Setup URI environment-variable fix (#817).
|
||||
- @junkerderprovinz, for adapting that fix to the current generator while preserving compatibility with existing scripts (#1274).
|
||||
- @s-qin, for reducing memory use during start-up checks and avoiding repeated content decoding in Commonlib (#1283, Commonlib #184).
|
||||
|
||||
## 1.0.40
|
||||
|
||||
|
||||
+3
-1
@@ -51,5 +51,7 @@
|
||||
"1.0.37": "1.7.2",
|
||||
"1.0.38": "1.7.2",
|
||||
"1.0.39": "1.7.2",
|
||||
"1.0.40": "1.7.2"
|
||||
"1.0.40": "1.7.2",
|
||||
"1.0.41": "1.7.2",
|
||||
"1.0.42": "1.7.2"
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import fs from "node:fs";
|
||||
import { platform } from "node:process";
|
||||
import { copyBuildFiles } from "./utils/copy-build-files";
|
||||
import { copyBuildFiles } from "./scripts/copy-build-files";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
function readJson(filePath: string) {
|
||||
|
||||
Reference in new issue
Block a user