Confirm Fast Setup choices from the scheduled fetch flag

This commit is contained in:
vorotamoroz committed 2026-10-29 14:45:15 +00:00
1 parent efa3e356b4
commit 9632f2ae5a
12 files changed
+1440 -150

No files matched your search

@@ -398,6 +398,15 @@ export const liveSyncProvisionalEnglishMessages = {
"Save report": "Save report",
"The inspection could not finish. Close this dialogue and try again after database startup.":
"The inspection could not finish. Close this dialogue and try again after database startup.",
"Resume scheduled fetch?": "Resume scheduled fetch?",
"Apply these saved choices to the current remote?\n\nCurrent remote: ${REMOTE}\n\n- ${STAGE1}\n- ${STAGE2}\n\nThe fetch flag may remain after an interruption or an incomplete cleanup. Continue only if this fetch is still intended.":
"Apply these saved choices to the current remote?\n\nCurrent remote: ${REMOTE}\n\n- ${STAGE1}\n- ${STAGE2}\n\nThe fetch flag may remain after an interruption or an incomplete cleanup. Continue only if this fetch is still intended.",
"Continue with these choices": "Continue with these choices",
"Choose again": "Choose again",
"Saved fetch choices could not be read": "Saved fetch choices could not be read",
"The fetch flag does not contain one supported selection. Choose new options and continue with the current remote?\n\nCurrent remote: ${REMOTE}\n\nThe flag contents will remain unchanged. The new choices apply only to this attempt; another restart will ask again. Completing or cancelling the fetch removes the flag.":
"The fetch flag does not contain one supported selection. Choose new options and continue with the current remote?\n\nCurrent remote: ${REMOTE}\n\nThe flag contents will remain unchanged. The new choices apply only to this attempt; another restart will ask again. Completing or cancelling the fetch removes the flag.",
"Choose new options and continue": "Choose new options and continue",
} as const;
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
+109
View File
@@ -0,0 +1,109 @@
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import {
FlagFilesHumanReadable,
FlagFilesOriginal,
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import { readFetchFlagState, writeFetchFlagState, type FetchFlagState } from "./redFlag.fetchStateMarkdown";
type FetchStateHost = NecessaryServices<never, "storageAccess">;
function endpointDescription(value: string): string {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/, "");
} catch {
return "";
}
}
/** Describe the current destination for confirmation, without connection credentials. */
export function describeFetchRemote(settings: ObsidianLiveSyncSettings): string {
switch (settings.remoteType) {
case REMOTE_COUCHDB:
return ["CouchDB", endpointDescription(settings.couchDB_URI), settings.couchDB_DBNAME].join(" / ");
case REMOTE_MINIO:
return [
"Object Storage",
endpointDescription(settings.endpoint),
settings.region,
settings.bucket,
settings.bucketPrefix,
]
.filter(Boolean)
.join(" / ");
case REMOTE_P2P:
return [
"P2P",
settings.P2P_AppID,
settings.P2P_roomID,
...settings.P2P_relays.split(",").map((relay) => endpointDescription(relay.trim())),
]
.filter(Boolean)
.join(" / ");
default:
throw new Error("The selected remote type cannot use Fast Setup.");
}
}
async function readFetchFlag(host: FetchStateHost) {
const storage = host.serviceModules.storageAccess;
// A newly scheduled fetch writes the human-readable flag, even if an older alias remains.
for (const name of [FlagFilesHumanReadable.FETCH_ALL, FlagFilesOriginal.FETCH_ALL]) {
const path = storage.normalisePath(name);
if (await storage.isExists(path)) {
return { path, markdown: await storage.readFileText(path) };
}
}
throw new Error("The scheduled fetch flag is missing.");
}
async function writeVerified(host: FetchStateHost, path: string, markdown: string) {
const storage = host.serviceModules.storageAccess;
if (!(await storage.writeFileAuto(path, markdown)) || (await storage.readFileText(path)) !== markdown) {
throw new Error("The Fast Setup choices could not be saved in the fetch flag.");
}
}
export async function readFetchSelection(host: FetchStateHost): Promise<FetchFlagState | "empty" | "unreadable"> {
const { markdown } = await readFetchFlag(host);
if (!markdown.trim()) return "empty";
return readFetchFlagState(markdown) ?? "unreadable";
}
export async function saveFetchSelection(host: FetchStateHost, stage1: string, stage2: string): Promise<void> {
const { path, markdown } = await readFetchFlag(host);
const state: FetchFlagState = {
type: "livesync-fast-setup",
version: 1,
stage1,
stage2,
};
const updated = writeFetchFlagState(markdown, state);
if (updated !== markdown) await writeVerified(host, path, updated);
}
export async function clearFetchSelection(host: FetchStateHost): Promise<void> {
const { path, markdown } = await readFetchFlag(host);
const cleared = writeFetchFlagState(markdown, undefined);
if (cleared !== markdown) await writeVerified(host, path, cleared);
}
/** Remove both aliases, retaining the authoritative flag until the other alias is gone. */
export async function deleteFetchFlags(host: NecessaryServices<never, "storageAccess">): Promise<void> {
const storage = host.serviceModules.storageAccess;
for (const name of [FlagFilesOriginal.FETCH_ALL, FlagFilesHumanReadable.FETCH_ALL]) {
const path = storage.normalisePath(name);
if (await storage.isExists(path)) await storage.delete(path, true);
if (await storage.isExists(path)) throw new Error(`Could not remove ${name}.`);
}
}
@@ -0,0 +1,202 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import {
FlagFilesHumanReadable,
FlagFilesOriginal,
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import {
clearFetchSelection,
deleteFetchFlags,
describeFetchRemote,
readFetchSelection,
saveFetchSelection,
} from "./redFlag.fetchState";
import { readFetchFlagState, writeFetchFlagState } from "./redFlag.fetchStateMarkdown";
import {
askSimpleFetchMode,
SIMPLE_FETCH_STAGE1_REMOTE_WINS,
SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE,
} from "./redFlag.simpleFetch";
const modern = FlagFilesHumanReadable.FETCH_ALL;
const legacy = FlagFilesOriginal.FETCH_ALL;
const stage1 = SIMPLE_FETCH_STAGE1_REMOTE_WINS;
const stage2 = SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE;
function fixture() {
const files = new Map<string, string>([[modern, ""]]);
const settings = { ...DEFAULT_SETTINGS, couchDB_URI: "https://example.invalid", couchDB_DBNAME: "vault" };
const storage = {
normalisePath: (path: string) => path,
isExists: vi.fn(async (path: string) => files.has(path)),
readFileText: vi.fn(async (path: string) => {
if (!files.has(path)) throw new Error("missing file");
return files.get(path)!;
}),
writeFileAuto: vi.fn(async (path: string, value: string) => {
files.set(path, value);
return true;
}),
delete: vi.fn(async (path: string) => {
files.delete(path);
}),
};
const confirm = vi.fn(async (_title: string, _message: string, buttons: string[]) => buttons[1]);
const host = {
services: { setting: { currentSettings: () => settings }, UI: { confirm: { confirmWithMessage: confirm } } },
serviceModules: { storageAccess: storage },
} as any;
return { host, files, settings, storage, confirm };
}
describe("Fetch flag state", () => {
it("preserves Markdown and reuses the selection independently of the database suffix", async () => {
const { host, files, settings } = fixture();
await saveFetchSelection(host, stage1, stage2);
const prose = "# My recovery notes\n\nKeep this explanation.\n\n";
files.set(modern, prose + files.get(modern)!);
await saveFetchSelection(host, stage1, stage2);
expect(files.get(modern)).toContain(prose);
settings.additionalSuffixOfDatabaseName = "changed-by-rebuild";
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
await clearFetchSelection(host);
expect(files.get(modern)).toContain(prose);
expect(readFetchFlagState(files.get(modern)!)).toBeUndefined();
});
it("reads and updates the legacy filename when it is the only flag", async () => {
const { host, files } = fixture();
files.delete(modern);
files.set(legacy, "");
await saveFetchSelection(host, stage1, stage2);
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
expect(files.has(modern)).toBe(false);
await deleteFetchFlags(host);
expect(files.size).toBe(0);
});
it("treats a new empty canonical flag as a new operation even when a legacy selection remains", async () => {
const { host, files, confirm } = fixture();
await saveFetchSelection(host, stage1, stage2);
files.set(legacy, files.get(modern)!);
files.set(modern, "");
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledTimes(2);
await deleteFetchFlags(host);
expect(files.size).toBe(0);
});
it("does not fall back to the legacy flag when reading the canonical flag fails", async () => {
const { host, files, storage } = fixture();
files.set(legacy, "");
storage.readFileText.mockRejectedValueOnce(new Error("read failed"));
await expect(readFetchSelection(host)).rejects.toThrow("read failed");
expect(storage.readFileText).toHaveBeenCalledOnce();
});
it("does not create an unsolicited fetch flag when none exists", async () => {
const { host, files, storage } = fixture();
files.clear();
await expect(saveFetchSelection(host, stage1, stage2)).rejects.toThrow("flag is missing");
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
it("rejects a save when the storage silently retains different content", async () => {
const { host, storage } = fixture();
storage.writeFileAuto.mockResolvedValueOnce(true);
await expect(saveFetchSelection(host, stage1, stage2)).rejects.toThrow("could not be saved");
});
it("retains the authoritative flag when removing the legacy alias fails", async () => {
const { host, files, storage } = fixture();
files.set(legacy, "");
storage.delete.mockRejectedValueOnce(new Error("delete failed"));
await expect(deleteFetchFlags(host)).rejects.toThrow("delete failed");
expect(files.has(modern)).toBe(true);
expect(storage.delete).toHaveBeenCalledOnce();
});
it("detects a deletion which returned without removing the flag", async () => {
const { host, files, storage } = fixture();
storage.delete.mockResolvedValueOnce(undefined);
await expect(deleteFetchFlags(host)).rejects.toThrow("Could not remove");
expect(files.has(modern)).toBe(true);
});
it.each([
{ stage1: "unknown", stage2 },
{ stage1, stage2: "Delete local files if deleted on remote" },
{ stage1: "Use the detailed flow", stage2 },
])("asks again for an invalid combination: %j", async (choices) => {
const { host, files, storage, confirm } = fixture();
files.set(
modern,
writeFetchFlagState("", {
type: "livesync-fast-setup",
version: 1,
...choices,
})
);
const original = files.get(modern);
confirm.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]);
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledTimes(3);
expect(files.get(modern)).toBe(original);
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
it.each([
{ remoteType: "" as const, couchDB_DBNAME: "different-vault" },
{ remoteType: REMOTE_MINIO, endpoint: "https://s3.invalid", bucket: "other-bucket" },
{ remoteType: REMOTE_P2P, P2P_roomID: "other-room" },
])("requires explicit confirmation for the currently selected remote: %j", async (remote) => {
const { host, settings, confirm } = fixture();
await saveFetchSelection(host, stage1, stage2);
Object.assign(settings, remote);
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
confirm.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]);
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledOnce();
expect(confirm.mock.calls[0][1]).toContain("Current remote:");
});
});
describe("Fetch destination description", () => {
it("omits URL credentials, query parameters, fragments, and connection secrets", () => {
const { settings } = fixture();
Object.assign(settings, {
couchDB_URI: "https://alice:secret@example.invalid/path?token=secret#secret",
couchDB_USER: "alice",
couchDB_PASSWORD: "secret",
jwtKey: "secret",
passphrase: "secret",
additionalSuffixOfDatabaseName: "another-device",
});
expect(describeFetchRemote(settings)).toBe("CouchDB / https://example.invalid/path / vault");
});
it("shows Object Storage prefixes and P2P rooms without relay credentials", () => {
const { settings } = fixture();
Object.assign(settings, {
remoteType: REMOTE_MINIO,
endpoint: "https://s3.invalid",
region: "region",
bucket: "vault",
bucketPrefix: "one/",
});
expect(describeFetchRemote(settings)).toContain("vault / one/");
Object.assign(settings, {
remoteType: REMOTE_P2P,
P2P_roomID: "group",
P2P_passphrase: "secret",
P2P_relays: "wss://alice:secret@relay.invalid?token=secret,wss://other.invalid",
});
const description = describeFetchRemote(settings);
expect(description).toContain("group");
expect(description).toContain("wss://relay.invalid");
expect(description).toContain("wss://other.invalid");
expect(description).not.toContain("secret");
expect(description).not.toContain("alice");
});
});
@@ -0,0 +1,131 @@
import MarkdownIt from "markdown-it";
export interface FetchFlagState {
type: "livesync-fast-setup";
version: 1;
stage1: string;
stage2: string;
}
const STATE_TYPE = "livesync-fast-setup";
const markdownParser = new MarkdownIt({ html: true });
type MarkdownStateBlock = {
start: number;
end: number;
state: FetchFlagState | undefined;
};
function parseFetchFlagState(value: unknown): FetchFlagState | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (
record.type !== STATE_TYPE ||
record.version !== 1 ||
typeof record.stage1 !== "string" ||
typeof record.stage2 !== "string"
) {
return undefined;
}
return {
type: STATE_TYPE,
version: 1,
stage1: record.stage1,
stage2: record.stage2,
};
}
function getLineStartOffsets(markdown: string): number[] {
const starts = [0];
for (let index = 0; index < markdown.length; index++) {
if (markdown[index] === "\r") {
if (markdown[index + 1] === "\n") index++;
starts.push(index + 1);
} else if (markdown[index] === "\n") {
starts.push(index + 1);
}
}
return starts;
}
function isFenceClosed(markdown: string, map: [number, number] | null, markup: string): boolean {
if (!map) return false;
const closingLine = markdown.split(/\r\n|\n|\r/)[map[1] - 1] ?? "";
const closing = /^ {0,3}(`+|~+)[\t ]*$/.exec(closingLine);
return closing !== null && closing[1][0] === markup[0] && closing[1].length >= markup.length;
}
function getStateBlocks(markdown: string): MarkdownStateBlock[] | undefined {
const tokens = markdownParser.parse(markdown, {});
const topLevelFences = tokens.filter((token) => token.type === "fence" && token.level === 0);
if (topLevelFences.some((token) => !isFenceClosed(markdown, token.map, token.markup))) return undefined;
const starts = getLineStartOffsets(markdown);
const blocks: MarkdownStateBlock[] = [];
for (const token of topLevelFences) {
if (token.info.trim() !== "json") continue;
let parsed: unknown;
try {
parsed = JSON.parse(token.content);
} catch {
return undefined;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
const record = parsed as Record<string, unknown>;
if (record.type !== STATE_TYPE) continue;
if (!token.map) return undefined;
const start = starts[token.map[0]];
const end = starts[token.map[1]] ?? markdown.length;
if (start === undefined || end < start) return undefined;
blocks.push({ start, end, state: parseFetchFlagState(parsed) });
}
return blocks;
}
function getLineEnding(markdown: string): string {
return markdown.match(/\r\n|\n|\r/)?.[0] ?? "\n";
}
function formatStateBlock(state: FetchFlagState, lineEnding: string): string {
const json = JSON.stringify(
{
type: state.type,
version: state.version,
stage1: state.stage1,
stage2: state.stage2,
},
null,
2
);
return `\`\`\`json${lineEnding}${json}${lineEnding}\`\`\``;
}
function createFastSetupNote(state: FetchFlagState): string {
return [
"# Fast Setup",
"",
"This file keeps the Fast Setup choices while the fetch is incomplete. It is removed when the fetch completes.",
"",
formatStateBlock(state, "\n"),
].join("\n");
}
export function readFetchFlagState(markdown: string): FetchFlagState | undefined {
const blocks = getStateBlocks(markdown);
if (!blocks || blocks.length !== 1) return undefined;
return blocks[0].state;
}
export function writeFetchFlagState(markdown: string, state: FetchFlagState | undefined): string {
if (markdown.trim().length === 0) return state ? createFastSetupNote(state) : markdown;
const blocks = getStateBlocks(markdown);
if (!blocks || blocks.length !== 1 || !blocks[0].state) return markdown;
const block = blocks[0];
const originalBlock = markdown.slice(block.start, block.end);
const trailingLineEnding = originalBlock.match(/(?:\r\n|\n|\r)$/)?.[0] ?? "";
const replacement = state ? formatStateBlock(state, getLineEnding(markdown)) + trailingLineEnding : "";
return markdown.slice(0, block.start) + replacement + markdown.slice(block.end);
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { readFetchFlagState, writeFetchFlagState, type FetchFlagState } from "./redFlag.fetchStateMarkdown";
const state: FetchFlagState = {
type: "livesync-fast-setup",
version: 1,
stage1: "remote-only",
stage2: "keep-local",
};
function stateBlock(value: unknown = state): string {
return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
}
describe("Fast Setup Markdown state codec", () => {
it("reads a valid top-level JSON state fence", () => {
expect(readFetchFlagState(stateBlock())).toEqual(state);
});
it("ignores a legacy remote field", () => {
expect(readFetchFlagState(stateBlock({ ...state, remote: "old-remote-hash" }))).toEqual(state);
});
it("creates a Fast Setup note only for an empty or whitespace-only document", () => {
const markdown = writeFetchFlagState("", state);
expect(markdown).toContain("# Fast Setup");
expect(markdown).toContain("This file keeps the Fast Setup choices while the fetch is incomplete.");
expect(markdown).toContain("It is removed when the fetch completes.");
expect(readFetchFlagState(markdown)).toEqual(state);
const whitespace = " \n\t";
expect(readFetchFlagState(writeFetchFlagState(whitespace, state))).toEqual(state);
expect(writeFetchFlagState(whitespace, undefined)).toBe(whitespace);
});
it("updates one readable state while preserving prose and unrelated code fences", () => {
const unrelatedFence = '```json\n{\n "type": "example"\n}\n```';
const markdown = `Opening note.\n\n${unrelatedFence}\n\n${stateBlock()}\n\nClosing note.`;
const updated = { ...state, stage1: "new-choice" };
const result = writeFetchFlagState(markdown, updated);
expect(result).toContain("Opening note.");
expect(result).toContain(unrelatedFence);
expect(result).toContain("Closing note.");
expect(readFetchFlagState(result)).toEqual(updated);
});
it("clears one readable state while preserving surrounding prose", () => {
const markdown = `Before.\n${stateBlock()}\nAfter.`;
expect(writeFetchFlagState(markdown, undefined)).toBe("Before.\nAfter.");
});
it("does not read or write nested code examples", () => {
const example = [
"> Example state:",
"> ```json",
`> ${JSON.stringify(state)}`,
"> ```",
"",
"````markdown",
"```json",
JSON.stringify(state),
"```",
"````",
].join("\n");
expect(readFetchFlagState(example)).toBeUndefined();
expect(writeFetchFlagState(example, state)).toBe(example);
expect(writeFetchFlagState(example, undefined)).toBe(example);
});
it("does not change plain prose, malformed JSON, or an unsupported version", () => {
const malformed = '```json\n{"type":"livesync-fast-setup",\n```';
const unsupportedVersion = stateBlock({ ...state, version: 2 });
for (const markdown of ["Ordinary prose only.", malformed, unsupportedVersion]) {
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
expect(writeFetchFlagState(markdown, undefined)).toBe(markdown);
}
});
it("does not change duplicate state candidates", () => {
const markdown = `Start.\n\n${stateBlock()}\n\n${stateBlock({ ...state, stage1: "second" })}\n\nEnd.`;
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
expect(writeFetchFlagState(markdown, undefined)).toBe(markdown);
});
it("does not repair malformed marker blocks", () => {
const malformed = [
"Before.",
"",
"```json",
"{",
' "type": "livesync-fast-setup",',
' "version": 1,',
' "remote":',
"}",
"```",
"",
"After.",
].join("\n");
expect(readFetchFlagState(malformed)).toBeUndefined();
expect(writeFetchFlagState(malformed, state)).toBe(malformed);
expect(writeFetchFlagState(malformed, undefined)).toBe(malformed);
});
it("does not write state into malformed Markdown without a readable state block", () => {
const markdown = "Opening note.\n\n```text\nThis note remains inside an unclosed fence.";
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
});
it("does not write when a state block is inside an unclosed fence", () => {
const markdown = `Opening note.\n\n\`\`\`json\n${JSON.stringify(state)}`;
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
});
it("does not write into unclosed raw HTML comments and blocks", () => {
const markdownExamples = [
"Opening note.\n\n<!--\nThis comment remains open.",
"Opening note.\n\n<script>\nconst rawBlockRemainsOpen = true;",
`<!--\n${stateBlock()}`,
];
for (const markdown of markdownExamples) {
expect(readFetchFlagState(markdown)).toBeUndefined();
expect(writeFetchFlagState(markdown, state)).toBe(markdown);
expect(writeFetchFlagState(markdown, undefined)).toBe(markdown);
}
});
it("does not treat a marker-like string value as a state candidate", () => {
const unrelated = '```json\n{"description":"\\\"type\\\":\\\"livesync-fast-setup\\\""}\n```';
expect(readFetchFlagState(unrelated)).toBeUndefined();
expect(writeFetchFlagState(unrelated, state)).toBe(unrelated);
});
});
+49 -34
View File
@@ -13,6 +13,7 @@ import {
import { adjustSettingToRemoteIfNeeded, cancelScheduledInitialisation, processVaultInitialisation } from "./redFlag";
import { $msg } from "@/common/translation";
import type { AllMessageKeys } from "@/common/rosetta";
import { readFetchSelection, saveFetchSelection, clearFetchSelection, describeFetchRemote } from "./redFlag.fetchState";
export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files";
export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer";
@@ -26,8 +27,6 @@ export const SIMPLE_FETCH_STAGE2_NEWER_CLEANUP = "Delete local files if deleted
export const SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL = "Keep local files even if deleted on remote";
export const STAGE2_ABORT = "Cancel all and reboot";
const SIMPLE_FETCH_MODE_KEY = "simple-fetch-mode";
function buildSimpleFetchResult(stage1: string, stage2?: string) {
if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) {
return { mode: "detailed", options: {} };
@@ -63,27 +62,8 @@ function buildSimpleFetchResult(stage1: string, stage2?: string) {
return undefined;
}
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 }));
}
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) remembered = buildSimpleFetchResult(stage1, stage2);
} catch {
// Clear below; the saved choice is optional and can be rebuilt by asking again.
}
if (remembered) return remembered;
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
return undefined;
}
async function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
await host.services.setting.config.delete(SIMPLE_FETCH_MODE_KEY);
function escapeMarkdownText(value: string): string {
return value.replace(/[\\`*_{}[\]()<>|]/g, "\\$&").replace(/[\r\n]/g, " ");
}
async function confirmTranslatedChoice(
@@ -101,10 +81,45 @@ async function confirmTranslatedChoice(
}
export async function askSimpleFetchMode(
host: NecessaryServices<"UI" | "setting", never>
host: NecessaryServices<"UI" | "setting", "storageAccess">
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
const remembered = await getRememberedSimpleFetchMode(host);
if (remembered) return remembered;
const saved = await readFetchSelection(host);
const remembered =
typeof saved === "object" && saved.stage1 !== SIMPLE_FETCH_STAGE1_DETAILED
? buildSimpleFetchResult(saved.stage1, saved.stage2)
: undefined;
const canSave = saved === "empty" || remembered !== undefined;
const remote = escapeMarkdownText(describeFetchRemote(host.services.setting.currentSettings()));
if (remembered && typeof saved === "object") {
const decision = await confirmTranslatedChoice(
host,
$msg("Resume scheduled fetch?"),
$msg(
"Apply these saved choices to the current remote?\n\nCurrent remote: ${REMOTE}\n\n- ${STAGE1}\n- ${STAGE2}\n\nThe fetch flag may remain after an interruption or an incomplete cleanup. Continue only if this fetch is still intended.",
{
REMOTE: remote,
STAGE1: $msg(saved.stage1 as AllMessageKeys),
STAGE2: $msg(saved.stage2 as AllMessageKeys),
}
),
["Continue with these choices", "Choose again", SIMPLE_FETCH_STAGE1_CANCEL],
SIMPLE_FETCH_STAGE1_CANCEL
);
if (decision === "Continue with these choices") return remembered;
if (decision !== "Choose again") return "cancelled";
} else if (saved !== "empty") {
const decision = await confirmTranslatedChoice(
host,
$msg("Saved fetch choices could not be read"),
$msg(
"The fetch flag does not contain one supported selection. Choose new options and continue with the current remote?\n\nCurrent remote: ${REMOTE}\n\nThe flag contents will remain unchanged. The new choices apply only to this attempt; another restart will ask again. Completing or cancelling the fetch removes the flag.",
{ REMOTE: remote }
),
["Choose new options and continue", SIMPLE_FETCH_STAGE1_CANCEL],
SIMPLE_FETCH_STAGE1_CANCEL
);
if (decision !== "Choose new options and continue") return "cancelled";
}
const msg = $msg(
`We are about to retrieve the remote data.
@@ -140,6 +155,7 @@ Firstly, how shall we handle the data retrieved from this remote source?
if (!stage1 || stage1 === SIMPLE_FETCH_STAGE1_CANCEL) return "cancelled";
if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) {
if (canSave) await clearFetchSelection(host);
return buildSimpleFetchResult(stage1)!;
}
@@ -166,9 +182,10 @@ Firstly, how shall we handle the data retrieved from this remote source?
);
if (!stage2) return "cancelled";
if (stage2 === STAGE2_ABORT) {
if (canSave) await clearFetchSelection(host);
return "aborted";
}
await rememberSimpleFetchMode(host, stage1, stage2);
if (canSave) await saveFetchSelection(host, stage1, stage2);
return buildSimpleFetchResult(stage1, stage2)!;
}
@@ -195,9 +212,10 @@ Firstly, how shall we handle the data retrieved from this remote source?
);
if (!stage2) return "cancelled";
if (stage2 === STAGE2_ABORT) {
if (canSave) await clearFetchSelection(host);
return "aborted";
}
await rememberSimpleFetchMode(host, stage1, stage2);
if (canSave) await saveFetchSelection(host, stage1, stage2);
return buildSimpleFetchResult(stage1, stage2)!;
}
@@ -233,18 +251,15 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
"Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.",
LOG_LEVEL_NOTICE
);
await clearRememberedSimpleFetchMode(host);
return undefined;
}
const result = await askSimpleFetchMode(host);
if (result === "cancelled") {
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
await clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
if (result === "aborted") {
log("Fetch exited by user.", LOG_LEVEL_NOTICE);
await clearRememberedSimpleFetchMode(host);
host.services.appLifecycle.performRestart();
return false;
}
@@ -255,7 +270,6 @@ 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);
await clearRememberedSimpleFetchMode(host);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
@@ -292,9 +306,10 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
return false;
}
}
await host.serviceModules.rebuilder.finishRebuild();
if (!(await host.serviceModules.rebuilder.finishRebuild())) {
throw new Error("Fast Setup could not finalise the rebuilt database.");
}
await cleanupFlag();
await clearRememberedSimpleFetchMode(host);
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
return true;
};
@@ -3,11 +3,15 @@ import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interf
import { FullScanModes } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { $msg, setLang } from "@/common/translation";
import { askSimpleFetchMode } from "./redFlag.simpleFetch";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { FlagFilesHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import { readFetchFlagState, writeFetchFlagState } from "./redFlag.fetchStateMarkdown";
const REMOTE_WINS = "Overwrite all with remote files";
const KEEP_LOCAL = "Keep local files even if not on remote";
function createHost(savedChoice?: string) {
async function createHost(savedChoice?: string) {
const smallConfig = new Map<string, string>();
if (savedChoice) smallConfig.set("simple-fetch-mode", savedChoice);
const config = {
@@ -19,6 +23,24 @@ function createHost(savedChoice?: string) {
smallConfig.delete(key);
}),
};
const settings = { ...DEFAULT_SETTINGS, couchDB_URI: "https://example.invalid", couchDB_DBNAME: "vault" };
const flagPath = FlagFilesHumanReadable.FETCH_ALL;
const files = new Map<string, string>([[flagPath, ""]]);
const storage = {
normalisePath: (path: string) => path,
isExists: vi.fn(async (path: string) => files.has(path)),
readFileText: vi.fn(async (path: string) => files.get(path)!),
writeFileAuto: vi.fn(async (path: string, text: string) => {
files.set(path, text);
return true;
}),
};
const rememberedMarkdown = writeFetchFlagState("", {
type: "livesync-fast-setup",
version: 1,
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
const confirmWithMessage =
vi.fn<(title: string, message: string, buttons: string[], defaultAction: string) => Promise<string | false>>();
const host = {
@@ -26,17 +48,19 @@ function createHost(savedChoice?: string) {
UI: { confirm: { confirmWithMessage } },
setting: {
config,
currentSettings: () => settings,
},
},
} as unknown as NecessaryServices<"UI" | "setting", never>;
return { host, confirmWithMessage, smallConfig, config };
serviceModules: { storageAccess: storage },
} as unknown as NecessaryServices<"UI" | "setting", "storageAccess">;
return { host, confirmWithMessage, smallConfig, config, files, storage, flagPath, rememberedMarkdown, settings };
}
afterEach(() => setLang("def"));
describe("Fast Setup translated choices", () => {
it("uses the current language after the module has loaded and saves stable choice values", async () => {
const { host, confirmWithMessage, smallConfig } = createHost();
const { host, confirmWithMessage, files, flagPath } = await createHost();
setLang("es");
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
@@ -51,14 +75,14 @@ describe("Fast Setup translated choices", () => {
expect(buttons[1]).not.toBe(REMOTE_WINS);
expect(message).toContain($msg(REMOTE_WINS));
expect(confirmWithMessage.mock.calls[1][2][1]).toBe($msg(KEEP_LOCAL));
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
});
it("waits for the saved choice before returning it to the fetch workflow", async () => {
const { host, confirmWithMessage, smallConfig, config } = createHost();
const { host, confirmWithMessage, files, flagPath, storage } = await createHost();
let markSaveStarted!: () => void;
const saveStarted = new Promise<void>((resolve) => {
markSaveStarted = resolve;
@@ -67,81 +91,77 @@ describe("Fast Setup translated choices", () => {
const save = new Promise<void>((resolve) => {
finishSave = resolve;
});
config.setValue.mockImplementation(async (key, value) => {
storage.writeFileAuto.mockImplementation(async (key, value) => {
markSaveStarted();
await save;
smallConfig.set(key, value);
files.set(key, value);
return true;
});
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
const asking = askSimpleFetchMode(host);
await saveStarted;
expect(smallConfig.has("simple-fetch-mode")).toBe(false);
expect(files.get(flagPath)).toBe("");
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
finishSave();
await expect(asking).resolves.toMatchObject({ mode: "remote-only" });
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
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"));
const { host, confirmWithMessage, storage } = await createHost();
storage.readFileText.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);
it("asks before proceeding with an invalid selection block and leaves it unchanged", async () => {
const { host, confirmWithMessage, files, flagPath, storage } = await createHost();
const markdown = '```json\n{"type":"livesync-fast-setup", broken}\n```\n';
files.set(flagPath, markdown);
confirmWithMessage.mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(confirmWithMessage.mock.calls[0][0]).toBe("Saved fetch choices could not be read");
expect(files.get(flagPath)).toBe(markdown);
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
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);
it("confirms stable flag choices while displaying Spanish", async () => {
const { host, confirmWithMessage, files, flagPath, rememberedMarkdown } = await createHost();
files.set(flagPath, rememberedMarkdown);
setLang("es");
confirmWithMessage.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]);
await expect(askSimpleFetchMode(host)).resolves.toEqual({
mode: "remote-only",
options: { mode: FullScanModes.DB_APPLY, extraOnRemote: undefined },
});
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(confirmWithMessage.mock.calls[0][1]).toContain($msg(REMOTE_WINS));
expect(confirmWithMessage.mock.calls[0][1]).toContain($msg(KEEP_LOCAL));
expect(files.get(flagPath)).toBe(rememberedMarkdown);
});
expect(confirmWithMessage).not.toHaveBeenCalled();
it("asks again instead of migrating the previous localStorage choice", async () => {
const savedChoice = JSON.stringify({ stage1: REMOTE_WINS, stage2: KEEP_LOCAL });
const { host, confirmWithMessage, config, smallConfig } = await createHost(savedChoice);
setLang("es");
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[3]);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(config.getValue).not.toHaveBeenCalled();
expect(smallConfig.get("simple-fetch-mode")).toBe(savedChoice);
});
it("uses the new language in the second dialogue without changing the first choice", async () => {
const { host, confirmWithMessage, smallConfig } = createHost();
const { host, confirmWithMessage, files, flagPath } = await createHost();
setLang("def");
confirmWithMessage
.mockImplementationOnce(async (_title, _message, buttons) => {
@@ -154,21 +174,21 @@ describe("Fast Setup translated choices", () => {
expect(confirmWithMessage.mock.calls[0][2][1]).toBe(REMOTE_WINS);
expect(confirmWithMessage.mock.calls[1][2][1]).toBe($msg(KEEP_LOCAL));
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
});
it("recognises the translated cancellation choice", async () => {
const { host, confirmWithMessage, smallConfig } = createHost();
const { host, confirmWithMessage, files, flagPath } = await createHost();
setLang("es");
confirmWithMessage.mockImplementationOnce(async (_title, _message, buttons) => buttons[3]);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage.mock.calls[0][2][3]).toBe($msg("Cancel"));
expect(smallConfig.size).toBe(0);
expect(files.get(flagPath)).toBe("");
});
it("saves English-compatible values when the module initially loads in Spanish", async () => {
@@ -176,12 +196,12 @@ describe("Fast Setup translated choices", () => {
const translation = await import("@/common/translation");
translation.setLang("es");
const { askSimpleFetchMode: askAfterSpanishStartup } = await import("./redFlag.simpleFetch");
const { host, confirmWithMessage, smallConfig } = createHost();
const { host, confirmWithMessage, files, flagPath } = await createHost();
confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[1]);
try {
await expect(askAfterSpanishStartup(host)).resolves.toMatchObject({ mode: "remote-only" });
expect(JSON.parse(smallConfig.get("simple-fetch-mode")!)).toEqual({
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: REMOTE_WINS,
stage2: KEEP_LOCAL,
});
@@ -190,3 +210,77 @@ describe("Fast Setup translated choices", () => {
}
});
});
describe("Fetch restart confirmation regressions", () => {
it("requires explicit confirmation even when the saved selection is valid", async () => {
const { host, files, flagPath, rememberedMarkdown, confirmWithMessage } = await createHost();
files.set(flagPath, rememberedMarkdown);
confirmWithMessage.mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(confirmWithMessage.mock.calls[0][1]).toContain("example.invalid");
expect(confirmWithMessage.mock.calls[0][1]).toContain(REMOTE_WINS);
expect(confirmWithMessage.mock.calls[0][1]).toContain(KEEP_LOCAL);
expect(files.get(flagPath)).toBe(rememberedMarkdown);
});
it("asks before applying saved choices after only the P2P passphrase changes", async () => {
const { host, files, flagPath, confirmWithMessage, settings } = await createHost();
Object.assign(settings, {
remoteType: REMOTE_P2P,
P2P_roomID: "group",
P2P_passphrase: "old-private-room-passphrase",
});
const { saveFetchSelection } = await import("./redFlag.fetchState");
await saveFetchSelection(host, REMOTE_WINS, KEEP_LOCAL);
settings.P2P_passphrase = "new-private-room-passphrase";
const original = files.get(flagPath);
confirmWithMessage.mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledOnce();
expect(confirmWithMessage.mock.calls[0][1]).toContain("group");
expect(confirmWithMessage.mock.calls[0][1]).not.toContain("new-private-room-passphrase");
expect(files.get(flagPath)).toBe(original);
});
it("uses new choices only for this attempt when the flag cannot be read as a selection", async () => {
const { host, files, flagPath, confirmWithMessage, storage } = await createHost();
const markdown = "# Recovery notes\n\n<!-- unfinished comment";
files.set(flagPath, markdown);
confirmWithMessage
.mockImplementationOnce(async (_title, _message, buttons) => buttons[0])
.mockResolvedValueOnce(REMOTE_WINS)
.mockResolvedValueOnce(KEEP_LOCAL);
await expect(askSimpleFetchMode(host)).resolves.toMatchObject({ mode: "remote-only" });
expect(confirmWithMessage).toHaveBeenCalledTimes(3);
expect(files.get(flagPath)).toBe(markdown);
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
});
describe("Fetch resume decisions", () => {
it("replaces a valid saved choice only after choosing new options", async () => {
const { host, files, flagPath, rememberedMarkdown, confirmWithMessage } = await createHost();
files.set(flagPath, rememberedMarkdown);
confirmWithMessage
.mockResolvedValueOnce("Choose again")
.mockResolvedValueOnce("Compare time and take newer")
.mockResolvedValueOnce("Keep local files even if deleted on remote");
await expect(askSimpleFetchMode(host)).resolves.toMatchObject({ mode: "newer-wins" });
expect(confirmWithMessage).toHaveBeenCalledTimes(3);
expect(readFetchFlagState(files.get(flagPath)!)).toMatchObject({
stage1: "Compare time and take newer",
stage2: "Keep local files even if deleted on remote",
});
});
it("requires confirmation again after a successful explicit reuse", async () => {
const { host, files, flagPath, rememberedMarkdown, confirmWithMessage } = await createHost();
files.set(flagPath, rememberedMarkdown);
confirmWithMessage.mockResolvedValueOnce("Continue with these choices").mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host)).resolves.toMatchObject({ mode: "remote-only" });
await expect(askSimpleFetchMode(host)).resolves.toBe("cancelled");
expect(confirmWithMessage).toHaveBeenCalledTimes(2);
expect(confirmWithMessage.mock.calls[0][3]).toBe("Cancel");
});
});
+44 -20
View File
@@ -15,6 +15,7 @@ import type {
RebuildEverythingResult,
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes";
import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch";
import { deleteFetchFlags } from "./redFlag.fetchState";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isP2PMainRemote } from "@/common/remoteConfiguration";
@@ -136,10 +137,7 @@ export function createFetchAllFlagHandler(
(await isFlagFileExist(host, FlagFilesHumanReadable.FETCH_ALL));
// Cleanup fetch all flag files
const cleanupFlag = async () => {
await deleteFlagFile(host, log, FlagFilesOriginal.FETCH_ALL);
await deleteFlagFile(host, log, FlagFilesHumanReadable.FETCH_ALL);
};
const cleanupFlag = () => deleteFetchFlags(host);
// Handle the fetch all scheduled operation
const onScheduled = async () => {
@@ -189,27 +187,45 @@ export function createFetchAllFlagHandler(
log("Fetch initialisation cancelled by user.", LOG_LEVEL_NOTICE);
return await cancelScheduledInitialisation(host, cleanupFlag);
}
return await processVaultInitialisation(host, log, async () => {
const vaultStateToAction = mapVaultStateToAction[vault];
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction;
log(
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
LOG_LEVEL_INFO
);
await host.serviceModules.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
await cleanupFlag();
log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
return true;
});
return await processVaultInitialisation(
host,
log,
async () => {
const vaultStateToAction = mapVaultStateToAction[vault];
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction;
log(
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
LOG_LEVEL_INFO
);
await host.serviceModules.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
await cleanupFlag();
log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
return true;
},
"resume-on-success"
);
};
return {
priority: 10,
check: () => isFlagActive(),
handle: async () => {
const res = await onScheduled();
if (res) {
return await verifyAndUnlockSuspension(host, log);
try {
const res = await onScheduled();
if (res) return await verifyAndUnlockSuspension(host, log);
} catch (ex) {
log(
"Could not prepare or finish the scheduled fetch. File and database reflection remain suspended.",
LOG_LEVEL_NOTICE
);
log(ex, LOG_LEVEL_VERBOSE);
await host.services.setting.applyPartial(
{
suspendFileWatching: true,
suspendParseReplicationResult: true,
},
true
);
}
return false;
},
@@ -373,7 +389,7 @@ export async function cancelScheduledInitialisation(
return false;
}
type InitialisationSuspensionPolicy = "resume" | "keep" | "keep-on-failure";
type InitialisationSuspensionPolicy = "resume" | "keep" | "keep-on-failure" | "resume-on-success";
/**
* Process Vault initialisation with file watching and synchronisation suspended.
@@ -417,6 +433,14 @@ export async function processVaultInitialisation(
await host.services.setting.applyPartial({ suspendFileWatching: false }, true);
} else if (suspensionPolicy === "keep") {
await host.services.setting.applyPartial({ suspendFileWatching: true }, true);
} else if (suspensionPolicy === "resume-on-success") {
// Detailed Fetch owns its database reflection state on success or cancellation.
await host.services.setting.applyPartial(
completed
? { suspendFileWatching: false }
: { suspendFileWatching: true, suspendParseReplicationResult: true },
true
);
} else {
// Fast Setup owns both directions at this boundary. Reasserting the
// outcome also covers a late failure after finishRebuild started to
+324 -42
View File
@@ -1,3 +1,6 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
import { describe, it, expect, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
@@ -74,12 +77,24 @@ const availableRemoteTweaks = (values: Record<string, unknown>) => ({ status: "a
const createStorageAccessMock = () => {
const files: Set<string> = new Set();
const contents = new Map<string, string>();
return {
files,
contents,
readFileText: vi.fn(async (path: string) => {
if (!files.has(path)) throw new Error("Missing flag");
return contents.get(path) ?? "";
}),
writeFileAuto: vi.fn(async (path: string, text: string) => {
files.add(path);
contents.set(path, text);
return true;
}),
isExists: vi.fn((path: string) => Promise.resolve(files.has(path))),
normalisePath: vi.fn((path: string) => path),
delete: vi.fn((path: string, _recursive?: boolean) => {
files.delete(path);
contents.delete(path);
return Promise.resolve();
}),
getFileNames: vi.fn(() => Array.from(files)),
@@ -88,10 +103,13 @@ const createStorageAccessMock = () => {
const createSettingServiceMock = () => {
const settings: any = {
...DEFAULT_SETTINGS,
couchDB_URI: "https://example.invalid",
couchDB_DBNAME: "vault",
batchSave: true,
suspendFileWatching: false,
writeLogToTheFile: false,
remoteType: "CouchDB",
remoteType: "",
};
const smallConfig = new Map<string, string>();
return {
@@ -149,7 +167,7 @@ const createRebuilderMock = () => {
$fetchLocal: vi.fn(async () => {}),
$fetchLocalDBFast: vi.fn(async () => {}),
$rebuildEverything: vi.fn(async () => {}),
finishRebuild: vi.fn(async () => {}),
finishRebuild: vi.fn(async () => true),
};
};
@@ -161,8 +179,9 @@ const createTweakValueMock = () => {
};
};
const createHostMock = () => {
const createHostMock = (withFetchFlag = false) => {
const storageAccessMock = createStorageAccessMock();
if (withFetchFlag) storageAccessMock.files.add(FlagFilesHumanReadable.FETCH_ALL);
const settingMock = createSettingServiceMock();
const appLifecycleMock = createAppLifecycleMock();
const uiMock = createUIServiceMock();
@@ -677,21 +696,21 @@ describe("Red Flag Feature", () => {
describe("askSimpleFetchMode", () => {
it("should return cancelled when stage1 is cancelled", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
await expect(askSimpleFetchMode(host as any)).resolves.toBe("cancelled");
});
it("selects the detailed Fetch flow", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "detailed", options: {} });
});
it("should return remote-only with keep-local option", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE);
@@ -706,7 +725,7 @@ describe("Red Flag Feature", () => {
});
it("should return cancelled when remote-only stage2 is cancelled", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(false);
@@ -715,7 +734,7 @@ describe("Red Flag Feature", () => {
});
it("should return aborted when remote-only stage2 aborts", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(STAGE2_ABORT);
@@ -724,7 +743,7 @@ describe("Red Flag Feature", () => {
});
it("should return newer-wins cleanup option", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
@@ -739,7 +758,7 @@ describe("Red Flag Feature", () => {
});
it("should return newer-wins keep-all option", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL);
@@ -754,7 +773,7 @@ describe("Red Flag Feature", () => {
});
it("should return cancelled when newer-wins stage2 is cancelled", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(false);
@@ -763,7 +782,7 @@ describe("Red Flag Feature", () => {
});
it("should return aborted when newer-wins stage2 aborts", async () => {
const host = createHostMock();
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(STAGE2_ABORT);
@@ -774,9 +793,11 @@ describe("Red Flag Feature", () => {
describe("askAndPerformFastSetupOnScheduledFetchAll", () => {
it("uses the detailed flow instead of Simple Fetch while remediation mode is active", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
Object.assign(host.mocks.setting.settings, {
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
@@ -787,14 +808,16 @@ describe("Red Flag Feature", () => {
);
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
expect(host.mocks.setting.config.getValue).not.toHaveBeenCalled();
expect(cleanupFlag).not.toHaveBeenCalled();
});
it("releases both reflection suspensions after Fast Setup succeeds", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
Object.assign(host.mocks.setting.settings, {
doNotSuspendOnFetching: true,
@@ -814,9 +837,11 @@ describe("Red Flag Feature", () => {
});
it("keeps Vault reflection suspended and preserves recovery state when Fast Fetch fails", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
@@ -832,13 +857,17 @@ describe("Red Flag Feature", () => {
});
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
expect(cleanupFlag).not.toHaveBeenCalled();
expect(host.mocks.setting.config.delete).not.toHaveBeenCalledWith("simple-fetch-mode");
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain(
"livesync-fast-setup"
);
});
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
@@ -864,28 +893,33 @@ describe("Red Flag Feature", () => {
expect(cleanupFlag).not.toHaveBeenCalled();
});
it("should remember quick flow choices while the scheduled fetch is pending", async () => {
const host = createHostMock();
it("should confirm remembered quick flow choices while the scheduled fetch is pending", async () => {
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP)
.mockResolvedValueOnce("Continue with these choices");
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(2);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(3);
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalledTimes(2);
});
it("should clear remembered quick flow choices after completion", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
@@ -894,13 +928,15 @@ describe("Red Flag Feature", () => {
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
expect(host.mocks.setting.config.delete).toHaveBeenCalledWith("simple-fetch-mode");
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(false);
});
it("should return false and cleanup when quick flow is cancelled", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
@@ -912,9 +948,11 @@ describe("Red Flag Feature", () => {
});
it("should return false without cleanup when quick flow is aborted", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
@@ -928,9 +966,11 @@ describe("Red Flag Feature", () => {
});
it("leaves the detailed Fetch flow to its existing handler", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
@@ -941,8 +981,10 @@ describe("Red Flag Feature", () => {
});
it("should preserve automatic synchronisation choices and enter Scram when quick Fetch is cancelled", async () => {
const host = createHostMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const host = createHostMock(true);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
Object.assign(host.mocks.setting.settings, {
liveSync: true,
periodicReplication: true,
@@ -1000,9 +1042,11 @@ describe("Red Flag Feature", () => {
});
it("should reboot and return false when sync has failures and user chooses rerun", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
@@ -1022,9 +1066,11 @@ describe("Red Flag Feature", () => {
});
it("should continue and finalise when sync has failures but user releases flag", async () => {
const host = createHostMock();
const host = createHostMock(true);
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
const cleanupFlag = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
@@ -2000,3 +2046,239 @@ describe("Red Flag Feature", () => {
});
});
});
describe("Issue 1244: completed Fetch followed by another Join", () => {
it.each(["receiver-app", "imported-source-app"])(
"asks again after successful Fetch with initial database suffix %s",
async (initialSuffix) => {
const host = createHostMock();
const context = createServiceContext();
class FixtureSettings extends SettingService {
protected async saveData() {}
protected async loadData() {
return undefined;
}
}
const setting = new FixtureSettings(context, {
deviceValues: new MemoryValueStore(),
APIService: {
getSystemVaultName: () => "receiver-vault",
getAppID: () => "receiver-app",
addLog: vi.fn(),
} as any,
});
Object.assign(host.mocks.setting.settings, DEFAULT_SETTINGS, {
additionalSuffixOfDatabaseName: initialSuffix,
couchDB_URI: "https://example.invalid",
couchDB_DBNAME: "vault",
});
setting.settings = host.mocks.setting.settings;
host.services.setting.config = setting.config as any;
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
host.mocks.ui.confirm.confirmWithMessage.mockImplementation(
async (_title, _message, buttons) => buttons[0]
);
const reset = {
API: { getAppID: () => "receiver-app" },
setting: host.services.setting,
database: { resetDatabaseForCurrentSettings: vi.fn(async () => true) },
databaseEvents: {},
replicator: {},
events: context.events,
};
host.mocks.rebuilder.$fetchLocalDBFast.mockImplementation(async () => {
await ServiceRebuilder.prototype.resetLocalDatabase.call(reset as any);
});
const cleanup = vi.fn(async () => {
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
});
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL);
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, vi.fn(), cleanup)).resolves.toBe(true);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(2);
expect(host.mocks.setting.settings.additionalSuffixOfDatabaseName).toBe("receiver-app");
expect(cleanup).toHaveBeenCalledOnce();
await host.services.setting.applyPartial({ additionalSuffixOfDatabaseName: initialSuffix });
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL);
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, vi.fn(), cleanup)).resolves.toBe(true);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(4);
expect(cleanup).toHaveBeenCalledTimes(2);
}
);
});
describe("Fetch flag selection lifecycle", () => {
function scheduledFetch() {
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage.mockImplementation(async (_title, _message, buttons) => buttons[0]);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
return { host, handler: createFetchAllFlagHandler(host as any, vi.fn()) };
}
it("ignores a stale localStorage choice and records the new selection in Markdown", async () => {
const { host, handler } = scheduledFetch();
await host.services.setting.config.setValue(
"simple-fetch-mode",
JSON.stringify({
stage1: SIMPLE_FETCH_STAGE1_REMOTE_WINS,
stage2: SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL,
})
);
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(2);
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain("```json");
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain(
SIMPLE_FETCH_STAGE1_NEWER_WINS
);
});
it("confirms the same selection after the database suffix changes and Fetch fails", async () => {
const { host, handler } = scheduledFetch();
host.mocks.rebuilder.$fetchLocalDBFast.mockImplementationOnce(async () => {
host.mocks.setting.settings.additionalSuffixOfDatabaseName = "receiver-app";
throw new Error("offline after resetting database");
});
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain(
"livesync-fast-setup"
);
await expect(createFetchAllFlagHandler(host as any, vi.fn()).handle()).resolves.toBe(true);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(3);
expect(host.mocks.ui.confirm.confirmWithMessage.mock.calls[2][0]).toBe("Resume scheduled fetch?");
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(false);
});
it("does not fetch or reflect files when saving the choice returns false", async () => {
const { host, handler } = scheduledFetch();
host.mocks.storageAccess.writeFileAuto.mockResolvedValueOnce(false);
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled();
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
});
it("retains the choices when rebuild finalisation returns false", async () => {
const { host, handler } = scheduledFetch();
host.mocks.rebuilder.finishRebuild.mockResolvedValueOnce(false);
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toContain(
"livesync-fast-setup"
);
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
});
it("keeps reflection suspended if the flag cannot be removed after completion", async () => {
const { host, handler } = scheduledFetch();
host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("read-only storage"));
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
});
});
describe("Detailed Fetch cancellation regression", () => {
it("preserves an existing parse suspension when Commonlib cancels restricted fetch", async () => {
const host = createHostMock(true);
Object.assign(host.mocks.setting.settings, {
maxMTimeForReflectEvents: 123456789,
suspendParseReplicationResult: true,
});
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
vault: "identical",
extra: { preventFetchingConfig: true },
});
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel operation");
const runBoundedRemoteActivity = vi.fn();
const rebuilder = {
checkPathIdentitiesBeforeReconstruction: vi.fn(),
checkRemotePathIdentityBeforeFetch: vi.fn(),
setting: host.services.setting,
UI: host.services.UI,
replicator: { runBoundedRemoteActivity },
};
host.mocks.rebuilder.$fetchLocal.mockImplementation(async () => {
await ServiceRebuilder.prototype.fetchLocal.call(rebuilder as any, false, false);
});
await createFetchAllFlagHandler(host as any, vi.fn()).handle();
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ title: "Confirm restricted fetch" })
);
expect(runBoundedRemoteActivity).not.toHaveBeenCalled();
expect(host.mocks.setting.settings.suspendParseReplicationResult).toBe(true);
});
});
describe("Fetch confirmation lifecycle", () => {
it("cancels a retained completed flag without fetching again", async () => {
const host = createHostMock(true);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE)
.mockResolvedValueOnce("Cancel");
host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("deletion interrupted"));
const handler = createFetchAllFlagHandler(host as any, vi.fn());
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalledOnce();
expect(host.mocks.ui.confirm.confirmWithMessage.mock.calls[2][0]).toBe("Resume scheduled fetch?");
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(false);
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
});
it("retains unreadable Markdown after interruption and asks again on the next attempt", async () => {
const host = createHostMock(true);
const markdown = "# Recovery notes\n\n<!-- unfinished comment";
host.mocks.storageAccess.contents.set(FlagFilesHumanReadable.FETCH_ALL, markdown);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({}));
for (let i = 0; i < 2; i++) {
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce("Choose new options and continue")
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE);
}
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
const handler = createFetchAllFlagHandler(host as any, vi.fn());
await expect(handler.handle()).resolves.toBe(false);
expect(host.mocks.storageAccess.contents.get(FlagFilesHumanReadable.FETCH_ALL)).toBe(markdown);
expect(host.mocks.storageAccess.writeFileAuto).not.toHaveBeenCalled();
await expect(handler.handle()).resolves.toBe(true);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(6);
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(false);
});
it("keeps both reflection directions suspended when detailed Fetch cannot delete its flag", async () => {
const host = createHostMock(true);
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
vault: "identical",
extra: { preventFetchingConfig: true },
});
host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("read-only storage"));
await expect(createFetchAllFlagHandler(host as any, vi.fn()).handle()).resolves.toBe(false);
expect(host.mocks.rebuilder.$fetchLocal).toHaveBeenCalledOnce();
expect(host.mocks.storageAccess.files.has(FlagFilesHumanReadable.FETCH_ALL)).toBe(true);
expect(host.mocks.setting.settings).toMatchObject({
suspendFileWatching: true,
suspendParseReplicationResult: true,
});
});
});