mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-10 05:35:45 +00:00
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.
316 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|