diff --git a/src/cli.ts b/src/cli.ts index bdf004b..52c5ccf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,6 +38,7 @@ import { } from "./tools"; import { init, switchTestTarget } from "./tools/init"; import { update } from "./tools/update"; +import { create } from "./tools/yamlMutations/create"; import { edit } from "./tools/yamlMutations/edit"; import { version } from "./version"; @@ -402,6 +403,24 @@ export const buildCmd = (): CompletableCommand => { .addOption(testTargetIdOption) .action(addTestTargetWrapper(pushTestTarget)); + // noinspection RequiredAttributes + createCommandWithCommonOptions(program, "create-test-case") + .completer(testTargetIdCompleter) + .description("Create a new test case") + .helpGroup("test-cases") + .addOption(testTargetIdOption) + .requiredOption( + "-n, --name ", + "The name of the test case you want to create", + ) + .addOption( + new Option( + "-d, --dependency-path ", + "The path of to test case you want to use as dependency", + ), + ) + .action(addTestTargetWrapper(create)); + // noinspection RequiredAttributes createCommandWithCommonOptions(program, "edit-test-case") .completer(testTargetIdCompleter) diff --git a/src/tools/sync/yaml.ts b/src/tools/sync/yaml.ts index f7e89cb..1a00a0f 100644 --- a/src/tools/sync/yaml.ts +++ b/src/tools/sync/yaml.ts @@ -201,6 +201,15 @@ export const readTestCasesFromDir = (startDir: string): SyncTestCase[] => { return testCases; }; +export const loadTestCase = (testCasePath: string): SyncTestCase => { + try { + const content = fs.readFileSync(testCasePath, "utf8"); + return yaml.parse(content); + } catch (error) { + throw new Error(`Could not parse ${testCasePath}: ${error}`); + } +}; + export const cleanupFilesystem = ({ newTestCases, destination, diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts new file mode 100644 index 0000000..d819b50 --- /dev/null +++ b/src/tools/yamlMutations/create.ts @@ -0,0 +1,180 @@ +import path from "node:path"; + +import { createTwoFilesPatch } from "diff"; +import open from "open"; +import yaml from "yaml"; + +import { OCTOMIND_FOLDER_NAME } from "../../constants"; +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, +} from "../../helpers"; +import { BASE_URL, client, handleError } from "../client"; +import { checkForConsistency } from "../sync/consistency"; +import { draftPush } from "../sync/push"; +import { SyncDataByStableId, SyncTestCase } from "../sync/types"; +import { + buildFilename, + buildFolderName, + loadTestCase, + readTestCasesFromDir, + writeSingleTestCaseYaml, +} from "../sync/yaml"; +import { getRelevantTestCases } from "./getRelevantTestCases"; +import { waitForLocalChangesToBeFinished } from "./waitForLocalChanges"; + +type CreateOptions = { + testTargetId: string; + name: string; + dependencyPath?: string; +}; + +const getDependencyTestCases = async ({ + octomindRoot, + dependencyPath, +}: { + octomindRoot: string; + dependencyPath: string; +}): Promise<{ + dependencyTestCase: SyncTestCase; + relevantTestCases: SyncTestCase[]; +}> => { + const dependencyFilePath = await getAbsoluteFilePathInOctomindRoot({ + octomindRoot, + filePath: dependencyPath, + }); + + if (!dependencyFilePath) { + throw new Error( + `Could not find dependency test case ${dependencyPath} in folder ${octomindRoot}`, + ); + } + const dependencyTestCase = loadTestCase(dependencyFilePath); + + const testCases = readTestCasesFromDir(octomindRoot); + const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); + + const relevantTestCases = getRelevantTestCases( + testCasesById, + dependencyTestCase, + ); + return { dependencyTestCase, relevantTestCases }; +}; + +const openBrowser = async ({ + versionId, + testTargetId, +}: { + versionId: string; + testTargetId: string; +}): Promise => { + const parsedBaseUrl = URL.parse(BASE_URL); + const localEditingUrl = new URL( + `${parsedBaseUrl?.protocol}//${parsedBaseUrl?.host}/testtargets/${testTargetId}/testcases/${versionId}/localEdit`, + ); + + await open(localEditingUrl.href); + + console.log( + `Navigating to local url, open it manually if a browser didn't open already: ${localEditingUrl}`, + ); +}; + +const writeOutput = async ({ + testCaseFilePath, + createResult, +}: { + testCaseFilePath: string; + createResult: SyncTestCase | "cancelled"; +}): Promise => { + if (createResult === "cancelled") { + console.log("Cancelled editing test case, exiting"); + return; + } + + await writeSingleTestCaseYaml(testCaseFilePath, createResult); + + const diff = createTwoFilesPatch( + "old.yaml", + "new.yaml", + "", + yaml.stringify(createResult), + ); + + console.log(`Created test case successfully`); + console.log(diff); +}; + +export const create = async (options: CreateOptions): Promise => { + const octomindRoot = await findOctomindFolder(); + if (!octomindRoot) { + throw new Error( + `Could not find ${OCTOMIND_FOLDER_NAME} folder, make sure to pull before trying to create a test case`, + ); + } + + let dependencyId: string | undefined = undefined; + const testCasesToPush: SyncTestCase[] = []; + if (options.dependencyPath) { + const { dependencyTestCase, relevantTestCases } = + await getDependencyTestCases({ + octomindRoot, + dependencyPath: options.dependencyPath, + }); + dependencyId = dependencyTestCase.id; + testCasesToPush.push(...relevantTestCases); + } + + const newTestCase: SyncTestCase = { + version: "1", + id: crypto.randomUUID(), + dependencyId, + elements: [], + runStatus: "OFF", + description: options.name, + prompt: "", + localEditingStatus: "IN_PROGRESS" as const, + }; + testCasesToPush.push(newTestCase); + checkForConsistency(testCasesToPush); + + const response = await draftPush( + { + testCases: testCasesToPush, + }, + { + testTargetId: options.testTargetId, + client, + onError: handleError, + }, + ); + + if (!response) { + throw new Error( + `Could not create new test case with id '${newTestCase.id}'`, + ); + } + const syncData = response.syncDataByStableId[newTestCase.id]; + if (!syncData) { + throw new Error(`Could not create test case with id '${newTestCase.id}'`); + } + + await openBrowser({ + versionId: syncData.versionId, + testTargetId: options.testTargetId, + }); + const createResult = await waitForLocalChangesToBeFinished( + syncData.versionId, + newTestCase, + { testTargetId: options.testTargetId }, + ); + + const testCaseFolder = buildFolderName( + newTestCase, + testCasesToPush, + octomindRoot, + ); + const newTestCaseName = buildFilename(newTestCase, octomindRoot); + const newTestCasePath = path.join(testCaseFolder, newTestCaseName); + await writeOutput({ createResult, testCaseFilePath: newTestCasePath }); +}; diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index 78723b9..b8fb58e 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -1,21 +1,20 @@ -import fs from "fs"; - import { createTwoFilesPatch } from "diff"; import open from "open"; -import ora from "ora"; import yaml from "yaml"; import { OCTOMIND_FOLDER_NAME } from "../../constants"; import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot, - sleep, } from "../../helpers"; import { BASE_URL, client, handleError } from "../client"; import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; -import { SyncTestCase } from "../sync/types"; -import { readTestCasesFromDir, writeSingleTestCaseYaml } from "../sync/yaml"; +import { + loadTestCase, + readTestCasesFromDir, + writeSingleTestCaseYaml, +} from "../sync/yaml"; import { getRelevantTestCases } from "./getRelevantTestCases"; import { waitForLocalChangesToBeFinished } from "./waitForLocalChanges"; @@ -24,15 +23,6 @@ type EditOptions = { filePath: string; }; -const loadTestCase = (testCasePath: string): SyncTestCase => { - try { - const content = fs.readFileSync(testCasePath, "utf8"); - return yaml.parse(content); - } catch (error) { - throw new Error(`Could not parse ${testCasePath}: ${error}`); - } -}; - export const edit = async (options: EditOptions): Promise => { const octomindRoot = await findOctomindFolder(); if (!octomindRoot) { diff --git a/tests/mocks.ts b/tests/mocks.ts index 3a87cd2..42342b8 100644 --- a/tests/mocks.ts +++ b/tests/mocks.ts @@ -3,7 +3,7 @@ import { TestCaseResponse, TestReport, } from "../src/tools"; -import { SyncTestCase } from "../src/tools/sync/types"; +import { SyncDataByStableId, SyncTestCase } from "../src/tools/sync/types"; export const createMockSyncTestCase = ( overrides?: Partial, @@ -76,3 +76,18 @@ export const createMockTestReport = ( updatedAt: new Date().toISOString(), ...overrides, }); + +export const createMockDraftPushResponse = ( + testCaseId: string, + overrides?: Partial, +): { + success: boolean; + versionIds: string[]; + syncDataByStableId: SyncDataByStableId; +} => ({ + success: true, + versionIds: [], + syncDataByStableId: { + [testCaseId]: { versionId: "version-123", ...overrides }, + }, +}); diff --git a/tests/tools/yamlMutations/create.spec.ts b/tests/tools/yamlMutations/create.spec.ts new file mode 100644 index 0000000..eadbc73 --- /dev/null +++ b/tests/tools/yamlMutations/create.spec.ts @@ -0,0 +1,188 @@ +import open from "open"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, +} from "../../../src/helpers"; +import { draftPush } from "../../../src/tools/sync/push"; +import { + buildFilename, + buildFolderName, + loadTestCase, + readTestCasesFromDir, + writeSingleTestCaseYaml, +} from "../../../src/tools/sync/yaml"; +import { create } from "../../../src/tools/yamlMutations/create"; +import { waitForLocalChangesToBeFinished } from "../../../src/tools/yamlMutations/waitForLocalChanges"; +import { + createMockDraftPushResponse, + createMockSyncTestCase, +} from "../../mocks"; + +vi.mock("open"); +vi.mock("../../../src/helpers"); +vi.mock("../../../src/tools/client"); +vi.mock("../../../src/tools/sync/push"); +vi.mock("../../../src/tools/sync/yaml"); +vi.mock("../../../src/tools/sync/consistency"); +vi.mock("../../../src/tools/yamlMutations/waitForLocalChanges"); + +describe("create", () => { + const GENERATED_TEST_ID = "00000000-0000-0000-0000-000000000000"; + + beforeEach(() => { + console.log = vi.fn(); + + vi.spyOn(crypto, "randomUUID").mockReturnValue(GENERATED_TEST_ID); + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(draftPush).mockResolvedValue( + createMockDraftPushResponse(GENERATED_TEST_ID), + ); + vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue( + createMockSyncTestCase({ id: GENERATED_TEST_ID }), + ); + + vi.mocked(readTestCasesFromDir).mockReturnValue([]); + vi.mocked(buildFolderName).mockReturnValue("/mock/.octomind"); + vi.mocked(buildFilename).mockReturnValue("new-test.yaml"); + }); + + it("throws if octomind folder is not found", async () => { + vi.mocked(findOctomindFolder).mockResolvedValue(null); + + await expect( + create({ testTargetId: "someId", name: "Test Name" }), + ).rejects.toThrow("Could not find .octomind folder"); + }); + + it("throws if dependency path is not found", async () => { + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue(null); + + await expect( + create({ + testTargetId: "someId", + name: "Test Name", + dependencyPath: "missing.yaml", + }), + ).rejects.toThrow("Could not find dependency test case missing.yaml"); + }); + + it("throws if dependency test case file cannot be parsed", async () => { + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/dependency.yaml", + ); + vi.mocked(loadTestCase).mockImplementation(() => { + throw new Error("Could not parse /mock/.octomind/dependency.yaml"); + }); + + await expect( + create({ + testTargetId: "someId", + name: "Test Name", + dependencyPath: "dependency.yaml", + }), + ).rejects.toThrow("Could not parse"); + }); + + it("throws if draftPush returns no response", async () => { + vi.mocked(draftPush).mockResolvedValue(undefined); + + await expect( + create({ testTargetId: "someId", name: "Test Name" }), + ).rejects.toThrow("Could not create new test case"); + }); + + it("throws if versionId is not returned for test case", async () => { + vi.mocked(draftPush).mockResolvedValue({ + success: true, + versionIds: [], + syncDataByStableId: {}, + }); + + await expect( + create({ testTargetId: "someId", name: "Test Name" }), + ).rejects.toThrow("Could not create test case"); + }); + + it("exits gracefully when creation is cancelled", async () => { + vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue("cancelled"); + + await create({ testTargetId: "someId", name: "Test Name" }); + + expect(console.log).toHaveBeenCalledWith( + "Cancelled editing test case, exiting", + ); + }); + + it("exits gracefully when creation is finished", async () => { + await create({ testTargetId: "someId", name: "Test Name" }); + + expect(console.log).toHaveBeenCalledWith("Created test case successfully"); + expect(writeSingleTestCaseYaml).toHaveBeenCalledWith( + "/mock/.octomind/new-test.yaml", + expect.objectContaining({ id: GENERATED_TEST_ID }), + ); + }); + + it("creates test case with correct properties", async () => { + await create({ testTargetId: "someId", name: "My New Test" }); + + expect(draftPush).toHaveBeenCalledWith( + { + testCases: [ + expect.objectContaining({ + id: GENERATED_TEST_ID, + version: "1", + description: "My New Test", + runStatus: "OFF", + localEditingStatus: "IN_PROGRESS", + elements: [], + prompt: "", + dependencyId: undefined, + }), + ], + }, + expect.anything(), + ); + }); + + it("includes dependency when dependencyPath is provided", async () => { + const dependencyId = "dependency-id"; + const dependencyTestCase = createMockSyncTestCase({ id: dependencyId }); + + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/dependency.yaml", + ); + vi.mocked(loadTestCase).mockReturnValue(dependencyTestCase); + vi.mocked(readTestCasesFromDir).mockReturnValue([dependencyTestCase]); + + await create({ + testTargetId: "someId", + name: "Test With Dependency", + dependencyPath: "dependency.yaml", + }); + + expect(draftPush).toHaveBeenCalledWith( + { + testCases: expect.arrayContaining([ + expect.objectContaining({ + id: GENERATED_TEST_ID, + dependencyId, + }), + ]), + }, + expect.anything(), + ); + }); + + it("opens browser with correct URL", async () => { + await create({ testTargetId: "someId", name: "Test Name" }); + + expect(open).toHaveBeenCalledWith( + expect.stringContaining( + "/testtargets/someId/testcases/version-123/localEdit", + ), + ); + }); +}); diff --git a/tests/tools/yamlMutations/edit.spec.ts b/tests/tools/yamlMutations/edit.spec.ts index 4602013..dcb928c 100644 --- a/tests/tools/yamlMutations/edit.spec.ts +++ b/tests/tools/yamlMutations/edit.spec.ts @@ -1,23 +1,22 @@ -import fs from "fs"; - import open from "open"; -import ora from "ora"; -import { beforeEach, describe, expect, it, MockedObject, vi } from "vitest"; -import { mock } from "vitest-mock-extended"; -import yaml from "yaml"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot, } from "../../../src/helpers"; -import { client } from "../../../src/tools/client"; import { draftPush } from "../../../src/tools/sync/push"; -import { readTestCasesFromDir } from "../../../src/tools/sync/yaml"; +import { + loadTestCase, + readTestCasesFromDir, +} from "../../../src/tools/sync/yaml"; import { edit } from "../../../src/tools/yamlMutations/edit"; import { waitForLocalChangesToBeFinished } from "../../../src/tools/yamlMutations/waitForLocalChanges"; -import { createMockSyncTestCase } from "../../mocks"; +import { + createMockDraftPushResponse, + createMockSyncTestCase, +} from "../../mocks"; -vi.mock("fs"); vi.mock("open"); vi.mock("../../../src/helpers"); vi.mock("../../../src/tools/client"); @@ -27,18 +26,20 @@ vi.mock("../../../src/tools/sync/consistency"); vi.mock("../../../src/tools/yamlMutations/waitForLocalChanges"); describe("edit", () => { - let mockedClient: MockedObject; + const testCase = createMockSyncTestCase({ id: "test-id" }); beforeEach(() => { console.log = vi.fn(); - mockedClient = vi.mocked(client); - + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/test.yaml", + ); + vi.mocked(loadTestCase).mockReturnValue(testCase); + vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue( createMockSyncTestCase({ id: "test-id" }), ); - - vi.mocked(readTestCasesFromDir).mockReturnValue([]); }); it("throws if octomind folder is not found", async () => { @@ -50,7 +51,6 @@ describe("edit", () => { }); it("throws if file path is not found", async () => { - vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue(null); await expect( @@ -59,11 +59,9 @@ describe("edit", () => { }); it("throws if test case file cannot be parsed", async () => { - vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); - vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( - "/mock/.octomind/test.yaml", - ); - vi.mocked(fs.readFileSync).mockReturnValue("this: is: invalid: "); + vi.mocked(loadTestCase).mockImplementation(() => { + throw new Error("Could not parse /mock/.octomind/test.yaml"); + }); await expect( edit({ testTargetId: "someId", filePath: "test.yaml" }), @@ -71,14 +69,6 @@ describe("edit", () => { }); it("throws if draftPush returns no response", async () => { - const testCase = createMockSyncTestCase({ id: "test-id" }); - - vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); - vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( - "/mock/.octomind/test.yaml", - ); - vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); - vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); vi.mocked(draftPush).mockResolvedValue(undefined); await expect( @@ -87,14 +77,6 @@ describe("edit", () => { }); it("throws if versionId is not returned for test case", async () => { - const testCase = createMockSyncTestCase({ id: "test-id" }); - - vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); - vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( - "/mock/.octomind/test.yaml", - ); - vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); - vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); vi.mocked(draftPush).mockResolvedValue({ success: true, versionIds: [], @@ -107,30 +89,9 @@ describe("edit", () => { }); it("exits gracefully when editing is cancelled", async () => { - const testCase = createMockSyncTestCase({ id: "test-id" }); - - vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); - vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( - "/mock/.octomind/test.yaml", + vi.mocked(draftPush).mockResolvedValue( + createMockDraftPushResponse("test-id"), ); - vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); - vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); - vi.mocked(draftPush).mockResolvedValue({ - success: true, - versionIds: [], - syncDataByStableId: { "test-id": { versionId: "version-123" } }, - }); - vi.mocked(mockedClient.GET) - .mockResolvedValueOnce({ - data: { localEditingStatus: "IN_PROGRESS" }, - error: undefined, - response: mock(), - }) - .mockResolvedValueOnce({ - data: { localEditingStatus: "CANCELLED" }, - error: undefined, - response: mock(), - }); vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue("cancelled"); await edit({ testTargetId: "someId", filePath: "test.yaml" }); @@ -141,30 +102,9 @@ describe("edit", () => { }); it("exits gracefully when editing is finished", async () => { - const testCase = createMockSyncTestCase({ id: "test-id" }); - - vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); - vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( - "/mock/.octomind/test.yaml", + vi.mocked(draftPush).mockResolvedValue( + createMockDraftPushResponse("test-id"), ); - vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); - vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); - vi.mocked(draftPush).mockResolvedValue({ - success: true, - versionIds: [], - syncDataByStableId: { "test-id": { versionId: "version-123" } }, - }); - vi.mocked(mockedClient.GET) - .mockResolvedValueOnce({ - data: { localEditingStatus: "IN_PROGRESS" }, - error: undefined, - response: mock(), - }) - .mockResolvedValueOnce({ - data: { localEditingStatus: "DONE" }, - error: undefined, - response: mock(), - }); await edit({ testTargetId: "someId", filePath: "test.yaml" }); @@ -184,21 +124,9 @@ describe("edit", () => { testResultId, expectedUrl, }) => { - const testCase = createMockSyncTestCase({ id: "test-id" }); - - vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); - vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( - "/mock/.octomind/test.yaml", + vi.mocked(draftPush).mockResolvedValue( + createMockDraftPushResponse("test-id", { testResultId }), ); - vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); - vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); - vi.mocked(draftPush).mockResolvedValue({ - success: true, - versionIds: [], - syncDataByStableId: { - "test-id": { versionId: "version-123", testResultId }, - }, - }); vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue("cancelled"); await edit({ testTargetId: "someId", filePath: "test.yaml" });