diff --git a/src/apps/cli/adapters/NodeFileSystemAdapter.ts b/src/apps/cli/adapters/NodeFileSystemAdapter.ts index acc7596e..d386b7cf 100644 --- a/src/apps/cli/adapters/NodeFileSystemAdapter.ts +++ b/src/apps/cli/adapters/NodeFileSystemAdapter.ts @@ -92,10 +92,9 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { - if (this.fileCache.size === 0) { - await this.scanDirectory(); - } - return Array.from(this.fileCache.values()); + const files = new Map(); + await this.scanDirectoryInto("", files); + return Array.from(files.values()); } async renameFile(file: NodeFile, newPath: string): Promise { @@ -147,6 +146,10 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { + await this.scanDirectoryInto(relativePath, this.fileCache); + } + + private async scanDirectoryInto(relativePath: string, files: Map): Promise { const fullPath = this.resolvePath(relativePath); try { const directoryStat = await this.storage.stat(relativePath); @@ -160,10 +163,10 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { + const tempDirs: string[] = []; + const paths = ["a.md", "folder/b.md", "folder/sub/c.md"]; + + async function createVault() { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-enumeration-")); + tempDirs.push(directory); + for (const file of paths) { + 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("lists every file when one file was refreshed before the first enumeration", async () => { + const { adapter } = await createVault(); + + expect(await adapter.refreshFile("folder/b.md")).not.toBeNull(); + + expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths); + }); + + it("lists every file after a path lookup without any replication", async () => { + const { adapter } = await createVault(); + + expect((await adapter.getAbstractFileByPath("folder/b.md"))?.path).toBe("folder/b.md"); + + expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths); + }); + + it("lists every file on the first enumeration without a prior path lookup", async () => { + const { adapter } = await createVault(); + + expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths); + }); + + it("excludes a deleted file after its cache entry is refreshed", async () => { + const { directory, adapter } = await createVault(); + await adapter.getFiles(); + + await fs.rm(path.join(directory, "folder/b.md")); + expect(await adapter.refreshFile("folder/b.md")).toBeNull(); + + expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(["a.md", "folder/sub/c.md"]); + }); + + it("reflects files added and deleted between enumerations", async () => { + const { directory, adapter } = await createVault(); + + expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(paths); + + await fs.rm(path.join(directory, "folder/b.md")); + const updatedContent = "updated content of a.md"; + await fs.writeFile(path.join(directory, "a.md"), updatedContent); + await fs.writeFile(path.join(directory, "later.md"), "content of later.md"); + + const files = await adapter.getFiles(); + expect(files.map((file) => file.path).sort()).toEqual(["a.md", "folder/sub/c.md", "later.md"]); + expect(files.find((file) => file.path === "a.md")?.stat.size).toBe(updatedContent.length); + }); + + it("returns complete listings from simultaneous calls", async () => { + const { adapter } = await createVault(); + + const originalStat = adapter.storage.stat.bind(adapter.storage); + let releaseFolderStat!: () => void; + const folderStatReleased = new Promise((resolve) => { + releaseFolderStat = resolve; + }); + let folderStatStarted!: () => void; + const folderStatStartedPromise = new Promise((resolve) => { + folderStatStarted = resolve; + }); + let pauseFolderStat = true; + const statSpy = vi.spyOn(adapter.storage, "stat").mockImplementation(async (relativePath) => { + const stat = await originalStat(relativePath); + if (pauseFolderStat && relativePath === "folder") { + pauseFolderStat = false; + folderStatStarted(); + await folderStatReleased; + } + return stat; + }); + + const firstListing = adapter.getFiles(); + let listings: Awaited>[] | undefined; + try { + await folderStatStartedPromise; + const secondListing = adapter.getFiles(); + const secondFiles = await secondListing; + releaseFolderStat(); + const firstFiles = await firstListing; + listings = [secondFiles, firstFiles]; + } finally { + releaseFolderStat(); + statSpy.mockRestore(); + } + + if (!listings) throw new Error("Expected both concurrent listings to complete"); + expect(listings.map((files) => files.map((file) => file.path).sort())).toEqual([paths, paths]); + }); + + it("returns an empty listing for an empty vault", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-enumeration-empty-")); + tempDirs.push(directory); + const adapter = new NodeFileSystemAdapter(directory); + + await expect(adapter.getFiles()).resolves.toEqual([]); + }); +}); diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index 40c71da9..557e591a 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -12,7 +12,7 @@ "buildRun": "npm run build && npm run cli --", "build:docker": "docker build -f Dockerfile -t livesync-cli ../../..", "check": "tsc -p tsconfig.json", - "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts", + "test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts", "test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh", "test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh", "test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh", diff --git a/updates.md b/updates.md index 23b0dad4..a59de8a8 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +### Fixed + +- CLI: file enumeration now includes current files even after individual path lookups or earlier scans. + ## 1.0.28 9th September, 2026