Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,10 @@ export const buildCmd = async (): Promise<CompletableCommand> => {
.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
Expand Down
1 change: 1 addition & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 16 additions & 7 deletions src/tools/sync/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,12 @@ export const writeSingleTestCaseYaml = async (
export const writeYaml = async (
data: TestTargetSyncData,
destination?: string,
partialSync = false,
): Promise<void> => {
cleanupFilesystem({
remoteTestCases: data.testCases,
destination,
partialSync,
});

for (const testCase of data.testCases) {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
}
}
}
};
30 changes: 30 additions & 0 deletions src/tools/test-plans.ts
Original file line number Diff line number Diff line change
@@ -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<TestPlanResponse> => {
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;
};
9 changes: 7 additions & 2 deletions src/tools/test-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const listTestTargets = async (options: ListOptions): Promise<void> => {
};

export const pullTestTarget = async (
options: { testTargetId: string } & ListOptions,
options: { testTargetId: string; testPlanId?: string } & ListOptions,
): Promise<void> => {
const { data, error } = await client.GET(
"/apiKey/beta/test-targets/{testTargetId}/pull",
Expand All @@ -79,6 +79,11 @@ export const pullTestTarget = async (
path: {
testTargetId: options.testTargetId,
},
query: options.testPlanId
? {
testPlanId: options.testPlanId,
}
: undefined,
},
},
);
Expand All @@ -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");
};
Expand Down
40 changes: 40 additions & 0 deletions tests/tools/sync/yaml.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ describe("yaml", () => {
cleanupFilesystem({
remoteTestCases: [updatedTestCase],
destination: tmpDir,
partialSync: false,
});

expect(fs.existsSync(oldFilePath)).toBe(false);
Expand Down Expand Up @@ -267,6 +268,7 @@ describe("yaml", () => {
cleanupFilesystem({
remoteTestCases: [updatedTestCase],
destination: tmpDir,
partialSync: false,
});

expect(fs.existsSync(oldFolderPath)).toBe(false);
Expand All @@ -293,6 +295,7 @@ describe("yaml", () => {
cleanupFilesystem({
remoteTestCases: [remoteTestCase],
destination: tmpDir,
partialSync: false,
});

expect(fs.existsSync(localFilePath)).toBe(false);
Expand Down Expand Up @@ -328,6 +331,7 @@ describe("yaml", () => {
cleanupFilesystem({
remoteTestCases: [parentTestCase],
destination: tmpDir,
partialSync: false,
});

expect(fs.existsSync(childFilePath)).toBe(false);
Expand Down Expand Up @@ -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", () => {
Expand Down
72 changes: 72 additions & 0 deletions tests/tools/test-plans.spec.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
});