Merge main into NFC transport support

This commit is contained in:
vorotamoroz committed 2026-11-01 14:11:17 +00:00
commit 4957f8d28f
160 files changed
+12441 -1388

No files matched your search

@@ -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() {
@@ -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");
});
});
+55 -22
View File
@@ -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));
+228
View File
@@ -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);
});
});
+51 -30
View File
@@ -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" });
});
});
});
@@ -16,6 +16,43 @@ declare module "obsidian" {
}
}
const cacheReuseDirectives = new Set(["max-age", "max-stale", "min-fresh", "only-if-cached"]);
function splitHeaderDirectives(value: string): string[] {
const directives: string[] = [];
let start = 0;
let inQuotes = false;
let escaped = false;
for (let index = 0; index < value.length; index++) {
const character = value[index];
if (inQuotes && escaped) {
escaped = false;
continue;
}
if (inQuotes && character === "\\") {
escaped = true;
continue;
}
if (character === '"') {
inQuotes = !inQuotes;
} else if (character === "," && !inQuotes) {
const directive = value.slice(start, index).trim();
if (directive) directives.push(directive);
start = index + 1;
}
}
const lastDirective = value.slice(start).trim();
if (lastDirective) directives.push(lastDirective);
return directives;
}
function getDirectiveName(directive: string): string {
const equalsIndex = directive.indexOf("=");
return (equalsIndex === -1 ? directive : directive.slice(0, equalsIndex)).trim().toLowerCase();
}
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
_customHandler: ObsHttpHandler | undefined;
_confirmInstance: Confirm;
@@ -182,6 +219,30 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
delete transformedHeaders["Host"];
delete transformedHeaders["content-length"];
delete transformedHeaders["Content-Length"];
const cacheMode = opts?.cache ?? (req instanceof Request ? req.cache : undefined);
if (cacheMode === "no-store" && ["GET", "HEAD"].includes(method.toUpperCase())) {
// requestUrl has no cache option; no-store alone can reuse an existing cached response.
const cacheControlKeys = Object.keys(transformedHeaders).filter(
(key) => key.toLowerCase() === "cache-control"
);
const cacheControlDirectives = cacheControlKeys
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
.filter((directive) => {
const name = getDirectiveName(directive);
return !cacheReuseDirectives.has(name) && name !== "no-cache" && name !== "no-store";
});
const cacheControlKey = cacheControlKeys[0] ?? "cache-control";
for (const key of cacheControlKeys) delete transformedHeaders[key];
transformedHeaders[cacheControlKey] = [...cacheControlDirectives, "no-cache", "no-store"].join(", ");
const pragmaKeys = Object.keys(transformedHeaders).filter((key) => key.toLowerCase() === "pragma");
const pragmaDirectives = pragmaKeys
.flatMap((key) => splitHeaderDirectives(transformedHeaders[key]))
.filter((directive) => getDirectiveName(directive) !== "no-cache");
const pragmaKey = pragmaKeys[0] ?? "pragma";
for (const key of pragmaKeys) delete transformedHeaders[key];
transformedHeaders[pragmaKey] = [...pragmaDirectives, "no-cache"].join(", ");
}
const contentType =
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
const requestParam: RequestUrlParam = {
@@ -1,6 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestUrlParam } from "@/deps";
const mocks = vi.hoisted(() => ({
requestUrl: vi.fn<
(request: RequestUrlParam) => Promise<{
status: number;
headers: Record<string, string>;
arrayBuffer: ArrayBuffer;
}>
>(),
platform: {
isMobile: false,
},
@@ -8,12 +16,12 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/deps.ts", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/deps", () => ({
Platform: mocks.platform,
requestUrl: vi.fn(),
requestUrl: mocks.requestUrl,
}));
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
@@ -36,6 +44,8 @@ function createService(workspace: Record<string, unknown>, isMobile = false): Ob
beforeEach(() => {
mocks.platform.isMobile = false;
vi.clearAllMocks();
mocks.requestUrl.mockReset();
mocks.requestUrl.mockResolvedValue({ status: 200, headers: {}, arrayBuffer: new ArrayBuffer(0) });
});
describe("ObsidianAPIService.showWindowOnRight", () => {
@@ -65,3 +75,115 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
});
});
describe("ObsidianAPIService.nativeFetch cache control", () => {
const url = "https://couchdb.example.test/vault/f%3Ametadata";
function sentHeaders(): Headers {
expect(mocks.requestUrl).toHaveBeenCalledOnce();
return new Headers(mocks.requestUrl.mock.calls[0][0].headers);
}
it.each(["GET", "HEAD", "get", "head"])("bypasses cached responses for an explicit no-store %s", async (method) => {
const headers = {
Authorization: "Basic fixture",
"Cache-Control": "public, max-age=3600",
"cAcHe-CoNtRoL": "max-stale",
Pragma: "cache",
};
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
expect(sentHeaders().get("cache-control")).toBe("public, no-cache, no-store");
expect(sentHeaders().get("pragma")).toBe("cache, no-cache");
expect(sentHeaders().get("authorization")).toBe("Basic fixture");
expect(headers["Cache-Control"]).toBe("public, max-age=3600");
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method });
});
it("preserves non-conflicting cache directives when enforcing no-store", async () => {
const headers = {
"Cache-Control": 'max-age=3600, no-transform, custom="a,b"',
"cAcHe-CoNtRoL": 'max-stale=30, min-fresh=5, only-if-cached, custom-two="max-age=1, only-if-cached"',
Pragma: 'legacy="a,b", no-cache',
"X-Custom-Header": "preserved",
};
const originalHeaders = { ...headers };
await createService({}).nativeFetch(url, { cache: "no-store", headers });
const sent = mocks.requestUrl.mock.calls[0][0].headers ?? {};
expect(sent["Cache-Control"]).toBe(
'no-transform, custom="a,b", custom-two="max-age=1, only-if-cached", no-cache, no-store'
);
expect(sent["cAcHe-CoNtRoL"]).toBeUndefined();
expect(sent.Pragma).toBe('legacy="a,b", no-cache');
expect(sent["X-Custom-Header"]).toBe("preserved");
expect(headers).toEqual(originalHeaders);
});
it("uses the Request cache mode when the options do not override it", async () => {
const request = new Request(url, {
method: "HEAD",
cache: "no-store",
headers: { "X-Fixture": "request" },
});
const headers = new Headers({ "X-Other": "options" });
await createService({}).nativeFetch(request, { headers });
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
expect(sentHeaders().get("pragma")).toBe("no-cache");
expect(sentHeaders().get("x-fixture")).toBe("request");
expect(sentHeaders().get("x-other")).toBe("options");
expect(headers.has("cache-control")).toBe(false);
expect(request.headers.has("cache-control")).toBe(false);
});
it("lets the options request no-store for an otherwise cacheable Request", async () => {
await createService({}).nativeFetch(new Request(url, { cache: "force-cache" }), { cache: "no-store" });
expect(sentHeaders().get("cache-control")).toBe("no-cache, no-store");
});
it("lets an explicit default cache mode override a no-store Request", async () => {
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { cache: "default" });
expect(sentHeaders().has("cache-control")).toBe(false);
expect(sentHeaders().has("pragma")).toBe(false);
});
it.each([undefined, "default", "no-cache", "reload", "force-cache"] as const)(
"preserves caller headers for cache mode %s",
async (cache) => {
const headers = { "Cache-Control": "max-age=60", Pragma: "fixture" };
await createService({}).nativeFetch(url, { cache, headers });
expect(mocks.requestUrl.mock.calls[0][0].headers).toEqual(headers);
}
);
it("does not add cache directives to an ordinary request", async () => {
await createService({}).nativeFetch(url);
expect(sentHeaders().has("cache-control")).toBe(false);
expect(sentHeaders().has("pragma")).toBe(false);
});
it.each(["POST", "PUT", "DELETE"])("preserves a no-store %s request", async (method) => {
const headers = { "Cache-Control": "no-transform" };
await createService({}).nativeFetch(url, { method, cache: "no-store", headers });
expect(mocks.requestUrl.mock.calls[0][0]).toMatchObject({ url, method, headers });
expect(sentHeaders().has("pragma")).toBe(false);
});
it("uses the effective method from the options", async () => {
await createService({}).nativeFetch(new Request(url, { cache: "no-store" }), { method: "POST" });
expect(sentHeaders().has("cache-control")).toBe(false);
expect(sentHeaders().has("pragma")).toBe(false);
});
});
+21 -7
View File
@@ -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);