mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
Merge prerequisite changes into Japanese localisation
This commit is contained in:
commit
49120cf5e0
98 files changed
+8487
-1233
No files matched your search
@@ -81,6 +81,10 @@ function createBufferedLog(entries: ReactiveSource<LogEntry[]>) {
|
||||
};
|
||||
}
|
||||
|
||||
function logToConsole(message: unknown, level?: number, key?: string) {
|
||||
console.log({ message, level, key });
|
||||
}
|
||||
|
||||
// Legacy Logger calls use this shared buffer; service logs use their owner's buffer.
|
||||
const recentGlobalLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
setGlobalLogFunction(createBufferedLog(recentGlobalLogEntries));
|
||||
@@ -126,7 +130,7 @@ 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. */
|
||||
/** The host releases log display subscriptions after the complete asynchronous unload. */
|
||||
logResource!: OwnedResource<LogFunction>;
|
||||
private readonly recentLogEntries = reactiveSource<LogEntry[]>([]);
|
||||
private readonly displayLogEntries = () => {
|
||||
@@ -615,8 +619,10 @@ ${stringifyYaml(info)}
|
||||
}
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
const log = createBufferedLog(this.recentLogEntries);
|
||||
const unregisterLog = services.API.addLog.setHandler(log);
|
||||
const bufferedLog = createBufferedLog(this.recentLogEntries);
|
||||
let logTarget: LogFunction = bufferedLog;
|
||||
const log: LogFunction = (message, level, key) => logTarget(message, level, key);
|
||||
services.API.addLog.setHandler(log);
|
||||
const collectGlobalLogs = () => {
|
||||
const entries = recentGlobalLogEntries.value;
|
||||
if (entries.length === 0) return;
|
||||
@@ -624,7 +630,7 @@ ${stringifyYaml(info)}
|
||||
this.recentLogEntries.value = [...this.recentLogEntries.value, ...entries];
|
||||
};
|
||||
this.logResource = createOwnedResource(log, () => {
|
||||
unregisterLog();
|
||||
logTarget = logToConsole;
|
||||
recentGlobalLogEntries.offChanged(collectGlobalLogs);
|
||||
this.recentLogEntries.offChanged(this.displayLogEntries);
|
||||
this.recentLogEntries.value = [];
|
||||
|
||||
@@ -107,25 +107,92 @@ describe("ModuleLog service connection", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("detaches both service registration and legacy subscription on disposal", async () => {
|
||||
it("keeps service logging callable after disposal and sends late logs to the console", async () => {
|
||||
const { services, module, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
await module.logResource.dispose();
|
||||
try {
|
||||
expect(() => services.API.addLog("late-service", 32, "late-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledExactlyOnceWith({
|
||||
message: "late-service",
|
||||
level: 32,
|
||||
key: "late-key",
|
||||
});
|
||||
const cause = new Error("root cause");
|
||||
const error = Object.assign(new Error("late failure"), { cause });
|
||||
services.API.addLog(error, 128, "late-error");
|
||||
const errorEntry = consoleLog.mock.calls[1][0] as {
|
||||
message: Error & { cause?: unknown };
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
expect(errorEntry.message).toBe(error);
|
||||
expect(errorEntry.message.cause).toBe(cause);
|
||||
expect(errorEntry.level).toBe(128);
|
||||
expect(errorEntry.key).toBe("late-error");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("sends late circular service messages directly to the console", async () => {
|
||||
const { services, module, sink } = await createHarness();
|
||||
await services.appLifecycle.onSettingLoaded();
|
||||
sink.mockClear();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const circular: { self?: unknown } = {};
|
||||
circular.self = circular;
|
||||
|
||||
try {
|
||||
await module.logResource.dispose();
|
||||
expect(() => services.API.addLog(circular, 32, "circular")).not.toThrow();
|
||||
const consoleEntry = consoleLog.mock.calls[0][0] as {
|
||||
message: unknown;
|
||||
level?: number;
|
||||
key?: string;
|
||||
};
|
||||
expect(consoleEntry.message).toBe(circular);
|
||||
expect(consoleEntry.level).toBe(32);
|
||||
expect(consoleEntry.key).toBe("circular");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases legacy and UI subscriptions while retaining late service console logging", 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();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
|
||||
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();
|
||||
try {
|
||||
await first.module.logResource.dispose();
|
||||
await first.module.logResource.dispose();
|
||||
expect(() => first.services.API.addLog("retired", 32, "retired-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "retired",
|
||||
level: 32,
|
||||
key: "retired-key",
|
||||
});
|
||||
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();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a replacement service handler registered when the old owner is disposed", async () => {
|
||||
@@ -143,20 +210,31 @@ describe("ModuleLog service connection", () => {
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases a connection which never reached the settings-loaded phase", async () => {
|
||||
it("retires 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 consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
try {
|
||||
await module.logResource.dispose();
|
||||
Logger("after-disposal");
|
||||
expect(() => services.API.addLog("retired", 32, "retired-key")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "retired",
|
||||
level: 32,
|
||||
key: "retired-key",
|
||||
});
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
|
||||
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();
|
||||
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");
|
||||
expect(messages).not.toContain("retired");
|
||||
await replacement.module.logResource.dispose();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["complete", "fail"] as const)(
|
||||
@@ -219,10 +297,20 @@ describe("ModuleLog service connection", () => {
|
||||
"moduleLiveSyncMain.logUnloadingPlugin"
|
||||
);
|
||||
}
|
||||
expect(() => services.API.addLog("retired")).toThrow("not assigned");
|
||||
sink.mockClear();
|
||||
Logger("after-unload");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
try {
|
||||
expect(() => services.API.addLog("late-after-close", 32, "late-after-close")).not.toThrow();
|
||||
expect(consoleLog).toHaveBeenCalledWith({
|
||||
message: "late-after-close",
|
||||
level: 32,
|
||||
key: "late-after-close",
|
||||
});
|
||||
sink.mockClear();
|
||||
Logger("after-unload");
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { SettingSpecGroup } from "./SettingSpec.ts";
|
||||
|
||||
export type AdvancedSettingSpecContext = {
|
||||
@@ -24,18 +23,6 @@ export function createAdvancedSettingSpecGroups({
|
||||
},
|
||||
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
|
||||
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
|
||||
{
|
||||
key: "pathNormalizationVersion",
|
||||
holdValue: true,
|
||||
control: {
|
||||
type: "numeric-dropdown",
|
||||
options: () => ({
|
||||
"0": $msg("Legacy path identity"),
|
||||
"1": $msg("Unicode NFC path identity"),
|
||||
}),
|
||||
},
|
||||
visible: isCouchDB,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ export type GeneralSettingSpecContext = {
|
||||
showVerboseLog: () => boolean;
|
||||
};
|
||||
|
||||
/** Build the shared Appearance and Logging controls. */
|
||||
/** Build the shared General Settings controls. */
|
||||
export function createGeneralSettingSpecGroups({
|
||||
showEditorStatusDetails,
|
||||
showVerboseLog,
|
||||
@@ -61,6 +61,22 @@ export function createGeneralSettingSpecGroups({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "File identity",
|
||||
items: [
|
||||
{
|
||||
key: "pathNormalizationVersion",
|
||||
holdValue: true,
|
||||
control: {
|
||||
type: "numeric-dropdown",
|
||||
options: () => ({
|
||||
"0": $msg("Legacy path identity"),
|
||||
"1": $msg("Unicode NFC path identity"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -319,7 +319,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
).toEqual(["Copy the current settings to a Setup URI", "Show QR code"]);
|
||||
});
|
||||
|
||||
it("keeps Appearance, Logging, and Extra menus inside General Settings", () => {
|
||||
it("keeps Appearance, Logging, File identity, and Extra menus inside General Settings", () => {
|
||||
const tab = createSettingsTab();
|
||||
const definitions = tab.getSettingDefinitions();
|
||||
const general = definitions.find(
|
||||
@@ -332,7 +332,19 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
(item): item is SettingDefinitionPage => isPage(item) && item.name === "🎚️ Extra menus"
|
||||
);
|
||||
|
||||
expect(generalPages?.map(({ name }) => name)).toEqual(["🎨 Appearance", "📝 Logging", "🎚️ Extra menus"]);
|
||||
expect(generalPages?.map(({ name }) => name)).toEqual([
|
||||
"🎨 Appearance",
|
||||
"📝 Logging",
|
||||
"🗂️ File identity",
|
||||
"🎚️ Extra menus",
|
||||
]);
|
||||
const fileIdentity = generalPages?.find(({ name }) => name === "🗂️ File identity");
|
||||
expect(
|
||||
fileIdentity?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
|
||||
).toEqual(["pathNormalizationVersion"]);
|
||||
expect(
|
||||
fileIdentity?.items?.filter((item) => "action" in item && typeof item.action === "function")
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
appearance?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
|
||||
).toEqual([
|
||||
@@ -390,7 +402,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
const tab = createSettingsTab();
|
||||
const pages = collectPages(tab.getSettingDefinitions());
|
||||
|
||||
expect(pages).toHaveLength(14);
|
||||
expect(pages).toHaveLength(15);
|
||||
expect(pages.map(({ name }) => name)).toEqual(
|
||||
expect.arrayContaining(
|
||||
createSettingsPageCatalogue()
|
||||
|
||||
@@ -892,9 +892,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true),
|
||||
showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false),
|
||||
});
|
||||
const [appearance, logging] = groups;
|
||||
if (!appearance || !logging) {
|
||||
throw new Error("General settings must define Appearance and Logging groups");
|
||||
const [appearance, logging, fileIdentity] = groups;
|
||||
if (!appearance || !logging || !fileIdentity) {
|
||||
throw new Error("General settings must define Appearance, Logging, and File identity groups");
|
||||
}
|
||||
return this.createRootGroup("general-settings", [
|
||||
{
|
||||
@@ -907,6 +907,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
name: `📝 ${logging.heading}`,
|
||||
items: logging.items,
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
name: `🗂️ ${fileIdentity.heading}`,
|
||||
items: [this.createRebuildRequiredAction(), ...(fileIdentity.items ?? [])],
|
||||
},
|
||||
this.createExtraMenusPage(),
|
||||
]);
|
||||
}
|
||||
@@ -1128,7 +1133,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
if (method === "remoteOnly" && this.isDirty("pathNormalizationVersion")) {
|
||||
Logger(
|
||||
$msg(
|
||||
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
|
||||
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
|
||||
@@ -581,11 +581,11 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
|
||||
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
|
||||
expect(vi.mocked($msg)).toHaveBeenCalledWith(
|
||||
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
|
||||
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first."
|
||||
);
|
||||
expect(loggerMocks.Logger).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices."
|
||||
"Rebuild from a complete authoritative Vault and Fetch on the other devices."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
@@ -692,6 +692,30 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
return { tab, saveSettingData };
|
||||
}
|
||||
|
||||
it("includes path identity once in the declarative General settings group", () => {
|
||||
const { tab } = createSettingsTab();
|
||||
const group = (
|
||||
tab as unknown as {
|
||||
createGeneralSettingsGroup: () => {
|
||||
items: Array<{
|
||||
name: string;
|
||||
items?: Array<{ control?: { key: string }; action?: () => void; visible?: () => boolean }>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).createGeneralSettingsGroup();
|
||||
const fileIdentity = group.items.find(({ name }) => name === "🗂️ File identity");
|
||||
|
||||
expect(fileIdentity).toBeDefined();
|
||||
const apply = fileIdentity?.items?.find(({ action }) => typeof action === "function");
|
||||
expect(apply).toBeDefined();
|
||||
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
|
||||
expect(apply?.visible?.()).toBe(true);
|
||||
expect(fileIdentity?.items?.flatMap(({ control }) => (control ? [control.key] : []))).toEqual([
|
||||
"pathNormalizationVersion",
|
||||
]);
|
||||
});
|
||||
|
||||
it("awaits the stored passphrase before initialising the editing buffer", async () => {
|
||||
const { tab } = createSettingsTab();
|
||||
tab.resetEditingSettings();
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
|
||||
};
|
||||
|
||||
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
|
||||
|
||||
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
|
||||
"Memory cache",
|
||||
@@ -56,7 +56,6 @@ describe("paneAdvanced", () => {
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Local Database Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
panelElements.get("Transfer Tweak"),
|
||||
@@ -69,7 +68,6 @@ describe("paneAdvanced", () => {
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
"concurrencyOfReadChunksOnline",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { paneGeneral } from "./PaneGeneral.ts";
|
||||
|
||||
const settingHarness = vi.hoisted(() => ({
|
||||
rendered: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (key: string) => key,
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class LiveSyncSetting {},
|
||||
}));
|
||||
|
||||
vi.mock("./SettingSpec.ts", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("./SettingSpec.ts")>();
|
||||
return {
|
||||
...original,
|
||||
renderLegacySettingSpec: vi.fn((_renderer: unknown, spec: unknown) => {
|
||||
settingHarness.rendered.push(spec);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
settingHarness.rendered.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("paneGeneral", () => {
|
||||
it("renders path identity once in the File identity panel and preserves its held value", async () => {
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) =>
|
||||
Promise.resolve({ heading } as unknown as HTMLElement)
|
||||
);
|
||||
const host = {
|
||||
isConfiguredAs: vi.fn(() => false),
|
||||
};
|
||||
|
||||
paneGeneral.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(14));
|
||||
|
||||
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
|
||||
"obsidianLiveSyncSettingTab.titleAppearance",
|
||||
"obsidianLiveSyncSettingTab.titleLogging",
|
||||
"File identity",
|
||||
"obsidianLiveSyncSettingTab.titleExtraMenus",
|
||||
]);
|
||||
const pathIdentity = settingHarness.rendered.filter(
|
||||
(spec) => (spec as { key: string }).key === "pathNormalizationVersion"
|
||||
);
|
||||
expect(pathIdentity).toHaveLength(1);
|
||||
expect(pathIdentity[0]).toMatchObject({ holdValue: true });
|
||||
expect((pathIdentity[0] as { visible?: () => boolean }).visible).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/comm
|
||||
import { FlagFilesHumanReadable, FlagFilesOriginal } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { isLiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { StoppedJournal } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import { visibleOnly, type PageFunctions } from "./SettingPane";
|
||||
@@ -151,6 +153,80 @@ export function paneMaintenance(
|
||||
)
|
||||
.addOnUpdate(this.onlyOnCouchDB);
|
||||
|
||||
// Offer manual recovery after Journal receipt stops on a failure. Normal retries
|
||||
// revisit the same Journal; this action confirms and skips only that Journal,
|
||||
// resumes receipt, and still stops at the next Journal failure.
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Skip stopped Journal and resume"))
|
||||
.setDesc(
|
||||
$msg(
|
||||
"Object Storage only. Confirm to skip the currently stopped Journal and resume replication. The confirmation names the exact Journal key and failure stage. Skipping may leave content or history missing; the next Journal failure still stops replication."
|
||||
)
|
||||
)
|
||||
.addButton((button) =>
|
||||
setButtonDestructiveState(button)
|
||||
.setButtonText($msg("Skip and resume"))
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
const replicator = this.core.replicator;
|
||||
if (!isLiveSyncJournalReplicator(replicator)) {
|
||||
Logger(
|
||||
$msg(
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
const stoppedJournal: Readonly<StoppedJournal> | undefined = replicator.getStoppedJournal();
|
||||
if (!stoppedJournal) {
|
||||
Logger(
|
||||
$msg(
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Skip stopped Journal '${KEY}' at failure stage '${STAGE}' and resume replication? This may leave content or history missing.",
|
||||
{ KEY: stoppedJournal.key, STAGE: stoppedJournal.stage }
|
||||
),
|
||||
{
|
||||
title: $msg("Skip stopped Journal and resume"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
if (this.core.replicator !== replicator) {
|
||||
Logger(
|
||||
$msg(
|
||||
"The active replicator changed. Retry replication to identify the stopped Journal."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resumed = await replicator.skipStoppedJournalAndResume(
|
||||
this.core.settings,
|
||||
stoppedJournal
|
||||
);
|
||||
if (!resumed) {
|
||||
Logger(
|
||||
$msg(
|
||||
"Journal receipt did not complete after this action. Check the current failure before retrying."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
|
||||
new Setting(paneEl)
|
||||
.setName($msg("Reset journal received history"))
|
||||
.setDesc(
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const maintenanceHarness = vi.hoisted(() => ({
|
||||
createdSettings: [] as Array<{ name: string; click?: () => Promise<void> }>,
|
||||
createdSettings: [] as Array<{
|
||||
name: string;
|
||||
click?: () => Promise<void>;
|
||||
toggleKey?: string;
|
||||
onUpdate?: () => { visibility?: boolean };
|
||||
}>,
|
||||
logger: vi.fn(),
|
||||
notice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
@@ -10,7 +16,19 @@ vi.mock("@/common/events.ts", () => ({
|
||||
eventHub: { emitEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
$msg: (message: string, replacements?: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(replacements ?? {})) {
|
||||
message = message.split(String.fromCharCode(36) + "{" + key + "}").join(String(value));
|
||||
}
|
||||
return message;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Notice: class {
|
||||
constructor(message: string) {
|
||||
maintenanceHarness.notice(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/serviceFeatures/setupObsidian/settingsReset.ts", () => ({
|
||||
createCoreSettingsAfterFullReset: vi.fn(),
|
||||
@@ -33,10 +51,19 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", () => ({
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => {
|
||||
class LiveSyncJournalReplicator {}
|
||||
return {
|
||||
LiveSyncJournalReplicator,
|
||||
isLiveSyncJournalReplicator: (value: unknown) => value instanceof LiveSyncJournalReplicator,
|
||||
};
|
||||
});
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
name = "";
|
||||
click?: () => Promise<void>;
|
||||
toggleKey?: string;
|
||||
onUpdate?: () => { visibility?: boolean };
|
||||
|
||||
constructor() {
|
||||
maintenanceHarness.createdSettings.push(this);
|
||||
@@ -51,6 +78,11 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireToggle(key: string) {
|
||||
this.toggleKey = key;
|
||||
return this;
|
||||
}
|
||||
|
||||
addButton(callback: (button: this) => void) {
|
||||
callback(this);
|
||||
return this;
|
||||
@@ -73,7 +105,8 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
return this;
|
||||
}
|
||||
|
||||
addOnUpdate() {
|
||||
addOnUpdate(onUpdate: () => { visibility?: boolean }) {
|
||||
this.onUpdate = onUpdate;
|
||||
return this;
|
||||
}
|
||||
},
|
||||
@@ -85,14 +118,61 @@ vi.mock("./settingComponentStyles.ts", () => ({
|
||||
setButtonDestructiveState: <T>(button: T) => button,
|
||||
}));
|
||||
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { paneMaintenance } from "./PaneMaintenance.ts";
|
||||
|
||||
afterEach(() => {
|
||||
maintenanceHarness.createdSettings.length = 0;
|
||||
maintenanceHarness.logger.mockClear();
|
||||
maintenanceHarness.notice.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
type TestStoppedJournal = Readonly<{ key: string; stage: string }>;
|
||||
type ConfirmationOptions = { title: string; defaultOption: "No" };
|
||||
type AskYesNoDialog = (message: string, options: ConfirmationOptions) => Promise<"yes" | "no">;
|
||||
|
||||
function createJournalReplicator(stoppedJournal: TestStoppedJournal | undefined, result = true) {
|
||||
const getStoppedJournal = vi.fn(() => stoppedJournal);
|
||||
const skipStoppedJournalAndResume = vi.fn(async (_settings: unknown, _snapshot: unknown) => result);
|
||||
const replicator = Object.assign(new LiveSyncJournalReplicator({} as never), {
|
||||
getStoppedJournal,
|
||||
skipStoppedJournalAndResume,
|
||||
});
|
||||
return { replicator, getStoppedJournal, skipStoppedJournalAndResume };
|
||||
}
|
||||
|
||||
function createSyncingPane(replicator: unknown, askYesNoDialog: AskYesNoDialog = async () => "no") {
|
||||
const settings = { databaseName: "test-database" };
|
||||
const onlyOnMinIO = vi.fn(() => ({ visibility: true }));
|
||||
const confirmation = vi.fn(askYesNoDialog);
|
||||
const host = {
|
||||
core: { replicator, settings, confirm: { askYesNoDialog: confirmation } },
|
||||
createEl: vi.fn(),
|
||||
getMinioJournalSyncClient: vi.fn(),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO,
|
||||
services: { replication: { markLocked: vi.fn(), markUnlocked: vi.fn() } },
|
||||
};
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Syncing") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
|
||||
const setting = maintenanceHarness.createdSettings.find(({ name }) => name === "Skip stopped Journal and resume");
|
||||
if (!setting?.click) {
|
||||
throw new Error("Skip stopped Journal action was not registered");
|
||||
}
|
||||
return { setting, click: setting.click, host, settings, askYesNoDialog: confirmation, onlyOnMinIO };
|
||||
}
|
||||
|
||||
describe("paneMaintenance", () => {
|
||||
it("does not announce success when the remote wipe reports failure", async () => {
|
||||
const updateCheckPointInfo = vi.fn(async () => undefined);
|
||||
@@ -180,9 +260,145 @@ describe("paneMaintenance", () => {
|
||||
|
||||
expect(resetDatabase).toHaveBeenCalledOnce();
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", "notice");
|
||||
});
|
||||
|
||||
it("removes the broken journal toggle and preserves received history reset semantics", async () => {
|
||||
const checkpoint = {
|
||||
receivedFiles: new Set(["received-file"]),
|
||||
knownIDs: new Set(["known-id"]),
|
||||
sentIDs: new Set(["sent-id"]),
|
||||
sentFiles: new Set(["sent-file"]),
|
||||
lastLocalSeq: 23,
|
||||
};
|
||||
let updatedCheckpoint: typeof checkpoint | undefined;
|
||||
const updateCheckPointInfo = vi.fn(async (update: (info: typeof checkpoint) => typeof checkpoint) => {
|
||||
updatedCheckpoint = update(checkpoint);
|
||||
});
|
||||
const onlyOnMinIO = vi.fn(() => ({ visibility: true }));
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Syncing") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
const host = {
|
||||
core: { replicator: {}, storageAccess: {} },
|
||||
createEl: vi.fn(),
|
||||
getMinioJournalSyncClient: vi.fn(() => ({ updateCheckPointInfo })),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO,
|
||||
services: { replication: { markLocked: vi.fn(), markUnlocked: vi.fn() } },
|
||||
};
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
|
||||
expect(maintenanceHarness.createdSettings.some(({ toggleKey }) => toggleKey === "ignoreBrokenJournals")).toBe(
|
||||
false
|
||||
);
|
||||
const resetReceivedHistory = maintenanceHarness.createdSettings.find(
|
||||
({ name }) => name === "Reset journal received history"
|
||||
);
|
||||
if (!resetReceivedHistory?.click) {
|
||||
throw new Error("Reset journal received history action was not registered");
|
||||
}
|
||||
await resetReceivedHistory.click();
|
||||
|
||||
expect(updateCheckPointInfo).toHaveBeenCalledOnce();
|
||||
expect(updatedCheckpoint).toEqual({
|
||||
...checkpoint,
|
||||
receivedFiles: new Set(),
|
||||
knownIDs: new Set(),
|
||||
});
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith("Journal received history has been cleared.", "notice");
|
||||
});
|
||||
|
||||
it("asks the user to retry when there is no stopped Journal", async () => {
|
||||
const { replicator, getStoppedJournal, skipStoppedJournalAndResume } = createJournalReplicator(undefined);
|
||||
const rendered = createSyncingPane(replicator);
|
||||
|
||||
expect(rendered.setting.onUpdate).toBe(rendered.onlyOnMinIO);
|
||||
await rendered.click();
|
||||
|
||||
expect(getStoppedJournal).toHaveBeenCalledOnce();
|
||||
expect(rendered.askYesNoDialog).not.toHaveBeenCalled();
|
||||
expect(skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
"No stopped Journal is available. Retry replication to identify the stopped Journal.",
|
||||
"notice"
|
||||
);
|
||||
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not skip the stopped Journal when confirmation is cancelled", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal);
|
||||
const rendered = createSyncingPane(replicator);
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(rendered.askYesNoDialog).toHaveBeenCalledOnce();
|
||||
const call = rendered.askYesNoDialog.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Stopped Journal confirmation was not requested");
|
||||
}
|
||||
const [message, options] = call;
|
||||
expect(message).toContain(stoppedJournal.key);
|
||||
expect(message).toContain(stoppedJournal.stage);
|
||||
expect(options).toEqual({ title: "Skip stopped Journal and resume", defaultOption: "No" });
|
||||
expect(skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips and resumes with the exact confirmed stopped Journal snapshot", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal);
|
||||
const confirm = vi.fn(async () => "yes" as const);
|
||||
const rendered = createSyncingPane(replicator, confirm);
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
expect(skipStoppedJournalAndResume.mock.calls[0]?.[0]).toBe(rendered.settings);
|
||||
expect(skipStoppedJournalAndResume.mock.calls[0]?.[1]).toBe(stoppedJournal);
|
||||
});
|
||||
|
||||
it("does not skip after the active replicator changes during confirmation", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const active = createJournalReplicator(stoppedJournal);
|
||||
const replacement = createJournalReplicator(stoppedJournal);
|
||||
const confirm = vi.fn(async () => "yes" as const);
|
||||
const rendered = createSyncingPane(active.replicator, confirm);
|
||||
confirm.mockImplementation(async () => {
|
||||
rendered.host.core.replicator = replacement.replicator;
|
||||
return "yes";
|
||||
});
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(active.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(replacement.skipStoppedJournalAndResume).not.toHaveBeenCalled();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"The active replicator changed. Retry replication to identify the stopped Journal.",
|
||||
"notice"
|
||||
);
|
||||
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports when receipt does not complete after skip and resume", async () => {
|
||||
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
|
||||
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal, false);
|
||||
const rendered = createSyncingPane(replicator, async () => "yes");
|
||||
|
||||
await rendered.click();
|
||||
|
||||
expect(skipStoppedJournalAndResume).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Journal receipt did not complete after this action. Check the current failure before retrying.",
|
||||
"notice"
|
||||
);
|
||||
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ export function panePowerUsers(
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
});
|
||||
new Setting(paneEl).autoWireToggle("useTimeouts", { onUpdate: this.onlyOnCouchDB });
|
||||
new Setting(paneEl).autoWireToggle("useCouchDBCacheControlHeadersOnly", { onUpdate: this.onlyOnCouchDB });
|
||||
});
|
||||
void addPanel(paneEl, $msg("Configuration Encryption")).then((paneEl) => {
|
||||
const passphrase_options: Record<ConfigPassphraseStore, string> = {
|
||||
|
||||
@@ -17,7 +17,7 @@ const rangeMessages = {
|
||||
};
|
||||
|
||||
describe("Advanced setting specifications", () => {
|
||||
it("lists only the eleven controls currently exposed by the Advanced page", () => {
|
||||
it("lists only the ten controls currently exposed by the Advanced page", () => {
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
|
||||
|
||||
expect(groups.map(({ heading }) => heading)).toEqual([
|
||||
@@ -31,7 +31,6 @@ describe("Advanced setting specifications", () => {
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
"concurrencyOfReadChunksOnline",
|
||||
@@ -52,13 +51,12 @@ describe("Advanced setting specifications", () => {
|
||||
expect(spec.disabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
|
||||
it("keeps the four remote chunk controls CouchDB-only", () => {
|
||||
let couchDB = false;
|
||||
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
|
||||
const conditional = groups.flatMap(({ items }) => items).filter(({ visible }) => visible !== undefined);
|
||||
|
||||
expect(conditional.map(({ key }) => key)).toEqual([
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
"concurrencyOfReadChunksOnline",
|
||||
@@ -70,18 +68,67 @@ describe("Advanced setting specifications", () => {
|
||||
expect(conditional.every(({ visible }) => visible?.() === true)).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes CouchDB path identity as a two-choice numeric dropdown", () => {
|
||||
let couchDB = false;
|
||||
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB })
|
||||
it("excludes transient settings-dialogue keys from the persisted key type", () => {
|
||||
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
|
||||
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
|
||||
});
|
||||
|
||||
it("resolves translated Commonlib metadata for every explicitly exposed key", () => {
|
||||
const specs = [
|
||||
...createAdvancedSettingSpecGroups({ isCouchDB: () => true }),
|
||||
...createGeneralSettingSpecGroups({
|
||||
showEditorStatusDetails: () => true,
|
||||
showVerboseLog: () => true,
|
||||
}),
|
||||
].flatMap(({ items }) => items);
|
||||
|
||||
for (const spec of specs) {
|
||||
const metadata = getConfig(spec.key);
|
||||
expect(metadata, spec.key).not.toBe(false);
|
||||
if (!metadata) {
|
||||
throw new Error(`Missing setting metadata for ${spec.key}`);
|
||||
}
|
||||
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
|
||||
expect(definition.name).toBe(`${metadata.name}${statusDisplay(metadata.status)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("General setting specifications", () => {
|
||||
it("shares every General, Logging, and File identity control between the imperative and declarative renderers", () => {
|
||||
const groups = createGeneralSettingSpecGroups({
|
||||
showEditorStatusDetails: () => true,
|
||||
showVerboseLog: () => true,
|
||||
});
|
||||
|
||||
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging", "File identity"]);
|
||||
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
|
||||
"displayLanguage",
|
||||
"showStatusOnEditor",
|
||||
"showOnlyIconsOnEditor",
|
||||
"showStatusOnStatusbar",
|
||||
"hideFileWarningNotice",
|
||||
"syncGuardEnabled",
|
||||
"syncGuardMinimumFiles",
|
||||
"networkWarningStyle",
|
||||
"lessInformationInLog",
|
||||
"showVerboseLog",
|
||||
"pathNormalizationVersion",
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes path identity for every remote type as a reconstruction-required dropdown", () => {
|
||||
const spec = createGeneralSettingSpecGroups({
|
||||
showEditorStatusDetails: () => false,
|
||||
showVerboseLog: () => false,
|
||||
})
|
||||
.flatMap(({ items }) => items)
|
||||
.find(({ key }) => key === "pathNormalizationVersion");
|
||||
if (!spec) throw new Error("Path identity mode is not exposed on the Advanced page");
|
||||
if (!spec) throw new Error("Path identity mode is not exposed in General settings");
|
||||
if (spec.control.type !== "numeric-dropdown") throw new Error("Expected a numeric dropdown");
|
||||
|
||||
expect(spec.control).toMatchObject({ type: "numeric-dropdown", options: expect.any(Function) });
|
||||
expect(spec.visible?.()).toBe(false);
|
||||
couchDB = true;
|
||||
expect(spec.visible?.()).toBe(true);
|
||||
expect(spec).toMatchObject({ key: "pathNormalizationVersion", holdValue: true });
|
||||
expect(spec.visible).toBeUndefined();
|
||||
expect(spec.control.options()).toEqual({
|
||||
"0": "Legacy path identity",
|
||||
"1": "Unicode NFC path identity",
|
||||
@@ -102,54 +149,11 @@ describe("Advanced setting specifications", () => {
|
||||
key: "pathNormalizationVersion",
|
||||
options: {
|
||||
holdValue: true,
|
||||
onUpdate: expect.any(Function),
|
||||
options: { "0": "Legacy path identity", "1": "Unicode NFC path identity" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes transient settings-dialogue keys from the persisted key type", () => {
|
||||
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
|
||||
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
|
||||
});
|
||||
|
||||
it("resolves translated Commonlib metadata for every explicitly exposed key", () => {
|
||||
const specs = createAdvancedSettingSpecGroups({ isCouchDB: () => true }).flatMap(({ items }) => items);
|
||||
|
||||
for (const spec of specs) {
|
||||
const metadata = getConfig(spec.key);
|
||||
expect(metadata, spec.key).not.toBe(false);
|
||||
if (!metadata) {
|
||||
throw new Error(`Missing setting metadata for ${spec.key}`);
|
||||
}
|
||||
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
|
||||
expect(definition.name).toBe(`${metadata.name}${statusDisplay(metadata.status)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("General setting specifications", () => {
|
||||
it("shares every General and Logging control between the imperative and declarative renderers", () => {
|
||||
const groups = createGeneralSettingSpecGroups({
|
||||
showEditorStatusDetails: () => true,
|
||||
showVerboseLog: () => true,
|
||||
});
|
||||
|
||||
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging"]);
|
||||
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
|
||||
"displayLanguage",
|
||||
"showStatusOnEditor",
|
||||
"showOnlyIconsOnEditor",
|
||||
"showStatusOnStatusbar",
|
||||
"hideFileWarningNotice",
|
||||
"syncGuardEnabled",
|
||||
"syncGuardMinimumFiles",
|
||||
"networkWarningStyle",
|
||||
"lessInformationInLog",
|
||||
"showVerboseLog",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the three feature-level controls together under Extra menus", () => {
|
||||
const group = createExtraMenuSettingSpecGroup();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (key: string) => key,
|
||||
$t: (key: string) => key,
|
||||
translateLiveSyncMessage: (key: string) => key,
|
||||
}));
|
||||
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() }));
|
||||
@@ -20,6 +21,7 @@ vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
|
||||
|
||||
import {
|
||||
createAdvancedSettingDefinitionGroups,
|
||||
createGeneralSettingDefinitionGroups,
|
||||
createSettingsPageCatalogue,
|
||||
getSettingsRootGroupEntry,
|
||||
type SettingsRootGroupId,
|
||||
@@ -87,7 +89,6 @@ describe("settings page catalogue", () => {
|
||||
"chunkSplitterVersion",
|
||||
"customChunkSize",
|
||||
"strictContentCheckOnStartup",
|
||||
"pathNormalizationVersion",
|
||||
"readChunksOnline",
|
||||
"useOnlyLocalChunk",
|
||||
"concurrencyOfReadChunksOnline",
|
||||
@@ -97,4 +98,22 @@ describe("settings page catalogue", () => {
|
||||
]);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("registers path identity once in General settings and leaves it out of Advanced", () => {
|
||||
const generalGroups = createGeneralSettingDefinitionGroups({
|
||||
showEditorStatusDetails: () => true,
|
||||
showVerboseLog: () => true,
|
||||
});
|
||||
const generalKeys = generalGroups.flatMap(({ items = [] }) =>
|
||||
items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
|
||||
);
|
||||
const advancedKeys = createAdvancedSettingDefinitionGroups({ isCouchDB: () => true }).flatMap(
|
||||
({ items = [] }) =>
|
||||
items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
|
||||
);
|
||||
|
||||
expect(generalGroups.map(({ heading }) => heading)).toContain("File identity");
|
||||
expect(generalKeys.filter((key) => key === "pathNormalizationVersion")).toHaveLength(1);
|
||||
expect(advancedKeys).not.toContain("pathNormalizationVersion");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { createNewVaultSettings } from "@/common/newVaultSettings";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
type SetupInitialisationMode,
|
||||
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { resolveInitialPathIdentity } from "@/common/initialPathIdentity";
|
||||
import type { P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
|
||||
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
@@ -58,11 +60,14 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseImportedIdDerivationSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
function normaliseImportedIdentitySettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
// Setup URIs are complete imports even when their encoder omitted default-valued fields.
|
||||
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
|
||||
return {
|
||||
...settings,
|
||||
pathNormalizationVersion: Object.prototype.hasOwnProperty.call(settings, "pathNormalizationVersion")
|
||||
? settings.pathNormalizationVersion
|
||||
: 0,
|
||||
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
|
||||
? settings.idDerivationVersion
|
||||
: 0,
|
||||
@@ -233,7 +238,7 @@ export class SetupManager extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
|
||||
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdDerivationSettings(newSetting), userMode);
|
||||
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdentitySettings(newSetting), userMode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,6 +276,7 @@ export class SetupManager extends AbstractModule {
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_COUCHDB;
|
||||
}
|
||||
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
|
||||
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
@@ -302,10 +308,37 @@ export class SetupManager extends AbstractModule {
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_MINIO;
|
||||
}
|
||||
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
|
||||
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
private async selectInitialPathIdentity(
|
||||
settings: ObsidianLiveSyncSettings,
|
||||
userMode: UserMode,
|
||||
activate: boolean
|
||||
): Promise<boolean> {
|
||||
if (!activate || (userMode !== UserMode.NewUser && userMode !== UserMode.ExistingUser)) return true;
|
||||
try {
|
||||
const probe = await this.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
settings
|
||||
);
|
||||
settings.pathNormalizationVersion = await resolveInitialPathIdentity(
|
||||
probe,
|
||||
settings.pathNormalizationVersion
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this._log(
|
||||
"The remote path identity could not be inspected. Settings have not been applied.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for P2P
|
||||
* @param userMode
|
||||
@@ -345,10 +378,7 @@ export class SetupManager extends AbstractModule {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData
|
||||
>(
|
||||
SetupRemoteE2EE,
|
||||
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
|
||||
);
|
||||
>(SetupRemoteE2EE, { settings: currentSetting, newVault: userMode === UserMode.NewUser });
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
@@ -397,10 +427,7 @@ export class SetupManager extends AbstractModule {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteE2EEInitialData
|
||||
>(
|
||||
SetupRemoteE2EE,
|
||||
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
|
||||
);
|
||||
>(SetupRemoteE2EE, { settings: originalSetting, newVault: userMode === UserMode.NewUser });
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
@@ -545,7 +572,7 @@ export class SetupManager extends AbstractModule {
|
||||
async decodeQR(qr: string) {
|
||||
let newSettings: ObsidianLiveSyncSettings;
|
||||
try {
|
||||
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr));
|
||||
newSettings = normaliseImportedIdentitySettings(decodeSettingsFromQRCodeData(qr));
|
||||
} catch {
|
||||
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
|
||||
@@ -3,12 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { createNewVaultSettings } from "@/common/newVaultSettings";
|
||||
import type {
|
||||
P2PConnectionProbeAdmission,
|
||||
P2PConnectionProbeAdmissionResult,
|
||||
@@ -98,6 +99,12 @@ function createSetupManager() {
|
||||
dialogManager,
|
||||
},
|
||||
setting,
|
||||
replicator: {
|
||||
createRemoteResource: vi.fn(async () => ({
|
||||
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
})),
|
||||
},
|
||||
} as any;
|
||||
const core: any = {
|
||||
_services: services,
|
||||
@@ -165,6 +172,99 @@ describe("SetupManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([REMOTE_COUCHDB, REMOTE_MINIO])(
|
||||
"uses an existing remote declaration during first manual setup for %s",
|
||||
async (remoteType) => {
|
||||
const { manager, core, dialogManager } = createSetupManager();
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
core.services.replicator.createRemoteResource.mockResolvedValue({
|
||||
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
|
||||
dispose,
|
||||
});
|
||||
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValue({
|
||||
couchDB_URI: "https://example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
couchDB_USER: "user",
|
||||
couchDB_PASSWORD: "pass",
|
||||
endpoint: "https://objects.test",
|
||||
bucket: "vault",
|
||||
accessKey: "key",
|
||||
secretKey: "secret",
|
||||
region: "auto",
|
||||
bucketPrefix: "",
|
||||
});
|
||||
const initial = { ...createLegacyRemoteSetting(), pathNormalizationVersion: 1 } as ObsidianLiveSyncSettings;
|
||||
if (remoteType === REMOTE_COUCHDB) await manager.onCouchDBManualSetup(UserMode.NewUser, initial);
|
||||
else await manager.onBucketManualSetup(UserMode.NewUser, initial);
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pathNormalizationVersion: 0 }),
|
||||
UserMode.NewUser,
|
||||
true
|
||||
);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(initial.pathNormalizationVersion).toBe(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[{ status: "empty" }, 1],
|
||||
[{ status: "configured", pathNormalizationVersion: 1 }, 1],
|
||||
] as const)("uses inspected setup identity %s", async (inspection, expected) => {
|
||||
const { manager, core, dialogManager } = createSetupManager();
|
||||
core.services.replicator.createRemoteResource.mockResolvedValue({
|
||||
inspectPathIdentity: vi.fn(async () => inspection),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
});
|
||||
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValue({
|
||||
couchDB_URI: "https://example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
});
|
||||
await manager.onCouchDBManualSetup(UserMode.NewUser, createNewVaultSettings());
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pathNormalizationVersion: expected }),
|
||||
UserMode.NewUser,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply setup settings when identity inspection fails", async () => {
|
||||
const { manager, core, dialogManager, setting } = createSetupManager();
|
||||
const failure = new Error("remote unavailable");
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
core.services.replicator.createRemoteResource.mockResolvedValue({
|
||||
inspectPathIdentity: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
dispose,
|
||||
});
|
||||
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValue({
|
||||
couchDB_URI: "https://example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
});
|
||||
await expect(manager.onCouchDBManualSetup(UserMode.NewUser, createNewVaultSettings())).resolves.toBe(false);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(setting.saveSettingData).not.toHaveBeenCalled();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[UserMode.Update, true],
|
||||
[UserMode.NewUser, false],
|
||||
] as const)("keeps identity during profile edits in mode %s with activation %s", async (mode, activate) => {
|
||||
const { manager, core, dialogManager } = createSetupManager();
|
||||
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValue({
|
||||
couchDB_URI: "https://example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
});
|
||||
await manager.onCouchDBManualSetup(mode, createLegacyRemoteSetting(), activate);
|
||||
expect(core.services.replicator.createRemoteResource).not.toHaveBeenCalled();
|
||||
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ pathNormalizationVersion: 0 }), mode, activate);
|
||||
});
|
||||
|
||||
it("starts manual new-user setup from the recommended new-Vault settings", async () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
@@ -192,12 +292,13 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
|
||||
it("compatibility: treats omitted ID derivation fields in a Setup URI as legacy defaults", async () => {
|
||||
it("compatibility: treats omitted identity fields in a Setup URI as legacy defaults", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const savedKey = "12".repeat(32);
|
||||
setting.settings = {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
pathNormalizationVersion: 1,
|
||||
idDerivationVersion: 1,
|
||||
idDerivationKey: savedKey,
|
||||
};
|
||||
@@ -205,6 +306,7 @@ describe("SetupManager", () => {
|
||||
...createLegacyRemoteSetting(),
|
||||
isConfigured: true,
|
||||
} as Partial<ObsidianLiveSyncSettings>;
|
||||
delete imported.pathNormalizationVersion;
|
||||
delete imported.idDerivationVersion;
|
||||
delete imported.idDerivationKey;
|
||||
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
|
||||
@@ -213,6 +315,7 @@ describe("SetupManager", () => {
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
|
||||
|
||||
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
|
||||
expect(mergedSettings.pathNormalizationVersion).toBe(0);
|
||||
expect(mergedSettings.idDerivationVersion).toBe(0);
|
||||
expect(mergedSettings.idDerivationKey).toBe("");
|
||||
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
|
||||
@@ -489,44 +592,47 @@ describe("SetupManager", () => {
|
||||
it.each([
|
||||
{ importedSize: 0, expectedSize: 0 },
|
||||
{ importedSize: undefined, expectedSize: 60 },
|
||||
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
couch: {
|
||||
id: "couch",
|
||||
name: "Office CouchDB",
|
||||
uri: "sls+https://alice:secret@couch.example/?db=notes",
|
||||
isEncrypted: false,
|
||||
])(
|
||||
"preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize",
|
||||
async ({ importedSize, expectedSize }) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
couch: {
|
||||
id: "couch",
|
||||
name: "Office CouchDB",
|
||||
uri: "sls+https://alice:secret@couch.example/?db=notes",
|
||||
isEncrypted: false,
|
||||
},
|
||||
archive: {
|
||||
id: "archive",
|
||||
name: "Archive bucket",
|
||||
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive®ion=auto",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
archive: {
|
||||
id: "archive",
|
||||
name: "Archive bucket",
|
||||
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive®ion=auto",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "archive",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
|
||||
else imported.customChunkSize = importedSize;
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(imported)
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
activeConfigurationId: "archive",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
|
||||
else imported.customChunkSize = importedSize;
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(imported)
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(expectedSize);
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
});
|
||||
const current = setting.currentSettings();
|
||||
expect(current.customChunkSize).toBe(expectedSize);
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
|
||||
Reference in new issue
Block a user