diff --git a/README.md b/README.md index e09e0e4..7882e66 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ This way even entityIds like environmentIds or testCaseIds will be autocompleted # octomind -Octomind cli tool. Version: 4.3.2. Additional documentation see https://octomind.dev/docs/api-reference/ +Octomind cli tool. Version: 4.3.3. Additional documentation see https://octomind.dev/docs/api-reference/ **Usage:** `octomind [options] [command]` diff --git a/package.json b/package.json index 1a780df..b3909ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@octomind/octomind", - "version": "4.3.2", + "version": "4.3.3", "description": "a command line client for octomind apis", "main": "./dist/index.js", "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316", diff --git a/src/tools/sync/yaml.ts b/src/tools/sync/yaml.ts index edf2d28..4114c35 100644 --- a/src/tools/sync/yaml.ts +++ b/src/tools/sync/yaml.ts @@ -59,7 +59,7 @@ export const writeYaml = async ( destination?: string, ): Promise => { cleanupFilesystem({ - newTestCases: data.testCases, + remoteTestCases: data.testCases, destination, }); @@ -178,9 +178,11 @@ const collectYamlFiles = (startDir: string): string[] => { return files; }; -export const readTestCasesFromDir = (startDir: string): SyncTestCase[] => { +export const readTestCasesFromDir = ( + startDir: string, +): Array => { const yamlFiles = collectYamlFiles(startDir); - const testCases: SyncTestCase[] = []; + const testCases: Array = []; for (const file of yamlFiles) { try { const content = fs.readFileSync(file, "utf8"); @@ -188,7 +190,7 @@ export const readTestCasesFromDir = (startDir: string): SyncTestCase[] => { const result = syncTestCaseSchema.safeParse(raw); if (result.success) { - testCases.push(result.data); + testCases.push({ ...result.data, filePath: file }); } else { logger.warn( `Failed to read test case from ${file}: ${result.error.message}`, @@ -211,20 +213,35 @@ export const loadTestCase = (testCasePath: string): SyncTestCase => { } }; +export const removeEmptyDirectoriesRecursively = ( + dirPath: string, + rootFolderPath: string, +): void => { + if (dirPath === rootFolderPath || !fs.existsSync(dirPath)) { + return; + } + + const remainingFiles = fs.readdirSync(dirPath); + if (remainingFiles.length === 0) { + fs.rmdirSync(dirPath); + removeEmptyDirectoriesRecursively(path.dirname(dirPath), rootFolderPath); + } +}; + export const cleanupFilesystem = ({ - newTestCases, + remoteTestCases, destination, }: { - newTestCases: SyncTestCase[]; + remoteTestCases: SyncTestCase[]; destination: string | undefined; }) => { const rootFolderPath = destination ?? process.cwd(); - const existingtestCases = readTestCasesFromDir(rootFolderPath); + const localTestCases = readTestCasesFromDir(rootFolderPath); - const existingTestCasesById = new Map( - existingtestCases.map((tc) => [tc.id, tc]), - ); + const localTestCasesById = new Map(localTestCases.map((tc) => [tc.id, tc])); + + const remoteTestCasesById = new Map(remoteTestCases.map((tc) => [tc.id, tc])); // There is generally a bigger issue here: // We need a better check what changed locally. @@ -232,20 +249,17 @@ export const cleanupFilesystem = ({ // Then you pull, and you local changes will just be deleted. // Same applies for changing the dependency, as it will be in a different folder. We also don't clean up these folders properly. - for (const testCase of newTestCases) { - const existingTestCase = existingTestCasesById.get(testCase.id); - if (existingTestCase) { - const existingTestCasePath = buildFilename( - existingTestCase, - rootFolderPath, - ); + for (const remoteTestCase of remoteTestCases) { + const localTestCase = localTestCasesById.get(remoteTestCase.id); + if (localTestCase) { + const localTestCasePath = buildFilename(localTestCase, rootFolderPath); const oldFolderPath = path.join( rootFolderPath, - existingTestCasePath.replace(/\.yaml$/, ""), + localTestCasePath.replace(/\.yaml$/, ""), ); - const oldFilePath = path.join(rootFolderPath, existingTestCasePath); + const oldFilePath = path.join(rootFolderPath, localTestCasePath); - if (existingTestCase.description !== testCase.description) { + if (localTestCase.description !== remoteTestCase.description) { if (fs.existsSync(oldFilePath)) { fs.unlinkSync(oldFilePath); } @@ -256,4 +270,13 @@ export const cleanupFilesystem = ({ } } } + for (const localTestCase of localTestCases) { + // If the local test case is not in the remote test cases, remove it + if (!remoteTestCasesById.has(localTestCase.id) && localTestCase.filePath) { + fs.rmSync(localTestCase.filePath, { force: true }); + + const dirPath = path.dirname(localTestCase.filePath); + removeEmptyDirectoriesRecursively(dirPath, rootFolderPath); + } + } }; diff --git a/tests/tools/sync/yaml.spec.ts b/tests/tools/sync/yaml.spec.ts index d143e02..c4d3cff 100644 --- a/tests/tools/sync/yaml.spec.ts +++ b/tests/tools/sync/yaml.spec.ts @@ -10,6 +10,7 @@ import { buildFolderName, cleanupFilesystem, readTestCasesFromDir, + removeEmptyDirectoriesRecursively, } from "../../../src/tools/sync/yaml"; import { createMockSyncTestCase } from "../../mocks"; @@ -186,11 +187,9 @@ describe("yaml", () => { fs.mkdirSync(path.join(tmpDir, "test1")); const testCase = createMockSyncTestCase(); - fs.writeFileSync( - path.join(tmpDir, "test1", "test.yaml"), - yaml.stringify(testCase), - ); - expect(readTestCasesFromDir(tmpDir)).toEqual([testCase]); + const filePath = path.join(tmpDir, "test1", "test.yaml"); + fs.writeFileSync(filePath, yaml.stringify(testCase)); + expect(readTestCasesFromDir(tmpDir)).toEqual([{ ...testCase, filePath }]); }); it("should skip invalid test cases and log an error", () => { @@ -229,7 +228,7 @@ describe("yaml", () => { }; cleanupFilesystem({ - newTestCases: [updatedTestCase], + remoteTestCases: [updatedTestCase], destination: tmpDir, }); @@ -266,11 +265,196 @@ describe("yaml", () => { }; cleanupFilesystem({ - newTestCases: [updatedTestCase], + remoteTestCases: [updatedTestCase], destination: tmpDir, }); expect(fs.existsSync(oldFolderPath)).toBe(false); }); + + it("should remove local test case files that no longer exist remotely", () => { + const localTestCase = createMockSyncTestCase({ + id: crypto.randomUUID(), + description: "Local test case", + }); + const remoteTestCase = createMockSyncTestCase({ + id: crypto.randomUUID(), + description: "Remote test case", + }); + + const localFilePath = path.join( + tmpDir, + buildFilename(localTestCase, tmpDir), + ); + fs.writeFileSync(localFilePath, yaml.stringify(localTestCase)); + + expect(fs.existsSync(localFilePath)).toBe(true); + + cleanupFilesystem({ + remoteTestCases: [remoteTestCase], + destination: tmpDir, + }); + + expect(fs.existsSync(localFilePath)).toBe(false); + }); + + it("should remove empty directories after removing local test cases", () => { + const parentTestCase = createMockSyncTestCase({ + id: crypto.randomUUID(), + description: "Parent test case", + }); + const childTestCase = createMockSyncTestCase({ + id: crypto.randomUUID(), + description: "Child test case", + dependencyId: parentTestCase.id, + }); + + const parentDir = path.join(tmpDir, "parentTestCase"); + fs.mkdirSync(parentDir, { recursive: true }); + + const childFilePath = path.join( + parentDir, + buildFilename(childTestCase, tmpDir), + ); + fs.writeFileSync( + path.join(tmpDir, buildFilename(parentTestCase, tmpDir)), + yaml.stringify(parentTestCase), + ); + fs.writeFileSync(childFilePath, yaml.stringify(childTestCase)); + + expect(fs.existsSync(parentDir)).toBe(true); + expect(fs.existsSync(childFilePath)).toBe(true); + + cleanupFilesystem({ + remoteTestCases: [parentTestCase], + destination: tmpDir, + }); + + expect(fs.existsSync(childFilePath)).toBe(false); + expect(fs.existsSync(parentDir)).toBe(false); + }); + + it("should not remove directories that still contain files", () => { + const parentTestCase = createMockSyncTestCase({ + id: crypto.randomUUID(), + description: "Parent test case", + }); + const childTestCase1 = createMockSyncTestCase({ + id: crypto.randomUUID(), + description: "Child test case 1", + dependencyId: parentTestCase.id, + }); + const childTestCase2 = createMockSyncTestCase({ + id: crypto.randomUUID(), + description: "Child test case 2", + dependencyId: parentTestCase.id, + }); + + const parentDir = path.join(tmpDir, "parentTestCase"); + fs.mkdirSync(parentDir, { recursive: true }); + + const childFilePath1 = path.join( + parentDir, + buildFilename(childTestCase1, tmpDir), + ); + const childFilePath2 = path.join( + parentDir, + buildFilename(childTestCase2, tmpDir), + ); + fs.writeFileSync( + path.join(tmpDir, buildFilename(parentTestCase, tmpDir)), + yaml.stringify(parentTestCase), + ); + fs.writeFileSync(childFilePath1, yaml.stringify(childTestCase1)); + fs.writeFileSync(childFilePath2, yaml.stringify(childTestCase2)); + + expect(fs.existsSync(parentDir)).toBe(true); + + cleanupFilesystem({ + remoteTestCases: [parentTestCase, childTestCase2], + destination: tmpDir, + }); + + expect(fs.existsSync(childFilePath1)).toBe(false); + expect(fs.existsSync(childFilePath2)).toBe(true); + expect(fs.existsSync(parentDir)).toBe(true); + }); + }); + + describe("removeEmptyDirectoriesRecursively", () => { + it("should remove a single empty directory", () => { + const emptyDir = path.join(tmpDir, "emptyDir"); + fs.mkdirSync(emptyDir); + + expect(fs.existsSync(emptyDir)).toBe(true); + + removeEmptyDirectoriesRecursively(emptyDir, tmpDir); + + expect(fs.existsSync(emptyDir)).toBe(false); + }); + + it("should recursively remove nested empty directories", () => { + const level1 = path.join(tmpDir, "level1"); + const level2 = path.join(level1, "level2"); + const level3 = path.join(level2, "level3"); + + fs.mkdirSync(level3, { recursive: true }); + + expect(fs.existsSync(level1)).toBe(true); + expect(fs.existsSync(level2)).toBe(true); + expect(fs.existsSync(level3)).toBe(true); + + removeEmptyDirectoriesRecursively(level3, tmpDir); + + expect(fs.existsSync(level3)).toBe(false); + expect(fs.existsSync(level2)).toBe(false); + expect(fs.existsSync(level1)).toBe(false); + }); + + it("should stop at root folder path", () => { + const subDir = path.join(tmpDir, "subDir"); + fs.mkdirSync(subDir); + + removeEmptyDirectoriesRecursively(subDir, tmpDir); + + expect(fs.existsSync(subDir)).toBe(false); + expect(fs.existsSync(tmpDir)).toBe(true); + }); + + it("should not remove directories that contain files", () => { + const level1 = path.join(tmpDir, "level1"); + const level2 = path.join(level1, "level2"); + + fs.mkdirSync(level2, { recursive: true }); + fs.writeFileSync(path.join(level1, "file.txt"), "content"); + + removeEmptyDirectoriesRecursively(level2, tmpDir); + + expect(fs.existsSync(level2)).toBe(false); + expect(fs.existsSync(level1)).toBe(true); + }); + + it("should handle non-existent directories gracefully", () => { + const nonExistent = path.join(tmpDir, "nonExistent"); + + expect(() => + removeEmptyDirectoriesRecursively(nonExistent, tmpDir), + ).not.toThrow(); + }); + + it("should not remove directories with subdirectories that contain files", () => { + const level1 = path.join(tmpDir, "level1"); + const level2 = path.join(level1, "level2"); + const level3 = path.join(level2, "level3"); + + fs.mkdirSync(level3, { recursive: true }); + fs.writeFileSync(path.join(level2, "file.txt"), "content"); + + removeEmptyDirectoriesRecursively(level3, tmpDir); + + expect(fs.existsSync(level3)).toBe(false); + expect(fs.existsSync(level2)).toBe(true); + expect(fs.existsSync(level1)).toBe(true); + }); }); });