mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 15:58:19 +00:00
Verify NFC scan failure preservation and recovery
This commit is contained in:
1 parent
602dfd7d37
commit
6d811f80e1
4 files changed
+285
-2
No files matched your search
@@ -0,0 +1,180 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { NodeStorageAdapter, fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
|
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||||
|
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||||
|
import { createDefaultCliSettings } from "@/apps/cli/cliSettingsDefaults";
|
||||||
|
import { runCommand } from "@/apps/cli/commands/runCommand";
|
||||||
|
import type { CLICommand, CLICommandContext } from "@/apps/cli/commands/types";
|
||||||
|
import { main, type CliCommandRunner } from "@/apps/cli/main";
|
||||||
|
|
||||||
|
type StoredFileSnapshot = {
|
||||||
|
id: string;
|
||||||
|
revision: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
children: string[];
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CLI NFC scan failure recovery", () => {
|
||||||
|
const originalArgv = process.argv.slice();
|
||||||
|
const originalExitCode = process.exitCode;
|
||||||
|
const signalHandlers = new Map<"SIGINT" | "SIGTERM", Set<Parameters<typeof process.removeListener>[1]>>();
|
||||||
|
let directory: string;
|
||||||
|
let vaultPath: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
let standardIo: StandardIo;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const [signal, originalHandlers] of signalHandlers) {
|
||||||
|
for (const handler of process.listeners(signal)) {
|
||||||
|
if (!originalHandlers.has(handler)) process.removeListener(signal, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signalHandlers.clear();
|
||||||
|
process.argv = originalArgv.slice();
|
||||||
|
process.exitCode = originalExitCode;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (directory) await fsPromises.rm(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function start(command: CLICommand, runner: CliCommandRunner, exitCode: number) {
|
||||||
|
process.exitCode = undefined;
|
||||||
|
process.argv = [
|
||||||
|
"node",
|
||||||
|
"livesync-cli",
|
||||||
|
path.join(directory, "db"),
|
||||||
|
"--vault",
|
||||||
|
vaultPath,
|
||||||
|
"--settings",
|
||||||
|
settingsPath,
|
||||||
|
command,
|
||||||
|
];
|
||||||
|
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotFile(context: CLICommandContext, targetPath: string): Promise<StoredFileSnapshot> {
|
||||||
|
for await (const doc of context.core.services.database.localDatabase.findAllNormalDocs({ conflicts: true }) || []) {
|
||||||
|
if (stripAllPrefixes(doc.path) !== targetPath) continue;
|
||||||
|
const file = await context.core.serviceModules.databaseFileAccess.fetch(targetPath as FilePathWithPrefix);
|
||||||
|
if (!file || file.deleted) throw new Error(`Expected readable database content for ${targetPath}`);
|
||||||
|
const body = file.body;
|
||||||
|
if (body.type !== "text/plain") throw new Error(`Expected text database content for ${targetPath}`);
|
||||||
|
return {
|
||||||
|
id: doc._id,
|
||||||
|
revision: doc._rev ?? "",
|
||||||
|
path: doc.path,
|
||||||
|
size: doc.size,
|
||||||
|
children: [...doc.children],
|
||||||
|
content: await body.text(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Expected a stored Metadata document for ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not accept a partially enumerated Vault as complete and keeps its PouchDB Metadata", async () => {
|
||||||
|
directory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-cli-nfc-scan-integration-"));
|
||||||
|
vaultPath = path.join(directory, "vault");
|
||||||
|
settingsPath = path.join(directory, "settings.json");
|
||||||
|
const databasePath = path.join(directory, "db");
|
||||||
|
const knownPath = "notes/known.md";
|
||||||
|
const blockedPath = "notes/blocked/new.md";
|
||||||
|
const knownContent = "stored before the failed inventory\n";
|
||||||
|
const blockedContent = "available after recovery\n";
|
||||||
|
|
||||||
|
await fsPromises.mkdir(path.join(vaultPath, "notes"), { recursive: true });
|
||||||
|
await fsPromises.mkdir(databasePath);
|
||||||
|
await fsPromises.writeFile(path.join(vaultPath, knownPath), knownContent);
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
settingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
...createDefaultCliSettings(),
|
||||||
|
isConfigured: true,
|
||||||
|
pathNormalizationVersion: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
standardIo = {
|
||||||
|
readStdin: vi.fn(async () => ""),
|
||||||
|
prompt: vi.fn(async () => ""),
|
||||||
|
writeStdout: vi.fn(),
|
||||||
|
writeStderr: vi.fn(),
|
||||||
|
};
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||||
|
signalHandlers.set(signal, new Set(process.listeners(signal)));
|
||||||
|
}
|
||||||
|
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||||
|
throw new Error(`__EXIT__:${code ?? 0}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let seeded: StoredFileSnapshot | undefined;
|
||||||
|
const seedMirror = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||||
|
const result = await runCommand(options, context);
|
||||||
|
seeded = await snapshotFile(context, knownPath);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
await start("mirror", seedMirror, 0);
|
||||||
|
expect(seedMirror).toHaveBeenCalledOnce();
|
||||||
|
expect(seeded).toMatchObject({ path: knownPath, content: knownContent });
|
||||||
|
|
||||||
|
await fsPromises.mkdir(path.join(vaultPath, "notes", "blocked"));
|
||||||
|
await fsPromises.writeFile(path.join(vaultPath, blockedPath), blockedContent);
|
||||||
|
|
||||||
|
const scanFailure = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||||
|
const directoryReads: string[] = [];
|
||||||
|
const parentDirectoryEntries: string[] = [];
|
||||||
|
// Retain the prototype implementation before the spy replaces it; invoke it with each real adapter below.
|
||||||
|
const originalReadDirectory = Reflect.get(
|
||||||
|
NodeStorageAdapter.prototype,
|
||||||
|
"readDirectory"
|
||||||
|
);
|
||||||
|
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory").mockImplementation(
|
||||||
|
async function (this: NodeStorageAdapter, relativePath) {
|
||||||
|
directoryReads.push(relativePath);
|
||||||
|
if (relativePath === "notes/blocked") throw scanFailure;
|
||||||
|
const entries = await originalReadDirectory.call(this, relativePath);
|
||||||
|
if (relativePath === "notes") {
|
||||||
|
parentDirectoryEntries.push(...(entries ?? []).map((entry) => entry.name));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const failedMirror = vi.fn<CliCommandRunner>(async (options, context) => await runCommand(options, context));
|
||||||
|
try {
|
||||||
|
await start("mirror", failedMirror, 1);
|
||||||
|
} finally {
|
||||||
|
readDirectory.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(failedMirror).not.toHaveBeenCalled();
|
||||||
|
expect(directoryReads).toContain("");
|
||||||
|
expect(directoryReads).toContain("notes");
|
||||||
|
expect(directoryReads).toContain("notes/blocked");
|
||||||
|
expect(parentDirectoryEntries).toContain("known.md");
|
||||||
|
expect(parentDirectoryEntries).toContain("blocked");
|
||||||
|
|
||||||
|
let afterFailure: StoredFileSnapshot | undefined;
|
||||||
|
const inspectAfterFailure = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||||
|
const result = await runCommand(options, context);
|
||||||
|
afterFailure = await snapshotFile(context, knownPath);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
await start("ls", inspectAfterFailure, 0);
|
||||||
|
expect(afterFailure).toEqual(seeded);
|
||||||
|
expect(await fsPromises.readFile(path.join(vaultPath, knownPath), "utf8")).toBe(knownContent);
|
||||||
|
expect(await fsPromises.readFile(path.join(vaultPath, blockedPath), "utf8")).toBe(blockedContent);
|
||||||
|
|
||||||
|
let recoveredKnown: StoredFileSnapshot | undefined;
|
||||||
|
let recoveredNewFile: StoredFileSnapshot | undefined;
|
||||||
|
const recoverMirror = vi.fn<CliCommandRunner>(async (options, context) => {
|
||||||
|
const result = await runCommand(options, context);
|
||||||
|
recoveredKnown = await snapshotFile(context, knownPath);
|
||||||
|
recoveredNewFile = await snapshotFile(context, blockedPath);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
await start("mirror", recoverMirror, 0);
|
||||||
|
expect(recoverMirror).toHaveBeenCalledOnce();
|
||||||
|
expect(recoveredKnown).toEqual(seeded);
|
||||||
|
expect(recoveredNewFile).toMatchObject({ path: blockedPath, content: blockedContent });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -268,6 +268,49 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
|||||||
expect(report).not.toHaveBeenCalled();
|
expect(report).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not return or cache a partial NFC listing after a nested directory read fails", async () => {
|
||||||
|
const directory = await createVault();
|
||||||
|
const knownPath = "Known.md";
|
||||||
|
const initialContent = "initial content";
|
||||||
|
await fs.writeFile(path.join(directory, knownPath), initialContent);
|
||||||
|
let normalizationVersion = 1;
|
||||||
|
const adapter = new NodeFileSystemAdapter(directory, undefined, () => normalizationVersion, () => true);
|
||||||
|
await adapter.scanDirectory();
|
||||||
|
|
||||||
|
const changedContent = "changed content after cache";
|
||||||
|
await fs.writeFile(path.join(directory, knownPath), changedContent);
|
||||||
|
await fs.mkdir(path.join(directory, "late-directory"));
|
||||||
|
await fs.writeFile(path.join(directory, "late-directory", "later.md"), "later content");
|
||||||
|
|
||||||
|
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
|
||||||
|
const readDirectory = adapter.storage.readDirectory.bind(adapter.storage);
|
||||||
|
const readDirectorySpy = vi.spyOn(adapter.storage, "readDirectory").mockImplementation(async (relativePath) => {
|
||||||
|
if (relativePath === "late-directory") throw error;
|
||||||
|
const entries = await readDirectory(relativePath);
|
||||||
|
return relativePath === "" && entries !== null
|
||||||
|
? [...entries].sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
|
||||||
|
: entries;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(adapter.getFiles()).rejects.toBe(error);
|
||||||
|
await expect(adapter.scanDirectory()).rejects.toBe(error);
|
||||||
|
} finally {
|
||||||
|
readDirectorySpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility-mode case-insensitive lookup exposes the cache without
|
||||||
|
// triggering another scan, so it can verify that the failed NFC scan did
|
||||||
|
// not publish its already-enumerated file metadata.
|
||||||
|
normalizationVersion = 0;
|
||||||
|
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||||
|
await expect(adapter.getAbstractFileByPathInsensitive("known.md")).resolves.toMatchObject({
|
||||||
|
path: knownPath,
|
||||||
|
stat: { size: initialContent.length },
|
||||||
|
});
|
||||||
|
expect(scanDirectory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses one comparison policy snapshot for the whole NFC scan", async () => {
|
it("uses one comparison policy snapshot for the whole NFC scan", async () => {
|
||||||
const directory = await createVault();
|
const directory = await createVault();
|
||||||
await fs.mkdir(path.join(directory, "Folder"));
|
await fs.mkdir(path.join(directory, "Folder"));
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
|
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { copyFile, mkdir, readFile, readdir, stat, utimes, writeFile } from "node:fs/promises";
|
import { copyFile, mkdir, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
import { runCli, runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
||||||
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
|
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
|
||||||
|
|
||||||
test("mirror: storage <-> DB synchronisation", async (t) => {
|
test("mirror: storage <-> DB synchronisation", async (t) => {
|
||||||
@@ -286,3 +286,62 @@ test("mirror: NFC identity retains physical Unicode spelling", async () => {
|
|||||||
assert.deepEqual(await readdir(vault), [parent]);
|
assert.deepEqual(await readdir(vault), [parent]);
|
||||||
assert.deepEqual(await readdir(workDir.join("vault", parent)), [leaf]);
|
assert.deepEqual(await readdir(workDir.join("vault", parent)), [leaf]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mirror: rejects an ambiguous NFC inventory without changing prior data and recovers", async (t) => {
|
||||||
|
await using workDir = await TempDir.create("livesync-cli-nfc-scan-failure");
|
||||||
|
const vault = workDir.join("vault");
|
||||||
|
const database = workDir.join("db");
|
||||||
|
const settings = workDir.join("settings.json");
|
||||||
|
const existingContent = "retained before failed scan\n";
|
||||||
|
const existingFile = workDir.join("vault", "known.md");
|
||||||
|
const nfcParent = "Café";
|
||||||
|
const nfdParent = nfcParent.normalize("NFD");
|
||||||
|
const nfcFile = workDir.join("vault", nfcParent, "from-nfc.md");
|
||||||
|
const nfdFile = workDir.join("vault", nfdParent, "from-nfd.md");
|
||||||
|
|
||||||
|
await mkdir(vault, { recursive: true });
|
||||||
|
await mkdir(database);
|
||||||
|
await initSettingsFile(settings);
|
||||||
|
await markSettingsConfigured(settings);
|
||||||
|
const data = JSON.parse(await readFile(settings, "utf8"));
|
||||||
|
data.pathNormalizationVersion = 1;
|
||||||
|
await writeFile(settings, JSON.stringify(data));
|
||||||
|
await writeFile(existingFile, existingContent);
|
||||||
|
|
||||||
|
const runMirror = () => runCliOrFail(database, "--settings", settings, "mirror", vault);
|
||||||
|
const runDatabase = (...args: string[]) => runCliOrFail(database, "--settings", settings, ...args);
|
||||||
|
|
||||||
|
await runMirror();
|
||||||
|
const before = JSON.parse(await runDatabase("info", "known.md"));
|
||||||
|
assert.equal(await runDatabase("cat-rev", "known.md", before.revision), existingContent);
|
||||||
|
|
||||||
|
await mkdir(workDir.join("vault", nfcParent), { recursive: true });
|
||||||
|
await mkdir(workDir.join("vault", nfdParent), { recursive: true });
|
||||||
|
await writeFile(nfcFile, "NFC spelling content\n");
|
||||||
|
await writeFile(nfdFile, "NFD spelling content\n");
|
||||||
|
const inventoryNames = await readdir(vault);
|
||||||
|
if (!inventoryNames.includes(nfcParent) || !inventoryNames.includes(nfdParent)) {
|
||||||
|
t.skip("This filesystem cannot represent both NFC and NFD directory entries separately");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedMirror = await runCli(database, "--settings", settings, "mirror", vault);
|
||||||
|
assert.notEqual(failedMirror.code, 0, "An ambiguous inventory must fail the mirror command");
|
||||||
|
assert(
|
||||||
|
!failedMirror.combined.includes("[Done] Command 'mirror' completed"),
|
||||||
|
"A failed inventory must not claim successful mirror completion"
|
||||||
|
);
|
||||||
|
assert.equal(await readFile(existingFile, "utf8"), existingContent);
|
||||||
|
assert.equal(await readFile(nfcFile, "utf8"), "NFC spelling content\n");
|
||||||
|
assert.equal(await readFile(nfdFile, "utf8"), "NFD spelling content\n");
|
||||||
|
assert.deepEqual(JSON.parse(await runDatabase("info", "known.md")), before);
|
||||||
|
assert.equal(await runDatabase("cat-rev", "known.md", before.revision), existingContent);
|
||||||
|
|
||||||
|
await rm(workDir.join("vault", nfdParent), { recursive: true });
|
||||||
|
const recoveredMirror = await runMirror();
|
||||||
|
assert(recoveredMirror.includes("[Done] Command 'mirror' completed"));
|
||||||
|
const recovered = JSON.parse(await runDatabase("info", `${nfcParent}/from-nfc.md`));
|
||||||
|
assert.equal(await runDatabase("cat-rev", `${nfcParent}/from-nfc.md`, recovered.revision), "NFC spelling content\n");
|
||||||
|
assert.equal(await readFile(existingFile, "utf8"), existingContent);
|
||||||
|
assert.deepEqual(JSON.parse(await runDatabase("info", "known.md")), before);
|
||||||
|
});
|
||||||
@@ -31,6 +31,7 @@ export default mergeConfig(
|
|||||||
"**/*.test.ts",
|
"**/*.test.ts",
|
||||||
"**/*unit.test.ts",
|
"**/*unit.test.ts",
|
||||||
"**/*.unit.spec.ts",
|
"**/*.unit.spec.ts",
|
||||||
|
"**/*.integration.spec.ts",
|
||||||
"test/**",
|
"test/**",
|
||||||
"**/_*",
|
"**/_*",
|
||||||
"**/*_obsolete.ts",
|
"**/*_obsolete.ts",
|
||||||
|
|||||||
Reference in new issue
Block a user