diff --git a/README.md b/README.md index 7882e66..4e528f2 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.3. Additional documentation see https://octomind.dev/docs/api-reference/ +Octomind cli tool. Version: 4.4.0. Additional documentation see https://octomind.dev/docs/api-reference/ **Usage:** `octomind [options] [command]` @@ -432,6 +432,7 @@ Pull test cases from the test target |:-------|:----------|:---------|:--------| | `-j, --json` | Output raw JSON response | No | | | `-t, --test-target-id [id]` | Test target ID, if not provided will use the test target id from the config | No | | +| `-p, --test-plan-id [id]` | Optional test plan ID to filter test cases | No | | ## push diff --git a/package.json b/package.json index b3909ef..f645a66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@octomind/octomind", - "version": "4.3.3", + "version": "4.4.0", "description": "a command line client for octomind apis", "main": "./dist/index.js", "packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316", diff --git a/src/cli.ts b/src/cli.ts index 2577f10..203c599 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -400,6 +400,10 @@ export const buildCmd = async (): Promise => { .description("Pull test cases from the test target") .helpGroup("test-cases") .addOption(testTargetIdOption) + .option( + "-p, --test-plan-id [id]", + "Optional test plan ID to filter test cases", + ) .action(addTestTargetWrapper(pullTestTarget)); // noinspection RequiredAttributes diff --git a/src/tools/index.ts b/src/tools/index.ts index e6da8ad..b8d3045 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -4,5 +4,6 @@ export * from "./notifications"; export * from "./playwright"; export * from "./private-locations"; export * from "./test-cases"; +export * from "./test-plans"; export * from "./test-reports"; export * from "./test-targets"; diff --git a/src/tools/sync/yaml.ts b/src/tools/sync/yaml.ts index 4114c35..32fa285 100644 --- a/src/tools/sync/yaml.ts +++ b/src/tools/sync/yaml.ts @@ -57,10 +57,12 @@ export const writeSingleTestCaseYaml = async ( export const writeYaml = async ( data: TestTargetSyncData, destination?: string, + partialSync = false, ): Promise => { cleanupFilesystem({ remoteTestCases: data.testCases, destination, + partialSync, }); for (const testCase of data.testCases) { @@ -231,9 +233,11 @@ export const removeEmptyDirectoriesRecursively = ( export const cleanupFilesystem = ({ remoteTestCases, destination, + partialSync, }: { remoteTestCases: SyncTestCase[]; destination: string | undefined; + partialSync: boolean; }) => { const rootFolderPath = destination ?? process.cwd(); @@ -270,13 +274,18 @@ 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); + if (!partialSync) { + 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/src/tools/test-plans.ts b/src/tools/test-plans.ts new file mode 100644 index 0000000..4a98f62 --- /dev/null +++ b/src/tools/test-plans.ts @@ -0,0 +1,30 @@ +import type { components, paths } from "../api"; +import { client, handleError } from "./client"; + +export type TestPlanResponse = components["schemas"]["ExternalTestPlanSchema"]; +export type GetTestPlanParams = + paths["/apiKey/beta/test-targets/{testTargetId}/test-plans/{id}"]["get"]["parameters"]["path"]; + +export const getTestPlan = async ( + options: GetTestPlanParams, +): Promise => { + const { data, error } = await client.GET( + "/apiKey/beta/test-targets/{testTargetId}/test-plans/{id}", + { + params: { + path: { + id: options.id, + testTargetId: options.testTargetId, + }, + }, + }, + ); + + handleError(error); + + if (!data) { + throw new Error(`No test plan with id ${options.id} found`); + } + + return data; +}; diff --git a/src/tools/test-targets.ts b/src/tools/test-targets.ts index 2cb64e4..dc6e8f1 100644 --- a/src/tools/test-targets.ts +++ b/src/tools/test-targets.ts @@ -70,7 +70,7 @@ export const listTestTargets = async (options: ListOptions): Promise => { }; export const pullTestTarget = async ( - options: { testTargetId: string } & ListOptions, + options: { testTargetId: string; testPlanId?: string } & ListOptions, ): Promise => { const { data, error } = await client.GET( "/apiKey/beta/test-targets/{testTargetId}/pull", @@ -79,6 +79,11 @@ export const pullTestTarget = async ( path: { testTargetId: options.testTargetId, }, + query: options.testPlanId + ? { + testPlanId: options.testPlanId, + } + : undefined, }, }, ); @@ -97,7 +102,7 @@ export const pullTestTarget = async ( const destination = (await findOctomindFolder()) ?? path.join(process.cwd(), OCTOMIND_FOLDER_NAME); - await writeYaml(data, destination); + await writeYaml(data, destination, !!options.testPlanId); logger.info("Test Target pulled successfully"); }; diff --git a/tests/tools/sync/yaml.spec.ts b/tests/tools/sync/yaml.spec.ts index c4d3cff..a200340 100644 --- a/tests/tools/sync/yaml.spec.ts +++ b/tests/tools/sync/yaml.spec.ts @@ -230,6 +230,7 @@ describe("yaml", () => { cleanupFilesystem({ remoteTestCases: [updatedTestCase], destination: tmpDir, + partialSync: false, }); expect(fs.existsSync(oldFilePath)).toBe(false); @@ -267,6 +268,7 @@ describe("yaml", () => { cleanupFilesystem({ remoteTestCases: [updatedTestCase], destination: tmpDir, + partialSync: false, }); expect(fs.existsSync(oldFolderPath)).toBe(false); @@ -293,6 +295,7 @@ describe("yaml", () => { cleanupFilesystem({ remoteTestCases: [remoteTestCase], destination: tmpDir, + partialSync: false, }); expect(fs.existsSync(localFilePath)).toBe(false); @@ -328,6 +331,7 @@ describe("yaml", () => { cleanupFilesystem({ remoteTestCases: [parentTestCase], destination: tmpDir, + partialSync: false, }); expect(fs.existsSync(childFilePath)).toBe(false); @@ -373,12 +377,48 @@ describe("yaml", () => { cleanupFilesystem({ remoteTestCases: [parentTestCase, childTestCase2], destination: tmpDir, + partialSync: false, }); expect(fs.existsSync(childFilePath1)).toBe(false); expect(fs.existsSync(childFilePath2)).toBe(true); expect(fs.existsSync(parentDir)).toBe(true); }); + + it("should not remove local test cases when partialSync is true", () => { + 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), + ); + const remoteFilePath = path.join( + tmpDir, + buildFilename(remoteTestCase, tmpDir), + ); + + fs.writeFileSync(localFilePath, yaml.stringify(localTestCase)); + fs.writeFileSync(remoteFilePath, yaml.stringify(remoteTestCase)); + + expect(fs.existsSync(localFilePath)).toBe(true); + expect(fs.existsSync(remoteFilePath)).toBe(true); + + cleanupFilesystem({ + remoteTestCases: [remoteTestCase], + destination: tmpDir, + partialSync: true, + }); + + expect(fs.existsSync(localFilePath)).toBe(true); + expect(fs.existsSync(remoteFilePath)).toBe(true); + }); }); describe("removeEmptyDirectoriesRecursively", () => { diff --git a/tests/tools/test-plans.spec.ts b/tests/tools/test-plans.spec.ts new file mode 100644 index 0000000..510b70f --- /dev/null +++ b/tests/tools/test-plans.spec.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { client, handleError } from "../../src/tools/client"; +import { getTestPlan } from "../../src/tools/test-plans"; + +vi.mock("../../src/tools/client"); + +describe("test-plans", () => { + beforeEach(() => { + console.log = vi.fn(); + }); + + describe("getTestPlan", () => { + it("should retrieve a test plan by id", async () => { + const testPlanId = "test-plan-123"; + const mockTestPlan = { + id: testPlanId, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + status: "DONE", + testTargetId: "test-target-123", + environmentId: "env-123", + baseUrl: "https://example.com", + prompt: "Test plan prompt", + traceUrl: "https://trace.example.com", + videoUrl: null, + context: {}, + }; + + vi.mocked(client.GET).mockResolvedValue({ + data: mockTestPlan, + error: undefined, + response: mock(), + }); + + const result = await getTestPlan({ + id: testPlanId, + testTargetId: "test-target-123", + }); + + expect(client.GET).toHaveBeenCalledWith( + "/apiKey/beta/test-targets/{testTargetId}/test-plans/{id}", + { + params: { + path: { + id: testPlanId, + testTargetId: "test-target-123", + }, + }, + }, + ); + expect(handleError).toHaveBeenCalledWith(undefined); + expect(result).toEqual(mockTestPlan); + }); + + it("should handle error when test plan is not found", async () => { + const testPlanId = "non-existent-id"; + + vi.mocked(client.GET).mockResolvedValue({ + data: undefined, + error: { message: "Not found" }, + response: mock(), + }); + + await expect( + getTestPlan({ id: testPlanId, testTargetId: "test-target-123" }), + ).rejects.toThrow(`No test plan with id ${testPlanId} found`); + expect(handleError).toHaveBeenCalledWith({ message: "Not found" }); + }); + }); +});