mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-30 08:23:01 +00:00
refactor: compose browser application runtimes
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import type {
|
||||
ComponentHasResult,
|
||||
DialogHostProps,
|
||||
SvelteDialogManagerDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { Component } from "svelte";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SvelteDialogSession,
|
||||
type SvelteDialogRenderer,
|
||||
type SvelteDialogSurface,
|
||||
} from "@/modules/services/SvelteDialogSession";
|
||||
|
||||
describe("SvelteDialogSession", () => {
|
||||
it("mounts, resolves the selected result, and unmounts through the owning surface", async () => {
|
||||
const context = createServiceContext();
|
||||
const replaceChildren = vi.fn();
|
||||
const setTitle = vi.fn();
|
||||
const close = vi.fn();
|
||||
const mounted = {};
|
||||
const unmount = vi.fn().mockResolvedValue(undefined);
|
||||
let mountedProps: DialogHostProps<string, string> | undefined;
|
||||
const renderer: SvelteDialogRenderer = {
|
||||
mount(_dialogHost, _target, props) {
|
||||
mountedProps = props as DialogHostProps<string, string>;
|
||||
return mounted as never;
|
||||
},
|
||||
unmount,
|
||||
};
|
||||
const session = new SvelteDialogSession({
|
||||
surface: {
|
||||
contentEl: { replaceChildren } as unknown as HTMLElement,
|
||||
close,
|
||||
setTitle,
|
||||
},
|
||||
context,
|
||||
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
|
||||
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
|
||||
component: (() => {}) as unknown as ComponentHasResult<string, string>,
|
||||
initialData: "initial",
|
||||
renderer,
|
||||
});
|
||||
|
||||
session.onOpen();
|
||||
const result = session.waitForClose();
|
||||
mountedProps?.setTitle("Dialogue title");
|
||||
mountedProps?.setResult("selected");
|
||||
session.onClose();
|
||||
|
||||
await expect(result).resolves.toBe("selected");
|
||||
expect(replaceChildren).toHaveBeenCalledOnce();
|
||||
expect(setTitle).toHaveBeenCalledWith("Dialogue title");
|
||||
const getInitialData = mountedProps?.getInitialData;
|
||||
expect(getInitialData).toBeTypeOf("function");
|
||||
expect(getInitialData?.()).toBe("initial");
|
||||
await vi.waitFor(() => expect(unmount).toHaveBeenCalledWith(mounted));
|
||||
});
|
||||
|
||||
it("rejects the pending result and closes the surface when the owning context unloads", async () => {
|
||||
const context = createServiceContext();
|
||||
const close = vi.fn();
|
||||
const session = new SvelteDialogSession({
|
||||
surface: {
|
||||
contentEl: { replaceChildren: vi.fn() } as unknown as HTMLElement,
|
||||
close,
|
||||
setTitle: vi.fn(),
|
||||
},
|
||||
context,
|
||||
dependencies: {} as SvelteDialogManagerDependencies<typeof context>,
|
||||
dialogHost: (() => {}) as unknown as Component<DialogHostProps>,
|
||||
component: (() => {}) as unknown as ComponentHasResult<string, string>,
|
||||
renderer: {
|
||||
mount: () => ({}) as never,
|
||||
unmount: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
session.onOpen();
|
||||
const result = session.waitForClose();
|
||||
context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
|
||||
await expect(result).rejects.toThrow("Plug-in unloaded");
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { LOG_LEVEL_INFO, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LiveSyncBrowserServiceHubOptions } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => {
|
||||
const addLog = vi.fn();
|
||||
const cleanup = vi.fn();
|
||||
const currentSettings = vi.fn();
|
||||
const getFiles = vi.fn();
|
||||
const onLoad = vi.fn();
|
||||
const onReady = vi.fn();
|
||||
const onUnload = vi.fn();
|
||||
const scanVault = vi.fn();
|
||||
const scanDirectory = vi.fn();
|
||||
const clearCache = vi.fn();
|
||||
const collectFilesOnStorage = vi.fn();
|
||||
const updateToDatabase = vi.fn();
|
||||
const p2p = {
|
||||
replicator: {},
|
||||
};
|
||||
|
||||
const serviceHub = {
|
||||
API: { addLog },
|
||||
control: { onLoad, onReady, onUnload },
|
||||
setting: { currentSettings },
|
||||
vault: { scanVault },
|
||||
};
|
||||
const platformModules = {
|
||||
fileHandler: {},
|
||||
storageAccess: {},
|
||||
storageEventManager: { cleanup },
|
||||
vaultAccess: {
|
||||
fsapiAdapter: {
|
||||
clearCache,
|
||||
getFiles,
|
||||
scanDirectory,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
addLog,
|
||||
options: undefined as LiveSyncBrowserServiceHubOptions<never> | undefined,
|
||||
clearCache,
|
||||
cleanup,
|
||||
collectFilesOnStorage,
|
||||
currentSettings,
|
||||
getFiles,
|
||||
onLoad,
|
||||
onReady,
|
||||
onUnload,
|
||||
platformModules,
|
||||
p2p,
|
||||
scanDirectory,
|
||||
scanVault,
|
||||
serviceHub,
|
||||
updateToDatabase,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/apps/browser/createLiveSyncBrowserServiceHub", () => ({
|
||||
createLiveSyncBrowserServiceHub: vi.fn((options: LiveSyncBrowserServiceHubOptions<never>) => {
|
||||
runtimeMocks.options = options;
|
||||
return runtimeMocks.serviceHub;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/FSAPIServiceModules", () => ({
|
||||
initialiseServiceModulesFSAPI: vi.fn(() => runtimeMocks.platformModules),
|
||||
}));
|
||||
|
||||
vi.mock("@/LiveSyncBaseCore", () => ({
|
||||
LiveSyncBaseCore: class {
|
||||
readonly serviceModules;
|
||||
readonly services;
|
||||
|
||||
constructor(
|
||||
services: typeof runtimeMocks.serviceHub,
|
||||
initialisePlatformModules: (core: unknown, serviceHub: typeof runtimeMocks.serviceHub) => unknown,
|
||||
_initialiseCoreModules: () => unknown[],
|
||||
_getAddOns: () => unknown[],
|
||||
initialiseFeatures: (core: unknown) => void
|
||||
) {
|
||||
this.services = services;
|
||||
this.serviceModules = initialisePlatformModules(this, services);
|
||||
initialiseFeatures(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", () => ({
|
||||
collectFilesOnStorage: runtimeMocks.collectFilesOnStorage,
|
||||
updateToDatabase: runtimeMocks.updateToDatabase,
|
||||
useOfflineScanner: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/serviceFeatures/redFlag", () => ({
|
||||
useRedFlagFeatures: vi.fn(),
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize", () => ({
|
||||
useCheckRemoteSize: vi.fn(),
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", () => ({
|
||||
useRemoteConfiguration: vi.fn(),
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature", () => ({
|
||||
useP2PReplicatorFeature: vi.fn(() => runtimeMocks.p2p),
|
||||
}));
|
||||
|
||||
import { WebAppRuntime } from "@/apps/webapp/WebAppRuntime";
|
||||
|
||||
const unconfiguredSettings = {
|
||||
couchDB_DBNAME: "",
|
||||
isConfigured: false,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
|
||||
function createRootHandle(name = "runtime-vault"): FileSystemDirectoryHandle {
|
||||
return { name } as FileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
async function waitForMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("WebAppRuntime lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.options = undefined;
|
||||
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
|
||||
runtimeMocks.getFiles.mockResolvedValue([{ path: "one.md" }]);
|
||||
runtimeMocks.onLoad.mockResolvedValue(true);
|
||||
runtimeMocks.onReady.mockResolvedValue(undefined);
|
||||
runtimeMocks.onUnload.mockResolvedValue(undefined);
|
||||
runtimeMocks.cleanup.mockResolvedValue(undefined);
|
||||
runtimeMocks.clearCache.mockReturnValue(undefined);
|
||||
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
|
||||
storageFileNameMap: {
|
||||
"one.md": {
|
||||
path: "one.md",
|
||||
stat: { ctime: 1, mtime: 1, size: 3, type: "file" },
|
||||
},
|
||||
},
|
||||
storageFileNames: ["one.md"],
|
||||
storageFileNameCI2CS: { "one.md": "one.md" },
|
||||
});
|
||||
runtimeMocks.scanDirectory.mockResolvedValue(undefined);
|
||||
runtimeMocks.scanVault.mockResolvedValue(false);
|
||||
runtimeMocks.updateToDatabase.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("owns core start, directory scan, status reporting, and shutdown", async () => {
|
||||
const reportStatus = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
|
||||
|
||||
await runtime.start();
|
||||
|
||||
expect(runtimeMocks.onLoad).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.onReady).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.getFiles).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.addLog).toHaveBeenCalledWith("Found 1 files", expect.anything(), "scan");
|
||||
expect(reportStatus).toHaveBeenCalledWith(
|
||||
"warning",
|
||||
"Warning: Please configure CouchDB connection in settings"
|
||||
);
|
||||
expect(runtime.p2pPaneHost.services).toBe(runtimeMocks.serviceHub);
|
||||
expect(runtime.p2pPaneHost.p2p).toBe(runtimeMocks.p2p);
|
||||
expect(runtime.p2pPaneHost.showPeerMenu).toBeUndefined();
|
||||
expect("p2pController" in runtime).toBe(false);
|
||||
|
||||
await runtime.shutdown();
|
||||
|
||||
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("imports local files for optional P2P while the main remote remains unconfigured", async () => {
|
||||
const runtime = new WebAppRuntime(createRootHandle());
|
||||
await runtime.start();
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.currentSettings.mockReturnValue(unconfiguredSettings);
|
||||
runtimeMocks.collectFilesOnStorage.mockResolvedValue({
|
||||
storageFileNameMap: {
|
||||
"one.md": {
|
||||
path: "one.md",
|
||||
stat: { ctime: 1, mtime: 1, size: 3, type: "file" },
|
||||
},
|
||||
},
|
||||
storageFileNames: ["one.md"],
|
||||
storageFileNameCI2CS: { "one.md": "one.md" },
|
||||
});
|
||||
|
||||
await expect(runtime.scanLocalFiles()).resolves.toBe(true);
|
||||
|
||||
expect(runtimeMocks.clearCache).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.collectFilesOnStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
services: runtimeMocks.serviceHub,
|
||||
serviceModules: runtimeMocks.platformModules,
|
||||
}),
|
||||
unconfiguredSettings,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(runtimeMocks.updateToDatabase).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.scanVault).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.currentSettings()).toBe(unconfiguredSettings);
|
||||
expect(unconfiguredSettings.isConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a failed core start after cleaning up the partial runtime", async () => {
|
||||
runtimeMocks.onLoad.mockResolvedValue(false);
|
||||
const reportStatus = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { reportStatus });
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("Failed to initialise LiveSync");
|
||||
|
||||
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
|
||||
expect(reportStatus).toHaveBeenCalledWith(
|
||||
"error",
|
||||
"Error: Failed to start: Error: Failed to initialise LiveSync"
|
||||
);
|
||||
});
|
||||
|
||||
it("shuts down once before scheduling a host reload", async () => {
|
||||
const scheduleReload = vi.fn();
|
||||
const runtime = new WebAppRuntime(createRootHandle(), { scheduleReload });
|
||||
await runtime.start();
|
||||
|
||||
runtimeMocks.options?.restart?.schedule();
|
||||
runtimeMocks.options?.restart?.schedule();
|
||||
await waitForMicrotasks();
|
||||
|
||||
expect(runtimeMocks.options?.restart?.isScheduled?.()).toBe(true);
|
||||
expect(runtimeMocks.cleanup).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.onUnload).toHaveBeenCalledOnce();
|
||||
expect(scheduleReload).toHaveBeenCalledOnce();
|
||||
expect(scheduleReload).toHaveBeenCalledWith(1_000);
|
||||
expect(runtimeMocks.addLog).toHaveBeenCalledWith("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
WEBPEER_SETTINGS_KEY,
|
||||
createWebPeerPersistence,
|
||||
} from "@/apps/webpeer/src/WebPeerPersistence";
|
||||
|
||||
function createMemoryStore(initial: Record<string, unknown> = {}): SimpleStore<unknown> {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
db: Promise.resolve(undefined),
|
||||
get: async (key) => values.get(key),
|
||||
set: async (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: async (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
keys: async (from, to, count) => {
|
||||
const selected = [...values.keys()]
|
||||
.sort()
|
||||
.filter((key) => (from === undefined || key >= from) && (to === undefined || key <= to));
|
||||
return count === undefined ? selected : selected.slice(0, count);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("WebPeer settings persistence", () => {
|
||||
it("loads P2P-only defaults and saves the complete settings object", async () => {
|
||||
const store = createMemoryStore({
|
||||
[WEBPEER_SETTINGS_KEY]: {
|
||||
P2P_AppID: "custom-app",
|
||||
P2P_roomID: "room-42",
|
||||
P2P_Enabled: true,
|
||||
},
|
||||
});
|
||||
const persistence = createWebPeerPersistence(store);
|
||||
|
||||
const loaded = await persistence.settings.load();
|
||||
|
||||
expect(loaded).toEqual(
|
||||
expect.objectContaining({
|
||||
P2P_AppID: "custom-app",
|
||||
P2P_roomID: "room-42",
|
||||
P2P_Enabled: true,
|
||||
remoteType: REMOTE_P2P,
|
||||
isConfigured: true,
|
||||
additionalSuffixOfDatabaseName: "",
|
||||
suspendParseReplicationResult: true,
|
||||
})
|
||||
);
|
||||
const updated = { ...loaded!, P2P_AutoStart: true };
|
||||
await persistence.settings.save(updated);
|
||||
await expect(store.get(WEBPEER_SETTINGS_KEY)).resolves.toEqual(updated);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WebPeerRuntime } from "@/apps/webpeer/src/WebPeerRuntime";
|
||||
|
||||
function createMemoryStore(): SimpleStore<unknown> {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
db: Promise.resolve(undefined),
|
||||
get: async (key) => values.get(key),
|
||||
set: async (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete: async (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
keys: async () => [...values.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
describe("WebPeer runtime composition", () => {
|
||||
it("preserves one context and live P2P result across the service graph and pane host", () => {
|
||||
const context = createServiceContext({
|
||||
translate: (key) => `webpeer:${key}`,
|
||||
});
|
||||
const runtime = new WebPeerRuntime({
|
||||
context,
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
|
||||
expect(runtime.context).toBe(context);
|
||||
expect(runtime.services.context).toBe(context);
|
||||
expect(runtime.events).toBe(context.events);
|
||||
expect(runtime.currentReplicator).toBe(runtime.p2p.replicator);
|
||||
expect(runtime.paneHost.services).toBe(runtime.services);
|
||||
expect(runtime.paneHost.p2p).toBe(runtime.p2p);
|
||||
expect(runtime.paneHost.showPeerMenu).toBeTypeOf("function");
|
||||
expect("controller" in runtime).toBe(false);
|
||||
});
|
||||
|
||||
it("loads settings and opens the local database before announcing layout readiness", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
const loadSettings = vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
P2P_AutoStart: false,
|
||||
P2P_Enabled: false,
|
||||
});
|
||||
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(true);
|
||||
const markIsReady = vi.spyOn(runtime.services.appLifecycle, "markIsReady");
|
||||
const layoutReady = vi.fn();
|
||||
runtime.events.onEvent(EVENT_LAYOUT_READY, layoutReady);
|
||||
|
||||
await expect(runtime.start()).resolves.toBe(runtime);
|
||||
|
||||
expect(loadSettings).toHaveBeenCalledOnce();
|
||||
expect(openDatabase).toHaveBeenCalledOnce();
|
||||
expect(markIsReady).toHaveBeenCalledOnce();
|
||||
expect(layoutReady).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects start when the browser-local database cannot be opened", async () => {
|
||||
const runtime = new WebPeerRuntime({
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
|
||||
vi.spyOn(runtime.services.database, "openDatabase").mockResolvedValue(false);
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user