Files
obsidian-livesync/src/modules/essentialObsidian/ModuleObsidianEvents.unit.spec.ts
T
Ouyang Xingyuan 0df1f57f3b fix: keep replicated document application active
Why:
- Large downloaded batches can outlive the finite remote operation and be suspended before every document reaches the Vault.
- The v1.0 activity contract requires local application to remain distinct from remote-operation reporting.

Changes:
- Add an Obsidian-owned local application activity boundary which reuses Wake Lock policy without incrementing remote activity.
- Retain one boundary through queue processing and the final recovery snapshot, releasing and reacquiring it around suspension.
- Log final snapshot failures, preserve processing results, and cover the lifecycle with focused tests and documentation.
2026-07-31 11:06:18 +08:00

316 lines
13 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
type SetupOptions = {
settings?: Partial<typeof DEFAULT_SETTINGS>;
hidden: boolean;
isLastHidden?: boolean;
hasFocus?: boolean;
isSuspended?: boolean;
// Platform is read via services.API.isMobile(); default desktop (false) so the feature applies.
isMobile?: boolean;
};
function setup(opts: SetupOptions) {
const appLifecycle = {
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => opts.isSuspended ?? false),
onSuspending: vi.fn(async () => true),
onResuming: vi.fn(async () => true),
onResumed: vi.fn(async () => true),
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0);
const core = {
_services: {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
isMobile: vi.fn(() => opts.isMobile ?? false),
},
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
},
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
isConfigured: true,
...opts.settings,
},
} as any;
Object.defineProperty(core, "services", { get: () => core._services });
const module = new ModuleObsidianEvents({} as any, core);
module.isLastHidden = opts.isLastHidden ?? false;
module.hasFocus = opts.hasFocus ?? true;
// The handler reads `activeWindow.document.hidden`.
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
return {
module,
appLifecycle,
fileProcessing,
boundedRemoteActivityCount,
boundedLocalApplicationActivityCount,
};
}
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
afterEach(() => {
// The handler reads a global `activeWindow`; clear it so it doesn't leak into sibling spec
// files running in the same worker.
delete (globalThis as any).activeWindow;
});
it("does NOT suspend on hide when enabled in LiveSync mode on the desktop app", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
});
it("suspends on hide by default (setting off)", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
it("defers desktop suspension while bounded remote activity is running", async () => {
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
hidden: true,
});
boundedRemoteActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
});
it("suspends a hidden desktop window after the final bounded remote activity ends", async () => {
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
hidden: true,
});
boundedRemoteActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
boundedRemoteActivityCount.value = 0;
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 },
hidden: true,
isMobile: true,
});
boundedRemoteActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
boundedRemoteActivityCount.value = 0;
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("records deferred suspension while rebuild file watching is suspended", async () => {
const { module, appLifecycle, boundedRemoteActivityCount, fileProcessing } = setup({
settings: { suspendFileWatching: true },
hidden: true,
});
boundedRemoteActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(fileProcessing.commitPendingFileEvents).not.toHaveBeenCalled();
boundedRemoteActivityCount.value = 0;
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("resumes after a hidden rebuild finishes and the window becomes visible", async () => {
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
settings: { suspendFileWatching: true },
hidden: true,
});
boundedRemoteActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
boundedRemoteActivityCount.value = 0;
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
(module.settings as typeof DEFAULT_SETTINGS).suspendFileWatching = false;
(globalThis as any).activeWindow.document.hidden = false;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("does not resume when the window becomes visible before deferred suspension runs", async () => {
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
hidden: true,
});
boundedRemoteActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
(globalThis as any).activeWindow.document.hidden = false;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResuming).not.toHaveBeenCalled();
expect(appLifecycle.onResumed).not.toHaveBeenCalled();
boundedRemoteActivityCount.value = 0;
await Promise.resolve();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResuming).not.toHaveBeenCalled();
expect(appLifecycle.onResumed).not.toHaveBeenCalled();
});
it("forces onSuspending before the resume on becoming visible when enabled (LiveSync teardown)", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: false,
isLastHidden: true, // hidden -> visible transition
});
await module.watchWindowVisibilityAsync();
// Decision-logic only: on visible + enabled + LiveSync the handler calls onSuspending (the
// forced teardown) before onResuming. The actual stalled-channel replacement is exercised by
// the manual integration test, not here.
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
expect(appLifecycle.onSuspending.mock.invocationCallOrder[0]).toBeLessThan(
appLifecycle.onResuming.mock.invocationCallOrder[0]
);
});
it("defers the LiveSync teardown on becoming visible until bounded remote activity ends", async () => {
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: false,
isLastHidden: true,
});
boundedRemoteActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResuming).not.toHaveBeenCalled();
expect(appLifecycle.onResumed).not.toHaveBeenCalled();
boundedRemoteActivityCount.value = 0;
await vi.waitFor(() => expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1));
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
expect(appLifecycle.onSuspending.mock.invocationCallOrder[0]).toBeLessThan(
appLifecycle.onResuming.mock.invocationCallOrder[0]
);
});
it("does not force a teardown on becoming visible by default (setting off)", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: false,
isLastHidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("does not apply in On-Events mode even if the flag is set (no scope leak)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: false,
},
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
it("does NOT suspend on hide when enabled in Periodic mode (the periodic timer also stalls otherwise)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: true,
},
hidden: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
});
it("does NOT force a teardown on becoming visible in Periodic mode (only the continuous channel can stall)", async () => {
const { module, appLifecycle } = setup({
settings: {
keepReplicationActiveInBackground: true,
liveSync: false,
periodicReplication: true,
},
hidden: false,
isLastHidden: true,
});
await module.watchWindowVisibilityAsync();
// The teardown is gated on liveSync: a periodic timer doesn't go half-open, so bouncing it
// on every restore would be needless churn. Resume still runs normally.
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1);
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("does not apply on mobile even if the flag is set", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
hidden: true,
isMobile: true,
});
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
});