mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 14:58:18 +00:00
Share application adapter and diagnostic helpers
This commit is contained in:
1 parent
4027417bf5
commit
b9dcb0d4c7
26 files changed
+937
-305
No files matched your search
@@ -8,6 +8,7 @@ import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for Node.js
|
||||
@@ -87,27 +88,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
}
|
||||
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await findCaseInsensitiveFile(
|
||||
pathStr,
|
||||
() => this.getAbstractFileByPath(pathStr),
|
||||
this.fileCache,
|
||||
() => this.scanDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(): Promise<NodeFile[]> {
|
||||
|
||||
@@ -124,6 +124,83 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter case-insensitive path lookup", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createVault(files: string[] = []) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-insensitive-"));
|
||||
tempDirs.push(directory);
|
||||
for (const file of files) {
|
||||
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true });
|
||||
await fs.writeFile(path.join(directory, file), `content of ${file}`);
|
||||
}
|
||||
return { directory, adapter: new NodeFileSystemAdapter(directory) };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("returns an exact path without scanning the directory", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
await adapter.scanDirectory();
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||
});
|
||||
|
||||
it("returns null after one scan when no cached path matches", async () => {
|
||||
const { adapter } = await createVault(["other.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns null and reports a directory scan failure", async () => {
|
||||
const missingDirectory = path.join(os.tmpdir(), `livesync-cli-insensitive-missing-${Date.now()}`);
|
||||
const reportDiagnostic = vi.fn();
|
||||
const adapter = new NodeFileSystemAdapter(missingDirectory, reportDiagnostic);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(reportDiagnostic).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error scanning directory"),
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
@@ -15,30 +14,7 @@ import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
|
||||
/**
|
||||
* CLI-specific type guard adapter
|
||||
*/
|
||||
class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, NodeFolder> {
|
||||
isFile(file: unknown): file is NodeFile {
|
||||
return !!(
|
||||
file &&
|
||||
typeof file === "object" &&
|
||||
"path" in file &&
|
||||
"stat" in file &&
|
||||
!(file as { isFolder?: boolean }).isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: unknown): item is NodeFolder {
|
||||
return !!(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"path" in item &&
|
||||
(item as { isFolder?: boolean }).isFolder === true
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
|
||||
|
||||
/**
|
||||
* CLI-specific persistence adapter (file-based snapshot)
|
||||
@@ -211,7 +187,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
* Composite adapter for CLI StorageEventManager
|
||||
*/
|
||||
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
||||
readonly typeGuard: CLITypeGuardAdapter;
|
||||
readonly typeGuard: NodeTypeGuardAdapter;
|
||||
readonly persistence: CLIPersistenceAdapter;
|
||||
readonly watch: CLIWatchAdapter;
|
||||
readonly status: CLIStatusAdapter;
|
||||
@@ -223,7 +199,7 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
||||
watchEnabled: boolean = false,
|
||||
reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||
) {
|
||||
this.typeGuard = new CLITypeGuardAdapter();
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
|
||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
||||
this.status = new CLIStatusAdapter();
|
||||
|
||||
@@ -201,3 +201,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLIStorageEventManagerAdapter type guard", () => {
|
||||
const typeGuard = new CLIStorageEventManagerAdapter("/base").typeGuard;
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { stat: {} }, expected: false },
|
||||
{ label: "an object without a stat", value: { path: "note.md" }, expected: false },
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: true },
|
||||
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: false },
|
||||
])("classifies $label as a file", ({ value, expected }) => {
|
||||
expect(typeGuard.isFile(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { isFolder: true }, expected: false },
|
||||
{ label: "an object without the folder marker", value: { path: "folder" }, expected: false },
|
||||
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: true },
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: false },
|
||||
])("classifies $label as a folder", ({ value, expected }) => {
|
||||
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
@@ -10,7 +8,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { IgnoreRules } from "./IgnoreRules";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||
|
||||
/**
|
||||
* Initialize service modules for CLI version
|
||||
@@ -82,40 +80,7 @@ export function initialiseServiceModulesCLI(
|
||||
vault: services.vault,
|
||||
});
|
||||
|
||||
// File handler (platform-independent)
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||
|
||||
return {
|
||||
rebuilder,
|
||||
|
||||
@@ -70,6 +70,21 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev
|
||||
console.log("[PASS] push/cat roundtrip matched");
|
||||
});
|
||||
|
||||
await t.step("push/pull binary roundtrip", async () => {
|
||||
const source = workDir.join("binary-source.bin");
|
||||
const restored = workDir.join("binary-restored.bin");
|
||||
const remotePath = "test/blob-roundtrip.bin";
|
||||
const content = Uint8Array.from({ length: 1025 }, (_, index) => index % 256);
|
||||
await Deno.writeFile(source, content);
|
||||
|
||||
await run("push", source, remotePath);
|
||||
await run("pull", remotePath, restored);
|
||||
|
||||
assertEquals(await Deno.readFile(restored), content, "Binary push/pull must preserve every byte");
|
||||
const info = JSON.parse(await run("info", remotePath));
|
||||
assertEquals(info.size, content.byteLength, "Binary Metadata must retain the original byte length");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ls: single file
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user