mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-26 11:03:21 +00:00
Merge main for startup integrity follow-up
This commit is contained in:
commit
a3b55ed439
104 files changed
+6010
-678
No files matched your search
@@ -44,17 +44,17 @@ export class CompatibilityReviewController {
|
||||
return this._initialised;
|
||||
}
|
||||
|
||||
private readAcknowledgedVersion(): string | null {
|
||||
private async readAcknowledgedVersion(): Promise<string | null> {
|
||||
const setting = this.core.services.setting;
|
||||
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
|
||||
const currentMarker = await this.core.services.config.getValue(DATABASE_COMPATIBILITY_VERSION_KEY, "");
|
||||
if (currentMarker) return currentMarker;
|
||||
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
const legacyMarker = setting.getDeviceLocalConfig(legacyKey);
|
||||
const legacyMarker = await setting.deviceValues.getValue(legacyKey, "");
|
||||
if (!legacyMarker) return null;
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
|
||||
await setting.deviceValues.delete(legacyKey);
|
||||
return legacyMarker;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class CompatibilityReviewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acknowledgedVersion = this.readAcknowledgedVersion();
|
||||
const acknowledgedVersion = await this.readAcknowledgedVersion();
|
||||
const evaluation = evaluateCompatibilityPause({
|
||||
acknowledgedVersion,
|
||||
currentVersion: this.currentVersion,
|
||||
@@ -85,7 +85,7 @@ export class CompatibilityReviewController {
|
||||
|
||||
this.pause = evaluation.pause;
|
||||
if (evaluation.initialiseAcknowledgedVersion) {
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
this._initialised = true;
|
||||
return true;
|
||||
}
|
||||
@@ -115,9 +115,9 @@ export class CompatibilityReviewController {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
await this.core.services.config.setValue(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
|
||||
setting.deleteDeviceLocalConfig(legacyKey);
|
||||
await setting.deviceValues.delete(legacyKey);
|
||||
this.pause = undefined;
|
||||
this.ui.clearReminder();
|
||||
await this.core.services.control.applySettings();
|
||||
|
||||
@@ -33,13 +33,32 @@ function createFixture(
|
||||
} = {}
|
||||
) {
|
||||
const local = new Map<string, string>();
|
||||
const deviceValues = new Map<string, string>();
|
||||
if (options.marker !== undefined && options.marker !== null) {
|
||||
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
|
||||
}
|
||||
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
|
||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||
local.set(legacyKey, options.legacyMarker);
|
||||
deviceValues.set(legacyKey, options.legacyMarker);
|
||||
}
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
local.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
local.delete(key);
|
||||
}),
|
||||
};
|
||||
const deviceValueStore = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => deviceValues.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
deviceValues.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
deviceValues.delete(key);
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
versionUpFlash: options.versionUpFlash ?? "",
|
||||
isConfigured: options.isConfigured ?? true,
|
||||
@@ -49,15 +68,13 @@ function createFixture(
|
||||
const setting = {
|
||||
currentSettings: () => settings,
|
||||
getSettingsMigrationState: () => migrationState(options.migration),
|
||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
||||
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
|
||||
deleteDeviceLocalConfig: (key: string) => local.delete(key),
|
||||
deviceValues: deviceValueStore,
|
||||
saveSettingData,
|
||||
};
|
||||
const core = {
|
||||
services: {
|
||||
setting,
|
||||
config,
|
||||
vault: { getVaultName: () => "Test Vault" },
|
||||
control: { applySettings },
|
||||
},
|
||||
@@ -69,7 +86,18 @@ function createFixture(
|
||||
clearReminder: vi.fn(),
|
||||
};
|
||||
const controller = new CompatibilityReviewController(core, ui, 12);
|
||||
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings };
|
||||
return {
|
||||
controller,
|
||||
ui,
|
||||
local,
|
||||
deviceValues,
|
||||
config,
|
||||
deviceValueStore,
|
||||
legacyKey,
|
||||
settings,
|
||||
saveSettingData,
|
||||
applySettings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compatibility review controller", () => {
|
||||
@@ -196,10 +224,96 @@ describe("compatibility review controller", () => {
|
||||
await fixture.controller.initialise();
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.local.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
});
|
||||
|
||||
it("saves the scoped marker before deleting the legacy device value", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||
await saving;
|
||||
fixture.local.set(key, value);
|
||||
});
|
||||
|
||||
const initialising = fixture.controller.initialise();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||
expect(fixture.deviceValueStore.delete).not.toHaveBeenCalled();
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
|
||||
finishSave();
|
||||
await initialising;
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.deviceValues.has(fixture.legacyKey)).toBe(false);
|
||||
expect(fixture.controller.initialised).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a failed marker read as an absent value", async () => {
|
||||
const fixture = createFixture({ legacyMarker: "11" });
|
||||
fixture.config.getValue.mockRejectedValueOnce(new Error("marker read failed"));
|
||||
|
||||
await expect(fixture.controller.initialise()).rejects.toThrow("marker read failed");
|
||||
|
||||
expect(fixture.deviceValueStore.getValue).not.toHaveBeenCalled();
|
||||
expect(fixture.deviceValues.get(fixture.legacyKey)).toBe("11");
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the compatibility pause blocked until its async marker write succeeds", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
fixture.config.setValue.mockImplementation(async (key, value) => {
|
||||
await saving;
|
||||
fixture.local.set(key, value);
|
||||
});
|
||||
|
||||
const reviewing = fixture.controller.openReview();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
|
||||
finishSave();
|
||||
await reviewing;
|
||||
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a storage-failed acknowledgement blocked and retryable", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
await fixture.controller.initialise();
|
||||
fixture.config.setValue.mockRejectedValueOnce(new Error("marker write failed"));
|
||||
|
||||
await expect(fixture.controller.openReview()).rejects.toThrow("marker write failed");
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JoinedDevice, JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { JoinedDevicesView } from "./joinedDevicesDialogTypes";
|
||||
|
||||
/** Compare recognised SemVer values; unknown development labels have no ordering. */
|
||||
export function comparePluginVersions(left: string, right: string): number | undefined {
|
||||
const parse = (value: string) => {
|
||||
const match =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
||||
value
|
||||
);
|
||||
if (!match) return undefined;
|
||||
const prerelease = match[4]?.split(".");
|
||||
if (prerelease?.some((part) => /^0\d+$/.test(part))) return undefined;
|
||||
return { core: match.slice(1, 4).map(BigInt), prerelease };
|
||||
};
|
||||
const a = parse(left);
|
||||
const b = parse(right);
|
||||
if (!a || !b) return undefined;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
|
||||
}
|
||||
if (!a.prerelease || !b.prerelease) return a.prerelease ? -1 : b.prerelease ? 1 : 0;
|
||||
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
|
||||
const x = a.prerelease[i];
|
||||
const y = b.prerelease[i];
|
||||
if (x === undefined) return -1;
|
||||
if (y === undefined) return 1;
|
||||
if (x === y) continue;
|
||||
const xn = /^\d+$/.test(x);
|
||||
const yn = /^\d+$/.test(y);
|
||||
if (xn && yn) return BigInt(x) > BigInt(y) ? 1 : -1;
|
||||
if (xn !== yn) return xn ? -1 : 1;
|
||||
return x > y ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function joinedDevicesView(
|
||||
snapshot: JoinedDevicesSnapshot,
|
||||
self: Omit<JoinedDevice, "accepted">
|
||||
): JoinedDevicesView {
|
||||
const ownRecord = snapshot.devices.find((device) => device.id === self.id);
|
||||
const ownDevice: JoinedDevice = { ...ownRecord, ...self, accepted: ownRecord?.accepted ?? false };
|
||||
return {
|
||||
...snapshot,
|
||||
devices: [ownDevice, ...snapshot.devices.filter((device) => device.id !== self.id)].map((device) => ({
|
||||
...device,
|
||||
self: device.id === self.id,
|
||||
lockedOut: snapshot.locked && !device.accepted,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function differentDeviceVersions(snapshot: JoinedDevicesSnapshot, ownId: string, ownVersion: string) {
|
||||
const newer = new Set<string>();
|
||||
const older = new Set<string>();
|
||||
for (const device of snapshot.devices) {
|
||||
if (device.id === ownId || !device.pluginVersion) continue;
|
||||
const comparison = comparePluginVersions(device.pluginVersion, ownVersion);
|
||||
if (comparison !== undefined && comparison > 0) newer.add(device.pluginVersion);
|
||||
if (comparison !== undefined && comparison < 0) older.add(device.pluginVersion);
|
||||
}
|
||||
return { newer: [...newer].sort(), older: [...older].sort() };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { comparePluginVersions, differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
|
||||
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const empty: JoinedDevicesSnapshot = { milestonePresent: false, locked: false, cleaned: false, devices: [] };
|
||||
|
||||
describe("joined device presentation", () => {
|
||||
it("always shows this device first, including before its remote record exists", () => {
|
||||
const view = joinedDevicesView(
|
||||
{ ...empty, devices: [{ id: "another", accepted: true }] },
|
||||
{ id: "self", platform: "ios", pluginVersion: "1.0.39" }
|
||||
);
|
||||
expect(view.devices.map((device) => device.id)).toEqual(["self", "another"]);
|
||||
expect(view.devices[0]).toMatchObject({ self: true, accepted: false, lockedOut: false, platform: "ios" });
|
||||
});
|
||||
it("keeps the remote last-recorded date and acceptance while showing current local versions", () => {
|
||||
const view = joinedDevicesView(
|
||||
{
|
||||
...empty,
|
||||
milestonePresent: true,
|
||||
locked: true,
|
||||
devices: [
|
||||
{ id: "other", accepted: true },
|
||||
{ id: "self", accepted: false, lastConnected: 123, pluginVersion: "1.0.0" },
|
||||
],
|
||||
},
|
||||
{ id: "self", pluginVersion: "1.1.0" }
|
||||
);
|
||||
expect(view.devices[0]).toMatchObject({
|
||||
self: true,
|
||||
lockedOut: true,
|
||||
lastConnected: 123,
|
||||
pluginVersion: "1.1.0",
|
||||
});
|
||||
expect(view.devices[1].lockedOut).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version advisories", () => {
|
||||
it.each([
|
||||
["1.10.0", "1.9.9", 1],
|
||||
["1.0.0", "1.0.0-rc.1", 1],
|
||||
["1.0.0-beta.2", "1.0.0-beta.10", -1],
|
||||
["1.0.0-1", "1.0.0-alpha", -1],
|
||||
["1.0.0-alpha", "1.0.0-alpha.1", -1],
|
||||
["1.0.0+first", "1.0.0+second", 0],
|
||||
["99999999999999999.0.0", "99999999999999998.0.0", 1],
|
||||
])("compares %s with %s", (a, b, result) => expect(comparePluginVersions(a, b)).toBe(result));
|
||||
it.each(["", "development", "1.0", "01.0.0", "1.0.0-01", "v1.0.0"])(
|
||||
"does not order unknown version %s",
|
||||
(version) => {
|
||||
expect(comparePluginVersions(version, "1.0.0")).toBeUndefined();
|
||||
expect(comparePluginVersions("1.0.0", version)).toBeUndefined();
|
||||
}
|
||||
);
|
||||
it("excludes this device and unknown versions, and deduplicates versions", () => {
|
||||
const snapshot = {
|
||||
...empty,
|
||||
devices: [
|
||||
{ id: "self", accepted: false, pluginVersion: "9.0.0" },
|
||||
{ id: "a", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "b", accepted: false, pluginVersion: "2.0.0" },
|
||||
{ id: "c", accepted: false, pluginVersion: "0.9.0" },
|
||||
{ id: "d", accepted: true, pluginVersion: "custom" },
|
||||
],
|
||||
};
|
||||
expect(differentDeviceVersions(snapshot, "self", "1.0.0")).toEqual({ newer: ["2.0.0"], older: ["0.9.0"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { declareUICommand } from "@vrtmrz/livesync-commonlib/ui-commands";
|
||||
|
||||
export const OPEN_JOINED_DEVICES = declareUICommand<void, "closed" | "already-open" | "unavailable">(
|
||||
"livesync:joined-devices"
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ForgetJoinedDeviceResult, JoinedDevice } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export interface JoinedDeviceRow extends JoinedDevice {
|
||||
readonly self: boolean;
|
||||
readonly lockedOut: boolean;
|
||||
}
|
||||
|
||||
export interface JoinedDevicesView {
|
||||
readonly milestonePresent: boolean;
|
||||
readonly locked: boolean;
|
||||
readonly cleaned: boolean;
|
||||
readonly devices: readonly JoinedDeviceRow[];
|
||||
}
|
||||
|
||||
export interface JoinedDevicesDialogOptions {
|
||||
refresh(): Promise<JoinedDevicesView>;
|
||||
forget(id: string): Promise<ForgetJoinedDeviceResult>;
|
||||
cancel(): void;
|
||||
}
|
||||
@@ -63,28 +63,27 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||
host.services.setting.setSmallConfig(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||
async function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
|
||||
await host.services.setting.config.setValue(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
|
||||
}
|
||||
|
||||
function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
const saved = host.services.setting.getSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
async function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
const saved = await host.services.setting.config.getValue(SIMPLE_FETCH_MODE_KEY, "");
|
||||
if (!saved) return undefined;
|
||||
let remembered: ReturnType<typeof buildSimpleFetchResult> = undefined;
|
||||
try {
|
||||
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
|
||||
if (stage1) {
|
||||
const remembered = buildSimpleFetchResult(stage1, stage2);
|
||||
if (remembered) return remembered;
|
||||
}
|
||||
if (stage1) remembered = buildSimpleFetchResult(stage1, stage2);
|
||||
} catch {
|
||||
// Clear below; the saved choice is optional and can be rebuilt by asking again.
|
||||
}
|
||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
if (remembered) return remembered;
|
||||
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
|
||||
async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
|
||||
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
|
||||
}
|
||||
|
||||
async function confirmTranslatedChoice(
|
||||
@@ -104,7 +103,7 @@ async function confirmTranslatedChoice(
|
||||
export async function askSimpleFetchMode(
|
||||
host: NecessaryServices<"UI" | "setting", never>
|
||||
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
|
||||
const remembered = getRememberedSimpleFetchMode(host);
|
||||
const remembered = await getRememberedSimpleFetchMode(host);
|
||||
if (remembered) return remembered;
|
||||
|
||||
const msg = $msg(
|
||||
@@ -169,7 +168,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
||||
if (stage2 === STAGE2_ABORT) {
|
||||
return "aborted";
|
||||
}
|
||||
rememberSimpleFetchMode(host, stage1, stage2);
|
||||
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||
return buildSimpleFetchResult(stage1, stage2)!;
|
||||
}
|
||||
|
||||
@@ -198,7 +197,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
|
||||
if (stage2 === STAGE2_ABORT) {
|
||||
return "aborted";
|
||||
}
|
||||
rememberSimpleFetchMode(host, stage1, stage2);
|
||||
await rememberSimpleFetchMode(host, stage1, stage2);
|
||||
return buildSimpleFetchResult(stage1, stage2)!;
|
||||
}
|
||||
|
||||
@@ -234,18 +233,18 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return undefined;
|
||||
}
|
||||
const result = await askSimpleFetchMode(host);
|
||||
if (result === "cancelled") {
|
||||
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
if (result === "aborted") {
|
||||
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
@@ -256,7 +255,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
const settings = host.services.setting.currentSettings();
|
||||
if (!(await adjustSettingToRemoteIfNeeded(host, log, { preventFetchingConfig: false }, settings))) {
|
||||
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
return await cancelScheduledInitialisation(host, cleanupFlag);
|
||||
}
|
||||
|
||||
@@ -295,7 +294,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
|
||||
}
|
||||
await host.serviceModules.rebuilder.finishRebuild();
|
||||
await cleanupFlag();
|
||||
clearRememberedSimpleFetchMode(host);
|
||||
await clearRememberedSimpleFetchMode(host);
|
||||
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -10,19 +10,26 @@ const KEEP_LOCAL = "Keep local files even if not on remote";
|
||||
function createHost(savedChoice?: string) {
|
||||
const smallConfig = new Map<string, string>();
|
||||
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
};
|
||||
const confirmWithMessage =
|
||||
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
|
||||
const host = {
|
||||
services: {
|
||||
UI: { confirm: { confirmWithMessage } },
|
||||
setting: {
|
||||
getSmallConfig: (key: string) => smallConfig.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => smallConfig.set(key, value),
|
||||
deleteSmallConfig: (key: string) => smallConfig.delete(key),
|
||||
config,
|
||||
},
|
||||
},
|
||||
} as unknown as NecessaryServices<"UI" | "setting", never>;
|
||||
return { host, confirmWithMessage, smallConfig };
|
||||
return { host, confirmWithMessage, smallConfig, config };
|
||||
}
|
||||
|
||||
afterEach(() => setLang("def"));
|
||||
@@ -50,6 +57,75 @@ describe("Fast Setup translated choices", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the saved choice before returning it to the fetch workflow", async () => {
|
||||
const { host, confirmWithMessage, smallConfig, config } = createHost();
|
||||
let markSaveStarted!: () => void;
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
markSaveStarted = resolve;
|
||||
});
|
||||
let finishSave!: () => void;
|
||||
const save = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
config.setValue.mockImplementation(async (key, value) => {
|
||||
markSaveStarted();
|
||||
await save;
|
||||
smallConfig.set(key, value);
|
||||
});
|
||||
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
|
||||
|
||||
const asking = askSimpleFetchMode(host);
|
||||
await saveStarted;
|
||||
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
|
||||
|
||||
finishSave();
|
||||
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
|
||||
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
|
||||
stage1: REMOTE_WINS,
|
||||
stage2: KEEP_LOCAL,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates a stored-choice read failure instead of treating it as missing", async () => {
|
||||
const { host, confirmWithMessage, config } = createHost();
|
||||
config.getValue.mockRejectedValueOnce(new Error("choice read failed"));
|
||||
|
||||
await expect(askSimpleFetchMode(host)).rejects.toThrow("choice read failed");
|
||||
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for an invalid optional choice to be deleted before asking again", async () => {
|
||||
const { host, confirmWithMessage, smallConfig, config } = createHost("not-json");
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deletion = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
config.delete.mockImplementation(async (key) => {
|
||||
markDeleteStarted();
|
||||
await deletion;
|
||||
smallConfig.delete(key);
|
||||
});
|
||||
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
|
||||
|
||||
const asking = askSimpleFetchMode(host);
|
||||
await deleteStarted;
|
||||
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(true);
|
||||
|
||||
finishDelete();
|
||||
await expect(asking).resolves.toBe("cancelled");
|
||||
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
|
||||
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores choices saved by the previous English implementation while displaying Spanish", async () => {
|
||||
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
|
||||
const { host, confirmWithMessage, smallConfig } = createHost(savedChoice);
|
||||
|
||||
@@ -107,13 +107,15 @@ const createSettingServiceMock = () => {
|
||||
}),
|
||||
suspendAllSync: vi.fn(() => Promise.resolve()),
|
||||
suspendExtraSync: vi.fn(() => Promise.resolve()),
|
||||
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""),
|
||||
setSmallConfig: vi.fn((key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
deleteSmallConfig: vi.fn((key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
config: {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => smallConfig.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
smallConfig.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
smallConfig.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -785,7 +787,7 @@ describe("Red Flag Feature", () => {
|
||||
);
|
||||
|
||||
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -830,7 +832,7 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
||||
expect(cleanupFlag).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
|
||||
@@ -892,7 +894,7 @@ describe("Red Flag Feature", () => {
|
||||
|
||||
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
||||
|
||||
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
|
||||
});
|
||||
|
||||
it("should return false and cleanup when quick flow is cancelled", async () => {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
NODEINFO_DOCID,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
type EntryNodeInfo,
|
||||
type RemoteDBSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { REMOTE_RESOURCE_KINDS, type JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { ObsidianNoticeGroups } from "@/modules/services/ObsidianNoticeGroups";
|
||||
import JoinedDevicesDialog from "@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "@/common/replicatorConfigurationIdentity";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
|
||||
import { differentDeviceVersions, joinedDevicesView } from "./joinedDevices";
|
||||
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
|
||||
|
||||
type JoinedDevicesHost = NecessaryServices<
|
||||
"API" | "UI" | "setting" | "database" | "replicator" | "replication" | "appLifecycle",
|
||||
never
|
||||
>;
|
||||
const noticeGroup = "joined-device-versions";
|
||||
|
||||
function remoteIdentity(settings: RemoteDBSettings): string | undefined {
|
||||
if (settings.remoteType === REMOTE_COUCHDB && settings.couchDB_URI && settings.couchDB_DBNAME) {
|
||||
return getCouchDBReplicatorConfigurationIdentity(settings);
|
||||
}
|
||||
if (settings.remoteType === REMOTE_MINIO && settings.endpoint && settings.bucket) {
|
||||
return getObjectStorageReplicatorConfigurationIdentity(settings);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Own the dialogue, advisory Notices, and their per-session state for one host. */
|
||||
export function useJoinedDevicesFeature(host: JoinedDevicesHost, notices: ObsidianNoticeGroups): void {
|
||||
const { services } = host;
|
||||
let disposed = false;
|
||||
let active: AbortController | undefined;
|
||||
let checking = false;
|
||||
let lastCheck: { identity: string; at: number } | undefined;
|
||||
const notified = new Map<string, Set<string>>();
|
||||
const available = () =>
|
||||
!disposed &&
|
||||
services.database.isDatabaseReady() &&
|
||||
remoteIdentity(services.setting.currentSettings()) !== undefined;
|
||||
|
||||
const notify = (snapshot: JoinedDevicesSnapshot, ownId: string, identity: string) => {
|
||||
if (disposed || identity !== remoteIdentity(services.setting.currentSettings())) return;
|
||||
const version = services.API.getPluginVersion();
|
||||
const differences = differentDeviceVersions(snapshot, ownId, version);
|
||||
const seen = notified.get(identity) ?? new Set<string>();
|
||||
notified.set(identity, seen);
|
||||
let updated = false;
|
||||
for (const direction of ["newer", "older"] as const) {
|
||||
const versions = differences[direction];
|
||||
if (versions.length === 0) continue;
|
||||
const key = JSON.stringify([version, direction, versions]);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
notices.setItem(noticeGroup, direction, {
|
||||
message:
|
||||
direction === "newer"
|
||||
? $msg("Newer Self-hosted LiveSync versions detected: ${versions}.", {
|
||||
versions: versions.join(", "),
|
||||
})
|
||||
: $msg("Older Self-hosted LiveSync versions detected: ${versions}.", {
|
||||
versions: versions.join(", "),
|
||||
}),
|
||||
});
|
||||
updated = true;
|
||||
}
|
||||
if (!updated) return;
|
||||
notices.removeItem(noticeGroup, "review");
|
||||
notices.setItem(noticeGroup, "review", {
|
||||
message: $msg("Reviewing and aligning Self-hosted LiveSync versions across devices is recommended."),
|
||||
action: {
|
||||
label: $msg("Review devices"),
|
||||
onSelect: () => {
|
||||
if (!disposed && identity === remoteIdentity(services.setting.currentSettings())) {
|
||||
void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
notices.finish(noticeGroup, { durationMs: 15000 });
|
||||
};
|
||||
|
||||
const createOperations = (controller: AbortController): JoinedDevicesDialogOptions => {
|
||||
const settings = { ...services.setting.currentSettings() };
|
||||
const identity = remoteIdentity(settings);
|
||||
const database = services.database.localDatabase;
|
||||
const assertCurrent = () => {
|
||||
if (
|
||||
disposed ||
|
||||
controller.signal.aborted ||
|
||||
!identity ||
|
||||
identity !== remoteIdentity(services.setting.currentSettings()) ||
|
||||
!services.database.isDatabaseReady() ||
|
||||
services.database.localDatabase !== database
|
||||
) {
|
||||
throw new Error("Joined devices are no longer available for this dialogue");
|
||||
}
|
||||
};
|
||||
const ownId = async () => {
|
||||
assertCurrent();
|
||||
const info = await database.localDatabase.get<EntryNodeInfo>(NODEINFO_DOCID);
|
||||
assertCurrent();
|
||||
if (typeof info.nodeid !== "string" || !info.nodeid) throw new Error("Device identity is unavailable");
|
||||
return info.nodeid;
|
||||
};
|
||||
return {
|
||||
refresh: async () => {
|
||||
const id = await ownId();
|
||||
const resource = await services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.JOINED_DEVICES,
|
||||
settings
|
||||
);
|
||||
if (!resource) throw new Error("Joined devices are unavailable");
|
||||
try {
|
||||
assertCurrent();
|
||||
const snapshot = await resource.read();
|
||||
assertCurrent();
|
||||
notify(snapshot, id, identity!);
|
||||
return joinedDevicesView(snapshot, {
|
||||
id,
|
||||
platform: services.API.getPlatform(),
|
||||
pluginVersion: services.API.getPluginVersion(),
|
||||
appVersion: services.API.getAppVersion(),
|
||||
});
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
},
|
||||
forget: async (id) => {
|
||||
if (id === (await ownId())) throw new Error("This device cannot be forgotten");
|
||||
const resource = await services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.JOINED_DEVICES,
|
||||
settings
|
||||
);
|
||||
if (!resource) throw new Error("Joined devices are unavailable");
|
||||
try {
|
||||
assertCurrent();
|
||||
const result = await resource.forget(id);
|
||||
assertCurrent();
|
||||
return result;
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
},
|
||||
cancel: () => controller.abort(),
|
||||
};
|
||||
};
|
||||
|
||||
const unregister = services.UI.commands.register(OPEN_JOINED_DEVICES, async () => {
|
||||
if (!available()) return "unavailable";
|
||||
if (active) return "already-open";
|
||||
const controller = new AbortController();
|
||||
active = controller;
|
||||
try {
|
||||
await services.UI.dialogManager.open<"close", JoinedDevicesDialogOptions>(
|
||||
JoinedDevicesDialog,
|
||||
createOperations(controller)
|
||||
);
|
||||
} catch {
|
||||
// The host may close its dialogues during unload.
|
||||
} finally {
|
||||
controller.abort();
|
||||
if (active === controller) active = undefined;
|
||||
}
|
||||
return "closed";
|
||||
});
|
||||
|
||||
const checkVersions = () => {
|
||||
if (!available() || checking) return;
|
||||
const identity = remoteIdentity(services.setting.currentSettings())!;
|
||||
if (lastCheck?.identity === identity && Date.now() - lastCheck.at < 60000) return;
|
||||
lastCheck = { identity, at: Date.now() };
|
||||
checking = true;
|
||||
const controller = new AbortController();
|
||||
void Promise.resolve()
|
||||
.then(() => createOperations(controller).refresh())
|
||||
.catch(() => {
|
||||
// Advisory inspection never changes the outcome of synchronisation.
|
||||
})
|
||||
.finally(() => {
|
||||
controller.abort();
|
||||
checking = false;
|
||||
});
|
||||
};
|
||||
const stopBeforeReplicate = services.replication.onBeforeReplicate.addHandler(() => {
|
||||
checkVersions();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "joined-devices",
|
||||
name: $msg("Review devices"),
|
||||
icon: "monitor-smartphone",
|
||||
checkCallback: (checking) => {
|
||||
if (!available()) return false;
|
||||
if (!checking) void services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
disposed = true;
|
||||
active?.abort();
|
||||
unregister();
|
||||
stopBeforeReplicate();
|
||||
notices.hide(noticeGroup);
|
||||
notified.clear();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UICommandRegistry } from "@vrtmrz/livesync-commonlib/ui-commands";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { JoinedDevicesSnapshot } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { JoinedDevicesDialogOptions } from "./joinedDevicesDialogTypes";
|
||||
vi.mock("@/modules/services/LiveSyncUI/dialogues/JoinedDevicesDialog.svelte", () => ({ default: {} }));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string, values: Record<string, string> = {}) =>
|
||||
message.replace(/\$\{([^}]+)\}/g, (placeholder, key: string) => values[key] ?? placeholder),
|
||||
}));
|
||||
import { useJoinedDevicesFeature } from "./useJoinedDevices";
|
||||
import { OPEN_JOINED_DEVICES } from "./joinedDevicesCommand";
|
||||
|
||||
function fixture() {
|
||||
const settings = {
|
||||
...createNewVaultSettings(),
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
};
|
||||
const initialise: Array<() => Promise<boolean>> = [];
|
||||
const unload: Array<() => Promise<boolean>> = [];
|
||||
const before: Array<() => Promise<boolean>> = [];
|
||||
const stopBefore = vi.fn();
|
||||
const commands = new UICommandRegistry();
|
||||
const read = vi.fn<() => Promise<JoinedDevicesSnapshot>>(async () => ({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true, pluginVersion: "2.0.0" }],
|
||||
}));
|
||||
const forget = vi.fn(async (_id: string) => "forgotten" as const);
|
||||
const dispose = vi.fn(async () => {});
|
||||
const createResource = vi.fn(async () => ({ read, forget, dispose }));
|
||||
let close!: () => void;
|
||||
const dialog = vi.fn(
|
||||
(_component: unknown, _options: JoinedDevicesDialogOptions) =>
|
||||
new Promise<void>((resolve) => {
|
||||
close = resolve;
|
||||
})
|
||||
);
|
||||
const notices = { setItem: vi.fn(), removeItem: vi.fn(), finish: vi.fn(), hide: vi.fn() };
|
||||
const palette: Array<{ id: string; checkCallback: (checking: boolean) => boolean }> = [];
|
||||
const database = { localDatabase: { get: vi.fn(async () => ({ nodeid: "self" })) } };
|
||||
const host = {
|
||||
services: {
|
||||
API: {
|
||||
getPluginVersion: () => "1.0.0",
|
||||
getAppVersion: () => "1.9.0",
|
||||
getPlatform: () => "desktop",
|
||||
addCommand: (command: (typeof palette)[number]) => palette.push(command),
|
||||
},
|
||||
UI: { commands, dialogManager: { open: dialog } },
|
||||
setting: { currentSettings: () => settings },
|
||||
database: { isDatabaseReady: () => true, localDatabase: database },
|
||||
replicator: { createRemoteResource: createResource },
|
||||
replication: {
|
||||
onBeforeReplicate: {
|
||||
addHandler: (handler: (typeof before)[number]) => {
|
||||
before.push(handler);
|
||||
return stopBefore;
|
||||
},
|
||||
},
|
||||
},
|
||||
appLifecycle: {
|
||||
onInitialise: { addHandler: (handler: (typeof initialise)[number]) => initialise.push(handler) },
|
||||
onUnload: { addHandler: (handler: (typeof unload)[number]) => unload.push(handler) },
|
||||
},
|
||||
},
|
||||
serviceModules: {},
|
||||
} as unknown as Parameters<typeof useJoinedDevicesFeature>[0];
|
||||
useJoinedDevicesFeature(host, notices as unknown as Parameters<typeof useJoinedDevicesFeature>[1]);
|
||||
return {
|
||||
settings,
|
||||
initialise,
|
||||
unload,
|
||||
before,
|
||||
stopBefore,
|
||||
commands,
|
||||
read,
|
||||
forget,
|
||||
dispose,
|
||||
createResource,
|
||||
dialog,
|
||||
notices,
|
||||
palette,
|
||||
database,
|
||||
close: () => close(),
|
||||
};
|
||||
}
|
||||
|
||||
async function open(data: ReturnType<typeof fixture>) {
|
||||
const pending = data.commands.invoke(OPEN_JOINED_DEVICES, undefined);
|
||||
const options = data.dialog.mock.calls[0][1];
|
||||
return { options, pending };
|
||||
}
|
||||
|
||||
describe("joined devices service feature", () => {
|
||||
it("opens one dialogue through the command and disposes each completed read", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({
|
||||
status: "handled",
|
||||
value: "already-open",
|
||||
});
|
||||
expect((await options.refresh()).devices[0]).toMatchObject({ id: "self", self: true });
|
||||
expect(data.dispose).toHaveBeenCalledTimes(1);
|
||||
data.close();
|
||||
expect(await pending).toEqual({ status: "handled", value: "closed" });
|
||||
await expect(options.refresh()).rejects.toThrow();
|
||||
});
|
||||
it("prevents self removal and fences an old dialogue after a remote switch", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
await expect(options.forget("self")).rejects.toThrow();
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
await expect(options.forget("other")).rejects.toThrow();
|
||||
expect(data.forget).not.toHaveBeenCalled();
|
||||
expect(data.createResource).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("discards a stale read, releases the resource, and emits no stale Notice", async () => {
|
||||
const data = fixture();
|
||||
let resolve!: (value: JoinedDevicesSnapshot) => void;
|
||||
data.read.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const reading = options.refresh();
|
||||
await vi.waitFor(() => expect(data.read).toHaveBeenCalled());
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
resolve({ milestonePresent: true, locked: false, cleaned: false, devices: [] });
|
||||
await expect(reading).rejects.toThrow();
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
expect(data.notices.setItem).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it.each(["dialogue cancellation", "remote switch", "unload"] as const)(
|
||||
"does not send a pending forget operation after %s",
|
||||
async (invalidation) => {
|
||||
const data = fixture();
|
||||
const resource = { read: data.read, forget: data.forget, dispose: data.dispose };
|
||||
let resolve!: (value: typeof resource) => void;
|
||||
data.createResource.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const forgetting = options.forget("other");
|
||||
const rejected = expect(forgetting).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(data.createResource).toHaveBeenCalledOnce());
|
||||
|
||||
if (invalidation === "dialogue cancellation") options.cancel();
|
||||
if (invalidation === "remote switch") data.settings.couchDB_DBNAME = "new-vault";
|
||||
if (invalidation === "unload") for (const unload of data.unload) await unload();
|
||||
resolve(resource);
|
||||
|
||||
await rejected;
|
||||
expect(data.forget).not.toHaveBeenCalled();
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
await pending;
|
||||
}
|
||||
);
|
||||
it("discards a completed forget result after the dialogue closes and releases its resource", async () => {
|
||||
const data = fixture();
|
||||
let resolve!: (value: "forgotten") => void;
|
||||
data.forget.mockReturnValueOnce(
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { options, pending } = await open(data);
|
||||
const forgetting = options.forget("other");
|
||||
const rejected = expect(forgetting).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(data.forget).toHaveBeenCalledWith("other"));
|
||||
data.close();
|
||||
await pending;
|
||||
resolve("forgotten");
|
||||
await rejected;
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("propagates a failed forget operation after releasing its resource", async () => {
|
||||
const data = fixture();
|
||||
const failure = new Error("conditional write failed");
|
||||
data.forget.mockRejectedValueOnce(failure);
|
||||
const { options, pending } = await open(data);
|
||||
await expect(options.forget("other")).rejects.toBe(failure);
|
||||
expect(data.dispose).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("shares one review action across newer and older device versions", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "newer-a", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "newer-b", accepted: true, pluginVersion: "3.0.0" },
|
||||
{ id: "older-a", accepted: true, pluginVersion: "0.9.0" },
|
||||
{ id: "older-b", accepted: true, pluginVersion: "0.8.0" },
|
||||
{ id: "older-duplicate", accepted: true, pluginVersion: "0.8.0" },
|
||||
],
|
||||
});
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
const items = data.notices.setItem.mock.calls.map(([, , item]) => item);
|
||||
expect(items.filter((item) => item.action)).toHaveLength(1);
|
||||
const messages = items.map((item) => item.message).join("\n");
|
||||
expect(messages).toContain("Self-hosted LiveSync");
|
||||
expect(messages).toMatch(/Newer.*2\.0\.0, 3\.0\.0/);
|
||||
expect(messages).toMatch(/Older.*0\.8\.0, 0\.9\.0/);
|
||||
expect(messages.match(/0\.8\.0/g)).toHaveLength(1);
|
||||
expect(messages.match(/Reviewing and aligning/g)).toHaveLength(1);
|
||||
expect(items[items.length - 1]?.action?.label).toBe("Review devices");
|
||||
expect(data.notices.finish).toHaveBeenCalledExactlyOnceWith("joined-device-versions", {
|
||||
durationMs: 15000,
|
||||
});
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("keeps the shared action last when another version direction arrives", async () => {
|
||||
const data = fixture();
|
||||
const visible = new Map<string, { message: string; action?: unknown }>();
|
||||
data.notices.setItem.mockImplementation((_group, key, item) => visible.set(key, item));
|
||||
data.notices.removeItem.mockImplementation((_group, key) => visible.delete(key));
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["newer", "review"]);
|
||||
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "other", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "older", accepted: true, pluginVersion: "0.9.0" },
|
||||
],
|
||||
});
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["newer", "older", "review"]);
|
||||
expect([...visible.values()].filter((item) => item.action)).toHaveLength(1);
|
||||
|
||||
visible.clear();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [
|
||||
{ id: "other", accepted: true, pluginVersion: "2.0.0" },
|
||||
{ id: "older", accepted: true, pluginVersion: "0.8.0" },
|
||||
],
|
||||
});
|
||||
await options.refresh();
|
||||
expect([...visible.keys()]).toEqual(["older", "review"]);
|
||||
expect([...visible.values()].filter((item) => item.action)).toHaveLength(1);
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("does not show a review action when no different version is reported", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockResolvedValue({
|
||||
milestonePresent: true,
|
||||
locked: false,
|
||||
cleaned: false,
|
||||
devices: [{ id: "other", accepted: true, pluginVersion: "1.0.0" }],
|
||||
});
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
expect(data.notices.setItem).not.toHaveBeenCalled();
|
||||
expect(data.notices.removeItem).not.toHaveBeenCalled();
|
||||
expect(data.notices.finish).not.toHaveBeenCalled();
|
||||
data.close();
|
||||
await pending;
|
||||
});
|
||||
it("deduplicates advisory Notices and fences their actions after configuration changes", async () => {
|
||||
const data = fixture();
|
||||
const { options, pending } = await open(data);
|
||||
await options.refresh();
|
||||
await options.refresh();
|
||||
expect(data.notices.setItem).toHaveBeenCalledTimes(2);
|
||||
const actions = data.notices.setItem.mock.calls.map(([, , item]) => item.action).filter(Boolean);
|
||||
expect(actions).toHaveLength(1);
|
||||
const action = actions[0];
|
||||
data.close();
|
||||
await pending;
|
||||
data.settings.couchDB_DBNAME = "new-vault";
|
||||
action.onSelect();
|
||||
expect(data.dialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("keeps inspection failures out of the replication result and unregisters on unload", async () => {
|
||||
const data = fixture();
|
||||
data.read.mockRejectedValue(new Error("unavailable"));
|
||||
expect(await data.before[0]()).toBe(true);
|
||||
await vi.waitFor(() => expect(data.dispose).toHaveBeenCalledOnce());
|
||||
expect(await data.before[0]()).toBe(true);
|
||||
expect(data.read).toHaveBeenCalledTimes(1);
|
||||
for (const unload of data.unload) await unload();
|
||||
expect(await data.commands.invoke(OPEN_JOINED_DEVICES, undefined)).toEqual({ status: "unavailable" });
|
||||
expect(data.stopBefore).toHaveBeenCalledOnce();
|
||||
expect(data.notices.hide).toHaveBeenCalled();
|
||||
});
|
||||
it("uses the same UI command from the command palette", async () => {
|
||||
const data = fixture();
|
||||
for (const initialise of data.initialise) await initialise();
|
||||
expect(data.palette[0].checkCallback(true)).toBe(true);
|
||||
expect(data.dialog).not.toHaveBeenCalled();
|
||||
data.palette[0].checkCallback(false);
|
||||
expect(data.dialog).toHaveBeenCalledOnce();
|
||||
data.close();
|
||||
});
|
||||
});
|
||||
@@ -69,9 +69,9 @@ export function useReviewHarness(
|
||||
await createFileContentHashBenchmarkOperations(activeWindow.performance),
|
||||
activeWindow.performance
|
||||
),
|
||||
readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
||||
writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value),
|
||||
deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY),
|
||||
readContinuation: () => services.config.getValue(REVIEW_HARNESS_STATE_KEY, ""),
|
||||
writeContinuation: (value) => services.config.setValue(REVIEW_HARNESS_STATE_KEY, value),
|
||||
deleteContinuation: () => services.config.delete(REVIEW_HARNESS_STATE_KEY),
|
||||
restart: () => services.appLifecycle.performRestart(),
|
||||
reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE),
|
||||
copyText: async (value) => {
|
||||
@@ -94,14 +94,14 @@ export function useReviewHarness(
|
||||
let registered = false;
|
||||
let openAfterLayout = false;
|
||||
|
||||
services.appLifecycle.onSettingLoaded.addHandler(() => {
|
||||
services.appLifecycle.onSettingLoaded.addHandler(async () => {
|
||||
if (!continuationConsumed) {
|
||||
await controller.consumeContinuation();
|
||||
continuationConsumed = true;
|
||||
controller.consumeContinuation();
|
||||
}
|
||||
const snapshot = controller.snapshot();
|
||||
openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null;
|
||||
if (!services.setting.currentSettings().enableDebugTools || registered) return Promise.resolve(true);
|
||||
if (!services.setting.currentSettings().enableDebugTools || registered) return true;
|
||||
|
||||
registered = true;
|
||||
services.API.registerWindow(
|
||||
@@ -115,7 +115,7 @@ export function useReviewHarness(
|
||||
void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS);
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
return true;
|
||||
});
|
||||
|
||||
services.appLifecycle.onLayoutReady.addHandler(() => {
|
||||
|
||||
@@ -31,6 +31,15 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
const layoutReadyHandlers: Array<() => Promise<boolean>> = [];
|
||||
const local = new Map<string, string>();
|
||||
if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation);
|
||||
const config = {
|
||||
getValue: vi.fn(async (key: string, defaultValue = "") => local.get(key) ?? defaultValue),
|
||||
setValue: vi.fn(async (key: string, value: string) => {
|
||||
local.set(key, value);
|
||||
}),
|
||||
delete: vi.fn(async (key: string) => {
|
||||
local.delete(key);
|
||||
}),
|
||||
};
|
||||
const settings = {
|
||||
enableDebugTools: options.enableDebugTools ?? true,
|
||||
liveSync: false,
|
||||
@@ -63,10 +72,8 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
requiresSyncReview: true,
|
||||
reviewReasons: [],
|
||||
}),
|
||||
getSmallConfig: (key: string) => local.get(key) ?? "",
|
||||
setSmallConfig: (key: string, value: string) => local.set(key, value),
|
||||
deleteSmallConfig: (key: string) => local.delete(key),
|
||||
},
|
||||
config,
|
||||
appLifecycle: {
|
||||
onSettingLoaded: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)),
|
||||
@@ -106,6 +113,7 @@ function createFixture(options: { enableDebugTools?: boolean; continuation?: str
|
||||
api,
|
||||
settings,
|
||||
local,
|
||||
config,
|
||||
settingLoadedHandlers,
|
||||
layoutReadyHandlers,
|
||||
compatibilityReview,
|
||||
@@ -144,6 +152,74 @@ describe("Review Harness composition", () => {
|
||||
expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS);
|
||||
});
|
||||
|
||||
it("holds the setting-loaded lifecycle until continuation deletion has completed", async () => {
|
||||
const requestedAt = "2026-07-18T11:59:00.000Z";
|
||||
const continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: `compatibility-review-${requestedAt}`,
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
});
|
||||
const fixture = createFixture({ continuation });
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deleting = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
fixture.config.delete.mockImplementation(async (key) => {
|
||||
markDeleteStarted();
|
||||
await deleting;
|
||||
fixture.local.delete(key);
|
||||
});
|
||||
|
||||
const loading = fixture.settingLoadedHandlers[0]();
|
||||
await deleteStarted;
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
|
||||
|
||||
finishDelete();
|
||||
await loading;
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
|
||||
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("can retry one-shot continuation consumption after a deletion failure", async () => {
|
||||
const requestedAt = "2026-07-18T11:59:00.000Z";
|
||||
const continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: `compatibility-review-${requestedAt}`,
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
});
|
||||
const fixture = createFixture({ continuation });
|
||||
fixture.config.delete
|
||||
.mockRejectedValueOnce(new Error("continuation delete failed"))
|
||||
.mockImplementation(async (key) => {
|
||||
fixture.local.delete(key);
|
||||
});
|
||||
|
||||
await expect(fixture.settingLoadedHandlers[0]()).rejects.toThrow("continuation delete failed");
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(true);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(fixture.api.registerWindow).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.settingLoadedHandlers[0]();
|
||||
|
||||
expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false);
|
||||
expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`);
|
||||
expect(fixture.api.registerWindow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the actual compatibility controller as the guided review boundary", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
|
||||
Reference in new issue
Block a user