mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 11:33:21 +00:00
Merge main and verify catalogue Reload and Apply in Obsidian
This commit is contained in:
commit
b2fc839584
117 files changed
+7215
-744
No files matched your search
@@ -20,10 +20,14 @@
|
||||
MODE_SHINY,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { normalizePath } from "@/deps";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import {
|
||||
HiddenFileSync,
|
||||
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
|
||||
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -139,6 +143,11 @@
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.deleteData(data);
|
||||
}
|
||||
function checkAutomaticSyncAvailable() {
|
||||
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
|
||||
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
function askMode(evt: MouseEvent, title: string, key: string) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
||||
@@ -149,6 +158,7 @@
|
||||
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
||||
.onClick((e) => {
|
||||
if (mode === MODE_AUTOMATIC) {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
askOverwriteModeForAutomatic(evt, key);
|
||||
} else {
|
||||
setMode(key, mode as SYNC_MODE);
|
||||
@@ -161,10 +171,11 @@
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
setMode(key, MODE_AUTOMATIC);
|
||||
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
|
||||
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
|
||||
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
|
||||
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
const menu = new Menu();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export function getAutomaticSyncTargetFiles(
|
||||
key: string,
|
||||
configDir: string,
|
||||
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
|
||||
): string[] {
|
||||
const keys = key.startsWith("PLUGIN_ALL/")
|
||||
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
|
||||
: [key];
|
||||
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
|
||||
return [...files].map((file) => `${configDir}/${file}`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
|
||||
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
|
||||
return {
|
||||
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
|
||||
"PLUGIN_MAIN/example": {
|
||||
key: "PLUGIN_MAIN/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
|
||||
},
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/data.json"],
|
||||
},
|
||||
"PLUGIN_ETC/example": {
|
||||
key: "PLUGIN_ETC/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/extra.json"],
|
||||
},
|
||||
"PLUGIN_MAIN/other": {
|
||||
key: "PLUGIN_MAIN/other",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/other/main.js"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getAutomaticSyncTargetFiles", () => {
|
||||
it("initialises a single selected item in the current configuration directory", () => {
|
||||
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
|
||||
".custom-config/app.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("initialises both saved child selections when the whole plug-in is selected", () => {
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
|
||||
".obsidian/plugins/example/data.json",
|
||||
".obsidian/plugins/example/main.js",
|
||||
".obsidian/plugins/example/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains the available child files when the other child has no saved entry", () => {
|
||||
const entries = createEntries();
|
||||
delete entries["PLUGIN_DATA/example"];
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
|
||||
".obsidian/plugins/example/main.js",
|
||||
".obsidian/plugins/example/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
|
||||
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||
|
||||
const hiddenPath = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const prefixedPath = `i:${hiddenPath}`;
|
||||
const body = "module.exports = class ExamplePlugin {};";
|
||||
const allowList = "\\.(?:md|png|pdf)$";
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function hiddenFiles() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.usePluginSync = false;
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: fixture.settings,
|
||||
services: {
|
||||
...fixture.services,
|
||||
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
|
||||
},
|
||||
},
|
||||
cacheFileRegExps: new Map(),
|
||||
cacheCustomisationSyncIgnoredFiles: new Map(),
|
||||
});
|
||||
return { ...fixture, hiddenFileSync };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("issue #1272: Hidden File Sync with normal-file selectors", () => {
|
||||
it("stores a selected hidden file while the normal-file allow-list is set", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const stored = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
|
||||
expect(stored).not.toBe(false);
|
||||
if (stored) expect(await readContent(stored)).toBe(body);
|
||||
});
|
||||
|
||||
it("reads existing hidden-file content while the normal-file allow-list is set", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const meta = await fixture.localDatabase.getDBEntryMeta(prefixedPath as FilePath);
|
||||
if (!meta) throw new Error("The seeded hidden-file Metadata is unavailable");
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
|
||||
const entry = await fixture.localDatabase.getDBEntryFromMeta(meta);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("stores and reads the same hidden file when the normal-file allow-list is empty", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(prefixedPath, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const entry = await fixture.localDatabase.getDBEntry(prefixedPath as FilePath);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("continues to apply the normal-file allow-list to ordinary files", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
expect(fixture.localDatabase.isTargetFile("notes/example.md" as FilePath)).toBe(true);
|
||||
expect(fixture.localDatabase.isTargetFile("notes/example.js" as FilePath)).toBe(false);
|
||||
expect(fixture.localDatabase.isTargetFile("i:.obsidian/example.md" as FilePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Customisation Sync documents usable with the same normal-file allow-list", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
const path = "ix:device/PLUGIN_MAIN/example%main.js" as FilePath;
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, body))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const entry = await fixture.localDatabase.getDBEntry(path);
|
||||
expect(entry).not.toBe(false);
|
||||
if (entry) expect(await readContent(entry)).toBe(body);
|
||||
});
|
||||
|
||||
it("keeps Hidden File Sync target and ignore patterns effective", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncInternalFilesTargetPatterns =
|
||||
"^\\.obsidian/plugins/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(true);
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(".obsidian/snippets/example.css" as FilePath)).resolves.toBe(
|
||||
false
|
||||
);
|
||||
fixture.settings.syncInternalFilesIgnorePatterns =
|
||||
"main\\.js$" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
|
||||
await expect(fixture.hiddenFileSync.isTargetFile(hiddenPath)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("compatibility: applies the normal-file ignore-list to hidden documents", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncOnlyRegEx = allowList as typeof fixture.settings.syncOnlyRegEx;
|
||||
fixture.settings.syncIgnoreRegEx = "^\\.obsidian/" as typeof fixture.settings.syncIgnoreRegEx;
|
||||
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects hidden documents when Hidden File Sync is disabled", async () => {
|
||||
const fixture = await hiddenFiles();
|
||||
fixture.settings.syncInternalFiles = false;
|
||||
expect(fixture.localDatabase.isTargetFile(prefixedPath as FilePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,9 @@ type HiddenFileInitialisationProgress = {
|
||||
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
||||
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
|
||||
|
||||
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
|
||||
|
||||
function getComparingMTime(
|
||||
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
|
||||
includeDeleted = false
|
||||
@@ -1444,6 +1447,11 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?: HiddenFileInitialisationProgress
|
||||
) {
|
||||
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
|
||||
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
|
||||
return false;
|
||||
}
|
||||
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
|
||||
// p.log("Resolving conflicts before starting...");
|
||||
// await this.resolveConflictOnInternalFiles();
|
||||
@@ -1494,6 +1502,7 @@ Offline Changed files: ${files.length}`;
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
p.done();
|
||||
return true;
|
||||
}
|
||||
// <-- Initialization functions
|
||||
|
||||
@@ -1925,6 +1934,7 @@ Offline Changed files: ${files.length}`;
|
||||
|
||||
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
|
||||
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
|
||||
let initialised = false;
|
||||
let result: ConfigureHiddenFileSyncResult;
|
||||
try {
|
||||
result = await configureHiddenFileSyncMode(mode, {
|
||||
@@ -1954,7 +1964,7 @@ Offline Changed files: ${files.length}`;
|
||||
);
|
||||
},
|
||||
initialise: async (direction) => {
|
||||
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialisationProgress = undefined;
|
||||
},
|
||||
});
|
||||
@@ -1962,7 +1972,7 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?.done("Failed");
|
||||
throw error;
|
||||
}
|
||||
if (result == "ignored" || result == "disabled") {
|
||||
if (result == "ignored" || result == "disabled" || !initialised) {
|
||||
return;
|
||||
}
|
||||
// this.plugin.settings.useAdvancedMode = true;
|
||||
|
||||
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
|
||||
}
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
||||
async (direction) => {
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const path = ".obsidian/example.json" as FilePath;
|
||||
const operations = {
|
||||
rebuildFromStorage: vi.fn(async () => [path]),
|
||||
rebuildFromDatabase: vi.fn(async () => []),
|
||||
rebuildMerging: vi.fn(async () => [path]),
|
||||
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
|
||||
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
|
||||
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
const settings = { syncInternalFiles: false };
|
||||
const createProgress = vi.fn(() => progress);
|
||||
const log = vi.fn();
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings },
|
||||
_progress: createProgress,
|
||||
_log: log,
|
||||
...operations,
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
|
||||
|
||||
for (const operation of Object.values(operations)) {
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(result).toBe(false);
|
||||
expect(settings.syncInternalFiles).toBe(false);
|
||||
expect(createProgress).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
|
||||
expect(progress.done).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const createProgress = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: false } },
|
||||
_progress: createProgress,
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(createProgress).not.toHaveBeenCalled();
|
||||
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
|
||||
});
|
||||
|
||||
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
|
||||
const path = ".obsidian/example.json" as FilePath;
|
||||
const deleted: MetaEntry = {
|
||||
_id: "i:example" as DocumentID,
|
||||
_rev: "2-deleted",
|
||||
path: `i:${path}` as FilePathWithPrefix,
|
||||
type: "plain",
|
||||
ctime: 1,
|
||||
mtime: 10,
|
||||
size: 0,
|
||||
deleted: true,
|
||||
children: [],
|
||||
eden: {},
|
||||
};
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const deleteFile = vi.fn(async () => "OK" as const);
|
||||
const updateDeletion = vi.fn();
|
||||
const getFiles = vi.fn(async () => [deleted]);
|
||||
const processedStorage = new Map([[path, "storage-processed"]]);
|
||||
const processedDatabase = new Map([[path, "database-processed"]]);
|
||||
const resetProcessed = vi.fn(() => processedDatabase.clear());
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: { syncInternalFiles: false },
|
||||
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
|
||||
},
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: vi.fn(),
|
||||
_verbose: vi.fn(),
|
||||
_fileInfoLastProcessed: processedStorage,
|
||||
_databaseInfoLastProcessed: processedDatabase,
|
||||
getAllDatabaseFiles: getFiles,
|
||||
getPath: (doc: MetaEntry) => doc.path,
|
||||
resetLastProcessedDatabase: resetProcessed,
|
||||
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
|
||||
__deleteFile: deleteFile,
|
||||
updateLastProcessedDeletion: updateDeletion,
|
||||
adoptCurrentStorageFilesAsProcessed: vi.fn(),
|
||||
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
|
||||
scanAllStorageChanges: vi.fn(),
|
||||
scanAllDatabaseChanges: vi.fn(),
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
|
||||
|
||||
expect(deleteFile).not.toHaveBeenCalled();
|
||||
expect(updateDeletion).not.toHaveBeenCalled();
|
||||
expect(getFiles).not.toHaveBeenCalled();
|
||||
expect(resetProcessed).not.toHaveBeenCalled();
|
||||
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
|
||||
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
|
||||
});
|
||||
|
||||
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
}
|
||||
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
const command = commands.find(({ id }) => id === commandId);
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
expect(command?.checkCallback?.(false)).toBe(false);
|
||||
}
|
||||
|
||||
settings.syncInternalFiles = true;
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed,
|
||||
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
scanAllDatabaseChanges,
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"retains the selected files and completes %s initialisation when enabled",
|
||||
async (direction) => {
|
||||
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
|
||||
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const rebuildFromStorage = vi.fn(async () => files);
|
||||
const rebuildFromDatabase = vi.fn(async () => entries);
|
||||
const rebuildMerging = vi.fn(async () => files);
|
||||
const adoptStorage = vi.fn();
|
||||
const adoptDatabase = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
|
||||
rebuildFromStorage,
|
||||
rebuildFromDatabase,
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed: adoptStorage,
|
||||
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
|
||||
scanAllStorageChanges: vi.fn(),
|
||||
scanAllDatabaseChanges: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
|
||||
expect(result).toBe(true);
|
||||
if (direction === "pushForce") {
|
||||
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||
} else if (direction === "pullForce") {
|
||||
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
|
||||
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
|
||||
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it("does not report completion when the initialisation entry point refuses to start", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
await handlers.initialise("safe");
|
||||
return "enabled";
|
||||
});
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { services: { setting: { applyPartial: vi.fn() } } },
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: log,
|
||||
initialiseInternalFileSync: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await hiddenFileSync.configureHiddenFileSync("MERGE");
|
||||
|
||||
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
|
||||
});
|
||||
|
||||
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const applyPartial = vi.fn(async () => {
|
||||
events.push("apply-settings");
|
||||
});
|
||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
||||
const initialiseInternalFileSync = vi.fn(async () => true);
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { ValueStoreError } from "@vrtmrz/value-store";
|
||||
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
AcceptedStatus,
|
||||
@@ -33,6 +34,14 @@
|
||||
let settings = $state<P2PSyncSetting>(initialSettings);
|
||||
|
||||
let deviceName = $state<string>("");
|
||||
let deviceNameReady = $state(false);
|
||||
let dialogStatusReady = $state(false);
|
||||
type StorageErrorKey = "deviceNameLoad" | "dialogStatusLoad" | "deviceNameWrite" | "dialogStatusWrite";
|
||||
let storageErrors = $state<Partial<Record<StorageErrorKey, string>>>({});
|
||||
let mounted = false;
|
||||
let deviceNameLoadSequence = 0;
|
||||
let deviceNameWriteQueue: Promise<void> = Promise.resolve();
|
||||
let dialogStatusWriteQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
|
||||
let eRelay = $state<string>(initialSettings.P2P_relays);
|
||||
@@ -68,6 +77,7 @@
|
||||
);
|
||||
|
||||
async function saveAndApply() {
|
||||
const savedDeviceName = eDeviceName;
|
||||
// const newSettings = {
|
||||
// ...currentSettings(),
|
||||
// P2P_Enabled: eP2PEnabled,
|
||||
@@ -92,8 +102,39 @@
|
||||
},
|
||||
true
|
||||
);
|
||||
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
|
||||
deviceName = eDeviceName;
|
||||
try {
|
||||
await queueDeviceNameWrite(savedDeviceName);
|
||||
if (!mounted) return;
|
||||
deviceName = savedDeviceName;
|
||||
clearStorageError("deviceNameWrite");
|
||||
} catch (error) {
|
||||
setStorageError("deviceNameWrite", error);
|
||||
}
|
||||
}
|
||||
|
||||
function setStorageError(key: StorageErrorKey, error: unknown) {
|
||||
if (mounted) {
|
||||
storageErrors = {
|
||||
...storageErrors,
|
||||
[key]: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clearStorageError(key: StorageErrorKey) {
|
||||
if (!mounted) return;
|
||||
const nextErrors = { ...storageErrors };
|
||||
delete nextErrors[key];
|
||||
storageErrors = nextErrors;
|
||||
}
|
||||
|
||||
function queueDeviceNameWrite(name: string): Promise<void> {
|
||||
const write = deviceNameWriteQueue.catch(() => undefined).then(async () => {
|
||||
if (!mounted) return;
|
||||
await services.config.setValue(SETTING_KEY_P2P_DEVICE_NAME, name);
|
||||
});
|
||||
deviceNameWriteQueue = write;
|
||||
return write;
|
||||
}
|
||||
async function revert() {
|
||||
eP2PEnabled = settings.P2P_Enabled;
|
||||
@@ -110,10 +151,7 @@
|
||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
|
||||
if (force) {
|
||||
const initDeviceName =
|
||||
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
|
||||
deviceName = initDeviceName;
|
||||
eDeviceName = initDeviceName;
|
||||
void loadDeviceName(true).catch(() => undefined);
|
||||
}
|
||||
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
|
||||
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
|
||||
@@ -129,6 +167,7 @@
|
||||
settings = d;
|
||||
};
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const r = events.onEvent("setting-saved", async (d) => {
|
||||
applyLoadSettings(d, false);
|
||||
closeServer();
|
||||
@@ -143,15 +182,52 @@
|
||||
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
replicatorInfo = status;
|
||||
});
|
||||
applyLoadSettings(currentSettings(), true);
|
||||
applyLoadSettings(currentSettings(), false);
|
||||
void loadInitialPreferences().catch(() => undefined);
|
||||
host.p2p.diagnostics.requestStatus();
|
||||
return () => {
|
||||
mounted = false;
|
||||
r();
|
||||
rx();
|
||||
r2();
|
||||
r3();
|
||||
};
|
||||
});
|
||||
|
||||
async function loadInitialPreferences() {
|
||||
const deviceNameLoad = loadDeviceName(true).catch(() => undefined);
|
||||
const dialogStatusLoad = readDialogStatus().then(
|
||||
(dialogStatus) => {
|
||||
if (!mounted) return;
|
||||
isNoticeOpened = dialogStatus.notice ?? true;
|
||||
isSettingOpened = dialogStatus.setting ?? true;
|
||||
dialogStatusReady = true;
|
||||
clearStorageError("dialogStatusLoad");
|
||||
},
|
||||
(error: unknown) => {
|
||||
setStorageError("dialogStatusLoad", error);
|
||||
}
|
||||
);
|
||||
await Promise.all([deviceNameLoad, dialogStatusLoad]);
|
||||
}
|
||||
|
||||
async function loadDeviceName(force: boolean) {
|
||||
const request = ++deviceNameLoadSequence;
|
||||
try {
|
||||
const savedDeviceName = await services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME, "");
|
||||
if (!mounted || request !== deviceNameLoadSequence) return;
|
||||
const initialDeviceName = savedDeviceName;
|
||||
if (force || !isDeviceNameModified) {
|
||||
deviceName = initialDeviceName;
|
||||
eDeviceName = initialDeviceName;
|
||||
}
|
||||
deviceNameReady = true;
|
||||
clearStorageError("deviceNameLoad");
|
||||
} catch (error) {
|
||||
if (mounted && request === deviceNameLoadSequence) setStorageError("deviceNameLoad", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let isConnected = $derived.by(() => {
|
||||
return serverInfo?.isConnected ?? false;
|
||||
});
|
||||
@@ -234,26 +310,42 @@
|
||||
}
|
||||
|
||||
const initialDialogStatusKey = `p2p-dialog-status`;
|
||||
const getDialogStatus = () => {
|
||||
const readDialogStatus = async (): Promise<{ notice?: boolean; setting?: boolean }> => {
|
||||
try {
|
||||
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
|
||||
notice?: boolean;
|
||||
setting?: boolean;
|
||||
const initialDialogStatus = await services.config.getObject(initialDialogStatusKey);
|
||||
if (typeof initialDialogStatus !== "object" || initialDialogStatus === null || Array.isArray(initialDialogStatus)) {
|
||||
return {};
|
||||
}
|
||||
const status = initialDialogStatus as Record<string, unknown>;
|
||||
return {
|
||||
...(typeof status.notice === "boolean" ? { notice: status.notice } : {}),
|
||||
...(typeof status.setting === "boolean" ? { setting: status.setting } : {}),
|
||||
};
|
||||
return initialDialogStatus;
|
||||
} catch {
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error instanceof ValueStoreError && error.code === "invalid-data") {
|
||||
// Invalid optional UI state is recovered with the historical defaults.
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const initialDialogStatus = getDialogStatus();
|
||||
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true);
|
||||
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true);
|
||||
let isNoticeOpened = $state<boolean>(true);
|
||||
let isSettingOpened = $state<boolean>(true);
|
||||
$effect(() => {
|
||||
if (!mounted || !dialogStatusReady) return;
|
||||
const dialogStatus = {
|
||||
notice: isNoticeOpened,
|
||||
setting: isSettingOpened,
|
||||
};
|
||||
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
|
||||
const write = dialogStatusWriteQueue.catch(() => undefined).then(async () => {
|
||||
if (!mounted) return;
|
||||
await services.config.setObject(initialDialogStatusKey, dialogStatus);
|
||||
});
|
||||
dialogStatusWriteQueue = write;
|
||||
void write.then(
|
||||
() => clearStorageError("dialogStatusWrite"),
|
||||
(error: unknown) => setStorageError("dialogStatusWrite", error)
|
||||
);
|
||||
});
|
||||
let isObsidian = $derived.by(() => {
|
||||
return services.API.getPlatform() === "obsidian";
|
||||
@@ -261,6 +353,9 @@
|
||||
</script>
|
||||
|
||||
<article>
|
||||
{#each Object.values(storageErrors) as storageError}
|
||||
<p role="alert">{storageError}</p>
|
||||
{/each}
|
||||
<h1>{_msg("Peer to Peer Replicator")}</h1>
|
||||
<details bind:open={isNoticeOpened}>
|
||||
<summary>{_msg("P2P.Note.Summary")}</summary>
|
||||
@@ -345,6 +440,7 @@
|
||||
type="text"
|
||||
placeholder="iphone-16"
|
||||
bind:value={eDeviceName}
|
||||
disabled={!deviceNameReady}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
@@ -383,7 +479,7 @@
|
||||
</tr> -->
|
||||
</tbody>
|
||||
</table>
|
||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}
|
||||
<button disabled={!isAnyModified || !deviceNameReady} class="button mod-cta" onclick={saveAndApply}
|
||||
>{_msg("Save and Apply")}</button
|
||||
>
|
||||
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ItemView, Notice, Setting, type WorkspaceLeaf } from "@/deps";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
REVIEW_HARNESS_SCENARIOS,
|
||||
type ReviewHarnessScenarioId,
|
||||
@@ -101,7 +102,7 @@ export class ReviewHarnessView extends ItemView {
|
||||
true
|
||||
);
|
||||
this.addActionButton(actions, "Restart and return", "review-harness-restart", () =>
|
||||
this.controller.prepareCompatibilityReviewRestart()
|
||||
this.controller.prepareCompatibilityReviewRestart().catch((error) => Logger(error, LOG_LEVEL_NOTICE))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ export interface ReviewHarnessRuntime {
|
||||
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
|
||||
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||
runFileContentHashBenchmark(): Promise<ReviewHarnessScenarioResult>;
|
||||
readContinuation(): string | null;
|
||||
writeContinuation(value: string): void;
|
||||
deleteContinuation(): void;
|
||||
readContinuation(): Promise<string | null>;
|
||||
writeContinuation(value: string): Promise<void>;
|
||||
deleteContinuation(): Promise<void>;
|
||||
restart(): void;
|
||||
reportError(error: unknown): void;
|
||||
copyText(value: string): Promise<void>;
|
||||
@@ -68,6 +68,7 @@ export class ReviewHarnessController {
|
||||
private current: ReviewHarnessScenarioId | null = null;
|
||||
private resumedRequestId: string | null = null;
|
||||
private continuationError: string | null = null;
|
||||
private continuationConsumption: Promise<void> | undefined;
|
||||
|
||||
constructor(private readonly runtime: ReviewHarnessRuntime) {}
|
||||
|
||||
@@ -102,10 +103,20 @@ export class ReviewHarnessController {
|
||||
};
|
||||
}
|
||||
|
||||
consumeContinuation(): void {
|
||||
const serialised = this.runtime.readContinuation();
|
||||
consumeContinuation(): Promise<void> {
|
||||
if (this.continuationConsumption) return this.continuationConsumption;
|
||||
const consumption = this.consumeContinuationFromStore();
|
||||
const trackedConsumption = consumption.finally(() => {
|
||||
if (this.continuationConsumption === trackedConsumption) this.continuationConsumption = undefined;
|
||||
});
|
||||
this.continuationConsumption = trackedConsumption;
|
||||
return trackedConsumption;
|
||||
}
|
||||
|
||||
private async consumeContinuationFromStore(): Promise<void> {
|
||||
const serialised = await this.runtime.readContinuation();
|
||||
if (!serialised) return;
|
||||
this.runtime.deleteContinuation();
|
||||
await this.runtime.deleteContinuation();
|
||||
const parsed = parsePendingReviewRun(serialised);
|
||||
if (!parsed.pendingRun) {
|
||||
this.continuationError = parsed.error ?? "The Review Harness continuation was invalid.";
|
||||
@@ -232,7 +243,7 @@ export class ReviewHarnessController {
|
||||
this.record("scenario-failed", id);
|
||||
}
|
||||
|
||||
prepareCompatibilityReviewRestart(): void {
|
||||
async prepareCompatibilityReviewRestart(): Promise<void> {
|
||||
const requestedAt = this.runtime.now().toISOString();
|
||||
const pending = {
|
||||
formatVersion: 1,
|
||||
@@ -241,7 +252,7 @@ export class ReviewHarnessController {
|
||||
stage: "awaiting-restart",
|
||||
requestedAt,
|
||||
} as const;
|
||||
this.runtime.writeContinuation(JSON.stringify(pending));
|
||||
await this.runtime.writeContinuation(JSON.stringify(pending));
|
||||
this.results["compatibility-review"] = {
|
||||
status: "waiting-for-user",
|
||||
detail: "Restart requested. The one-shot continuation will be removed before the review resumes.",
|
||||
|
||||
@@ -90,14 +90,14 @@ function createRuntime(): ReviewHarnessRuntime & {
|
||||
detail: "File content hash measurements completed.",
|
||||
observations: ["SHA-256, 4 KiB: median=1.00 ms; range=1.00–1.00 ms"],
|
||||
})),
|
||||
readContinuation() {
|
||||
async readContinuation() {
|
||||
return this.continuation;
|
||||
},
|
||||
writeContinuation(value) {
|
||||
async writeContinuation(value) {
|
||||
this.events.push("write");
|
||||
this.continuation = value;
|
||||
},
|
||||
deleteContinuation() {
|
||||
async deleteContinuation() {
|
||||
this.events.push("delete");
|
||||
this.continuation = null;
|
||||
},
|
||||
@@ -251,7 +251,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.snapshot().running).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
|
||||
it("deletes a one-shot continuation before exposing the resumed guided step", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -262,7 +262,7 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(runtime.events).toEqual(["delete"]);
|
||||
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
|
||||
@@ -271,7 +271,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
});
|
||||
|
||||
it("does not copy rejected continuation values into the report", () => {
|
||||
it("does not copy rejected continuation values into the report", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -282,7 +282,7 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(controller.snapshot().continuationError).toContain("private-vault");
|
||||
expect(controller.snapshot().results["compatibility-review"].detail).toBe(
|
||||
@@ -292,7 +292,7 @@ describe("ReviewHarnessController", () => {
|
||||
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||
});
|
||||
|
||||
it("does not accept an injected request ID which could enter the report transcript", () => {
|
||||
it("does not accept an injected request ID which could enter the report transcript", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
@@ -303,18 +303,18 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.consumeContinuation();
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("failed");
|
||||
expect(controller.createReport()).not.toContain("private-vault");
|
||||
expect(controller.createReport()).not.toContain("secret-note.md");
|
||||
});
|
||||
|
||||
it("persists the fixed continuation before requesting restart", () => {
|
||||
it("persists the fixed continuation before requesting restart", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
controller.prepareCompatibilityReviewRestart();
|
||||
await controller.prepareCompatibilityReviewRestart();
|
||||
|
||||
expect(runtime.events).toEqual(["write", "restart"]);
|
||||
expect(JSON.parse(runtime.continuation!)).toMatchObject({
|
||||
@@ -324,6 +324,135 @@ describe("ReviewHarnessController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the continuation save before requesting restart", async () => {
|
||||
const runtime = createRuntime();
|
||||
let markSaveStarted!: () => void;
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
markSaveStarted = resolve;
|
||||
});
|
||||
let finishSave!: () => void;
|
||||
const saving = new Promise<void>((resolve) => {
|
||||
finishSave = resolve;
|
||||
});
|
||||
runtime.writeContinuation = vi.fn(async (value) => {
|
||||
runtime.events.push("write-started");
|
||||
markSaveStarted();
|
||||
await saving;
|
||||
runtime.continuation = value;
|
||||
runtime.events.push("write-saved");
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
const preparing = controller.prepareCompatibilityReviewRestart();
|
||||
await saveStarted;
|
||||
|
||||
expect(runtime.events).toEqual(["write-started"]);
|
||||
expect(runtime.restart).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
finishSave();
|
||||
await preparing;
|
||||
|
||||
expect(runtime.events).toEqual(["write-started", "write-saved", "restart"]);
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("keeps the guided review retryable when saving its continuation fails", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
await controller.runScenario("compatibility-review");
|
||||
runtime.writeContinuation = vi.fn(async () => {
|
||||
runtime.events.push("write-failed");
|
||||
throw new Error("continuation save failed");
|
||||
});
|
||||
|
||||
await expect(controller.prepareCompatibilityReviewRestart()).rejects.toThrow("continuation save failed");
|
||||
|
||||
expect(runtime.events).toEqual(["write-failed"]);
|
||||
expect(runtime.restart).not.toHaveBeenCalled();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("waits for one continuation deletion before exposing it and coalesces concurrent consumption", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||
});
|
||||
let markDeleteStarted!: () => void;
|
||||
const deleteStarted = new Promise<void>((resolve) => {
|
||||
markDeleteStarted = resolve;
|
||||
});
|
||||
let finishDelete!: () => void;
|
||||
const deleting = new Promise<void>((resolve) => {
|
||||
finishDelete = resolve;
|
||||
});
|
||||
runtime.readContinuation = vi.fn(async () => {
|
||||
runtime.events.push("read");
|
||||
return runtime.continuation;
|
||||
});
|
||||
runtime.deleteContinuation = vi.fn(async () => {
|
||||
runtime.events.push("delete-started");
|
||||
markDeleteStarted();
|
||||
await deleting;
|
||||
runtime.continuation = null;
|
||||
runtime.events.push("delete-finished");
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
const firstConsumption = controller.consumeContinuation();
|
||||
const duplicateConsumption = controller.consumeContinuation();
|
||||
await deleteStarted;
|
||||
|
||||
expect(duplicateConsumption).toBe(firstConsumption);
|
||||
expect(runtime.events).toEqual(["read", "delete-started"]);
|
||||
expect(controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
finishDelete();
|
||||
await firstConsumption;
|
||||
|
||||
expect(runtime.events).toEqual(["read", "delete-started", "delete-finished"]);
|
||||
expect(runtime.readContinuation).toHaveBeenCalledOnce();
|
||||
expect(runtime.deleteContinuation).toHaveBeenCalledOnce();
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("keeps a failed continuation deletion available for retry", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.continuation = JSON.stringify({
|
||||
formatVersion: 1,
|
||||
requestId: "compatibility-review-2026-07-18T11:59:00.000Z",
|
||||
scenarioId: "compatibility-review",
|
||||
stage: "awaiting-restart",
|
||||
requestedAt: "2026-07-18T11:59:00.000Z",
|
||||
});
|
||||
runtime.deleteContinuation = vi
|
||||
.fn<ReviewHarnessRuntime["deleteContinuation"]>()
|
||||
.mockRejectedValueOnce(new Error("continuation delete failed"))
|
||||
.mockImplementation(async () => {
|
||||
runtime.events.push("delete");
|
||||
runtime.continuation = null;
|
||||
});
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
await expect(controller.consumeContinuation()).rejects.toThrow("continuation delete failed");
|
||||
|
||||
expect(runtime.continuation).not.toBeNull();
|
||||
expect(controller.snapshot().resumedRequestId).toBeNull();
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("idle");
|
||||
|
||||
await controller.consumeContinuation();
|
||||
|
||||
expect(runtime.continuation).toBeNull();
|
||||
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
|
||||
expect(controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user");
|
||||
});
|
||||
|
||||
it("passes only after the actual compatibility review clears its pause", async () => {
|
||||
const runtime = createRuntime();
|
||||
const controller = new ReviewHarnessController(runtime);
|
||||
|
||||
Reference in new issue
Block a user