diff --git a/README.md b/README.md index 425129d..ae8ce52 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: 3.7.0. Additional documentation see https://octomind.dev/docs/api-reference/ +Octomind cli tool. Version: 4.0.0. Additional documentation see https://octomind.dev/docs/api-reference/ **Usage:** `octomind [options] [command]` @@ -238,12 +238,11 @@ Execute local YAML test cases | `-j, --json` | Output raw JSON response | No | | | `-u, --url ` | url the tests should run against | Yes | | | `-e, --environment-id [uuid]` | id of the environment you want to run against, if not provided will run all test cases against the default environment | No | | -| `-t, --test-target-id [uuid]` | id of the test target of the test case, if not provided will use the test target id from the config | No | | +| `-t, --test-target-id [id]` | Test target ID, if not provided will use the test target id from the config | No | | | `--headless` | if we should run headless without the UI of playwright and the browser | No | | | `--bypass-proxy` | bypass proxy when accessing the test target | No | | | `--browser [CHROMIUM, FIREFOX, SAFARI]` | Browser type | No | CHROMIUM | | `--breakpoint [DESKTOP, MOBILE, TABLET]` | Breakpoint | No | DESKTOP | -| `-s, --source ` | Source directory (defaults to current directory) | Yes | ./.octomind | ## create-discovery @@ -432,7 +431,6 @@ 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 | | -| `-d, --destination ` | Destination folder | Yes | ./.octomind | ## push @@ -446,7 +444,6 @@ Push local YAML test cases to 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 | | -| `-s, --source ` | Source directory (defaults to current directory) | Yes | .octomind | ## Test Reports @@ -478,6 +475,24 @@ List all test targets |:-------|:----------|:---------|:--------| | `-j, --json` | Output raw JSON response | No | | +## Other Commands + +## show-octomind-dir + +**Usage:** `show-octomind-dir [options] [command]` + +### Options + +| Option | Description | Required | Default | +|:-------|:----------|:---------|:--------| +| `-j, --json` | Output raw JSON response | No | | + +### show-octomind-dir + +Show where the octomind directory containing your local test cases is located. + +**Usage:** `show-octomind-dir [options]` + ## Output Formats diff --git a/package.json b/package.json index d00b448..a42b336 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@octomind/octomind", - "version": "3.7.0", + "version": "4.0.0", "description": "a command line client for octomind apis", "main": "./dist/index.js", "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402", diff --git a/src/cli.ts b/src/cli.ts index 6be5942..4e1d9b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import { uninstallCompletion, } from "./completion"; import { executeLocalTestCases, runDebugtopus } from "./debugtopus"; +import { showOctomindDir } from "./dirManagement"; import { resolveTestTargetId } from "./helpers"; import { startPrivateLocationWorker, stopPLW } from "./plw"; import { @@ -181,10 +182,7 @@ export const buildCmd = (): CompletableCommand => { "-e, --environment-id [uuid]", "id of the environment you want to run against, if not provided will run all test cases against the default environment", ) - .option( - "-t, --test-target-id [uuid]", - "id of the test target of the test case, if not provided will use the test target id from the config", - ) + .addOption(testTargetIdOption) .option( "--headless", "if we should run headless without the UI of playwright and the browser", @@ -192,11 +190,6 @@ export const buildCmd = (): CompletableCommand => { .option("--bypass-proxy", "bypass proxy when accessing the test target") .option("--browser [CHROMIUM, FIREFOX, SAFARI]", "Browser type", "CHROMIUM") .option("--breakpoint [DESKTOP, MOBILE, TABLET]", "Breakpoint", "DESKTOP") - .option( - "-s, --source ", - "Source directory (defaults to current directory)", - "./.octomind", - ) .action(addTestTargetWrapper(executeLocalTestCases)); createCommandWithCommonOptions(program, "test-report") @@ -395,7 +388,6 @@ export const buildCmd = (): CompletableCommand => { .description("Pull test cases from the test target") .helpGroup("test-cases") .addOption(testTargetIdOption) - .option("-d, --destination ", "Destination folder", "./.octomind") .action(addTestTargetWrapper(pullTestTarget)); // noinspection RequiredAttributes @@ -404,13 +396,15 @@ export const buildCmd = (): CompletableCommand => { .description("Push local YAML test cases to the test target") .helpGroup("test-cases") .addOption(testTargetIdOption) - .option( - "-s, --source ", - "Source directory (defaults to current directory)", - ".octomind", - ) .action(addTestTargetWrapper(pushTestTarget)); + createCommandWithCommonOptions(program, "show-octomind-dir") + .description( + "Show where the octomind directory containing your local test cases is located.", + ) + .helpGroup("setup") + .action(showOctomindDir); + createCommandWithCommonOptions(program, "list-test-targets") .description("List all test targets") .helpGroup("test-targets") diff --git a/src/debugtopus/index.ts b/src/debugtopus/index.ts index c8de521..2ca3230 100644 --- a/src/debugtopus/index.ts +++ b/src/debugtopus/index.ts @@ -10,6 +10,7 @@ import { promisify } from "util"; import { Open } from "unzipper"; +import { getPathToOctomindDirWithActiveTestTarget } from "../dirManagement"; import { getEnvironments, getPlaywrightCode, @@ -262,9 +263,18 @@ export const runDebugtopus = async (options: DebugtopusOptions) => { }; export const executeLocalTestCases = async ( - options: DebugtopusOptions & { source: string }, + options: DebugtopusOptions & { + startDir?: string; + }, ): Promise => { - const testCases = readTestCasesFromDir(options.source); + const source = await getPathToOctomindDirWithActiveTestTarget({ + startDir: options.startDir, + providedTestTargetId: options.testTargetId, + }); + if (!source) { + throw new Error("No octomind directory found"); + } + const testCases = readTestCasesFromDir(source); const body = { testCases, testTargetId: options.testTargetId, diff --git a/src/dirManagement.ts b/src/dirManagement.ts new file mode 100644 index 0000000..c569a90 --- /dev/null +++ b/src/dirManagement.ts @@ -0,0 +1,123 @@ +import { existsSync, mkdirSync, readdirSync, rmdirSync } from "node:fs"; +import path from "node:path"; + +import { promptUser, resolveTestTargetId } from "./helpers"; +import { getTestTargets } from "./tools"; +import { toFileSystemCompatibleCamelCase } from "./tools/sync/yml"; + +export const OCTOMIND_DIR = ".octomind"; + +export const getPathToOctomindDirWithActiveTestTarget = async (options: { + startDir?: string; + providedTestTargetId?: string; + allowCreation?: boolean; +}): Promise => { + const testTargetId = await resolveTestTargetId(options.providedTestTargetId); + const octomindDir = await getPathToOctomindDir({ + allowCreation: options.allowCreation, + startDir: options.startDir, + }); + if (!octomindDir) { + return null; + } + const testTargets = await getTestTargets(); + const testTarget = testTargets.find((tt) => tt.id === testTargetId); + if (!testTarget) { + throw new Error(`Configured test target with id ${testTargetId} not found`); + } + const testTargetDirName = toFileSystemCompatibleCamelCase(testTarget.app); + const testTargetFolderPath = path.resolve( + path.join(octomindDir, testTargetDirName), + ); + if (!existsSync(testTargetFolderPath)) { + mkdirSync(testTargetFolderPath, { recursive: true }); + } + return testTargetFolderPath; +}; + +export const getPathToOctomindDir = async ({ + allowCreation, + startDir = process.cwd(), +}: { + allowCreation?: boolean; + startDir?: string; +} = {}): Promise => { + let currentDir = startDir; + // Make loop stop when reaching the root directory, in an OS-independent way + while (currentDir !== path.dirname(currentDir)) { + if (checkForOctomindDir(currentDir)) { + return path.resolve(path.join(currentDir, OCTOMIND_DIR)); + } + currentDir = path.dirname(currentDir); + } + if (allowCreation) { + return createOctomindDirInteractive({ dir: startDir }); + } + return null; +}; + +const checkForOctomindDir = (dir: string): boolean => { + const octomindDir = path.join(dir, OCTOMIND_DIR); + try { + return existsSync(octomindDir); + } catch (error) { + console.debug(`Error checking for octomind dir in ${dir}:`, error); + // likely a permission issue, so we return false + return false; + } +}; + +export const createOctomindDirInteractive = async ( + options: { dir?: string; recreateOctomindDir?: boolean } = {}, +): Promise => { + const dir = path.resolve( + path.join(options.dir ?? process.cwd(), OCTOMIND_DIR), + ); + const existingOctomindDir = await getPathToOctomindDir({ + allowCreation: false, + startDir: options.dir, + }); + + if ( + existingOctomindDir && + existingOctomindDir !== dir && + !options.recreateOctomindDir + ) { + console.log(`Octomind directory already exists: ${existingOctomindDir}.`); + console.log(`Using existing octomind directory.`); + return existingOctomindDir; + } + + if (existingOctomindDir !== null) { + console.log(`Octomind directory already exists: ${dir}.`); + const removeExisting = + options.recreateOctomindDir || + ( + await promptUser(`Remove and recreate empty directory? (y/N): `) + ).toLowerCase() === "y"; + if (removeExisting) { + console.log(`Removing and recreating empty directory...`); + rmdirSync(existingOctomindDir); + mkdirSync(dir, { recursive: true }); + } else { + console.log(`Using existing octomind directory.`); + } + } else { + mkdirSync(dir, { recursive: true }); + console.log(`✨ Created octomind directory: ${dir}`); + } + return dir; +}; + +export const showOctomindDir = async (): Promise => { + const octomindDir = await getPathToOctomindDirWithActiveTestTarget({ + allowCreation: false, + }); + if (!octomindDir) { + console.log( + "You are not in an octomind directory or one of its children. Please run `octomind pull` to create one.", + ); + return; + } + console.log(`Octomind directory: ${octomindDir}`); +}; diff --git a/src/tools/sync/yml.ts b/src/tools/sync/yml.ts index d94e74f..a695839 100644 --- a/src/tools/sync/yml.ts +++ b/src/tools/sync/yml.ts @@ -17,7 +17,9 @@ const removeInvalidCharacters = (str: string): string => { return removeDiacritics(str).replace(/[\\/:*?"'<>|.]/g, ""); }; -const toFileSystemCompatibleCamelCase = (description: string): string => { +export const toFileSystemCompatibleCamelCase = ( + description: string, +): string => { const tokens = removeInvalidCharacters(description) .split(/\s/) .filter(Boolean); diff --git a/src/tools/test-targets.ts b/src/tools/test-targets.ts index 112a2d2..e889cca 100644 --- a/src/tools/test-targets.ts +++ b/src/tools/test-targets.ts @@ -1,5 +1,9 @@ import path from "path"; +import { + getPathToOctomindDir, + getPathToOctomindDirWithActiveTestTarget, +} from "../dirManagement"; import { getUrl } from "../url"; import { client, handleError, ListOptions, logJson } from "./client"; import { push } from "./sync/push"; @@ -44,7 +48,7 @@ export const listTestTargets = async (options: ListOptions): Promise => { }; export const pullTestTarget = async ( - options: { testTargetId: string; destination?: string } & ListOptions, + options: { testTargetId: string } & ListOptions, ): Promise => { const { data, error } = await client.GET( "/apiKey/beta/test-targets/{testTargetId}/pull", @@ -68,21 +72,32 @@ export const pullTestTarget = async ( return; } - writeYaml(data, options.destination); + const destination = await getPathToOctomindDirWithActiveTestTarget({ + allowCreation: true, + providedTestTargetId: options.testTargetId, + }); + if (!destination) { + throw new Error("No octomind directory found"); + } + writeYaml(data, destination); console.log("Test Target pulled successfully"); }; export const pushTestTarget = async ( - options: { testTargetId: string; source?: string } & ListOptions, + options: { testTargetId: string } & ListOptions, ): Promise => { - const sourceDir = options.source - ? path.resolve(options.source) - : process.cwd(); + const source = await getPathToOctomindDirWithActiveTestTarget({ + allowCreation: false, + providedTestTargetId: options.testTargetId, + }); + if (!source) { + throw new Error("No octomind directory found"); + } const data = await push({ ...options, - sourceDir, + sourceDir: source, testTargetId: options.testTargetId, onError: handleError, client, diff --git a/tests/debugtopous/index.spec.ts b/tests/debugtopous/index.spec.ts index d167fdb..5e9d1ca 100644 --- a/tests/debugtopous/index.spec.ts +++ b/tests/debugtopous/index.spec.ts @@ -10,6 +10,10 @@ import { getPlaywrightConfig } from "../../src/tools/playwright"; import { createMockSyncTestCase } from "../mocks"; import { DeepMockProxy, mockDeep, mock } from "jest-mock-extended"; import { ensureChromiumIsInstalled } from "../../src/debugtopus/installation"; +import path from "node:path"; +import os from "node:os"; +import * as fsSync from "node:fs"; +import { getPathToOctomindDir, getPathToOctomindDirWithActiveTestTarget, OCTOMIND_DIR } from "../../src/dirManagement"; jest.mock("fs/promises"); jest.mock("fs"); @@ -20,6 +24,7 @@ jest.mock("../../src/tools/playwright"); jest.mock("../../src/debugtopus/installation"); jest.mock("child_process"); jest.mock("node:stream/promises"); +jest.mock("../../src/dirManagement"); const mockedFs = fs as jest.Mocked; const mockedCreateWriteStream = createWriteStream as jest.MockedFunction; @@ -35,8 +40,12 @@ const mockedEnsureChromiumIsInstalled = ensureChromiumIsInstalled as jest.Mocked describe("debugtopus", () => { + let octomindDir: string = `test-data/${OCTOMIND_DIR}/test-target-id`; + beforeEach(() => { jest.clearAllMocks(); + + jest.mocked(getPathToOctomindDirWithActiveTestTarget).mockResolvedValue(octomindDir); }); describe("readZipFromResponseBody", () => { @@ -156,6 +165,7 @@ describe("debugtopus", () => { describe("executeLocalTestCases", () => { + it("should execute local test cases from zip response body", async () => { const mockTestCases = [createMockSyncTestCase()]; const mockZipBuffer = Buffer.from([0x50, 0x4B, 0x03, 0x04]); @@ -195,11 +205,10 @@ describe("debugtopus", () => { await executeLocalTestCases({ testTargetId: "test-target-id", url: "https://example.com", - source: "/test/source", headless: true, }); - expect(mockedReadTestCasesFromDir).toHaveBeenCalledWith("/test/source"); + expect(mockedReadTestCasesFromDir).toHaveBeenCalledWith(octomindDir); expect(mockedClient.POST).toHaveBeenCalledWith( "/apiKey/beta/test-targets/{testTargetId}/code", expect.objectContaining({ @@ -226,7 +235,6 @@ describe("debugtopus", () => { executeLocalTestCases({ testTargetId: "test-target-id", url: "https://example.com", - source: "/test/source", headless: true, }) ).rejects.toThrow(); diff --git a/tests/dirManagement.spec.ts b/tests/dirManagement.spec.ts new file mode 100644 index 0000000..54d4c1e --- /dev/null +++ b/tests/dirManagement.spec.ts @@ -0,0 +1,117 @@ +import path from "node:path"; +import fs from "fs"; +import os from "os"; +import { promptUser, resolveTestTargetId } from "../src/helpers"; +import { createOctomindDirInteractive, getPathToOctomindDir, getPathToOctomindDirWithActiveTestTarget, OCTOMIND_DIR } from "../src/dirManagement"; +import { getTestTargets } from "../src/tools"; +jest.mock("../src/helpers"); +jest.mock("../src/tools"); + +describe("dirManagement", () => { + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + let tmpDir: string; + + beforeEach(() => { + console.log = jest.fn(); + console.error = jest.fn(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "octomind-cli-test-")); + jest.mocked(promptUser).mockResolvedValue("y"); + + }) + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + } + }); + + describe("getPathToOctomindDirWithActiveTestTarget", () => { + + it("should return the path to the octomind directory with the active test target", async () => { + jest.mocked(getTestTargets).mockResolvedValue([{ id: "test-target-id", app: "cool test target" }]); + jest.mocked(resolveTestTargetId).mockResolvedValue("test-target-id"); + fs.mkdirSync(path.join(tmpDir, OCTOMIND_DIR), { recursive: true }); + const octomindDir = await getPathToOctomindDirWithActiveTestTarget({ startDir: tmpDir, providedTestTargetId: "test-target-id"}); + expect(octomindDir).toBe(path.join(tmpDir, OCTOMIND_DIR, "coolTestTarget")); + expect(fs.existsSync(path.join(tmpDir, OCTOMIND_DIR, "coolTestTarget"))).toBe(true); + expect(resolveTestTargetId).toHaveBeenCalledWith("test-target-id"); + }) + }) + + describe("getPathToOctomindDir", () => { + + it("should return the path to the existing octomind directory", async () => { + fs.mkdirSync(path.join(tmpDir, OCTOMIND_DIR), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, "random-dir"), { recursive: true }); + const octomindDir = await getPathToOctomindDir({ startDir: tmpDir, allowCreation: false }); + const octomindDirNested = await getPathToOctomindDir({ + startDir: path.join(tmpDir, "random-dir"), + allowCreation: false + }); + + expect(octomindDir).toBe(path.join(tmpDir, OCTOMIND_DIR)); + expect(octomindDirNested).toBe(path.join(tmpDir, OCTOMIND_DIR)); + }) + + it("should return null if no octomind directory is found", async () => { + const octomindDir = await getPathToOctomindDir({ startDir: tmpDir, allowCreation: false }); + expect(octomindDir).toBeNull(); + }) + + it("should create the octomind directory if allowCreation is true", async () => { + const octomindDir = await getPathToOctomindDir({ startDir: tmpDir, allowCreation: true }); + expect(octomindDir).toBe(path.join(tmpDir, OCTOMIND_DIR)); + }) + + }) + + describe("createOctomindDirInteractive", () => { + it("should create the octomind directory if it doesn't exist", async () => { + const octomindDir = await createOctomindDirInteractive({ dir: tmpDir }); + expect(octomindDir).toBe(path.join(tmpDir, OCTOMIND_DIR)); + expect(fs.existsSync(path.join(tmpDir, OCTOMIND_DIR))).toBe(true); + }) + + it("should return the path to the existing octomind directory if it already exists", async () => { + fs.mkdirSync(path.join(tmpDir, OCTOMIND_DIR), { recursive: true }); + const octomindDir = await createOctomindDirInteractive({ + dir: path.join(tmpDir, "random-dir"), + recreateOctomindDir: false + }); + expect(octomindDir).toBe(path.join(tmpDir, OCTOMIND_DIR)); + expect(fs.existsSync(path.join(tmpDir, OCTOMIND_DIR))).toBe(true); + expect(console.log).toHaveBeenCalledWith("Using existing octomind directory."); + }) + + it("should remove the existing octomind directory if recreateOctomindDir is true", async () => { + fs.mkdirSync(path.join(tmpDir, OCTOMIND_DIR), { recursive: true }); + const octomindDir = await createOctomindDirInteractive({ dir: tmpDir, recreateOctomindDir: true }); + expect(octomindDir).toBe(path.join(tmpDir, OCTOMIND_DIR)); + expect(fs.existsSync(path.join(tmpDir, OCTOMIND_DIR))).toBe(true); + expect(console.log).toHaveBeenCalledWith("Removing and recreating empty directory..."); + }) + + it("should prompt the user to remove the existing octomind directory if recreateOctomindDir is not true", async () => { + fs.mkdirSync(path.join(tmpDir, OCTOMIND_DIR), { recursive: true }); + const octomindDir = await createOctomindDirInteractive({ dir: tmpDir }); + expect(octomindDir).toBe(path.join(tmpDir, OCTOMIND_DIR)); + expect(fs.existsSync(path.join(tmpDir, OCTOMIND_DIR))).toBe(true); + expect(promptUser).toHaveBeenCalledWith("Remove and recreate empty directory? (y/N): "); + expect(console.log).toHaveBeenCalledWith("Removing and recreating empty directory..."); + }) + + it("should not remove the existing octomind directory if the user does not confirm", async () => { + fs.mkdirSync(path.join(tmpDir, OCTOMIND_DIR), { recursive: true }); + jest.mocked(promptUser).mockResolvedValue("n"); + const octomindDir = await createOctomindDirInteractive({ dir: tmpDir }); + expect(octomindDir).toBe(path.join(tmpDir, OCTOMIND_DIR)); + expect(fs.existsSync(path.join(tmpDir, OCTOMIND_DIR))).toBe(true); + expect(promptUser).toHaveBeenCalledWith("Remove and recreate empty directory? (y/N): "); + expect(console.log).toHaveBeenCalledWith("Using existing octomind directory."); + }) + }) +}) diff --git a/tests/tools/init.spec.ts b/tests/tools/init.spec.ts index 3a3a892..6935f39 100644 --- a/tests/tools/init.spec.ts +++ b/tests/tools/init.spec.ts @@ -5,6 +5,7 @@ import { getTestTargets } from "../../src/tools/test-targets"; jest.mock("../../src/config"); jest.mock("../../src/helpers"); jest.mock("../../src/tools/test-targets"); + describe("init", () => { const originalConsoleLog = console.log; beforeAll(() => { @@ -42,4 +43,4 @@ describe("init", () => { expect(saveConfig).toHaveBeenCalledWith({ apiKey: "newApiKey", testTargetId: "testTargetId1" }); expect(console.log).toHaveBeenNthCalledWith(2, "\n✨ Initialization complete!"); }); -}); \ No newline at end of file +}); diff --git a/tests/tools/test-targets.spec.ts b/tests/tools/test-targets.spec.ts index 8612a3b..8606126 100644 --- a/tests/tools/test-targets.spec.ts +++ b/tests/tools/test-targets.spec.ts @@ -3,10 +3,12 @@ import {pushTestTarget} from "../../src/tools"; import {readTestCasesFromDir} from "../../src/tools/sync/yml"; import {client} from "../../src/tools/client"; import {mock} from "jest-mock-extended"; +import { getPathToOctomindDirWithActiveTestTarget } from "../../src/dirManagement"; jest.mock("../../src/tools/sync/git"); jest.mock("../../src/tools/sync/yml"); jest.mock("../../src/tools/client"); +jest.mock("../../src/dirManagement"); describe("push", () => { @@ -22,6 +24,7 @@ describe("push", () => { jest.mocked(readTestCasesFromDir).mockReturnValue([]) jest.mocked(client).POST.mockResolvedValue({ data: undefined, error: undefined, response: mock() }) console.log = jest.fn(); + jest.mocked(getPathToOctomindDirWithActiveTestTarget).mockResolvedValue("test-data/.octomind/test-target-id"); }) it("pushes to main if on default branch", async () => { @@ -55,4 +58,4 @@ describe("push", () => { expect(client.POST).toHaveBeenCalledWith("/apiKey/beta/test-targets/{testTargetId}/draft/push", expect.anything()) }) -}) \ No newline at end of file +})