mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-30 13:03:20 +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
+44
-1
@@ -68,6 +68,49 @@ Once you confirm your choices:
|
||||
|
||||
Fast Setup records the last successfully stored remote position as it saves documents. A transient connection interruption is retried automatically from that position. The operation reports completion only after the captured remote state has been stored successfully.
|
||||
|
||||
If an error while decrypting data, reading the remote response, or writing to the local database stops the operation, LiveSync does not run the Vault scan or finalise the reconstructed local database. It retains the saved position, the selected data-processing method, and the fetch flag. File watching and database reflection also remain suspended so that a partly reconstructed database cannot be applied to the Vault or combined with new local changes.
|
||||
If an error while decrypting data, reading the remote response, or writing to the local database stops the operation, LiveSync does not run the Vault scan or finalise the reconstructed local database. It retains the saved position and the fetch flag. Any valid stored choices remain available for confirmation on restart. File watching and database reflection also remain suspended so that a partly reconstructed database cannot be applied to the Vault or combined with new local changes.
|
||||
|
||||
Review the first specific error in **Show log**, correct its cause, then restart Obsidian to retry from the retained state. While you intend to continue the operation, do not remove the fetch flag or manually resume the Scram switches. If the same error remains after a restart, leave LiveSync suspended, preserve the available data, and [collect a report](../troubleshooting.md#collect-a-report).
|
||||
|
||||
|
||||
## Remembered choices in the fetch flag
|
||||
|
||||
Fast Setup stores its choices in a `json` code block inside `flag_fetch.md` (or
|
||||
`redflag3.md` when that is the only fetch flag). The file remains Markdown and
|
||||
can be opened and edited in Obsidian. Explanatory text outside the state block
|
||||
is preserved when the choices are updated. The entire flag file is temporary:
|
||||
it is removed when the operation completes or is cancelled.
|
||||
|
||||
After an interruption, a restart shows the current remote and the saved
|
||||
choices. We can continue with those choices, choose again, or cancel the
|
||||
scheduled Fetch. Saved choices are never applied without confirmation, even
|
||||
when the previous Fetch completed but its flag could not be removed.
|
||||
|
||||
Starting a new Join or scheduling another Fetch replaces `flag_fetch.md` with
|
||||
an empty flag and asks for new choices. If both filenames exist, `flag_fetch.md`
|
||||
takes precedence, including when it is empty; completion removes both aliases.
|
||||
|
||||
The generated block has `type: "livesync-fast-setup"`, `version: 1`, and the two
|
||||
stable English choice values (`stage1` and `stage2`). It stores no connection
|
||||
credentials. Only recognised combinations of the existing choices are accepted.
|
||||
The confirmation applies the choices to the currently selected remote,
|
||||
including after changing P2P connection settings.
|
||||
|
||||
An empty flag starts the normal choice dialogues. If a non-empty flag has no
|
||||
readable supported selection, the plug-in asks whether to choose new options
|
||||
and continue. This includes malformed JSON, unsupported versions, duplicate
|
||||
state blocks, and incomplete Markdown fences. Continuing leaves the file
|
||||
contents unchanged and uses the new choices only for that attempt. Another
|
||||
restart asks again; the plug-in does not repair the Markdown or append a
|
||||
replacement block. Completing or cancelling the Fetch removes the entire flag,
|
||||
including any explanatory text.
|
||||
|
||||
A storage read or write failure stops the operation; it is not treated as an
|
||||
empty flag. If flag deletion fails at the end, file watching and database
|
||||
reflection remain suspended until the failure is resolved. A remaining flag
|
||||
will ask for confirmation again on restart.
|
||||
|
||||
Older versions stored choices separately in device-local storage. Those values
|
||||
are no longer reused because they may have survived a completed Fetch. After
|
||||
updating during an interrupted operation, confirm the choices once more. This
|
||||
change does not itself remove downloaded data or the Fast Fetch checkpoint.
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -192,6 +192,7 @@ async function startUnconfiguredSession(
|
||||
binary: context.binary,
|
||||
cliBinary: context.cliBinary,
|
||||
vault,
|
||||
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
});
|
||||
context.activeSessions.add(session);
|
||||
@@ -210,11 +211,29 @@ async function stopTrackedSessions(context: RunnerContext): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function enterSetupURI(port: number, mode: "new" | "existing", artifact: SetupArtifact): Promise<void> {
|
||||
async function enterSetupURI(
|
||||
port: number,
|
||||
mode: "new" | "existing",
|
||||
artifact: SetupArtifact,
|
||||
repeat = false
|
||||
): Promise<void> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
|
||||
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
|
||||
if (repeat) {
|
||||
const navigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
const panel =
|
||||
navigator.renderer === "declarative"
|
||||
? (await navigator.returnToCatalogue(), navigator.dialogue)
|
||||
: await navigator.openPage("Quick Setup");
|
||||
const rerun = panel.locator(".setting-item").filter({
|
||||
has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
|
||||
});
|
||||
if (navigator.renderer === "declarative") await rerun.click({ timeout: uiTimeoutMs });
|
||||
else await rerun.getByRole("button", { name: "Rerun Wizard", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
} else {
|
||||
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
|
||||
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
|
||||
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
@@ -669,6 +688,154 @@ async function captureFailure(session: ObsidianLiveSyncSession): Promise<void> {
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function assertFetchFlagsAbsent(port: number): Promise<void> {
|
||||
const flags = await withObsidianPage(
|
||||
port,
|
||||
async (page) =>
|
||||
await page.evaluate(async () => {
|
||||
const app = (globalThis as any).app;
|
||||
return await Promise.all(
|
||||
["flag_fetch.md", "redflag3.md"].map((path) => app.vault.adapter.exists(path))
|
||||
);
|
||||
})
|
||||
);
|
||||
assertEqual(flags.some(Boolean), false, "A completed Fetch retained a fetch flag.");
|
||||
}
|
||||
|
||||
async function verifyInterruptedFetchRestart(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
vault: TemporaryVault,
|
||||
artifact: SetupArtifact,
|
||||
unreadable = false
|
||||
): Promise<void> {
|
||||
const port = session.remoteDebuggingPort;
|
||||
await enterSetupURI(port, "existing", artifact, true);
|
||||
await captureAndStartInitialisation(port, "existing");
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, "Data retrieval scheduled").waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await page.evaluate(() => {
|
||||
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
|
||||
core.rebuilder.$fetchLocalDBFast = async () => {
|
||||
await core.rebuilder.resetLocalDatabase();
|
||||
(globalThis as any).__fetchChoiceInterrupted = true;
|
||||
throw new Error("E2E: interrupted after resetting the local database");
|
||||
};
|
||||
});
|
||||
});
|
||||
await confirmFastFetch(port);
|
||||
const pending = await withObsidianPage(port, async (page) => {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
|
||||
const settings = core.services.setting.currentSettings();
|
||||
return (
|
||||
(globalThis as any).__fetchChoiceInterrupted === true &&
|
||||
settings.suspendFileWatching &&
|
||||
settings.suspendParseReplicationResult
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
return await page.evaluate(async () => {
|
||||
const app = (globalThis as any).app;
|
||||
const core = app.plugins.plugins["obsidian-livesync"].core;
|
||||
return {
|
||||
markdown: await app.vault.adapter.read("flag_fetch.md"),
|
||||
suffix: core.services.setting.currentSettings().additionalSuffixOfDatabaseName,
|
||||
appId: core.services.API.getAppID(),
|
||||
};
|
||||
});
|
||||
});
|
||||
assertEqual(pending.suffix, pending.appId, "The interrupted Fetch did not change the database suffix.");
|
||||
const block = /```json\s*\n([\s\S]*?)\n```/.exec(pending.markdown);
|
||||
if (!block) throw new Error("The pending Fetch has no editable JSON code block.");
|
||||
const selection = JSON.parse(block[1]);
|
||||
assertEqual(selection.type, "livesync-fast-setup", "Unexpected saved Fetch state.");
|
||||
assertEqual(selection.stage1, "Overwrite all with remote files", "The first Fetch choice was not retained.");
|
||||
assertEqual(
|
||||
selection.stage2,
|
||||
"Keep local files even if not on remote",
|
||||
"The second Fetch choice was not retained."
|
||||
);
|
||||
|
||||
const localPath = `E2E/setup-uri/retained-during-recovery${unreadable ? "-unreadable" : ""}.md`;
|
||||
const localContent = "# Local recovery note\n\nRetain this local-only file after restart.\n";
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(
|
||||
async ({ localPath, localContent, unreadable }) => {
|
||||
const app = (globalThis as any).app;
|
||||
const flag = app.vault.getAbstractFileByPath("flag_fetch.md");
|
||||
const markdown = await app.vault.read(flag);
|
||||
await app.vault.modify(
|
||||
flag,
|
||||
"# Recovery notes\n\nEdited in Obsidian before restarting.\n\n" +
|
||||
(unreadable ? "<!-- unfinished comment\n" : "") +
|
||||
markdown
|
||||
);
|
||||
await app.vault.create(localPath, localContent);
|
||||
await app.workspace.openLinkText("flag_fetch.md", "", false);
|
||||
},
|
||||
{ localPath, localContent, unreadable }
|
||||
);
|
||||
});
|
||||
await captureObsidianPage(port, "issue-1244-editable-fetch-flag.png", async () => undefined);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate(() =>
|
||||
(globalThis as any).app.plugins.plugins["obsidian-livesync"].core.services.appLifecycle.performRestart()
|
||||
);
|
||||
});
|
||||
const title = unreadable ? "Saved fetch choices could not be read" : "Resume scheduled fetch?";
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const modal = modalByTitle(page, title);
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const message = await modal.textContent();
|
||||
if (!message?.includes("Current remote:") || !message.includes(context.dbName)) {
|
||||
throw new Error("The resume dialogue did not identify the current remote.");
|
||||
}
|
||||
if (!unreadable && (!message.includes(selection.stage1) || !message.includes(selection.stage2))) {
|
||||
throw new Error("The resume dialogue did not display both saved choices.");
|
||||
}
|
||||
if (unreadable) {
|
||||
await page.evaluate(async () => {
|
||||
const app = (globalThis as any).app;
|
||||
const core = app.plugins.plugins["obsidian-livesync"].core;
|
||||
const originalMarkdown = await app.vault.adapter.read("flag_fetch.md");
|
||||
const fetch = core.rebuilder.$fetchLocalDBFast.bind(core.rebuilder);
|
||||
core.rebuilder.$fetchLocalDBFast = async (...args: unknown[]) => {
|
||||
if ((await app.vault.adapter.read("flag_fetch.md")) !== originalMarkdown) {
|
||||
throw new Error("Choosing new options rewrote the unreadable fetch flag.");
|
||||
}
|
||||
return await fetch(...args);
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
await captureObsidianPage(
|
||||
port,
|
||||
unreadable ? "issue-1244-unreadable-confirmation.png" : "issue-1244-resume-confirmation.png",
|
||||
async () => undefined
|
||||
);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await modalByTitle(page, title)
|
||||
.getByRole("button", {
|
||||
name: unreadable ? "Choose new options and continue" : "Continue with these choices",
|
||||
exact: true,
|
||||
})
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
});
|
||||
if (unreadable) await confirmFastFetch(port);
|
||||
await finishInitialisation(port, "issue-1244-resumed.png", context.cliBinary, session.cliEnv);
|
||||
await assertFetchFlagsAbsent(port);
|
||||
await waitForPathContent(vault.path, notePath, noteContent);
|
||||
await waitForPathContent(vault.path, localPath, localContent);
|
||||
console.log(
|
||||
"ISSUE_1244_RESTART " +
|
||||
JSON.stringify({ prompted: true, unreadable, localOnlyFilePreserved: true, flagRemoved: true })
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -768,6 +935,66 @@ async function main(): Promise<void> {
|
||||
const secondState = secondCompletion.state;
|
||||
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
||||
assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile.");
|
||||
if (process.env.E2E_OBSIDIAN_ISSUE_1244 === "true") {
|
||||
const observations: Array<{ attempt: number; prompted: boolean; state: unknown }> = [];
|
||||
const record = async (attempt: number, prompted: boolean) => {
|
||||
const state = await withObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
async (page) =>
|
||||
await page.evaluate(() => {
|
||||
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
|
||||
return {
|
||||
suffix: core.services.setting.currentSettings().additionalSuffixOfDatabaseName,
|
||||
appId: core.services.API.getAppID(),
|
||||
savedChoices: Object.keys(localStorage)
|
||||
.filter((key) => key.endsWith("-simple-fetch-mode"))
|
||||
.map((key) => ({ key, value: localStorage.getItem(key) })),
|
||||
};
|
||||
})
|
||||
);
|
||||
await assertFetchFlagsAbsent(session.remoteDebuggingPort);
|
||||
observations.push({ attempt, prompted, state });
|
||||
console.log(`ISSUE_1244 ${JSON.stringify(observations[observations.length - 1])}`);
|
||||
};
|
||||
await waitForPathContent(vaultB.path, notePath, noteContent);
|
||||
await record(1, true);
|
||||
for (const attempt of [2, 3]) {
|
||||
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact, true);
|
||||
await captureAndStartInitialisation(session.remoteDebuggingPort, "existing");
|
||||
const prompted = await withObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
async (page) =>
|
||||
await modalByTitle(page, "Data retrieval scheduled")
|
||||
.waitFor({ state: "visible", timeout: 15000 })
|
||||
.then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
);
|
||||
if (prompted) await confirmFastFetch(session.remoteDebuggingPort);
|
||||
await finishInitialisation(
|
||||
session.remoteDebuggingPort,
|
||||
`issue-1244-join-${attempt}.png`,
|
||||
context.cliBinary,
|
||||
session.cliEnv
|
||||
);
|
||||
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
|
||||
await waitForPathContent(vaultB.path, notePath, noteContent);
|
||||
await captureObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
`issue-1244-after-join-${attempt}.png`,
|
||||
async () => undefined
|
||||
);
|
||||
await record(attempt, prompted);
|
||||
}
|
||||
assertEqual(
|
||||
observations.every((observation) => observation.prompted),
|
||||
true,
|
||||
`Every completed Join must offer fetch choices on the next Join: ${JSON.stringify(observations)}`
|
||||
);
|
||||
await verifyInterruptedFetchRestart(context, session, vaultB, secondDeviceArtifact);
|
||||
await verifyInterruptedFetchRestart(context, session, vaultB, secondDeviceArtifact, true);
|
||||
}
|
||||
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await scanHiddenDatabase(context.cliBinary, session.cliEnv);
|
||||
|
||||
@@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- A new Join or scheduled Fetch asks for data-handling choices again, even after a previous Fetch changed the local database suffix.
|
||||
- Fast Setup retains interrupted choices in an editable Markdown code block in the fetch flag. On restart, we confirm the current remote and saved choices, choose again, or cancel; a remaining flag never reapplies choices silently.
|
||||
- If the saved selection cannot be read, we can choose new options for that attempt without repairing or rewriting the flag. Completing or cancelling the operation removes the whole flag.
|
||||
- After updating during an interrupted Fetch, confirm the choices once more. Older separately stored choices are ignored; downloaded data and the existing checkpoint are not removed by this change.
|
||||
|
||||
## 1.0.42
|
||||
|
||||
27th October, 2026
|
||||
|
||||
Reference in new issue
Block a user