mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 14:58:18 +00:00
Merge shared runtime changes onto current main
This commit is contained in:
commit
6211597d2d
128 files changed
+9204
-1285
No files matched your search
@@ -31,6 +31,7 @@ import { useReplicationScheduling, type ReplicationSchedulingControl } from "./s
|
||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
@@ -290,7 +291,11 @@ export class LiveSyncBaseCore<
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useConflictResolutionFeature(this);
|
||||
useConflictResolutionFeature({
|
||||
services: this.services,
|
||||
serviceModules: this.serviceModules,
|
||||
log: createInstanceLogFunction("SF:ConflictResolution", this.services.API),
|
||||
});
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
@@ -303,7 +308,7 @@ export class LiveSyncBaseCore<
|
||||
}
|
||||
|
||||
export interface IMinimumLiveSyncCommands {
|
||||
onunload(): void;
|
||||
onunload(): void | Promise<void>;
|
||||
onload(): void | Promise<void>;
|
||||
constructor: { name: string };
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
|
||||
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
|
||||
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
/**
|
||||
* Compatibility facade consumed by Commonlib while browser presentation is
|
||||
@@ -14,26 +13,26 @@ import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
|
||||
readonly context: T;
|
||||
|
||||
constructor(context: T) {
|
||||
const dialogueController = new AbortController();
|
||||
constructor(context: T, ui: BrowserUiScope = createBrowserUiScope(context)) {
|
||||
const notifications = new BrowserUiNotifications({
|
||||
document: _activeDocument,
|
||||
document: ui.document,
|
||||
});
|
||||
super({
|
||||
ui: createBrowserUi({
|
||||
document: _activeDocument,
|
||||
signal: dialogueController.signal,
|
||||
document: ui.document,
|
||||
signal: ui.signal,
|
||||
renderMarkdown: ({ container, markdown }) => {
|
||||
renderMessageMarkdownInto(container, markdown);
|
||||
},
|
||||
}),
|
||||
notifications,
|
||||
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
|
||||
createActionAnchor: () => createNativeElement(ui.document, "a"),
|
||||
});
|
||||
this.context = context;
|
||||
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
dialogueController.abort();
|
||||
if (ui.signal.aborted) {
|
||||
notifications.dispose();
|
||||
});
|
||||
} else {
|
||||
ui.signal.addEventListener("abort", () => notifications.dispose(), { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { mount } from "svelte";
|
||||
import type { BrowserUiOptions } from "./BrowserUiScope";
|
||||
import { mount, unmount } from "svelte";
|
||||
import MenuView from "./ui/MenuView.svelte";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type MountedMenu = ReturnType<typeof mount>;
|
||||
type MenuSession = {
|
||||
waitingForClose: PromiseWithResolvers<void>;
|
||||
element: HTMLElement;
|
||||
component?: MountedMenu;
|
||||
abortListener?: () => void;
|
||||
closed: boolean;
|
||||
};
|
||||
|
||||
export class MenuItem {
|
||||
type = "item";
|
||||
title = "";
|
||||
@@ -29,7 +39,15 @@ export class Menu {
|
||||
type = "menu";
|
||||
items: (MenuItem | MenuSeparator)[] = [];
|
||||
|
||||
constructor() {}
|
||||
private readonly document: Document;
|
||||
private readonly signal?: AbortSignal;
|
||||
private activeSession?: MenuSession;
|
||||
|
||||
constructor(options: BrowserUiOptions = {}) {
|
||||
this.document = options.document ?? _activeDocument;
|
||||
this.signal = options.signal;
|
||||
}
|
||||
|
||||
addItem(callback: (item: MenuItem) => void) {
|
||||
const item = new MenuItem();
|
||||
callback(item);
|
||||
@@ -41,32 +59,75 @@ export class Menu {
|
||||
return this;
|
||||
}
|
||||
waitingForClose?: PromiseWithResolvers<void>;
|
||||
showAtPosition(pos: { x: number; y: number }) {
|
||||
const el = createNativeElement(_activeDocument, "div");
|
||||
if (this.waitingForClose) {
|
||||
this.waitingForClose.resolve();
|
||||
|
||||
showAtPosition(pos: { x: number; y: number }): Promise<void> {
|
||||
if (this.activeSession) {
|
||||
this.closeSession(this.activeSession);
|
||||
}
|
||||
this.waitingForClose = promiseWithResolvers<void>();
|
||||
mount(MenuView, {
|
||||
target: el,
|
||||
props: {
|
||||
items: this.items,
|
||||
closeMenu: () => {
|
||||
this.waitingForClose?.resolve();
|
||||
this.waitingForClose = undefined;
|
||||
if (this.signal?.aborted) {
|
||||
this.waitingForClose = undefined;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const waitingForClose = promiseWithResolvers<void>();
|
||||
const element = createNativeElement(this.document, "div");
|
||||
const session: MenuSession = {
|
||||
waitingForClose,
|
||||
element,
|
||||
closed: false,
|
||||
};
|
||||
this.activeSession = session;
|
||||
this.waitingForClose = waitingForClose;
|
||||
|
||||
const closeMenu = () => this.closeSession(session);
|
||||
session.abortListener = closeMenu;
|
||||
this.signal?.addEventListener("abort", closeMenu, { once: true });
|
||||
|
||||
try {
|
||||
session.component = mount(MenuView, {
|
||||
target: element,
|
||||
props: {
|
||||
items: this.items,
|
||||
closeMenu,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
},
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
},
|
||||
});
|
||||
_activeDocument.body.appendChild(el);
|
||||
void this.waitingForClose.promise.finally(() => {
|
||||
el.remove();
|
||||
});
|
||||
return this.waitingForClose.promise;
|
||||
});
|
||||
if (!session.closed) {
|
||||
this.document.body.appendChild(element);
|
||||
} else if (session.component) {
|
||||
fireAndForget(() => unmount(session.component!));
|
||||
}
|
||||
} catch (error) {
|
||||
this.closeSession(session);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return waitingForClose.promise;
|
||||
}
|
||||
|
||||
private closeSession(session: MenuSession): void {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
if (session.abortListener) {
|
||||
this.signal?.removeEventListener("abort", session.abortListener);
|
||||
session.abortListener = undefined;
|
||||
}
|
||||
if (this.activeSession === session) {
|
||||
this.activeSession = undefined;
|
||||
this.waitingForClose = undefined;
|
||||
}
|
||||
|
||||
session.element.remove();
|
||||
if (session.component) {
|
||||
fireAndForget(() => unmount(session.component!));
|
||||
}
|
||||
session.waitingForClose.resolve();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.waitingForClose?.resolve();
|
||||
this.waitingForClose = undefined;
|
||||
if (this.activeSession) {
|
||||
this.closeSession(this.activeSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
|
||||
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
|
||||
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
export class BrowserModal {
|
||||
contentEl: HTMLElement;
|
||||
@@ -15,14 +16,14 @@ export class BrowserModal {
|
||||
modalEl: HTMLElement;
|
||||
isOpen: boolean = false;
|
||||
baseEl: HTMLElement;
|
||||
constructor() {
|
||||
const baseEl = createNativeElement(_activeDocument, "popup");
|
||||
constructor(document: Document = _activeDocument) {
|
||||
const baseEl = createNativeElement(document, "popup");
|
||||
this.baseEl = baseEl;
|
||||
this.contentEl = createNativeElement(_activeDocument, "div");
|
||||
this.contentEl = createNativeElement(document, "div");
|
||||
this.contentEl.className = "modal-content";
|
||||
this.titleEl = createNativeElement(_activeDocument, "div");
|
||||
this.titleEl = createNativeElement(document, "div");
|
||||
this.titleEl.className = "modal-title";
|
||||
this.modalEl = createNativeElement(_activeDocument, "div");
|
||||
this.modalEl = createNativeElement(document, "div");
|
||||
this.modalEl.className = "modal";
|
||||
this.modalEl.hidden = true;
|
||||
this.modalEl.appendChild(this.titleEl);
|
||||
@@ -33,7 +34,7 @@ export class BrowserModal {
|
||||
this.isOpen = true;
|
||||
this.modalEl.hidden = false;
|
||||
if (!this.baseEl.parentElement) {
|
||||
_activeDocument.body.appendChild(this.baseEl);
|
||||
this.baseEl.ownerDocument.body.appendChild(this.baseEl);
|
||||
}
|
||||
this.onOpen();
|
||||
}
|
||||
@@ -57,9 +58,10 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
context: C,
|
||||
dependents: SvelteDialogManagerDependencies<C>,
|
||||
component: ComponentHasResult<T, U>,
|
||||
initialData?: U
|
||||
initialData?: U,
|
||||
private readonly ui?: BrowserUiScope
|
||||
) {
|
||||
super();
|
||||
super(ui?.document ?? _activeDocument);
|
||||
this.session = new SvelteDialogSession({
|
||||
surface: this,
|
||||
context,
|
||||
@@ -67,9 +69,15 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
dialogHost: DialogHost,
|
||||
component,
|
||||
initialData,
|
||||
signal: ui?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
override open(): void {
|
||||
if (this.ui?.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||
super.open();
|
||||
}
|
||||
|
||||
override onOpen(): void {
|
||||
this.session.onOpen();
|
||||
}
|
||||
@@ -83,11 +91,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
}
|
||||
}
|
||||
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
|
||||
constructor(
|
||||
context: T,
|
||||
dependencies: SvelteDialogManagerDependencies<T>,
|
||||
private readonly ui: BrowserUiScope = createBrowserUiScope(context)
|
||||
) {
|
||||
super(context, dependencies);
|
||||
}
|
||||
|
||||
override async openSvelteDialog<TT, TU>(
|
||||
component: ComponentHasResult<TT, TU>,
|
||||
initialData?: TU
|
||||
): Promise<TT | undefined> {
|
||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(this.context, this.dependents, component, initialData);
|
||||
if (this.ui.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(
|
||||
this.context,
|
||||
this.dependents,
|
||||
component,
|
||||
initialData,
|
||||
this.ui
|
||||
);
|
||||
dialog.open();
|
||||
return await dialog.waitForClose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
export interface BrowserUiOptions {
|
||||
readonly document?: Document;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Browser presentation target and lifetime owned by one application context. */
|
||||
export interface BrowserUiScope {
|
||||
readonly document: Document;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export function createBrowserUiScope(
|
||||
context: Pick<ServiceContext, "events">,
|
||||
options: BrowserUiOptions = {}
|
||||
): BrowserUiScope {
|
||||
const controller = new AbortController();
|
||||
const parent = options.signal;
|
||||
let unregisterUnload: (() => void) | undefined;
|
||||
const end = (reason: unknown): void => {
|
||||
if (controller.signal.aborted) return;
|
||||
unregisterUnload?.();
|
||||
unregisterUnload = undefined;
|
||||
parent?.removeEventListener("abort", onParentAbort);
|
||||
controller.abort(reason);
|
||||
};
|
||||
const onParentAbort = () => end(parent?.reason);
|
||||
if (parent?.aborted) {
|
||||
controller.abort(parent.reason);
|
||||
} else {
|
||||
unregisterUnload = context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => end(new Error("Plug-in unloaded")));
|
||||
parent?.addEventListener("abort", onParentAbort, { once: true });
|
||||
}
|
||||
return { document: options.document ?? _activeDocument, signal: controller.signal };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/ser
|
||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
|
||||
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||
import type { BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||
API: InjectableAPIService<T>;
|
||||
@@ -20,15 +21,19 @@ export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIServic
|
||||
override get dialogToCopy() {
|
||||
return DialogToCopy;
|
||||
}
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>, ui?: BrowserUiScope) {
|
||||
const browserConfirm = dependents.API.confirm;
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: browserConfirm,
|
||||
control: dependents.control,
|
||||
});
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(
|
||||
context,
|
||||
{
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: browserConfirm,
|
||||
control: dependents.control,
|
||||
},
|
||||
ui
|
||||
);
|
||||
super(context, {
|
||||
dialogManager: obsidianSvelteDialogManager,
|
||||
APIService: dependents.API,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
|
||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { createBrowserUiScope, type BrowserUiOptions, type BrowserUiScope } from "./BrowserUiScope";
|
||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
@@ -41,6 +42,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
deviceValues: ValueStore & ValueDeleter;
|
||||
context?: T;
|
||||
ui?: BrowserUiOptions;
|
||||
getSystemVaultName?: () => string;
|
||||
settings?: LiveSyncBrowserSettingsPersistence;
|
||||
restart?: LiveSyncBrowserRestartPolicy;
|
||||
@@ -56,15 +58,18 @@ class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValu
|
||||
|
||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||
readonly browserUi: BrowserUiScope;
|
||||
|
||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||
const context =
|
||||
options.context ??
|
||||
(new ServiceContext({
|
||||
translate: translateLiveSyncMessage,
|
||||
}) as T);
|
||||
const browserUi = createBrowserUiScope(context, options.ui);
|
||||
const API = new LiveSyncBrowserAPIService(context, {
|
||||
...options.API,
|
||||
confirm: new BrowserConfirm(context),
|
||||
confirm: new BrowserConfirm(context, browserUi),
|
||||
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
|
||||
});
|
||||
const conflict = new InjectableConflictService(context);
|
||||
@@ -147,13 +152,17 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
APIService: API,
|
||||
replicatorService: replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(context, {
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(
|
||||
context,
|
||||
{
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
},
|
||||
browserUi
|
||||
);
|
||||
|
||||
super(context, {
|
||||
API,
|
||||
@@ -175,6 +184,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
ui,
|
||||
vault,
|
||||
});
|
||||
this.browserUi = browserUi;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export async function findCaseInsensitiveFile<T extends object>(
|
||||
requestedPath: string,
|
||||
findExact: () => Promise<T | null>,
|
||||
cachedFiles: ReadonlyMap<string, T>,
|
||||
scan: () => Promise<void>
|
||||
): Promise<T | null> {
|
||||
const exact = await findExact();
|
||||
if (exact) return exact;
|
||||
|
||||
const lowerPath = requestedPath.toLowerCase();
|
||||
const findCached = (): T | null => {
|
||||
for (const [cachedPath, cachedFile] of cachedFiles) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) return cachedFile;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cached = findCached();
|
||||
if (cached) return cached;
|
||||
|
||||
await scan();
|
||||
return findCached();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for Node.js
|
||||
@@ -87,27 +88,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
}
|
||||
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await findCaseInsensitiveFile(
|
||||
pathStr,
|
||||
() => this.getAbstractFileByPath(pathStr),
|
||||
this.fileCache,
|
||||
() => this.scanDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(): Promise<NodeFile[]> {
|
||||
|
||||
@@ -124,6 +124,83 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter case-insensitive path lookup", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createVault(files: string[] = []) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-insensitive-"));
|
||||
tempDirs.push(directory);
|
||||
for (const file of files) {
|
||||
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true });
|
||||
await fs.writeFile(path.join(directory, file), `content of ${file}`);
|
||||
}
|
||||
return { directory, adapter: new NodeFileSystemAdapter(directory) };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("returns an exact path without scanning the directory", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
await adapter.scanDirectory();
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||
});
|
||||
|
||||
it("returns null after one scan when no cached path matches", async () => {
|
||||
const { adapter } = await createVault(["other.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns null and reports a directory scan failure", async () => {
|
||||
const missingDirectory = path.join(os.tmpdir(), `livesync-cli-insensitive-missing-${Date.now()}`);
|
||||
const reportDiagnostic = vi.fn();
|
||||
const adapter = new NodeFileSystemAdapter(missingDirectory, reportDiagnostic);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(reportDiagnostic).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error scanning directory"),
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
@@ -15,30 +14,7 @@ import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
|
||||
/**
|
||||
* CLI-specific type guard adapter
|
||||
*/
|
||||
class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, NodeFolder> {
|
||||
isFile(file: unknown): file is NodeFile {
|
||||
return !!(
|
||||
file &&
|
||||
typeof file === "object" &&
|
||||
"path" in file &&
|
||||
"stat" in file &&
|
||||
!(file as { isFolder?: boolean }).isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: unknown): item is NodeFolder {
|
||||
return !!(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"path" in item &&
|
||||
(item as { isFolder?: boolean }).isFolder === true
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
|
||||
|
||||
/**
|
||||
* CLI-specific persistence adapter (file-based snapshot)
|
||||
@@ -211,7 +187,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
* Composite adapter for CLI StorageEventManager
|
||||
*/
|
||||
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
||||
readonly typeGuard: CLITypeGuardAdapter;
|
||||
readonly typeGuard: NodeTypeGuardAdapter;
|
||||
readonly persistence: CLIPersistenceAdapter;
|
||||
readonly watch: CLIWatchAdapter;
|
||||
readonly status: CLIStatusAdapter;
|
||||
@@ -223,7 +199,7 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
||||
watchEnabled: boolean = false,
|
||||
reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||
) {
|
||||
this.typeGuard = new CLITypeGuardAdapter();
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
|
||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
||||
this.status = new CLIStatusAdapter();
|
||||
|
||||
@@ -201,3 +201,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLIStorageEventManagerAdapter type guard", () => {
|
||||
const typeGuard = new CLIStorageEventManagerAdapter("/base").typeGuard;
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { stat: {} }, expected: false },
|
||||
{ label: "an object without a stat", value: { path: "note.md" }, expected: false },
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: true },
|
||||
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: false },
|
||||
])("classifies $label as a file", ({ value, expected }) => {
|
||||
expect(typeGuard.isFile(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { isFolder: true }, expected: false },
|
||||
{ label: "an object without the folder marker", value: { path: "folder" }, expected: false },
|
||||
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: true },
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: false },
|
||||
])("classifies $label as a folder", ({ value, expected }) => {
|
||||
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
@@ -10,7 +8,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { IgnoreRules } from "./IgnoreRules";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||
|
||||
/**
|
||||
* Initialize service modules for CLI version
|
||||
@@ -82,40 +80,7 @@ export function initialiseServiceModulesCLI(
|
||||
vault: services.vault,
|
||||
});
|
||||
|
||||
// File handler (platform-independent)
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||
|
||||
return {
|
||||
rebuilder,
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { defaultServerConditions, defaultServerMainFields, defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { fileURLToPath, fs, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fileURLToPath, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { readVersion } from "../readVersion";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const resolve = (...args: string[]) => path.resolve(...args).replace(/\\/g, "/");
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
|
||||
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
||||
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
||||
// https://vite.dev/config/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { fs } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
/** Read a string-valued version field from a JSON file during Node builds. */
|
||||
export function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readVersion } from "./readVersion";
|
||||
|
||||
describe("readVersion", () => {
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), "livesync-read-version-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns only string version values", () => {
|
||||
const filePath = join(directory, "version.json");
|
||||
writeFileSync(filePath, JSON.stringify({ version: "1.2.3" }));
|
||||
expect(readVersion(filePath)).toBe("1.2.3");
|
||||
|
||||
for (const contents of ["null", "[]", "{}", '{"version": 42}', '{"version": null}']) {
|
||||
writeFileSync(filePath, contents);
|
||||
expect(readVersion(filePath)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves file read and JSON parse errors", () => {
|
||||
expect(() => readVersion(join(directory, "missing.json"))).toThrow();
|
||||
|
||||
const filePath = join(directory, "invalid.json");
|
||||
writeFileSync(filePath, "not JSON");
|
||||
expect(() => readVersion(filePath)).toThrow(SyntaxError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
|
||||
/** Compose the service modules shared by app hosts after their storage setup. */
|
||||
export function composeSharedServiceModules(
|
||||
services: InjectableServiceHub<ServiceContext>,
|
||||
databaseFileAccess: DatabaseFileAccess,
|
||||
storageAccess: StorageAccess
|
||||
): Pick<ServiceModules, "fileHandler" | "rebuilder"> {
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
return { rebuilder, fileHandler };
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { initialiseServiceModulesCLI } from "@/apps/cli/serviceModules/CLIServiceModules";
|
||||
import { initialiseServiceModulesFSAPI } from "@/apps/webapp/serviceModules/FSAPIServiceModules";
|
||||
import { FILE_REFLECTION_PROVENANCE_STORE } from "@/serviceModules/FileReflectionProvenance";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
|
||||
const composition = vi.hoisted(() => ({
|
||||
calls: [] as string[],
|
||||
dependencies: {} as Record<string, unknown>,
|
||||
instances: {} as Record<string, unknown>,
|
||||
capture(name: string, dependencies: unknown, instance: unknown) {
|
||||
this.calls.push(name);
|
||||
this.dependencies[name] = dependencies;
|
||||
this.instances[name] = instance;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager", () => ({
|
||||
StorageAccessManager: class {
|
||||
constructor() {
|
||||
composition.capture("StorageAccessManager", undefined, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder", () => ({
|
||||
ServiceRebuilder: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceRebuilder", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/serviceModules/FileHandler", () => ({
|
||||
ServiceFileHandler: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileHandler", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/FileAccessCLI", () => ({
|
||||
FileAccessCLI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("FileAccessCLI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/ServiceFileAccessImpl", () => ({
|
||||
ServiceFileAccessCLI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileAccessCLI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/DatabaseFileAccess", () => ({
|
||||
ServiceDatabaseFileAccessCLI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceDatabaseFileAccessCLI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/managers/StorageEventManagerCLI", () => ({
|
||||
StorageEventManagerCLI: class {
|
||||
close = vi.fn(async () => undefined);
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("StorageEventManagerCLI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/FileAccessFSAPI", () => ({
|
||||
FileAccessFSAPI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("FileAccessFSAPI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/ServiceFileAccessImpl", () => ({
|
||||
ServiceFileAccessFSAPI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileAccessFSAPI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/DatabaseFileAccess", () => ({
|
||||
ServiceDatabaseFileAccessFSAPI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceDatabaseFileAccessFSAPI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/managers/StorageEventManagerFSAPI", () => ({
|
||||
StorageEventManagerFSAPI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("StorageEventManagerFSAPI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
function createServices() {
|
||||
const hostServices = Object.fromEntries(
|
||||
[
|
||||
"API",
|
||||
"conflict",
|
||||
"control",
|
||||
"database",
|
||||
"databaseEvents",
|
||||
"fileProcessing",
|
||||
"path",
|
||||
"remote",
|
||||
"replication",
|
||||
"replicator",
|
||||
"setting",
|
||||
"UI",
|
||||
"vault",
|
||||
].map((name) => [name, { name }])
|
||||
);
|
||||
const events = { label: "host event hub" };
|
||||
const addUnloadHandler = vi.fn<(handler: () => Promise<boolean>) => void>();
|
||||
const openSimpleStore = vi.fn((name: string) => {
|
||||
composition.calls.push("openSimpleStore");
|
||||
composition.dependencies.openSimpleStore = name;
|
||||
return { name };
|
||||
});
|
||||
|
||||
return {
|
||||
...hostServices,
|
||||
appLifecycle: { onUnload: { addHandler: addUnloadHandler } },
|
||||
context: { events },
|
||||
events,
|
||||
keyValueDB: { openSimpleStore },
|
||||
addUnloadHandler,
|
||||
};
|
||||
}
|
||||
|
||||
function createCore() {
|
||||
return {} as LiveSyncBaseCore<ServiceContext, never>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
composition.calls.length = 0;
|
||||
composition.dependencies = {};
|
||||
composition.instances = {};
|
||||
});
|
||||
|
||||
describe("app service module composition", () => {
|
||||
it("preserves CLI host construction, shared wiring, provenance, and watcher shutdown", async () => {
|
||||
const services = createServices();
|
||||
const core = createCore();
|
||||
|
||||
const modules = initialiseServiceModulesCLI(
|
||||
"/vault",
|
||||
core,
|
||||
services as unknown as InjectableServiceHub<ServiceContext>
|
||||
);
|
||||
|
||||
expect(composition.calls).toEqual([
|
||||
"StorageAccessManager",
|
||||
"FileAccessCLI",
|
||||
"StorageEventManagerCLI",
|
||||
"ServiceFileAccessCLI",
|
||||
"ServiceDatabaseFileAccessCLI",
|
||||
"openSimpleStore",
|
||||
"ServiceFileHandler",
|
||||
"ServiceRebuilder",
|
||||
]);
|
||||
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
|
||||
|
||||
const [basePath, fileAccessOptions] = composition.dependencies.FileAccessCLI as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const [eventBasePath, eventCore, eventDependencies, ignoreRules, watchEnabled] = composition.dependencies
|
||||
.StorageEventManagerCLI as [string, unknown, Record<string, unknown>, unknown, boolean];
|
||||
const storageAccessDependencies = composition.dependencies.ServiceFileAccessCLI as Record<string, unknown>;
|
||||
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessCLI as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const fileHandlerDependencies = composition.dependencies.ServiceFileHandler as Record<string, unknown>;
|
||||
const rebuilderDependencies = composition.dependencies.ServiceRebuilder as Record<string, unknown>;
|
||||
|
||||
expect(basePath).toBe("/vault");
|
||||
expect(fileAccessOptions).toEqual({
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultService: services.vault,
|
||||
settingService: services.setting,
|
||||
APIService: services.API,
|
||||
pathService: services.path,
|
||||
});
|
||||
expect(eventBasePath).toBe("/vault");
|
||||
expect(eventCore).toBe(core);
|
||||
expect(eventDependencies).toEqual({
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
APIService: services.API,
|
||||
});
|
||||
expect(ignoreRules).toBeUndefined();
|
||||
expect(watchEnabled).toBe(false);
|
||||
expect(storageAccessDependencies).toEqual({
|
||||
API: services.API,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
storageEventManager: composition.instances.StorageEventManagerCLI,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultAccess: composition.instances.FileAccessCLI,
|
||||
});
|
||||
expect(databaseAccessDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
path: services.path,
|
||||
storageAccess: modules.storageAccess,
|
||||
vault: services.vault,
|
||||
});
|
||||
expect(modules.databaseFileAccess).toBe(composition.instances.ServiceDatabaseFileAccessCLI);
|
||||
expect(modules.fileHandler).toBe(composition.instances.ServiceFileHandler);
|
||||
expect(modules.rebuilder).toBe(composition.instances.ServiceRebuilder);
|
||||
expect(fileHandlerDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: modules.databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: modules.storageAccess,
|
||||
fileReflectionProvenance: expect.any(Object),
|
||||
});
|
||||
expect(rebuilderDependencies).toEqual({
|
||||
path: services.path,
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: modules.fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: modules.storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
const unloadHandler = services.addUnloadHandler.mock.calls[0]?.[0];
|
||||
expect(unloadHandler).toBeDefined();
|
||||
await expect(unloadHandler?.()).resolves.toBe(true);
|
||||
expect(
|
||||
(composition.instances.StorageEventManagerCLI as { close: ReturnType<typeof vi.fn> }).close
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves WebApp host construction and returns its vault access and event manager", () => {
|
||||
const services = createServices();
|
||||
const core = createCore();
|
||||
const rootHandle = {} as FileSystemDirectoryHandle;
|
||||
|
||||
const modules = initialiseServiceModulesFSAPI(
|
||||
rootHandle,
|
||||
core,
|
||||
services as unknown as InjectableServiceHub<ServiceContext>
|
||||
);
|
||||
|
||||
expect(composition.calls).toEqual([
|
||||
"StorageAccessManager",
|
||||
"FileAccessFSAPI",
|
||||
"StorageEventManagerFSAPI",
|
||||
"ServiceFileAccessFSAPI",
|
||||
"ServiceDatabaseFileAccessFSAPI",
|
||||
"openSimpleStore",
|
||||
"ServiceFileHandler",
|
||||
"ServiceRebuilder",
|
||||
]);
|
||||
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
|
||||
const [fileAccessRoot, fileAccessOptions] = composition.dependencies.FileAccessFSAPI as [
|
||||
FileSystemDirectoryHandle,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const [eventRoot, eventCore, eventDependencies] = composition.dependencies.StorageEventManagerFSAPI as [
|
||||
FileSystemDirectoryHandle,
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const storageAccessDependencies = composition.dependencies.ServiceFileAccessFSAPI as Record<string, unknown>;
|
||||
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessFSAPI as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(fileAccessRoot).toBe(rootHandle);
|
||||
expect(fileAccessOptions).toEqual({
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultService: services.vault,
|
||||
settingService: services.setting,
|
||||
APIService: services.API,
|
||||
pathService: services.path,
|
||||
});
|
||||
expect(eventRoot).toBe(rootHandle);
|
||||
expect(eventCore).toBe(core);
|
||||
expect(eventDependencies).toEqual({
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
APIService: services.API,
|
||||
});
|
||||
expect(storageAccessDependencies).toEqual({
|
||||
API: services.API,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
storageEventManager: composition.instances.StorageEventManagerFSAPI,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultAccess: composition.instances.FileAccessFSAPI,
|
||||
});
|
||||
expect(databaseAccessDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
path: services.path,
|
||||
storageAccess: modules.storageAccess,
|
||||
vault: services.vault,
|
||||
});
|
||||
expect(modules.vaultAccess).toBe(composition.instances.FileAccessFSAPI);
|
||||
expect(modules.storageEventManager).toBe(composition.instances.StorageEventManagerFSAPI);
|
||||
expect(composition.dependencies.ServiceFileHandler).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: modules.databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: modules.storageAccess,
|
||||
fileReflectionProvenance: expect.any(Object),
|
||||
});
|
||||
expect(composition.dependencies.ServiceRebuilder).toEqual({
|
||||
path: services.path,
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: modules.fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: modules.storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
|
||||
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
|
||||
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for FileSystem API
|
||||
@@ -85,28 +86,12 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
|
||||
*/
|
||||
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
// TODO: Refactor: Very, Very heavy.
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await findCaseInsensitiveFile(
|
||||
pathStr,
|
||||
() => this.getAbstractFileByPath(pathStr),
|
||||
this.fileCache,
|
||||
() => this.scanDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(): Promise<FSAPIFile[]> {
|
||||
|
||||
@@ -110,6 +110,83 @@ describe("FileSystemAccessStorageAdapter", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter creation", () => {
|
||||
it.each(["text", "binary"] as const)(
|
||||
"creates nested %s files with exact content and stored metadata",
|
||||
async (kind) => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const data = new Uint8Array([0, 255, 1, 128, 42]).buffer;
|
||||
const path = "/nested//folder/file.dat";
|
||||
const file =
|
||||
kind === "text" ? await adapter.create(path, "日本語😀") : await adapter.createBinary(path, data);
|
||||
const expected = kind === "text" ? new TextEncoder().encode("日本語😀") : new Uint8Array(data);
|
||||
const nested = await root.getDirectoryHandle("nested");
|
||||
const folder = await nested.getDirectoryHandle("folder");
|
||||
const stored = await folder.getFileHandle("file.dat");
|
||||
expect(file.path).toBe(path);
|
||||
expect(file.handle).toBe(stored);
|
||||
expect(new Uint8Array(await adapter.readBinary(file))).toEqual(expected);
|
||||
expect(file.stat).toEqual({ size: expected.byteLength, mtime: 1, ctime: 1, type: "file" });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["text", "binary"] as const)("waits for the %s writer to close before reading metadata", async (kind) => {
|
||||
let releaseClose!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
const events: string[] = [];
|
||||
const getFile = vi.fn(async () => {
|
||||
events.push("metadata");
|
||||
return new File(["result"], "file.dat", { lastModified: 123 });
|
||||
});
|
||||
const close = vi.fn(async () => {
|
||||
events.push("close-start");
|
||||
await closed;
|
||||
events.push("closed");
|
||||
});
|
||||
const write = vi.fn(async () => {
|
||||
events.push("write");
|
||||
});
|
||||
const fileHandle = { createWritable: vi.fn(async () => ({ write, close })), getFile };
|
||||
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const data = new Uint8Array([0, 255]).buffer;
|
||||
const pending = kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", data);
|
||||
try {
|
||||
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseClose();
|
||||
}
|
||||
const file = await pending;
|
||||
expect(events).toEqual(["write", "close-start", "closed", "metadata"]);
|
||||
expect(write).toHaveBeenCalledWith(kind === "text" ? "text" : data);
|
||||
expect(file.stat).toEqual({ size: 6, ctime: 123, mtime: 123, type: "file" });
|
||||
});
|
||||
|
||||
it.each(["text", "binary"] as const)("propagates a %s write failure without returning metadata", async (kind) => {
|
||||
const failure = new Error("fixture write failed");
|
||||
const getFile = vi.fn();
|
||||
const fileHandle = {
|
||||
createWritable: vi.fn(async () => ({
|
||||
write: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
getFile,
|
||||
};
|
||||
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const pending =
|
||||
kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", new ArrayBuffer(2));
|
||||
await expect(pending).rejects.toBe(failure);
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter.rename", () => {
|
||||
it("moves the file through a temporary copy while preserving content", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root");
|
||||
@@ -142,6 +219,20 @@ describe("FSAPIVaultAdapter.rename", () => {
|
||||
});
|
||||
|
||||
describe("FSAPIFileSystemAdapter path case", () => {
|
||||
it("returns an exact path without scanning the directory", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the stored path case from a case-insensitive file system", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root", true);
|
||||
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
|
||||
@@ -155,6 +246,48 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
await adapter.scanDirectory();
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||
});
|
||||
|
||||
it("returns null after one scan when no cached path matches", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("other.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports scan failures through the injected Webapp log", async () => {
|
||||
const root = {
|
||||
name: "root",
|
||||
@@ -173,4 +306,25 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
||||
"fsapi-scan"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null and reports scan failure during case-insensitive lookup", async () => {
|
||||
const root = {
|
||||
name: "root",
|
||||
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
||||
throw new Error("scan failed");
|
||||
},
|
||||
} as unknown as FileSystemDirectoryHandle;
|
||||
const addLog = vi.fn();
|
||||
const adapter = new FSAPIFileSystemAdapter(root, addLog);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(addLog).toHaveBeenCalledWith(
|
||||
"Error scanning directory '.': Error: scan failed",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"fsapi-scan"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
|
||||
}
|
||||
|
||||
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Navigate to parent directory, creating as needed
|
||||
let currentHandle = this.rootHandle;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
|
||||
}
|
||||
|
||||
// Create the file
|
||||
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
|
||||
// Get file metadata
|
||||
const fileObject = await fileHandle.getFile();
|
||||
|
||||
return {
|
||||
path: p as FilePath,
|
||||
stat: {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
},
|
||||
handle: fileHandle,
|
||||
};
|
||||
return await this.createFile(p, data);
|
||||
}
|
||||
|
||||
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
return await this.createFile(p, data);
|
||||
}
|
||||
|
||||
private async createFile(p: string, data: string | ArrayBuffer): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
@@ -14,6 +13,7 @@ import type { FSAPIFile, FSAPIFolder } from "@/apps/webapp/adapters/FSAPITypes";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||
import { FSAPITypeGuardAdapter } from "@/apps/webapp/adapters/FSAPITypeGuardAdapter";
|
||||
|
||||
type FileSystemObserverRecord = {
|
||||
changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle;
|
||||
@@ -34,32 +34,6 @@ type GlobalWithFileSystemObserver = typeof compatGlobal & {
|
||||
FileSystemObserver?: FileSystemObserverConstructor;
|
||||
};
|
||||
|
||||
/**
|
||||
* FileSystem API-specific type guard adapter
|
||||
*/
|
||||
class FSAPITypeGuardAdapter implements IStorageEventTypeGuardAdapter<FSAPIFile, FSAPIFolder> {
|
||||
isFile(file: unknown): file is FSAPIFile {
|
||||
return !!(
|
||||
file &&
|
||||
typeof file === "object" &&
|
||||
"path" in file &&
|
||||
"stat" in file &&
|
||||
"handle" in file &&
|
||||
!(file as { isFolder?: boolean }).isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: unknown): item is FSAPIFolder {
|
||||
return !!(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"path" in item &&
|
||||
(item as { isFolder?: boolean }).isFolder === true &&
|
||||
"handle" in item
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystem API-specific persistence adapter (IndexedDB-based snapshot)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FSAPIStorageEventManagerAdapter } from "./FSAPIStorageEventManagerAdapter";
|
||||
|
||||
describe("FSAPIStorageEventManagerAdapter type guard", () => {
|
||||
const typeGuard = new FSAPIStorageEventManagerAdapter({} as FileSystemDirectoryHandle, vi.fn()).typeGuard;
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { stat: {}, handle: undefined }, expected: false },
|
||||
{ label: "an object without a stat", value: { path: "note.md", handle: undefined }, expected: false },
|
||||
{ label: "an object without a handle", value: { path: "note.md", stat: {} }, expected: false },
|
||||
{
|
||||
label: "a file-shaped object with a handle property",
|
||||
value: { path: "note.md", stat: {}, handle: undefined },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
label: "a folder-shaped object",
|
||||
value: { path: "folder", handle: undefined, isFolder: true },
|
||||
expected: false,
|
||||
},
|
||||
])("classifies $label as a file", ({ value, expected }) => {
|
||||
expect(typeGuard.isFile(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { isFolder: true, handle: undefined }, expected: false },
|
||||
{ label: "an object without the folder marker", value: { path: "folder", handle: undefined }, expected: false },
|
||||
{ label: "an object without a handle", value: { path: "folder", isFolder: true }, expected: false },
|
||||
{
|
||||
label: "a folder-shaped object with a handle property",
|
||||
value: { path: "folder", isFolder: true, handle: undefined },
|
||||
expected: true,
|
||||
},
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {}, handle: undefined }, expected: false },
|
||||
])("classifies $label as a folder", ({ value, expected }) => {
|
||||
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
|
||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
@@ -9,8 +8,7 @@ import { ServiceFileAccessFSAPI } from "./ServiceFileAccessImpl";
|
||||
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||
|
||||
export interface FSAPIServiceModules extends ServiceModules {
|
||||
vaultAccess: FileAccessFSAPI;
|
||||
@@ -73,40 +71,7 @@ export function initialiseServiceModulesFSAPI(
|
||||
vault: services.vault,
|
||||
});
|
||||
|
||||
// File handler (platform-independent)
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||
|
||||
return {
|
||||
rebuilder,
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fileURLToPath, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { readVersion } from "../readVersion";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
|
||||
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
||||
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
||||
// https://vite.dev/config/
|
||||
|
||||
@@ -145,7 +145,7 @@ export class WebPeerRuntime {
|
||||
|
||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||
this.menu?.hide();
|
||||
this.menu = new Menu()
|
||||
this.menu = new Menu(this.services.browserUi)
|
||||
.addItem((item) =>
|
||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { stepCyclicIndex } from "octagonal-wheels/collection";
|
||||
|
||||
export function navigateDiffNodes(
|
||||
root: ParentNode,
|
||||
selector: string,
|
||||
navigation: {
|
||||
currentDiffIndex: number;
|
||||
diffNavIndicator: { setText(text: string): void };
|
||||
},
|
||||
direction: "prev" | "next"
|
||||
): void {
|
||||
const diffElements = root.querySelectorAll<HTMLElement>(selector);
|
||||
if (diffElements.length === 0) return;
|
||||
|
||||
const previousFocused = root.querySelector(".diff-focused");
|
||||
if (previousFocused) {
|
||||
previousFocused.classList.remove("diff-focused");
|
||||
}
|
||||
|
||||
navigation.currentDiffIndex = stepCyclicIndex(navigation.currentDiffIndex, diffElements.length, direction);
|
||||
const target = diffElements[navigation.currentDiffIndex];
|
||||
target.classList.add("diff-focused");
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
navigation.diffNavIndicator.setText(`${navigation.currentDiffIndex + 1}/${diffElements.length}`);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { withOwnedResource, type DisposableResource } from "octagonal-wheels/resource";
|
||||
|
||||
/**
|
||||
* Run a finite operation with a flow-owned remote resource and release it
|
||||
* after either success or failure.
|
||||
@@ -6,13 +8,9 @@
|
||||
* caller's ownership boundary explicit and prevents finite flows from leaking
|
||||
* a provider-owned resource when their operation rejects.
|
||||
*/
|
||||
export async function withOwnedRemoteResource<TResource extends { dispose(): Promise<void> }, TResult>(
|
||||
export function withOwnedRemoteResource<TResource extends DisposableResource & { dispose(): Promise<void> }, TResult>(
|
||||
resource: TResource,
|
||||
operation: (ownedResource: TResource) => Promise<TResult>
|
||||
): Promise<TResult> {
|
||||
try {
|
||||
return await operation(resource);
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
return withOwnedResource(resource, operation);
|
||||
}
|
||||
@@ -26,3 +26,47 @@ describe("flow-owned remote resources", () => {
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("owned remote resource completion", () => {
|
||||
it("waits for cleanup and preserves its receiver", async () => {
|
||||
let finish!: () => void;
|
||||
let started!: () => void;
|
||||
const disposing = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const cleanup = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const resource = {
|
||||
dispose: vi.fn(function (this: unknown) {
|
||||
expect(this).toBe(resource);
|
||||
started();
|
||||
return cleanup;
|
||||
}),
|
||||
};
|
||||
let settled = false;
|
||||
const result = withOwnedRemoteResource(resource, async () => "done");
|
||||
void result.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await disposing;
|
||||
expect(settled).toBe(false);
|
||||
finish();
|
||||
await expect(result).resolves.toBe("done");
|
||||
expect(resource.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps finally precedence when both the operation and cleanup fail", async () => {
|
||||
const operationError = new Error("operation");
|
||||
const disposalError = new Error("cleanup");
|
||||
const dispose = vi.fn(async () => {
|
||||
throw disposalError;
|
||||
});
|
||||
await expect(
|
||||
withOwnedRemoteResource({ dispose }, async () => {
|
||||
throw operationError;
|
||||
})
|
||||
).rejects.toBe(disposalError);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { findPathComponentsExceedingUtf8Limit as findKitPathComponentsExceedingUtf8Limit } from "octagonal-wheels/path";
|
||||
|
||||
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
||||
|
||||
export interface OversizedPathComponent {
|
||||
@@ -5,8 +7,6 @@ export interface OversizedPathComponent {
|
||||
utf8Bytes: number;
|
||||
}
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Return path components which exceed the conservative Android/Linux
|
||||
* compatibility boundary.
|
||||
@@ -18,9 +18,5 @@ export function findPathComponentsExceedingUtf8Limit(
|
||||
path: string,
|
||||
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||
): OversizedPathComponent[] {
|
||||
return path
|
||||
.split("/")
|
||||
.filter((component) => component.length > 0)
|
||||
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
|
||||
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
|
||||
return findKitPathComponentsExceedingUtf8Limit(path, maxBytes);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ async function runOneShotWithOutcome(
|
||||
|
||||
// Manual and unattended wrappers share the provider transfer operation, but
|
||||
// keep interaction authority and result presentation explicit at this boundary.
|
||||
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
const userInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
@@ -81,20 +81,7 @@ const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance,
|
||||
);
|
||||
};
|
||||
|
||||
const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
|
||||
const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
|
||||
);
|
||||
};
|
||||
|
||||
const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
const unattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
@@ -136,8 +123,8 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
),
|
||||
},
|
||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
|
||||
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(unattendedOneShot),
|
||||
continuous: supportedOpenReplicationContinuous(),
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
@@ -166,8 +153,8 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
||||
},
|
||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
|
||||
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(unattendedOneShot),
|
||||
continuous: CAPABILITY_NOT_APPLICABLE,
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
|
||||
@@ -209,6 +209,37 @@ describe("central Replicator provider definitions", () => {
|
||||
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
|
||||
});
|
||||
|
||||
it.each([REMOTE_COUCHDB, REMOTE_MINIO] as const)(
|
||||
"keeps unattended %s work quiet and rejects interaction before opening replication",
|
||||
async (remoteType) => {
|
||||
const definition = createCentralReplicatorProviderDefinitions({} as never).get(remoteType)!;
|
||||
if (definition.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("Unattended OneShot is unavailable");
|
||||
}
|
||||
const setting = Object.assign(createNewVaultSettings(), { remoteType });
|
||||
const instance = {
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
};
|
||||
const blocked = await definition.unattendedOneShot.run(instance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
expect(blocked).toEqual({ status: "blocked", reason: "interaction-required" });
|
||||
expect(instance.openOneShotReplicationWithOutcome).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
definition.unattendedOneShot.run(instance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
expect(instance.openOneShotReplicationWithOutcome).toHaveBeenCalledExactlyOnceWith(setting, false);
|
||||
}
|
||||
);
|
||||
|
||||
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createOwnedResource } from "octagonal-wheels/resource";
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
/**
|
||||
@@ -13,13 +14,8 @@ export interface ResourceReplicator {
|
||||
|
||||
/** Create one idempotent asynchronous disposer for a private Replicator. */
|
||||
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
|
||||
let disposal: Promise<void> | undefined;
|
||||
return () => {
|
||||
if (disposal === undefined) {
|
||||
disposal = Promise.resolve().then(() => replicator.closeReplication());
|
||||
}
|
||||
return disposal;
|
||||
};
|
||||
const resource = createOwnedResource(replicator, (owned) => Promise.resolve().then(() => owned.closeReplication()));
|
||||
return () => Promise.resolve(resource.dispose());
|
||||
}
|
||||
|
||||
/** Fence a finite resource from later edits to its source settings object. */
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createReplicatorDisposer } from "./shared";
|
||||
|
||||
describe("Replicator disposal", () => {
|
||||
it("defers closing, preserves the receiver, and retains one completion promise", async () => {
|
||||
let finish!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const replicator = {
|
||||
closeReplication: vi.fn(function (this: unknown) {
|
||||
expect(this).toBe(replicator);
|
||||
return closed;
|
||||
}),
|
||||
};
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
const pending = dispose();
|
||||
expect(replicator.closeReplication).not.toHaveBeenCalled();
|
||||
expect(dispose()).toBe(pending);
|
||||
await Promise.resolve();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
finish();
|
||||
await pending;
|
||||
expect(dispose()).toBe(pending);
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains synchronous closing failures as one rejected promise", async () => {
|
||||
const error = new Error("close");
|
||||
const replicator = {
|
||||
closeReplication: vi.fn(() => {
|
||||
throw error;
|
||||
}),
|
||||
};
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
const pending = dispose();
|
||||
await expect(pending).rejects.toBe(error);
|
||||
expect(dispose()).toBe(pending);
|
||||
await expect(dispose()).rejects.toBe(error);
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -389,13 +389,4 @@ export async function autosaveCache<K, V>(db: KeyValueDatabase, mapKey: string):
|
||||
};
|
||||
}
|
||||
|
||||
export function onlyInNTimes(n: number, proc: (progress: number) => unknown) {
|
||||
let counter = 0;
|
||||
return function () {
|
||||
if (counter++ % n == 0) {
|
||||
proc(counter);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({
|
||||
PluginDialogModal: class PluginDialogModal {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||
LiveSyncCommands: class LiveSyncCommands {
|
||||
core!: { services: unknown };
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConfigSync, type PluginDataExDisplayV2 } from "./CmdConfigSync";
|
||||
|
||||
const BASE_DIR = ".obsidian";
|
||||
const DEVICE_NAME = "test-device";
|
||||
const STAT = { mtime: 123456, ctime: 123000 };
|
||||
|
||||
type ApplyFile = {
|
||||
filename: string;
|
||||
datatype: "newnote" | "plain";
|
||||
data: string[];
|
||||
mtime: number;
|
||||
ctime: number;
|
||||
};
|
||||
|
||||
function makeApplyFile(datatype: ApplyFile["datatype"], data: string[]): ApplyFile {
|
||||
return {
|
||||
filename: "plugins/example/data.json",
|
||||
datatype,
|
||||
data,
|
||||
mtime: STAT.mtime,
|
||||
ctime: STAT.ctime,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(file: ApplyFile, currentText = "before", currentBinary = new ArrayBuffer(0)) {
|
||||
const path = `${BASE_DIR}/${file.filename}`;
|
||||
const events: string[] = [];
|
||||
const storageAccess = {
|
||||
ensureDir: vi.fn(async (_path: string) => {
|
||||
events.push("ensure");
|
||||
}),
|
||||
readHiddenFileText: vi.fn(async (_path: string) => {
|
||||
events.push("read-text");
|
||||
return currentText;
|
||||
}),
|
||||
readHiddenFileBinary: vi.fn(async (_path: string) => {
|
||||
events.push("read-binary");
|
||||
return currentBinary;
|
||||
}),
|
||||
writeHiddenFileAuto: vi.fn(
|
||||
async (_path: string, _content: string | ArrayBuffer, _stat?: { mtime: number; ctime: number }) => {
|
||||
events.push("write");
|
||||
}
|
||||
),
|
||||
};
|
||||
const storeCustomisationFileV2 = vi.fn(async (_path: string, _term: string) => {
|
||||
events.push("store");
|
||||
return true;
|
||||
});
|
||||
const log = vi.fn();
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
services: {
|
||||
API: { getSystemConfigDir: () => BASE_DIR },
|
||||
setting: { getDeviceAndVaultName: () => DEVICE_NAME },
|
||||
},
|
||||
storageAccess,
|
||||
},
|
||||
_log: log,
|
||||
storeCustomisationFileV2,
|
||||
});
|
||||
const data = {
|
||||
displayName: "Example plug-in",
|
||||
name: "example",
|
||||
files: [file],
|
||||
} as unknown as PluginDataExDisplayV2;
|
||||
return { configSync, data, events, log, path, storageAccess, storeCustomisationFileV2 };
|
||||
}
|
||||
|
||||
const changedInputs = [
|
||||
{
|
||||
kind: "text",
|
||||
file: makeApplyFile("plain", ["replacement text"]),
|
||||
currentText: "previous text",
|
||||
expectedText: "replacement text",
|
||||
},
|
||||
{
|
||||
kind: "binary",
|
||||
file: makeApplyFile("newnote", ["AQID"]),
|
||||
currentBinary: new Uint8Array([9]).buffer,
|
||||
expectedBytes: [1, 2, 3],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const unchangedInputs = [
|
||||
{
|
||||
kind: "text",
|
||||
file: makeApplyFile("plain", ["unchanged text"]),
|
||||
currentText: "unchanged text",
|
||||
},
|
||||
{
|
||||
kind: "binary",
|
||||
file: makeApplyFile("newnote", ["AQID"]),
|
||||
currentBinary: new Uint8Array([1, 2, 3]).buffer,
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("ConfigSync V2 content application", () => {
|
||||
it.each(changedInputs)("writes changed $kind content, then stores it", async (input) => {
|
||||
const fixture = setup(
|
||||
input.file,
|
||||
input.kind === "text" ? input.currentText : undefined,
|
||||
input.kind === "binary" ? input.currentBinary : undefined
|
||||
);
|
||||
|
||||
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.storageAccess.ensureDir).toHaveBeenCalledWith(fixture.path);
|
||||
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledOnce();
|
||||
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledWith(
|
||||
fixture.path,
|
||||
input.kind === "text" ? input.expectedText : expect.any(ArrayBuffer),
|
||||
STAT
|
||||
);
|
||||
if (input.kind === "binary") {
|
||||
const written = fixture.storageAccess.writeHiddenFileAuto.mock.calls[0]?.[1];
|
||||
expect(written).toBeInstanceOf(ArrayBuffer);
|
||||
expect(Array.from(new Uint8Array(written as ArrayBuffer))).toEqual(input.expectedBytes);
|
||||
}
|
||||
expect(fixture.events).toEqual([
|
||||
"ensure",
|
||||
input.kind === "text" ? "read-text" : "read-binary",
|
||||
"write",
|
||||
"store",
|
||||
]);
|
||||
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledWith(fixture.path, DEVICE_NAME);
|
||||
});
|
||||
|
||||
it("writes the file timestamps captured before reading existing content", async () => {
|
||||
const file = makeApplyFile("plain", ["replacement text"]);
|
||||
const fixture = setup(file, "previous text");
|
||||
fixture.storageAccess.readHiddenFileText.mockImplementation(async () => {
|
||||
fixture.events.push("read-text");
|
||||
file.mtime = STAT.mtime + 1;
|
||||
file.ctime = STAT.ctime + 1;
|
||||
return "previous text";
|
||||
});
|
||||
|
||||
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledWith(fixture.path, "replacement text", STAT);
|
||||
});
|
||||
|
||||
it.each(unchangedInputs)("skips writing and storing unchanged $kind content", async (input) => {
|
||||
const fixture = setup(
|
||||
input.file,
|
||||
input.kind === "text" ? input.currentText : undefined,
|
||||
input.kind === "binary" ? input.currentBinary : undefined
|
||||
);
|
||||
|
||||
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.storageAccess.writeHiddenFileAuto).not.toHaveBeenCalled();
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
expect(fixture.events).toEqual(["ensure", input.kind === "text" ? "read-text" : "read-binary"]);
|
||||
expect(fixture.log).toHaveBeenCalledWith(
|
||||
`The file ${input.file.filename} is already up-to-date`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "text", file: makeApplyFile("plain", ["replacement text"]) },
|
||||
{ kind: "binary", file: makeApplyFile("newnote", ["AQID"]) },
|
||||
])("uses empty $kind content when reading the existing file fails", async ({ kind, file }) => {
|
||||
const fixture = setup(file);
|
||||
const readError = new Error("Could not read existing content");
|
||||
if (kind === "text") {
|
||||
fixture.storageAccess.readHiddenFileText.mockRejectedValueOnce(readError);
|
||||
} else {
|
||||
fixture.storageAccess.readHiddenFileBinary.mockRejectedValueOnce(readError);
|
||||
}
|
||||
|
||||
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledOnce();
|
||||
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledWith(fixture.path, DEVICE_NAME);
|
||||
expect(fixture.log).toHaveBeenCalledWith(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.log).toHaveBeenCalledWith(readError, LOG_LEVEL_VERBOSE);
|
||||
});
|
||||
|
||||
it("awaits the write before storing the applied content", async () => {
|
||||
const file = makeApplyFile("plain", ["replacement text"]);
|
||||
const fixture = setup(file, "previous text");
|
||||
let finishWrite!: () => void;
|
||||
let signalWriteStarted!: () => void;
|
||||
const writeGate = new Promise<void>((resolve) => {
|
||||
finishWrite = resolve;
|
||||
});
|
||||
const writeStarted = new Promise<void>((resolve) => {
|
||||
signalWriteStarted = resolve;
|
||||
});
|
||||
fixture.storageAccess.writeHiddenFileAuto.mockImplementation(async () => {
|
||||
fixture.events.push("write-start");
|
||||
signalWriteStarted();
|
||||
await writeGate;
|
||||
fixture.events.push("write-finished");
|
||||
});
|
||||
|
||||
const applying = fixture.configSync.applyDataV2(fixture.data);
|
||||
await writeStarted;
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
|
||||
finishWrite();
|
||||
await expect(applying).resolves.toBe(true);
|
||||
expect(fixture.events.slice(-3)).toEqual(["write-start", "write-finished", "store"]);
|
||||
});
|
||||
|
||||
it("returns false after a write failure and does not store the file", async () => {
|
||||
const fixture = setup(makeApplyFile("plain", ["replacement text"]), "previous text");
|
||||
const writeError = new Error("Write failed");
|
||||
fixture.storageAccess.writeHiddenFileAuto.mockRejectedValueOnce(writeError);
|
||||
|
||||
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalledWith("Applying Example plug-in.. Failed", LOG_LEVEL_NOTICE);
|
||||
expect(fixture.log).toHaveBeenCalledWith(writeError, LOG_LEVEL_VERBOSE);
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,7 @@ async function catalogue() {
|
||||
pluginListGeneration: 0,
|
||||
loadedManifest_mTime: new Map(),
|
||||
updatingV2Count: 0,
|
||||
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
|
||||
pluginScanProcessor: { clearPending: vi.fn(), enqueue: vi.fn() },
|
||||
_log: vi.fn(),
|
||||
storeCustomisationFileV2: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("ConfigSync commands", () => {
|
||||
expect(showPluginSyncModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cancels the pending configuration Notice before releasing its owned UI", () => {
|
||||
it("cancels the pending configuration Notice before releasing its owned UI", async () => {
|
||||
const notices = { hide: vi.fn() };
|
||||
const periodicPluginSweepProcessor = { disable: vi.fn() };
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
@@ -102,9 +102,13 @@ describe("ConfigSync commands", () => {
|
||||
},
|
||||
},
|
||||
periodicPluginSweepProcessor,
|
||||
pluginList: [],
|
||||
pluginListGeneration: 0,
|
||||
loadedManifest_mTime: new Map(),
|
||||
pluginScanProcessor: { clearPending: vi.fn(), stop: vi.fn(async () => {}) },
|
||||
});
|
||||
|
||||
configSync.onunload();
|
||||
await configSync.onunload();
|
||||
|
||||
expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration");
|
||||
expect(notices.hide).toHaveBeenCalledWith("config-sync:updated-configuration");
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: (path: string) => path,
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {
|
||||
disable = vi.fn();
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
|
||||
|
||||
import { get } from "svelte/store";
|
||||
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type AnyEntry,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
import { ConfigSync, pluginIsEnumerating, pluginList } from "./CmdConfigSync";
|
||||
|
||||
const queueOwners: ConfigSync[] = [];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function waitForCondition(predicate: () => boolean, message: string) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function queueEntry(path: string, data: unknown = {}): AnyEntry {
|
||||
const documentPath = path as FilePathWithPrefix;
|
||||
return {
|
||||
_id: documentPath,
|
||||
path: documentPath,
|
||||
mtime: 2_000,
|
||||
data: [JSON.stringify(data)],
|
||||
} as unknown as AnyEntry;
|
||||
}
|
||||
|
||||
function legacyCatalogueEntry(path: string, name: string, mtime = 2_000): AnyEntry {
|
||||
return queueEntry(path, {
|
||||
category: "PLUGIN_MAIN",
|
||||
name,
|
||||
term: "device",
|
||||
mtime,
|
||||
files: [
|
||||
{
|
||||
filename: `plugins/${name}/main.js`,
|
||||
data: [`legacy source for ${name}`],
|
||||
mtime,
|
||||
size: name.length,
|
||||
hash: `hash-${name}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function createQueueConfigSync(
|
||||
settings: typeof DEFAULT_SETTINGS,
|
||||
localDatabase: unknown,
|
||||
services: unknown = {
|
||||
API: { addLog: vi.fn(), addRibbonIcon: vi.fn() },
|
||||
path: { getPath: (entry: { path: string }) => entry.path },
|
||||
context: { notices: { hide: vi.fn() } },
|
||||
}
|
||||
) {
|
||||
vi.spyOn(ConfigSync.prototype, "onBindFunction").mockImplementation(() => {});
|
||||
const configSync = new ConfigSync({
|
||||
settings,
|
||||
localDatabase,
|
||||
services,
|
||||
} as unknown as ConstructorParameters<typeof ConfigSync>[0]);
|
||||
queueOwners.push(configSync);
|
||||
return configSync;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const configSync of queueOwners.splice(0)) {
|
||||
await configSync.pluginScanProcessor.stop();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
pluginList.set([]);
|
||||
pluginIsEnumerating.set(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ConfigSync legacy catalogue queue", () => {
|
||||
it("starts duplicate entries in FIFO order with ten concurrent items", async () => {
|
||||
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: true };
|
||||
const configSync = createQueueConfigSync(settings, {});
|
||||
const paths = [
|
||||
"ix:device/PLUGIN_MAIN/first.md",
|
||||
"ix:device/PLUGIN_MAIN/second.md",
|
||||
"ix:device/PLUGIN_MAIN/duplicate.md",
|
||||
"ix:device/PLUGIN_MAIN/third.md",
|
||||
"ix:device/PLUGIN_MAIN/duplicate.md",
|
||||
"ix:device/PLUGIN_MAIN/fourth.md",
|
||||
"ix:device/PLUGIN_MAIN/fifth.md",
|
||||
"ix:device/PLUGIN_MAIN/sixth.md",
|
||||
"ix:device/PLUGIN_MAIN/seventh.md",
|
||||
"ix:device/PLUGIN_MAIN/eighth.md",
|
||||
"ix:device/PLUGIN_MAIN/ninth.md",
|
||||
"ix:device/PLUGIN_MAIN/tenth.md",
|
||||
];
|
||||
const gate = deferred();
|
||||
const started: string[] = [];
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
vi.spyOn(configSync, "migrateV1ToV2").mockImplementation(async (_showMessage, entry) => {
|
||||
started.push(entry.path);
|
||||
active++;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
await gate.promise;
|
||||
active--;
|
||||
});
|
||||
|
||||
try {
|
||||
for (const path of paths)
|
||||
configSync.pluginScanProcessor.enqueue({ entry: queueEntry(path), generation: 0 });
|
||||
await waitForCondition(() => started.length === 10, "The first ten queue items did not start");
|
||||
|
||||
expect(started).toEqual(paths.slice(0, 10));
|
||||
expect(maximumActive).toBe(10);
|
||||
|
||||
gate.resolve();
|
||||
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||
expect(started).toEqual(paths);
|
||||
} finally {
|
||||
gate.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues with queued items after a migration throws", async () => {
|
||||
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: true };
|
||||
const configSync = createQueueConfigSync(settings, {});
|
||||
const paths = Array.from({ length: 11 }, (_, index) => `ix:device/PLUGIN_MAIN/item-${index}.md`);
|
||||
const gate = deferred();
|
||||
const started: string[] = [];
|
||||
vi.spyOn(configSync, "migrateV1ToV2").mockImplementation(async (_showMessage, entry) => {
|
||||
started.push(entry.path);
|
||||
if (entry.path === paths[0]) throw new Error("migration failed");
|
||||
if (paths.slice(1, 10).includes(entry.path)) await gate.promise;
|
||||
});
|
||||
|
||||
try {
|
||||
for (const path of paths)
|
||||
configSync.pluginScanProcessor.enqueue({ entry: queueEntry(path), generation: 0 });
|
||||
await waitForCondition(() => started.length === 10, "The first ten queue items did not start");
|
||||
await waitForCondition(() => started.length === 11, "The item after the failed migration did not start");
|
||||
|
||||
gate.resolve();
|
||||
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||
expect(started).toEqual(paths);
|
||||
} finally {
|
||||
gate.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not restore an old read result after reloading a newer catalogue", async () => {
|
||||
vi.useFakeTimers();
|
||||
const oldPath = "ix:device/PLUGIN_MAIN/old" as FilePathWithPrefix;
|
||||
const newerPath = "ix:device/PLUGIN_MAIN/newer" as FilePathWithPrefix;
|
||||
const oldEntry = legacyCatalogueEntry(oldPath, "old");
|
||||
const newerEntry = legacyCatalogueEntry(newerPath, "newer", 3_000);
|
||||
const oldRead = deferred();
|
||||
let scanCount = 0;
|
||||
let oldReadStarted = false;
|
||||
const localDatabase = {
|
||||
findEntries: vi.fn(async function* () {
|
||||
for (const entry of scanCount++ === 0 ? [oldEntry] : [newerEntry]) yield entry;
|
||||
}),
|
||||
getDBEntry: vi.fn(async (path: FilePathWithPrefix) => {
|
||||
if (path === oldPath) {
|
||||
oldReadStarted = true;
|
||||
await oldRead.promise;
|
||||
return oldEntry;
|
||||
}
|
||||
return newerEntry;
|
||||
}),
|
||||
};
|
||||
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false };
|
||||
const configSync = createQueueConfigSync(settings, localDatabase);
|
||||
|
||||
try {
|
||||
await configSync.updatePluginList(false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await waitForCondition(() => oldReadStarted, "The old catalogue read did not start");
|
||||
|
||||
await configSync.reloadPluginList(false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(configSync.pluginList.map((entry) => entry.name)).toEqual(["newer"]);
|
||||
|
||||
oldRead.resolve();
|
||||
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||
expect(configSync.pluginList.map((entry) => entry.name)).toEqual(["newer"]);
|
||||
expect(get(pluginList).map((entry) => entry.name)).toEqual(["newer"]);
|
||||
} finally {
|
||||
oldRead.resolve();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["catalogue update", "settings application", "temporary suspension"] as const)(
|
||||
"does not restore an active legacy read after disabling through %s and re-enabling",
|
||||
async (route) => {
|
||||
vi.useFakeTimers();
|
||||
const oldPath = "ix:device/PLUGIN_MAIN/old" as FilePathWithPrefix;
|
||||
const oldEntry = legacyCatalogueEntry(oldPath, "old");
|
||||
const oldRead = deferred();
|
||||
let oldReadStarted = false;
|
||||
let scanCount = 0;
|
||||
const localDatabase = {
|
||||
findEntries: vi.fn(async function* () {
|
||||
if (scanCount++ === 0) yield oldEntry;
|
||||
}),
|
||||
getDBEntry: vi.fn(async () => {
|
||||
oldReadStarted = true;
|
||||
await oldRead.promise;
|
||||
return oldEntry;
|
||||
}),
|
||||
};
|
||||
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false };
|
||||
const configSync = createQueueConfigSync(settings, localDatabase);
|
||||
|
||||
try {
|
||||
await configSync.updatePluginList(false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await waitForCondition(() => oldReadStarted, "The active legacy read did not start");
|
||||
|
||||
settings.usePluginSync = false;
|
||||
if (route === "catalogue update") {
|
||||
await configSync.updatePluginList(false);
|
||||
} else if (route === "settings application") {
|
||||
await configSync._everyRealizeSettingSyncMode();
|
||||
} else {
|
||||
await (
|
||||
configSync as unknown as { _allSuspendExtraSync(): Promise<boolean> }
|
||||
)._allSuspendExtraSync();
|
||||
}
|
||||
settings.usePluginSync = true;
|
||||
await configSync.updatePluginList(false);
|
||||
|
||||
oldRead.resolve();
|
||||
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||
expect(configSync.pluginList).toEqual([]);
|
||||
expect(get(pluginList)).toEqual([]);
|
||||
} finally {
|
||||
oldRead.resolve();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("stops admission on unload and joins a read without changing the replacement instance's list", async () => {
|
||||
vi.useFakeTimers();
|
||||
const entry = legacyCatalogueEntry("ix:device/PLUGIN_MAIN/old", "old");
|
||||
const read = deferred();
|
||||
const localDatabase = {
|
||||
findEntries: vi.fn(async function* () {
|
||||
yield entry;
|
||||
}),
|
||||
getDBEntry: vi.fn(async () => {
|
||||
await read.promise;
|
||||
return entry;
|
||||
}),
|
||||
};
|
||||
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false };
|
||||
const configSync = createQueueConfigSync(settings, localDatabase);
|
||||
try {
|
||||
await configSync.updatePluginList(false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(localDatabase.getDBEntry).toHaveBeenCalledOnce();
|
||||
let stopped = false;
|
||||
const done = configSync.onunload().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
const replacement = { name: "replacement" } as (typeof configSync.pluginList)[number];
|
||||
pluginList.set([replacement]);
|
||||
await configSync.updatePluginList(false);
|
||||
await configSync.reloadPluginList(false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(localDatabase.findEntries).toHaveBeenCalledOnce();
|
||||
expect(stopped).toBe(false);
|
||||
expect(configSync.pluginScanProcessor.remaining).toBe(1);
|
||||
read.resolve();
|
||||
await done;
|
||||
expect(configSync.pluginList).toEqual([]);
|
||||
expect(get(pluginList)).toEqual([replacement]);
|
||||
expect(configSync.pluginScanProcessor.remaining).toBe(0);
|
||||
} finally {
|
||||
read.resolve();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels unstarted legacy reads when the first ten are blocked and the module is disabled", async () => {
|
||||
const paths = Array.from({ length: 12 }, (_, index) => `ix:device/PLUGIN_MAIN/item-${index}`);
|
||||
const entries = paths.map((path, index) => legacyCatalogueEntry(path, `item-${index}`));
|
||||
const gates = entries.slice(0, 10).map(() => deferred());
|
||||
const started: string[] = [];
|
||||
const localDatabase = {
|
||||
findEntries: vi.fn(async function* () {
|
||||
for (const entry of entries) yield entry;
|
||||
}),
|
||||
getDBEntry: vi.fn(async (path: FilePathWithPrefix) => {
|
||||
const index = paths.indexOf(path);
|
||||
started.push(path);
|
||||
if (index >= 0 && index < gates.length) await gates[index].promise;
|
||||
return entries[index];
|
||||
}),
|
||||
};
|
||||
const settings = { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false };
|
||||
const configSync = createQueueConfigSync(settings, localDatabase);
|
||||
|
||||
try {
|
||||
await configSync.updatePluginList(false);
|
||||
await waitForCondition(() => started.length === 10, "The first ten legacy reads did not start");
|
||||
|
||||
settings.usePluginSync = false;
|
||||
await configSync.updatePluginList(false);
|
||||
for (const gate of gates) gate.resolve();
|
||||
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||
|
||||
expect(started).toEqual(paths.slice(0, 10));
|
||||
} finally {
|
||||
for (const gate of gates) gate.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it("finishes a started V1-to-V2 migration but does not start pending migrations after disable", async () => {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
const v1Path = "ix:device/PLUGIN_MAIN/migrate.md" as FilePathWithPrefix;
|
||||
const v2Path = "ix:device/PLUGIN_MAIN/migrate%main.js" as FilePathWithPrefix;
|
||||
const source = "module.exports = 'migrated';";
|
||||
const data = {
|
||||
category: "PLUGIN_MAIN",
|
||||
name: "migrate",
|
||||
term: "device",
|
||||
mtime: 2_000,
|
||||
files: [
|
||||
{
|
||||
filename: "plugins/migrate/main.js",
|
||||
data: [source],
|
||||
mtime: 2_000,
|
||||
size: source.length,
|
||||
hash: "migration-source-hash",
|
||||
},
|
||||
],
|
||||
};
|
||||
const settings = fixture.settings;
|
||||
const configSync = createQueueConfigSync(settings, fixture.localDatabase, {
|
||||
...fixture.services,
|
||||
API: { ...fixture.services.API, addRibbonIcon: vi.fn() },
|
||||
});
|
||||
const pendingGate = deferred();
|
||||
const putGate = deferred();
|
||||
const migrationFinished = deferred();
|
||||
const started: string[] = [];
|
||||
let v2PutStarted = false;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
fixture.localDatabase.putDBEntry(optionalFileEntry(v1Path, JSON.stringify(data)))
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
const loadedLegacy = {
|
||||
...optionalFileEntry(v1Path, JSON.stringify(data)),
|
||||
data: [JSON.stringify(data)],
|
||||
} as unknown as Awaited<ReturnType<typeof fixture.localDatabase.getDBEntry>>;
|
||||
let migrationRead = false;
|
||||
const read = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
|
||||
if (path === v1Path && !migrationRead) {
|
||||
migrationRead = true;
|
||||
return loadedLegacy;
|
||||
}
|
||||
return await read(path, ...args);
|
||||
});
|
||||
const migrate = configSync.migrateV1ToV2.bind(configSync);
|
||||
vi.spyOn(configSync, "migrateV1ToV2").mockImplementation(async (showMessage, entry) => {
|
||||
started.push(entry.path);
|
||||
if (entry.path === v1Path) {
|
||||
try {
|
||||
await migrate(showMessage, entry);
|
||||
} finally {
|
||||
migrationFinished.resolve();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await pendingGate.promise;
|
||||
});
|
||||
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
|
||||
vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
|
||||
if ((args[0] as { path?: string }).path === v2Path) {
|
||||
v2PutStarted = true;
|
||||
await putGate.promise;
|
||||
}
|
||||
return await put(...args);
|
||||
});
|
||||
|
||||
const paths = [
|
||||
v1Path,
|
||||
...Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) => `ix:device/PLUGIN_MAIN/pending-${index}.md` as FilePathWithPrefix
|
||||
),
|
||||
];
|
||||
for (const path of paths)
|
||||
configSync.pluginScanProcessor.enqueue({ entry: queueEntry(path), generation: 0 });
|
||||
await waitForCondition(
|
||||
() => started.length === 10,
|
||||
`The first ten migrations did not start: ${started.join(", ")}`
|
||||
);
|
||||
await waitForCondition(() => v2PutStarted, "The migration did not reach its write");
|
||||
|
||||
settings.usePluginSync = false;
|
||||
await configSync.updatePluginList(false);
|
||||
putGate.resolve();
|
||||
await migrationFinished.promise;
|
||||
expect.soft(started).toEqual(paths.slice(0, 10));
|
||||
|
||||
pendingGate.resolve();
|
||||
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||
expect.soft(started).toEqual(paths.slice(0, 10));
|
||||
|
||||
const migrated = await fixture.localDatabase.getDBEntry(v2Path, undefined, false, false);
|
||||
expect(migrated).not.toBe(false);
|
||||
if (migrated !== false) expect(getDocData(migrated.data)).toContain(source);
|
||||
await expect(fixture.localDatabase.getDBEntry(v1Path)).resolves.toBe(false);
|
||||
} finally {
|
||||
putGate.resolve();
|
||||
pendingGate.resolve();
|
||||
await configSync.pluginScanProcessor.waitForIdle();
|
||||
await fixture.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads legacy catalogue contents through the database with native timers", async () => {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
const path = "ix:device/PLUGIN_MAIN/native-timers" as FilePathWithPrefix;
|
||||
const data = {
|
||||
category: "PLUGIN_MAIN",
|
||||
name: "native-timers",
|
||||
term: "device",
|
||||
mtime: 2_000,
|
||||
files: [{ filename: "main.js", data: ["stored content"], mtime: 2_000, size: 14, hash: "stored-hash" }],
|
||||
};
|
||||
let configSync: ConfigSync | undefined;
|
||||
try {
|
||||
fixture.settings.usePluginSyncV2 = false;
|
||||
await expect(
|
||||
fixture.localDatabase.putDBEntry(optionalFileEntry(path, JSON.stringify(data)))
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
vi.spyOn(ConfigSync.prototype, "onBindFunction").mockImplementation(() => {});
|
||||
configSync = new ConfigSync({
|
||||
settings: fixture.settings,
|
||||
localDatabase: fixture.localDatabase,
|
||||
services: {
|
||||
...fixture.services,
|
||||
API: { ...fixture.services.API, addRibbonIcon: vi.fn() },
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof ConfigSync>[0]);
|
||||
queueOwners.push(configSync);
|
||||
await configSync.updatePluginList(false);
|
||||
await expect(configSync.pluginScanProcessor.waitForIdle()).resolves.toBeUndefined();
|
||||
expect(configSync.pluginList).toEqual([
|
||||
{
|
||||
...data,
|
||||
documentPath: path,
|
||||
files: [{ ...data.files[0], data: ["stored-hash"] }],
|
||||
},
|
||||
]);
|
||||
expect(get(pluginList)).toEqual(configSync.pluginList);
|
||||
expect(configSync.pluginScanProcessor.remaining).toBe(0);
|
||||
} finally {
|
||||
await configSync?.pluginScanProcessor.stop();
|
||||
await fixture.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns after enumeration, then loads a queued legacy catalogue item after its configured delay", async () => {
|
||||
vi.useFakeTimers();
|
||||
pluginList.set([]);
|
||||
pluginIsEnumerating.set(false);
|
||||
|
||||
const path = "ix:device/PLUGIN_MAIN/example" as FilePathWithPrefix;
|
||||
const catalogueData = {
|
||||
category: "PLUGIN_MAIN",
|
||||
name: "example",
|
||||
term: "device",
|
||||
mtime: 2_000,
|
||||
files: [
|
||||
{ filename: "main.js", data: ["legacy plugin source"], mtime: 2_000, size: 20, hash: "legacy-hash" },
|
||||
],
|
||||
};
|
||||
const entry = { _id: path, path, mtime: 2_000, data: [JSON.stringify(catalogueData)] };
|
||||
const readGate = deferred();
|
||||
let loadHasStarted = false;
|
||||
const localDatabase = {
|
||||
findEntries: vi.fn(async function* () {
|
||||
yield entry;
|
||||
}),
|
||||
getDBEntry: vi.fn(async () => {
|
||||
loadHasStarted = true;
|
||||
await readGate.promise;
|
||||
return entry;
|
||||
}),
|
||||
};
|
||||
vi.spyOn(ConfigSync.prototype, "onBindFunction").mockImplementation(() => {});
|
||||
const configSync = new ConfigSync({
|
||||
settings: { ...DEFAULT_SETTINGS, usePluginSync: true, usePluginSyncV2: false },
|
||||
localDatabase,
|
||||
services: {
|
||||
API: { addLog: vi.fn(), addRibbonIcon: vi.fn() },
|
||||
path: { getPath: (document: { path: string }) => document.path },
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof ConfigSync>[0]);
|
||||
queueOwners.push(configSync);
|
||||
|
||||
let drained = false;
|
||||
try {
|
||||
await configSync.updatePluginList(false);
|
||||
|
||||
expect(configSync.pluginScanProcessor.remaining).toBe(1);
|
||||
expect(configSync.pluginList).toEqual([]);
|
||||
expect(get(pluginIsEnumerating)).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(loadHasStarted).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(loadHasStarted).toBe(true);
|
||||
expect(configSync.pluginScanProcessor.remaining).toBe(1);
|
||||
const drain = configSync.pluginScanProcessor.waitForIdle();
|
||||
void drain.then(() => {
|
||||
drained = true;
|
||||
});
|
||||
|
||||
readGate.resolve();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await waitForCondition(() => drained, "The legacy catalogue queue did not drain");
|
||||
|
||||
expect(configSync.pluginList).toEqual([
|
||||
{
|
||||
...catalogueData,
|
||||
documentPath: path,
|
||||
files: [{ ...catalogueData.files[0], data: ["legacy-hash"] }],
|
||||
},
|
||||
]);
|
||||
expect(get(pluginList)).toEqual(configSync.pluginList);
|
||||
expect(configSync.pluginScanProcessor.remaining).toBe(0);
|
||||
expect(get(pluginIsEnumerating)).toBe(false);
|
||||
} finally {
|
||||
readGate.resolve();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({
|
||||
PluginDialogModal: class PluginDialogModal {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||
LiveSyncCommands: class LiveSyncCommands {
|
||||
core!: { services: unknown; localDatabase: unknown };
|
||||
get localDatabase() {
|
||||
return this.core.localDatabase;
|
||||
}
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("octagonal-wheels/concurrency/scoped.iteration", { spy: true });
|
||||
|
||||
import { LOG_LEVEL_VERBOSE, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import * as iterationModule from "octagonal-wheels/concurrency/scoped.iteration";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { ConfigSync } from "./CmdConfigSync";
|
||||
|
||||
const term = "device";
|
||||
const prefix = `ix:${term}/`;
|
||||
const keyOf = (path: string) => prefix + path;
|
||||
type Row = { _id: string; path: string };
|
||||
const row = (name: string, path = name): Row => ({ _id: keyOf(name), path });
|
||||
|
||||
function setup(files: string[] = [], rows: Row[] = []) {
|
||||
const events: string[] = [];
|
||||
const findEntries = vi.fn(async function* () {
|
||||
yield* rows;
|
||||
});
|
||||
const scanInternalFiles = vi.fn(async () => files as FilePath[]);
|
||||
const storeCustomisationFileV2 = vi.fn(async (path: string, _term: string) => {
|
||||
events.push(`store:${path}`);
|
||||
});
|
||||
const deleteConfigOnDatabase = vi.fn(async (key: string) => {
|
||||
events.push(`delete:${key}`);
|
||||
});
|
||||
const updatePluginList = vi.fn(async (_showMessage: boolean) => {});
|
||||
const log = vi.fn();
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
settings: { usePluginSyncV2: true },
|
||||
localDatabase: { findEntries },
|
||||
services: { setting: { getDeviceAndVaultName: () => term } },
|
||||
},
|
||||
scanInternalFiles,
|
||||
isTargetPath: (path: string) => path !== "ignored",
|
||||
filenameWithUnifiedKey: (path: string, _term: string) => keyOf(path),
|
||||
unifiedKeyPrefixOfTerminal: (_term: string) => prefix,
|
||||
storeCustomisationFileV2,
|
||||
deleteConfigOnDatabase,
|
||||
updatePluginList,
|
||||
_log: log,
|
||||
});
|
||||
return {
|
||||
configSync,
|
||||
events,
|
||||
findEntries,
|
||||
scanInternalFiles,
|
||||
storeCustomisationFileV2,
|
||||
deleteConfigOnDatabase,
|
||||
updatePluginList,
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConfigSync V2 scan behaviour", () => {
|
||||
it("finishes existing database entries before storing extra files and retains target filtering", async () => {
|
||||
const fixture = setup(
|
||||
["matched", "extra", "ignored"],
|
||||
[row("matched"), row("stale"), row("encoded", "encoded%path")]
|
||||
);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.events).toEqual(["store:matched", `delete:${keyOf("stale")}`, "store:extra"]);
|
||||
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||
["matched", term],
|
||||
["extra", term],
|
||||
]);
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledExactlyOnceWith(keyOf("stale"));
|
||||
expect(fixture.findEntries).toHaveBeenCalledExactlyOnceWith(prefix, `${prefix}\u{10ffff}`, {
|
||||
include_docs: true,
|
||||
});
|
||||
expect(fixture.updatePluginList).toHaveBeenCalledExactlyOnceWith(false);
|
||||
});
|
||||
|
||||
it("finishes database enumeration before dispatching database work", async () => {
|
||||
const fixture = setup();
|
||||
const enumerationPaused = promiseWithResolvers<void>();
|
||||
const continueEnumeration = promiseWithResolvers<void>();
|
||||
fixture.findEntries.mockImplementation(async function* () {
|
||||
yield row("stale");
|
||||
enumerationPaused.resolve();
|
||||
await continueEnumeration.promise;
|
||||
});
|
||||
|
||||
const scan = fixture.configSync.scanAllConfigFiles(false);
|
||||
try {
|
||||
await enumerationPaused.promise;
|
||||
expect(fixture.deleteConfigOnDatabase).not.toHaveBeenCalled();
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
continueEnumeration.resolve();
|
||||
await scan;
|
||||
}
|
||||
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledExactlyOnceWith(keyOf("stale"));
|
||||
});
|
||||
|
||||
it("limits actual database operations to ten and completes that batch before the extra batch", async () => {
|
||||
const fixture = setup(
|
||||
["extra"],
|
||||
Array.from({ length: 11 }, (_, index) => row(String(index)))
|
||||
);
|
||||
const gates = Array.from({ length: 11 }, () => promiseWithResolvers<void>());
|
||||
const tenStarted = promiseWithResolvers<void>();
|
||||
const lastStarted = promiseWithResolvers<void>();
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
let started = 0;
|
||||
fixture.deleteConfigOnDatabase.mockImplementation(async (key) => {
|
||||
const index = Number(key.slice(prefix.length));
|
||||
active++;
|
||||
maximum = Math.max(maximum, active);
|
||||
started++;
|
||||
if (started === 10) tenStarted.resolve();
|
||||
if (index === 10) lastStarted.resolve();
|
||||
try {
|
||||
await gates[index].promise;
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
});
|
||||
const scan = fixture.configSync.scanAllConfigFiles(false);
|
||||
try {
|
||||
await tenStarted.promise;
|
||||
expect(active).toBe(10);
|
||||
expect(started).toBe(10);
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
gates[0].resolve();
|
||||
await lastStarted.promise;
|
||||
expect(active).toBe(10);
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
gates.forEach((gate) => gate.resolve());
|
||||
await scan;
|
||||
}
|
||||
expect(maximum).toBe(10);
|
||||
expect(active).toBe(0);
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(11);
|
||||
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledExactlyOnceWith("extra", term);
|
||||
});
|
||||
|
||||
it("logs a database processing error and continues through the extra batch", async () => {
|
||||
const fixture = setup(["extra"], [row("bad"), row("other")]);
|
||||
const error = new Error("delete");
|
||||
fixture.deleteConfigOnDatabase.mockRejectedValueOnce(error);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.log).toHaveBeenCalledWith(`scanAllConfigFiles - Error: ${keyOf("bad")}`, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledExactlyOnceWith("extra", term);
|
||||
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains a matched local file for the extra batch when its first store fails", async () => {
|
||||
const fixture = setup(["matched"], [row("matched")]);
|
||||
const error = new Error("first store");
|
||||
fixture.storeCustomisationFileV2.mockRejectedValueOnce(error);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||
["matched", term],
|
||||
["matched", term],
|
||||
]);
|
||||
expect(fixture.log).toHaveBeenCalledWith(`scanAllConfigFiles - Error: ${keyOf("matched")}`, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.events).toEqual(["store:matched"]);
|
||||
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("logs an extra-file processing error and continues with the remaining files", async () => {
|
||||
const fixture = setup(["bad", "other"]);
|
||||
const error = new Error("store");
|
||||
fixture.storeCustomisationFileV2.mockRejectedValueOnce(error);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||
["bad", term],
|
||||
["other", term],
|
||||
]);
|
||||
expect(fixture.log).toHaveBeenCalledWith("scanAllConfigFiles - Error: bad", LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfigSync scoped iteration composition", () => {
|
||||
it("uses bounded child scopes for both batches with the same shared values", async () => {
|
||||
const fixture = setup(["matched", "extra"], [row("matched"), row("stale")]);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(iterationModule.forEachInScope).toHaveBeenCalledTimes(2);
|
||||
const [database, extra] = vi.mocked(iterationModule.forEachInScope).mock.calls;
|
||||
expect(database[0]).toBe(extra[0]);
|
||||
expect(database[0].value).toEqual({ term, localFileMap: new Map([[keyOf("extra"), "extra"]]) });
|
||||
expect(database[2]).toEqual({ concurrency: 10 });
|
||||
expect(extra[2]).toEqual({ concurrency: 10 });
|
||||
expect(fixture.events).toEqual(["store:matched", `delete:${keyOf("stale")}`, "store:extra"]);
|
||||
});
|
||||
|
||||
it.each(["database", "extra"])("propagates a %s runner failure outside per-item recovery", async (batch) => {
|
||||
const fixture = setup(["extra"], [row("stale")]);
|
||||
const error = new Error("iteration");
|
||||
const actual = await vi.importActual<typeof import("octagonal-wheels/concurrency/scoped.iteration")>(
|
||||
"octagonal-wheels/concurrency/scoped.iteration"
|
||||
);
|
||||
if (batch === "extra") {
|
||||
vi.mocked(iterationModule.forEachInScope).mockImplementationOnce(actual.forEachInScope);
|
||||
}
|
||||
vi.mocked(iterationModule.forEachInScope).mockRejectedValueOnce(error);
|
||||
await expect(fixture.configSync.scanAllConfigFiles(false)).rejects.toBe(error);
|
||||
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(batch === "extra" ? 1 : 0);
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
expect(fixture.log).not.toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.updatePluginList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -55,11 +55,11 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
|
||||
import { cancelTask, EVEN, isCustomisationSyncMetadata, isPluginMetadata, scheduleTask } from "@/common/utils.ts";
|
||||
import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts";
|
||||
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { pluginScanningCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { base64ToArrayBuffer, base64ToString } from "octagonal-wheels/binary/base64";
|
||||
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import { forEachInScope } from "octagonal-wheels/concurrency/scoped.iteration";
|
||||
import { withScope } from "octagonal-wheels/resource/scope";
|
||||
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts";
|
||||
import { PluginDialogModal } from "./PluginDialogModal.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
@@ -68,10 +68,12 @@ import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
import { createModificationHandler } from "octagonal-wheels/modification";
|
||||
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
import { ConfigScanQueue } from "./ConfigScanQueue";
|
||||
|
||||
const d = "\u200b";
|
||||
const d2 = "\n";
|
||||
@@ -285,6 +287,10 @@ export type PluginDataExDisplay = {
|
||||
mtime: number;
|
||||
};
|
||||
type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile;
|
||||
type ConfigFileApplicationTarget = {
|
||||
readonly path: FilePath;
|
||||
readonly stat: { readonly mtime: number; readonly ctime: number };
|
||||
};
|
||||
|
||||
function categoryToFolder(category: string, configDir: string = ""): string {
|
||||
switch (category) {
|
||||
@@ -388,13 +394,6 @@ export type PluginDataEx = {
|
||||
};
|
||||
|
||||
export class ConfigSync extends LiveSyncCommands {
|
||||
constructor(core: LiveSyncCore) {
|
||||
super(core);
|
||||
pluginScanningCount.onChanged((e) => {
|
||||
const total = e.value;
|
||||
pluginIsEnumerating.set(total != 0);
|
||||
});
|
||||
}
|
||||
get configDir() {
|
||||
return this.core.services.API.getSystemConfigDir();
|
||||
}
|
||||
@@ -417,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
|
||||
pluginList: IPluginDataExDisplay[] = [];
|
||||
private pluginListGeneration = 0;
|
||||
private unloaded = false;
|
||||
showPluginSyncModal() {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
return;
|
||||
@@ -435,11 +435,18 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
this.pluginDialog = undefined;
|
||||
}
|
||||
}
|
||||
onunload() {
|
||||
onunload(): Promise<void> {
|
||||
this.unloaded = true;
|
||||
this.resetPluginList();
|
||||
const stopped = this.pluginScanProcessor.stop();
|
||||
pluginScanningCount.value = 0;
|
||||
pluginIsEnumerating.set(false);
|
||||
cancelTask("updatePluginListV2");
|
||||
cancelTask(UPDATED_CONFIGURATION_NOTICE_KEY);
|
||||
this.hidePluginSyncModal();
|
||||
this.periodicPluginSweepProcessor?.disable();
|
||||
this.services.context.notices.hide(UPDATED_CONFIGURATION_NOTICE_KEY);
|
||||
return stopped;
|
||||
}
|
||||
addRibbonIcon = this.services.API.addRibbonIcon.bind(this.services.API);
|
||||
onload() {
|
||||
@@ -536,11 +543,21 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
q?.toggleClass("sls-hidden", !this.isThisModuleEnabled());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async reloadPluginList(showMessage: boolean) {
|
||||
private isCurrentPluginList(generation: number): boolean {
|
||||
return !this.unloaded && generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
||||
}
|
||||
|
||||
private resetPluginList(): void {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginScanProcessor.clearPending();
|
||||
this.pluginList = [];
|
||||
this.loadedManifest_mTime.clear();
|
||||
pluginList.set(this.pluginList);
|
||||
}
|
||||
|
||||
async reloadPluginList(showMessage: boolean) {
|
||||
if (this.unloaded) return;
|
||||
this.resetPluginList();
|
||||
await this.updatePluginList(showMessage);
|
||||
}
|
||||
async loadPluginData(path: FilePathWithPrefix): Promise<PluginDataExDisplay | false> {
|
||||
@@ -575,77 +592,37 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
return false;
|
||||
}
|
||||
|
||||
pluginScanProcessor = new QueueProcessor(
|
||||
async (v: AnyEntry[]) => {
|
||||
const plugin = v[0];
|
||||
if (this.useV2) {
|
||||
await this.migrateV1ToV2(false, plugin);
|
||||
return [];
|
||||
}
|
||||
const path = plugin.path || this.getPath(plugin);
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == path);
|
||||
if (oldEntry && oldEntry.mtime == plugin.mtime) return [];
|
||||
pluginScanProcessor = new ConfigScanQueue<{ entry: AnyEntry; generation: number }>({
|
||||
process: async ({ entry: plugin, generation }) => {
|
||||
if (!this.isCurrentPluginList(generation)) return;
|
||||
try {
|
||||
if (this.useV2) {
|
||||
// Once started, migration owns its writes through completion.
|
||||
await this.migrateV1ToV2(false, plugin);
|
||||
return;
|
||||
}
|
||||
const path = plugin.path || this.getPath(plugin);
|
||||
const oldEntry = this.pluginList.find((entry) => entry.documentPath == path);
|
||||
if (oldEntry && oldEntry.mtime == plugin.mtime) return;
|
||||
const pluginData = await this.loadPluginData(path);
|
||||
if (!this.isCurrentPluginList(generation)) return;
|
||||
if (pluginData) {
|
||||
let newList = [...this.pluginList];
|
||||
newList = newList.filter((x) => x.documentPath != pluginData.documentPath);
|
||||
const newList = this.pluginList.filter((entry) => entry.documentPath != pluginData.documentPath);
|
||||
newList.push(pluginData);
|
||||
this.pluginList = newList;
|
||||
pluginList.set(newList);
|
||||
}
|
||||
// Failed to load
|
||||
return [];
|
||||
} catch (ex) {
|
||||
this._log(`Something happened at enumerating customization :${path}`, LOG_LEVEL_NOTICE);
|
||||
this._log(`Something happened at enumerating customization :${plugin.path}`, LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
delay: 100,
|
||||
yieldThreshold: 10,
|
||||
maintainDelay: false,
|
||||
totalRemainingReactiveSource: pluginScanningCount,
|
||||
}
|
||||
).startPipeline();
|
||||
|
||||
pluginScanProcessorV2 = new QueueProcessor(
|
||||
async (v: AnyEntry[]) => {
|
||||
const plugin = v[0];
|
||||
const path = plugin.path || this.getPath(plugin);
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == path);
|
||||
if (oldEntry && oldEntry.mtime == plugin.mtime) return [];
|
||||
try {
|
||||
const pluginData = await this.loadPluginData(path);
|
||||
if (pluginData) {
|
||||
let newList = [...this.pluginList];
|
||||
newList = newList.filter((x) => x.documentPath != pluginData.documentPath);
|
||||
newList.push(pluginData);
|
||||
this.pluginList = newList;
|
||||
pluginList.set(newList);
|
||||
}
|
||||
// Failed to load
|
||||
return [];
|
||||
} catch (ex) {
|
||||
this._log(`Something happened at enumerating customization :${path}`, LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return [];
|
||||
onProgress: (remaining) => {
|
||||
if (this.unloaded) return;
|
||||
pluginScanningCount.value = remaining;
|
||||
pluginIsEnumerating.set(remaining !== 0);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
delay: 100,
|
||||
yieldThreshold: 10,
|
||||
maintainDelay: false,
|
||||
totalRemainingReactiveSource: pluginScanningCount,
|
||||
}
|
||||
).startPipeline();
|
||||
});
|
||||
|
||||
filenameToUnifiedKey(path: string, termOverRide?: string) {
|
||||
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
|
||||
@@ -768,7 +745,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
|
||||
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
||||
const generation = this.pluginListGeneration;
|
||||
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
||||
const isCurrent = () => this.isCurrentPluginList(generation);
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
@@ -883,11 +860,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
||||
if (this.unloaded) return;
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginScanProcessor.clearQueue();
|
||||
this.pluginList = [];
|
||||
pluginList.set(this.pluginList);
|
||||
this.resetPluginList();
|
||||
return;
|
||||
}
|
||||
const generation = this.pluginListGeneration;
|
||||
@@ -903,7 +878,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
})
|
||||
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
||||
for await (const v of plugins) {
|
||||
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
|
||||
if (!this.isCurrentPluginList(generation)) return;
|
||||
if (v.deleted || v._deleted) continue;
|
||||
if (v.path.indexOf("%") !== -1) {
|
||||
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
||||
@@ -912,7 +887,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
|
||||
const path = v.path || this.getPath(v);
|
||||
if (updatedDocumentPath && updatedDocumentPath != path) continue;
|
||||
this.pluginScanProcessor.enqueue(v);
|
||||
this.pluginScanProcessor.enqueue({ entry: v, generation });
|
||||
}
|
||||
} finally {
|
||||
pluginIsEnumerating.set(false);
|
||||
@@ -1027,44 +1002,63 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
||||
} else {
|
||||
const files = data.files;
|
||||
const applyBinaryContent = createModificationHandler<
|
||||
ConfigFileApplicationTarget,
|
||||
LoadedEntryPluginDataExFile,
|
||||
ArrayBuffer,
|
||||
ArrayBuffer
|
||||
>({
|
||||
read: (target) => this.core.storageAccess.readHiddenFileBinary(target.path),
|
||||
onReadError: (ex, _path, file) => {
|
||||
this._log(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return new ArrayBuffer(0);
|
||||
},
|
||||
decode: (file) => base64ToArrayBuffer(file.data),
|
||||
isSame: (oldData, content) => isDocContentSame(oldData, content),
|
||||
write: (target, content) =>
|
||||
this.core.storageAccess.writeHiddenFileAuto(target.path, content, target.stat),
|
||||
});
|
||||
const applyTextContent = createModificationHandler<
|
||||
ConfigFileApplicationTarget,
|
||||
LoadedEntryPluginDataExFile,
|
||||
string,
|
||||
string
|
||||
>({
|
||||
read: (target) => this.core.storageAccess.readHiddenFileText(target.path),
|
||||
onReadError: (ex, _path, file) => {
|
||||
this._log(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return "";
|
||||
},
|
||||
decode: (file) => getDocData(file.data),
|
||||
isSame: (oldData, content) => isDocContentSame(oldData, content),
|
||||
write: (target, content) =>
|
||||
this.core.storageAccess.writeHiddenFileAuto(target.path, content, target.stat),
|
||||
});
|
||||
for (const f of files) {
|
||||
// If files have applied, modified time will be updated to the current time.
|
||||
const stat = { mtime: f.mtime, ctime: f.ctime };
|
||||
const path = `${baseDir}/${f.filename}` as FilePath;
|
||||
const target: ConfigFileApplicationTarget = {
|
||||
path,
|
||||
stat: { mtime: f.mtime, ctime: f.ctime },
|
||||
};
|
||||
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
|
||||
// const contentEach = createBlob(f.data);
|
||||
await this.core.storageAccess.ensureDir(path);
|
||||
|
||||
if (f.datatype == "newnote") {
|
||||
let oldData;
|
||||
try {
|
||||
oldData = await this.core.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (ex) {
|
||||
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
oldData = new ArrayBuffer(0);
|
||||
}
|
||||
const content = base64ToArrayBuffer(f.data);
|
||||
if (await isDocContentSame(oldData, content)) {
|
||||
const result = await applyBinaryContent(target, f);
|
||||
if (result == "unchanged") {
|
||||
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
await this.core.storageAccess.writeHiddenFileAuto(path, content, stat);
|
||||
} else {
|
||||
let oldData;
|
||||
try {
|
||||
oldData = await this.core.storageAccess.readHiddenFileText(path);
|
||||
} catch (ex) {
|
||||
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
oldData = "";
|
||||
}
|
||||
const content = getDocData(f.data);
|
||||
if (await isDocContentSame(oldData, content)) {
|
||||
const result = await applyTextContent(target, f);
|
||||
if (result == "unchanged") {
|
||||
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
await this.core.storageAccess.writeHiddenFileAuto(path, content, stat);
|
||||
}
|
||||
this._log(`Applied ${f.filename} of ${data.displayName || data.name}..`);
|
||||
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
||||
@@ -1215,6 +1209,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
return true;
|
||||
}
|
||||
_everyRealizeSettingSyncMode(): Promise<boolean> {
|
||||
if (!this.isThisModuleEnabled()) this.resetPluginList();
|
||||
// applySettings calls onResuming after this handler when synchronisation is active.
|
||||
// That handler scans and restarts the sweep; doing so here would scan twice.
|
||||
this.periodicPluginSweepProcessor?.disable();
|
||||
@@ -1613,49 +1608,35 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, {
|
||||
include_docs: true,
|
||||
});
|
||||
const tasks = [] as (() => Promise<void>)[];
|
||||
const concurrency = 10;
|
||||
const semaphore = Semaphore(concurrency);
|
||||
const databaseEntries: { _id: string; path: FilePathWithPrefix }[] = [];
|
||||
for await (const item of entries) {
|
||||
if (item.path.indexOf("%") !== -1) {
|
||||
continue;
|
||||
}
|
||||
tasks.push(async () => {
|
||||
const releaser = await semaphore.acquire();
|
||||
if (item.path.indexOf("%") === -1) databaseEntries.push(item);
|
||||
}
|
||||
await withScope({ term, localFileMap }, async (scope) => {
|
||||
await forEachInScope(scope, databaseEntries, { concurrency: 10 }, async (item, ctx) => {
|
||||
try {
|
||||
const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix;
|
||||
const localPath = localFileMap.get(unifiedFilenameWithKey);
|
||||
const localPath = ctx.value.localFileMap.get(unifiedFilenameWithKey);
|
||||
if (localPath) {
|
||||
await this.storeCustomisationFileV2(localPath, term);
|
||||
localFileMap.delete(unifiedFilenameWithKey);
|
||||
await this.storeCustomisationFileV2(localPath, ctx.value.term);
|
||||
ctx.value.localFileMap.delete(unifiedFilenameWithKey);
|
||||
} else {
|
||||
await this.deleteConfigOnDatabase(unifiedFilenameWithKey);
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(`scanAllConfigFiles - Error: ${item._id}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
} finally {
|
||||
releaser();
|
||||
}
|
||||
});
|
||||
}
|
||||
await Promise.all(tasks.map((e) => e()));
|
||||
// Extra files
|
||||
const taskExtra = [] as (() => Promise<void>)[];
|
||||
for (const [, filePath] of localFileMap) {
|
||||
taskExtra.push(async () => {
|
||||
const releaser = await semaphore.acquire();
|
||||
await forEachInScope(scope, localFileMap.values(), { concurrency: 10 }, async (filePath, ctx) => {
|
||||
try {
|
||||
await this.storeCustomisationFileV2(filePath, term);
|
||||
await this.storeCustomisationFileV2(filePath, ctx.value.term);
|
||||
} catch (ex) {
|
||||
this._log(`scanAllConfigFiles - Error: ${filePath}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
} finally {
|
||||
releaser();
|
||||
}
|
||||
});
|
||||
}
|
||||
await Promise.all(taskExtra.map((e) => e()));
|
||||
});
|
||||
fireAndForget(() => this.updatePluginList(false));
|
||||
} else {
|
||||
const files = filesAll
|
||||
@@ -1765,6 +1746,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
this.core.settings.usePluginSync = false;
|
||||
this.core.settings.autoSweepPlugins = false;
|
||||
}
|
||||
this.resetPluginList();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createNotifier } from "octagonal-wheels/concurrency/notifier";
|
||||
import { forEachInScope } from "octagonal-wheels/concurrency/scoped.iteration";
|
||||
import { createAsyncQueue, type AsyncQueue } from "octagonal-wheels/iterable/asyncQueue";
|
||||
import { withScope, type Awaitable } from "octagonal-wheels/resource/scope";
|
||||
|
||||
interface ConfigScanQueueOptions<T> {
|
||||
/** The caller logs recoverable item failures and decides whether to continue. */
|
||||
process: (item: T) => Awaitable<void>;
|
||||
onProgress?: (remaining: number) => void;
|
||||
}
|
||||
|
||||
interface PendingQueue<T> {
|
||||
fifo: AsyncQueue<T>;
|
||||
iterator: AsyncIterator<T>;
|
||||
}
|
||||
|
||||
interface PendingScan<T> {
|
||||
value: T;
|
||||
queue: PendingQueue<T>;
|
||||
}
|
||||
|
||||
/** Owns FIFO admission, the initial scan delay, and completion for one catalogue. */
|
||||
export class ConfigScanQueue<T> {
|
||||
private pending: PendingQueue<T>;
|
||||
private pendingCount = 0;
|
||||
private active = 0;
|
||||
private ready = false;
|
||||
private stopped = false;
|
||||
private stoppingAfterFailure = false;
|
||||
private readonly failures: unknown[] = [];
|
||||
private timer?: CompatTimeoutHandle;
|
||||
private running?: Promise<void>;
|
||||
private readonly changed = createNotifier();
|
||||
|
||||
constructor(private readonly options: ConfigScanQueueOptions<T>) {
|
||||
this.pending = this.createPendingQueue();
|
||||
}
|
||||
|
||||
private createPendingQueue(): PendingQueue<T> {
|
||||
const fifo = createAsyncQueue<T>();
|
||||
return { fifo, iterator: fifo[Symbol.asyncIterator]() };
|
||||
}
|
||||
|
||||
get remaining(): number {
|
||||
return this.pendingCount + this.active;
|
||||
}
|
||||
|
||||
enqueue(value: T): void {
|
||||
if (!this.canDispatch()) throw new Error("The customisation scan queue is closed.");
|
||||
this.pending.fifo.enqueue(value);
|
||||
this.pendingCount++;
|
||||
this.reportProgress();
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
/** Discard unstarted scans; active reads and migrations retain their slots. */
|
||||
clearPending(): void {
|
||||
const discarded = this.pending;
|
||||
this.pending = this.createPendingQueue();
|
||||
this.pendingCount = 0;
|
||||
this.ready = false;
|
||||
this.clearTimer();
|
||||
discarded.fifo.dispose();
|
||||
this.reportProgress();
|
||||
}
|
||||
|
||||
/** Stop admission and join active work. A processor must not await its own stop. */
|
||||
stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.clearPending();
|
||||
return this.waitForIdle();
|
||||
}
|
||||
|
||||
/** Include later arrivals until the queue is idle; this does not close admission. */
|
||||
async waitForIdle(): Promise<void> {
|
||||
this.schedule();
|
||||
while (this.running || this.timer !== undefined) await this.changed.wait();
|
||||
if (this.failures.length === 1) throw this.failures[0];
|
||||
if (this.failures.length > 1) {
|
||||
throw Object.assign(new Error("Customisation scans failed."), { errors: [...this.failures] });
|
||||
}
|
||||
}
|
||||
|
||||
private canDispatch(): boolean {
|
||||
return !this.stopped && !this.stoppingAfterFailure;
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer === undefined) return;
|
||||
compatGlobal.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private recordFailure(error: unknown): void {
|
||||
this.stoppingAfterFailure = true;
|
||||
this.failures.push(error);
|
||||
this.clearTimer();
|
||||
this.changed.notify();
|
||||
}
|
||||
|
||||
private reportProgress(): void {
|
||||
if (this.failures.length === 0) {
|
||||
try {
|
||||
this.options.onProgress?.(this.remaining);
|
||||
} catch (error) {
|
||||
this.recordFailure(error);
|
||||
}
|
||||
}
|
||||
this.changed.notify();
|
||||
}
|
||||
|
||||
private claim(entry: PendingScan<T>): boolean {
|
||||
if (!this.canDispatch() || this.pending !== entry.queue) return false;
|
||||
this.pendingCount--;
|
||||
if (this.pendingCount === 0) this.ready = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async *availableWork() {
|
||||
while (this.canDispatch()) {
|
||||
const pending = this.pending;
|
||||
if (!this.ready) {
|
||||
if (this.active === 0 && this.pendingCount === 0) return;
|
||||
await this.changed.wait();
|
||||
continue;
|
||||
}
|
||||
if (pending.fifo.size > 0) {
|
||||
const entry = await pending.iterator.next();
|
||||
if (entry.done) {
|
||||
if (pending !== this.pending) continue;
|
||||
return;
|
||||
}
|
||||
// The callback checks queue identity so clearing a pulled item cancels it.
|
||||
yield { value: entry.value, queue: pending };
|
||||
} else if (this.active > 0 || this.pendingCount > 0) {
|
||||
// Keep free slots available to later arrivals while a slow scan finishes.
|
||||
await this.changed.wait();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.canDispatch() || this.pendingCount === 0) return;
|
||||
if (!this.ready) {
|
||||
if (this.pendingCount > 10) {
|
||||
this.ready = true;
|
||||
this.clearTimer();
|
||||
} else if (this.timer === undefined) {
|
||||
this.timer = compatGlobal.setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
this.ready = true;
|
||||
this.schedule();
|
||||
this.changed.notify();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
if (!this.ready || this.running) return;
|
||||
this.running = Promise.resolve()
|
||||
.then(() =>
|
||||
withScope(undefined, (parent) =>
|
||||
forEachInScope(parent, this.availableWork(), { concurrency: 10 }, async (entry, child) => {
|
||||
if (!this.claim(entry)) return;
|
||||
this.active++;
|
||||
child.defer(() => {
|
||||
this.active--;
|
||||
this.reportProgress();
|
||||
});
|
||||
this.reportProgress();
|
||||
if (!this.canDispatch()) return;
|
||||
try {
|
||||
await this.options.process(entry.value);
|
||||
} catch (error) {
|
||||
this.stoppingAfterFailure = true;
|
||||
this.clearTimer();
|
||||
this.changed.notify();
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
.then(
|
||||
(): void => undefined,
|
||||
(error: unknown) => this.recordFailure(error)
|
||||
)
|
||||
.finally(() => {
|
||||
this.running = undefined;
|
||||
this.schedule();
|
||||
this.changed.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConfigScanQueue } from "./ConfigScanQueue";
|
||||
|
||||
const queues: { stop(): Promise<void> }[] = [];
|
||||
const releases: (() => void)[] = [];
|
||||
|
||||
function gate() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
releases.push(resolve);
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function queue<T>(options: ConstructorParameters<typeof ConfigScanQueue<T>>[0]) {
|
||||
const result = new ConfigScanQueue(options);
|
||||
queues.push(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(async () => {
|
||||
for (const release of releases.splice(0)) release();
|
||||
await Promise.all(queues.splice(0).map((item) => item.stop().catch(() => {})));
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ConfigScanQueue", () => {
|
||||
it("waits from the first arrival without extending the delay for later arrivals", async () => {
|
||||
const seen: string[] = [];
|
||||
const scans = queue<string>({
|
||||
process: (item) => {
|
||||
seen.push(item);
|
||||
},
|
||||
});
|
||||
scans.enqueue("first");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
scans.enqueue("second");
|
||||
await vi.advanceTimersByTimeAsync(49);
|
||||
expect(seen).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await scans.waitForIdle();
|
||||
expect(seen).toEqual(["first", "second"]);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("bypasses the delay only when pending work exceeds ten items", async () => {
|
||||
const seen: number[] = [];
|
||||
const scans = queue<number>({
|
||||
process: (item) => {
|
||||
seen.push(item);
|
||||
},
|
||||
});
|
||||
for (let item = 0; item < 10; item++) scans.enqueue(item);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(seen).toEqual([]);
|
||||
scans.enqueue(10);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await scans.waitForIdle();
|
||||
expect(seen).toEqual(Array.from({ length: 11 }, (_, index) => index));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps duplicate items in FIFO order and admits at most ten active scans", async () => {
|
||||
const finish = Array.from({ length: 13 }, () => gate());
|
||||
const input = Array.from({ length: 13 }, (_, index) => index % 3);
|
||||
const seen: number[] = [];
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
const progress: number[] = [];
|
||||
const scans = queue<number>({
|
||||
process: async (item) => {
|
||||
const index = seen.length;
|
||||
seen.push(item);
|
||||
maximum = Math.max(maximum, ++active);
|
||||
await finish[index].promise;
|
||||
active--;
|
||||
},
|
||||
onProgress: (remaining) => progress.push(remaining),
|
||||
});
|
||||
input.forEach((item) => scans.enqueue(item));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(seen).toEqual(input.slice(0, 10));
|
||||
expect(scans.remaining).toBe(13);
|
||||
finish[3].resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(seen).toEqual(input.slice(0, 11));
|
||||
finish.forEach((item) => item.resolve());
|
||||
await scans.waitForIdle();
|
||||
expect(seen).toEqual(input);
|
||||
expect(maximum).toBe(10);
|
||||
expect(scans.remaining).toBe(0);
|
||||
expect(progress[progress.length - 1]).toBe(0);
|
||||
});
|
||||
|
||||
it("starts a later arrival in a free slot before an earlier slow scan finishes", async () => {
|
||||
const slow = gate();
|
||||
const seen: string[] = [];
|
||||
const scans = queue<string>({
|
||||
process: async (item) => {
|
||||
seen.push(item);
|
||||
if (item === "slow") await slow.promise;
|
||||
},
|
||||
});
|
||||
scans.enqueue("slow");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
let idle = false;
|
||||
const done = scans.waitForIdle().then(() => {
|
||||
idle = true;
|
||||
});
|
||||
scans.enqueue("later");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(["slow", "later"]);
|
||||
expect(idle).toBe(false);
|
||||
slow.resolve();
|
||||
await done;
|
||||
expect(scans.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("clears delayed scans and gives replacement work its own delay", async () => {
|
||||
const seen: string[] = [];
|
||||
const scans = queue<string>({
|
||||
process: (item) => {
|
||||
seen.push(item);
|
||||
},
|
||||
});
|
||||
scans.enqueue("old");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
scans.clearPending();
|
||||
expect(scans.remaining).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await scans.waitForIdle();
|
||||
scans.enqueue("new");
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(seen).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await scans.waitForIdle();
|
||||
expect(seen).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("discards all unstarted work while active scans remain tracked", async () => {
|
||||
const finish = gate();
|
||||
const seen: number[] = [];
|
||||
const scans = queue<number>({
|
||||
process: async (item) => {
|
||||
seen.push(item);
|
||||
await finish.promise;
|
||||
},
|
||||
});
|
||||
for (let item = 0; item < 50; item++) scans.enqueue(item);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(seen).toHaveLength(10);
|
||||
scans.clearPending();
|
||||
expect(scans.remaining).toBe(10);
|
||||
let idle = false;
|
||||
const done = scans.waitForIdle().then(() => {
|
||||
idle = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(idle).toBe(false);
|
||||
finish.resolve();
|
||||
await done;
|
||||
expect(seen).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||
expect(scans.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("invalidates prefetched work after clearing and admits replacements alongside active scans", async () => {
|
||||
const finishFirst = gate();
|
||||
const finishReplacement = gate();
|
||||
let scans!: ConfigScanQueue<string>;
|
||||
const seen: string[] = [];
|
||||
scans = queue<string>({
|
||||
process: async (item) => {
|
||||
seen.push(item);
|
||||
if (item === "first") {
|
||||
void Promise.resolve().then(() => Promise.resolve().then(() => scans.clearPending()));
|
||||
await finishFirst.promise;
|
||||
} else if (item === "replacement") {
|
||||
await finishReplacement.promise;
|
||||
}
|
||||
},
|
||||
});
|
||||
scans.enqueue("first");
|
||||
scans.enqueue("stale");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(["first"]);
|
||||
expect(scans.remaining).toBe(1);
|
||||
|
||||
scans.enqueue("replacement");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(["first", "replacement"]);
|
||||
expect(scans.remaining).toBe(2);
|
||||
|
||||
finishFirst.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(scans.remaining).toBe(1);
|
||||
finishReplacement.resolve();
|
||||
await scans.waitForIdle();
|
||||
expect(scans.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the ten-scan limit across clear and replacement work", async () => {
|
||||
const finish = Array.from({ length: 12 }, () => gate());
|
||||
const seen: number[] = [];
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
const scans = queue<number>({
|
||||
process: async (item) => {
|
||||
seen.push(item);
|
||||
maximum = Math.max(maximum, ++active);
|
||||
await finish[item].promise;
|
||||
active--;
|
||||
},
|
||||
});
|
||||
for (let item = 0; item < 10; item++) scans.enqueue(item);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||
|
||||
scans.clearPending();
|
||||
scans.enqueue(10);
|
||||
scans.enqueue(11);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||
expect(maximum).toBe(10);
|
||||
|
||||
finish[0].resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(seen).toEqual(Array.from({ length: 11 }, (_, index) => index));
|
||||
expect(maximum).toBe(10);
|
||||
finish.forEach((item) => item.resolve());
|
||||
await scans.waitForIdle();
|
||||
expect(seen).toEqual(Array.from({ length: 12 }, (_, index) => index));
|
||||
expect(maximum).toBe(10);
|
||||
});
|
||||
|
||||
it("stops while waiting for input, joins active work, and rejects later admission", async () => {
|
||||
const finish = gate();
|
||||
const seen: number[] = [];
|
||||
const scans = queue<number>({
|
||||
process: async (item) => {
|
||||
seen.push(item);
|
||||
await finish.promise;
|
||||
},
|
||||
});
|
||||
scans.enqueue(1);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
scans.enqueue(2);
|
||||
let stopped = false;
|
||||
const done = scans.stop().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
expect(() => scans.enqueue(3)).toThrow("closed");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(stopped).toBe(false);
|
||||
finish.resolve();
|
||||
await done;
|
||||
expect(seen).toEqual([1]);
|
||||
expect(scans.remaining).toBe(0);
|
||||
await scans.stop();
|
||||
});
|
||||
|
||||
it("stops before the first scan without leaving a timer", async () => {
|
||||
const process = vi.fn();
|
||||
const scans = queue({ process });
|
||||
scans.enqueue("pending");
|
||||
await scans.stop();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(process).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("observes an unhandled item failure after other started work settles", async () => {
|
||||
const fail = gate();
|
||||
const finish = gate();
|
||||
const failure = new Error("read failed");
|
||||
const seen: number[] = [];
|
||||
const scans = queue<number>({
|
||||
process: async (item) => {
|
||||
seen.push(item);
|
||||
if (item === 0) {
|
||||
await fail.promise;
|
||||
throw failure;
|
||||
}
|
||||
await finish.promise;
|
||||
},
|
||||
});
|
||||
for (let item = 0; item < 12; item++) scans.enqueue(item);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
let error: unknown;
|
||||
const done = scans.waitForIdle().catch((reason: unknown) => {
|
||||
error = reason;
|
||||
});
|
||||
fail.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(error).toBeUndefined();
|
||||
expect(seen).toHaveLength(10);
|
||||
expect(() => scans.enqueue(12)).toThrow("closed");
|
||||
finish.resolve();
|
||||
await done;
|
||||
expect(error).toBe(failure);
|
||||
expect(scans.remaining).toBe(2);
|
||||
});
|
||||
|
||||
it("includes work enqueued by an active scan in an existing idle wait", async () => {
|
||||
const seen: string[] = [];
|
||||
const scans = queue<string>({
|
||||
process: (item) => {
|
||||
seen.push(item);
|
||||
if (item === "first") scans.enqueue("follow-up");
|
||||
},
|
||||
});
|
||||
scans.enqueue("first");
|
||||
let idle = false;
|
||||
const done = scans.waitForIdle().then(() => {
|
||||
idle = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(seen).toEqual(["first"]);
|
||||
expect(idle).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await done;
|
||||
expect(seen).toEqual(["first", "follow-up"]);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("wakes an input wait when its only active scan fails", async () => {
|
||||
const finish = gate();
|
||||
const failure = new Error("read failed");
|
||||
const scans = queue<string>({
|
||||
process: async () => {
|
||||
await finish.promise;
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
scans.enqueue("only");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
const done = expect(scans.waitForIdle()).rejects.toBe(failure);
|
||||
finish.resolve();
|
||||
await done;
|
||||
expect(scans.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an existing idle wait if observing completion fails", async () => {
|
||||
const finish = gate();
|
||||
const failure = new Error("observer failed");
|
||||
const scans = queue<string>({
|
||||
process: () => finish.promise,
|
||||
onProgress: (remaining) => {
|
||||
if (remaining === 0) throw failure;
|
||||
},
|
||||
});
|
||||
scans.enqueue("active");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
const done = expect(scans.waitForIdle()).rejects.toBe(failure);
|
||||
finish.resolve();
|
||||
await done;
|
||||
expect(scans.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("retains both an observer failure and a later failure from active work", async () => {
|
||||
const finish = gate();
|
||||
const observerFailure = new Error("observer failed");
|
||||
const readFailure = new Error("read failed");
|
||||
const scans = queue<string>({
|
||||
process: async () => {
|
||||
await finish.promise;
|
||||
throw readFailure;
|
||||
},
|
||||
onProgress: (remaining) => {
|
||||
if (remaining === 2) throw observerFailure;
|
||||
},
|
||||
});
|
||||
scans.enqueue("active");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
scans.enqueue("pending");
|
||||
const done = expect(scans.waitForIdle()).rejects.toMatchObject({
|
||||
message: "Customisation scans failed.",
|
||||
errors: [observerFailure, readFailure],
|
||||
});
|
||||
finish.resolve();
|
||||
await done;
|
||||
});
|
||||
|
||||
it("wakes an idle waiter when the progress observer fails", async () => {
|
||||
const failure = new Error("observer failed");
|
||||
const process = vi.fn();
|
||||
const scans = queue<string>({
|
||||
process,
|
||||
onProgress: () => {
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
scans.enqueue("pending");
|
||||
await expect(scans.waitForIdle()).rejects.toBe(failure);
|
||||
expect(process).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type DocumentID,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||
LiveSyncCommands: class LiveSyncCommands {
|
||||
plugin!: { app: unknown };
|
||||
core!: { services: unknown; settings: unknown };
|
||||
get app() {
|
||||
return this.plugin.app;
|
||||
}
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
get settings() {
|
||||
return this.core.settings;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
configureHiddenFileSyncMode: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||
|
||||
function createProgress(events: string[] = []) {
|
||||
return {
|
||||
log: vi.fn((message: string) => {
|
||||
if (/^(?:\d+\/\d+|Checking \d+\/\d+)$/.test(message)) events.push(message);
|
||||
}),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createStoragePaths(count: number): FilePath[] {
|
||||
return Array.from({ length: count }, (_, index) => `.obsidian/plugins/example/${index}.json` as FilePath);
|
||||
}
|
||||
|
||||
function createDatabaseEntries(count: number): MetaEntry[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
({
|
||||
_id: `i:.obsidian/plugins/example/${index}.json` as DocumentID,
|
||||
_rev: "1-test",
|
||||
path: `i:.obsidian/plugins/example/${index}.json` as FilePathWithPrefix,
|
||||
ctime: 1,
|
||||
mtime: 1,
|
||||
size: 0,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
deleted: false,
|
||||
}) as MetaEntry
|
||||
);
|
||||
}
|
||||
|
||||
function createHiddenFileSync(count: number, events: string[] = []) {
|
||||
const progress = createProgress(events);
|
||||
const statHidden = vi.fn(async () => {
|
||||
events.push(`stat:${statHidden.mock.calls.length}`);
|
||||
return { mtime: 1 };
|
||||
});
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: vi.fn(),
|
||||
_fileInfoLastProcessed: new Map(),
|
||||
_databaseInfoLastProcessed: new Map(),
|
||||
scanInternalFileNames: vi.fn(async () => createStoragePaths(count)),
|
||||
getAllDatabaseFiles: vi.fn(async () => createDatabaseEntries(count)),
|
||||
getPath: vi.fn((file: MetaEntry) => file.path),
|
||||
updateLastProcessed: vi.fn(),
|
||||
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||
resetLastProcessedDatabase: vi.fn(),
|
||||
resetLastProcessedFile: vi.fn(),
|
||||
useStorageFiles: vi.fn(async () => false),
|
||||
useDatabaseFiles: vi.fn(async () => false),
|
||||
trackStorageFileModification: vi.fn(async () => false),
|
||||
trackDatabaseFileModification: vi.fn(async () => false),
|
||||
core: { storageAccess: { statHidden } },
|
||||
});
|
||||
return { hiddenFileSync, progress, statHidden };
|
||||
}
|
||||
|
||||
describe("HiddenFileSync attempt progress", () => {
|
||||
it("reports applyOfflineChanges attempts at 1 and each 25 items, without a final partial count", async () => {
|
||||
const events: string[] = [];
|
||||
const { hiddenFileSync, progress } = createHiddenFileSync(52, events);
|
||||
|
||||
await hiddenFileSync.applyOfflineChanges(false);
|
||||
|
||||
expect(progress.log.mock.calls.map(([message]) => message).filter((message) => /^\d+\/\d+$/.test(message))).toEqual([
|
||||
"1/52",
|
||||
"26/52",
|
||||
"51/52",
|
||||
]);
|
||||
expect(events.indexOf("51/52")).toBeLessThan(events.indexOf("stat:1"));
|
||||
});
|
||||
|
||||
it("reports rebuildMerging attempts at 1 and each 100 items, without a final partial count", async () => {
|
||||
const events: string[] = [];
|
||||
const { hiddenFileSync, progress } = createHiddenFileSync(202, events);
|
||||
|
||||
await hiddenFileSync.rebuildMerging(false);
|
||||
|
||||
expect(progress.log.mock.calls.map(([message]) => message).filter((message) => message.startsWith("Checking "))).toEqual([
|
||||
"Checking 1/202",
|
||||
"Checking 101/202",
|
||||
"Checking 201/202",
|
||||
]);
|
||||
expect(events.indexOf("Checking 101/202")).toBeLessThan(events.indexOf("stat:101"));
|
||||
});
|
||||
|
||||
it("preserves stat failures from applyOfflineChanges", async () => {
|
||||
const { hiddenFileSync, progress, statHidden } = createHiddenFileSync(1);
|
||||
const error = new Error("stat failed");
|
||||
statHidden.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(hiddenFileSync.applyOfflineChanges(false)).rejects.toBe(error);
|
||||
expect(progress.done).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves stat failures from rebuildMerging", async () => {
|
||||
const { hiddenFileSync, progress, statHidden } = createHiddenFileSync(1);
|
||||
const error = new Error("stat failed");
|
||||
statHidden.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(hiddenFileSync.rebuildMerging(false)).rejects.toBe(error);
|
||||
expect(progress.done).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,39 @@ afterEach(async () => {
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Hidden File Sync initial modification times", () => {
|
||||
it.each([
|
||||
{ label: "newer", localMtime: 30_000, expected: "local content" },
|
||||
{ label: "older", localMtime: 10_000, expected: "database content" },
|
||||
{ label: "equal", localMtime: 20_000, expected: "database content" },
|
||||
])("stores only a newer local file when the local timestamp is $label", async ({ localMtime, expected }) => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
const entry = optionalFileEntry(`i:${path}`, "database content");
|
||||
entry.mtime = 20_000;
|
||||
await fixture.localDatabase.putDBEntry(entry);
|
||||
storageAccess.setFile("local content", localMtime);
|
||||
|
||||
await hiddenFileSync.trackStorageFileModification(path, true);
|
||||
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
if (saved) {
|
||||
expect(await getText(saved)).toBe(expected);
|
||||
expect(saved.mtime).toBe(expected === "local content" ? localMtime : 20_000);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores a local-only file when only newer changes are requested", async () => {
|
||||
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
||||
|
||||
await hiddenFileSync.trackStorageFileModification(path, true);
|
||||
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
if (saved) expect(await getText(saved)).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hidden File Sync optional save outcomes", () => {
|
||||
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
|
||||
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type DocumentID,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||
LiveSyncCommands: class LiveSyncCommands {
|
||||
plugin!: { app: unknown };
|
||||
core!: { services: unknown; settings: unknown };
|
||||
get app() {
|
||||
return this.plugin.app;
|
||||
}
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
get settings() {
|
||||
return this.core.settings;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
configureHiddenFileSyncMode: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||
|
||||
function createProgress() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createHiddenFileSync() {
|
||||
const progress = createProgress();
|
||||
const log = vi.fn();
|
||||
const trackStorageFileModification = vi.fn(async (_file: FilePath) => false);
|
||||
const trackDatabaseFileModification = vi.fn(async (_path: FilePath) => false);
|
||||
const isTargetFile = vi.fn(async (_path: FilePath) => true);
|
||||
const getPath = vi.fn((file: MetaEntry) => file.path);
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
|
||||
Object.assign(hiddenFileSync, {
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: log,
|
||||
trackStorageFileModification,
|
||||
trackDatabaseFileModification,
|
||||
isTargetFile,
|
||||
getPath,
|
||||
});
|
||||
|
||||
return {
|
||||
hiddenFileSync,
|
||||
progress,
|
||||
log,
|
||||
trackStorageFileModification,
|
||||
trackDatabaseFileModification,
|
||||
isTargetFile,
|
||||
};
|
||||
}
|
||||
|
||||
function createStoragePaths(count: number): FilePath[] {
|
||||
return Array.from({ length: count }, (_, index) => `.obsidian/plugins/example/${index}.json` as FilePath);
|
||||
}
|
||||
|
||||
function createDatabaseEntries(count: number): MetaEntry[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
({
|
||||
_id: `i:.obsidian/plugins/example/${index}.json` as DocumentID,
|
||||
_rev: "1-test",
|
||||
path: `i:.obsidian/plugins/example/${index}.json` as FilePathWithPrefix,
|
||||
ctime: 1,
|
||||
mtime: 1,
|
||||
size: 0,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
deleted: false,
|
||||
}) as MetaEntry
|
||||
);
|
||||
}
|
||||
|
||||
describe("HiddenFileSync batch progress consumers", () => {
|
||||
it("counts completed storage modifications even when they return false", async () => {
|
||||
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||
const files = createStoragePaths(101);
|
||||
|
||||
await hiddenFileSync.trackScannedStorageChanges(files);
|
||||
|
||||
expect(trackStorageFileModification).toHaveBeenCalledTimes(101);
|
||||
expect(progress.log.mock.calls).toEqual([["1/101"], ["101/101"]]);
|
||||
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs and skips failed storage items when counting progress", async () => {
|
||||
const { hiddenFileSync, progress, log, trackStorageFileModification } = createHiddenFileSync();
|
||||
const files = createStoragePaths(100);
|
||||
const failingFile = files[50];
|
||||
const error = new Error("Storage item failed");
|
||||
trackStorageFileModification.mockImplementation(async (file) => {
|
||||
if (file === failingFile) throw error;
|
||||
return false;
|
||||
});
|
||||
|
||||
await hiddenFileSync.trackScannedStorageChanges(files);
|
||||
|
||||
expect(progress.once).toHaveBeenCalledWith(`Failed to process storage change file:${failingFile}`);
|
||||
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(progress.log.mock.calls).toEqual([["1/100"]]);
|
||||
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("counts completed database items, including filtered items and false returns", async () => {
|
||||
const { hiddenFileSync, progress, trackDatabaseFileModification, isTargetFile } = createHiddenFileSync();
|
||||
const files = createDatabaseEntries(101);
|
||||
const filteredPath = ".obsidian/plugins/example/50.json" as FilePath;
|
||||
isTargetFile.mockImplementation(async (path) => path !== filteredPath);
|
||||
|
||||
await hiddenFileSync.trackScannedDatabaseChange(files);
|
||||
|
||||
expect(isTargetFile).toHaveBeenCalledTimes(101);
|
||||
expect(trackDatabaseFileModification).toHaveBeenCalledTimes(100);
|
||||
expect(progress.log.mock.calls).toEqual([["1/101"], ["101/101"]]);
|
||||
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs and skips failed database scan items when counting progress", async () => {
|
||||
const { hiddenFileSync, progress, log, trackDatabaseFileModification } = createHiddenFileSync();
|
||||
const files = createDatabaseEntries(101);
|
||||
const failingPath = ".obsidian/plugins/example/50.json" as FilePath;
|
||||
const error = new Error("Database scan item failed");
|
||||
trackDatabaseFileModification.mockImplementation(async (path) => {
|
||||
if (path === failingPath) throw error;
|
||||
return false;
|
||||
});
|
||||
|
||||
await hiddenFileSync.trackScannedDatabaseChange(files);
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to process storage change file:"),
|
||||
expect.anything()
|
||||
);
|
||||
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(progress.log.mock.calls).toEqual([["1/101"]]);
|
||||
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses the database-file progress interval and counts false returns", async () => {
|
||||
const { hiddenFileSync, progress, trackDatabaseFileModification } = createHiddenFileSync();
|
||||
const files = createDatabaseEntries(26);
|
||||
|
||||
await expect(hiddenFileSync.useDatabaseFiles(files)).resolves.toBe(true);
|
||||
|
||||
expect(trackDatabaseFileModification).toHaveBeenCalledTimes(26);
|
||||
expect(progress.log.mock.calls).toEqual([["Scanning database files..."], ["1/26"], ["26/26"]]);
|
||||
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs and skips failed database-file items when counting progress", async () => {
|
||||
const { hiddenFileSync, progress, log, trackDatabaseFileModification } = createHiddenFileSync();
|
||||
const files = createDatabaseEntries(26);
|
||||
const failingPath = ".obsidian/plugins/example/12.json" as FilePath;
|
||||
const error = new Error("Database file item failed");
|
||||
trackDatabaseFileModification.mockImplementation(async (path) => {
|
||||
if (path === failingPath) throw error;
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(hiddenFileSync.useDatabaseFiles(files)).resolves.toBe(true);
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed to process database changes:"));
|
||||
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(progress.log.mock.calls).toEqual([["Scanning database files..."], ["1/26"]]);
|
||||
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts all storage work eagerly and calls done only after all promises resolve", async () => {
|
||||
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||
const files = createStoragePaths(101);
|
||||
const resolveModifications: Array<(result: boolean) => void> = [];
|
||||
trackStorageFileModification.mockImplementation(
|
||||
() => new Promise<boolean>((resolve) => resolveModifications.push(resolve))
|
||||
);
|
||||
|
||||
const operation = hiddenFileSync.trackScannedStorageChanges(files);
|
||||
expect(trackStorageFileModification).toHaveBeenCalledTimes(files.length);
|
||||
expect(progress.done).not.toHaveBeenCalled();
|
||||
|
||||
for (const resolveModification of resolveModifications) resolveModification(false);
|
||||
await operation;
|
||||
|
||||
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not call done when the item error handler rejects the batch", async () => {
|
||||
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||
const itemError = new Error("Storage item failed");
|
||||
const handlerError = new Error("Progress error handler failed");
|
||||
trackStorageFileModification.mockRejectedValueOnce(itemError);
|
||||
progress.once.mockImplementation(() => {
|
||||
throw handlerError;
|
||||
});
|
||||
|
||||
await expect(hiddenFileSync.trackScannedStorageChanges(createStoragePaths(1))).rejects.toBe(handlerError);
|
||||
expect(progress.done).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {
|
||||
disable = vi.fn();
|
||||
},
|
||||
}));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
|
||||
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type LoadedEntry,
|
||||
type MetaEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||
|
||||
const queueOwners: HiddenFileSync[] = [];
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function waitForCondition(predicate: () => boolean, message: string) {
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
if (predicate()) return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function createHiddenFileSync(localDatabase: object) {
|
||||
vi.spyOn(HiddenFileSync.prototype, "onBindFunction").mockImplementation(() => {});
|
||||
const hiddenFileSync = new HiddenFileSync({
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
syncInternalFiles: true,
|
||||
syncInternalFileOverwritePatterns: "",
|
||||
},
|
||||
localDatabase,
|
||||
services: { API: { addLog: vi.fn() }, context: { app: {} } },
|
||||
} as unknown as ConstructorParameters<typeof HiddenFileSync>[0]);
|
||||
hiddenFileSync._log = vi.fn();
|
||||
queueOwners.push(hiddenFileSync);
|
||||
return { hiddenFileSync };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const hiddenFileSync of queueOwners.splice(0)) {
|
||||
hiddenFileSync.conflictResolutionProcessor.dispose();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("HiddenFileSync conflict queue", () => {
|
||||
it("keeps a self-requeued conflict check and waits for its follow-up", async () => {
|
||||
vi.useFakeTimers();
|
||||
const path = "i:.obsidian/plugins/example/data.txt" as FilePathWithPrefix;
|
||||
const id = String(path) as DocumentID;
|
||||
const conflictDoc = {
|
||||
_id: id,
|
||||
_rev: "2-current",
|
||||
path,
|
||||
_conflicts: ["2-conflicted"],
|
||||
} as MetaEntry;
|
||||
const resolvedDoc = { _id: id, _rev: "3-resolved", path } as MetaEntry;
|
||||
let rawRead = 0;
|
||||
const localDatabase = {
|
||||
findEntries: vi.fn(async function* () {
|
||||
yield conflictDoc;
|
||||
}),
|
||||
getRaw: vi.fn(async () => (rawRead++ === 0 ? conflictDoc : resolvedDoc)),
|
||||
};
|
||||
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
|
||||
hiddenFileSync.path2id = async () => id;
|
||||
|
||||
let resolutionCalls = 0;
|
||||
let requeued = false;
|
||||
hiddenFileSync.resolveByNewerEntry = vi.fn(async () => {
|
||||
resolutionCalls++;
|
||||
hiddenFileSync.requeueConflictCheck(path);
|
||||
requeued = true;
|
||||
});
|
||||
|
||||
let completed = false;
|
||||
hiddenFileSync.queueConflictCheck(path);
|
||||
try {
|
||||
await waitForCondition(
|
||||
() => hiddenFileSync.conflictResolutionProcessor.remaining === 1,
|
||||
"The initial conflict check was not queued"
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9);
|
||||
expect(localDatabase.getRaw).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await waitForCondition(
|
||||
() =>
|
||||
requeued &&
|
||||
hiddenFileSync.conflictResolutionProcessor.nowProcessing === 0 &&
|
||||
hiddenFileSync.conflictResolutionProcessor.remaining === 1,
|
||||
"The conflict follow-up was not retained after the first pass"
|
||||
);
|
||||
void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
expect(completed).toBe(false);
|
||||
expect(localDatabase.getRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9);
|
||||
expect(localDatabase.getRaw).toHaveBeenCalledTimes(1);
|
||||
expect(completed).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await waitForCondition(() => completed, "The conflict pipeline did not await its follow-up");
|
||||
|
||||
expect(localDatabase.getRaw).toHaveBeenCalledTimes(2);
|
||||
expect(resolutionCalls).toBe(1);
|
||||
expect(hiddenFileSync.pendingConflictChecks.has(path)).toBe(false);
|
||||
expect(hiddenFileSync.conflictResolutionProcessor.remaining).toBe(0);
|
||||
} finally {
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for a cancelled manual merge dialogue and then finishes without requeueing", async () => {
|
||||
vi.useFakeTimers();
|
||||
const path = "i:.obsidian/plugins/example/data.json" as FilePathWithPrefix;
|
||||
const id = String(path) as DocumentID;
|
||||
const conflictDoc = {
|
||||
_id: id,
|
||||
_rev: "2-current",
|
||||
path,
|
||||
_conflicts: ["2-conflicted"],
|
||||
} as MetaEntry;
|
||||
const historyDoc = {} as unknown as MetaEntry;
|
||||
const loaded = { _id: id, _rev: "2-current", path } as LoadedEntry;
|
||||
const dialogue = deferred<boolean>();
|
||||
let dialogueOpened = false;
|
||||
const localDatabase = {
|
||||
findEntries: vi.fn(async function* () {
|
||||
yield conflictDoc;
|
||||
}),
|
||||
getRaw: vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) =>
|
||||
options?.revs_info ? historyDoc : conflictDoc
|
||||
),
|
||||
getDBEntry: vi.fn(async () => loaded),
|
||||
managers: {
|
||||
conflictManager: {
|
||||
mergeObject: vi.fn(async () => false),
|
||||
},
|
||||
},
|
||||
};
|
||||
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
|
||||
hiddenFileSync.path2id = async () => id;
|
||||
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(async () => {
|
||||
dialogueOpened = true;
|
||||
return await dialogue.promise;
|
||||
});
|
||||
|
||||
let completed = false;
|
||||
hiddenFileSync.queueConflictCheck(path);
|
||||
try {
|
||||
await waitForCondition(
|
||||
() => hiddenFileSync.conflictResolutionProcessor.remaining === 1,
|
||||
"The initial conflict check was not queued"
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(dialogueOpened).toBe(true);
|
||||
void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
expect(completed).toBe(false);
|
||||
expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBeGreaterThan(0);
|
||||
expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledOnce();
|
||||
expect(localDatabase.getDBEntry).toHaveBeenCalledTimes(2);
|
||||
|
||||
dialogue.resolve(false);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await waitForCondition(() => completed, "The cancelled dialogue did not release the pipeline");
|
||||
|
||||
expect(hiddenFileSync.pendingConflictChecks.has(path)).toBe(false);
|
||||
expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBe(0);
|
||||
} finally {
|
||||
dialogue.resolve(false);
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiddenFileSync pipeline contracts", () => {
|
||||
it("starts above the threshold, limits analysis to five, and retains FIFO input", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gates = Array.from({ length: 11 }, () => deferred<MetaEntry>());
|
||||
const paths = gates.map((_, index) => `i:.obsidian/${index}.txt` as FilePathWithPrefix);
|
||||
const reads: string[] = [];
|
||||
const getRaw = vi.fn((id: DocumentID) => {
|
||||
reads.push(id);
|
||||
return gates[paths.indexOf(id as unknown as FilePathWithPrefix)].promise;
|
||||
});
|
||||
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
|
||||
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
|
||||
for (const path of paths.slice(0, 10)) hiddenFileSync.queueConflictCheck(path);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(reads).toEqual([]);
|
||||
hiddenFileSync.queueConflictCheck(paths[10]);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(reads).toEqual(paths.slice(0, 5));
|
||||
hiddenFileSync.queueConflictCheck(paths[0]);
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(11);
|
||||
gates[0].resolve({ path: paths[0] } as MetaEntry);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(reads).toEqual(paths.slice(0, 6));
|
||||
gates.forEach((gate, index) => gate.resolve({ path: paths[index] } as MetaEntry));
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true);
|
||||
expect(reads).toEqual(paths);
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("continues analysis while one manual dialogue is open and serialises the dialogues", async () => {
|
||||
vi.useFakeTimers();
|
||||
const paths = Array.from({ length: 12 }, (_, index) => `i:.obsidian/${index}.json` as FilePathWithPrefix);
|
||||
const gates = paths.map(() => deferred<boolean>());
|
||||
const localDatabase = {
|
||||
getRaw: vi.fn(async (id: DocumentID, options?: { revs_info?: boolean }) =>
|
||||
options?.revs_info
|
||||
? {}
|
||||
: {
|
||||
_id: id,
|
||||
path: id,
|
||||
_rev: "2-current",
|
||||
_conflicts: ["2-other"],
|
||||
}
|
||||
),
|
||||
getDBEntry: vi.fn(async (path: FilePathWithPrefix) => ({ path }) as LoadedEntry),
|
||||
managers: { conflictManager: { mergeObject: vi.fn(async () => false) } },
|
||||
};
|
||||
const { hiddenFileSync } = createHiddenFileSync(localDatabase);
|
||||
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
|
||||
const opened: FilePathWithPrefix[] = [];
|
||||
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn((doc) => {
|
||||
opened.push(doc.path);
|
||||
return gates[paths.indexOf(doc.path)].promise;
|
||||
});
|
||||
paths.forEach((path) => hiddenFileSync.queueConflictCheck(path));
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledTimes(12);
|
||||
expect(opened).toEqual([paths[0]]);
|
||||
paths.forEach((path) => hiddenFileSync.queueConflictCheck(path));
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(12);
|
||||
let completed = false;
|
||||
void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
for (let index = 0; index < paths.length; index++) {
|
||||
expect(opened).toEqual(paths.slice(0, index + 1));
|
||||
expect(completed).toBe(false);
|
||||
gates[index].resolve(false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
}
|
||||
expect(completed).toBe(true);
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("logs an analysis error and continues with later paths", async () => {
|
||||
vi.useFakeTimers();
|
||||
const failure = new Error("Injected read failure");
|
||||
const getRaw = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue({});
|
||||
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
|
||||
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
|
||||
hiddenFileSync.queueConflictCheck("i:bad.txt" as FilePathWithPrefix);
|
||||
hiddenFileSync.queueConflictCheck("i:good.txt" as FilePathWithPrefix);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true);
|
||||
expect(getRaw).toHaveBeenCalledTimes(2);
|
||||
expect(hiddenFileSync._log).toHaveBeenCalledWith(failure, expect.anything());
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("collects a conflict enumeration before resuming analysis", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gate = deferred();
|
||||
const path = "i:.obsidian/scanned.txt" as FilePathWithPrefix;
|
||||
const getRaw = vi.fn(async () => ({}));
|
||||
const { hiddenFileSync } = createHiddenFileSync({
|
||||
getRaw,
|
||||
findEntries: async function* () {
|
||||
await gate.promise;
|
||||
yield { _id: path, path, _conflicts: ["2-other"] };
|
||||
},
|
||||
});
|
||||
hiddenFileSync.path2id = async (value) => value as unknown as DocumentID;
|
||||
const result = hiddenFileSync.resolveConflictOnInternalFiles();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
hiddenFileSync.queueConflictCheck(path);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(getRaw).not.toHaveBeenCalled();
|
||||
gate.resolve();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await result;
|
||||
expect(getRaw).toHaveBeenCalledOnce();
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiddenFileSync conflict queue ownership", () => {
|
||||
it("stops delayed work on unload and rejects later conflict events", async () => {
|
||||
vi.useFakeTimers();
|
||||
const getRaw = vi.fn(async () => ({}));
|
||||
const { hiddenFileSync } = createHiddenFileSync({ getRaw });
|
||||
hiddenFileSync.path2id = async (path) => path as unknown as DocumentID;
|
||||
const path = "i:.obsidian/queued.txt" as FilePathWithPrefix;
|
||||
hiddenFileSync.queueConflictCheck(path);
|
||||
const idle = hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed();
|
||||
hiddenFileSync.onunload();
|
||||
hiddenFileSync.queueConflictCheck(path);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await idle).toBe(false);
|
||||
expect(getRaw).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("finishes an admitted analysis without handing its result to a new dialogue after unload", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gate = deferred<MetaEntry>();
|
||||
const path = "i:.obsidian/active.json" as FilePathWithPrefix;
|
||||
const getRaw = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => gate.promise)
|
||||
.mockResolvedValue({});
|
||||
const getDBEntry = vi.fn(async () => ({ path }));
|
||||
const mergeObject = vi.fn(async () => false);
|
||||
const { hiddenFileSync } = createHiddenFileSync({
|
||||
getRaw,
|
||||
getDBEntry,
|
||||
managers: { conflictManager: { mergeObject } },
|
||||
});
|
||||
hiddenFileSync.path2id = async () => path as unknown as DocumentID;
|
||||
hiddenFileSync.queueConflictCheck(path);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(getRaw).toHaveBeenCalledOnce();
|
||||
hiddenFileSync.onunload();
|
||||
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(false);
|
||||
gate.resolve({
|
||||
_id: String(path) as DocumentID,
|
||||
path,
|
||||
_rev: "2-current",
|
||||
_conflicts: ["2-other"],
|
||||
} as MetaEntry);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(mergeObject).toHaveBeenCalledOnce();
|
||||
expect(getDBEntry).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not requeue a manual resolution which finishes after unload", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gate = deferred<boolean>();
|
||||
const path = "i:.obsidian/dialogue.json" as FilePathWithPrefix;
|
||||
const getRaw = vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) =>
|
||||
options?.revs_info
|
||||
? {}
|
||||
: {
|
||||
_id: path,
|
||||
path,
|
||||
_rev: "2-current",
|
||||
_conflicts: ["2-other"],
|
||||
}
|
||||
);
|
||||
const getDBEntry = vi.fn(async () => ({ path }) as LoadedEntry);
|
||||
const { hiddenFileSync } = createHiddenFileSync({
|
||||
getRaw,
|
||||
getDBEntry,
|
||||
managers: { conflictManager: { mergeObject: async () => false } },
|
||||
});
|
||||
hiddenFileSync.path2id = async () => path as unknown as DocumentID;
|
||||
hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(() => gate.promise);
|
||||
hiddenFileSync.queueConflictCheck(path);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(hiddenFileSync.showJSONMergeDialogAndMerge).toHaveBeenCalledOnce();
|
||||
hiddenFileSync.onunload();
|
||||
expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(false);
|
||||
gate.resolve(true);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(getRaw).toHaveBeenCalledTimes(2);
|
||||
expect(getDBEntry).toHaveBeenCalledTimes(2);
|
||||
expect(hiddenFileSync.pendingConflictChecks.size).toBe(0);
|
||||
expect(hiddenFileSync.conflictResolutionProcessor.totalRemaining).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
getLogLevel,
|
||||
autosaveCache,
|
||||
type MapLike,
|
||||
onlyInNTimes,
|
||||
BASE_IS_NEW,
|
||||
EVEN,
|
||||
displayRev,
|
||||
@@ -45,7 +44,8 @@ import { serialized, skipIfDuplicated } from "octagonal-wheels/concurrency/lock"
|
||||
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
||||
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
|
||||
import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { createCoalescingNotifier } from "octagonal-wheels/concurrency/coalescingNotifier";
|
||||
import { HiddenFileConflictQueue } from "./HiddenFileConflictQueue.ts";
|
||||
import {
|
||||
hiddenFilesEventCount,
|
||||
hiddenFilesProcessingCount,
|
||||
@@ -57,6 +57,7 @@ import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.d
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
||||
import { performBatchWithProgress } from "./performBatchWithProgress.ts";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
@@ -107,6 +108,8 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
}
|
||||
onunload() {
|
||||
this.periodicInternalFileScanProcessor?.disable();
|
||||
this.conflictResolutionProcessor.dispose();
|
||||
this.pendingConflictChecks.clear();
|
||||
}
|
||||
onload() {
|
||||
this.services.API.addCommand({
|
||||
@@ -571,18 +574,15 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
) {
|
||||
const logLevel = getLogLevel(showNotice);
|
||||
const p = this._progress(`[⚙ Storage -> DB ]\n`, logLevel);
|
||||
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
|
||||
const processes = processFiles.map(async (file, i) => {
|
||||
try {
|
||||
await this.trackStorageFileModification(file, onlyNew, forceWriteAll, includeDeleted);
|
||||
notifyProgress();
|
||||
} catch (ex) {
|
||||
await performBatchWithProgress(processFiles, 100, {
|
||||
processItem: (file) => this.trackStorageFileModification(file, onlyNew, forceWriteAll, includeDeleted),
|
||||
onProgress: (progress) => p.log(`${progress}/${processFiles.length}`),
|
||||
onItemError: (ex, file) => {
|
||||
p.once(`Failed to process storage change file:${file}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
},
|
||||
onDone: () => p.done(),
|
||||
});
|
||||
await Promise.all(processes);
|
||||
p.done();
|
||||
}
|
||||
async scanAllStorageChanges(
|
||||
showNotice: boolean = false,
|
||||
@@ -688,7 +688,7 @@ Offline Changed files: ${processFiles.length}`;
|
||||
const dbMTime = getComparingMTime(filesOnDB, includeDeleted);
|
||||
const diff = compareMTime(storageMTime, dbMTime);
|
||||
|
||||
if (diff != TARGET_IS_NEW) {
|
||||
if (diff != BASE_IS_NEW) {
|
||||
this._log(`Hidden file:${path} is not new.`, LOG_LEVEL_VERBOSE);
|
||||
if (filesOnDB && stat) {
|
||||
// OnlyNew not handles the deletion.
|
||||
@@ -747,7 +747,7 @@ Offline Changed files: ${processFiles.length}`;
|
||||
queueConflictCheck(path: FilePathWithPrefix) {
|
||||
if (this.pendingConflictChecks.has(path)) return;
|
||||
this.pendingConflictChecks.add(path);
|
||||
this.conflictResolutionProcessor.enqueue(path);
|
||||
if (!this.conflictResolutionProcessor.enqueue(path)) this.pendingConflictChecks.delete(path);
|
||||
}
|
||||
|
||||
finishConflictCheck(path: FilePathWithPrefix) {
|
||||
@@ -804,9 +804,8 @@ Offline Changed files: ${processFiles.length}`;
|
||||
}
|
||||
// check the file again
|
||||
}
|
||||
conflictResolutionProcessor = new QueueProcessor(
|
||||
async (paths: FilePathWithPrefix[]) => {
|
||||
const path = paths[0];
|
||||
conflictResolutionProcessor = new HiddenFileConflictQueue(
|
||||
async (path: FilePathWithPrefix) => {
|
||||
try {
|
||||
// Retrieve data
|
||||
const id = await this.path2id(path, ICHeader);
|
||||
@@ -874,46 +873,32 @@ Offline Changed files: ${processFiles.length}`;
|
||||
return [];
|
||||
}
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 5,
|
||||
delay: 10,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
yieldThreshold: 10,
|
||||
pipeTo: new QueueProcessor(
|
||||
async (results) => {
|
||||
const { id, doc, path, revA, revB } = results[0];
|
||||
const prefixedPath = addPrefix(path, ICHeader);
|
||||
const docAMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revA });
|
||||
const docBMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revB });
|
||||
try {
|
||||
if (docAMerge != false && docBMerge != false) {
|
||||
if (await this.showJSONMergeDialogAndMerge(docAMerge, docBMerge)) {
|
||||
// Again for other conflicted revisions.
|
||||
this.requeueConflictCheck(path);
|
||||
} else {
|
||||
this.finishConflictCheck(path);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// If either revision could not read, force resolving by the newer one.
|
||||
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
||||
}
|
||||
} catch (ex) {
|
||||
async (result) => {
|
||||
const { id, doc, path, revA, revB } = result;
|
||||
const prefixedPath = addPrefix(path, ICHeader);
|
||||
const docAMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revA });
|
||||
const docBMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revB });
|
||||
try {
|
||||
if (docAMerge != false && docBMerge != false) {
|
||||
if (await this.showJSONMergeDialogAndMerge(docAMerge, docBMerge)) {
|
||||
// Again for other conflicted revisions.
|
||||
this.requeueConflictCheck(path);
|
||||
} else {
|
||||
this.finishConflictCheck(path);
|
||||
throw ex;
|
||||
}
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 1,
|
||||
delay: 10,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
yieldThreshold: 10,
|
||||
return;
|
||||
} else {
|
||||
// If either revision could not read, force resolving by the newer one.
|
||||
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
||||
}
|
||||
),
|
||||
} catch (ex) {
|
||||
this.finishConflictCheck(path);
|
||||
throw ex;
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
this._log("Processor error!");
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1134,9 +1119,8 @@ Offline Changed files: ${processFiles.length}`;
|
||||
) {
|
||||
const logLevel = getLogLevel(showNotice);
|
||||
const p = this._progress(`[⚙ DB -> Storage ]\n`, logLevel);
|
||||
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
|
||||
const processes = processFiles.map(async (file) => {
|
||||
try {
|
||||
await performBatchWithProgress(processFiles, 100, {
|
||||
processItem: async (file) => {
|
||||
const path = stripAllPrefixes(this.getPath(file));
|
||||
if (!(await this.isTargetFile(path))) {
|
||||
this._log(
|
||||
@@ -1153,14 +1137,14 @@ Offline Changed files: ${processFiles.length}`;
|
||||
includeDeletion
|
||||
);
|
||||
}
|
||||
notifyProgress();
|
||||
} catch (ex) {
|
||||
},
|
||||
onProgress: (progress) => p.log(`${progress}/${processFiles.length}`),
|
||||
onItemError: (ex, file) => {
|
||||
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
},
|
||||
onDone: () => p.done(),
|
||||
});
|
||||
await Promise.all(processes);
|
||||
p.done();
|
||||
}
|
||||
|
||||
async applyOfflineChanges(showNotice: boolean) {
|
||||
@@ -1188,9 +1172,14 @@ Untracked database files: ${untrackedDatabase.length}
|
||||
Common untracked files: ${bothUntracked.length}`;
|
||||
p.once(stat);
|
||||
const semaphores = Semaphore(10);
|
||||
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${bothUntracked.length}`));
|
||||
const notifyProgress = createCoalescingNotifier<number>({
|
||||
everyCount: 25,
|
||||
leading: true,
|
||||
onNotify: (progress) => p.log(`${progress}/${bothUntracked.length}`),
|
||||
});
|
||||
let attempted = 0;
|
||||
const allProcesses = bothUntracked.map(async (file) => {
|
||||
notifyProgress();
|
||||
notifyProgress.update(++attempted);
|
||||
const rel = await semaphores.acquire();
|
||||
try {
|
||||
const fileStat = await this.core.storageAccess.statHidden(file);
|
||||
@@ -1260,20 +1249,18 @@ Offline Changed files: ${files.length}`;
|
||||
const logLevel = getLogLevel(showNotice);
|
||||
const p = this._progress(`[⚙ Scanning DB -> Storage ]\n`, logLevel);
|
||||
p.log("Scanning database files...");
|
||||
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${files.length}`));
|
||||
const processFiles = files.map(async (file) => {
|
||||
try {
|
||||
await performBatchWithProgress(files, 25, {
|
||||
processItem: async (file) => {
|
||||
const path = stripAllPrefixes(this.getPath(file));
|
||||
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
|
||||
notifyProgress();
|
||||
} catch (ex) {
|
||||
},
|
||||
onProgress: (progress) => p.log(`${progress}/${files.length}`),
|
||||
onItemError: (ex, file) => {
|
||||
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return;
|
||||
},
|
||||
onDone: () => p.done(),
|
||||
});
|
||||
await Promise.all(processFiles);
|
||||
p.done();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1435,9 +1422,14 @@ Offline Changed files: ${files.length}`;
|
||||
const storageToDatabase = [] as FilePath[];
|
||||
const databaseToStorage = [] as MetaEntry[];
|
||||
|
||||
const eachProgress = onlyInNTimes(100, (progress) => p.log(`Checking ${progress}/${allFileNames.size}`));
|
||||
const eachProgress = createCoalescingNotifier<number>({
|
||||
everyCount: 100,
|
||||
leading: true,
|
||||
onNotify: (progress) => p.log(`Checking ${progress}/${allFileNames.size}`),
|
||||
});
|
||||
let attempted = 0;
|
||||
for (const file of allFileNames) {
|
||||
eachProgress();
|
||||
eachProgress.update(++attempted);
|
||||
const storageMTime = await this.core.storageAccess.statHidden(file);
|
||||
const mtimeStorage = getComparingMTime(storageMTime);
|
||||
const dbEntry = allDatabaseMap.get(file)!;
|
||||
|
||||
@@ -98,6 +98,85 @@ function createHiddenRevisionOperation() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("HiddenFileSync storage loading", () => {
|
||||
function createStorageReader(content: string | ArrayBuffer) {
|
||||
const path = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
const stat = {
|
||||
ctime: 123,
|
||||
mtime: 456,
|
||||
size: typeof content === "string" ? new TextEncoder().encode(content).byteLength : content.byteLength,
|
||||
type: "file" as const,
|
||||
};
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async (): Promise<typeof stat | null> => stat),
|
||||
readHiddenFileAuto: vi.fn(async () => content),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, { core: { storageAccess } });
|
||||
return { hiddenFileSync, storageAccess, path, stat };
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
kind: "text",
|
||||
content: "first\r\n日本語 🐙",
|
||||
expected: new TextEncoder().encode("first\r\n日本語 🐙"),
|
||||
mime: "text/plain",
|
||||
},
|
||||
{
|
||||
kind: "binary",
|
||||
content: new Uint8Array([0, 128, 255]).buffer,
|
||||
expected: new Uint8Array([0, 128, 255]),
|
||||
mime: "application/octet-stream",
|
||||
},
|
||||
{ kind: "empty text", content: "", expected: new Uint8Array(0), mime: "text/plain" },
|
||||
{
|
||||
kind: "empty binary",
|
||||
content: new ArrayBuffer(0),
|
||||
expected: new Uint8Array(0),
|
||||
mime: "application/octet-stream",
|
||||
},
|
||||
])("preserves $kind content and its file metadata", async ({ content, expected, mime }) => {
|
||||
const { hiddenFileSync, storageAccess, path, stat } = createStorageReader(content);
|
||||
|
||||
const file = await hiddenFileSync.loadFileWithInfo(path);
|
||||
|
||||
expect(storageAccess.statHidden).toHaveBeenCalledWith(path);
|
||||
expect(storageAccess.readHiddenFileAuto).toHaveBeenCalledWith(path);
|
||||
expect(file).toMatchObject({ name: "data.json", path, stat, isInternal: true, deleted: false });
|
||||
expect(file.body).toBeInstanceOf(Blob);
|
||||
expect(file.body.type).toBe(mime);
|
||||
expect(new Uint8Array(await file.body.arrayBuffer())).toEqual(expected);
|
||||
});
|
||||
|
||||
it("represents a missing file as a deletion without reading storage content", async () => {
|
||||
const { hiddenFileSync, storageAccess, path } = createStorageReader("unavailable");
|
||||
storageAccess.statHidden.mockResolvedValue(null);
|
||||
|
||||
const file = await hiddenFileSync.loadFileWithInfo(path);
|
||||
|
||||
expect(storageAccess.readHiddenFileAuto).not.toHaveBeenCalled();
|
||||
expect(file).toMatchObject({
|
||||
name: "data.json",
|
||||
path,
|
||||
stat: { ctime: 0, mtime: 0, size: 0, type: "file" },
|
||||
isInternal: true,
|
||||
deleted: true,
|
||||
});
|
||||
expect(file.body.type).toBe("application/octet-stream");
|
||||
expect(file.body.size).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates a read failure for an existing file", async () => {
|
||||
const { hiddenFileSync, storageAccess, path } = createStorageReader("unavailable");
|
||||
const error = new Error("Storage read failed");
|
||||
storageAccess.readHiddenFileAuto.mockRejectedValue(error);
|
||||
|
||||
await expect(hiddenFileSync.loadFileWithInfo(path)).rejects.toBe(error);
|
||||
expect(storageAccess.readHiddenFileAuto).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { withCancellation } from "octagonal-wheels/concurrency/cancellation";
|
||||
import { createNotifier } from "octagonal-wheels/concurrency/notifier";
|
||||
import { forEachInScope } from "octagonal-wheels/concurrency/scoped.iteration";
|
||||
import { createAsyncQueue } from "octagonal-wheels/iterable/asyncQueue";
|
||||
import { withOwnedResource } from "octagonal-wheels/resource";
|
||||
|
||||
/** The scheduling used by one hidden-file conflict stage. */
|
||||
class ConflictStage<T> {
|
||||
private readonly input = createAsyncQueue<T>();
|
||||
private readonly reader = this.input[Symbol.asyncIterator]();
|
||||
private readonly changed = createNotifier();
|
||||
private readonly cancellation = new AbortController();
|
||||
private timer: CompatTimeoutHandle | undefined;
|
||||
private ready = false;
|
||||
private suspended = false;
|
||||
private stopped = false;
|
||||
private outstanding = 0;
|
||||
active = 0;
|
||||
|
||||
constructor(
|
||||
concurrency: number,
|
||||
process: (item: T) => Promise<unknown>,
|
||||
private readonly notify: () => void,
|
||||
onError: (error: unknown) => void,
|
||||
onFailure: (error: unknown) => void
|
||||
) {
|
||||
// The operation owns its input until started callbacks have settled.
|
||||
// Abort also releases input immediately, before that final cleanup.
|
||||
void withCancellation(undefined, { signal: this.cancellation.signal }, (scope) => {
|
||||
const stopInput = () => {
|
||||
this.input.dispose();
|
||||
this.clearTimer();
|
||||
this.changed.notify();
|
||||
};
|
||||
scope.signal.addEventListener("abort", stopInput, { once: true });
|
||||
scope.defer(() => scope.signal.removeEventListener("abort", stopInput));
|
||||
return withOwnedResource(this.input, () =>
|
||||
forEachInScope(scope, this.availableItems(), { concurrency }, async (entry) => {
|
||||
while (this.suspended && !this.stopped) await this.changed.wait();
|
||||
if (this.stopped) return;
|
||||
this.active++;
|
||||
try {
|
||||
await process(entry.value);
|
||||
} catch (error) {
|
||||
try {
|
||||
onError(error);
|
||||
} catch (reportingError) {
|
||||
// Wake a pending input pull before the runner joins
|
||||
// its other callbacks after an unexpected failure.
|
||||
onFailure(reportingError);
|
||||
throw reportingError;
|
||||
}
|
||||
} finally {
|
||||
this.active--;
|
||||
this.outstanding--;
|
||||
this.notify();
|
||||
}
|
||||
})
|
||||
);
|
||||
}).catch(onFailure);
|
||||
}
|
||||
|
||||
get remaining() {
|
||||
return this.outstanding;
|
||||
}
|
||||
|
||||
enqueue(item: T): boolean {
|
||||
if (this.stopped) return false;
|
||||
this.input.enqueue(item);
|
||||
this.outstanding++;
|
||||
this.schedule();
|
||||
this.notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
if (this.timer === undefined) return;
|
||||
compatGlobal.clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
if (this.stopped || this.suspended || this.input.size === 0) return;
|
||||
if (this.ready || this.input.size > 10) {
|
||||
this.clearTimer();
|
||||
this.ready = true;
|
||||
this.changed.notify();
|
||||
} else if (this.timer === undefined) {
|
||||
this.timer = compatGlobal.setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
this.ready = true;
|
||||
this.changed.notify();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
private async *availableItems() {
|
||||
while (!this.stopped) {
|
||||
while (!this.stopped && (this.suspended || !this.ready || this.input.size === 0)) {
|
||||
await this.changed.wait();
|
||||
}
|
||||
if (this.stopped) return;
|
||||
const entry = await this.reader.next();
|
||||
if (entry.done) return;
|
||||
if (this.input.size === 0) {
|
||||
this.ready = false;
|
||||
this.clearTimer();
|
||||
}
|
||||
// Outstanding includes this pulled item until its callback finishes.
|
||||
yield { value: entry.value };
|
||||
}
|
||||
}
|
||||
|
||||
suspend() {
|
||||
this.suspended = true;
|
||||
this.ready = false;
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (!this.suspended || this.stopped) return;
|
||||
this.suspended = false;
|
||||
this.schedule();
|
||||
this.changed.notify();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
this.outstanding = this.active;
|
||||
this.cancellation.abort();
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/** Two hidden-file stages: five analyses, followed by one manual resolution. */
|
||||
export class HiddenFileConflictQueue<T, U> {
|
||||
private readonly changed = createNotifier();
|
||||
private readonly analysis: ConflictStage<T>;
|
||||
private readonly resolution: ConflictStage<U>;
|
||||
private stopped = false;
|
||||
private failure: { error: unknown } | undefined;
|
||||
|
||||
constructor(
|
||||
analyse: (item: T) => Promise<U[]>,
|
||||
resolve: (item: U) => Promise<unknown>,
|
||||
onError: (error: unknown) => void
|
||||
) {
|
||||
const notify = () => this.changed.notify();
|
||||
const onFailure = (error: unknown) => {
|
||||
this.failure ??= { error };
|
||||
this.dispose();
|
||||
};
|
||||
this.resolution = new ConflictStage(1, resolve, notify, onError, onFailure);
|
||||
this.analysis = new ConflictStage(
|
||||
5,
|
||||
async (item) => {
|
||||
const results = await analyse(item);
|
||||
for (const result of results) this.resolution.enqueue(result);
|
||||
},
|
||||
notify,
|
||||
onError,
|
||||
onFailure
|
||||
);
|
||||
}
|
||||
|
||||
enqueue(item: T): boolean {
|
||||
return this.analysis.enqueue(item);
|
||||
}
|
||||
|
||||
get nowProcessing() {
|
||||
return this.analysis.active;
|
||||
}
|
||||
|
||||
get remaining() {
|
||||
return this.analysis.remaining;
|
||||
}
|
||||
|
||||
get totalRemaining() {
|
||||
return this.analysis.remaining + this.resolution.remaining;
|
||||
}
|
||||
|
||||
suspend() {
|
||||
this.analysis.suspend();
|
||||
}
|
||||
|
||||
startPipeline(): this {
|
||||
this.analysis.resume();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Includes handoff and self-requeues; disposal is never successful drainage. */
|
||||
async waitForAllProcessed(): Promise<boolean> {
|
||||
this.startPipeline();
|
||||
while (!this.stopped && this.totalRemaining > 0) await this.changed.wait();
|
||||
if (this.failure) throw this.failure.error;
|
||||
return !this.stopped;
|
||||
}
|
||||
|
||||
/** Stop admission and release pending work without waiting for active dialogue or writes. */
|
||||
dispose() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
this.analysis.dispose();
|
||||
this.resolution.dispose();
|
||||
this.changed.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HiddenFileConflictQueue } from "./HiddenFileConflictQueue.ts";
|
||||
|
||||
const owners: { dispose(): void }[] = [];
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function own<T, U>(queue: HiddenFileConflictQueue<T, U>) {
|
||||
owners.push(queue);
|
||||
return queue;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
owners.splice(0).forEach((owner) => owner.dispose());
|
||||
if (vi.isFakeTimers()) await vi.runAllTimersAsync();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("HiddenFileConflictQueue", () => {
|
||||
it("awaits repeated handoffs and self-requeues with native timers", async () => {
|
||||
const seen: number[] = [];
|
||||
let queue!: HiddenFileConflictQueue<number, number>;
|
||||
queue = own(
|
||||
new HiddenFileConflictQueue(
|
||||
async (value: number) => [value],
|
||||
async (value) => {
|
||||
seen.push(value);
|
||||
if (value < 3) queue.enqueue(value + 1);
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
);
|
||||
queue.enqueue(0);
|
||||
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||
expect(seen).toEqual([0, 1, 2, 3]);
|
||||
expect(queue.totalRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the first arrival's ten-millisecond delay in each stage", async () => {
|
||||
vi.useFakeTimers();
|
||||
const analyse = vi.fn(async (value: number) => [value]);
|
||||
const resolve = vi.fn(async (_value: number) => {});
|
||||
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||
queue.enqueue(1);
|
||||
await vi.advanceTimersByTimeAsync(9);
|
||||
queue.enqueue(2);
|
||||
expect(analyse).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(analyse.mock.calls).toEqual([[1], [2]]);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(9);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(resolve.mock.calls).toEqual([[1], [2]]);
|
||||
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||
expect(queue.totalRemaining).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("starts both stages immediately above ten waiting items", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gate = deferred();
|
||||
const analyse = vi.fn(async (value: number) => [value]);
|
||||
const resolve = vi.fn(async (_value: number) => {
|
||||
await gate.promise;
|
||||
});
|
||||
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||
for (let value = 0; value < 11; value++) queue.enqueue(value);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(analyse).toHaveBeenCalledTimes(11);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
expect(queue.totalRemaining).toBe(11);
|
||||
gate.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(resolve.mock.calls).toEqual(Array.from({ length: 11 }, (_, value) => [value]));
|
||||
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||
});
|
||||
|
||||
it("logs item failures in both stages and continues without implicit retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = new Error("analysis failed");
|
||||
const second = new Error("resolution failed");
|
||||
const analyse = vi.fn(async (value: number) => {
|
||||
if (value === 1) throw first;
|
||||
return [value];
|
||||
});
|
||||
const resolve = vi.fn(async (value: number) => {
|
||||
if (value === 2) throw second;
|
||||
});
|
||||
const onError = vi.fn();
|
||||
const queue = own(new HiddenFileConflictQueue(analyse, resolve, onError));
|
||||
[1, 2, 3].forEach((value) => queue.enqueue(value));
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(onError.mock.calls).toEqual([[first], [second]]);
|
||||
expect(analyse.mock.calls).toEqual([[1], [2], [3]]);
|
||||
expect(resolve.mock.calls).toEqual([[2], [3]]);
|
||||
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||
});
|
||||
|
||||
it("discards delayed input and releases idle waiters on disposal", async () => {
|
||||
vi.useFakeTimers();
|
||||
const analyse = vi.fn(async (value: number) => [value]);
|
||||
const resolve = vi.fn(async (_value: number) => {});
|
||||
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||
queue.enqueue(1);
|
||||
const idle = queue.waitForAllProcessed();
|
||||
queue.dispose();
|
||||
queue.dispose();
|
||||
expect(await idle).toBe(false);
|
||||
expect(queue.enqueue(2)).toBe(false);
|
||||
expect(queue.totalRemaining).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(analyse).not.toHaveBeenCalled();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not wait for active analysis or admit its late handoff and self-requeue after disposal", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gate = deferred();
|
||||
const attempted: boolean[] = [];
|
||||
let finished = 0;
|
||||
const analyse = vi.fn(async (value: number) => {
|
||||
await gate.promise;
|
||||
attempted.push(queue.enqueue(value));
|
||||
finished++;
|
||||
return [value];
|
||||
});
|
||||
const resolve = vi.fn(async (_value: number) => {});
|
||||
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||
for (let value = 0; value < 12; value++) queue.enqueue(value);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(analyse).toHaveBeenCalledTimes(5);
|
||||
const idle = queue.waitForAllProcessed();
|
||||
queue.dispose();
|
||||
expect(await idle).toBe(false);
|
||||
expect(finished).toBe(0);
|
||||
expect(queue.totalRemaining).toBe(5);
|
||||
gate.resolve();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(finished).toBe(5);
|
||||
expect(attempted).toEqual([false, false, false, false, false]);
|
||||
expect(analyse).toHaveBeenCalledTimes(5);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(queue.totalRemaining).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves an active dialogue running and discards the next dialogue", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gate = deferred();
|
||||
let finished = false;
|
||||
const resolve = vi.fn(async (_value: number) => {
|
||||
await gate.promise;
|
||||
finished = true;
|
||||
});
|
||||
const queue = own(new HiddenFileConflictQueue(async (value: number) => [value], resolve, vi.fn()));
|
||||
queue.enqueue(1);
|
||||
queue.enqueue(2);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
queue.dispose();
|
||||
expect(await queue.waitForAllProcessed()).toBe(false);
|
||||
expect(finished).toBe(false);
|
||||
gate.resolve();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(finished).toBe(true);
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
expect(queue.totalRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it("suspends analysis while already handed-off manual work continues", async () => {
|
||||
vi.useFakeTimers();
|
||||
const analyse = vi.fn(async (value: number) => [value]);
|
||||
const resolve = vi.fn(async (_value: number) => {});
|
||||
const queue = own(new HiddenFileConflictQueue(analyse, resolve, vi.fn()));
|
||||
queue.enqueue(1);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
queue.suspend();
|
||||
queue.enqueue(2);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(analyse.mock.calls).toEqual([[1]]);
|
||||
expect(resolve.mock.calls).toEqual([[1]]);
|
||||
queue.startPipeline();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(analyse.mock.calls).toEqual([[1], [2]]);
|
||||
expect(resolve.mock.calls).toEqual([[1], [2]]);
|
||||
expect(await queue.waitForAllProcessed()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createCoalescingNotifier } from "octagonal-wheels/concurrency/coalescingNotifier";
|
||||
|
||||
interface PerformBatchWithProgressCallbacks<T> {
|
||||
/** Runs one item. Its resolved value is ignored. */
|
||||
processItem: (item: T) => Promise<unknown>;
|
||||
/** Receives the number of successfully completed items. */
|
||||
onProgress: (completed: number) => void;
|
||||
/** Handles an item or progress callback error. */
|
||||
onItemError: (error: unknown, item: T) => void;
|
||||
/** Runs once after all per-item promises resolve. */
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an eager batch and coalesces progress notifications by successful item count.
|
||||
* @internal
|
||||
*
|
||||
* @param items - Items to process concurrently.
|
||||
* @param eachCount - Number of completed items between progress notifications.
|
||||
* @param callbacks - Item processing, progress, error, and completion callbacks.
|
||||
* @returns A promise that resolves with no value after the batch and completion callback finish.
|
||||
* @throws When notifier configuration is invalid, an error handler throws, or the completion callback throws.
|
||||
* @remarks The first completed item triggers progress immediately. Work and the progress callback share
|
||||
* one error boundary; errors are passed to `onItemError`. A rejected item operation does not advance
|
||||
* the count. The batch has no concurrency limit or retry, and pending progress is not flushed when
|
||||
* processing ends.
|
||||
*/
|
||||
export async function performBatchWithProgress<T>(
|
||||
items: readonly T[],
|
||||
eachCount: number,
|
||||
callbacks: PerformBatchWithProgressCallbacks<T>
|
||||
): Promise<void> {
|
||||
const notifier = createCoalescingNotifier<number>({
|
||||
everyCount: eachCount,
|
||||
leading: true,
|
||||
onNotify: callbacks.onProgress,
|
||||
});
|
||||
let completed = 0;
|
||||
const processes = items.map(async (item) => {
|
||||
try {
|
||||
await callbacks.processItem(item);
|
||||
notifier.update(++completed);
|
||||
} catch (error) {
|
||||
callbacks.onItemError(error, item);
|
||||
}
|
||||
});
|
||||
await Promise.all(processes);
|
||||
callbacks.onDone();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { performBatchWithProgress } from "./performBatchWithProgress.ts";
|
||||
|
||||
describe("performBatchWithProgress", () => {
|
||||
it("counts resolved false values, continues after item errors, and does not flush partial progress", async () => {
|
||||
const error = new Error("Item failed");
|
||||
const processItem = vi.fn(async (item: number) => {
|
||||
if (item === 2) throw error;
|
||||
return false;
|
||||
});
|
||||
const onProgress = vi.fn();
|
||||
const onItemError = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
|
||||
const result = await performBatchWithProgress([1, 2, 3, 4, 5, 6, 7], 2, {
|
||||
processItem,
|
||||
onProgress,
|
||||
onItemError,
|
||||
onDone,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(processItem).toHaveBeenCalledTimes(7);
|
||||
expect(onItemError).toHaveBeenCalledWith(error, 2);
|
||||
expect(onProgress.mock.calls).toEqual([[1], [3], [5]]);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts every item eagerly and calls onDone only after all item promises resolve", async () => {
|
||||
const items = [1, 2, 3];
|
||||
const resolvers: Array<() => void> = [];
|
||||
const processItem = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
})
|
||||
);
|
||||
const onProgress = vi.fn();
|
||||
const onItemError = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
|
||||
const operation = performBatchWithProgress(items, 100, { processItem, onProgress, onItemError, onDone });
|
||||
|
||||
expect(processItem).toHaveBeenCalledTimes(items.length);
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
for (const resolve of resolvers) resolve();
|
||||
await operation;
|
||||
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes a progress callback error through the item error handler", async () => {
|
||||
const error = new Error("Progress callback failed");
|
||||
const onProgress = vi.fn(() => {
|
||||
throw error;
|
||||
});
|
||||
const onItemError = vi.fn();
|
||||
const onDone = vi.fn();
|
||||
|
||||
await performBatchWithProgress(["item"], 100, {
|
||||
processItem: async () => false,
|
||||
onProgress,
|
||||
onItemError,
|
||||
onDone,
|
||||
});
|
||||
|
||||
expect(onItemError).toHaveBeenCalledWith(error, "item");
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects when the item error handler throws and skips onDone", async () => {
|
||||
const itemError = new Error("Item failed");
|
||||
const handlerError = new Error("Error handler failed");
|
||||
const onItemError = vi.fn(() => {
|
||||
throw handlerError;
|
||||
});
|
||||
const onDone = vi.fn();
|
||||
|
||||
await expect(
|
||||
performBatchWithProgress(["item"], 100, {
|
||||
processItem: async () => {
|
||||
throw itemError;
|
||||
},
|
||||
onProgress: vi.fn(),
|
||||
onItemError,
|
||||
onDone,
|
||||
})
|
||||
).rejects.toBe(handlerError);
|
||||
|
||||
expect(onItemError).toHaveBeenCalledWith(itemError, "item");
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export abstract class LiveSyncCommands {
|
||||
this._log = createInstanceLogFunction(this.constructor.name, this.services.API);
|
||||
// __$checkInstanceBinding(this);
|
||||
}
|
||||
abstract onunload(): void;
|
||||
abstract onunload(): void | Promise<void>;
|
||||
abstract onload(): void | Promise<void>;
|
||||
|
||||
_isMainReady() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
||||
import { createReviewHarnessHeapSampler } from "./reviewHarnessHeapSampling";
|
||||
|
||||
export const REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS = [
|
||||
{ label: "4 KiB", bytes: 4 * 1024 },
|
||||
@@ -23,15 +24,6 @@ type BenchmarkPerformance = Pick<Performance, "now"> & {
|
||||
|
||||
type HashAlgorithm = "SHA-256" | "xxHash64";
|
||||
|
||||
function readHeap(clock: BenchmarkPerformance): number | undefined {
|
||||
try {
|
||||
const bytes = clock.memory?.usedJSHeapSize;
|
||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function summary(samples: readonly number[]): string {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const middle = sorted[Math.floor(sorted.length / 2)];
|
||||
@@ -63,19 +55,13 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
||||
throw new Error("The xxHash64 initialisation time is invalid.");
|
||||
}
|
||||
|
||||
const before = readHeap(clock);
|
||||
let highest = before;
|
||||
const sampleHeap = () => {
|
||||
const value = readHeap(clock);
|
||||
if (value !== undefined) highest = Math.max(highest ?? value, value);
|
||||
return value;
|
||||
};
|
||||
const heap = createReviewHarnessHeapSampler(clock);
|
||||
|
||||
const inputs: { readonly label: string; readonly bytes: number; readonly content: Blob }[] = [];
|
||||
for (const { label, bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) {
|
||||
await yieldControl();
|
||||
inputs.push({ label, bytes, content: operations.createInput(bytes) });
|
||||
sampleHeap();
|
||||
heap.sample();
|
||||
}
|
||||
|
||||
const hash = (algorithm: HashAlgorithm, content: Blob) =>
|
||||
@@ -99,7 +85,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
||||
const elapsed = clock.now() - started;
|
||||
checkHash(algorithm, inputs[0].label, result);
|
||||
warmupTimes.set(algorithm, elapsed);
|
||||
sampleHeap();
|
||||
heap.sample();
|
||||
}
|
||||
|
||||
const observations = [
|
||||
@@ -124,7 +110,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
||||
throw new Error(`The ${algorithm} measurement returned an invalid duration.`);
|
||||
}
|
||||
samples[algorithm].push(elapsed);
|
||||
sampleHeap();
|
||||
heap.sample();
|
||||
}
|
||||
}
|
||||
for (const algorithm of ["SHA-256", "xxHash64"] as const) {
|
||||
@@ -132,16 +118,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
||||
}
|
||||
}
|
||||
|
||||
const after = sampleHeap();
|
||||
if (highest === undefined) {
|
||||
observations.push("JavaScript heap: unavailable on this device.");
|
||||
} else {
|
||||
const mib = (bytes: number | undefined) =>
|
||||
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
|
||||
observations.push(
|
||||
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
|
||||
);
|
||||
}
|
||||
observations.push(heap.finish());
|
||||
observations.push(
|
||||
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
|
||||
);
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("Review Harness file content hash measurements", () => {
|
||||
|
||||
expect(result.status).toBe("passed");
|
||||
expect(result.detail).toBe("File content hash measurements completed.");
|
||||
expect(report).toContain("JavaScript heap: unavailable on this device.");
|
||||
expect([...f.inputs().contentBySize.keys()]).toEqual(
|
||||
REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.map(({ bytes }) => bytes)
|
||||
);
|
||||
@@ -107,6 +108,49 @@ describe("Review Harness file content hash measurements", () => {
|
||||
expect(report).not.toContain("4096n");
|
||||
});
|
||||
|
||||
it("tracks the sampled maximum and reports a lower final heap sample", async () => {
|
||||
const f = fixture();
|
||||
let reads = 0;
|
||||
const clock = {
|
||||
now: f.now,
|
||||
get memory() {
|
||||
const sample = reads++;
|
||||
const mebibytes =
|
||||
sample === 0 ? 2 : sample === 1 ? 5 : sample === 31 ? 1 : 3;
|
||||
return { usedJSHeapSize: mebibytes * 1048576 };
|
||||
},
|
||||
};
|
||||
const result = await runReviewHarnessFileContentHashBenchmark(f.operations, clock, f.yieldControl);
|
||||
|
||||
expect(result.observations).toContain(
|
||||
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
|
||||
);
|
||||
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
|
||||
expect(reads).toBe(32);
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"] as const)(
|
||||
"keeps timings usable when the heap API returns %s",
|
||||
async (value) => {
|
||||
const f = fixture();
|
||||
const result = await runReviewHarnessFileContentHashBenchmark(
|
||||
f.operations,
|
||||
{
|
||||
now: f.now,
|
||||
get memory() {
|
||||
if (value === "throws") throw new Error("Heap API unavailable");
|
||||
return { usedJSHeapSize: value as number };
|
||||
},
|
||||
},
|
||||
f.yieldControl
|
||||
);
|
||||
|
||||
expect(result.status).toBe("passed");
|
||||
expect(result.observations).toContain("JavaScript heap: unavailable on this device.");
|
||||
expect(result.observations.join("\n")).not.toMatch(/NaN|Infinity/u);
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the same deterministic byte sequence for short and long inputs", async () => {
|
||||
const shortInput = createFixedFileContentHashInput(4 * 1024);
|
||||
const longInput = createFixedFileContentHashInput(64 * 1024);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
type HeapPerformance = {
|
||||
readonly memory?: { readonly usedJSHeapSize: number };
|
||||
};
|
||||
|
||||
function readHeap(clock: HeapPerformance): number | undefined {
|
||||
try {
|
||||
const bytes = clock.memory?.usedJSHeapSize;
|
||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createReviewHarnessHeapSampler(clock: HeapPerformance): {
|
||||
sample(): void;
|
||||
finish(): string;
|
||||
} {
|
||||
const before = readHeap(clock);
|
||||
let highest = before;
|
||||
let after = before;
|
||||
const sample = () => {
|
||||
after = readHeap(clock);
|
||||
if (after !== undefined) highest = Math.max(highest ?? after, after);
|
||||
};
|
||||
const finish = () => {
|
||||
sample();
|
||||
if (highest === undefined) return "JavaScript heap: unavailable on this device.";
|
||||
const mib = (bytes: number | undefined) =>
|
||||
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
|
||||
return `JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`;
|
||||
};
|
||||
|
||||
return { sample, finish };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
||||
import { createReviewHarnessHeapSampler } from "./reviewHarnessHeapSampling";
|
||||
|
||||
export interface IdBenchmarkOperations {
|
||||
deriveKey(): Promise<unknown>;
|
||||
@@ -15,15 +16,6 @@ const SAMPLES = 3;
|
||||
const BATCH_SIZE = 100;
|
||||
const WARMUP_COUNT = 32;
|
||||
|
||||
function readHeap(clock: BenchmarkPerformance): number | undefined {
|
||||
try {
|
||||
const bytes = clock.memory?.usedJSHeapSize;
|
||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function summary(samples: readonly number[]): string {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return `median=${sorted[1].toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[2].toFixed(2)} ms`;
|
||||
@@ -34,13 +26,7 @@ export async function runReviewHarnessIdBenchmark(
|
||||
clock: BenchmarkPerformance = performance,
|
||||
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
): Promise<ReviewHarnessScenarioResult> {
|
||||
const before = readHeap(clock);
|
||||
let highest = before;
|
||||
const sampleHeap = () => {
|
||||
const value = readHeap(clock);
|
||||
if (value !== undefined) highest = Math.max(highest ?? value, value);
|
||||
return value;
|
||||
};
|
||||
const heap = createReviewHarnessHeapSampler(clock);
|
||||
const observations = [
|
||||
"Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.",
|
||||
"Compute timings include input construction and awaited ID generation. Initialisation, warm-up, and pauses between batches are excluded. This does not measure a Rebuild or remote transfer.",
|
||||
@@ -51,7 +37,7 @@ export async function runReviewHarnessIdBenchmark(
|
||||
const started = clock.now();
|
||||
await operations.deriveKey();
|
||||
derivationSamples.push(clock.now() - started);
|
||||
sampleHeap();
|
||||
heap.sample();
|
||||
}
|
||||
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
|
||||
|
||||
@@ -80,7 +66,7 @@ export async function runReviewHarnessIdBenchmark(
|
||||
const started = clock.now();
|
||||
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
|
||||
elapsed += clock.now() - started;
|
||||
sampleHeap();
|
||||
heap.sample();
|
||||
}
|
||||
samples[independent ? 1 : 0].push(elapsed);
|
||||
}
|
||||
@@ -92,16 +78,7 @@ export async function runReviewHarnessIdBenchmark(
|
||||
);
|
||||
}
|
||||
}
|
||||
const after = sampleHeap();
|
||||
if (highest === undefined) {
|
||||
observations.push("JavaScript heap: unavailable on this device.");
|
||||
} else {
|
||||
const mib = (bytes: number | undefined) =>
|
||||
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
|
||||
observations.push(
|
||||
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
|
||||
);
|
||||
}
|
||||
observations.push(heap.finish());
|
||||
observations.push(
|
||||
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
|
||||
);
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("Review Harness ID measurements", () => {
|
||||
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
|
||||
);
|
||||
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
|
||||
expect(reads).toBe(245);
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOwnedResource } from "octagonal-wheels/resource";
|
||||
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { bindableFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
|
||||
const unusedStartupDependencies: readonly [string, readonly string[]][] = [
|
||||
["@/serviceFeatures/useP2PSettingsPreparation", ["useP2PSettingsPreparation"]],
|
||||
["@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", ["setGetLanguage"]],
|
||||
["./features/LiveSyncCommands.ts", ["LiveSyncCommands"]],
|
||||
["./features/HiddenFileSync/CmdHiddenFileSync.ts", ["HiddenFileSync"]],
|
||||
["./features/ConfigSync/CmdConfigSync.ts", ["ConfigSync"]],
|
||||
["./modules/features/ModuleLog.ts", ["ModuleLog"]],
|
||||
["./modules/essentialObsidian/ModuleObsidianEvents.ts", ["ModuleObsidianEvents"]],
|
||||
["./modules/features/ModuleObsidianSettingTab.ts", ["ModuleObsidianSettingDialogue"]],
|
||||
["./modules/features/ModuleObsidianDocumentHistory.ts", ["ModuleObsidianDocumentHistory"]],
|
||||
["./modules/features/ModuleGlobalHistory.ts", ["ModuleObsidianGlobalHistory"]],
|
||||
["./features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts", ["LocalDatabaseMaintenance"]],
|
||||
["./modules/services/ObsidianServiceHub.ts", ["ObsidianServiceHub"]],
|
||||
["@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder", ["ServiceRebuilder"]],
|
||||
["@/serviceModules/DatabaseFileAccess.ts", ["ServiceDatabaseFileAccess"]],
|
||||
["@/serviceModules/ServiceFileAccessImpl.ts", ["ServiceFileAccessObsidian"]],
|
||||
["@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager", ["StorageAccessManager"]],
|
||||
["./serviceModules/FileHandler.ts", ["ServiceFileHandler"]],
|
||||
["./serviceModules/FileAccessObsidian.ts", ["FileAccessObsidian"]],
|
||||
["./managers/StorageEventManagerObsidian.ts", ["StorageEventManagerObsidian"]],
|
||||
["@vrtmrz/livesync-commonlib/compat/mock_and_interop/wrapper", ["setNoticeClass"]],
|
||||
["./LiveSyncBaseCore.ts", ["LiveSyncBaseCore"]],
|
||||
["./modules/features/ModuleObsidianSettingAsMarkdown.ts", ["ModuleObsidianSettingsAsMarkdown"]],
|
||||
["./modules/features/SetupManager.ts", ["SetupManager"]],
|
||||
["./serviceFeatures/onLayoutReady/enablei18n.ts", ["enableI18nFeature"]],
|
||||
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", ["useOfflineScanner"]],
|
||||
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", ["useRemoteConfiguration"]],
|
||||
["@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize", ["useCheckRemoteSize"]],
|
||||
["./serviceFeatures/redFlag.ts", ["useRedFlagFeatures"]],
|
||||
["./serviceFeatures/setupObsidian/setupProtocol.ts", ["useSetupProtocolFeature"]],
|
||||
["@/serviceFeatures/setupObsidian/qrCode", ["useSetupQRCodeFeature"]],
|
||||
["@/serviceFeatures/setupObsidian/setupUri", ["useSetupURIFeature"]],
|
||||
[
|
||||
"./serviceFeatures/setupObsidian/setupManagerHandlers.ts",
|
||||
["showOnboardingInvitation", "useSetupManagerHandlersFeature"],
|
||||
],
|
||||
["@vrtmrz/livesync-commonlib/p2p", ["useP2PReplicatorCommands", "useP2PReplicatorFeature"]],
|
||||
["./serviceFeatures/useP2PReplicatorUI.ts", ["useP2PReplicatorUI"]],
|
||||
["./serviceFeatures/useReviewHarness.ts", ["useReviewHarness"]],
|
||||
["./features/P2PSync/P2PReplicator/P2PReplicationUI.ts", ["createOpenReplicationUI", "createOpenRebuildUI"]],
|
||||
["./serviceFeatures/compatibilityReview.ts", ["useCompatibilityReview"]],
|
||||
["./serviceFeatures/compatibilityReviewObsidian.ts", ["createObsidianCompatibilityReviewUi"]],
|
||||
["./serviceModules/FileReflectionProvenance.ts", ["createFileReflectionProvenance"]],
|
||||
["./serviceFeatures/interactiveConflictResolution", ["useInteractiveConflictResolutionFeature"]],
|
||||
["./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", ["ConflictResolveModal"]],
|
||||
["./serviceFeatures/obsidianReplicationRibbon.ts", ["useObsidianReplicationRibbonFeature"]],
|
||||
["./serviceFeatures/syncGuard", ["useSyncGuardFeature"]],
|
||||
["./serviceFeatures/syncGuard/SyncGuardOverlay", ["SyncGuardOverlay"]],
|
||||
["./serviceFeatures/startupLifecycle", ["useStartupLifecycleFeature"]],
|
||||
["./serviceFeatures/useJoinedDevices", ["useJoinedDevicesFeature"]],
|
||||
["./serviceFeatures/useDatabaseDiagnostics", ["useDatabaseDiagnosticsFeature"]],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doMock("./deps", () => ({ Plugin: class {}, Notice: class {}, getLanguage: () => "en" }));
|
||||
for (const [specifier, names] of unusedStartupDependencies) {
|
||||
vi.doMock(specifier, () => Object.fromEntries(names.map((name) => [name, vi.fn()])));
|
||||
}
|
||||
});
|
||||
|
||||
describe("Obsidian plug-in unload logging", () => {
|
||||
it.each(["complete", "fail"] as const)(
|
||||
"retains its log connection through shutdown and retires it afterwards: %s",
|
||||
async (outcome) => {
|
||||
const { default: LiveSyncPlugin } = await import("./main.ts");
|
||||
const events: string[] = [];
|
||||
const sink = vi.fn((message: unknown) => events.push(String(message)));
|
||||
const addLog = bindableFunction<(message: unknown, level?: number, key?: string) => void>("unload log");
|
||||
const unregister = addLog.setHandler(sink);
|
||||
const dispose = vi.fn(() => {
|
||||
unregister();
|
||||
events.push("disposed");
|
||||
});
|
||||
const logResource = createOwnedResource(sink, dispose);
|
||||
let finish!: () => void;
|
||||
let fail!: (reason: Error) => void;
|
||||
const shutdown = new Promise<void>((resolve, reject) => {
|
||||
finish = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
const onUnload = vi.fn(() => shutdown);
|
||||
const core = {
|
||||
getModule: vi.fn(() => ({ logResource })),
|
||||
services: { API: { addLog }, control: { onUnload } },
|
||||
};
|
||||
|
||||
expect(LiveSyncPlugin.prototype.onunload.call({ core } as never)).toBeUndefined();
|
||||
expect(onUnload).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
addLog("during shutdown");
|
||||
|
||||
const failure = new Error("database close failed");
|
||||
if (outcome === "fail") {
|
||||
fail(failure);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
await vi.waitFor(() => expect(dispose).toHaveBeenCalledTimes(1));
|
||||
|
||||
if (outcome === "fail") {
|
||||
const message = "Failed to unload Self-hosted LiveSync: " + String(failure);
|
||||
expect(sink).toHaveBeenCalledWith(message, LOG_LEVEL_URGENT, "shutdown");
|
||||
expect(events).toEqual(["during shutdown", message, "disposed"]);
|
||||
} else {
|
||||
expect(events).toEqual(["during shutdown", "disposed"]);
|
||||
}
|
||||
expect(() => addLog("after shutdown")).toThrow("not assigned");
|
||||
}
|
||||
);
|
||||
});
|
||||
+10
-7
@@ -9,6 +9,7 @@ import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||
|
||||
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
||||
import { withOwnedResource } from "octagonal-wheels/resource";
|
||||
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
|
||||
@@ -237,12 +238,14 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
void this._startUp();
|
||||
}
|
||||
override onunload(): void {
|
||||
void this.core.services.control.onUnload().catch((error) => {
|
||||
this.core.services.API.addLog(
|
||||
"Failed to unload Self-hosted LiveSync: " + String(error),
|
||||
LOG_LEVEL_URGENT,
|
||||
"shutdown"
|
||||
);
|
||||
});
|
||||
void withOwnedResource(this.core.getModule(ModuleLog).logResource, () =>
|
||||
this.core.services.control.onUnload().catch((error) => {
|
||||
this.core.services.API.addLog(
|
||||
"Failed to unload Self-hosted LiveSync: " + String(error),
|
||||
LOG_LEVEL_URGENT,
|
||||
"shutdown"
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Modal: class Modal {},
|
||||
TFile: class TFile {},
|
||||
}));
|
||||
|
||||
vi.mock("@/main.ts", () => ({
|
||||
default: class ObsidianLiveSyncPlugin {},
|
||||
}));
|
||||
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
getPathFromTFile: (file: { path: string }) => file.path,
|
||||
isValidPath: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("octagonal-wheels/concurrency/lock", () => ({
|
||||
scheduleOnceIfDuplicated: vi.fn((_key: string, callback: () => unknown) => callback()),
|
||||
}));
|
||||
|
||||
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { DocumentHistoryModal } from "./DocumentHistoryModal.ts";
|
||||
|
||||
type NavigationTarget = {
|
||||
classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
|
||||
scrollIntoView: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createDiffView(count: number) {
|
||||
const focused: { current: NavigationTarget | undefined } = { current: undefined };
|
||||
const createTargets = (targetCount: number) =>
|
||||
Array.from({ length: targetCount }, () => {
|
||||
const target: NavigationTarget = {
|
||||
classList: {
|
||||
add: vi.fn(() => {
|
||||
focused.current = target;
|
||||
}),
|
||||
remove: vi.fn(() => {
|
||||
if (focused.current === target) focused.current = undefined;
|
||||
}),
|
||||
},
|
||||
scrollIntoView: vi.fn(),
|
||||
};
|
||||
return target;
|
||||
});
|
||||
let targets = createTargets(count);
|
||||
const contentView = {
|
||||
querySelectorAll: vi.fn(() => targets),
|
||||
querySelector: vi.fn(() => (targets.includes(focused.current as NavigationTarget) ? focused.current : null)),
|
||||
};
|
||||
return {
|
||||
contentView,
|
||||
targets,
|
||||
replaceTargets(targetCount: number) {
|
||||
focused.current = undefined;
|
||||
targets = createTargets(targetCount);
|
||||
return targets;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHistoryModal() {
|
||||
return Object.create(DocumentHistoryModal.prototype) as DocumentHistoryModal;
|
||||
}
|
||||
|
||||
describe("DocumentHistoryModal cyclic navigation", () => {
|
||||
it("commits the selected index before scrolling, including when scrolling fails", () => {
|
||||
const modal = createHistoryModal();
|
||||
const view = createDiffView(2);
|
||||
const indicator = { setText: vi.fn() };
|
||||
const failure = new Error("fixture scroll failed");
|
||||
Object.assign(modal, { contentView: view.contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||
view.targets[0].scrollIntoView.mockImplementation(() => {
|
||||
expect(modal.currentDiffIndex).toBe(0);
|
||||
throw failure;
|
||||
});
|
||||
expect(() => modal.navigateDiff("next")).toThrow(failure);
|
||||
expect(modal.currentDiffIndex).toBe(0);
|
||||
expect(indicator.setText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts at the first diff for next and at the last diff for previous", () => {
|
||||
const nextModal = createHistoryModal();
|
||||
const nextView = createDiffView(3);
|
||||
const nextIndicator = { setText: vi.fn() };
|
||||
Object.assign(nextModal, {
|
||||
contentView: nextView.contentView,
|
||||
currentDiffIndex: -1,
|
||||
diffNavIndicator: nextIndicator,
|
||||
});
|
||||
|
||||
nextModal.navigateDiff("next");
|
||||
|
||||
expect(nextModal.currentDiffIndex).toBe(0);
|
||||
expect(nextView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||
expect(nextView.targets[0].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||
expect(nextIndicator.setText).toHaveBeenCalledWith("1/3");
|
||||
|
||||
const previousModal = createHistoryModal();
|
||||
const previousView = createDiffView(3);
|
||||
const previousIndicator = { setText: vi.fn() };
|
||||
Object.assign(previousModal, {
|
||||
contentView: previousView.contentView,
|
||||
currentDiffIndex: -1,
|
||||
diffNavIndicator: previousIndicator,
|
||||
});
|
||||
|
||||
previousModal.navigateDiff("prev");
|
||||
|
||||
expect(previousModal.currentDiffIndex).toBe(2);
|
||||
expect(previousView.targets[2].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||
expect(previousView.targets[2].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||
expect(previousIndicator.setText).toHaveBeenCalledWith("3/3");
|
||||
});
|
||||
|
||||
it("wraps in both directions while moving focus, scrolling, and updating the indicator", () => {
|
||||
const modal = createHistoryModal();
|
||||
const { contentView, targets } = createDiffView(3);
|
||||
const indicator = { setText: vi.fn() };
|
||||
Object.assign(modal, { contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||
|
||||
modal.navigateDiff("next");
|
||||
modal.navigateDiff("next");
|
||||
modal.navigateDiff("next");
|
||||
modal.navigateDiff("next");
|
||||
modal.navigateDiff("prev");
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(2);
|
||||
expect(targets.map((target) => target.classList.add.mock.calls.length)).toEqual([2, 1, 2]);
|
||||
expect(targets.map((target) => target.classList.remove.mock.calls.length)).toEqual([2, 1, 1]);
|
||||
expect(targets.map((target) => target.scrollIntoView.mock.calls.length)).toEqual([2, 1, 2]);
|
||||
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/3", "2/3", "3/3", "1/3", "3/3"]);
|
||||
});
|
||||
|
||||
it("leaves the current navigation index and content view untouched when no diff exists", () => {
|
||||
const modal = createHistoryModal();
|
||||
const { contentView } = createDiffView(0);
|
||||
const indicator = { setText: vi.fn() };
|
||||
Object.assign(modal, { contentView, currentDiffIndex: 1, diffNavIndicator: indicator });
|
||||
|
||||
modal.navigateDiff("next");
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(1);
|
||||
expect(contentView.querySelector).not.toHaveBeenCalled();
|
||||
expect(indicator.setText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("looks up diff nodes again after the content view is replaced", () => {
|
||||
const modal = createHistoryModal();
|
||||
const view = createDiffView(2);
|
||||
const indicator = { setText: vi.fn() };
|
||||
Object.assign(modal, { contentView: view.contentView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||
|
||||
modal.navigateDiff("next");
|
||||
const replacementTargets = view.replaceTargets(3);
|
||||
modal.navigateDiff("next");
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(1);
|
||||
expect(view.contentView.querySelectorAll).toHaveBeenCalledTimes(2);
|
||||
expect(view.contentView.querySelector).toHaveBeenCalledTimes(2);
|
||||
expect(replacementTargets[1].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||
expect(replacementTargets[1].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/2", "2/3"]);
|
||||
});
|
||||
|
||||
it("keeps navigation indices and focus independent between modal instances", () => {
|
||||
const firstModal = createHistoryModal();
|
||||
const secondModal = createHistoryModal();
|
||||
const firstView = createDiffView(2);
|
||||
const secondView = createDiffView(2);
|
||||
Object.assign(firstModal, {
|
||||
contentView: firstView.contentView,
|
||||
currentDiffIndex: -1,
|
||||
diffNavIndicator: { setText: vi.fn() },
|
||||
});
|
||||
Object.assign(secondModal, {
|
||||
contentView: secondView.contentView,
|
||||
currentDiffIndex: -1,
|
||||
diffNavIndicator: { setText: vi.fn() },
|
||||
});
|
||||
|
||||
firstModal.navigateDiff("next");
|
||||
secondModal.navigateDiff("next");
|
||||
firstModal.navigateDiff("next");
|
||||
|
||||
expect(firstModal.currentDiffIndex).toBe(1);
|
||||
expect(secondModal.currentDiffIndex).toBe(0);
|
||||
expect(firstView.targets[0].classList.remove).toHaveBeenCalledWith("diff-focused");
|
||||
expect(secondView.targets[0].classList.remove).not.toHaveBeenCalled();
|
||||
expect(secondView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||
});
|
||||
|
||||
it("resets the indicator and visibility according to showDiff", () => {
|
||||
const modal = createHistoryModal();
|
||||
const view = createDiffView(2);
|
||||
const indicator = { setText: vi.fn() };
|
||||
const diffNavContainer = { setCssStyles: vi.fn() };
|
||||
const diffOnlyLabel = { setCssStyles: vi.fn() };
|
||||
Object.assign(modal, {
|
||||
contentView: view.contentView,
|
||||
currentDiffIndex: 1,
|
||||
diffNavIndicator: indicator,
|
||||
diffNavContainer,
|
||||
diffOnlyLabel,
|
||||
showDiff: true,
|
||||
});
|
||||
|
||||
modal.resetDiffNavigation();
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(-1);
|
||||
expect(view.contentView.querySelectorAll).toHaveBeenCalledWith(".history-added, .history-deleted");
|
||||
expect(indicator.setText).toHaveBeenLastCalledWith("0/2");
|
||||
expect(diffNavContainer.setCssStyles).toHaveBeenLastCalledWith({ display: "flex" });
|
||||
expect(diffOnlyLabel.setCssStyles).toHaveBeenLastCalledWith({ display: "inline-block" });
|
||||
|
||||
view.replaceTargets(0);
|
||||
modal.resetDiffNavigation();
|
||||
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
|
||||
|
||||
Object.assign(modal, { showDiff: false });
|
||||
modal.resetDiffNavigation();
|
||||
expect(view.contentView.querySelectorAll).toHaveBeenCalledTimes(2);
|
||||
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
|
||||
expect(diffNavContainer.setCssStyles).toHaveBeenLastCalledWith({ display: "none" });
|
||||
expect(diffOnlyLabel.setCssStyles).toHaveBeenLastCalledWith({ display: "none" });
|
||||
});
|
||||
|
||||
it("updates visibility without an indicator", () => {
|
||||
const modal = createHistoryModal();
|
||||
const { contentView } = createDiffView(2);
|
||||
const diffNavContainer = { setCssStyles: vi.fn() };
|
||||
Object.assign(modal, { contentView, currentDiffIndex: 1, diffNavContainer, showDiff: true });
|
||||
|
||||
expect(() => modal.resetDiffNavigation()).not.toThrow();
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(-1);
|
||||
expect(contentView.querySelectorAll).not.toHaveBeenCalled();
|
||||
expect(diffNavContainer.setCssStyles).toHaveBeenCalledWith({ display: "flex" });
|
||||
});
|
||||
|
||||
it("maps selected search results to the revision slider and loads each wrapped selection", () => {
|
||||
const modal = createHistoryModal();
|
||||
const scheduleLoad = vi.mocked(scheduleOnceIfDuplicated);
|
||||
scheduleLoad.mockClear();
|
||||
const indicator = { setText: vi.fn() };
|
||||
Object.assign(modal, {
|
||||
searchResults: [
|
||||
{ rev: "newest", index: 0, matchType: "Content" },
|
||||
{ rev: "middle", index: 2, matchType: "Diff" },
|
||||
{ rev: "oldest", index: 4, matchType: "Content" },
|
||||
],
|
||||
currentSearchIndex: -1,
|
||||
revs_info: Array.from({ length: 5 }, (_, index) => ({ rev: `rev-${index}` })),
|
||||
range: { value: "" },
|
||||
loadRevs: vi.fn(),
|
||||
searchResultIndicator: indicator,
|
||||
searchPrevBtn: { disabled: true },
|
||||
searchNextBtn: { disabled: true },
|
||||
searchKeyword: "needle",
|
||||
showDiff: false,
|
||||
});
|
||||
|
||||
modal.navigateSearch("next");
|
||||
expect(modal.currentSearchIndex).toBe(0);
|
||||
expect(modal.range.value).toBe("4");
|
||||
expect(modal.loadRevs).toHaveBeenCalledTimes(1);
|
||||
expect(scheduleLoad).toHaveBeenCalledWith("loadRevs", expect.any(Function));
|
||||
expect(indicator.setText).toHaveBeenLastCalledWith("1/3 matches");
|
||||
|
||||
modal.navigateSearch("next");
|
||||
expect(modal.currentSearchIndex).toBe(1);
|
||||
expect(modal.range.value).toBe("2");
|
||||
expect(modal.loadRevs).toHaveBeenCalledTimes(2);
|
||||
|
||||
modal.navigateSearch("next");
|
||||
expect(modal.currentSearchIndex).toBe(2);
|
||||
expect(modal.range.value).toBe("0");
|
||||
|
||||
modal.navigateSearch("next");
|
||||
expect(modal.currentSearchIndex).toBe(0);
|
||||
expect(modal.range.value).toBe("4");
|
||||
|
||||
modal.navigateSearch("prev");
|
||||
expect(modal.currentSearchIndex).toBe(2);
|
||||
expect(modal.range.value).toBe("0");
|
||||
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual([
|
||||
"1/3 matches",
|
||||
"2/3 matches",
|
||||
"3/3 matches",
|
||||
"1/3 matches",
|
||||
"3/3 matches",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves search state and controls untouched when there are no results", () => {
|
||||
const modal = createHistoryModal();
|
||||
const indicator = { setText: vi.fn() };
|
||||
const searchPrevBtn = { disabled: true };
|
||||
const searchNextBtn = { disabled: true };
|
||||
Object.assign(modal, {
|
||||
searchResults: [],
|
||||
currentSearchIndex: -1,
|
||||
range: { value: "unchanged" },
|
||||
loadRevs: vi.fn(),
|
||||
searchResultIndicator: indicator,
|
||||
searchPrevBtn,
|
||||
searchNextBtn,
|
||||
searchKeyword: "needle",
|
||||
showDiff: false,
|
||||
});
|
||||
|
||||
modal.navigateSearch("next");
|
||||
|
||||
expect(modal.currentSearchIndex).toBe(-1);
|
||||
expect(modal.range.value).toBe("unchanged");
|
||||
expect(modal.loadRevs).not.toHaveBeenCalled();
|
||||
expect(indicator.setText).not.toHaveBeenCalled();
|
||||
expect(searchPrevBtn.disabled).toBe(true);
|
||||
expect(searchNextBtn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { isErrorOfMissingDoc } from "@vrtmrz/livesync-commonlib/compat/pouchdb/u
|
||||
import { fireAndForget, getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isPlainText, stripPrefix } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { stepCyclicIndex } from "octagonal-wheels/collection";
|
||||
import { navigateDiffNodes } from "@/common/diffNavigation.ts";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
@@ -375,26 +377,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
* Only effective when diff highlighting is enabled.
|
||||
*/
|
||||
navigateDiff(direction: "prev" | "next") {
|
||||
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
|
||||
if (diffElements.length === 0) return;
|
||||
|
||||
// Remove previous focus highlight
|
||||
const prevFocused = this.contentView.querySelector(".diff-focused");
|
||||
if (prevFocused) {
|
||||
prevFocused.classList.remove("diff-focused");
|
||||
}
|
||||
|
||||
if (direction === "next") {
|
||||
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||
} else {
|
||||
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||
}
|
||||
|
||||
const target = diffElements[this.currentDiffIndex];
|
||||
target.classList.add("diff-focused");
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
|
||||
navigateDiffNodes(this.contentView, ".history-added, .history-deleted", this, direction);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,12 +502,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
navigateSearch(direction: "prev" | "next") {
|
||||
if (this.searchResults.length === 0) return;
|
||||
|
||||
if (direction === "next") {
|
||||
this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length;
|
||||
} else {
|
||||
this.currentSearchIndex =
|
||||
this.currentSearchIndex <= 0 ? this.searchResults.length - 1 : this.currentSearchIndex - 1;
|
||||
}
|
||||
this.currentSearchIndex = stepCyclicIndex(this.currentSearchIndex, this.searchResults.length, direction);
|
||||
|
||||
const match = this.searchResults[this.currentSearchIndex];
|
||||
this.range.value = `${this.revs_info.length - 1 - match.index}`;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
||||
import { navigateDiffNodes } from "@/common/diffNavigation.ts";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
|
||||
|
||||
@@ -83,24 +84,7 @@ export class ConflictResolveModal extends Modal {
|
||||
}
|
||||
|
||||
navigateDiff(direction: "prev" | "next") {
|
||||
const diffElements = this.diffView.querySelectorAll(".added, .deleted");
|
||||
if (diffElements.length === 0) return;
|
||||
|
||||
const prevFocused = this.diffView.querySelector(".diff-focused");
|
||||
if (prevFocused) {
|
||||
prevFocused.classList.remove("diff-focused");
|
||||
}
|
||||
|
||||
if (direction === "next") {
|
||||
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||
} else {
|
||||
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||
}
|
||||
|
||||
const target = diffElements[this.currentDiffIndex];
|
||||
target.classList.add("diff-focused");
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
|
||||
navigateDiffNodes(this.diffView, ".added, .deleted", this, direction);
|
||||
}
|
||||
|
||||
resetDiffNavigation() {
|
||||
|
||||
+184
-5
@@ -106,11 +106,7 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
});
|
||||
|
||||
it("closes and completes its result when the plug-in unloads", async () => {
|
||||
const modal = new ConflictResolveModal(
|
||||
{} as never,
|
||||
"open-during-unload.md" as FilePathWithPrefix,
|
||||
conflict
|
||||
);
|
||||
const modal = new ConflictResolveModal({} as never, "open-during-unload.md" as FilePathWithPrefix, conflict);
|
||||
modal.onOpen();
|
||||
|
||||
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
@@ -165,3 +161,186 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
expect(previousState).toBe("still-open");
|
||||
});
|
||||
});
|
||||
|
||||
type NavigationTarget = {
|
||||
classList: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
|
||||
scrollIntoView: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createDiffView(count: number) {
|
||||
const focused: { current: NavigationTarget | undefined } = { current: undefined };
|
||||
const createTargets = (targetCount: number) =>
|
||||
Array.from({ length: targetCount }, () => {
|
||||
const target: NavigationTarget = {
|
||||
classList: {
|
||||
add: vi.fn(() => {
|
||||
focused.current = target;
|
||||
}),
|
||||
remove: vi.fn(() => {
|
||||
if (focused.current === target) focused.current = undefined;
|
||||
}),
|
||||
},
|
||||
scrollIntoView: vi.fn(),
|
||||
};
|
||||
return target;
|
||||
});
|
||||
let targets = createTargets(count);
|
||||
const diffView = {
|
||||
querySelectorAll: vi.fn(() => targets),
|
||||
querySelector: vi.fn(() => (targets.includes(focused.current as NavigationTarget) ? focused.current : null)),
|
||||
};
|
||||
return {
|
||||
diffView,
|
||||
targets,
|
||||
replaceTargets(targetCount: number) {
|
||||
focused.current = undefined;
|
||||
targets = createTargets(targetCount);
|
||||
return targets;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConflictResolveModal cyclic navigation", () => {
|
||||
it("commits the selected index before scrolling, including when scrolling fails", () => {
|
||||
const modal = Object.create(ConflictResolveModal.prototype) as ConflictResolveModal;
|
||||
const view = createDiffView(2);
|
||||
const indicator = { setText: vi.fn() };
|
||||
const failure = new Error("fixture scroll failed");
|
||||
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||
view.targets[0].scrollIntoView.mockImplementation(() => {
|
||||
expect(modal.currentDiffIndex).toBe(0);
|
||||
throw failure;
|
||||
});
|
||||
expect(() => modal.navigateDiff("next")).toThrow(failure);
|
||||
expect(modal.currentDiffIndex).toBe(0);
|
||||
expect(indicator.setText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts at the first diff for next and at the last diff for previous", () => {
|
||||
const nextModal = new ConflictResolveModal({} as never, "next.md" as FilePathWithPrefix, conflict);
|
||||
const nextView = createDiffView(3);
|
||||
const nextIndicator = { setText: vi.fn() };
|
||||
Object.assign(nextModal, {
|
||||
diffView: nextView.diffView,
|
||||
currentDiffIndex: -1,
|
||||
diffNavIndicator: nextIndicator,
|
||||
});
|
||||
|
||||
nextModal.navigateDiff("next");
|
||||
|
||||
expect(nextModal.currentDiffIndex).toBe(0);
|
||||
expect(nextView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||
expect(nextView.targets[0].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||
expect(nextIndicator.setText).toHaveBeenCalledWith("1/3");
|
||||
|
||||
const previousModal = new ConflictResolveModal({} as never, "previous.md" as FilePathWithPrefix, conflict);
|
||||
const previousView = createDiffView(3);
|
||||
const previousIndicator = { setText: vi.fn() };
|
||||
Object.assign(previousModal, {
|
||||
diffView: previousView.diffView,
|
||||
currentDiffIndex: -1,
|
||||
diffNavIndicator: previousIndicator,
|
||||
});
|
||||
|
||||
previousModal.navigateDiff("prev");
|
||||
|
||||
expect(previousModal.currentDiffIndex).toBe(2);
|
||||
expect(previousView.targets[2].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||
expect(previousView.targets[2].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||
expect(previousIndicator.setText).toHaveBeenCalledWith("3/3");
|
||||
});
|
||||
|
||||
it("wraps in both directions while moving focus, scrolling, and updating the indicator", () => {
|
||||
const modal = new ConflictResolveModal({} as never, "wrap.md" as FilePathWithPrefix, conflict);
|
||||
const { diffView, targets } = createDiffView(3);
|
||||
const indicator = { setText: vi.fn() };
|
||||
Object.assign(modal, { diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||
|
||||
modal.navigateDiff("next");
|
||||
modal.navigateDiff("next");
|
||||
modal.navigateDiff("next");
|
||||
modal.navigateDiff("next");
|
||||
modal.navigateDiff("prev");
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(2);
|
||||
expect(targets.map((target) => target.classList.add.mock.calls.length)).toEqual([2, 1, 2]);
|
||||
expect(targets.map((target) => target.classList.remove.mock.calls.length)).toEqual([2, 1, 1]);
|
||||
expect(targets.map((target) => target.scrollIntoView.mock.calls.length)).toEqual([2, 1, 2]);
|
||||
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/3", "2/3", "3/3", "1/3", "3/3"]);
|
||||
});
|
||||
|
||||
it("leaves the current navigation index and diff view untouched when no diff exists", () => {
|
||||
const modal = new ConflictResolveModal({} as never, "empty.md" as FilePathWithPrefix, conflict);
|
||||
const { diffView } = createDiffView(0);
|
||||
const indicator = { setText: vi.fn() };
|
||||
Object.assign(modal, { diffView, currentDiffIndex: 1, diffNavIndicator: indicator });
|
||||
|
||||
modal.navigateDiff("next");
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(1);
|
||||
expect(diffView.querySelector).not.toHaveBeenCalled();
|
||||
expect(indicator.setText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("looks up diff nodes again after the diff view is replaced", () => {
|
||||
const modal = new ConflictResolveModal({} as never, "replace.md" as FilePathWithPrefix, conflict);
|
||||
const view = createDiffView(2);
|
||||
const indicator = { setText: vi.fn() };
|
||||
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: -1, diffNavIndicator: indicator });
|
||||
|
||||
modal.navigateDiff("next");
|
||||
const replacementTargets = view.replaceTargets(3);
|
||||
modal.navigateDiff("next");
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(1);
|
||||
expect(view.diffView.querySelectorAll).toHaveBeenCalledTimes(2);
|
||||
expect(view.diffView.querySelector).toHaveBeenCalledTimes(2);
|
||||
expect(replacementTargets[1].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||
expect(replacementTargets[1].scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
|
||||
expect(indicator.setText.mock.calls.map(([text]) => text)).toEqual(["1/2", "2/3"]);
|
||||
});
|
||||
|
||||
it("keeps navigation indices and focus independent between modal instances", () => {
|
||||
const firstModal = new ConflictResolveModal({} as never, "first.md" as FilePathWithPrefix, conflict);
|
||||
const secondModal = new ConflictResolveModal({} as never, "second.md" as FilePathWithPrefix, conflict);
|
||||
const firstView = createDiffView(2);
|
||||
const secondView = createDiffView(2);
|
||||
Object.assign(firstModal, {
|
||||
diffView: firstView.diffView,
|
||||
currentDiffIndex: -1,
|
||||
diffNavIndicator: { setText: vi.fn() },
|
||||
});
|
||||
Object.assign(secondModal, {
|
||||
diffView: secondView.diffView,
|
||||
currentDiffIndex: -1,
|
||||
diffNavIndicator: { setText: vi.fn() },
|
||||
});
|
||||
|
||||
firstModal.navigateDiff("next");
|
||||
secondModal.navigateDiff("next");
|
||||
firstModal.navigateDiff("next");
|
||||
|
||||
expect(firstModal.currentDiffIndex).toBe(1);
|
||||
expect(secondModal.currentDiffIndex).toBe(0);
|
||||
expect(firstView.targets[0].classList.remove).toHaveBeenCalledWith("diff-focused");
|
||||
expect(secondView.targets[0].classList.remove).not.toHaveBeenCalled();
|
||||
expect(secondView.targets[0].classList.add).toHaveBeenCalledWith("diff-focused");
|
||||
});
|
||||
|
||||
it("always resets the indicator using the current diff count", () => {
|
||||
const modal = new ConflictResolveModal({} as never, "reset.md" as FilePathWithPrefix, conflict);
|
||||
const view = createDiffView(2);
|
||||
const indicator = { setText: vi.fn() };
|
||||
Object.assign(modal, { diffView: view.diffView, currentDiffIndex: 1, diffNavIndicator: indicator });
|
||||
|
||||
modal.resetDiffNavigation();
|
||||
|
||||
expect(modal.currentDiffIndex).toBe(-1);
|
||||
expect(view.diffView.querySelectorAll).toHaveBeenCalledWith(".added, .deleted");
|
||||
expect(indicator.setText).toHaveBeenLastCalledWith("0/2");
|
||||
|
||||
view.replaceTargets(0);
|
||||
modal.resetDiffNavigation();
|
||||
expect(indicator.setText).toHaveBeenLastCalledWith("\u2014");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { computed, reactive, reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import {
|
||||
computed,
|
||||
reactive,
|
||||
reactiveSource,
|
||||
type ReactiveSource,
|
||||
type ReactiveValue,
|
||||
} from "octagonal-wheels/dataobject/reactive";
|
||||
import {
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_INFO,
|
||||
@@ -7,6 +13,7 @@ import {
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { createOwnedResource, type OwnedResource } from "octagonal-wheels/resource";
|
||||
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import {
|
||||
chunkFetchCounts,
|
||||
@@ -46,7 +53,11 @@ import {
|
||||
isValidFilenameInDarwin,
|
||||
isValidFilenameInWidows,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
MARK_LOG_NETWORK_ERROR,
|
||||
MARK_LOG_SEPARATOR,
|
||||
type LogFunction,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
@@ -57,20 +68,22 @@ import {
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
// DI the log again.
|
||||
const recentLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
const globalLogFunction = (message: unknown, level?: number, key?: string) => {
|
||||
const messageX =
|
||||
message instanceof Error
|
||||
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
||||
: typeof message === "string"
|
||||
? message
|
||||
: JSON.stringify(message);
|
||||
const entry = { message: messageX, level, key } as LogEntry;
|
||||
recentLogEntries.value = [...recentLogEntries.value, entry];
|
||||
};
|
||||
function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
|
||||
return (message: unknown, level?: number, key?: string) => {
|
||||
const messageX =
|
||||
message instanceof Error
|
||||
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
||||
: typeof message === "string"
|
||||
? message
|
||||
: JSON.stringify(message);
|
||||
const entry = { message: messageX, level, key } as LogEntry;
|
||||
entries.value = [...entries.value, entry];
|
||||
};
|
||||
}
|
||||
|
||||
setGlobalLogFunction(globalLogFunction);
|
||||
// Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
|
||||
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
|
||||
// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs.
|
||||
// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI.
|
||||
const logForDump = [] as string[];
|
||||
@@ -113,6 +126,16 @@ function redactLog(log: string) {
|
||||
const showDebugLog = false;
|
||||
export const MARK_DONE = "\u{2009}\u{2009}";
|
||||
export class ModuleLog extends AbstractObsidianModule {
|
||||
/** The host releases this connection after the complete asynchronous unload. */
|
||||
logResource!: OwnedResource<LogFunction>;
|
||||
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
private readonly displayLogEntries = () => {
|
||||
if (this.recentLogEntries.value.length === 0) return;
|
||||
const entries = [...this.recentLogEntries.value];
|
||||
this.recentLogEntries.value = [];
|
||||
entries.forEach((entry) => this.__addLog(entry.message, entry.level, entry.key));
|
||||
};
|
||||
|
||||
statusBar?: HTMLElement;
|
||||
|
||||
statusDiv?: HTMLElement;
|
||||
@@ -460,12 +483,7 @@ ${stringifyYaml(info)}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
recentLogEntries.onChanged((entries) => {
|
||||
if (entries.value.length === 0) return;
|
||||
const newEntries = [...entries.value];
|
||||
recentLogEntries.value = [];
|
||||
newEntries.forEach((e) => this.__addLog(e.message, e.level, e.key));
|
||||
});
|
||||
this.recentLogEntries.onChanged(this.displayLogEntries);
|
||||
eventHub.onEvent(EVENT_FILE_RENAMED, (data) => {
|
||||
void this.setFileStatus();
|
||||
});
|
||||
@@ -597,7 +615,22 @@ ${stringifyYaml(info)}
|
||||
}
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.API.addLog.setHandler(globalLogFunction);
|
||||
const log = createBufferedLog(this.recentLogEntries);
|
||||
const unregisterLog = services.API.addLog.setHandler(log);
|
||||
const collectGlobalLogs = () => {
|
||||
const entries = recentGlobalLogEntries.value;
|
||||
if (entries.length === 0) return;
|
||||
recentGlobalLogEntries.value = [];
|
||||
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
|
||||
};
|
||||
this.logResource = createOwnedResource(log, () => {
|
||||
unregisterLog();
|
||||
recentGlobalLogEntries.offChanged(collectGlobalLogs);
|
||||
this.recentLogEntries.offChanged(this.displayLogEntries);
|
||||
this.recentLogEntries.value = [];
|
||||
});
|
||||
recentGlobalLogEntries.onChanged(collectGlobalLogs);
|
||||
collectGlobalLogs();
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
debounce: (callback: () => void) => callback,
|
||||
normalizePath: (path: string) => path,
|
||||
Notice: class {},
|
||||
stringifyYaml: vi.fn(),
|
||||
}));
|
||||
vi.mock("./Log/LogPaneView.ts", () => ({ LogPaneView: class {}, VIEW_TYPE_LOG: "log" }));
|
||||
vi.mock("@/common/reportTool.ts", () => ({ generateReport: vi.fn() }));
|
||||
vi.mock("@/common/utils.ts", () => ({ isValidPath: () => true }));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_FILE_RENAMED: "renamed",
|
||||
EVENT_LAYOUT_READY: "ready",
|
||||
EVENT_LEAF_ACTIVE_CHANGED: "active",
|
||||
EVENT_ON_UNRESOLVED_ERROR: "error",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector", () => ({
|
||||
P2PLogCollector: class {},
|
||||
}));
|
||||
|
||||
async function createHarness() {
|
||||
const { ModuleLog } = await import("./ModuleLog.ts");
|
||||
const { bindableFunction, allFunction } =
|
||||
await import("@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils");
|
||||
const services = {
|
||||
API: {
|
||||
addLog: bindableFunction<(message: unknown, level?: number, key?: string) => void>("log"),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
context: { events: {}, notices: { hide: vi.fn() } },
|
||||
appLifecycle: {
|
||||
onInitialise: allFunction<() => Promise<boolean>>(),
|
||||
onSettingLoaded: allFunction<() => Promise<boolean>>(),
|
||||
onLoaded: allFunction<() => Promise<boolean>>(),
|
||||
onBeforeUnload: allFunction<() => Promise<boolean>>(),
|
||||
},
|
||||
};
|
||||
const core = { services, settings: {} };
|
||||
const module = new ModuleLog({ app: {} } as never, core as never);
|
||||
const sink = vi.spyOn(module, "__addLog").mockImplementation(() => undefined);
|
||||
vi.spyOn(module, "observeForLogs").mockImplementation(() => undefined);
|
||||
vi.spyOn(module, "adjustStatusDivPosition").mockImplementation(() => undefined);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
const { Logger } = await import("octagonal-wheels/common/logger");
|
||||
return { module, services, sink, Logger };
|
||||
}
|
||||
|
||||
describe("ModuleLog service connection", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("document", { querySelectorAll: () => [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("buffers startup logs and preserves global and service call order", async () => {
|
||||
const { services, sink, Logger } = await createHarness();
|
||||
Logger("before-global");
|
||||
services.API.addLog("before-service", 32, "startup");
|
||||
Logger("between-global");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
const messages = sink.mock.calls.map(([message]) => message);
|
||||
expect(messages.filter((message) => typeof message === "string" && /^(before|between)-/.test(message))).toEqual(
|
||||
["before-global", "before-service", "between-global"]
|
||||
);
|
||||
expect(sink).toHaveBeenCalledWith("before-service", 32, "startup");
|
||||
});
|
||||
|
||||
it("delivers service logs only to their own module", async () => {
|
||||
const first = await createHarness();
|
||||
const second = await createHarness();
|
||||
await first.services.appLifecycle.onSettingLoaded();
|
||||
await second.services.appLifecycle.onSettingLoaded();
|
||||
first.sink.mockClear();
|
||||
second.sink.mockClear();
|
||||
|
||||
first.services.API.addLog("first", 32, "first-key");
|
||||
second.services.API.addLog("second", 64, "second-key");
|
||||
expect(first.sink.mock.calls).toEqual([["first", 32, "first-key"]]);
|
||||
expect(second.sink.mock.calls).toEqual([["second", 64, "second-key"]]);
|
||||
});
|
||||
|
||||
it("preserves message conversion, levels, keys, and error causes", async () => {
|
||||
const { services, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const error = new Error("failed");
|
||||
services.API.addLog(error, 128, "failure");
|
||||
services.API.addLog({ result: "value" }, 32, "object");
|
||||
services.API.addLog("plain");
|
||||
const converted = sink.mock.calls[0][0] as Error & { cause: unknown };
|
||||
expect(converted.message).toBe("[Error Logged]: failed");
|
||||
expect(converted.cause).toBe(error);
|
||||
expect(sink.mock.calls[0].slice(1)).toEqual([128, "failure"]);
|
||||
expect(sink.mock.calls.slice(1)).toEqual([
|
||||
['{"result":"value"}', 32, "object"],
|
||||
["plain", undefined, undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("detaches both service registration and legacy subscription on disposal", async () => {
|
||||
const first = await createHarness();
|
||||
const second = await createHarness();
|
||||
await first.services.appLifecycle.onSettingLoaded();
|
||||
await second.services.appLifecycle.onSettingLoaded();
|
||||
first.sink.mockClear();
|
||||
second.sink.mockClear();
|
||||
|
||||
await first.module.logResource.dispose();
|
||||
await first.module.logResource.dispose();
|
||||
expect(() => first.services.API.addLog("retired")).toThrow("not assigned");
|
||||
second.services.API.addLog("active", 32, "service");
|
||||
first.Logger("legacy-after-retirement", 32, "legacy");
|
||||
expect(first.sink).not.toHaveBeenCalled();
|
||||
expect(second.sink.mock.calls).toEqual([
|
||||
["active", 32, "service"],
|
||||
["legacy-after-retirement", 32, "legacy"],
|
||||
]);
|
||||
await second.module.logResource.dispose();
|
||||
});
|
||||
|
||||
it("leaves a replacement service handler registered when the old owner is disposed", async () => {
|
||||
const { services, module, sink, Logger } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
const replacement = vi.fn();
|
||||
services.API.addLog.setHandler(replacement, true);
|
||||
sink.mockClear();
|
||||
|
||||
await module.logResource.dispose();
|
||||
await module.logResource.dispose();
|
||||
services.API.addLog("replacement", 32, "key");
|
||||
Logger("detached-legacy");
|
||||
expect(replacement).toHaveBeenCalledExactlyOnceWith("replacement", 32, "key");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases a connection which never reached the settings-loaded phase", async () => {
|
||||
const { services, module, sink, Logger } = await createHarness();
|
||||
services.API.addLog("pending");
|
||||
await module.logResource.dispose();
|
||||
Logger("after-disposal");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
|
||||
const replacement = await createHarness();
|
||||
await replacement.services.appLifecycle.onSettingLoaded();
|
||||
const messages = replacement.sink.mock.calls.map(([message]) => message);
|
||||
expect(messages).toContain("after-disposal");
|
||||
expect(messages).not.toContain("pending");
|
||||
await replacement.module.logResource.dispose();
|
||||
});
|
||||
|
||||
it.each(["complete", "fail"] as const)(
|
||||
"keeps logging through asynchronous database shutdown and releases after it: %s",
|
||||
async (outcome) => {
|
||||
const { services, module, sink, Logger } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const { ControlService } = await import("@vrtmrz/livesync-commonlib/compat/services/base/ControlService");
|
||||
const { ServiceContext } = await import("@vrtmrz/livesync-commonlib/context");
|
||||
const { withOwnedResource } = await import("octagonal-wheels/resource");
|
||||
let finish!: () => void;
|
||||
let fail!: (reason: Error) => void;
|
||||
let started!: () => void;
|
||||
const closing = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
finish = resolve;
|
||||
fail = reject;
|
||||
});
|
||||
const control = new ControlService(new ServiceContext({ translate: (key) => key }), {
|
||||
appLifecycleService: {
|
||||
...services.appLifecycle,
|
||||
onAppUnload: vi.fn(async () => []),
|
||||
onUnload: vi.fn(async () => {
|
||||
services.API.addLog("lifecycle-unload");
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
APIService: services.API,
|
||||
databaseService: {
|
||||
localDatabaseDirect: {
|
||||
onunload: vi.fn(() => services.API.addLog("database-unload")),
|
||||
close: vi.fn(() => {
|
||||
started();
|
||||
return closed;
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const unloading = withOwnedResource(module.logResource, () => control.onUnload());
|
||||
await closing;
|
||||
services.API.addLog("during-close");
|
||||
expect(sink.mock.calls.map(([message]) => message)).toEqual([
|
||||
"lifecycle-unload",
|
||||
"database-unload",
|
||||
"during-close",
|
||||
]);
|
||||
if (outcome === "fail") {
|
||||
const failure = new Error("database close failed");
|
||||
const rejected = expect(unloading).rejects.toBe(failure);
|
||||
fail(failure);
|
||||
await rejected;
|
||||
} else {
|
||||
finish();
|
||||
await unloading;
|
||||
expect(sink.mock.calls[sink.mock.calls.length - 1][0]).toContain(
|
||||
"moduleLiveSyncMain.logUnloadingPlugin"
|
||||
);
|
||||
}
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
sink.mockClear();
|
||||
Logger("after-unload");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
statusDisplay,
|
||||
type ConfigurationItem,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import {
|
||||
type AllSettingItemKey,
|
||||
getConfig,
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
type AllBooleanItemKey,
|
||||
} from "./settingConstants.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
|
||||
import type { AutoWireOption, OnUpdateResult } from "./SettingPane.ts";
|
||||
import { onlyWhenChanged } from "octagonal-wheels/function";
|
||||
import { setButtonDestructiveState } from "./settingComponentStyles.ts";
|
||||
|
||||
export class LiveSyncSetting extends Setting {
|
||||
@@ -109,11 +110,15 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
LiveSyncSetting.env.requestUpdate();
|
||||
}
|
||||
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||
private autoWireStringComponent<TComponent extends TextComponent | TextAreaComponent>(
|
||||
key: AllStringItemKey,
|
||||
addComponent: (callback: (component: TComponent) => void) => void,
|
||||
opt?: AutoWireOption
|
||||
) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addText((text) => {
|
||||
addComponent((text) => {
|
||||
this.autoWiredComponent = text;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
const setValue = onlyWhenChanged((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
||||
@@ -127,26 +132,25 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
this.autoWireComponent(this.autoWiredComponent, conf, opt);
|
||||
});
|
||||
}
|
||||
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||
this.autoWireStringComponent<TextComponent>(
|
||||
key,
|
||||
(callback) => {
|
||||
this.addText(callback);
|
||||
},
|
||||
opt
|
||||
);
|
||||
return this;
|
||||
}
|
||||
autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addTextArea((text) => {
|
||||
this.autoWiredComponent = text;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
||||
this.invalidateValue();
|
||||
text.onChange(async (value) => {
|
||||
await this.commitValue(value);
|
||||
});
|
||||
if (opt?.isPassword) {
|
||||
text.inputEl.setAttribute("type", "password");
|
||||
this.hasPassword = true;
|
||||
}
|
||||
this.autoWireComponent(this.autoWiredComponent, conf, opt);
|
||||
});
|
||||
this.autoWireStringComponent<TextAreaComponent>(
|
||||
key,
|
||||
(callback) => {
|
||||
this.addTextArea(callback);
|
||||
},
|
||||
opt
|
||||
);
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(
|
||||
@@ -163,7 +167,7 @@ export class LiveSyncSetting extends Setting {
|
||||
text.inputEl.setAttribute("max", `${opt.clampMax}`);
|
||||
}
|
||||
let lastError = false;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
const setValue = onlyWhenChanged((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => {
|
||||
@@ -209,7 +213,7 @@ export class LiveSyncSetting extends Setting {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addToggle((toggle) => {
|
||||
this.autoWiredComponent = toggle;
|
||||
const setValue = wrapMemo((value: boolean) => {
|
||||
const setValue = onlyWhenChanged((value: boolean) => {
|
||||
toggle.setValue(opt?.invert ? !value : value);
|
||||
});
|
||||
this.invalidateValue = () =>
|
||||
@@ -228,7 +232,7 @@ export class LiveSyncSetting extends Setting {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addDropdown((dropdown) => {
|
||||
this.autoWiredComponent = dropdown;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
const setValue = onlyWhenChanged((value: string) => {
|
||||
dropdown.setValue(value);
|
||||
});
|
||||
|
||||
@@ -248,7 +252,7 @@ export class LiveSyncSetting extends Setting {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addDropdown((dropdown) => {
|
||||
this.autoWiredComponent = dropdown;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
const setValue = onlyWhenChanged((value: string) => {
|
||||
dropdown.setValue(value);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const liveSyncSettingHarness = vi.hoisted(() => ({
|
||||
components: [] as unknown[],
|
||||
settingElements: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => {
|
||||
class StringComponent {
|
||||
values: string[] = [];
|
||||
changeHandler?: (value: string) => Promise<void>;
|
||||
placeholder?: string;
|
||||
inputEl = {
|
||||
attributes: new Map<string, string>(),
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes.set(name, value);
|
||||
},
|
||||
toggleClass: (_name: string, _enabled: boolean) => {},
|
||||
};
|
||||
setValue(value: string) {
|
||||
this.values.push(value);
|
||||
}
|
||||
onChange(handler: (value: string) => Promise<void>) {
|
||||
this.changeHandler = handler;
|
||||
return this;
|
||||
}
|
||||
setPlaceholder(value: string) {
|
||||
this.placeholder = value;
|
||||
return this;
|
||||
}
|
||||
constructor() {
|
||||
liveSyncSettingHarness.components.push(this);
|
||||
}
|
||||
}
|
||||
class TextComponent extends StringComponent {}
|
||||
class TextAreaComponent extends StringComponent {}
|
||||
class Setting {
|
||||
settingEl = {
|
||||
classes: [] as { name: string; enabled: boolean }[],
|
||||
toggleClass(name: string, enabled: boolean) {
|
||||
this.classes.push({ name, enabled });
|
||||
},
|
||||
};
|
||||
constructor(_containerEl: HTMLElement) {
|
||||
liveSyncSettingHarness.settingElements.push(this.settingEl);
|
||||
}
|
||||
setDesc(_description: string | DocumentFragment) {
|
||||
return this;
|
||||
}
|
||||
setName(_name: string | DocumentFragment) {
|
||||
return this;
|
||||
}
|
||||
addText(callback: (component: TextComponent) => void) {
|
||||
callback(new TextComponent());
|
||||
return this;
|
||||
}
|
||||
addTextArea(callback: (component: TextAreaComponent) => void) {
|
||||
callback(new TextAreaComponent());
|
||||
return this;
|
||||
}
|
||||
setTooltip(_description: string) {
|
||||
return this;
|
||||
}
|
||||
setDisabled(_disabled: boolean) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
return { Setting, TextComponent, TextAreaComponent, ButtonComponent: class {} };
|
||||
});
|
||||
vi.mock("./ObsidianLiveSyncSettingTab.ts", () => ({}));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/types", () => ({
|
||||
LEVEL_ADVANCED: 1,
|
||||
LEVEL_POWER_USER: 2,
|
||||
statusDisplay: () => "",
|
||||
}));
|
||||
vi.mock("./settingConstants.ts", () => ({
|
||||
getConfig: (key: string) => ({
|
||||
name: key,
|
||||
desc: `description for ${key}`,
|
||||
placeHolder: `configured placeholder for ${key}`,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./settingComponentStyles.ts", () => ({ setButtonDestructiveState: vi.fn() }));
|
||||
vi.mock("octagonal-wheels/collection", () => ({ unique: (items: unknown[]) => [...new Set(items)] }));
|
||||
|
||||
import { LiveSyncSetting } from "./LiveSyncSetting.ts";
|
||||
|
||||
type WireKind = "text" | "textarea";
|
||||
type StringComponentHarness = {
|
||||
values: string[];
|
||||
changeHandler?: (value: string) => Promise<void>;
|
||||
placeholder?: string;
|
||||
inputEl: { attributes: Map<string, string> };
|
||||
};
|
||||
type SettingElementHarness = { classes: { name: string; enabled: boolean }[] };
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function wireStringSetting(
|
||||
kind: WireKind,
|
||||
options: {
|
||||
holdValue?: boolean;
|
||||
isPassword?: boolean;
|
||||
placeHolder?: string;
|
||||
onUpdate?: () => { visibility?: boolean };
|
||||
} = {}
|
||||
) {
|
||||
const key = kind === "text" ? "syncOnlyRegEx" : "ignoreFiles";
|
||||
const events: string[] = [];
|
||||
const editingSettings: Record<string, unknown> = { [key]: 17 };
|
||||
const saveSettings = vi.fn(async (_keys: string[]) => {});
|
||||
const requestUpdate = vi.fn(() => events.push("update"));
|
||||
const env = {
|
||||
settingComponents: [] as unknown[],
|
||||
editingSettings,
|
||||
saveSettings,
|
||||
requestUpdate,
|
||||
};
|
||||
LiveSyncSetting.env = env as never;
|
||||
|
||||
const setting = new LiveSyncSetting({} as HTMLElement);
|
||||
const onUpdate = vi.fn(options.onUpdate ?? (() => ({ visibility: false })));
|
||||
const wireOptions = { ...options, onUpdate };
|
||||
if (kind === "text") {
|
||||
setting.autoWireText(key as never, wireOptions);
|
||||
} else {
|
||||
setting.autoWireTextArea(key as never, wireOptions);
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
events,
|
||||
editingSettings,
|
||||
saveSettings,
|
||||
requestUpdate,
|
||||
setting,
|
||||
component: setting.autoWiredComponent as unknown as StringComponentHarness,
|
||||
settingElement: setting.settingEl as unknown as SettingElementHarness,
|
||||
onUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
describe("LiveSyncSetting string controls", () => {
|
||||
it.each(["text", "textarea"] as const)("wires %s through the shared string commit contract", async (kind) => {
|
||||
const {
|
||||
key,
|
||||
events,
|
||||
editingSettings,
|
||||
saveSettings,
|
||||
requestUpdate,
|
||||
setting,
|
||||
component,
|
||||
settingElement,
|
||||
onUpdate,
|
||||
} = wireStringSetting(kind, { isPassword: true, placeHolder: "option placeholder" });
|
||||
|
||||
expect(component.values).toEqual(["17"]);
|
||||
setting.invalidateValue?.();
|
||||
expect(component.values).toEqual(["17"]);
|
||||
editingSettings[key] = 18;
|
||||
setting.invalidateValue?.();
|
||||
expect(component.values).toEqual(["17", "18"]);
|
||||
expect(component.inputEl.attributes.get("type")).toBe("password");
|
||||
expect(setting.hasPassword).toBe(true);
|
||||
expect(setting.updateHandlers.size).toBe(1);
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(setting._getComputedStatus()).toEqual({ visibility: false });
|
||||
expect(onUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(settingElement.classes).toContainEqual({ name: "sls-setting-hidden", enabled: true });
|
||||
if (kind === "text") {
|
||||
expect(component.placeholder).toBe("configured placeholder for syncOnlyRegEx");
|
||||
} else {
|
||||
expect(component.placeholder).toBeUndefined();
|
||||
}
|
||||
|
||||
const pendingSave = deferred();
|
||||
saveSettings.mockImplementationOnce(async (keys) => {
|
||||
events.push(`save:${keys.join(",")}`);
|
||||
await pendingSave.promise;
|
||||
events.push("saved");
|
||||
});
|
||||
const commit = component.changeHandler!("changed");
|
||||
expect(editingSettings[key]).toBe("changed");
|
||||
expect(saveSettings).toHaveBeenCalledWith([key]);
|
||||
expect(events).toEqual([`save:${key}`]);
|
||||
expect(requestUpdate).not.toHaveBeenCalled();
|
||||
pendingSave.resolve();
|
||||
await commit;
|
||||
expect(events).toEqual([`save:${key}`, "saved", "update"]);
|
||||
|
||||
events.length = 0;
|
||||
await component.changeHandler!("changed");
|
||||
expect(saveSettings).toHaveBeenCalledTimes(1);
|
||||
expect(events).toEqual(["update"]);
|
||||
|
||||
events.length = 0;
|
||||
const failure = new Error("setting save failed");
|
||||
saveSettings.mockImplementationOnce(async (keys) => {
|
||||
events.push(`save:${keys.join(",")}`);
|
||||
throw failure;
|
||||
});
|
||||
await expect(component.changeHandler!("failed")).rejects.toBe(failure);
|
||||
expect(editingSettings[key]).toBe("failed");
|
||||
expect(events).toEqual([`save:${key}`]);
|
||||
expect(requestUpdate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["text", "textarea"] as const)("keeps %s held values in the editing copy without saving", async (kind) => {
|
||||
const { key, editingSettings, saveSettings, requestUpdate, component, events } = wireStringSetting(kind, {
|
||||
holdValue: true,
|
||||
});
|
||||
|
||||
await component.changeHandler!("held value");
|
||||
|
||||
expect(editingSettings[key]).toBe("held value");
|
||||
expect(saveSettings).not.toHaveBeenCalled();
|
||||
expect(requestUpdate).toHaveBeenCalledOnce();
|
||||
expect(events).toEqual(["update"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { LEVEL_ADVANCED, type CustomRegExpSource } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
LEVEL_ADVANCED,
|
||||
type CustomRegExpSource,
|
||||
type CustomRegExpSourceList,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { constructCustomRegExpList, splitCustomRegExpList } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import MultipleRegExpControl from "./MultipleRegExpControl.svelte";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -7,6 +11,35 @@ import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
type RegExpControlOwner = Pick<
|
||||
ObsidianLiveSyncSettingTab,
|
||||
"saveAllDirtySettings" | "requestPageRefresh" | "lifetimeComponent"
|
||||
>;
|
||||
|
||||
function mountMultipleRegExpControl<D extends "|[]|" | ",">(
|
||||
owner: RegExpControlOwner,
|
||||
target: HTMLElement,
|
||||
source: CustomRegExpSourceList<D>,
|
||||
delimiter: D,
|
||||
assign: (serializedPatterns: CustomRegExpSourceList<D>) => void
|
||||
): void {
|
||||
const patterns = splitCustomRegExpList(source, delimiter);
|
||||
const control = mount(MultipleRegExpControl, {
|
||||
target,
|
||||
props: {
|
||||
patterns,
|
||||
originals: [...patterns],
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
assign(constructCustomRegExpList(newPatterns, delimiter));
|
||||
await owner.saveAllDirtySettings();
|
||||
owner.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
owner.lifetimeComponent.register(() => void unmount(control));
|
||||
}
|
||||
|
||||
export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
void addPanel(paneEl, $msg("Normal Files")).then((paneEl) => {
|
||||
const syncFilesSetting = new Setting(paneEl)
|
||||
@@ -16,37 +49,29 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns."
|
||||
)
|
||||
);
|
||||
const syncFilesControl = mount(MultipleRegExpControl, {
|
||||
target: syncFilesSetting.controlEl,
|
||||
props: {
|
||||
patterns: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"),
|
||||
originals: splitCustomRegExpList(this.editingSettings.syncOnlyRegEx, "|[]|"),
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncOnlyRegEx = constructCustomRegExpList(newPatterns, "|[]|");
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(syncFilesControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
syncFilesSetting.controlEl,
|
||||
this.editingSettings.syncOnlyRegEx,
|
||||
"|[]|",
|
||||
(value) => {
|
||||
this.editingSettings.syncOnlyRegEx = value;
|
||||
}
|
||||
);
|
||||
|
||||
const nonSyncFilesSetting = new Setting(paneEl)
|
||||
.setName($msg("Non-Synchronising files"))
|
||||
.setDesc($msg("(RegExp) Skip changes to matching normal and hidden files, locally and remotely."));
|
||||
|
||||
const nonSyncFilesControl = mount(MultipleRegExpControl, {
|
||||
target: nonSyncFilesSetting.controlEl,
|
||||
props: {
|
||||
patterns: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"),
|
||||
originals: splitCustomRegExpList(this.editingSettings.syncIgnoreRegEx, "|[]|"),
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncIgnoreRegEx = constructCustomRegExpList(newPatterns, "|[]|");
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(nonSyncFilesControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
nonSyncFilesSetting.controlEl,
|
||||
this.editingSettings.syncIgnoreRegEx,
|
||||
"|[]|",
|
||||
(value) => {
|
||||
this.editingSettings.syncIgnoreRegEx = value;
|
||||
}
|
||||
);
|
||||
new Setting(paneEl).autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 });
|
||||
|
||||
new Setting(paneEl).autoWireToggle("useIgnoreFiles");
|
||||
@@ -58,41 +83,30 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
const targetPatternSetting = new Setting(paneEl)
|
||||
.setName($msg("Target patterns"))
|
||||
.setDesc($msg("Patterns to match files for syncing"));
|
||||
const patTarget = splitCustomRegExpList(this.editingSettings.syncInternalFilesTargetPatterns, ",");
|
||||
const targetPatternControl = mount(MultipleRegExpControl, {
|
||||
target: targetPatternSetting.controlEl,
|
||||
props: {
|
||||
patterns: patTarget,
|
||||
originals: [...patTarget],
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncInternalFilesTargetPatterns = constructCustomRegExpList(newPatterns, ",");
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(targetPatternControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
targetPatternSetting.controlEl,
|
||||
this.editingSettings.syncInternalFilesTargetPatterns,
|
||||
",",
|
||||
(value) => {
|
||||
this.editingSettings.syncInternalFilesTargetPatterns = value;
|
||||
}
|
||||
);
|
||||
|
||||
const defaultSkipPattern = "\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/";
|
||||
const defaultSkipPatternXPlat =
|
||||
defaultSkipPattern + ",\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$";
|
||||
|
||||
const pat = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
|
||||
const patSetting = new Setting(paneEl).setName($msg("Ignore patterns")).setDesc("");
|
||||
|
||||
const ignorePatternControl = mount(MultipleRegExpControl, {
|
||||
target: patSetting.controlEl,
|
||||
props: {
|
||||
patterns: pat,
|
||||
originals: [...pat],
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncInternalFilesIgnorePatterns = constructCustomRegExpList(newPatterns, ",");
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(ignorePatternControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
patSetting.controlEl,
|
||||
this.editingSettings.syncInternalFilesIgnorePatterns,
|
||||
",",
|
||||
(value) => {
|
||||
this.editingSettings.syncInternalFilesIgnorePatterns = value;
|
||||
}
|
||||
);
|
||||
|
||||
const addDefaultPatterns = async (patterns: string) => {
|
||||
const oldList = splitCustomRegExpList(this.editingSettings.syncInternalFilesIgnorePatterns, ",");
|
||||
@@ -122,22 +136,14 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
const overwritePatterns = new Setting(paneEl)
|
||||
.setName($msg("Overwrite patterns"))
|
||||
.setDesc($msg("Patterns to match files for overwriting instead of merging"));
|
||||
const patTarget2 = splitCustomRegExpList(this.editingSettings.syncInternalFileOverwritePatterns, ",");
|
||||
const overwritePatternControl = mount(MultipleRegExpControl, {
|
||||
target: overwritePatterns.controlEl,
|
||||
props: {
|
||||
patterns: patTarget2,
|
||||
originals: [...patTarget2],
|
||||
apply: async (newPatterns: CustomRegExpSource[]) => {
|
||||
this.editingSettings.syncInternalFileOverwritePatterns = constructCustomRegExpList(
|
||||
newPatterns,
|
||||
","
|
||||
);
|
||||
await this.saveAllDirtySettings();
|
||||
this.requestPageRefresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
this.lifetimeComponent.register(() => void unmount(overwritePatternControl));
|
||||
mountMultipleRegExpControl(
|
||||
this,
|
||||
overwritePatterns.controlEl,
|
||||
this.editingSettings.syncInternalFileOverwritePatterns,
|
||||
",",
|
||||
(value) => {
|
||||
this.editingSettings.syncInternalFileOverwritePatterns = value;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const paneSelectorHarness = vi.hoisted(() => ({
|
||||
mounts: [] as { component: unknown; target: unknown; props: Record<string, unknown>; handle: unknown }[],
|
||||
unmounted: [] as unknown[],
|
||||
settings: [] as { containerEl: HTMLElement; controlEl: unknown }[],
|
||||
buttons: [] as { text: string; handler?: () => void | Promise<void> }[],
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({ $msg: (value: string) => value }));
|
||||
vi.mock("./MultipleRegExpControl.svelte", () => ({ default: { name: "MultipleRegExpControl" } }));
|
||||
vi.mock("svelte", () => ({
|
||||
mount: vi.fn((component: unknown, options: { target: unknown; props: Record<string, unknown> }) => {
|
||||
const handle = { id: paneSelectorHarness.mounts.length };
|
||||
paneSelectorHarness.mounts.push({ component, target: options.target, props: options.props, handle });
|
||||
return handle;
|
||||
}),
|
||||
unmount: vi.fn((component: unknown) => {
|
||||
paneSelectorHarness.unmounted.push(component);
|
||||
}),
|
||||
}));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
controlEl: { id: number };
|
||||
constructor(containerEl: HTMLElement) {
|
||||
this.controlEl = { id: paneSelectorHarness.settings.length };
|
||||
paneSelectorHarness.settings.push({ containerEl, controlEl: this.controlEl });
|
||||
}
|
||||
setName(_name: string) {
|
||||
return this;
|
||||
}
|
||||
setDesc(_description: string) {
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(_key: string, _options: unknown) {
|
||||
return this;
|
||||
}
|
||||
autoWireToggle(_key: string) {
|
||||
return this;
|
||||
}
|
||||
autoWireTextArea(_key: string, _options: unknown) {
|
||||
return this;
|
||||
}
|
||||
addButton(
|
||||
callback: (button: {
|
||||
setButtonText: (text: string) => unknown;
|
||||
onClick: (handler: () => void | Promise<void>) => unknown;
|
||||
}) => void
|
||||
) {
|
||||
const button = {
|
||||
text: "",
|
||||
handler: undefined as (() => void | Promise<void>) | undefined,
|
||||
setButtonText(text: string) {
|
||||
this.text = text;
|
||||
return this;
|
||||
},
|
||||
onClick(handler: () => void | Promise<void>) {
|
||||
this.handler = handler;
|
||||
paneSelectorHarness.buttons.push(this);
|
||||
return this;
|
||||
},
|
||||
};
|
||||
callback(button);
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { paneSelector } from "./PaneSelector.ts";
|
||||
|
||||
type PatternControlProps = {
|
||||
patterns: string[];
|
||||
originals: string[];
|
||||
apply: (patterns: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function renderSelector() {
|
||||
const normalPanel = { id: "normal" } as unknown as HTMLElement;
|
||||
const hiddenPanel = { id: "hidden" } as unknown as HTMLElement;
|
||||
const addPanel = vi.fn((_parent: HTMLElement, title: string) =>
|
||||
Promise.resolve(title === "Normal Files" ? normalPanel : hiddenPanel)
|
||||
);
|
||||
const disposers: (() => void)[] = [];
|
||||
const tab = {
|
||||
editingSettings: {
|
||||
syncOnlyRegEx: "alpha|[]|beta",
|
||||
syncIgnoreRegEx: "skip|[]|also-skip",
|
||||
syncInternalFilesTargetPatterns: "target-a,target-b",
|
||||
syncInternalFilesIgnorePatterns: "existing,\\/node_modules\\/",
|
||||
syncInternalFileOverwritePatterns: "overwrite-a,overwrite-b",
|
||||
},
|
||||
saveAllDirtySettings: vi.fn(async () => {}),
|
||||
requestPageRefresh: vi.fn(),
|
||||
lifetimeComponent: { register: (dispose: () => void) => disposers.push(dispose) },
|
||||
};
|
||||
|
||||
paneSelector.call(tab as never, {} as HTMLElement, { addPanel } as never);
|
||||
await vi.waitFor(() => expect(paneSelectorHarness.mounts).toHaveLength(5));
|
||||
return { tab, addPanel, disposers };
|
||||
}
|
||||
|
||||
function controlProps(index: number): PatternControlProps {
|
||||
return paneSelectorHarness.mounts[index]!.props as unknown as PatternControlProps;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
paneSelectorHarness.mounts.length = 0;
|
||||
paneSelectorHarness.unmounted.length = 0;
|
||||
paneSelectorHarness.settings.length = 0;
|
||||
paneSelectorHarness.buttons.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("paneSelector multiple-pattern controls", () => {
|
||||
it("keeps all five bindings, independent originals, delimiter choices, save ordering, and owned cleanup", async () => {
|
||||
const { tab, addPanel, disposers } = await renderSelector();
|
||||
expect(addPanel.mock.calls.map(([, title]) => title)).toEqual(["Normal Files", "Hidden Files"]);
|
||||
expect(paneSelectorHarness.mounts.map(({ props }) => (props as PatternControlProps).patterns)).toEqual([
|
||||
["alpha", "beta"],
|
||||
["skip", "also-skip"],
|
||||
["target-a", "target-b"],
|
||||
["existing", "\\/node_modules\\/"],
|
||||
["overwrite-a", "overwrite-b"],
|
||||
]);
|
||||
|
||||
for (let index = 0; index < paneSelectorHarness.mounts.length; index++) {
|
||||
const { patterns, originals } = controlProps(index);
|
||||
expect(originals).toEqual(patterns);
|
||||
expect(originals).not.toBe(patterns);
|
||||
patterns.push("edited-current-value");
|
||||
expect(originals).not.toContain("edited-current-value");
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
index: 0,
|
||||
key: "syncOnlyRegEx",
|
||||
patterns: ["new-normal-a", "new-normal-b"],
|
||||
value: "new-normal-a|[]|new-normal-b",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
key: "syncIgnoreRegEx",
|
||||
patterns: ["new-skip-a", "new-skip-b"],
|
||||
value: "new-skip-a|[]|new-skip-b",
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
key: "syncInternalFilesTargetPatterns",
|
||||
patterns: ["new-target-a", "new-target-b"],
|
||||
value: "new-target-a,new-target-b",
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
key: "syncInternalFilesIgnorePatterns",
|
||||
patterns: ["new-ignore-a", "new-ignore-b"],
|
||||
value: "new-ignore-a,new-ignore-b",
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
key: "syncInternalFileOverwritePatterns",
|
||||
patterns: ["new-overwrite-a", "new-overwrite-b"],
|
||||
value: "new-overwrite-a,new-overwrite-b",
|
||||
},
|
||||
] as const;
|
||||
const events: string[] = [];
|
||||
for (let index = 0; index < cases.length; index++) {
|
||||
const item = cases[index]!;
|
||||
const pendingSave = deferred();
|
||||
events.length = 0;
|
||||
tab.saveAllDirtySettings.mockImplementationOnce(async () => {
|
||||
events.push(`save:${item.key}`);
|
||||
await pendingSave.promise;
|
||||
events.push("saved");
|
||||
});
|
||||
tab.requestPageRefresh.mockImplementationOnce(() => {
|
||||
events.push("refresh");
|
||||
});
|
||||
|
||||
const applying = controlProps(item.index).apply([...item.patterns]);
|
||||
expect(tab.editingSettings[item.key as keyof typeof tab.editingSettings]).toBe(item.value);
|
||||
expect(tab.saveAllDirtySettings).toHaveBeenCalledTimes(index + 1);
|
||||
expect(events).toEqual([`save:${item.key}`]);
|
||||
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(index);
|
||||
|
||||
pendingSave.resolve();
|
||||
await applying;
|
||||
expect(events).toEqual([`save:${item.key}`, "saved", "refresh"]);
|
||||
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(index + 1);
|
||||
}
|
||||
|
||||
const failure = new Error("settings save failed");
|
||||
tab.saveAllDirtySettings.mockRejectedValueOnce(failure);
|
||||
await expect(controlProps(2).apply(["assigned-before-failure"])).rejects.toBe(failure);
|
||||
expect(tab.editingSettings.syncInternalFilesTargetPatterns).toBe("assigned-before-failure");
|
||||
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(cases.length);
|
||||
|
||||
expect(disposers).toHaveLength(5);
|
||||
disposers.forEach((dispose) => dispose());
|
||||
expect(paneSelectorHarness.unmounted).toEqual(paneSelectorHarness.mounts.map(({ handle }) => handle));
|
||||
});
|
||||
|
||||
it("merges default hidden-file ignores with existing patterns and removes duplicates", async () => {
|
||||
const { tab } = await renderSelector();
|
||||
const defaultButton = paneSelectorHarness.buttons.find(({ text }) => text === "Default");
|
||||
const crossPlatformButton = paneSelectorHarness.buttons.find(({ text }) => text === "Cross-platform");
|
||||
expect(defaultButton?.handler).toBeDefined();
|
||||
expect(crossPlatformButton?.handler).toBeDefined();
|
||||
|
||||
await defaultButton!.handler!();
|
||||
expect(tab.editingSettings.syncInternalFilesIgnorePatterns).toBe(
|
||||
"existing,\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/"
|
||||
);
|
||||
await crossPlatformButton!.handler!();
|
||||
expect(tab.editingSettings.syncInternalFilesIgnorePatterns).toBe(
|
||||
"existing,\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/,\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$"
|
||||
);
|
||||
expect(tab.saveAllDirtySettings).toHaveBeenCalledTimes(2);
|
||||
expect(tab.requestPageRefresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -97,16 +97,6 @@ export function findAttrFromParent(el: HTMLElement, attr: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function wrapMemo<T>(func: (arg: T) => void) {
|
||||
let buf: T | undefined = undefined;
|
||||
return (arg: T) => {
|
||||
if (buf !== arg) {
|
||||
func(arg);
|
||||
buf = arg;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Defers pane construction until the owning settings page can confirm that its
|
||||
* render scope is still active.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues";
|
||||
|
||||
export const checkConfig = async (
|
||||
checkResultDiv: HTMLDivElement | undefined,
|
||||
@@ -89,19 +90,6 @@ export const checkConfig = async (
|
||||
addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
|
||||
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
|
||||
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
|
||||
const versionParts = `${versionStr}.0.0.0`.split(".");
|
||||
// Compare version string with the target version.
|
||||
// version must be a string like "3.2.1" or "3.10.2", and must be two or three parts.
|
||||
function isGreaterThanOrEqual(version: string) {
|
||||
const targetParts = version.split(".");
|
||||
for (let i = 0; i < targetParts.length; i++) {
|
||||
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
|
||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
||||
if (result > 0) return true;
|
||||
if (result < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Admin check
|
||||
// for database creation and deletion
|
||||
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
|
||||
@@ -109,7 +97,7 @@ export const checkConfig = async (
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||
}
|
||||
if (isGreaterThanOrEqual("3.2.0")) {
|
||||
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||
// HTTP user-authorization check
|
||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||
isSuccessful = false;
|
||||
@@ -148,7 +136,7 @@ export const checkConfig = async (
|
||||
} else {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||
}
|
||||
if (isGreaterThanOrEqual("3.2.0")) {
|
||||
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||
isSuccessful = false;
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"));
|
||||
@@ -240,12 +228,7 @@ export const checkConfig = async (
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseHeaders = Object.fromEntries(
|
||||
Object.entries(rr.headers).map((e) => {
|
||||
e[0] = `${e[0]}`.toLowerCase();
|
||||
return e;
|
||||
})
|
||||
);
|
||||
const responseHeaders = normaliseResponseHeaderKeys(rr.headers);
|
||||
addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||
addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues";
|
||||
|
||||
export type ResultMessage = { message: string; classes: string[] };
|
||||
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
|
||||
@@ -26,24 +27,6 @@ export type ConfigCheckResult<T = unknown, U = unknown> =
|
||||
| ResultError<U>
|
||||
| ResultMessage
|
||||
| ResultErrorMessage;
|
||||
/**
|
||||
* Compares two version strings to determine if the baseVersion is greater than or equal to the version.
|
||||
* @param baseVersion a.b.c format
|
||||
* @param version a.b.c format
|
||||
* @returns true if baseVersion is greater than or equal to version, false otherwise
|
||||
*/
|
||||
function isGreaterThanOrEqual(baseVersion: string, version: string) {
|
||||
const versionParts = `${baseVersion}.0.0.0`.split(".");
|
||||
const targetParts = version.split(".");
|
||||
for (let i = 0; i < targetParts.length; i++) {
|
||||
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
|
||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
||||
if (result > 0) return true;
|
||||
if (result < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the remote CouchDB setting with the given key and value.
|
||||
* @param setting Connection settings
|
||||
@@ -153,7 +136,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||
}
|
||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
||||
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||
// HTTP user-authorization check
|
||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||
addError(
|
||||
@@ -189,7 +172,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
} else {
|
||||
addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||
}
|
||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
||||
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||
addError(
|
||||
$msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"),
|
||||
@@ -287,12 +270,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseHeaders = Object.fromEntries(
|
||||
Object.entries(rr.headers).map((e) => {
|
||||
e[0] = `${e[0]}`.toLowerCase();
|
||||
return e;
|
||||
})
|
||||
);
|
||||
const responseHeaders = normaliseResponseHeaderKeys(rr.headers);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { createDeferredAction, type TimeoutScheduler } from "octagonal-wheels/concurrency/deferredAction";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
createChunkFetchCounterLabel,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
|
||||
vi.mock("octagonal-wheels/concurrency/deferredAction", { spy: true });
|
||||
|
||||
function createdControllers() {
|
||||
return vi.mocked(createDeferredAction).mock.results.map((result) => {
|
||||
if (result.type !== "return") throw new Error("Expected a real timeout controller");
|
||||
return result.value;
|
||||
});
|
||||
}
|
||||
|
||||
function createScheduler() {
|
||||
const entries: { callback: () => void; delayMs: number; cancel: ReturnType<typeof vi.fn> }[] = [];
|
||||
const schedule = vi.fn<TimeoutScheduler>((callback, delayMs) => {
|
||||
const cancel = vi.fn();
|
||||
entries.push({ callback, delayMs, cancel });
|
||||
return cancel;
|
||||
});
|
||||
return { schedule, entries };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-24T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("status timeout composition", () => {
|
||||
it("uses the real shared controller for minimum visibility, resumption, and disposal", () => {
|
||||
const scheduler = createScheduler();
|
||||
const source = reactiveSource(0);
|
||||
const display = createMinimumVisibleActivityCount(source, 150, scheduler.schedule);
|
||||
expect(createDeferredAction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), scheduler.schedule);
|
||||
const controller = createdControllers()[0];
|
||||
const schedule = vi.spyOn(controller, "schedule");
|
||||
const cancel = vi.spyOn(controller, "cancel");
|
||||
const dispose = vi.spyOn(controller, "dispose");
|
||||
|
||||
source.value = 1;
|
||||
vi.advanceTimersByTime(50);
|
||||
source.value = 0;
|
||||
expect(schedule).toHaveBeenCalledExactlyOnceWith(100);
|
||||
expect(scheduler.entries[0].delayMs).toBe(100);
|
||||
source.value = 2;
|
||||
expect(cancel).toHaveBeenCalledTimes(3);
|
||||
expect(scheduler.entries[0].cancel).toHaveBeenCalledTimes(1);
|
||||
scheduler.entries[0].callback();
|
||||
expect(display.value).toBe(2);
|
||||
|
||||
source.value = 0;
|
||||
scheduler.entries[1].callback();
|
||||
expect(display.value).toBe(0);
|
||||
source.value = 3;
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
display.dispose();
|
||||
scheduler.entries[2].callback();
|
||||
expect(display.value).toBe(3);
|
||||
expect(scheduler.entries[2].cancel).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses the shared controller for the counter linger and cancels through it when activity resumes", () => {
|
||||
const scheduler = createScheduler();
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "A", 25, scheduler.schedule);
|
||||
expect(createDeferredAction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), scheduler.schedule);
|
||||
const controller = createdControllers()[0];
|
||||
const schedule = vi.spyOn(controller, "schedule");
|
||||
const cancel = vi.spyOn(controller, "cancel");
|
||||
const dispose = vi.spyOn(controller, "dispose");
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
expect(schedule).toHaveBeenCalledExactlyOnceWith(25);
|
||||
source.value = 2;
|
||||
expect(cancel).toHaveBeenCalledTimes(3);
|
||||
expect(scheduler.entries[0].cancel).toHaveBeenCalledTimes(1);
|
||||
scheduler.entries[0].callback();
|
||||
expect(display.value).toBe(" A\u20072");
|
||||
expect(display.count.value).toBe(2);
|
||||
|
||||
source.value = 0;
|
||||
scheduler.entries[1].callback();
|
||||
expect(display.value).toBe("");
|
||||
expect(display.count.value).toBeUndefined();
|
||||
source.value = 3;
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
scheduler.entries[2].callback();
|
||||
expect(display.value).toBe(" A\u20070");
|
||||
expect(display.count.value).toBe(0);
|
||||
expect(scheduler.entries[2].cancel).toHaveBeenCalledTimes(1);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the scheduler to independent chunk-count controllers and disposes both children", () => {
|
||||
const scheduler = createScheduler();
|
||||
const source = reactiveSource({ initial: 2, retrying: 3 });
|
||||
const display = createChunkFetchCounterLabel(source, scheduler.schedule);
|
||||
expect(display.initialCount.value).toBe(2);
|
||||
expect(display.retryingCount.value).toBe(3);
|
||||
expect(createDeferredAction).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(createDeferredAction).mock.calls.map((args) => args[1])).toEqual([
|
||||
scheduler.schedule,
|
||||
scheduler.schedule,
|
||||
]);
|
||||
const controllers = createdControllers();
|
||||
const disposers = controllers.map((controller) => vi.spyOn(controller, "dispose"));
|
||||
source.value = { initial: 0, retrying: 0 };
|
||||
expect(scheduler.entries.map((entry) => entry.delayMs)).toEqual([
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
]);
|
||||
scheduler.entries[0].callback();
|
||||
expect(display.value.replace(/\u2007/g, "")).toBe(" 🔁0");
|
||||
expect(display.initialCount.value).toBeUndefined();
|
||||
expect(display.retryingCount.value).toBe(0);
|
||||
display.dispose();
|
||||
display.dispose();
|
||||
expect(scheduler.entries[0].cancel).not.toHaveBeenCalled();
|
||||
expect(scheduler.entries[1].cancel).toHaveBeenCalledTimes(1);
|
||||
for (const dispose of disposers) expect(dispose).toHaveBeenCalledTimes(1);
|
||||
scheduler.entries[1].callback();
|
||||
expect(display.value.replace(/\u2007/g, "")).toBe(" 🔁0");
|
||||
expect(display.initialCount.value).toBeUndefined();
|
||||
expect(display.retryingCount.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import { createDeferredAction, type TimeoutScheduler } from "octagonal-wheels/concurrency/deferredAction";
|
||||
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
const scheduleStatusTimeout: TimeoutScheduler = (callback, delayMs) => {
|
||||
const handle = compatGlobal.setTimeout(callback, delayMs);
|
||||
return () => compatGlobal.clearTimeout(handle);
|
||||
};
|
||||
|
||||
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
|
||||
|
||||
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
|
||||
@@ -37,32 +43,32 @@ function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => v
|
||||
/**
|
||||
* Mirrors an activity count while keeping each visible period on screen for a
|
||||
* minimum total lifetime. The delay applies only when the source becomes zero.
|
||||
*
|
||||
* @param source - The activity count to observe.
|
||||
* @param minimumVisibleMs - The minimum visible lifetime, clamped to zero.
|
||||
* @param scheduleTimeout - The scheduler for this display. Defaults to the
|
||||
* existing compatible host, preserving its timer method receivers.
|
||||
*/
|
||||
export function createMinimumVisibleActivityCount(
|
||||
source: ReactiveValue<number>,
|
||||
minimumVisibleMs: number
|
||||
minimumVisibleMs: number,
|
||||
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||
): DisposableReactiveValue<number> {
|
||||
const minimumLifetime = Math.max(0, minimumVisibleMs);
|
||||
const displayed = reactiveSource(Math.max(0, source.value));
|
||||
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
|
||||
let hideTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelHide = () => {
|
||||
if (hideTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(hideTimer);
|
||||
hideTimer = undefined;
|
||||
};
|
||||
const hideIfIdle = () => {
|
||||
hideTimer = undefined;
|
||||
if (disposed || Math.max(0, source.value) > 0) return;
|
||||
displayed.value = 0;
|
||||
visibleSince = undefined;
|
||||
};
|
||||
const hideTimeout = createDeferredAction(hideIfIdle, scheduleTimeout);
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const nextCount = Math.max(0, source.value);
|
||||
cancelHide();
|
||||
hideTimeout.cancel();
|
||||
if (nextCount > 0) {
|
||||
if (displayed.value === 0) {
|
||||
visibleSince = Date.now();
|
||||
@@ -80,7 +86,7 @@ export function createMinimumVisibleActivityCount(
|
||||
if (remaining === 0) {
|
||||
hideIfIdle();
|
||||
} else {
|
||||
hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining);
|
||||
hideTimeout.schedule(remaining);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,7 +94,7 @@ export function createMinimumVisibleActivityCount(
|
||||
return asDisposableReactiveValue(displayed, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelHide();
|
||||
hideTimeout.dispose();
|
||||
source.offChanged(update);
|
||||
});
|
||||
}
|
||||
@@ -96,24 +102,31 @@ export function createMinimumVisibleActivityCount(
|
||||
/**
|
||||
* Formats a counter with a stable width and briefly retains its zero value so
|
||||
* that the completion of queued work remains visible.
|
||||
*
|
||||
* @param source - The counter to observe.
|
||||
* @param mark - The label prefix before the padded count.
|
||||
* @param inactiveLingerMs - The delay before clearing a zero count, clamped to zero.
|
||||
* @param scheduleTimeout - The scheduler for this display. Defaults to the
|
||||
* existing compatible host, preserving its timer method receivers.
|
||||
*/
|
||||
export function createPaddedCounterLabel(
|
||||
source: ReactiveValue<number>,
|
||||
mark: string,
|
||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
|
||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||
): PaddedCounterLabel {
|
||||
const linger = Math.max(0, inactiveLingerMs);
|
||||
const formatted = reactiveSource("");
|
||||
const displayedCount = reactiveSource<number | undefined>(undefined);
|
||||
let maximumLength = 1;
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(clearTimer);
|
||||
clearTimer = undefined;
|
||||
};
|
||||
const clearLabelTimeout = createDeferredAction(() => {
|
||||
if (disposed) return;
|
||||
formatted.value = "";
|
||||
displayedCount.value = undefined;
|
||||
maximumLength = 1;
|
||||
}, scheduleTimeout);
|
||||
const format = (count: number) => {
|
||||
const requiredLength = `${Math.abs(count)}`.length + 1;
|
||||
maximumLength = Math.max(maximumLength, requiredLength);
|
||||
@@ -121,25 +134,19 @@ export function createPaddedCounterLabel(
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
cancelClear();
|
||||
clearLabelTimeout.cancel();
|
||||
const count = source.value;
|
||||
formatted.value = format(count);
|
||||
displayedCount.value = count;
|
||||
if (count !== 0) return;
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
formatted.value = "";
|
||||
displayedCount.value = undefined;
|
||||
maximumLength = 1;
|
||||
}, linger);
|
||||
clearLabelTimeout.schedule(linger);
|
||||
};
|
||||
|
||||
source.onChanged(update);
|
||||
const display = asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
clearLabelTimeout.dispose();
|
||||
source.offChanged(update);
|
||||
});
|
||||
return {
|
||||
@@ -162,14 +169,28 @@ export function createPaddedCounterLabel(
|
||||
/**
|
||||
* Displays the disjoint initial and retry chunk-fetch counts with the same
|
||||
* padding and inactive linger behaviour as the other status counters.
|
||||
*
|
||||
* @param source - The initial and retrying chunk-fetch counts.
|
||||
* @param scheduleTimeout - The scheduler passed to each independent child display.
|
||||
*/
|
||||
export function createChunkFetchCounterLabel(
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>
|
||||
source: ReactiveValue<{ initial: number; retrying: number }>,
|
||||
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||
): ChunkFetchCounterLabel {
|
||||
const initialCount = reactiveSource(0);
|
||||
const retryingCount = reactiveSource(0);
|
||||
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
|
||||
const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁");
|
||||
const initialLabel = createPaddedCounterLabel(
|
||||
initialCount,
|
||||
"🛄",
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
scheduleTimeout
|
||||
);
|
||||
const retryingLabel = createPaddedCounterLabel(
|
||||
retryingCount,
|
||||
"🔁",
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
scheduleTimeout
|
||||
);
|
||||
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
|
||||
let updatingCounts = false;
|
||||
let disposed = false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -194,3 +195,121 @@ describe("createChunkFetchCounterLabel", () => {
|
||||
expect(withoutPadding()).toBe(" 🔁0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("status timeout ownership", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-24T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("counts the minimum lifetime from construction when activity is already visible", () => {
|
||||
const source = reactiveSource(2);
|
||||
const display = createMinimumVisibleActivityCount(source, 150);
|
||||
vi.advanceTimersByTime(40);
|
||||
source.value = 0;
|
||||
|
||||
vi.advanceTimersByTime(109);
|
||||
expect(display.value).toBe(2);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBe(0);
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("hides immediately for a non-positive minimum lifetime and clamps negative activity", () => {
|
||||
const source = reactiveSource(-2);
|
||||
const display = createMinimumVisibleActivityCount(source, -10);
|
||||
expect(display.value).toBe(0);
|
||||
source.value = 1;
|
||||
source.value = -2;
|
||||
|
||||
expect(display.value).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("keeps a zero-duration counter clear asynchronous", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "A", 0);
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
|
||||
expect(display.value).toBe(" A\u20070");
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(display.value).toBe("");
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it.each(["activity", "label"] as const)(
|
||||
"retains timer receivers, zero handles, and idempotent disposal for %s",
|
||||
(kind) => {
|
||||
const source = reactiveSource(0);
|
||||
const offChanged = vi.spyOn(source, "offChanged");
|
||||
const setTimer = vi.spyOn(compatGlobal as Window, "setTimeout").mockReturnValue(0);
|
||||
const clearTimer = vi.spyOn(compatGlobal as Window, "clearTimeout").mockImplementation(() => {});
|
||||
const display =
|
||||
kind === "activity"
|
||||
? createMinimumVisibleActivityCount(source, 150)
|
||||
: createPaddedCounterLabel(source, "A", 150);
|
||||
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
expect(setTimer).toHaveBeenCalledTimes(1);
|
||||
expect(setTimer.mock.contexts[0]).toBe(compatGlobal);
|
||||
expect(setTimer.mock.calls[0][1]).toBe(150);
|
||||
source.value = 2;
|
||||
expect(clearTimer).toHaveBeenCalledExactlyOnceWith(0);
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
display.dispose();
|
||||
source.value = 3;
|
||||
|
||||
expect(setTimer).toHaveBeenCalledTimes(2);
|
||||
expect(clearTimer).toHaveBeenCalledTimes(2);
|
||||
expect(clearTimer.mock.calls).toEqual([[0], [0]]);
|
||||
expect(clearTimer.mock.contexts).toEqual([compatGlobal, compatGlobal]);
|
||||
expect(offChanged).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps pending clears independent between display instances", () => {
|
||||
const firstSource = reactiveSource(0);
|
||||
const secondSource = reactiveSource(0);
|
||||
const first = createPaddedCounterLabel(firstSource, "A", 100);
|
||||
const second = createPaddedCounterLabel(secondSource, "B", 100);
|
||||
firstSource.value = 1;
|
||||
firstSource.value = 0;
|
||||
secondSource.value = 1;
|
||||
secondSource.value = 0;
|
||||
expect(vi.getTimerCount()).toBe(2);
|
||||
|
||||
first.dispose();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(first.value).toBe(" A\u20070");
|
||||
expect(second.value).toBe("");
|
||||
second.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("disposes both child timers and its source subscription for chunk counts", () => {
|
||||
const source = reactiveSource({ initial: 2, retrying: 3 });
|
||||
const offChanged = vi.spyOn(source, "offChanged");
|
||||
const display = createChunkFetchCounterLabel(source);
|
||||
source.value = { initial: 0, retrying: 0 };
|
||||
const lastLabel = display.value;
|
||||
expect(vi.getTimerCount()).toBe(2);
|
||||
|
||||
display.dispose();
|
||||
display.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||
source.value = { initial: 4, retrying: 5 };
|
||||
expect(display.value).toBe(lastLabel);
|
||||
expect(offChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export function isCouchDBVersionAtLeast(baseVersion: string, targetVersion: string) {
|
||||
const versionParts = `${baseVersion}.0.0.0`.split(".");
|
||||
const targetParts = targetVersion.split(".");
|
||||
for (let i = 0; i < targetParts.length; i++) {
|
||||
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
|
||||
if (result > 0) return true;
|
||||
if (result < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normaliseResponseHeaderKeys<T>(headers: Record<string, T>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map((entry) => {
|
||||
entry[0] = `${entry[0]}`.toLowerCase();
|
||||
return entry;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "./couchDBDiagnosticValues";
|
||||
|
||||
describe("CouchDB diagnostic values", () => {
|
||||
describe("version comparison", () => {
|
||||
it.each([
|
||||
["3.10.2", "3.2.0", true],
|
||||
["3.2.0", "3.10.0", false],
|
||||
["3.2", "3.2.0", true],
|
||||
["3.10.1b", "3.10.1a", true],
|
||||
["3.10.1a", "3.10.1b", false],
|
||||
["3.x", "3.2", true],
|
||||
])("compares %s with %s", (baseVersion, targetVersion, expected) => {
|
||||
expect(isCouchDBVersionAtLeast(baseVersion, targetVersion)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("response header keys", () => {
|
||||
it("lower-cases keys while retaining last collision values and entry order", () => {
|
||||
const headers = { Server: "first", server: "last", Zed: "z", Alpha: "a" };
|
||||
|
||||
const normalised = normaliseResponseHeaderKeys(headers);
|
||||
|
||||
expect(normalised).toEqual({ server: "last", zed: "z", alpha: "a" });
|
||||
expect(Object.keys(normalised)).toEqual(["server", "zed", "alpha"]);
|
||||
expect(headers).toEqual({ Server: "first", server: "last", Zed: "z", Alpha: "a" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ export interface SvelteDialogSessionOptions<TResult, TInitial, TContext extends
|
||||
component: ComponentHasResult<TResult, TInitial>;
|
||||
initialData?: TInitial;
|
||||
renderer?: SvelteDialogRenderer;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const svelteDialogRenderer: SvelteDialogRenderer = {
|
||||
@@ -46,11 +47,7 @@ const svelteDialogRenderer: SvelteDialogRenderer = {
|
||||
};
|
||||
|
||||
/** Owns one Svelte dialogue result and mount lifecycle for a concrete host surface. */
|
||||
export class SvelteDialogSession<
|
||||
TResult,
|
||||
TInitial,
|
||||
TContext extends ServiceContext = ServiceContext,
|
||||
> {
|
||||
export class SvelteDialogSession<TResult, TInitial, TContext extends ServiceContext = ServiceContext> {
|
||||
private readonly surface: SvelteDialogSurface;
|
||||
private readonly context: TContext;
|
||||
private readonly dependencies: SvelteDialogManagerDependencies<TContext>;
|
||||
@@ -58,11 +55,13 @@ export class SvelteDialogSession<
|
||||
private readonly component: ComponentHasResult<TResult, TInitial>;
|
||||
private readonly initialData?: TInitial;
|
||||
private readonly renderer: SvelteDialogRenderer;
|
||||
private readonly signal?: AbortSignal;
|
||||
|
||||
private result?: TResult;
|
||||
private pendingResult?: PromiseWithResolvers<TResult | undefined>;
|
||||
private mountedDialog?: MountedDialog;
|
||||
private unregisterUnload?: () => void;
|
||||
private unregisterAbort?: () => void;
|
||||
|
||||
constructor(options: SvelteDialogSessionOptions<TResult, TInitial, TContext>) {
|
||||
this.surface = options.surface;
|
||||
@@ -72,10 +71,14 @@ export class SvelteDialogSession<
|
||||
this.component = options.component;
|
||||
this.initialData = options.initialData;
|
||||
this.renderer = options.renderer ?? svelteDialogRenderer;
|
||||
this.signal = options.signal;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
if (this.signal?.aborted) throw this.signal.reason ?? new Error("Plug-in unloaded");
|
||||
this.surface.contentEl.replaceChildren();
|
||||
this.unregisterAbort?.();
|
||||
this.unregisterAbort = undefined;
|
||||
this.unregisterUnload?.();
|
||||
this.unregisterUnload = undefined;
|
||||
if (this.pendingResult) {
|
||||
@@ -85,14 +88,23 @@ export class SvelteDialogSession<
|
||||
this.result = undefined;
|
||||
const pendingResult = promiseWithResolvers<TResult | undefined>();
|
||||
this.pendingResult = pendingResult;
|
||||
this.unregisterUnload = this.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
const cancel = (reason: unknown) => {
|
||||
if (this.pendingResult !== pendingResult) {
|
||||
return;
|
||||
}
|
||||
this.pendingResult = undefined;
|
||||
pendingResult.reject(new Error("Plug-in unloaded"));
|
||||
pendingResult.reject(reason);
|
||||
this.surface.close();
|
||||
};
|
||||
this.unregisterUnload = this.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
cancel(new Error("Plug-in unloaded"));
|
||||
});
|
||||
const signal = this.signal;
|
||||
if (signal) {
|
||||
const onAbort = () => cancel(signal.reason ?? new Error("Plug-in unloaded"));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.unregisterAbort = () => signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
this.mountedDialog = this.renderer.mount(this.dialogHost, this.surface.contentEl, {
|
||||
onSetupContext: (props: DialogSvelteComponentBaseProps<TResult, TInitial>) => {
|
||||
@@ -124,6 +136,8 @@ export class SvelteDialogSession<
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.unregisterAbort?.();
|
||||
this.unregisterAbort = undefined;
|
||||
this.unregisterUnload?.();
|
||||
this.unregisterUnload = undefined;
|
||||
this.pendingResult?.resolve(this.result);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCommandCheck } from "@vrtmrz/obsidian-plugin-kit/command";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
@@ -69,14 +70,12 @@ export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
services.API.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.vault.getActiveFilePath(),
|
||||
(file) => {
|
||||
fireAndForget(() => copyFileDatabaseInfo(createFileDatabaseInfoCore(host), file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
@@ -115,13 +114,12 @@ export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
services.API.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.setting.currentSettings().useAdvancedMode,
|
||||
() => {
|
||||
fireAndForget(() => services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
@@ -135,13 +133,12 @@ export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
services.API.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.setting.currentSettings().useAdvancedMode,
|
||||
() => {
|
||||
fireAndForget(() => services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
|
||||
@@ -228,6 +228,49 @@ describe("useBasicCommandsFeature", () => {
|
||||
expect(adapter).not.toBe(fixture.host);
|
||||
});
|
||||
|
||||
it("rechecks availability on execution and captures the current file only once", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
vi.mocked(copyFileDatabaseInfo).mockClear();
|
||||
const dump = fixture.getCommand("livesync-dump");
|
||||
expect(dump.checkCallback?.(true)).toBe(true);
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(undefined);
|
||||
expect(dump.checkCallback?.(false)).toBe(false);
|
||||
expect(copyFileDatabaseInfo).not.toHaveBeenCalled();
|
||||
|
||||
fixture.services.vault.getActiveFilePath
|
||||
.mockReset()
|
||||
.mockReturnValueOnce("selected.md")
|
||||
.mockReturnValue("later.md");
|
||||
expect(dump.checkCallback?.(false)).toBe(true);
|
||||
await vi.waitFor(() => expect(copyFileDatabaseInfo).toHaveBeenCalledOnce());
|
||||
expect(fixture.services.vault.getActiveFilePath).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(copyFileDatabaseInfo).mock.calls[0][1]).toBe("selected.md");
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
for (const id of ["livesync-scan-files", "livesync-abortsync"]) {
|
||||
expect(fixture.getCommand(id).checkCallback?.(true)).toBe(true);
|
||||
}
|
||||
fixture.settings.useAdvancedMode = false;
|
||||
for (const id of ["livesync-scan-files", "livesync-abortsync"]) {
|
||||
expect(fixture.getCommand(id).checkCallback?.(false)).toBe(false);
|
||||
}
|
||||
expect(fixture.services.vault.scanVault).not.toHaveBeenCalled();
|
||||
expect(fixture.services.replication.stopActiveTransfer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates an availability failure without running the command", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
vi.mocked(copyFileDatabaseInfo).mockClear();
|
||||
const failure = new Error("Vault unavailable");
|
||||
fixture.services.vault.getActiveFilePath.mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
expect(() => fixture.getCommand("livesync-dump").checkCallback?.(false)).toThrow(failure);
|
||||
expect(copyFileDatabaseInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits pending file events from the batch command", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import type {
|
||||
NecessaryServices,
|
||||
NecessaryServicesInterfaces,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
@@ -24,6 +27,16 @@ export interface CompatibilityReviewUi {
|
||||
clearReminder(): void;
|
||||
}
|
||||
|
||||
export type CompatibilityReviewDependencies = NecessaryServicesInterfaces<
|
||||
"setting" | "config" | "vault" | "control",
|
||||
never
|
||||
>;
|
||||
|
||||
export type CompatibilityReviewHost = NecessaryServices<
|
||||
"setting" | "config" | "vault" | "control" | "appLifecycle" | "API",
|
||||
never
|
||||
>;
|
||||
|
||||
export class CompatibilityReviewController {
|
||||
private pause: CompatibilityPause | undefined;
|
||||
private activeReview: Promise<void> | undefined;
|
||||
@@ -31,7 +44,7 @@ export class CompatibilityReviewController {
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly core: LiveSyncCore,
|
||||
private readonly core: CompatibilityReviewDependencies,
|
||||
private readonly ui: CompatibilityReviewUi,
|
||||
private readonly currentVersion: number = VER
|
||||
) {}
|
||||
@@ -161,7 +174,10 @@ export class CompatibilityReviewController {
|
||||
}
|
||||
}
|
||||
|
||||
export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityReviewUi): CompatibilityReviewController {
|
||||
export function useCompatibilityReview(
|
||||
core: CompatibilityReviewHost,
|
||||
ui: CompatibilityReviewUi
|
||||
): CompatibilityReviewController {
|
||||
const controller = new CompatibilityReviewController(core, ui);
|
||||
core.services.appLifecycle.onSettingLoaded.addHandler(() => controller.initialise());
|
||||
core.services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import type {
|
||||
NecessaryServices,
|
||||
NecessaryServicesInterfaces,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
@@ -101,6 +105,32 @@ function createFixture(
|
||||
}
|
||||
|
||||
describe("compatibility review controller", () => {
|
||||
it("accepts operation services without retaining an application-core contract", () => {
|
||||
type Dependencies = NecessaryServicesInterfaces<"setting" | "config" | "vault" | "control", never>;
|
||||
type ControllerHost = ConstructorParameters<typeof CompatibilityReviewController>[0];
|
||||
|
||||
expectTypeOf<Dependencies>().toExtend<ControllerHost>();
|
||||
expectTypeOf<keyof ControllerHost>().toEqualTypeOf<"services" | "serviceModules">();
|
||||
expectTypeOf<keyof ControllerHost["services"]>().toEqualTypeOf<"setting" | "config" | "vault" | "control">();
|
||||
expectTypeOf<keyof ControllerHost["serviceModules"]>().toBeNever();
|
||||
});
|
||||
|
||||
it("composes the review from its operation services and lifecycle and command ports", () => {
|
||||
type Dependencies = NecessaryServices<
|
||||
"setting" | "config" | "vault" | "control" | "appLifecycle" | "API",
|
||||
never
|
||||
>;
|
||||
type FeatureHost = Parameters<typeof useCompatibilityReview>[0];
|
||||
|
||||
expectTypeOf<Dependencies>().toExtend<FeatureHost>();
|
||||
expectTypeOf<keyof FeatureHost>().toEqualTypeOf<"services" | "serviceModules">();
|
||||
expectTypeOf<keyof FeatureHost["services"]>().toEqualTypeOf<
|
||||
"context" | "setting" | "config" | "vault" | "control" | "appLifecycle" | "API"
|
||||
>();
|
||||
expectTypeOf<keyof FeatureHost["serviceModules"]>().toBeNever();
|
||||
expectTypeOf<ReturnType<typeof useCompatibilityReview>>().toEqualTypeOf<CompatibilityReviewController>();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
import type { IConflictService, IVaultService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { createConflictCheckQueue } from "@vrtmrz/livesync-commonlib/conflicts";
|
||||
|
||||
export type ConflictCheckingSettings = Pick<ObsidianLiveSyncSettings, "checkConflictOnlyOnOpen">;
|
||||
|
||||
@@ -30,25 +31,21 @@ export interface ConflictCheckingHandlers {
|
||||
|
||||
/** Create conflict-checking handlers while retaining scheduling state privately. */
|
||||
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
|
||||
const conflictQueue = new QueueProcessor<FilePathWithPrefix, void>(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
const filename = filenames[0];
|
||||
return await dependencies.conflict.resolve(filename);
|
||||
const conflictQueue = createConflictCheckQueue({
|
||||
async process(filename) {
|
||||
try {
|
||||
await dependencies.conflict.resolve(filename);
|
||||
} catch (error) {
|
||||
dependencies.log(`Conflict check failed: ${filename}`);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
},
|
||||
// The resolver serialises shared user interactions; independent checks
|
||||
// and automatic merges can use the remaining execution slots.
|
||||
concurrency: 10,
|
||||
onProgress(remaining) {
|
||||
dependencies.conflictProcessQueueCount.value = remaining;
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
// No need to limit concurrency to `1` here, subsequent process will handle it,
|
||||
// and some cases do not need to be synchronised (for example, auto-merge).
|
||||
// Global concurrency is limited by the resolver with the UI.
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
totalRemainingReactiveSource: dependencies.conflictProcessQueueCount,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const newQueue = [...queue].filter((entry) => entry != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
@@ -76,7 +73,7 @@ export function createConflictCheckingHandlers(dependencies: ConflictCheckingDep
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForAllProcessed();
|
||||
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForIdle();
|
||||
|
||||
return {
|
||||
queueCheckForIfOpen,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import {
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type FilePath,
|
||||
@@ -15,6 +16,12 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import {
|
||||
createInstanceLogFunction,
|
||||
MARK_LOG_SEPARATOR,
|
||||
type LogFunction,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { ConflictResolutionHost } from "./index";
|
||||
import { createConflictResolutionOperations, useConflictResolutionFeature } from "./index";
|
||||
import type { ConflictResolutionOperationsDependencies } from "./operations";
|
||||
@@ -64,13 +71,15 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
vault: { getActiveFilePath: activeFile },
|
||||
};
|
||||
const serviceModules = { databaseFileAccess, fileHandler, storageAccess };
|
||||
const log = createInstanceLogFunction("SF:ConflictResolution", services.API as unknown as IAPIService);
|
||||
|
||||
if (options.compose !== false) {
|
||||
useConflictResolutionFeature({ services, serviceModules } as unknown as ConflictResolutionHost);
|
||||
useConflictResolutionFeature({ services, serviceModules, log } as unknown as ConflictResolutionHost);
|
||||
}
|
||||
|
||||
return {
|
||||
addLog,
|
||||
log,
|
||||
conflict,
|
||||
context,
|
||||
databaseFileAccess,
|
||||
@@ -151,6 +160,87 @@ function createOperationsHarness() {
|
||||
}
|
||||
|
||||
describe("conflict resolution serviceFeature", () => {
|
||||
it("declares a borrowed logger and only the services used by conflict resolution", () => {
|
||||
expectTypeOf<keyof ConflictResolutionHost["services"]>().toEqualTypeOf<
|
||||
"context" | "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault"
|
||||
>();
|
||||
expectTypeOf<ConflictResolutionHost["log"]>().toEqualTypeOf<LogFunction>();
|
||||
});
|
||||
|
||||
it("uses the injected logger for the checker and resolver without an API service", async () => {
|
||||
const harness = createHarness({
|
||||
compose: false,
|
||||
settings: { checkConflictOnlyOnOpen: true },
|
||||
activeFile: "active.md" as FilePathWithPrefix,
|
||||
});
|
||||
const { API, ...services } = harness.services;
|
||||
const log = vi.fn<LogFunction>();
|
||||
useConflictResolutionFeature({
|
||||
services,
|
||||
serviceModules: harness.serviceModules,
|
||||
log,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
|
||||
await harness.conflict.queueCheckForIfOpen("other.md" as FilePathWithPrefix);
|
||||
await harness.conflict.resolveByNewest("missing.md" as FilePathWithPrefix);
|
||||
|
||||
expect(log).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"other.md is conflicted, merging process has been postponed.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
expect(log).toHaveBeenNthCalledWith(2, "Could not get current revision of missing.md");
|
||||
expect(API.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps separately selected loggers isolated when legacy API services are shared", async () => {
|
||||
const first = createHarness({ compose: false });
|
||||
const second = createHarness({ compose: false });
|
||||
const firstLog = vi.fn<LogFunction>();
|
||||
const secondLog = vi.fn<LogFunction>();
|
||||
const sharedAPI = first.services.API;
|
||||
useConflictResolutionFeature({
|
||||
services: first.services,
|
||||
serviceModules: first.serviceModules,
|
||||
log: firstLog,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
useConflictResolutionFeature({
|
||||
services: { ...second.services, API: sharedAPI },
|
||||
serviceModules: second.serviceModules,
|
||||
log: secondLog,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
first.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
second.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
|
||||
await first.conflict.resolveByNewest("first.md" as FilePathWithPrefix);
|
||||
await second.conflict.resolveByNewest("second.md" as FilePathWithPrefix);
|
||||
|
||||
expect(firstLog).toHaveBeenCalledExactlyOnceWith("Could not get current revision of first.md");
|
||||
expect(secondLog).toHaveBeenCalledExactlyOnceWith("Could not get current revision of second.md");
|
||||
expect(sharedAPI.addLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains the prefix, level, and key selected by the host logger", async () => {
|
||||
const harness = createHarness({ compose: false });
|
||||
const { API, ...services } = harness.services;
|
||||
useConflictResolutionFeature({
|
||||
services,
|
||||
serviceModules: harness.serviceModules,
|
||||
log: harness.log,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
|
||||
await harness.conflict.resolveByDeletingRevision("same.md" as FilePathWithPrefix, "1-old", "same");
|
||||
await harness.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(API.addLog).toHaveBeenCalledWith(
|
||||
`[SF:ConflictResolution]${MARK_LOG_SEPARATOR} same.md has been merged automatically`,
|
||||
LOG_LEVEL_INFO,
|
||||
""
|
||||
);
|
||||
expect(API.addLog).toHaveBeenLastCalledWith("Done!", LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
});
|
||||
|
||||
it("keeps resolver operations on narrow collaborators and extension seams", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same.md" as FilePathWithPrefix;
|
||||
@@ -557,6 +647,7 @@ describe("conflict resolution serviceFeature", () => {
|
||||
useConflictResolutionFeature({
|
||||
services: harness.services,
|
||||
serviceModules: harness.serviceModules,
|
||||
log: harness.log,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
|
||||
for (const registration of registrations) {
|
||||
@@ -724,6 +815,56 @@ describe("conflict resolution serviceFeature", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("continues checking accepted and later paths after one resolver rejects", async () => {
|
||||
const harness = createHarness();
|
||||
const failure = new Error("Conflict check failed");
|
||||
const resolve = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(undefined);
|
||||
harness.conflict.resolve.setHandler(resolve, true);
|
||||
const first = "failed.md" as FilePathWithPrefix;
|
||||
const second = "accepted.md" as FilePathWithPrefix;
|
||||
const later = "later.md" as FilePathWithPrefix;
|
||||
|
||||
await Promise.all([harness.conflict.queueCheckFor(first), harness.conflict.queueCheckFor(second)]);
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
|
||||
await harness.conflict.queueCheckFor(later);
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
|
||||
|
||||
expect(resolve.mock.calls.map(([path]) => path)).toEqual([first, second, later]);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
`[SF:ConflictResolution]${MARK_LOG_SEPARATOR} Conflict check failed: ${first}`,
|
||||
LOG_LEVEL_INFO,
|
||||
""
|
||||
);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, "");
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("drains requeued automatic checks when every resolver slot is occupied", async () => {
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
const paths = Array.from({ length: 10 }, (_, index) => `requeue-${index}.md` as FilePathWithPrefix);
|
||||
const firstChecks = new Set<FilePathWithPrefix>();
|
||||
const finishers: Array<() => void> = [];
|
||||
harness.tryAutoMerge.mockImplementation(async (path: FilePathWithPrefix) => {
|
||||
if (firstChecks.has(path)) return { ok: NOT_CONFLICTED };
|
||||
firstChecks.add(path);
|
||||
await new Promise<void>((resolve) => {
|
||||
finishers.push(resolve);
|
||||
});
|
||||
return { ok: AUTO_MERGED };
|
||||
});
|
||||
|
||||
await Promise.all(paths.map((path) => harness.conflict.queueCheckFor(path)));
|
||||
await vi.waitFor(() => expect(finishers).toHaveLength(10));
|
||||
const completion = harness.conflict.ensureAllProcessed();
|
||||
for (const finish of finishers) finish();
|
||||
|
||||
await expect(completion).resolves.toBe(true);
|
||||
for (const path of paths) {
|
||||
expect(harness.tryAutoMerge.mock.calls.filter(([checked]) => checked === path)).toHaveLength(2);
|
||||
}
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("honours optional conflict handlers before entering the check queue", async () => {
|
||||
const path = "optional.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
createServiceFeature,
|
||||
type NecessaryServices,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { createConflictCheckingHandlers } from "./checker";
|
||||
import { createConflictResolutionOperations } from "./operations";
|
||||
|
||||
type ConflictResolutionServices = NecessaryServices<
|
||||
"API" | "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||
"databaseFileAccess" | "fileHandler" | "storageAccess"
|
||||
>;
|
||||
|
||||
export type ConflictResolutionHost = ConflictResolutionServices & {
|
||||
readonly services: ConflictResolutionServices["services"] & {
|
||||
readonly conflict: InjectableConflictService<ServiceContext>;
|
||||
};
|
||||
export type ConflictResolutionHost = NecessaryServices<
|
||||
"appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||
"databaseFileAccess" | "fileHandler" | "storageAccess",
|
||||
InjectableServiceHub
|
||||
> & {
|
||||
readonly log: LogFunction;
|
||||
};
|
||||
|
||||
/** Compose the host-neutral conflict checker and resolver handlers. */
|
||||
export function useConflictResolutionFeature(host: ConflictResolutionHost): void {
|
||||
const { services, serviceModules } = host;
|
||||
const log = createInstanceLogFunction("SF:ConflictResolution", services.API);
|
||||
/** Compose the host-neutral conflict handlers with the host's borrowed logger. */
|
||||
export const useConflictResolutionFeature = createServiceFeature((host: ConflictResolutionHost): void => {
|
||||
const { services, serviceModules, log } = host;
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: serviceModules.databaseFileAccess,
|
||||
@@ -48,7 +46,7 @@ export function useConflictResolutionFeature(host: ConflictResolutionHost): void
|
||||
services.conflict.resolve.setHandler(operations.resolve);
|
||||
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
|
||||
}
|
||||
});
|
||||
|
||||
export { createConflictCheckingHandlers } from "./checker";
|
||||
export type { ConflictCheckingDependencies, ConflictCheckingHandlers } from "./checker";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getLanguage, Notice, requireApiVersion } from "@/deps";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createServiceFeature } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@/common/rosetta";
|
||||
import { $msg, __onMissingTranslation, setLang } from "@/common/translation";
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
|
||||
type EnableI18nHost = NecessaryServices<"setting" | "API" | "appLifecycle", never>;
|
||||
|
||||
function tryGetLanguage(onError: (error: unknown) => void) {
|
||||
if (requireApiVersion("1.8.7")) {
|
||||
try {
|
||||
@@ -48,7 +51,8 @@ class ObsidianLanguageAppliedNotice {
|
||||
}
|
||||
}
|
||||
|
||||
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => {
|
||||
export const enableI18nFeature = createServiceFeature(async (host: EnableI18nHost) => {
|
||||
const { setting, API, appLifecycle } = host.services;
|
||||
// Clear missing translation handler to avoid unnecessary warnings.
|
||||
__onMissingTranslation(() => {});
|
||||
let isChanged = false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
|
||||
const noticeState = vi.hoisted(() => ({
|
||||
instances: [] as Array<{ hide: ReturnType<typeof vi.fn>; duration: number }>,
|
||||
@@ -63,6 +63,14 @@ describe("automatic display language", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("declares only the services it needs and returns a boolean promise", () => {
|
||||
type FeatureHost = Parameters<typeof enableI18nFeature>[0];
|
||||
|
||||
expectTypeOf<keyof FeatureHost["services"]>().toEqualTypeOf<"context" | "setting" | "API" | "appLifecycle">();
|
||||
expectTypeOf<keyof FeatureHost["serviceModules"]>().toBeNever();
|
||||
expectTypeOf<ReturnType<typeof enableI18nFeature>>().toEqualTypeOf<Promise<boolean>>();
|
||||
});
|
||||
|
||||
it("lets start-up continue and opens translation details only from a persistent Notice", async () => {
|
||||
const settings = { displayLanguage: "" };
|
||||
const applyPartial = vi.fn(async (partial: Partial<typeof settings>) => Object.assign(settings, partial));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||
|
||||
describe("ReplicateResultProcessor.withCounting", () => {
|
||||
it("counts overlapping callbacks until each settles and preserves results and errors", async () => {
|
||||
const originalCount = 7;
|
||||
const count = reactiveSource(originalCount);
|
||||
const processor = new ReplicateResultProcessor({} as never);
|
||||
const firstResult = { task: "first" };
|
||||
const firstResultGate = promiseWithResolvers<typeof firstResult>();
|
||||
const secondResultGate = promiseWithResolvers<string>();
|
||||
const firstStarted = promiseWithResolvers<undefined>();
|
||||
const secondStarted = promiseWithResolvers<undefined>();
|
||||
const failure = new Error("second callback failed");
|
||||
let firstCountAtStart = 0;
|
||||
let secondCountAtStart = 0;
|
||||
|
||||
const first = processor.withCounting(async () => {
|
||||
firstCountAtStart = count.value;
|
||||
firstStarted.resolve(undefined);
|
||||
return firstResultGate.promise;
|
||||
}, count);
|
||||
await firstStarted.promise;
|
||||
expect(firstCountAtStart).toBe(originalCount + 1);
|
||||
expect(count.value).toBe(originalCount + 1);
|
||||
|
||||
const second = processor.withCounting(async () => {
|
||||
secondCountAtStart = count.value;
|
||||
secondStarted.resolve(undefined);
|
||||
return secondResultGate.promise;
|
||||
}, count);
|
||||
await secondStarted.promise;
|
||||
expect(secondCountAtStart).toBe(originalCount + 2);
|
||||
expect(count.value).toBe(originalCount + 2);
|
||||
|
||||
firstResultGate.resolve(firstResult);
|
||||
await expect(first).resolves.toBe(firstResult);
|
||||
expect(count.value).toBe(originalCount + 1);
|
||||
|
||||
secondResultGate.reject(failure);
|
||||
await expect(second).rejects.toBe(failure);
|
||||
expect(count.value).toBe(originalCount);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { fireAndForget, isAnyNote, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2";
|
||||
import { withCounting } from "octagonal-wheels/concurrency/counting";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
@@ -223,12 +224,7 @@ export class ReplicateResultProcessor {
|
||||
* @returns result of the procedure
|
||||
*/
|
||||
async withCounting<T>(proc: () => Promise<T>, countValue: ReactiveSource<number>) {
|
||||
countValue.value++;
|
||||
try {
|
||||
return await proc();
|
||||
} finally {
|
||||
countValue.value--;
|
||||
}
|
||||
return withCounting(countValue, proc);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,48 +1,22 @@
|
||||
import {
|
||||
VERSIONING_DOCID,
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { balanceChunkPurgedDBs, purgeUnreferencedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
assessRemoteFeatureDocument,
|
||||
describeRemoteFeatureRejection,
|
||||
type ReplicatorInstance,
|
||||
type ReplicationFailureRequest,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
|
||||
async function canInterpretCleanupDatabase(db: PouchDB.Database<EntryDoc>): Promise<boolean> {
|
||||
try {
|
||||
const assessment = assessRemoteFeatureDocument(await db.get(VERSIONING_DOCID));
|
||||
if (assessment.status === "supported" || assessment.status === "older-generation") return true;
|
||||
Logger(`Database cleanup cancelled: ${describeRemoteFeatureRejection(assessment)}`, LOG_LEVEL_NOTICE);
|
||||
} catch (error) {
|
||||
Logger("Database cleanup cancelled: feature compatibility could not be checked.", LOG_LEVEL_NOTICE);
|
||||
Logger(error, LOG_LEVEL_INFO);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type CentralCompatibilityRecoveryServices = Pick<
|
||||
LiveSyncBaseCore["services"],
|
||||
"API" | "appLifecycle" | "replicator" | "setting" | "tweakValue"
|
||||
"appLifecycle" | "replicator" | "setting" | "tweakValue"
|
||||
>;
|
||||
|
||||
/** Collaborators for applying a compatibility decision to its failed publication. */
|
||||
interface CentralCompatibilityRecoveryContext {
|
||||
readonly confirm: LiveSyncBaseCore["confirm"];
|
||||
/** Obtain the database only when recovery runs, after initialisation or reset. */
|
||||
readonly getLocalDatabase: () => LiveSyncBaseCore["localDatabase"];
|
||||
readonly rebuilder: LiveSyncBaseCore["rebuilder"];
|
||||
readonly services: CentralCompatibilityRecoveryServices;
|
||||
}
|
||||
@@ -72,89 +46,11 @@ function canMarkRemoteResolved(replicator: ReplicatorInstance): replicator is Re
|
||||
* replacement; the failure result is never re-read from the current instance.
|
||||
*/
|
||||
export function createCentralCompatibilityRecovery(context: CentralCompatibilityRecoveryContext) {
|
||||
async function reconcileCleanedRemote(
|
||||
showProgress: boolean,
|
||||
setting: ObsidianLiveSyncSettings,
|
||||
expectedContext: ReplicationFailureRequest["context"]
|
||||
) {
|
||||
Logger("The remote database has been cleaned.", showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
if (!(await canInterpretCleanupDatabase(context.getLocalDatabase().localDatabase))) return;
|
||||
const count = await purgeUnreferencedChunks(context.getLocalDatabase().localDatabase, true);
|
||||
const message = `The remote database has been cleaned up.
|
||||
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
||||
However, If there are many chunks to be deleted, maybe fetching again is faster.
|
||||
We will lose the history of this device if we fetch the remote database again.
|
||||
Even if you choose to clean up, you will see this option again if you exit Obsidian and then synchronise again.`;
|
||||
const CHOICE_FETCH = "Fetch again";
|
||||
const CHOICE_CLEAN = "Cleanup";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const selected = await context.confirm.confirmWithMessage(
|
||||
"Cleaned",
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_CLEAN, CHOICE_DISMISS],
|
||||
CHOICE_DISMISS,
|
||||
30
|
||||
);
|
||||
if (selected == CHOICE_FETCH) {
|
||||
await context.rebuilder.$performRebuildDB("localOnly");
|
||||
}
|
||||
if (selected != CHOICE_CLEAN) return;
|
||||
|
||||
await context.services.replicator.runBoundedRemoteActivity(
|
||||
() =>
|
||||
context.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
|
||||
if (activeContext !== expectedContext) return;
|
||||
const replicator = activeContext.replicator;
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const localDatabase = context.getLocalDatabase();
|
||||
const remoteDatabase = await replicator.connectRemoteCouchDBWithSetting(
|
||||
setting,
|
||||
context.services.API.isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDatabase == "string") {
|
||||
Logger(remoteDatabase, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await purgeUnreferencedChunks(localDatabase.localDatabase, false);
|
||||
localDatabase.clearCaches();
|
||||
const replicated = await context.services.replicator.runFiniteReplicationActivity(
|
||||
() => replicator.openOneShotReplication(setting, showProgress, false, "sync", true),
|
||||
{ label: "replication" }
|
||||
);
|
||||
if (replicated) {
|
||||
await balanceChunkPurgedDBs(localDatabase.localDatabase, remoteDatabase.db);
|
||||
await purgeUnreferencedChunks(localDatabase.localDatabase, false);
|
||||
localDatabase.clearCaches();
|
||||
await replicator.markRemoteResolved(setting);
|
||||
Logger(
|
||||
"The local database has been cleaned up.",
|
||||
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await remoteDatabase.close();
|
||||
}
|
||||
}),
|
||||
{ label: "database-cleanup" }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleReplicationFailure(request: ReplicationFailureRequest): Promise<boolean> {
|
||||
const { context: failedContext, interaction, outcome, progressPresentation, setting } = request;
|
||||
const showProgress = progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE;
|
||||
const { context: failedContext, interaction, outcome, setting } = request;
|
||||
if (interaction.kind === "forbidden") {
|
||||
// Automatic requests may report the failure, but must not enter
|
||||
// tweak, lock, fetch, unlock, or cleanup dialogues.
|
||||
// tweak, lock, fetch, or unlock dialogues.
|
||||
Logger("Replication failed on an unattended path.", LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
@@ -198,14 +94,6 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED &&
|
||||
usesLegacyIndexedDBAdapter(setting)
|
||||
) {
|
||||
await reconcileCleanedRemote(showProgress, setting, failedContext);
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = $msg("Replicator.Dialogue.Locked.Message");
|
||||
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
||||
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
||||
@@ -240,5 +128,5 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.freeze({ handleReplicationFailure, reconcileCleanedRemote });
|
||||
return Object.freeze({ handleReplicationFailure });
|
||||
}
|
||||
@@ -16,55 +16,86 @@ const chunkMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/chunks", () => chunkMocks);
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {},
|
||||
}));
|
||||
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||
|
||||
describe("central compatibility recovery", () => {
|
||||
it("does not count chunks for cleanup when local feature requirements are unknown", async () => {
|
||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage },
|
||||
getLocalDatabase: () => ({
|
||||
localDatabase: {
|
||||
get: vi.fn(async (id: string) => ({
|
||||
_id: id,
|
||||
type: "versioninfo",
|
||||
version: 13,
|
||||
used_features: ["future-format-v7"],
|
||||
})),
|
||||
},
|
||||
}),
|
||||
services: { replicator: {} },
|
||||
} as never);
|
||||
describe.each([
|
||||
{ name: "unknown local features", version: 13, used_features: ["future-format-v7"] },
|
||||
{ name: "a legacy local version", version: 11 },
|
||||
{ name: "the current local version", version: 12 },
|
||||
])("compatibility: cleaned IndexedDB remote with $name", (localVersion) => {
|
||||
it.each(["fetch", "unlock", "dismiss"] as const)(
|
||||
"uses standard recovery for an explicit %s choice without legacy cleanup",
|
||||
async (choice) => {
|
||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
||||
chunkMocks.balanceChunkPurgedDBs.mockClear();
|
||||
const markRemoteResolved = vi.fn(async (_setting: unknown) => undefined);
|
||||
const failedContext = {
|
||||
provider: {},
|
||||
replicator: { markRemoteResolved },
|
||||
configurationIdentity: "cleaned-remote-test",
|
||||
};
|
||||
const setting = { useIndexedDBAdapter: true } as ObsidianLiveSyncSettings;
|
||||
const scheduleFetch = vi.fn(async () => undefined);
|
||||
const scheduleRestart = vi.fn();
|
||||
const getLocalDatabase = vi.fn(() => ({
|
||||
localDatabase: {
|
||||
get: vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", ...localVersion })),
|
||||
},
|
||||
}));
|
||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
||||
const askSelectStringDialogue = vi.fn(
|
||||
async (_message: string, choices: string[]) =>
|
||||
choices[choice === "fetch" ? 0 : choice === "unlock" ? 1 : 2]
|
||||
);
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { askSelectStringDialogue, confirmWithMessage },
|
||||
getLocalDatabase,
|
||||
rebuilder: { scheduleFetch },
|
||||
services: {
|
||||
appLifecycle: { scheduleRestart },
|
||||
replicator: {
|
||||
runWithActiveReplicatorContext: async (task: (context: unknown) => unknown) =>
|
||||
await task(failedContext),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
|
||||
const result = await recovery.handleReplicationFailure({
|
||||
context: failedContext,
|
||||
setting,
|
||||
outcome: replicationFailed(new Error("cleaned"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED,
|
||||
}),
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
|
||||
expect(chunkMocks.purgeUnreferencedChunks).not.toHaveBeenCalled();
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows cleanup counting for a legacy local version document", async () => {
|
||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage },
|
||||
getLocalDatabase: () => ({
|
||||
localDatabase: {
|
||||
get: vi.fn(async (id: string) => ({ _id: id, type: "versioninfo", version: 11 })),
|
||||
},
|
||||
}),
|
||||
services: { replicator: {} },
|
||||
} as never);
|
||||
|
||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
|
||||
|
||||
expect(chunkMocks.purgeUnreferencedChunks).toHaveBeenCalledWith(expect.anything(), true);
|
||||
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
||||
expect(result).toBe(false);
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledOnce();
|
||||
const choices = askSelectStringDialogue.mock.calls[0][1];
|
||||
expect(choices).toHaveLength(3);
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledWith(expect.any(String), choices, {
|
||||
title: expect.any(String),
|
||||
defaultAction: choices[2],
|
||||
timeout: 60,
|
||||
});
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(getLocalDatabase).not.toHaveBeenCalled();
|
||||
expect(chunkMocks.purgeUnreferencedChunks).not.toHaveBeenCalled();
|
||||
expect(chunkMocks.balanceChunkPurgedDBs).not.toHaveBeenCalled();
|
||||
expect(scheduleFetch).toHaveBeenCalledTimes(choice === "fetch" ? 1 : 0);
|
||||
expect(scheduleRestart).toHaveBeenCalledTimes(choice === "fetch" ? 1 : 0);
|
||||
expect(markRemoteResolved).toHaveBeenCalledTimes(choice === "unlock" ? 1 : 0);
|
||||
if (choice === "fetch") {
|
||||
expect(scheduleFetch.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
scheduleRestart.mock.invocationCallOrder[0]
|
||||
);
|
||||
}
|
||||
if (choice === "unlock") expect(markRemoteResolved).toHaveBeenCalledWith(setting);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the failed attempt's exact tweak assessment to mismatch resolution", async () => {
|
||||
@@ -139,7 +170,6 @@ describe("central compatibility recovery", () => {
|
||||
try {
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: {},
|
||||
getLocalDatabase: () => ({}),
|
||||
rebuilder: {},
|
||||
services: {
|
||||
appLifecycle: {},
|
||||
@@ -187,7 +217,6 @@ describe("central compatibility recovery", () => {
|
||||
});
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: {},
|
||||
getLocalDatabase: () => ({}),
|
||||
rebuilder: {},
|
||||
services: {
|
||||
appLifecycle: {},
|
||||
@@ -255,7 +284,6 @@ describe("central compatibility recovery", () => {
|
||||
);
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: {},
|
||||
getLocalDatabase: () => ({}),
|
||||
rebuilder: {},
|
||||
services: {
|
||||
appLifecycle: {},
|
||||
@@ -287,7 +315,10 @@ describe("central compatibility recovery", () => {
|
||||
expect(setPreferredRemoteTweakSettings.mock.calls[0][0]).not.toBe(effectiveSetting);
|
||||
});
|
||||
|
||||
it("does not apply an unlock selected for a replaced failed publication", async () => {
|
||||
it.each([
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED,
|
||||
])("does not apply an unlock for a replaced failed publication: %s", async (reason) => {
|
||||
const failedMarkResolved = vi.fn(async () => undefined);
|
||||
const replacementMarkResolved = vi.fn(async () => undefined);
|
||||
const failedContext = { provider: {}, replicator: { markRemoteResolved: failedMarkResolved } };
|
||||
@@ -299,7 +330,6 @@ describe("central compatibility recovery", () => {
|
||||
confirm: {
|
||||
askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]),
|
||||
},
|
||||
getLocalDatabase: () => ({}),
|
||||
rebuilder: {},
|
||||
services: {
|
||||
appLifecycle: { scheduleRestart: vi.fn() },
|
||||
@@ -311,9 +341,9 @@ describe("central compatibility recovery", () => {
|
||||
|
||||
await recovery.handleReplicationFailure({
|
||||
context: failedContext,
|
||||
setting: {},
|
||||
setting: { useIndexedDBAdapter: true },
|
||||
outcome: replicationFailed(new Error("locked"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||
reason,
|
||||
}),
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -324,69 +354,55 @@ describe("central compatibility recovery", () => {
|
||||
expect(replacementMarkResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps cleaned-remote replication and balancing inside the shared activity boundary", async () => {
|
||||
const activityFinished = vi.fn();
|
||||
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => {
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
activityFinished();
|
||||
}
|
||||
});
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const openOneShotReplication = vi.fn(async () => true);
|
||||
const remoteDatabase = {
|
||||
close: vi.fn(async () => undefined),
|
||||
it.each([
|
||||
NO_INTERACTION,
|
||||
{
|
||||
kind: "permitted" as const,
|
||||
permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false },
|
||||
},
|
||||
])("does not recover a cleaned remote without recovery authority: %j", async (interaction) => {
|
||||
const askSelectStringDialogue = vi.fn();
|
||||
const confirmWithMessage = vi.fn();
|
||||
const getLocalDatabase = vi.fn();
|
||||
const scheduleFetch = vi.fn();
|
||||
const scheduleRestart = vi.fn();
|
||||
const markRemoteResolved = vi.fn();
|
||||
const failedContext = {
|
||||
provider: {},
|
||||
replicator: { markRemoteResolved },
|
||||
configurationIdentity: "cleaned-remote-test",
|
||||
};
|
||||
const close = vi.fn(async () => undefined);
|
||||
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as never), {
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase, close })),
|
||||
openOneShotReplication,
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
});
|
||||
const expectedContext = { provider: {}, replicator: activeReplicator };
|
||||
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
|
||||
task(expectedContext)
|
||||
task(failedContext)
|
||||
);
|
||||
const localDatabase = {
|
||||
localDatabase: {
|
||||
get: vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 12 })),
|
||||
},
|
||||
clearCaches: vi.fn(),
|
||||
};
|
||||
const getLocalDatabase = vi.fn(() => localDatabase);
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
||||
confirm: { askSelectStringDialogue, confirmWithMessage },
|
||||
getLocalDatabase,
|
||||
rebuilder: {},
|
||||
rebuilder: { scheduleFetch },
|
||||
services: {
|
||||
appLifecycle: {},
|
||||
API: { isMobile: vi.fn(() => false) },
|
||||
replicator: {
|
||||
runBoundedRemoteActivity,
|
||||
runFiniteReplicationActivity,
|
||||
runWithActiveReplicatorContext,
|
||||
},
|
||||
tweakValue: {},
|
||||
appLifecycle: { scheduleRestart },
|
||||
replicator: { runWithActiveReplicatorContext },
|
||||
},
|
||||
} as never);
|
||||
|
||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, expectedContext as never);
|
||||
await expect(
|
||||
recovery.handleReplicationFailure({
|
||||
context: failedContext,
|
||||
setting: { useIndexedDBAdapter: true },
|
||||
outcome: replicationFailed(new Error("cleaned"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED,
|
||||
}),
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction,
|
||||
} as never)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "database-cleanup",
|
||||
});
|
||||
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "replication",
|
||||
});
|
||||
expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce();
|
||||
expect(openOneShotReplication).toHaveBeenCalledOnce();
|
||||
expect(openOneShotReplication.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
activityFinished.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
||||
expect(getLocalDatabase).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(getLocalDatabase).not.toHaveBeenCalled();
|
||||
expect(scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(scheduleRestart).not.toHaveBeenCalled();
|
||||
expect(runWithActiveReplicatorContext).not.toHaveBeenCalled();
|
||||
expect(markRemoteResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -83,10 +83,8 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
|
||||
const securitySeedPreflight = createSecuritySeedPreflight(unresolvedErrorManager, preflightContext);
|
||||
const centralCompatibilityRecovery = createCentralCompatibilityRecovery({
|
||||
confirm: core.confirm,
|
||||
getLocalDatabase: () => core.localDatabase,
|
||||
rebuilder: core.rebuilder,
|
||||
services: {
|
||||
API: services.API,
|
||||
appLifecycle: services.appLifecycle,
|
||||
replicator: services.replicator,
|
||||
setting: services.setting,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCommandCheck } from "@vrtmrz/obsidian-plugin-kit/command";
|
||||
import { LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
@@ -112,33 +113,40 @@ export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setti
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-copysetupuri",
|
||||
name: "Copy settings as a new setup URI",
|
||||
checkCallback: (checking) => {
|
||||
if (!host.services.setting.currentSettings().isConfigured) return false;
|
||||
if (!checking) fireAndForget(copySetupURI(host, log));
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => host.services.setting.currentSettings().isConfigured,
|
||||
() => {
|
||||
fireAndForget(copySetupURI(host, log));
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-copysetupuri-short",
|
||||
name: "Copy settings as a new setup URI (With customization sync)",
|
||||
checkCallback: (checking) => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!settings.isConfigured || !settings.usePluginSync) return false;
|
||||
if (!checking) fireAndForget(copySetupURI(host, log, false));
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
return settings.isConfigured && settings.usePluginSync;
|
||||
},
|
||||
() => {
|
||||
fireAndForget(copySetupURI(host, log, false));
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-copysetupurifull",
|
||||
name: "Copy settings as a new setup URI (Full)",
|
||||
checkCallback: (checking) => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!settings.isConfigured || !settings.useAdvancedMode) return false;
|
||||
if (!checking) fireAndForget(copySetupURIFull(host, log));
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
return settings.isConfigured && settings.useAdvancedMode;
|
||||
},
|
||||
() => {
|
||||
fireAndForget(copySetupURIFull(host, log));
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
host.services.context.events.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () =>
|
||||
|
||||
@@ -221,7 +221,7 @@ describe("setupObsidian/setupUri", () => {
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => "pass"),
|
||||
askString: vi.fn(async () => false),
|
||||
},
|
||||
promptCopyToClipboard: vi.fn(() => true),
|
||||
},
|
||||
@@ -269,7 +269,7 @@ describe("setupObsidian/setupUri", () => {
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askString: vi.fn(() => "pass"),
|
||||
askString: vi.fn(async () => false),
|
||||
},
|
||||
promptCopyToClipboard: vi.fn(() => true),
|
||||
},
|
||||
@@ -292,5 +292,12 @@ describe("setupObsidian/setupUri", () => {
|
||||
settings.useAdvancedMode = true;
|
||||
expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(true);
|
||||
expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(true);
|
||||
expect(host.services.UI.confirm.askString).not.toHaveBeenCalled();
|
||||
settings.isConfigured = false;
|
||||
for (const registered of commands) expect(registered.checkCallback?.(false)).toBe(false);
|
||||
expect(host.services.UI.confirm.askString).not.toHaveBeenCalled();
|
||||
settings.isConfigured = true;
|
||||
for (const registered of commands) expect(registered.checkCallback?.(false)).toBe(true);
|
||||
expect(host.services.UI.confirm.askString).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createCommandCheck } from "@vrtmrz/obsidian-plugin-kit/command";
|
||||
import {
|
||||
createServiceFeature,
|
||||
type NecessaryServices,
|
||||
@@ -46,22 +47,23 @@ export const useDatabaseDiagnosticsFeature = createServiceFeature((host: Databas
|
||||
id: "analyse-database",
|
||||
name: "Analyse Database Usage (advanced)",
|
||||
icon: "database-search",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode || !services.database.isDatabaseReady())
|
||||
return false;
|
||||
if (!checking) void open("usage");
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.setting.currentSettings().useAdvancedMode && services.database.isDatabaseReady(),
|
||||
() => {
|
||||
void open("usage");
|
||||
}
|
||||
),
|
||||
});
|
||||
services.API.addCommand({
|
||||
id: "inspect-database-integrity",
|
||||
name: $msg("Inspect database integrity"),
|
||||
icon: "database-search",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.database.isDatabaseReady()) return false;
|
||||
if (!checking) void open("integrity");
|
||||
return true;
|
||||
},
|
||||
checkCallback: createCommandCheck(
|
||||
() => services.database.isDatabaseReady(),
|
||||
() => {
|
||||
void open("integrity");
|
||||
}
|
||||
),
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
@@ -82,6 +82,26 @@ describe("database diagnostics serviceFeature", () => {
|
||||
expect(data.openDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["analyse-database", "inspect-database-integrity"])(
|
||||
"rechecks database availability before executing %s",
|
||||
async (id) => {
|
||||
const data = fixture();
|
||||
data.settings.useAdvancedMode = true;
|
||||
for (const initialise of data.initialise) await initialise();
|
||||
const command = data.commands.find((candidate) => candidate.id === id)!;
|
||||
expect(command.checkCallback(true)).toBe(true);
|
||||
expect(data.openDialog).not.toHaveBeenCalled();
|
||||
data.setReady(false);
|
||||
expect(command.checkCallback(false)).toBe(false);
|
||||
expect(data.openDialog).not.toHaveBeenCalled();
|
||||
data.setReady(true);
|
||||
expect(command.checkCallback(false)).toBe(true);
|
||||
expect(data.openDialog).toHaveBeenCalledOnce();
|
||||
expect(data.openDialog.mock.calls[0][1].kind).toBe(id === "analyse-database" ? "usage" : "integrity");
|
||||
data.close();
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the existing dialogue manager and performs no analysis until the run choice is submitted", async () => {
|
||||
vi.mocked(runDatabaseDiagnostic).mockClear();
|
||||
const data = fixture();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { REMOTE_RESOURCE_KINDS, type JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
|
||||
import JoinedDevicesDialog from "@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
@@ -119,9 +120,9 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
|
||||
settings
|
||||
);
|
||||
if (!resource) throw new Error("Joined devices are unavailable");
|
||||
try {
|
||||
return withOwnedRemoteResource(resource, async (ownedResource) => {
|
||||
assertCurrent();
|
||||
const snapshot = await resource.read();
|
||||
const snapshot = await ownedResource.read();
|
||||
assertCurrent();
|
||||
notify(snapshot, id, identity!);
|
||||
return joinedDevicesView(snapshot, {
|
||||
@@ -130,9 +131,7 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
|
||||
pluginVersion: services.API.getPluginVersion(),
|
||||
appVersion: services.API.getAppVersion(),
|
||||
});
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
});
|
||||
},
|
||||
forget: async (id) => {
|
||||
if (id === (await ownId())) throw new Error("This device cannot be forgotten");
|
||||
@@ -141,14 +140,12 @@ export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: Obsidi
|
||||
settings
|
||||
);
|
||||
if (!resource) throw new Error("Joined devices are unavailable");
|
||||
try {
|
||||
return withOwnedRemoteResource(resource, async (ownedResource) => {
|
||||
assertCurrent();
|
||||
const result = await resource.forget(id);
|
||||
const result = await ownedResource.forget(id);
|
||||
assertCurrent();
|
||||
return result;
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel: () => controller.abort(),
|
||||
};
|
||||
|
||||
@@ -110,6 +110,67 @@ describe("joined devices service feature", () => {
|
||||
expect(await pending).toEqual({ status: "handled", value: "closed" });
|
||||
await expect(options.refresh()).rejects.toThrow();
|
||||
});
|
||||
it("propagates a failed refresh after releasing its resource", async () => {
|
||||
const data = fixture();
|
||||
const failure = new Error("joined devices read failed");
|
||||
data.read.mockRejectedValueOnce(failure);
|
||||
const { options, pending } = await open(data);
|
||||
|
||||
await expect(options.refresh()).rejects.toBe(failure);
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it.each(["refresh", "forget"] as const)(
|
||||
"waits for resource disposal after %s",
|
||||
async (operationName) => {
|
||||
const data = fixture();
|
||||
let finishDisposal!: () => void;
|
||||
data.dispose.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishDisposal = resolve;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const operation = operationName === "refresh" ? options.refresh() : options.forget("other");
|
||||
let settled = false;
|
||||
void operation.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
}
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(data.dispose).toHaveBeenCalledOnce());
|
||||
expect(settled).toBe(false);
|
||||
finishDisposal();
|
||||
await expect(operation).resolves.toBeDefined();
|
||||
expect(settled).toBe(true);
|
||||
data.close();
|
||||
await pending;
|
||||
}
|
||||
);
|
||||
it.each(["refresh", "forget"] as const)(
|
||||
"uses disposal failure precedence when %s also fails",
|
||||
async (operationName) => {
|
||||
const data = fixture();
|
||||
const operationFailure = new Error("joined devices operation failed");
|
||||
const disposalFailure = new Error("joined devices disposal failed");
|
||||
if (operationName === "refresh") data.read.mockRejectedValueOnce(operationFailure);
|
||||
else data.forget.mockRejectedValueOnce(operationFailure);
|
||||
data.dispose.mockRejectedValueOnce(disposalFailure);
|
||||
const { options, pending } = await open(data);
|
||||
const operation = operationName === "refresh" ? options.refresh() : options.forget("other");
|
||||
|
||||
await expect(operation).rejects.toBe(disposalFailure);
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
await pending;
|
||||
}
|
||||
);
|
||||
it("prevents self removal and fences an old dialogue after a remote switch", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user