Merge main for startup integrity follow-up

This commit is contained in:
vorotamoroz committed 2026-10-24 11:42:35 +00:00
commit a3b55ed439
104 files changed
+6010 -678

No files matched your search

+9 -9
View File
@@ -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");
+64
View File
@@ -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;
}
+18 -19
View File
@@ -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);
+12 -10
View File
@@ -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 () => {
+219
View File
@@ -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();
});
});
+7 -7
View File
@@ -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();