mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-28 03:53:20 +00:00
Make strict startup content checks optional
This commit is contained in:
1 parent
e83aa892b4
commit
e46c05ddc2
13 files changed
+1029
-23
No files matched your search
@@ -59,10 +59,12 @@ that evidence. The inspected Commonlib source is commit
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
Make local file processing recoverable until its result is established, use
|
Make local file processing recoverable until its result is established, retain
|
||||||
content and provenance to reconcile ambiguous timestamps, and enable batching
|
strict content and provenance reconciliation for manual and recovery scans, and
|
||||||
in LiveSync mode only through an explicit choice after those contracts pass
|
enable batching in LiveSync mode only through an explicit choice after those
|
||||||
verification. Apply the recovery contract with batching enabled or disabled.
|
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
|
The content-reconciliation change targets selected ordinary files in the
|
||||||
Offline Scanner's `NEWER_WINS` / `sync-newer` action. Keep the existing action
|
Offline Scanner's `NEWER_WINS` / `sync-newer` action. Keep the existing action
|
||||||
@@ -208,12 +210,14 @@ finishes; correctness must also follow from the persistent record and start-up
|
|||||||
reconciliation. Remote availability must not determine whether local pending
|
reconciliation. Remote availability must not determine whether local pending
|
||||||
work survives shutdown.
|
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
|
Strict content checking retains the timestamp and provenance reconciliation
|
||||||
times. Timestamp equality, including exactly equal raw timestamps, does not
|
contract for platforms with coarse modification times. Timestamp equality,
|
||||||
prove content equality. The Offline Scanner must establish one of the following
|
including exactly equal raw timestamps, does not prove content equality. With
|
||||||
outcomes through the ordinary non-forced file handler and its provenance rules:
|
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 |
|
| Evidence | Required outcome |
|
||||||
| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -223,7 +227,19 @@ 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. |
|
| 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`. |
|
| 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 table describes stable-state reconciliation, not atomic database and
|
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
|
provenance persistence. Replay in the pre-provenance gap may create an
|
||||||
identical sibling; with no remote descendant, one of the identical branches
|
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
|
is tombstoned, leaving one live leaf with the exact saved content. If replication has
|
||||||
@@ -249,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
|
files to proceed; an unresolved pair must not become `completed` to achieve
|
||||||
that result. Keep the stricter callers' existing completion requirements.
|
that result. Keep the stricter callers' existing completion requirements.
|
||||||
|
|
||||||
This deliberately extends the current equal-time scanner contract. Existing
|
Keep the original unchanged-file, edited-file, untracked-provenance,
|
||||||
provenance tests expect no database write during that scan, including for an
|
incoming-protection, and concurrent-recheck fixtures under strict content
|
||||||
edited file. Preserve their unchanged-file and later incoming-protection
|
checking, including identical timestamps and distinct raw timestamps within
|
||||||
scenarios; change the edited-file expectation to require preservation during
|
one comparison bucket. Do not weaken those strict-mode assertions to describe
|
||||||
the scan itself. Test both identical timestamps and distinct raw timestamps
|
the ordinary start-up opt-out. Test the opt-out separately: only the exact
|
||||||
within one comparison bucket.
|
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
|
### 5. Make batch timing and activation explicit
|
||||||
|
|
||||||
@@ -369,7 +387,9 @@ matrix is:
|
|||||||
| Handler returns false, throws, or snapshot persistence fails | Flush reports failure; pending work is retained; unrelated recoverable work can continue. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Old settings, mode changes, explicit opt-in, and settings import | Existing LiveSync mode users retain immediate saving until they choose batching. |
|
||||||
|
|||||||
@@ -989,6 +989,12 @@ If enabled, chunks will be split into no more than 100 items. However, dedupe is
|
|||||||
Setting key: useSegmenter
|
Setting key: useSegmenter
|
||||||
If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.
|
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
|
### 3. Transfer Tweak
|
||||||
|
|
||||||
#### Fetch chunks on demand
|
#### Fetch chunks on demand
|
||||||
|
|||||||
@@ -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": "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: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: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: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: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",
|
"test:e2e:obsidian:folder-batch": "tsx test/e2e-obsidian/scripts/folder-batch.ts",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function createAdvancedSettingSpecGroups({
|
|||||||
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
|
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
|
||||||
},
|
},
|
||||||
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
|
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
|
||||||
|
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
|
||||||
{
|
{
|
||||||
key: "pathNormalizationVersion",
|
key: "pathNormalizationVersion",
|
||||||
holdValue: true,
|
holdValue: true,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
|
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([
|
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
|
||||||
"Memory cache",
|
"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("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"),
|
panelElements.get("Transfer Tweak"),
|
||||||
panelElements.get("Transfer Tweak"),
|
panelElements.get("Transfer Tweak"),
|
||||||
@@ -67,6 +68,7 @@ describe("paneAdvanced", () => {
|
|||||||
"hashCacheMaxCount",
|
"hashCacheMaxCount",
|
||||||
"chunkSplitterVersion",
|
"chunkSplitterVersion",
|
||||||
"customChunkSize",
|
"customChunkSize",
|
||||||
|
"strictContentCheckOnStartup",
|
||||||
"pathNormalizationVersion",
|
"pathNormalizationVersion",
|
||||||
"readChunksOnline",
|
"readChunksOnline",
|
||||||
"useOnlyLocalChunk",
|
"useOnlyLocalChunk",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const rangeMessages = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("Advanced setting specifications", () => {
|
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 });
|
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
|
||||||
|
|
||||||
expect(groups.map(({ heading }) => heading)).toEqual([
|
expect(groups.map(({ heading }) => heading)).toEqual([
|
||||||
@@ -30,6 +30,7 @@ describe("Advanced setting specifications", () => {
|
|||||||
"hashCacheMaxCount",
|
"hashCacheMaxCount",
|
||||||
"chunkSplitterVersion",
|
"chunkSplitterVersion",
|
||||||
"customChunkSize",
|
"customChunkSize",
|
||||||
|
"strictContentCheckOnStartup",
|
||||||
"pathNormalizationVersion",
|
"pathNormalizationVersion",
|
||||||
"readChunksOnline",
|
"readChunksOnline",
|
||||||
"useOnlyLocalChunk",
|
"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", () => {
|
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
|
||||||
let couchDB = false;
|
let couchDB = false;
|
||||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
|
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ describe("settings page catalogue", () => {
|
|||||||
"hashCacheMaxCount",
|
"hashCacheMaxCount",
|
||||||
"chunkSplitterVersion",
|
"chunkSplitterVersion",
|
||||||
"customChunkSize",
|
"customChunkSize",
|
||||||
|
"strictContentCheckOnStartup",
|
||||||
"pathNormalizationVersion",
|
"pathNormalizationVersion",
|
||||||
"readChunksOnline",
|
"readChunksOnline",
|
||||||
"useOnlyLocalChunk",
|
"useOnlyLocalChunk",
|
||||||
|
|||||||
@@ -19,6 +19,22 @@ import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker
|
|||||||
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
|
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
|
||||||
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
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 {
|
export class ModuleLiveSyncMain extends AbstractModule {
|
||||||
async _onLiveSyncReady() {
|
async _onLiveSyncReady() {
|
||||||
if (!(await this.core.services.appLifecycle.onLayoutReady())) return false;
|
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
|
// 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;
|
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(
|
const initialisationResult = await this.services.databaseEvents.initialiseDatabase(
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
ignoreSuspending,
|
ignoreSuspending,
|
||||||
continueOnFileFailure
|
continueOnFileFailure,
|
||||||
|
{ strictContentCheck }
|
||||||
);
|
);
|
||||||
if (initialisationResult === VaultScanResults.FAILED) {
|
if (initialisationResult === VaultScanResults.FAILED) {
|
||||||
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||||
|
|||||||
@@ -90,9 +90,11 @@ describe("ModuleLiveSyncMain", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
|
API: { getPlatform: () => "desktop" },
|
||||||
databaseEvents: { initialiseDatabase },
|
databaseEvents: { initialiseDatabase },
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
strictContentCheckOnStartup: false,
|
||||||
suspendFileWatching: false,
|
suspendFileWatching: false,
|
||||||
suspendParseReplicationResult: false,
|
suspendParseReplicationResult: false,
|
||||||
},
|
},
|
||||||
@@ -102,7 +104,7 @@ describe("ModuleLiveSyncMain", () => {
|
|||||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
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);
|
expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,6 +122,7 @@ describe("ModuleLiveSyncMain", () => {
|
|||||||
services: { appLifecycle },
|
services: { appLifecycle },
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
|
API: { getPlatform: () => "desktop" },
|
||||||
appLifecycle,
|
appLifecycle,
|
||||||
control: { applySettings: vi.fn(async () => undefined) },
|
control: { applySettings: vi.fn(async () => undefined) },
|
||||||
databaseEvents: { initialiseDatabase },
|
databaseEvents: { initialiseDatabase },
|
||||||
@@ -152,6 +155,7 @@ describe("ModuleLiveSyncMain", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
|
API: { getPlatform: () => "desktop" },
|
||||||
databaseEvents: { initialiseDatabase },
|
databaseEvents: { initialiseDatabase },
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@@ -163,6 +167,96 @@ describe("ModuleLiveSyncMain", () => {
|
|||||||
|
|
||||||
await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -100,6 +100,8 @@ The same scenario checks the five actions in Reporting Tools, the separate Check
|
|||||||
|
|
||||||
`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-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.
|
`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.
|
`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.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const focusedScenarios = new Set([
|
|||||||
"partial-startup-file-failure",
|
"partial-startup-file-failure",
|
||||||
"startup-scan",
|
"startup-scan",
|
||||||
"startup-integrity",
|
"startup-integrity",
|
||||||
|
"startup-content-check",
|
||||||
"stale-file-restart",
|
"stale-file-restart",
|
||||||
"folder-batch",
|
"folder-batch",
|
||||||
"setup-uri-workflow",
|
"setup-uri-workflow",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Synchronisation
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- Ordinary start-up can skip reading file content when the exact modification time, size, and recorded device-local provenance match a conflict-free revision, with no pending file processing.
|
||||||
|
- **Strict content check on startup** keeps full content comparisons enabled. The setting is disabled by default; an edit which preserves its exact size and modification time can go undetected by this start-up scan. Manual scans and explicit recovery operations remain strict.
|
||||||
|
|
||||||
## 1.0.41
|
## 1.0.41
|
||||||
|
|
||||||
26th October, 2026
|
26th October, 2026
|
||||||
|
|||||||
Reference in new issue
Block a user