mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-31 08:51:23 +00:00
Merge branch 'main' into pr/zeedif/1059
This commit is contained in:
@@ -37,9 +37,9 @@ The Obsidian host injects the screen wake-lock manager from the `octagonal-wheel
|
||||
|
||||
Finite replication enters both counts only after readiness checks have succeeded and leaves them after `openReplication(..., continuous: false, ...)` settles. A successful completion has reached the latest sequence in that operation's scope and is therefore an authoritative quiescence boundary for chunk retrieval. A failed operation does not prove latest state, but can no longer deliver documents from that attempt. Failure handling runs afterwards so a mismatch or recovery dialogue does not retain the activity. This includes the direct start-up synchronisation path as well as manual, event-driven, and periodic calls through `ReplicationService`. The unbounded continuous channel does not enter either boundary, but its finite initial pull-only catch-up does; the one-shot parameter fallback chain remains inside that boundary.
|
||||
|
||||
Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. A mobile device can have only a short opportunity to obtain remote data, while applying a large downloaded batch to the Vault is durable, offline-capable work which can continue or resume later. The replication-result queue and its recovery snapshot therefore remain outside `boundedRemoteActivityCount`. Finite remote activity ends when the transfer operation settles, even when the `📥` queue still contains documents awaiting Vault application.
|
||||
Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. Finite remote activity ends when the transfer operation settles, even when the `📥` queue still contains documents awaiting Vault application. The Obsidian host tracks that queue through a separate `boundedLocalApplicationActivityCount`, so local Vault writes do not increment either remote-activity count or keep the `📲` indicator visible.
|
||||
|
||||
This separation also keeps the activity indicators truthful: `📲` describes a finite remote operation and must not remain active solely because local Vault writes are pending. The existing replication-result count continues to describe that local queue. If a future feature offers screen-awake protection while applying downloaded documents, it must use a separately typed local-application activity or power-policy boundary, preserve the current behaviour by default, and avoid incrementing either remote-activity count.
|
||||
Applying downloaded document changes enters one local-application boundary from the first queued document until the queue and its in-progress set are both empty. The final empty recovery snapshot is attempted before the boundary settles; snapshot failure is logged and still releases the boundary. Additional batches share the existing boundary. Suspending replication-result processing releases it, and resuming reacquires it while work remains. The same host activity runner supplies best-effort Wake Lock protection, while the visibility lifecycle waits for both remote and local bounded activity to finish.
|
||||
|
||||
Manual P2P commands which bypass `ReplicationService` enter the broad boundary. Direct P2P pull and push entry points are therefore both protected as finite remote work, covering the Obsidian panes, CLI, and Webapp. A pull or bidirectional synchronisation also enters the narrower finite-replication boundary because it can place documents in the local database. A push-only request remains broad-only: it cannot satisfy a local missing-chunk read and must not present itself as a delivery source. Automatic synchronisation on peer discovery, a pull requested by a remote peer, and a watched pull following a peer progress notification enter both boundaries because each can deliver local documents. A normal P2P peer-selection dialogue represents one broad finite session: it remains inside the boundary while waiting for a peer and while the person may perform repeated synchronisations, then settles when the dialogue closes and any in-flight synchronisation has finished. Closing without synchronising returns a failed result and releases the boundary. The 'Start Sync & Close' action completes its synchronisation before closing. This deliberately protects peer discovery and selection, because display sleep can interrupt discovery or connection establishment and require the person to start detection again. It may therefore retain a Wake Lock longer than the network transfer alone. A transfer performed inside that session temporarily adds a nested activity; the count remains a logical-operation count rather than a connection total.
|
||||
|
||||
@@ -76,6 +76,8 @@ P2P does not yet contribute to the physical-request count because it does not ha
|
||||
|
||||
The platform activity runner remains injected. Common library and headless consumers can omit it while retaining the same bounded activity count and operation semantics.
|
||||
|
||||
The Obsidian-specific `ObsidianReplicatorService` owns `boundedLocalApplicationActivityCount` and reuses the injected activity runner. It is deliberately absent from the common service contract: CLI and Webapp processing continue directly, while the Obsidian host uses the count for Wake Lock and visibility-lifecycle policy without changing remote-operation reporting.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not count continuous replication as a bounded activity.
|
||||
@@ -85,11 +87,11 @@ The platform activity runner remains injected. Common library and headless consu
|
||||
- Do not guarantee protection against operating-system suspension, closing a laptop lid, forced termination, network loss, or a user-initiated sleep action.
|
||||
- Do not add a lifecycle timeout which would abort an unusually slow rebuild. A genuinely stalled operation may postpone LiveSync's visibility suspension until it settles, but the platform may still suspend or terminate background work.
|
||||
- Do not broaden `keepReplicationActiveInBackground`; it remains an opt-in desktop policy for continuous and periodic operation after finite work has ended.
|
||||
- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in this boundary. They are offline-capable and require a separate decision if activity reporting or power policy is added later.
|
||||
- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in either remote-activity count. The queue uses its separate local-application boundary.
|
||||
|
||||
## Verification
|
||||
|
||||
Before changing the transfer/application separation, add a deterministic regression scenario which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, persists the queued state, and resumes Vault application after suspension or restart. An optional local-application Wake Lock feature requires its own enabled and disabled cases; existing remote-operation E2E is not evidence for that separate policy.
|
||||
Before changing the transfer/application separation, keep deterministic coverage which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, and retains the separate local-application boundary until Vault application and the final recovery snapshot settle.
|
||||
|
||||
Unit tests cover:
|
||||
|
||||
@@ -106,6 +108,8 @@ Unit tests cover:
|
||||
- remote chunk fetching remaining inside the shared boundary from synchronous queue acceptance through local persistence and terminal notification;
|
||||
- missing-chunk waiters rechecking local storage when observed per-identifier claims and finite replication have settled;
|
||||
- the finite-replication count excluding other bounded work;
|
||||
- replicated document application sharing one local boundary, settling after the final recovery snapshot, and releasing around processing suspension;
|
||||
- local application activity leaving both remote-activity counts unchanged;
|
||||
- standard, fast, remote, and combined rebuild activity boundaries;
|
||||
- Rebuilder-owned confirmation and completion dialogues remaining outside rebuild activity;
|
||||
- fallback from fast fetch avoiding a nested activity boundary;
|
||||
@@ -125,5 +129,5 @@ The exact Fancy Kit screen wake-lock behaviour is covered by its package and Har
|
||||
- One finite activity definition drives Wake Lock, lifecycle protection, and status UI without coupling common library code to Obsidian or browser globals.
|
||||
- Callers can observe accurate logical activity even in CLI and Webapp hosts which do not inject a Wake Lock implementation.
|
||||
- Rebuild operations now retain Wake Lock and lifecycle protection across their longest interruption-sensitive phases without retaining them for Rebuilder-owned pre-operation or completion dialogues. Post-reset P2P discovery and selection remain protected as an intentional part of completing the rebuild.
|
||||
- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, allowing transfer and offline application to follow different mobile lifetimes without presenting local writes as communication.
|
||||
- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, while a separate local-application boundary retains best-effort Wake Lock and lifecycle protection without presenting local writes as communication.
|
||||
- Users can now distinguish the lifetime of a finite remote operation from approximate request activity within it.
|
||||
|
||||
Generated
+7
-7
@@ -32,7 +32,7 @@
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
@@ -11532,9 +11532,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/octagonal-wheels": {
|
||||
"version": "0.1.51",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.51.tgz",
|
||||
"integrity": "sha512-KTlfqKPjobHJg/t3A539srnFf+VHr1aXkHSmsNDDpiI5UFC7FamZ95dWpJfGE2EI/HULR5hveQDgkazmz8SAcg==",
|
||||
"version": "0.1.52",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.52.tgz",
|
||||
"integrity": "sha512-9WJN2UveNh90Op1S07cIso1WyNrQbO/unibDLfUGnpomIcU4g6F+p8reZHW0Ed8sGzKF5qGnQp991Y9MB1TwNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^8.0.3"
|
||||
@@ -15928,7 +15928,7 @@
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
@@ -15951,7 +15951,7 @@
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.1-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
@@ -15963,7 +15963,7 @@
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.1-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
+2
-1
@@ -58,6 +58,7 @@
|
||||
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
|
||||
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
|
||||
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
|
||||
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
|
||||
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
|
||||
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
|
||||
@@ -182,7 +183,7 @@
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
|
||||
@@ -94,10 +94,8 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable",
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B":
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable": "📦 DB: recorded ${RECORDED} B · decoded unavailable",
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B": "📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})":
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
|
||||
"✅ Matches Vault": "✅ Matches Vault",
|
||||
@@ -130,8 +128,7 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"More actions for revision ${REVISION}": "More actions for revision ${REVISION}",
|
||||
"More actions for ${FILE}": "More actions for ${FILE}",
|
||||
"Show revision history": "Show revision history",
|
||||
"Store Vault file as a new local database document":
|
||||
"Store Vault file as a new local database document",
|
||||
"Store Vault file as a new local database document": "Store Vault file as a new local database document",
|
||||
"Copy database information": "Copy database information",
|
||||
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
|
||||
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
|
||||
@@ -140,8 +137,7 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.":
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
|
||||
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
|
||||
"Inspect conflicts and file/database differences":
|
||||
"Inspect conflicts and file/database differences",
|
||||
"Inspect conflicts and file/database differences": "Inspect conflicts and file/database differences",
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
|
||||
"Begin inspection": "Begin inspection",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+469
-133
File diff suppressed because it is too large
Load Diff
+742
-402
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,16 @@ import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
|
||||
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
|
||||
const REPROCESS_BATCH_SIZE = 100;
|
||||
type LocalApplicationActivityOwner = {
|
||||
runBoundedLocalApplicationActivity<T>(
|
||||
task: () => T | PromiseLike<T>,
|
||||
options?: { label?: string }
|
||||
): Promise<T>;
|
||||
};
|
||||
type ReplicateResultProcessorState = {
|
||||
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||
@@ -67,9 +74,11 @@ export class ReplicateResultProcessor {
|
||||
|
||||
public suspend() {
|
||||
this._suspended = true;
|
||||
this.updateProcessingActivity();
|
||||
}
|
||||
public resume() {
|
||||
this._suspended = false;
|
||||
this.updateProcessingActivity();
|
||||
fireAndForget(() => this.runProcessQueue());
|
||||
}
|
||||
|
||||
@@ -251,6 +260,40 @@ export class ReplicateResultProcessor {
|
||||
*/
|
||||
private _processingChanges: PouchDB.Core.ExistingDocument<EntryDoc>[] = [];
|
||||
|
||||
private _processingActivity?: Promise<void>;
|
||||
private _processingActivityDone?: PromiseWithResolvers<void>;
|
||||
|
||||
private updateProcessingActivity() {
|
||||
if (this.isSuspended) {
|
||||
this._processingActivityDone?.resolve();
|
||||
return;
|
||||
}
|
||||
const hasPendingDocuments = this._queuedChanges.length > 0 || this._processingChanges.length > 0;
|
||||
if (!hasPendingDocuments) {
|
||||
this._processingActivityDone?.resolve();
|
||||
return;
|
||||
}
|
||||
if (this._processingActivity) return;
|
||||
|
||||
const activityDone = promiseWithResolvers<void>();
|
||||
this._processingActivityDone = activityDone;
|
||||
const activityOwner = this.services.replicator as typeof this.services.replicator &
|
||||
Partial<LocalApplicationActivityOwner>;
|
||||
this._processingActivity = (
|
||||
activityOwner.runBoundedLocalApplicationActivity
|
||||
? activityOwner.runBoundedLocalApplicationActivity(() => activityDone.promise, {
|
||||
label: "replicated-document-application",
|
||||
})
|
||||
: activityDone.promise
|
||||
)
|
||||
.catch((error) => this.logError(error))
|
||||
.finally(() => {
|
||||
if (this._processingActivityDone === activityDone) this._processingActivityDone = undefined;
|
||||
this._processingActivity = undefined;
|
||||
this.updateProcessingActivity();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the given document change for processing.
|
||||
* @param doc Document change to enqueue
|
||||
@@ -278,6 +321,7 @@ export class ReplicateResultProcessor {
|
||||
}
|
||||
// Enqueue the change
|
||||
this._queuedChanges.push(doc);
|
||||
this.updateProcessingActivity();
|
||||
this.triggerTakeSnapshot();
|
||||
this.triggerProcessQueue();
|
||||
}
|
||||
@@ -385,7 +429,19 @@ export class ReplicateResultProcessor {
|
||||
} finally {
|
||||
// Remove from processing queue
|
||||
this._processingChanges = this._processingChanges.filter((e) => e !== change);
|
||||
this.triggerTakeSnapshot();
|
||||
try {
|
||||
if (this._queuedChanges.length === 0 && this._processingChanges.length === 0) {
|
||||
try {
|
||||
await this._takeSnapshot();
|
||||
} catch (error) {
|
||||
this.logError(error);
|
||||
}
|
||||
} else {
|
||||
this.triggerTakeSnapshot();
|
||||
}
|
||||
} finally {
|
||||
this.updateProcessingActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,67 @@
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||
|
||||
describe("ReplicateResultProcessor target-filter reprocessing", () => {
|
||||
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||
return {
|
||||
_id: id,
|
||||
_rev: "1-test",
|
||||
path: `${id}.md`,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 1,
|
||||
children: [],
|
||||
datatype: "plain",
|
||||
type: "plain",
|
||||
eden: {},
|
||||
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
}
|
||||
|
||||
type SetupOptions = {
|
||||
processSynchroniseResult?: (entry: unknown) => Promise<void>;
|
||||
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
|
||||
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
|
||||
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
|
||||
const core = {
|
||||
services: {
|
||||
appLifecycle: { isReady: true, isSuspended: () => false },
|
||||
path: { getPath: (entry: { path: string }) => entry.path },
|
||||
replication: {
|
||||
databaseQueueCount: reactiveSource(0),
|
||||
storageApplyingCount: reactiveSource(0),
|
||||
replicationResultCount: reactiveSource(0),
|
||||
processVirtualDocument: vi.fn(async () => false),
|
||||
processOptionalSynchroniseResult: vi.fn(async () => false),
|
||||
processSynchroniseResult,
|
||||
},
|
||||
replicator: { runBoundedLocalApplicationActivity },
|
||||
vault: {
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
isFileSizeTooLarge: vi.fn(() => false),
|
||||
isValidPath: vi.fn(() => true),
|
||||
},
|
||||
},
|
||||
kvDB: { set: setSnapshot },
|
||||
localDatabase: {
|
||||
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
|
||||
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
|
||||
},
|
||||
replicator: { closeReplication: vi.fn() },
|
||||
};
|
||||
const processor = new ReplicateResultProcessor({
|
||||
core,
|
||||
settings: { maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false },
|
||||
} as never);
|
||||
return { processor, processSynchroniseResult, runBoundedLocalApplicationActivity };
|
||||
}
|
||||
|
||||
describe("ReplicateResultProcessor", () => {
|
||||
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
|
||||
const documents = [
|
||||
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
|
||||
@@ -22,4 +81,67 @@ describe("ReplicateResultProcessor target-filter reprocessing", () => {
|
||||
expect(enqueueAll).toHaveBeenCalledOnce();
|
||||
expect(enqueueAll).toHaveBeenCalledWith(documents);
|
||||
});
|
||||
|
||||
it("keeps one local application activity until every replicated document has been applied", async () => {
|
||||
const applying = promiseWithResolvers<void>();
|
||||
let activityFinished = false;
|
||||
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
|
||||
processSynchroniseResult: async () => applying.promise,
|
||||
});
|
||||
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
|
||||
await task();
|
||||
activityFinished = true;
|
||||
});
|
||||
|
||||
processor.enqueueAll([note("one"), note("two")]);
|
||||
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledTimes(2));
|
||||
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(1);
|
||||
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "replicated-document-application",
|
||||
});
|
||||
expect(activityFinished).toBe(false);
|
||||
|
||||
applying.resolve();
|
||||
|
||||
await vi.waitFor(() => expect(activityFinished).toBe(true));
|
||||
});
|
||||
|
||||
it("settles local application activity when the final recovery snapshot fails", async () => {
|
||||
let activityFinished = false;
|
||||
const { processor, runBoundedLocalApplicationActivity } = setup({
|
||||
setSnapshot: async () => Promise.reject(new Error("snapshot failed")),
|
||||
});
|
||||
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
|
||||
await task();
|
||||
activityFinished = true;
|
||||
});
|
||||
|
||||
processor.enqueueAll([note("one")]);
|
||||
|
||||
await vi.waitFor(() => expect(activityFinished).toBe(true));
|
||||
});
|
||||
|
||||
it("releases and reacquires local application activity around processing suspension", async () => {
|
||||
const applying = promiseWithResolvers<void>();
|
||||
let completedActivities = 0;
|
||||
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
|
||||
processSynchroniseResult: async () => applying.promise,
|
||||
});
|
||||
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
|
||||
await task();
|
||||
completedActivities++;
|
||||
});
|
||||
processor.enqueueAll([note("one")]);
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
|
||||
processor.suspend();
|
||||
await vi.waitFor(() => expect(completedActivities).toBe(1));
|
||||
|
||||
processor.resume();
|
||||
await vi.waitFor(() => expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(2));
|
||||
|
||||
applying.resolve();
|
||||
await vi.waitFor(() => expect(completedActivities).toBe(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,9 +113,20 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
|
||||
hasFocus = true;
|
||||
isLastHidden = false;
|
||||
private boundedRemoteActivityEndHandler?: (value: { readonly value: number }) => unknown;
|
||||
private boundedActivityEndHandler?: (value: { readonly value: number }) => unknown;
|
||||
private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible";
|
||||
|
||||
private get boundedActivityCounts(): ReactiveSource<number>[] {
|
||||
const replicator = this.services.replicator as typeof this.services.replicator & {
|
||||
boundedLocalApplicationActivityCount: ReactiveSource<number>;
|
||||
};
|
||||
return [replicator.boundedRemoteActivityCount, replicator.boundedLocalApplicationActivityCount];
|
||||
}
|
||||
|
||||
private hasBoundedActivity() {
|
||||
return this.boundedActivityCounts.some((count) => count.value > 0);
|
||||
}
|
||||
|
||||
private keepReplicationActiveInBackground() {
|
||||
return (
|
||||
this.settings.keepReplicationActiveInBackground &&
|
||||
@@ -125,9 +136,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
|
||||
private async applyDeferredBoundedActivityLifecycle() {
|
||||
const count = this.services.replicator.boundedRemoteActivityCount;
|
||||
if (count.value !== 0) {
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
if (this.hasBoundedActivity()) {
|
||||
this.deferLifecycleUntilBoundedActivityEnds();
|
||||
return;
|
||||
}
|
||||
const deferredLifecycle = this.deferredBoundedLifecycle;
|
||||
@@ -149,17 +159,17 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
}
|
||||
|
||||
private deferLifecycleUntilBoundedRemoteActivityEnds() {
|
||||
if (this.boundedRemoteActivityEndHandler) return;
|
||||
const count = this.services.replicator.boundedRemoteActivityCount;
|
||||
const handler = (value: { readonly value: number }) => {
|
||||
if (value.value !== 0) return;
|
||||
count.offChanged(handler);
|
||||
this.boundedRemoteActivityEndHandler = undefined;
|
||||
private deferLifecycleUntilBoundedActivityEnds() {
|
||||
if (this.boundedActivityEndHandler) return;
|
||||
const counts = this.boundedActivityCounts;
|
||||
const handler = () => {
|
||||
if (this.hasBoundedActivity()) return;
|
||||
for (const count of counts) count.offChanged(handler);
|
||||
this.boundedActivityEndHandler = undefined;
|
||||
fireAndForget(() => this.applyDeferredBoundedActivityLifecycle());
|
||||
};
|
||||
this.boundedRemoteActivityEndHandler = handler;
|
||||
count.onChanged(handler);
|
||||
this.boundedActivityEndHandler = handler;
|
||||
for (const count of counts) count.onChanged(handler);
|
||||
}
|
||||
|
||||
setHasFocus(hasFocus: boolean) {
|
||||
@@ -188,12 +198,12 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
if (
|
||||
this.settings.isConfigured &&
|
||||
this.services.appLifecycle.isReady() &&
|
||||
this.services.replicator.boundedRemoteActivityCount.value > 0
|
||||
this.hasBoundedActivity()
|
||||
) {
|
||||
const isHidden = activeWindow.document.hidden;
|
||||
this.isLastHidden = isHidden;
|
||||
this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
this.deferLifecycleUntilBoundedActivityEnds();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -210,8 +220,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedRemoteActivityInProgress = this.services.replicator.boundedRemoteActivityCount.value > 0;
|
||||
if (!isHidden && boundedRemoteActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
|
||||
const boundedActivityInProgress = this.hasBoundedActivity();
|
||||
if (!isHidden && boundedActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
|
||||
this.isLastHidden = false;
|
||||
this.deferredBoundedLifecycle = undefined;
|
||||
return;
|
||||
@@ -228,18 +238,18 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
const keepActiveInBackground = this.keepReplicationActiveInBackground();
|
||||
|
||||
if (isHidden) {
|
||||
if (boundedRemoteActivityInProgress && !keepActiveInBackground) {
|
||||
if (boundedActivityInProgress && !keepActiveInBackground) {
|
||||
this.deferredBoundedLifecycle = "suspend-if-hidden";
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
this.deferLifecycleUntilBoundedActivityEnds();
|
||||
} else if (!keepActiveInBackground) {
|
||||
await this.services.appLifecycle.onSuspending();
|
||||
}
|
||||
} else {
|
||||
// suspend all temporary.
|
||||
if (this.services.appLifecycle.isSuspended()) return;
|
||||
if (boundedRemoteActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
|
||||
if (boundedActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
|
||||
this.deferredBoundedLifecycle = "restart-continuous-if-visible";
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
this.deferLifecycleUntilBoundedActivityEnds();
|
||||
return;
|
||||
}
|
||||
// Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB
|
||||
|
||||
@@ -24,6 +24,7 @@ function setup(opts: SetupOptions) {
|
||||
};
|
||||
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
|
||||
const boundedRemoteActivityCount = reactiveSource(0);
|
||||
const boundedLocalApplicationActivityCount = reactiveSource(0);
|
||||
|
||||
const core = {
|
||||
_services: {
|
||||
@@ -38,7 +39,7 @@ function setup(opts: SetupOptions) {
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replicator: { boundedRemoteActivityCount },
|
||||
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -56,7 +57,13 @@ function setup(opts: SetupOptions) {
|
||||
// The handler reads `activeWindow.document.hidden`.
|
||||
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
|
||||
|
||||
return { module, appLifecycle, fileProcessing, boundedRemoteActivityCount };
|
||||
return {
|
||||
module,
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
boundedRemoteActivityCount,
|
||||
boundedLocalApplicationActivityCount,
|
||||
};
|
||||
}
|
||||
|
||||
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
|
||||
@@ -109,6 +116,21 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("defers suspension while local document application is active", async () => {
|
||||
const { module, appLifecycle, boundedLocalApplicationActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: false },
|
||||
hidden: true,
|
||||
});
|
||||
boundedLocalApplicationActivityCount.value = 1;
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
|
||||
boundedLocalApplicationActivityCount.value = 0;
|
||||
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("defers mobile suspension while bounded remote activity is running", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: false },
|
||||
|
||||
@@ -74,6 +74,11 @@ export class DocumentHistoryModal extends Modal {
|
||||
currentDeleted = false;
|
||||
initialRev?: string;
|
||||
|
||||
// Revision navigation state (◀/▶ beside the range slider)
|
||||
revPrevBtn!: HTMLButtonElement;
|
||||
revNextBtn!: HTMLButtonElement;
|
||||
revNavIndicator!: HTMLSpanElement;
|
||||
|
||||
// Diff navigation state
|
||||
currentDiffIndex = -1;
|
||||
diffNavContainer!: HTMLDivElement;
|
||||
@@ -84,6 +89,8 @@ export class DocumentHistoryModal extends Modal {
|
||||
searchKeyword = "";
|
||||
searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = [];
|
||||
currentSearchIndex = -1;
|
||||
searchPrevBtn!: HTMLButtonElement;
|
||||
searchNextBtn!: HTMLButtonElement;
|
||||
searchResultIndicator!: HTMLSpanElement;
|
||||
searchProgressIndicator!: HTMLSpanElement;
|
||||
searchTimeout: number | null = null;
|
||||
@@ -125,12 +132,14 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.range.value = this.range.max;
|
||||
this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`);
|
||||
await this.loadRevs(initialRev);
|
||||
this.updateRevisionNavUI();
|
||||
} catch (ex) {
|
||||
if (isErrorOfMissingDoc(ex)) {
|
||||
this.range.max = "0";
|
||||
this.range.value = "";
|
||||
this.range.disabled = true;
|
||||
this.contentView.setText(`We don't have any history for this note.`);
|
||||
this.updateRevisionNavUI();
|
||||
} else {
|
||||
this.contentView.setText(`Error while loading file.`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
@@ -148,6 +157,37 @@ export class DocumentHistoryModal extends Modal {
|
||||
const index = this.revs_info.length - 1 - (Number(this.range.value) || 0);
|
||||
const rev = this.revs_info[index];
|
||||
await this.showExactRev(rev.rev);
|
||||
this.updateRevisionNavUI();
|
||||
}
|
||||
|
||||
navigateVersion(direction: "older" | "newer") {
|
||||
const current = Number(this.range.value) || 0;
|
||||
const max = Number(this.range.max) || 0;
|
||||
|
||||
if (direction === "older" && current > 0) {
|
||||
this.range.value = `${current - 1}`;
|
||||
} else if (direction === "newer" && current < max) {
|
||||
this.range.value = `${current + 1}`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateRevisionNavUI();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
}
|
||||
|
||||
updateRevisionNavUI() {
|
||||
if (!this.revNavIndicator) return;
|
||||
|
||||
const total = this.revs_info.length;
|
||||
const max = Number(this.range.max) || 0;
|
||||
const current = Number(this.range.value) || 0;
|
||||
|
||||
this.revNavIndicator.setText(total > 0 ? `Rev ${current + 1}/${total}` : "\u2014");
|
||||
|
||||
const disabled = !!this.range.disabled || total <= 1;
|
||||
this.revPrevBtn.disabled = disabled || current <= 0;
|
||||
this.revNextBtn.disabled = disabled || current >= max;
|
||||
}
|
||||
BlobURLs = new Map<string, string>();
|
||||
|
||||
@@ -391,6 +431,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (!keyword) {
|
||||
this.searchResultIndicator.setText("");
|
||||
this.searchProgressIndicator.setText("");
|
||||
this.updateSearchUI();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -464,6 +505,10 @@ export class DocumentHistoryModal extends Modal {
|
||||
const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0;
|
||||
this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`);
|
||||
}
|
||||
|
||||
const hasResults = this.searchResults.length > 0;
|
||||
this.searchPrevBtn.disabled = !hasResults;
|
||||
this.searchNextBtn.disabled = !hasResults;
|
||||
}
|
||||
|
||||
navigateSearch(direction: "prev" | "next") {
|
||||
@@ -515,12 +560,14 @@ export class DocumentHistoryModal extends Modal {
|
||||
}, 500);
|
||||
});
|
||||
|
||||
searchRow.createEl("button", { text: "\u25B2" }, (e) => {
|
||||
this.searchPrevBtn = searchRow.createEl("button", { text: "\u25B2" }, (e) => {
|
||||
e.title = "Previous match";
|
||||
e.disabled = true;
|
||||
e.addEventListener("click", () => this.navigateSearch("prev"));
|
||||
});
|
||||
searchRow.createEl("button", { text: "\u25BC" }, (e) => {
|
||||
this.searchNextBtn = searchRow.createEl("button", { text: "\u25BC" }, (e) => {
|
||||
e.title = "Next match";
|
||||
e.disabled = true;
|
||||
e.addEventListener("click", () => this.navigateSearch("next"));
|
||||
});
|
||||
|
||||
@@ -530,18 +577,37 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.searchProgressIndicator = searchRow.createSpan({ text: "" });
|
||||
this.searchProgressIndicator.addClass("history-search-progress-indicator");
|
||||
|
||||
const divView = contentEl.createDiv("");
|
||||
divView.addClass("op-flex");
|
||||
const revNavRow = contentEl.createDiv({ cls: "history-rev-nav-row" });
|
||||
|
||||
divView.createEl("input", { type: "range" }, (e) => {
|
||||
this.revPrevBtn = revNavRow.createEl("button", { text: "\u25C0" }, (e) => {
|
||||
e.addClass("history-rev-nav-btn");
|
||||
e.title = "Older revision";
|
||||
e.disabled = true;
|
||||
e.addEventListener("click", () => this.navigateVersion("older"));
|
||||
});
|
||||
|
||||
revNavRow.createEl("input", { type: "range" }, (e) => {
|
||||
this.range = e;
|
||||
e.addEventListener("change", (e) => {
|
||||
e.addEventListener("change", () => {
|
||||
this.updateRevisionNavUI();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
e.addEventListener("input", (e) => {
|
||||
e.addEventListener("input", () => {
|
||||
this.updateRevisionNavUI();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
});
|
||||
|
||||
this.revNextBtn = revNavRow.createEl("button", { text: "\u25B6" }, (e) => {
|
||||
e.addClass("history-rev-nav-btn");
|
||||
e.title = "Newer revision";
|
||||
e.disabled = true;
|
||||
e.addEventListener("click", () => this.navigateVersion("newer"));
|
||||
});
|
||||
|
||||
this.revNavIndicator = revNavRow.createSpan({ text: "\u2014" }, (e) => {
|
||||
e.addClass("history-rev-indicator");
|
||||
});
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
diffOptionsRow.addClass("op-info");
|
||||
diffOptionsRow.addClass("diff-options-row");
|
||||
|
||||
@@ -10,11 +10,32 @@ import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/se
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
|
||||
type ActivityOptions = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export class ObsidianDatabaseEventService extends InjectableDatabaseEventService<ObsidianServiceContext> {}
|
||||
|
||||
// InjectableReplicatorService
|
||||
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {}
|
||||
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {
|
||||
readonly boundedLocalApplicationActivityCount = reactiveSource(0);
|
||||
|
||||
async runBoundedLocalApplicationActivity<T>(
|
||||
task: () => T | PromiseLike<T>,
|
||||
options?: ActivityOptions
|
||||
): Promise<T> {
|
||||
this.boundedLocalApplicationActivityCount.value++;
|
||||
try {
|
||||
return this.dependencies.activityRunner
|
||||
? await this.dependencies.activityRunner.run(task, options)
|
||||
: await task();
|
||||
} finally {
|
||||
this.boundedLocalApplicationActivityCount.value--;
|
||||
}
|
||||
}
|
||||
}
|
||||
// InjectableFileProcessingService
|
||||
export class ObsidianFileProcessingService extends InjectableFileProcessingService<ObsidianServiceContext> {}
|
||||
// InjectableReplicationService
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ObsidianReplicatorService } from "./ObsidianServices";
|
||||
|
||||
function handler() {
|
||||
return { addHandler: vi.fn() };
|
||||
}
|
||||
|
||||
describe("ObsidianReplicatorService", () => {
|
||||
it("tracks local application activity without extending remote activity", async () => {
|
||||
const activity = promiseWithResolvers<void>();
|
||||
const service = new ObsidianReplicatorService({ events: {}, translate: String } as never, {
|
||||
settingService: { onRealiseSetting: handler() },
|
||||
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
|
||||
databaseEventService: {
|
||||
onResetDatabase: handler(),
|
||||
onDatabaseInitialisation: handler(),
|
||||
onDatabaseInitialised: handler(),
|
||||
onDatabaseHasReady: handler(),
|
||||
},
|
||||
activityRunner: { run: vi.fn(async (task: () => Promise<void>) => await task()) },
|
||||
} as never);
|
||||
|
||||
const running = service.runBoundedLocalApplicationActivity(() => activity.promise);
|
||||
|
||||
expect(service.boundedLocalApplicationActivityCount.value).toBe(1);
|
||||
expect(service.boundedRemoteActivityCount.value).toBe(0);
|
||||
|
||||
activity.resolve();
|
||||
await running;
|
||||
|
||||
expect(service.boundedLocalApplicationActivityCount.value).toBe(0);
|
||||
expect(service.boundedRemoteActivityCount.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
|
||||
constructor(private app: App) {}
|
||||
|
||||
async read(file: TFile): Promise<string> {
|
||||
return await this.app.vault.read(file);
|
||||
// Vault.read strips a leading UTF-8 BOM, leaving the content size inconsistent with TFile.stat.
|
||||
return await this.app.vault.adapter.read(file.path);
|
||||
}
|
||||
|
||||
async cachedRead(file: TFile): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { App, TFile } from "obsidian";
|
||||
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||
|
||||
describe("ObsidianVaultAdapter.read", () => {
|
||||
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
|
||||
const path = "Transcripts/字幕.md";
|
||||
const contentWithoutBom = "字幕の検証行です。\n";
|
||||
const contentWithBom = `\ufeff${contentWithoutBom}`;
|
||||
const read = vi.fn().mockResolvedValue(contentWithoutBom);
|
||||
const adapterRead = vi.fn().mockResolvedValue(contentWithBom);
|
||||
const app = {
|
||||
vault: {
|
||||
read,
|
||||
adapter: {
|
||||
read: adapterRead,
|
||||
},
|
||||
},
|
||||
} as unknown as App;
|
||||
const file = {
|
||||
path,
|
||||
stat: {
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: new Blob([contentWithBom]).size,
|
||||
},
|
||||
} as TFile;
|
||||
const adapter = new ObsidianVaultAdapter(app);
|
||||
|
||||
const result = await adapter.read(file);
|
||||
|
||||
expect(new Blob([result]).size).toBe(file.stat.size);
|
||||
expect(result.charCodeAt(0)).toBe(0xfeff);
|
||||
expect(adapterRead).toHaveBeenCalledWith(path);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+45
@@ -748,6 +748,51 @@ body.is-mobile .livesync-compatibility-review-notice {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.history-search-row button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.history-rev-nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.history-rev-nav-row input[type="range"] {
|
||||
flex-grow: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.history-rev-nav-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.history-rev-nav-btn:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.history-rev-nav-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.history-rev-indicator {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
min-width: 4.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.history-diff-options-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ??= "60000";
|
||||
process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ??= "30000";
|
||||
|
||||
const notePath = "E2E/document-history-nav.md";
|
||||
const revisions = ["Version one alpha", "Version two beta keyword", "Version three gamma"];
|
||||
|
||||
type RevisionInfo = {
|
||||
revCount: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type OpenHistoryResult = {
|
||||
opened: boolean;
|
||||
modalTitle: string | null;
|
||||
};
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrue(value: boolean, message: string): void {
|
||||
if (!value) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissWelcomeWizard(port: number): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const cancel = page.getByText("No, please take me back");
|
||||
if (await cancel.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await cancel.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function seedRevisions(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RevisionInfo> {
|
||||
return await evalObsidianJson<RevisionInfo>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(notePath)};`,
|
||||
`const revisions=${JSON.stringify(revisions)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
|
||||
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
|
||||
"const existing=app.vault.getAbstractFileByPath(path);",
|
||||
"if(existing) await app.vault.delete(existing);",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"let baseRev='';",
|
||||
"for(const content of revisions){",
|
||||
" const blob=new Blob([content],{type:'text/plain'});",
|
||||
" const now=Date.now();",
|
||||
" const result=await core.localDatabase.putDBEntry({",
|
||||
" _id:id,",
|
||||
" path,",
|
||||
" data:blob,",
|
||||
" ctime:now,",
|
||||
" mtime:now,",
|
||||
" size:(await blob.arrayBuffer()).byteLength,",
|
||||
" children:[],",
|
||||
" datatype:'plain',",
|
||||
" type:'plain',",
|
||||
" eden:{},",
|
||||
" },false,baseRev||undefined);",
|
||||
" if(!result?.ok) throw new Error(`Could not store revision for ${path}`);",
|
||||
" baseRev=result.rev;",
|
||||
" await sleep(100);",
|
||||
"}",
|
||||
`await app.vault.create(path,revisions[revisions.length-1]);`,
|
||||
"await core.services.fileProcessing.commitPendingFileEvents();",
|
||||
"const raw=await core.localDatabase.getRaw(id,{revs_info:true});",
|
||||
"const revCount=(raw._revs_info||[]).filter((e)=>e&&e.status==='available').length;",
|
||||
"return JSON.stringify({revCount,id});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function openDocumentHistory(cliBinary: string, env: NodeJS.ProcessEnv): Promise<OpenHistoryResult> {
|
||||
return await evalObsidianJson<OpenHistoryResult>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(notePath)};`,
|
||||
"const file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) throw new Error('Note missing before opening history');",
|
||||
"document.querySelectorAll('.modal-close-button').forEach((btn)=>btn.click());",
|
||||
"await new Promise((resolve)=>setTimeout(resolve,300));",
|
||||
"const leaf=app.workspace.getLeaf(false);",
|
||||
"await leaf.openFile(file);",
|
||||
"await new Promise((resolve)=>setTimeout(resolve,300));",
|
||||
"await app.commands.executeCommandById('obsidian-livesync:livesync-history');",
|
||||
"await new Promise((resolve)=>setTimeout(resolve,500));",
|
||||
"const modal=document.querySelector('.modal-container .modal-title');",
|
||||
"return JSON.stringify({opened:!!modal,modalTitle:modal?modal.textContent:null});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
|
||||
const vault = await createTemporaryVault();
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
const screenshotDir =
|
||||
process.env.E2E_OBSIDIAN_HISTORY_SCREENSHOT_DIR ??
|
||||
join(process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", "document-history-nav");
|
||||
const reportPath = process.env.E2E_OBSIDIAN_HISTORY_REPORT ?? join(screenshotDir, "report.txt");
|
||||
|
||||
async function captureStep(page: import("playwright").Page, step: string): Promise<string> {
|
||||
await mkdir(screenshotDir, { recursive: true });
|
||||
const path = join(screenshotDir, `${step}.png`);
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
console.log(`Screenshot: ${path}`);
|
||||
return path;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Using Obsidian executable: ${binary}`);
|
||||
console.log(`Temporary vault: ${vault.path}`);
|
||||
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
remoteType: "",
|
||||
couchDB_URI: "",
|
||||
couchDB_DBNAME: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
periodicReplication: false,
|
||||
syncAfterMerge: false,
|
||||
syncOnEditorSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnSave: false,
|
||||
syncOnStart: false,
|
||||
},
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
||||
|
||||
await dismissWelcomeWizard(session.remoteDebuggingPort);
|
||||
|
||||
const revisionInfo = await seedRevisions(cli.binary, session.cliEnv);
|
||||
console.log(`Seeded local history: ${revisionInfo.revCount} revisions for ${revisionInfo.id}`);
|
||||
assertEqual(revisionInfo.revCount, revisions.length, "Unexpected number of seeded revisions.");
|
||||
|
||||
const opened = await openDocumentHistory(cli.binary, session.cliEnv);
|
||||
assertEqual(opened.opened, true, "Document History modal did not open.");
|
||||
assertEqual(opened.modalTitle, "Document History", "Unexpected modal title.");
|
||||
|
||||
const report = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({ hasText: "Document History" });
|
||||
await modal.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
const revNavRow = modal.locator(".history-rev-nav-row");
|
||||
await revNavRow.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
const indicator = revNavRow.locator(".history-rev-indicator");
|
||||
const initialIndicator = (await indicator.innerText()).trim();
|
||||
|
||||
const prevBtn = revNavRow.locator(".history-rev-nav-btn").first();
|
||||
const nextBtn = revNavRow.locator(".history-rev-nav-btn").last();
|
||||
const range = revNavRow.locator('input[type="range"]');
|
||||
|
||||
assertTrue(/Rev \d+\/\d+/.test(initialIndicator), `Unexpected initial indicator: ${initialIndicator}`);
|
||||
|
||||
const initialRange = await range.inputValue();
|
||||
|
||||
const screenshotPaths: string[] = [];
|
||||
screenshotPaths.push(await captureStep(page, "01-initial-latest-rev"));
|
||||
assertEqual(initialIndicator, "Rev 3/3", "History did not open at the latest revision.");
|
||||
assertEqual(initialRange, "2", "History slider did not open at the latest revision.");
|
||||
assertTrue(
|
||||
!(await prevBtn.isDisabled()),
|
||||
"Older revision button should be enabled at the latest revision."
|
||||
);
|
||||
assertTrue(await nextBtn.isDisabled(), "Newer revision button should be disabled at the latest revision.");
|
||||
|
||||
await prevBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
const afterPrevIndicator = (await indicator.innerText()).trim();
|
||||
const afterPrevRange = await range.inputValue();
|
||||
assertTrue(
|
||||
Number(afterPrevRange) < Number(initialRange),
|
||||
`◀ did not move to an older revision. before=${initialRange}, after=${afterPrevRange}`
|
||||
);
|
||||
assertTrue(afterPrevIndicator !== initialIndicator, "◀ did not update Rev indicator.");
|
||||
assertTrue(!(await prevBtn.isDisabled()), "Older revision button was disabled before the oldest revision.");
|
||||
assertTrue(!(await nextBtn.isDisabled()), "Newer revision button was not enabled after moving backwards.");
|
||||
screenshotPaths.push(await captureStep(page, "02-after-click-older-rev"));
|
||||
|
||||
await prevBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
assertEqual(await range.inputValue(), "0", "◀ did not reach the oldest revision.");
|
||||
assertTrue(await prevBtn.isDisabled(), "Older revision button should be disabled at the oldest revision.");
|
||||
assertTrue(
|
||||
!(await nextBtn.isDisabled()),
|
||||
"Newer revision button should be enabled at the oldest revision."
|
||||
);
|
||||
screenshotPaths.push(await captureStep(page, "03-at-oldest-rev"));
|
||||
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
const afterNextIndicator = (await indicator.innerText()).trim();
|
||||
const afterNextRange = await range.inputValue();
|
||||
assertEqual(afterNextRange, initialRange, "▶ did not return to the original revision.");
|
||||
assertEqual(afterNextIndicator, initialIndicator, "▶ did not restore the Rev indicator.");
|
||||
assertTrue(
|
||||
await nextBtn.isDisabled(),
|
||||
"Newer revision button should be disabled after returning to latest."
|
||||
);
|
||||
screenshotPaths.push(await captureStep(page, "04-after-click-newer-rev"));
|
||||
|
||||
const searchInput = modal.locator(".history-search-input");
|
||||
await searchInput.fill("keyword");
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const searchIndicator = modal.locator(".history-search-result-indicator");
|
||||
const searchText = (await searchIndicator.innerText()).trim();
|
||||
assertTrue(/matches/.test(searchText), `Search indicator did not report matches: ${searchText}`);
|
||||
screenshotPaths.push(await captureStep(page, "05-after-search-keyword"));
|
||||
|
||||
const searchPrev = modal.locator(".history-search-row button").nth(0);
|
||||
const searchNext = modal.locator(".history-search-row button").nth(1);
|
||||
assertTrue(!(await searchPrev.isDisabled()), "Search ▲ should be enabled when matches exist.");
|
||||
assertTrue(!(await searchNext.isDisabled()), "Search ▼ should be enabled when matches exist.");
|
||||
|
||||
await searchNext.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const afterSearchIndicator = (await searchIndicator.innerText()).trim();
|
||||
assertTrue(
|
||||
/1\/\d+ matches/.test(afterSearchIndicator),
|
||||
`Search navigation failed: ${afterSearchIndicator}`
|
||||
);
|
||||
screenshotPaths.push(await captureStep(page, "06-after-search-next-match"));
|
||||
|
||||
return [
|
||||
`initialIndicator: ${initialIndicator}`,
|
||||
`afterPrevIndicator: ${afterPrevIndicator}`,
|
||||
`afterNextIndicator: ${afterNextIndicator}`,
|
||||
`searchIndicator: ${searchText}`,
|
||||
`afterSearchIndicator: ${afterSearchIndicator}`,
|
||||
"",
|
||||
"Screenshots:",
|
||||
...screenshotPaths.map((p) => `- ${p}`),
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
await writeFile(reportPath, report, "utf-8");
|
||||
console.log(`Document History UI test passed.`);
|
||||
console.log(`Report: ${reportPath}`);
|
||||
console.log(`Screenshots: ${screenshotDir}`);
|
||||
} finally {
|
||||
if (session) await session.app.stop();
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const focusedScenarios = new Set([
|
||||
"onboarding-invitation",
|
||||
"dialog-mounts",
|
||||
"revision-repair",
|
||||
"document-history-nav",
|
||||
"settings-ui",
|
||||
"review-harness",
|
||||
"p2p-pane",
|
||||
|
||||
@@ -12,6 +12,10 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Improved
|
||||
|
||||
- Downloaded document batches retain best-effort screen-awake and lifecycle protection until every queued file has been applied to local storage, without extending the remote-activity indicator.
|
||||
|
||||
## 1.0.1
|
||||
|
||||
29th July, 2026
|
||||
|
||||
Reference in New Issue
Block a user