mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-31 13:28:18 +00:00
Confirm Fast Setup choices from the scheduled fetch flag
This commit is contained in:
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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user