From 04c59b36de15a88a7c9126bd86a8e3b0f4a304bd Mon Sep 17 00:00:00 2001 From: Daniel Draper Date: Mon, 12 Jan 2026 12:00:53 +0100 Subject: [PATCH 01/20] edit test cases --- package.json | 1 + src/cli.ts | 13 ++++ src/tools/sync/push.ts | 4 +- src/tools/yamlMutations/edit.ts | 113 ++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/tools/yamlMutations/edit.ts diff --git a/package.json b/package.json index f9521d6..0c31ce4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "gendoc": "tsx scripts/generate-docs.ts > README.md", "lint": "pnpm apigen && npx genversion -des src/version.ts && biome check", "tsc": "tsc --project tsconfig.build.json", + "dev:local": "pnpm apigen && OCTOMIND_CONFIG_FILE=octomind.dev.local.json OCTOMIND_API_URL=http://localhost:3000/api tsx src/index.ts", "dev:staging": "pnpm apigen && OCTOMIND_CONFIG_FILE=octomind.dev.staging.json OCTOMIND_API_URL=https://preview.octomind.dev/api tsx src/index.ts", "dev:prod": "pnpm apigen && OCTOMIND_CONFIG_FILE=octomind.dev.prod.json OCTOMIND_API_URL=https://app.octomind.dev/api tsx src/index.ts", "apigen": "openapi-typescript https://preview.octomind.dev/openapi.yaml --output src/api.ts && orval", diff --git a/src/cli.ts b/src/cli.ts index 59ddf3e..22213a4 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 { edit } from "./tools/yamlMutations/edit"; import { version } from "./version"; export const BINARY_NAME = "octomind"; @@ -412,6 +413,18 @@ export const buildCmd = (): CompletableCommand => { ) .action(addTestTargetWrapper(pushTestTarget)); + // noinspection RequiredAttributes + createCommandWithCommonOptions(program, "edit-test-case") + .completer(testTargetIdCompleter) + .description("Edit yaml test case") + .helpGroup("test-cases") + .addOption(testTargetIdOption) + .requiredOption( + "-f, --file-path ", + "The path to the local yaml file you want to edit", + ) + .action(addTestTargetWrapper(edit)); + createCommandWithCommonOptions(program, "list-test-targets") .description("List all test targets") .helpGroup("test-targets") diff --git a/src/tools/sync/push.ts b/src/tools/sync/push.ts index 647e01e..173f548 100644 --- a/src/tools/sync/push.ts +++ b/src/tools/sync/push.ts @@ -65,9 +65,9 @@ const defaultPush = async ( return data; }; -const draftPush = async ( +export const draftPush = async ( body: TestTargetSyncData, - options: PushOptions & ListOptions, + options: Omit & ListOptions, ): Promise<{ success: boolean; versionIds: string[] } | undefined> => { const { data, error } = await options.client.POST( "/apiKey/beta/test-targets/{testTargetId}/draft/push", diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts new file mode 100644 index 0000000..b5f667a --- /dev/null +++ b/src/tools/yamlMutations/edit.ts @@ -0,0 +1,113 @@ +import fs from "fs"; +import fsPromises from "fs/promises"; +import path from "path"; + +import { Client } from "openapi-fetch"; +import { ErrorResponse } from "openapi-typescript-helpers"; +import yaml from "yaml"; + +import { paths } from "../../api"; +import { client, handleError } from "../client"; +import { checkForConsistency } from "../sync/consistency"; +import { draftPush } from "../sync/push"; +import { SyncTestCase } from "../sync/types"; +import { readTestCasesFromDir } from "../sync/yml"; + +type EditOptions = { + testTargetId: string; + filePath: string; +}; + +const findOctomindFolder = async ( + startPath: string, +): Promise => { + let currentDir = path.dirname(startPath); + + while (currentDir !== path.parse(currentDir).root) { + const octomindPath = path.join(currentDir, ".octomind"); + if ( + fs.existsSync(octomindPath) && + (await fsPromises.stat(octomindPath)).isDirectory() + ) { + return octomindPath; + } + currentDir = path.dirname(currentDir); + } + + const rootOctomind = path.join(currentDir, ".octomind"); + if ( + fs.existsSync(rootOctomind) && + (await fsPromises.stat(rootOctomind)).isDirectory() + ) { + return rootOctomind; + } + + return null; +}; + +const getRelevantTestCases = ( + testCasesById: Record, + startTestCase: SyncTestCase, +): SyncTestCase[] => { + let dependencyId = startTestCase.dependencyId; + const result: SyncTestCase[] = [startTestCase]; + + while (dependencyId) { + const currentTestCase = testCasesById[dependencyId]; + + if (!currentTestCase) { + throw new Error( + `Could not find dependency ${dependencyId} for ${startTestCase.id}`, + ); + } + + result.push(currentTestCase); + dependencyId = currentTestCase?.dependencyId; + } + + return result; +}; + +const loadTestCase = (path: string): SyncTestCase => { + try { + const content = fs.readFileSync(path, "utf8"); + return yaml.parse(content); + } catch (error) { + throw new Error(`Could not parse ${path}: ${error}`); + } +}; + +export const edit = async (options: EditOptions): Promise => { + console.log(options) + const resolvedPath = path.resolve(options.filePath); + + const testCaseToEdit = loadTestCase(resolvedPath); + + const octomindRoot = await findOctomindFolder(options.filePath); + + if (!octomindRoot) { + throw new Error( + "Could not find .octomind folder, make sure to pull before trying to edit", + ); + } + + const testCases = readTestCasesFromDir(octomindRoot); + const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); + const relevantTestCases = getRelevantTestCases(testCasesById, testCaseToEdit); + + console.log(relevantTestCases); + checkForConsistency(relevantTestCases); + + const response = await draftPush( + { + testCases: relevantTestCases, + }, + { + testTargetId: options.testTargetId, + client, + onError: handleError, + }, + ); + + console.log(response); +}; From 9f75a385c9137ffbe24158d2b7ec69e107f65301 Mon Sep 17 00:00:00 2001 From: Daniel Draper Date: Mon, 12 Jan 2026 13:54:49 +0100 Subject: [PATCH 02/20] remove logs --- src/tools/yamlMutations/edit.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index b5f667a..ba9dce5 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -2,11 +2,8 @@ import fs from "fs"; import fsPromises from "fs/promises"; import path from "path"; -import { Client } from "openapi-fetch"; -import { ErrorResponse } from "openapi-typescript-helpers"; import yaml from "yaml"; -import { paths } from "../../api"; import { client, handleError } from "../client"; import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; @@ -68,17 +65,16 @@ const getRelevantTestCases = ( return result; }; -const loadTestCase = (path: string): SyncTestCase => { +const loadTestCase = (testCasePath: string): SyncTestCase => { try { - const content = fs.readFileSync(path, "utf8"); + const content = fs.readFileSync(testCasePath, "utf8"); return yaml.parse(content); } catch (error) { - throw new Error(`Could not parse ${path}: ${error}`); + throw new Error(`Could not parse ${testCasePath}: ${error}`); } }; export const edit = async (options: EditOptions): Promise => { - console.log(options) const resolvedPath = path.resolve(options.filePath); const testCaseToEdit = loadTestCase(resolvedPath); @@ -95,7 +91,6 @@ export const edit = async (options: EditOptions): Promise => { const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); const relevantTestCases = getRelevantTestCases(testCasesById, testCaseToEdit); - console.log(relevantTestCases); checkForConsistency(relevantTestCases); const response = await draftPush( From 9a96e3db81872d4f329c44549feb1c8a31c9d86d Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Mon, 12 Jan 2026 14:04:00 +0100 Subject: [PATCH 03/20] folder name constant --- src/cli.ts | 7 ++++--- src/constants.ts | 1 + src/tools/yamlMutations/edit.ts | 10 ++++------ 3 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 src/constants.ts diff --git a/src/cli.ts b/src/cli.ts index 22213a4..800fa72 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,6 +40,7 @@ import { init, switchTestTarget } from "./tools/init"; import { update } from "./tools/update"; import { edit } from "./tools/yamlMutations/edit"; import { version } from "./version"; +import { OCTOMIND_FOLDER_NAME } from "./constants"; export const BINARY_NAME = "octomind"; @@ -197,7 +198,7 @@ export const buildCmd = (): CompletableCommand => { .option( "-s, --source ", "Source directory (defaults to current directory)", - "./.octomind", + OCTOMIND_FOLDER_NAME, ) .action(addTestTargetWrapper(executeLocalTestCases)); @@ -397,7 +398,7 @@ export const buildCmd = (): CompletableCommand => { .description("Pull test cases from the test target") .helpGroup("test-cases") .addOption(testTargetIdOption) - .option("-d, --destination ", "Destination folder", "./.octomind") + .option("-d, --destination ", "Destination folder", OCTOMIND_FOLDER_NAME) .action(addTestTargetWrapper(pullTestTarget)); // noinspection RequiredAttributes @@ -409,7 +410,7 @@ export const buildCmd = (): CompletableCommand => { .option( "-s, --source ", "Source directory (defaults to current directory)", - ".octomind", + OCTOMIND_FOLDER_NAME, ) .action(addTestTargetWrapper(pushTestTarget)); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..0ec372d --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const OCTOMIND_FOLDER_NAME = ".octomind" diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index b5f667a..623180d 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -2,20 +2,19 @@ import fs from "fs"; import fsPromises from "fs/promises"; import path from "path"; -import { Client } from "openapi-fetch"; -import { ErrorResponse } from "openapi-typescript-helpers"; import yaml from "yaml"; -import { paths } from "../../api"; import { client, handleError } from "../client"; import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; import { SyncTestCase } from "../sync/types"; import { readTestCasesFromDir } from "../sync/yml"; +import { OCTOMIND_FOLDER_NAME } from "../../constants"; type EditOptions = { testTargetId: string; filePath: string; + sourceDir: string }; const findOctomindFolder = async ( @@ -24,7 +23,7 @@ const findOctomindFolder = async ( let currentDir = path.dirname(startPath); while (currentDir !== path.parse(currentDir).root) { - const octomindPath = path.join(currentDir, ".octomind"); + const octomindPath = path.join(currentDir, OCTOMIND_FOLDER_NAME); if ( fs.existsSync(octomindPath) && (await fsPromises.stat(octomindPath)).isDirectory() @@ -34,7 +33,7 @@ const findOctomindFolder = async ( currentDir = path.dirname(currentDir); } - const rootOctomind = path.join(currentDir, ".octomind"); + const rootOctomind = path.join(currentDir, OCTOMIND_FOLDER_NAME); if ( fs.existsSync(rootOctomind) && (await fsPromises.stat(rootOctomind)).isDirectory() @@ -78,7 +77,6 @@ const loadTestCase = (path: string): SyncTestCase => { }; export const edit = async (options: EditOptions): Promise => { - console.log(options) const resolvedPath = path.resolve(options.filePath); const testCaseToEdit = loadTestCase(resolvedPath); From 2114e3e99fedee6e66dd90f639c6aab1e1c74ca5 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Mon, 12 Jan 2026 15:28:20 +0100 Subject: [PATCH 04/20] make .octomind folder the default for push, pull, delete and edit. --- src/cli.ts | 12 -------- src/helpers.ts | 43 +++++++++++++++++++++++++++ src/tools/test-cases.ts | 52 +++++++++++++++++++++++---------- src/tools/test-targets.ts | 16 ++++++---- src/tools/yamlMutations/edit.ts | 43 +++++---------------------- 5 files changed, 98 insertions(+), 68 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 800fa72..ed6e970 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,7 +40,6 @@ import { init, switchTestTarget } from "./tools/init"; import { update } from "./tools/update"; import { edit } from "./tools/yamlMutations/edit"; import { version } from "./version"; -import { OCTOMIND_FOLDER_NAME } from "./constants"; export const BINARY_NAME = "octomind"; @@ -195,11 +194,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_FOLDER_NAME, - ) .action(addTestTargetWrapper(executeLocalTestCases)); createCommandWithCommonOptions(program, "test-report") @@ -398,7 +392,6 @@ export const buildCmd = (): CompletableCommand => { .description("Pull test cases from the test target") .helpGroup("test-cases") .addOption(testTargetIdOption) - .option("-d, --destination ", "Destination folder", OCTOMIND_FOLDER_NAME) .action(addTestTargetWrapper(pullTestTarget)); // noinspection RequiredAttributes @@ -407,11 +400,6 @@ 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_FOLDER_NAME, - ) .action(addTestTargetWrapper(pushTestTarget)); // noinspection RequiredAttributes diff --git a/src/helpers.ts b/src/helpers.ts index 7980a26..1898578 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -2,6 +2,10 @@ import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline"; import { loadConfig } from "./config"; +import path from "node:path"; +import fsPromises from "fs/promises"; +import { OCTOMIND_FOLDER_NAME } from "./constants"; + export function promptUser(question: string): Promise { const rl = createInterface({ input, output }); @@ -29,3 +33,42 @@ export const resolveTestTargetId = async ( } return config.testTargetId; }; + + +const isDirectory = async (dirPath: string): Promise => { + try { + const stat = await fsPromises.stat(dirPath); + return stat.isDirectory(); + } catch { + return false; + } +}; + +export const findOctomindFolder = async (): Promise => { + let currentDir = process.cwd(); + + while (currentDir !== path.parse(currentDir).root) { + const octomindPath = path.join(currentDir, OCTOMIND_FOLDER_NAME); + if (await isDirectory(octomindPath)) { + return octomindPath; + } + currentDir = path.dirname(currentDir); + } + + const rootOctomind = path.join(currentDir, OCTOMIND_FOLDER_NAME); + if (await isDirectory(rootOctomind)) { + return rootOctomind; + } + + return null +}; + +export const getAbsoluteFilePathInOctomindRoot = ({ filePath, octomindRoot }: { filePath: string, octomindRoot: string }): string | null => { + let resolvedPath: string + if (path.isAbsolute(filePath)) { + resolvedPath = filePath + } + resolvedPath = path.resolve(filePath, path.join(octomindRoot, filePath)); + + return resolvedPath.startsWith(octomindRoot) ? resolvedPath : null +} diff --git a/src/tools/test-cases.ts b/src/tools/test-cases.ts index 1949723..cb54a44 100644 --- a/src/tools/test-cases.ts +++ b/src/tools/test-cases.ts @@ -1,7 +1,11 @@ +import path from "path"; import type { components, paths } from "../api"; // generated by openapi-typescript +import { findOctomindFolder } from "../helpers"; import { getUrl } from "../url"; import { client, handleError, ListOptions, logJson } from "./client"; import { getEnvironments } from "./environments"; +import { buildFilename, readTestCasesFromDir } from "./sync/yml"; +import { unlink } from "fs/promises"; export type TestCaseResponse = components["schemas"]["TestCaseResponse"]; export type TestCasesResponse = components["schemas"]["TestCasesResponse"]; @@ -13,26 +17,44 @@ export type DeleteTestCaseParams = export const deleteTestCase = async ( options: DeleteTestCaseParams & ListOptions, ): Promise => { - const { data, error } = await client.DELETE( - "/apiKey/v3/test-targets/{testTargetId}/test-cases/{testCaseId}", - { - params: { - path: { - testTargetId: options.testTargetId, - testCaseId: options.testCaseId, + const octomindRoot = await findOctomindFolder() + + if (!octomindRoot) { + const { data, error } = await client.DELETE( + "/apiKey/v3/test-targets/{testTargetId}/test-cases/{testCaseId}", + { + params: { + path: { + testTargetId: options.testTargetId, + testCaseId: options.testCaseId, + }, }, }, - }, - ); - - handleError(error); + ); - if (options.json) { - logJson(data); - return; + handleError(error); + if (options.json) { + logJson(data); + } + console.log("Test Case deleted successfully"); + } else { + const testCases = readTestCasesFromDir(octomindRoot); + const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); + const existingTestCase = testCasesById[options.testCaseId] + + if (existingTestCase) { + const existingTestCasePath = buildFilename( + existingTestCase, + octomindRoot, + ); + await unlink(path.join(octomindRoot, existingTestCasePath)) + console.log("Test Case deleted successfully"); + } + else { + console.log(`No test case with id ${options.testCaseId} found in folder ${octomindRoot}`) + } } - console.log("Test Case deleted successfully"); }; export const listTestCase = async ( diff --git a/src/tools/test-targets.ts b/src/tools/test-targets.ts index 112a2d2..0b2238f 100644 --- a/src/tools/test-targets.ts +++ b/src/tools/test-targets.ts @@ -4,6 +4,8 @@ import { getUrl } from "../url"; import { client, handleError, ListOptions, logJson } from "./client"; import { push } from "./sync/push"; import { writeYaml } from "./sync/yml"; +import { findOctomindFolder } from "../helpers"; +import { OCTOMIND_FOLDER_NAME } from "../constants"; export const getTestTargets = async () => { const { data, error } = await client.GET("/apiKey/v3/test-targets"); @@ -44,7 +46,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,17 +70,19 @@ export const pullTestTarget = async ( return; } - writeYaml(data, options.destination); + const destination = await findOctomindFolder() ?? path.join(process.cwd(), OCTOMIND_FOLDER_NAME) + 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 sourceDir = await findOctomindFolder() + if (!sourceDir) { + throw new Error(`No ${OCTOMIND_FOLDER_NAME} folder found, please pull first.`) + } const data = await push({ ...options, diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index 953dfde..114f09d 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -1,5 +1,4 @@ import fs from "fs"; -import fsPromises from "fs/promises"; import path from "path"; import yaml from "yaml"; @@ -9,40 +8,13 @@ import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; import { SyncTestCase } from "../sync/types"; import { readTestCasesFromDir } from "../sync/yml"; -import { OCTOMIND_FOLDER_NAME } from "../../constants"; +import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot } from "../../helpers"; type EditOptions = { testTargetId: string; filePath: string; - sourceDir: string }; -const findOctomindFolder = async ( - startPath: string, -): Promise => { - let currentDir = path.dirname(startPath); - - while (currentDir !== path.parse(currentDir).root) { - const octomindPath = path.join(currentDir, OCTOMIND_FOLDER_NAME); - if ( - fs.existsSync(octomindPath) && - (await fsPromises.stat(octomindPath)).isDirectory() - ) { - return octomindPath; - } - currentDir = path.dirname(currentDir); - } - - const rootOctomind = path.join(currentDir, OCTOMIND_FOLDER_NAME); - if ( - fs.existsSync(rootOctomind) && - (await fsPromises.stat(rootOctomind)).isDirectory() - ) { - return rootOctomind; - } - - return null; -}; const getRelevantTestCases = ( testCasesById: Record, @@ -77,17 +49,18 @@ const loadTestCase = (testCasePath: string): SyncTestCase => { }; export const edit = async (options: EditOptions): Promise => { - const resolvedPath = path.resolve(options.filePath); - - const testCaseToEdit = loadTestCase(resolvedPath); - - const octomindRoot = await findOctomindFolder(options.filePath); - + const octomindRoot = await findOctomindFolder(); if (!octomindRoot) { throw new Error( "Could not find .octomind folder, make sure to pull before trying to edit", ); } + const testCaseFilePath = getAbsoluteFilePathInOctomindRoot({ octomindRoot, filePath: options.filePath }) + if (!testCaseFilePath) { + throw new Error(`Could not find ${options.filePath} in folder ${octomindRoot}`) + } + + const testCaseToEdit = loadTestCase(testCaseFilePath); const testCases = readTestCasesFromDir(octomindRoot); const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); From d33c197207e4b34da7a02ef85016ae7c72c6212a Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Mon, 12 Jan 2026 15:43:06 +0100 Subject: [PATCH 05/20] use octomind folder in local execution --- src/debugtopus/index.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/debugtopus/index.ts b/src/debugtopus/index.ts index c8de521..4631c64 100644 --- a/src/debugtopus/index.ts +++ b/src/debugtopus/index.ts @@ -19,6 +19,7 @@ import { import { client, handleError } from "../tools/client"; import { readTestCasesFromDir } from "../tools/sync/yml"; import { ensureChromiumIsInstalled } from "./installation"; +import { findOctomindFolder } from "../helpers"; export type DebugtopusOptions = { testCaseId?: string; @@ -207,8 +208,8 @@ export const runDebugtopus = async (options: DebugtopusOptions) => { .filter((testCase) => options.grep ? testCase.description - ?.toLowerCase() - .includes(options.grep.toLowerCase()) + ?.toLowerCase() + .includes(options.grep.toLowerCase()) : true, ) .map(async (testCase) => ({ @@ -262,9 +263,16 @@ export const runDebugtopus = async (options: DebugtopusOptions) => { }; export const executeLocalTestCases = async ( - options: DebugtopusOptions & { source: string }, + options: DebugtopusOptions, ): Promise => { - const testCases = readTestCasesFromDir(options.source); + const octomindRoot = await findOctomindFolder() + if (!octomindRoot) { + throw new Error( + "Could not find .octomind folder, make sure to pull before trying to edit", + ); + } + + const testCases = readTestCasesFromDir(octomindRoot); const body = { testCases, testTargetId: options.testTargetId, From cd8046765e814c583511e8b9b6b7e66d07394fa6 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Mon, 12 Jan 2026 15:59:51 +0100 Subject: [PATCH 06/20] fix .octomind folder name in logs --- src/debugtopus/index.ts | 3 ++- src/tools/yamlMutations/edit.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/debugtopus/index.ts b/src/debugtopus/index.ts index 4631c64..23bae21 100644 --- a/src/debugtopus/index.ts +++ b/src/debugtopus/index.ts @@ -20,6 +20,7 @@ import { client, handleError } from "../tools/client"; import { readTestCasesFromDir } from "../tools/sync/yml"; import { ensureChromiumIsInstalled } from "./installation"; import { findOctomindFolder } from "../helpers"; +import { OCTOMIND_FOLDER_NAME } from "../constants"; export type DebugtopusOptions = { testCaseId?: string; @@ -268,7 +269,7 @@ export const executeLocalTestCases = async ( const octomindRoot = await findOctomindFolder() if (!octomindRoot) { throw new Error( - "Could not find .octomind folder, make sure to pull before trying to edit", + `Could not find ${OCTOMIND_FOLDER_NAME} folder, make sure to pull before trying to execute locally`, ); } diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index 114f09d..c612827 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -9,6 +9,7 @@ import { draftPush } from "../sync/push"; import { SyncTestCase } from "../sync/types"; import { readTestCasesFromDir } from "../sync/yml"; import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot } from "../../helpers"; +import { OCTOMIND_FOLDER_NAME } from "../../constants"; type EditOptions = { testTargetId: string; @@ -52,7 +53,7 @@ export const edit = async (options: EditOptions): Promise => { const octomindRoot = await findOctomindFolder(); if (!octomindRoot) { throw new Error( - "Could not find .octomind folder, make sure to pull before trying to edit", + `Could not find ${OCTOMIND_FOLDER_NAME} folder, make sure to pull before trying to edit`, ); } const testCaseFilePath = getAbsoluteFilePathInOctomindRoot({ octomindRoot, filePath: options.filePath }) From 838990e9ec5d1cd11c0dbf67faf97cb6184c0609 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Mon, 12 Jan 2026 16:13:09 +0100 Subject: [PATCH 07/20] create test case via cli --- src/cli.ts | 35 +++++++++++++++------- src/tools/yamlMutations/create.ts | 48 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 src/tools/yamlMutations/create.ts diff --git a/src/cli.ts b/src/cli.ts index ed6e970..4ac327b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,6 +40,7 @@ import { init, switchTestTarget } from "./tools/init"; import { update } from "./tools/update"; import { edit } from "./tools/yamlMutations/edit"; import { version } from "./version"; +import { create } from "./tools/yamlMutations/create"; export const BINARY_NAME = "octomind"; @@ -50,17 +51,17 @@ type WithTestTargetId = { testTargetId: string }; const addTestTargetWrapper = (fn: (options: T) => Promise) => - async ( - options: Omit & Partial>, - ): Promise => { - const resolvedTestTargetId = await resolveTestTargetId( - options.testTargetId, - ); - await fn({ - ...options, - testTargetId: resolvedTestTargetId, - } as T); - }; + async ( + options: Omit & Partial>, + ): Promise => { + const resolvedTestTargetId = await resolveTestTargetId( + options.testTargetId, + ); + await fn({ + ...options, + testTargetId: resolvedTestTargetId, + } as T); + }; const testTargetIdOption = new Option( "-t, --test-target-id [id]", @@ -414,6 +415,18 @@ export const buildCmd = (): CompletableCommand => { ) .action(addTestTargetWrapper(edit)); + // 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", + ) + .action(addTestTargetWrapper(create)); + createCommandWithCommonOptions(program, "list-test-targets") .description("List all test targets") .helpGroup("test-targets") diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts new file mode 100644 index 0000000..bb6c828 --- /dev/null +++ b/src/tools/yamlMutations/create.ts @@ -0,0 +1,48 @@ +import fs from "fs"; +import path from "path"; + +import yaml from "yaml"; + +import { client, handleError } from "../client"; +import { checkForConsistency } from "../sync/consistency"; +import { draftPush } from "../sync/push"; +import { SyncTestCase } from "../sync/types"; +import { readTestCasesFromDir } from "../sync/yml"; +import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot } from "../../helpers"; +import { OCTOMIND_FOLDER_NAME } from "../../constants"; + +type CreateOptions = { + testTargetId: string; + name: string +}; + +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`, + ); + } + + const newTestCase: SyncTestCase = { + version: "1", + id: crypto.randomUUID(), + elements: [], + runStatus: "OFF", + description: options.name, + prompt: "" + } + + const response = await draftPush( + { + testCases: [newTestCase], + }, + { + testTargetId: options.testTargetId, + client, + onError: handleError, + }, + ); + + console.log(response); +}; From 8f6ada4d0f3d2fe4f180426d93a0ad7a54a374f9 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Mon, 12 Jan 2026 16:39:18 +0100 Subject: [PATCH 08/20] fix octomind folder helpers and add tests. --- src/helpers.ts | 15 +-- src/tools/yamlMutations/edit.ts | 2 +- tests/helpers.spec.ts | 186 ++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 tests/helpers.spec.ts diff --git a/src/helpers.ts b/src/helpers.ts index 1898578..59fd43f 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -63,12 +63,13 @@ export const findOctomindFolder = async (): Promise => { return null }; -export const getAbsoluteFilePathInOctomindRoot = ({ filePath, octomindRoot }: { filePath: string, octomindRoot: string }): string | null => { - let resolvedPath: string - if (path.isAbsolute(filePath)) { - resolvedPath = filePath +export const getAbsoluteFilePathInOctomindRoot = async ({ filePath, octomindRoot }: { filePath: string, octomindRoot: string }): Promise => { + try { + const resolvedPath = await fsPromises.realpath( + path.isAbsolute(filePath) ? filePath : path.join(octomindRoot, filePath) + ); + return resolvedPath.startsWith(octomindRoot) ? resolvedPath : null; + } catch { + return null; } - resolvedPath = path.resolve(filePath, path.join(octomindRoot, filePath)); - - return resolvedPath.startsWith(octomindRoot) ? resolvedPath : null } diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index c612827..09ee3e5 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -56,7 +56,7 @@ export const edit = async (options: EditOptions): Promise => { `Could not find ${OCTOMIND_FOLDER_NAME} folder, make sure to pull before trying to edit`, ); } - const testCaseFilePath = getAbsoluteFilePathInOctomindRoot({ octomindRoot, filePath: options.filePath }) + const testCaseFilePath = await getAbsoluteFilePathInOctomindRoot({ octomindRoot, filePath: options.filePath }) if (!testCaseFilePath) { throw new Error(`Could not find ${options.filePath} in folder ${octomindRoot}`) } diff --git a/tests/helpers.spec.ts b/tests/helpers.spec.ts new file mode 100644 index 0000000..2b28b27 --- /dev/null +++ b/tests/helpers.spec.ts @@ -0,0 +1,186 @@ +import fsPromises from "fs/promises"; +import os from "os"; +import path from "path"; + +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, +} from "../src/helpers"; +import { OCTOMIND_FOLDER_NAME } from "../src/constants"; + +describe("helpers", () => { + describe("findOctomindFolder", () => { + let tmpDir: string; + const originalCwd = process.cwd; + + beforeEach(async () => { + tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "octomind-test-")); + }); + + afterEach(async () => { + process.cwd = originalCwd; + await fsPromises.rm(tmpDir, { recursive: true }); + }); + + it("should find .octomind folder in current directory", async () => { + const octomindPath = path.join(tmpDir, OCTOMIND_FOLDER_NAME); + await fsPromises.mkdir(octomindPath); + process.cwd = () => tmpDir; + + const result = await findOctomindFolder(); + + expect(result).toBe(octomindPath); + }); + + it("should find .octomind folder in parent directory", async () => { + const octomindPath = path.join(tmpDir, OCTOMIND_FOLDER_NAME); + const subdir = path.join(tmpDir, "subdir"); + await fsPromises.mkdir(octomindPath); + await fsPromises.mkdir(subdir); + process.cwd = () => subdir; + + const result = await findOctomindFolder(); + + expect(result).toBe(octomindPath); + }); + + it("should traverse multiple levels to find .octomind folder", async () => { + const octomindPath = path.join(tmpDir, OCTOMIND_FOLDER_NAME); + const deepDir = path.join(tmpDir, "a", "b", "c"); + await fsPromises.mkdir(octomindPath); + await fsPromises.mkdir(deepDir, { recursive: true }); + process.cwd = () => deepDir; + + const result = await findOctomindFolder(); + + expect(result).toBe(octomindPath); + }); + + it("should return null when .octomind folder does not exist", async () => { + process.cwd = () => tmpDir; + + const result = await findOctomindFolder(); + + expect(result).toBeNull(); + }); + + it("should return null when .octomind is a file instead of directory", async () => { + const octomindPath = path.join(tmpDir, OCTOMIND_FOLDER_NAME); + await fsPromises.writeFile(octomindPath, ""); + process.cwd = () => tmpDir; + + const result = await findOctomindFolder(); + + expect(result).toBeNull(); + }); + + it("should find .octomind folder when inside a subfolder of it", async () => { + const octomindPath = path.join(tmpDir, OCTOMIND_FOLDER_NAME); + const subfolderInOctomind = path.join(octomindPath, "subfolder"); + await fsPromises.mkdir(subfolderInOctomind, { recursive: true }); + process.cwd = () => subfolderInOctomind; + + const result = await findOctomindFolder(); + + expect(result).toBe(octomindPath); + }); + }); + + describe("getAbsoluteFilePathInOctomindRoot", () => { + let tmpDir: string; + let octomindRoot: string; + + beforeEach(async () => { + tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "octomind-test-")); + octomindRoot = path.join(tmpDir, OCTOMIND_FOLDER_NAME); + await fsPromises.mkdir(octomindRoot); + }); + + afterEach(async () => { + await fsPromises.rm(tmpDir, { recursive: true }); + }); + + it("should resolve relative path within octomind root", async () => { + const filePath = path.join(octomindRoot, "test-case.yaml"); + await fsPromises.writeFile(filePath, ""); + + const result = await getAbsoluteFilePathInOctomindRoot({ + filePath: "test-case.yaml", + octomindRoot, + }); + + expect(result).toBe(filePath); + }); + + it("should resolve nested relative path within octomind root", async () => { + const subdir = path.join(octomindRoot, "subdir"); + await fsPromises.mkdir(subdir); + const filePath = path.join(subdir, "test-case.yaml"); + await fsPromises.writeFile(filePath, ""); + + const result = await getAbsoluteFilePathInOctomindRoot({ + filePath: "subdir/test-case.yaml", + octomindRoot, + }); + + expect(result).toBe(filePath); + }); + + it("should accept absolute path within octomind root", async () => { + const filePath = path.join(octomindRoot, "test-case.yaml"); + await fsPromises.writeFile(filePath, ""); + + const result = await getAbsoluteFilePathInOctomindRoot({ + filePath, + octomindRoot, + }); + + expect(result).toBe(filePath); + }); + + it("should return null for absolute path outside octomind root", async () => { + const outsideFile = path.join(tmpDir, "outside.yaml"); + await fsPromises.writeFile(outsideFile, ""); + + const result = await getAbsoluteFilePathInOctomindRoot({ + filePath: outsideFile, + octomindRoot, + }); + + expect(result).toBeNull(); + }); + + it("should return null for path traversal attempts", async () => { + const outsideFile = path.join(tmpDir, "outside.yaml"); + await fsPromises.writeFile(outsideFile, ""); + + const result = await getAbsoluteFilePathInOctomindRoot({ + filePath: "../outside.yaml", + octomindRoot, + }); + + expect(result).toBeNull(); + }); + + it("should return null for non-existent file", async () => { + const result = await getAbsoluteFilePathInOctomindRoot({ + filePath: "does-not-exist.yaml", + octomindRoot, + }); + + expect(result).toBeNull(); + }); + + it("should return null for malformed path with nested octomind structure", async () => { + // e.g. /some/folder/.octomind/some/folder/.octomind/a.yaml + const malformedPath = path.join(octomindRoot, tmpDir, OCTOMIND_FOLDER_NAME, "a.yaml"); + + const result = await getAbsoluteFilePathInOctomindRoot({ + filePath: malformedPath, + octomindRoot, + }); + + expect(result).toBeNull(); + }); + }); +}); From afb21b5801abf2048021e70f7aa096189114ed8f Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Mon, 12 Jan 2026 17:04:56 +0100 Subject: [PATCH 09/20] use early exits in deleteTestCase and add tests for local deletion. --- src/tools/test-cases.ts | 34 +++++------ tests/tools/test-cases.spec.ts | 103 +++++++++++++++++++++++++++------ 2 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/tools/test-cases.ts b/src/tools/test-cases.ts index cb54a44..ab41e77 100644 --- a/src/tools/test-cases.ts +++ b/src/tools/test-cases.ts @@ -5,7 +5,7 @@ import { getUrl } from "../url"; import { client, handleError, ListOptions, logJson } from "./client"; import { getEnvironments } from "./environments"; import { buildFilename, readTestCasesFromDir } from "./sync/yml"; -import { unlink } from "fs/promises"; +import fsPromises from "fs/promises"; export type TestCaseResponse = components["schemas"]["TestCaseResponse"]; export type TestCasesResponse = components["schemas"]["TestCasesResponse"]; @@ -37,24 +37,24 @@ export const deleteTestCase = async ( logJson(data); } console.log("Test Case deleted successfully"); - } else { - const testCases = readTestCasesFromDir(octomindRoot); - const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); - const existingTestCase = testCasesById[options.testCaseId] - - if (existingTestCase) { - const existingTestCasePath = buildFilename( - existingTestCase, - octomindRoot, - ); - await unlink(path.join(octomindRoot, existingTestCasePath)) - console.log("Test Case deleted successfully"); - } - else { - console.log(`No test case with id ${options.testCaseId} found in folder ${octomindRoot}`) - } + return + } + + const testCases = readTestCasesFromDir(octomindRoot); + const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); + const existingTestCase = testCasesById[options.testCaseId] + + if (!existingTestCase) { + console.log(`No test case with id ${options.testCaseId} found in folder ${octomindRoot}`) + return } + const existingTestCasePath = buildFilename( + existingTestCase, + octomindRoot, + ); + await fsPromises.unlink(path.join(octomindRoot, existingTestCasePath)) + console.log("Test Case deleted successfully"); }; export const listTestCase = async ( diff --git a/tests/tools/test-cases.spec.ts b/tests/tools/test-cases.spec.ts index 3f1d147..5ccac83 100644 --- a/tests/tools/test-cases.spec.ts +++ b/tests/tools/test-cases.spec.ts @@ -1,38 +1,107 @@ +import fsPromises from "fs/promises"; +import os from "os"; +import path from "path"; + import { client, handleError } from "../../src/tools/client"; +import { findOctomindFolder } from "../../src/helpers"; +import { readTestCasesFromDir, buildFilename } from "../../src/tools/sync/yml"; import { deleteTestCase } from "../../src/tools/test-cases"; jest.mock("../../src/tools/client"); +jest.mock("../../src/helpers"); +jest.mock("../../src/tools/sync/yml"); describe("test-cases", () => { + const originalConsoleLog = console.log; let clientDELETE: jest.Mock; + beforeEach(() => { jest.clearAllMocks(); clientDELETE = client.DELETE as jest.Mock; console.log = jest.fn(); }); - it("should delete a test case", async () => { - clientDELETE.mockResolvedValue({ - data: { success: true }, - error: undefined, + afterEach(() => { + console.log = originalConsoleLog; + }); + + describe("deleteTestCase via API", () => { + beforeEach(() => { + jest.mocked(findOctomindFolder).mockResolvedValue(null); }); - await deleteTestCase({ - testTargetId: "test-target-id", - testCaseId: "test-case-id", + + it("should delete a test case", async () => { + clientDELETE.mockResolvedValue({ + data: { success: true }, + error: undefined, + }); + await deleteTestCase({ + testTargetId: "test-target-id", + testCaseId: "test-case-id", + }); + expect(handleError).toHaveBeenCalledWith(undefined); + expect(console.log).toHaveBeenCalledWith("Test Case deleted successfully"); + }); + + it("should handle error", async () => { + clientDELETE.mockResolvedValue({ + data: undefined, + error: { message: "error" }, + }); + await deleteTestCase({ + testTargetId: "test-target-id", + testCaseId: "test-case-id", + }); + expect(handleError).toHaveBeenCalledWith({ message: "error" }); }); - expect(handleError).toHaveBeenCalledWith(undefined); - expect(console.log).toHaveBeenCalledWith("Test Case deleted successfully"); }); - it("should handle error", async () => { - clientDELETE.mockResolvedValue({ - data: undefined, - error: { message: "error" }, + describe("deleteTestCase via local filesystem", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "octomind-test-"), + ); + jest.mocked(findOctomindFolder).mockResolvedValue(tmpDir); + }); + + afterEach(async () => { + await fsPromises.rm(tmpDir, { recursive: true }); }); - await deleteTestCase({ - testTargetId: "test-target-id", - testCaseId: "test-case-id", + + it("should delete a test case file when it exists locally", async () => { + const testCaseId = "test-case-id"; + const fileName = "myTestCase.yaml"; + const filePath = path.join(tmpDir, fileName); + await fsPromises.writeFile(filePath, ""); + + jest.mocked(readTestCasesFromDir).mockReturnValue([ + { id: testCaseId, description: "My Test Case" }, + ] as ReturnType); + jest.mocked(buildFilename).mockReturnValue(fileName); + + await deleteTestCase({ + testTargetId: "test-target-id", + testCaseId, + }); + + expect(console.log).toHaveBeenCalledWith("Test Case deleted successfully"); + await expect(fsPromises.access(filePath)).rejects.toThrow(); + }); + + it("should log message when test case ID is not found locally", async () => { + jest.mocked(readTestCasesFromDir).mockReturnValue([]); + + await deleteTestCase({ + testTargetId: "test-target-id", + testCaseId: "non-existent-id", + }); + + expect(console.log).toHaveBeenCalledWith( + `No test case with id non-existent-id found in folder ${tmpDir}`, + ); + expect(clientDELETE).not.toHaveBeenCalled(); }); - expect(handleError).toHaveBeenCalledWith({ message: "error" }); }); }); From de28bf2687b2e73b06341f4e729f11ef600e80d0 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Mon, 12 Jan 2026 17:14:03 +0100 Subject: [PATCH 10/20] fix linting --- biome.json | 2 +- src/cli.ts | 24 ++++++------ src/constants.ts | 2 +- src/debugtopus/index.ts | 10 ++--- src/helpers.ts | 20 ++++++---- src/tools/test-cases.ts | 22 +++++------ src/tools/test-targets.ts | 14 ++++--- src/tools/yamlMutations/create.ts | 61 ++++++++++++++++--------------- src/tools/yamlMutations/edit.ts | 17 ++++++--- tests/helpers.spec.ts | 17 +++++++-- tests/tools/test-cases.spec.ts | 20 ++++++---- 11 files changed, 121 insertions(+), 88 deletions(-) diff --git a/biome.json b/biome.json index 5ba9215..e30cda2 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.9/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": false, "clientKind": "git", diff --git a/src/cli.ts b/src/cli.ts index 4ac327b..1f71440 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,9 +38,9 @@ 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"; -import { create } from "./tools/yamlMutations/create"; export const BINARY_NAME = "octomind"; @@ -51,17 +51,17 @@ type WithTestTargetId = { testTargetId: string }; const addTestTargetWrapper = (fn: (options: T) => Promise) => - async ( - options: Omit & Partial>, - ): Promise => { - const resolvedTestTargetId = await resolveTestTargetId( - options.testTargetId, - ); - await fn({ - ...options, - testTargetId: resolvedTestTargetId, - } as T); - }; + async ( + options: Omit & Partial>, + ): Promise => { + const resolvedTestTargetId = await resolveTestTargetId( + options.testTargetId, + ); + await fn({ + ...options, + testTargetId: resolvedTestTargetId, + } as T); + }; const testTargetIdOption = new Option( "-t, --test-target-id [id]", diff --git a/src/constants.ts b/src/constants.ts index 0ec372d..5701db5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1 @@ -export const OCTOMIND_FOLDER_NAME = ".octomind" +export const OCTOMIND_FOLDER_NAME = ".octomind"; diff --git a/src/debugtopus/index.ts b/src/debugtopus/index.ts index 23bae21..8450165 100644 --- a/src/debugtopus/index.ts +++ b/src/debugtopus/index.ts @@ -10,6 +10,8 @@ import { promisify } from "util"; import { Open } from "unzipper"; +import { OCTOMIND_FOLDER_NAME } from "../constants"; +import { findOctomindFolder } from "../helpers"; import { getEnvironments, getPlaywrightCode, @@ -19,8 +21,6 @@ import { import { client, handleError } from "../tools/client"; import { readTestCasesFromDir } from "../tools/sync/yml"; import { ensureChromiumIsInstalled } from "./installation"; -import { findOctomindFolder } from "../helpers"; -import { OCTOMIND_FOLDER_NAME } from "../constants"; export type DebugtopusOptions = { testCaseId?: string; @@ -209,8 +209,8 @@ export const runDebugtopus = async (options: DebugtopusOptions) => { .filter((testCase) => options.grep ? testCase.description - ?.toLowerCase() - .includes(options.grep.toLowerCase()) + ?.toLowerCase() + .includes(options.grep.toLowerCase()) : true, ) .map(async (testCase) => ({ @@ -266,7 +266,7 @@ export const runDebugtopus = async (options: DebugtopusOptions) => { export const executeLocalTestCases = async ( options: DebugtopusOptions, ): Promise => { - const octomindRoot = await findOctomindFolder() + const octomindRoot = await findOctomindFolder(); if (!octomindRoot) { throw new Error( `Could not find ${OCTOMIND_FOLDER_NAME} folder, make sure to pull before trying to execute locally`, diff --git a/src/helpers.ts b/src/helpers.ts index 59fd43f..06fd58e 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,12 +1,11 @@ +import path from "node:path"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline"; +import fsPromises from "fs/promises"; import { loadConfig } from "./config"; -import path from "node:path"; -import fsPromises from "fs/promises"; import { OCTOMIND_FOLDER_NAME } from "./constants"; - export function promptUser(question: string): Promise { const rl = createInterface({ input, output }); @@ -34,7 +33,6 @@ export const resolveTestTargetId = async ( return config.testTargetId; }; - const isDirectory = async (dirPath: string): Promise => { try { const stat = await fsPromises.stat(dirPath); @@ -60,16 +58,22 @@ export const findOctomindFolder = async (): Promise => { return rootOctomind; } - return null + return null; }; -export const getAbsoluteFilePathInOctomindRoot = async ({ filePath, octomindRoot }: { filePath: string, octomindRoot: string }): Promise => { +export const getAbsoluteFilePathInOctomindRoot = async ({ + filePath, + octomindRoot, +}: { + filePath: string; + octomindRoot: string; +}): Promise => { try { const resolvedPath = await fsPromises.realpath( - path.isAbsolute(filePath) ? filePath : path.join(octomindRoot, filePath) + path.isAbsolute(filePath) ? filePath : path.join(octomindRoot, filePath), ); return resolvedPath.startsWith(octomindRoot) ? resolvedPath : null; } catch { return null; } -} +}; diff --git a/src/tools/test-cases.ts b/src/tools/test-cases.ts index ab41e77..c831fe0 100644 --- a/src/tools/test-cases.ts +++ b/src/tools/test-cases.ts @@ -1,11 +1,12 @@ +import fsPromises from "fs/promises"; import path from "path"; + import type { components, paths } from "../api"; // generated by openapi-typescript import { findOctomindFolder } from "../helpers"; import { getUrl } from "../url"; import { client, handleError, ListOptions, logJson } from "./client"; import { getEnvironments } from "./environments"; import { buildFilename, readTestCasesFromDir } from "./sync/yml"; -import fsPromises from "fs/promises"; export type TestCaseResponse = components["schemas"]["TestCaseResponse"]; export type TestCasesResponse = components["schemas"]["TestCasesResponse"]; @@ -17,7 +18,7 @@ export type DeleteTestCaseParams = export const deleteTestCase = async ( options: DeleteTestCaseParams & ListOptions, ): Promise => { - const octomindRoot = await findOctomindFolder() + const octomindRoot = await findOctomindFolder(); if (!octomindRoot) { const { data, error } = await client.DELETE( @@ -37,23 +38,22 @@ export const deleteTestCase = async ( logJson(data); } console.log("Test Case deleted successfully"); - return + return; } const testCases = readTestCasesFromDir(octomindRoot); const testCasesById = Object.fromEntries(testCases.map((tc) => [tc.id, tc])); - const existingTestCase = testCasesById[options.testCaseId] + const existingTestCase = testCasesById[options.testCaseId]; if (!existingTestCase) { - console.log(`No test case with id ${options.testCaseId} found in folder ${octomindRoot}`) - return + console.log( + `No test case with id ${options.testCaseId} found in folder ${octomindRoot}`, + ); + return; } - const existingTestCasePath = buildFilename( - existingTestCase, - octomindRoot, - ); - await fsPromises.unlink(path.join(octomindRoot, existingTestCasePath)) + const existingTestCasePath = buildFilename(existingTestCase, octomindRoot); + await fsPromises.unlink(path.join(octomindRoot, existingTestCasePath)); console.log("Test Case deleted successfully"); }; diff --git a/src/tools/test-targets.ts b/src/tools/test-targets.ts index 0b2238f..77b185f 100644 --- a/src/tools/test-targets.ts +++ b/src/tools/test-targets.ts @@ -1,11 +1,11 @@ import path from "path"; +import { OCTOMIND_FOLDER_NAME } from "../constants"; +import { findOctomindFolder } from "../helpers"; import { getUrl } from "../url"; import { client, handleError, ListOptions, logJson } from "./client"; import { push } from "./sync/push"; import { writeYaml } from "./sync/yml"; -import { findOctomindFolder } from "../helpers"; -import { OCTOMIND_FOLDER_NAME } from "../constants"; export const getTestTargets = async () => { const { data, error } = await client.GET("/apiKey/v3/test-targets"); @@ -70,7 +70,9 @@ export const pullTestTarget = async ( return; } - const destination = await findOctomindFolder() ?? path.join(process.cwd(), OCTOMIND_FOLDER_NAME) + const destination = + (await findOctomindFolder()) ?? + path.join(process.cwd(), OCTOMIND_FOLDER_NAME); writeYaml(data, destination); console.log("Test Target pulled successfully"); @@ -79,9 +81,11 @@ export const pullTestTarget = async ( export const pushTestTarget = async ( options: { testTargetId: string } & ListOptions, ): Promise => { - const sourceDir = await findOctomindFolder() + const sourceDir = await findOctomindFolder(); if (!sourceDir) { - throw new Error(`No ${OCTOMIND_FOLDER_NAME} folder found, please pull first.`) + throw new Error( + `No ${OCTOMIND_FOLDER_NAME} folder found, please pull first.`, + ); } const data = await push({ diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts index bb6c828..63bc888 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -3,46 +3,49 @@ import path from "path"; import yaml from "yaml"; +import { OCTOMIND_FOLDER_NAME } from "../../constants"; +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, +} from "../../helpers"; import { client, handleError } from "../client"; import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; import { SyncTestCase } from "../sync/types"; import { readTestCasesFromDir } from "../sync/yml"; -import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot } from "../../helpers"; -import { OCTOMIND_FOLDER_NAME } from "../../constants"; type CreateOptions = { - testTargetId: string; - name: string + testTargetId: string; + name: string; }; 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`, - ); - } + 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`, + ); + } - const newTestCase: SyncTestCase = { - version: "1", - id: crypto.randomUUID(), - elements: [], - runStatus: "OFF", - description: options.name, - prompt: "" - } + const newTestCase: SyncTestCase = { + version: "1", + id: crypto.randomUUID(), + elements: [], + runStatus: "OFF", + description: options.name, + prompt: "", + }; - const response = await draftPush( - { - testCases: [newTestCase], - }, - { - testTargetId: options.testTargetId, - client, - onError: handleError, - }, - ); + const response = await draftPush( + { + testCases: [newTestCase], + }, + { + testTargetId: options.testTargetId, + client, + onError: handleError, + }, + ); - console.log(response); + console.log(response); }; diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index 09ee3e5..689c309 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -3,20 +3,22 @@ import path from "path"; import yaml from "yaml"; +import { OCTOMIND_FOLDER_NAME } from "../../constants"; +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, +} from "../../helpers"; import { client, handleError } from "../client"; import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; import { SyncTestCase } from "../sync/types"; import { readTestCasesFromDir } from "../sync/yml"; -import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot } from "../../helpers"; -import { OCTOMIND_FOLDER_NAME } from "../../constants"; type EditOptions = { testTargetId: string; filePath: string; }; - const getRelevantTestCases = ( testCasesById: Record, startTestCase: SyncTestCase, @@ -56,9 +58,14 @@ export const edit = async (options: EditOptions): Promise => { `Could not find ${OCTOMIND_FOLDER_NAME} folder, make sure to pull before trying to edit`, ); } - const testCaseFilePath = await getAbsoluteFilePathInOctomindRoot({ octomindRoot, filePath: options.filePath }) + const testCaseFilePath = await getAbsoluteFilePathInOctomindRoot({ + octomindRoot, + filePath: options.filePath, + }); if (!testCaseFilePath) { - throw new Error(`Could not find ${options.filePath} in folder ${octomindRoot}`) + throw new Error( + `Could not find ${options.filePath} in folder ${octomindRoot}`, + ); } const testCaseToEdit = loadTestCase(testCaseFilePath); diff --git a/tests/helpers.spec.ts b/tests/helpers.spec.ts index 2b28b27..a4f65b6 100644 --- a/tests/helpers.spec.ts +++ b/tests/helpers.spec.ts @@ -2,11 +2,11 @@ import fsPromises from "fs/promises"; import os from "os"; import path from "path"; +import { OCTOMIND_FOLDER_NAME } from "../src/constants"; import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot, } from "../src/helpers"; -import { OCTOMIND_FOLDER_NAME } from "../src/constants"; describe("helpers", () => { describe("findOctomindFolder", () => { @@ -14,7 +14,9 @@ describe("helpers", () => { const originalCwd = process.cwd; beforeEach(async () => { - tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "octomind-test-")); + tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "octomind-test-"), + ); }); afterEach(async () => { @@ -91,7 +93,9 @@ describe("helpers", () => { let octomindRoot: string; beforeEach(async () => { - tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "octomind-test-")); + tmpDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "octomind-test-"), + ); octomindRoot = path.join(tmpDir, OCTOMIND_FOLDER_NAME); await fsPromises.mkdir(octomindRoot); }); @@ -173,7 +177,12 @@ describe("helpers", () => { it("should return null for malformed path with nested octomind structure", async () => { // e.g. /some/folder/.octomind/some/folder/.octomind/a.yaml - const malformedPath = path.join(octomindRoot, tmpDir, OCTOMIND_FOLDER_NAME, "a.yaml"); + const malformedPath = path.join( + octomindRoot, + tmpDir, + OCTOMIND_FOLDER_NAME, + "a.yaml", + ); const result = await getAbsoluteFilePathInOctomindRoot({ filePath: malformedPath, diff --git a/tests/tools/test-cases.spec.ts b/tests/tools/test-cases.spec.ts index 5ccac83..49e1d9b 100644 --- a/tests/tools/test-cases.spec.ts +++ b/tests/tools/test-cases.spec.ts @@ -2,9 +2,9 @@ import fsPromises from "fs/promises"; import os from "os"; import path from "path"; -import { client, handleError } from "../../src/tools/client"; import { findOctomindFolder } from "../../src/helpers"; -import { readTestCasesFromDir, buildFilename } from "../../src/tools/sync/yml"; +import { client, handleError } from "../../src/tools/client"; +import { buildFilename, readTestCasesFromDir } from "../../src/tools/sync/yml"; import { deleteTestCase } from "../../src/tools/test-cases"; jest.mock("../../src/tools/client"); @@ -40,7 +40,9 @@ describe("test-cases", () => { testCaseId: "test-case-id", }); expect(handleError).toHaveBeenCalledWith(undefined); - expect(console.log).toHaveBeenCalledWith("Test Case deleted successfully"); + expect(console.log).toHaveBeenCalledWith( + "Test Case deleted successfully", + ); }); it("should handle error", async () => { @@ -76,9 +78,11 @@ describe("test-cases", () => { const filePath = path.join(tmpDir, fileName); await fsPromises.writeFile(filePath, ""); - jest.mocked(readTestCasesFromDir).mockReturnValue([ - { id: testCaseId, description: "My Test Case" }, - ] as ReturnType); + jest + .mocked(readTestCasesFromDir) + .mockReturnValue([ + { id: testCaseId, description: "My Test Case" }, + ] as ReturnType); jest.mocked(buildFilename).mockReturnValue(fileName); await deleteTestCase({ @@ -86,7 +90,9 @@ describe("test-cases", () => { testCaseId, }); - expect(console.log).toHaveBeenCalledWith("Test Case deleted successfully"); + expect(console.log).toHaveBeenCalledWith( + "Test Case deleted successfully", + ); await expect(fsPromises.access(filePath)).rejects.toThrow(); }); From 5e8f77d88f29b53f4c07f46b3ff77b2ae73fb3cc Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 14:19:09 +0100 Subject: [PATCH 11/20] add dependency option --- src/cli.ts | 8 +++++++- src/tools/yamlMutations/create.ts | 13 ++----------- src/tools/yamlMutations/edit.ts | 2 -- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index bcc0214..60b8b64 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -410,9 +410,15 @@ export const buildCmd = (): CompletableCommand => { .helpGroup("test-cases") .addOption(testTargetIdOption) .requiredOption( - "-n, --name ", + "-n, --name [string]", "The name of the test case you want to create", ) + .addOption( + new Option( + "-d, --dependency ", + "The path of to test case you want to use as dependency", + ), + ) .action(addTestTargetWrapper(create)); // noinspection RequiredAttributes diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts index 63bc888..a780383 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -1,22 +1,13 @@ -import fs from "fs"; -import path from "path"; - -import yaml from "yaml"; - import { OCTOMIND_FOLDER_NAME } from "../../constants"; -import { - findOctomindFolder, - getAbsoluteFilePathInOctomindRoot, -} from "../../helpers"; +import { findOctomindFolder } from "../../helpers"; import { client, handleError } from "../client"; -import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; import { SyncTestCase } from "../sync/types"; -import { readTestCasesFromDir } from "../sync/yml"; type CreateOptions = { testTargetId: string; name: string; + from: string; }; export const create = async (options: CreateOptions): Promise => { diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index 78723b9..c5724bf 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -2,14 +2,12 @@ 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"; From 1cac41dba267e74100810deb5aca4ed406e41ba6 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 14:38:53 +0100 Subject: [PATCH 12/20] optionally push dependencies in create command --- src/cli.ts | 4 +- src/tools/yamlMutations/create.ts | 58 ++++++++++++++++++- src/tools/yamlMutations/edit.ts | 14 +---- .../yamlMutations/getRelevantTestCases.ts | 13 +++++ 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 60b8b64..52c5ccf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -410,12 +410,12 @@ export const buildCmd = (): CompletableCommand => { .helpGroup("test-cases") .addOption(testTargetIdOption) .requiredOption( - "-n, --name [string]", + "-n, --name ", "The name of the test case you want to create", ) .addOption( new Option( - "-d, --dependency ", + "-d, --dependency-path ", "The path of to test case you want to use as dependency", ), ) diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts index a780383..7860a8d 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -1,13 +1,52 @@ import { OCTOMIND_FOLDER_NAME } from "../../constants"; -import { findOctomindFolder } from "../../helpers"; +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, +} from "../../helpers"; import { client, handleError } from "../client"; +import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; import { SyncTestCase } from "../sync/types"; +import { readTestCasesFromDir } from "../sync/yaml"; +import { getRelevantTestCases, loadTestCase } from "./getRelevantTestCases"; type CreateOptions = { testTargetId: string; name: string; - from: string; + dependencyPath?: string; +}; + +const getDependecyTestCases = 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, + ); + checkForConsistency(relevantTestCases); + return { dependencyTestCase, relevantTestCases }; }; export const create = async (options: CreateOptions): Promise => { @@ -18,14 +57,29 @@ export const create = async (options: CreateOptions): Promise => { ); } + let dependencyId: string | undefined = undefined; + const testCasesToPush: SyncTestCase[] = []; + if (options.dependencyPath) { + const { dependencyTestCase, relevantTestCases } = + await getDependecyTestCases({ + 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); const response = await draftPush( { diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index c5724bf..b5f2c5b 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -1,5 +1,3 @@ -import fs from "fs"; - import { createTwoFilesPatch } from "diff"; import open from "open"; import yaml from "yaml"; @@ -12,9 +10,8 @@ import { 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 { getRelevantTestCases } from "./getRelevantTestCases"; +import { getRelevantTestCases, loadTestCase } from "./getRelevantTestCases"; import { waitForLocalChangesToBeFinished } from "./waitForLocalChanges"; type EditOptions = { @@ -22,15 +19,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/src/tools/yamlMutations/getRelevantTestCases.ts b/src/tools/yamlMutations/getRelevantTestCases.ts index d274c49..e63a4ed 100644 --- a/src/tools/yamlMutations/getRelevantTestCases.ts +++ b/src/tools/yamlMutations/getRelevantTestCases.ts @@ -1,3 +1,7 @@ +import fs from "fs"; + +import yaml from "yaml"; + import { SyncTestCase } from "../sync/types"; type LinkedTestCaseId = { @@ -71,3 +75,12 @@ export const getRelevantTestCases = ( return result; }; + +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}`); + } +}; From f8f7f8908fc61ae4a90fa3cbebbe571a1c681397 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 14:56:47 +0100 Subject: [PATCH 13/20] create logic to open and poll new test case --- src/tools/yamlMutations/create.ts | 94 +++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts index 7860a8d..7ec7e3c 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -1,14 +1,25 @@ +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 { client, handleError } from "../client"; +import { BASE_URL, client, handleError } from "../client"; import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; -import { SyncTestCase } from "../sync/types"; -import { readTestCasesFromDir } from "../sync/yaml"; +import { SyncDataByStableId, SyncTestCase } from "../sync/types"; +import { + buildFilename, + readTestCasesFromDir, + writeSingleTestCaseYaml, +} from "../sync/yaml"; import { getRelevantTestCases, loadTestCase } from "./getRelevantTestCases"; +import { waitForLocalChangesToBeFinished } from "./waitForLocalChanges"; type CreateOptions = { testTargetId: string; @@ -49,6 +60,61 @@ const getDependecyTestCases = async ({ return { dependencyTestCase, relevantTestCases }; }; +const openBrowserAndPoll = async ({ + newTestCase, + syncData, + testTargetId, +}: { + newTestCase: SyncTestCase; + syncData: SyncDataByStableId[number]; + testTargetId: string; +}) => { + const { versionId } = syncData; + + 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 createResult = await waitForLocalChangesToBeFinished( + versionId, + newTestCase, + { testTargetId }, + ); + return createResult; +}; + +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(`Edited test case successfully`); + console.log(diff); +}; + export const create = async (options: CreateOptions): Promise => { const octomindRoot = await findOctomindFolder(); if (!octomindRoot) { @@ -92,5 +158,25 @@ export const create = async (options: CreateOptions): Promise => { }, ); - console.log(response); + 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}'`); + } + + const createResult = await openBrowserAndPoll({ + newTestCase, + syncData, + testTargetId: options.testTargetId, + }); + + const newTestCasePath = path.join( + octomindRoot, + buildFilename(newTestCase, octomindRoot), + ); + await writeOutput({ createResult, testCaseFilePath: newTestCasePath }); }; From 2bde7eedb8f586fc7ad3825ac0d714478d2664ad Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 15:30:59 +0100 Subject: [PATCH 14/20] correctly build output path --- src/tools/yamlMutations/create.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts index 7ec7e3c..01fe435 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -15,6 +15,7 @@ import { draftPush } from "../sync/push"; import { SyncDataByStableId, SyncTestCase } from "../sync/types"; import { buildFilename, + buildFolderName, readTestCasesFromDir, writeSingleTestCaseYaml, } from "../sync/yaml"; @@ -56,7 +57,6 @@ const getDependecyTestCases = async ({ testCasesById, dependencyTestCase, ); - checkForConsistency(relevantTestCases); return { dependencyTestCase, relevantTestCases }; }; @@ -146,10 +146,11 @@ export const create = async (options: CreateOptions): Promise => { localEditingStatus: "IN_PROGRESS" as const, }; testCasesToPush.push(newTestCase); + checkForConsistency(testCasesToPush); const response = await draftPush( { - testCases: [newTestCase], + testCases: testCasesToPush, }, { testTargetId: options.testTargetId, @@ -174,9 +175,8 @@ export const create = async (options: CreateOptions): Promise => { testTargetId: options.testTargetId, }); - const newTestCasePath = path.join( - octomindRoot, - buildFilename(newTestCase, octomindRoot), - ); + const testCaseFolder = buildFolderName(newTestCase, testCasesToPush, octomindRoot) + const newTestCaseName = buildFilename(newTestCase, octomindRoot) + const newTestCasePath = path.join(testCaseFolder, newTestCaseName) await writeOutput({ createResult, testCaseFilePath: newTestCasePath }); }; From ea150ec57eb9d19d1691d1bcc320502e62eed856 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 15:38:44 +0100 Subject: [PATCH 15/20] add tests for create command --- src/tools/yamlMutations/create.ts | 10 +- tests/tools/yamlMutations/create.spec.ts | 199 +++++++++++++++++++++++ 2 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 tests/tools/yamlMutations/create.spec.ts diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts index 01fe435..4373ea8 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -175,8 +175,12 @@ export const create = async (options: CreateOptions): Promise => { testTargetId: options.testTargetId, }); - const testCaseFolder = buildFolderName(newTestCase, testCasesToPush, octomindRoot) - const newTestCaseName = buildFilename(newTestCase, octomindRoot) - const newTestCasePath = path.join(testCaseFolder, newTestCaseName) + 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/tests/tools/yamlMutations/create.spec.ts b/tests/tools/yamlMutations/create.spec.ts new file mode 100644 index 0000000..d9b5565 --- /dev/null +++ b/tests/tools/yamlMutations/create.spec.ts @@ -0,0 +1,199 @@ +import fs from "fs"; + +import open from "open"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import yaml from "yaml"; + +import { + findOctomindFolder, + getAbsoluteFilePathInOctomindRoot, +} from "../../../src/helpers"; +import { draftPush } from "../../../src/tools/sync/push"; +import { + buildFilename, + buildFolderName, + readTestCasesFromDir, + writeSingleTestCaseYaml, +} from "../../../src/tools/sync/yaml"; +import { create } from "../../../src/tools/yamlMutations/create"; +import { waitForLocalChangesToBeFinished } from "../../../src/tools/yamlMutations/waitForLocalChanges"; +import { createMockSyncTestCase } from "../../mocks"; + +vi.mock("fs"); +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"); + +const mockSuccessfulDraftPush = () => { + vi.mocked(draftPush).mockImplementation(async ({ testCases }) => { + const newTestCase = testCases[0]; + return { + success: true, + versionIds: [], + syncDataByStableId: { [newTestCase.id]: { versionId: "version-123" } }, + }; + }); +}; + +describe("create", () => { + beforeEach(() => { + console.log = vi.fn(); + + vi.mocked(findOctomindFolder).mockResolvedValue("/mock/.octomind"); + vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue( + createMockSyncTestCase({ id: "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(fs.readFileSync).mockReturnValue("this: is: invalid: "); + + 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 () => { + mockSuccessfulDraftPush(); + 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 () => { + mockSuccessfulDraftPush(); + + await create({ testTargetId: "someId", name: "Test Name" }); + + expect(console.log).toHaveBeenCalledWith("Edited test case successfully"); + expect(writeSingleTestCaseYaml).toHaveBeenCalledWith( + "/mock/.octomind/new-test.yaml", + expect.objectContaining({ id: "test-id" }), + ); + }); + + it("creates test case with correct properties", async () => { + vi.mocked(draftPush).mockImplementation(async ({ testCases }) => { + const newTestCase = testCases[0]; + expect(newTestCase).toMatchObject({ + version: "1", + description: "My New Test", + runStatus: "OFF", + localEditingStatus: "IN_PROGRESS", + elements: [], + prompt: "", + }); + expect(newTestCase.id).toBeDefined(); + expect(newTestCase.dependencyId).toBeUndefined(); + return { + success: true, + versionIds: [], + syncDataByStableId: { [newTestCase.id]: { versionId: "version-123" } }, + }; + }); + + await create({ testTargetId: "someId", name: "My New Test" }); + }); + + it("includes dependency when dependencyPath is provided", async () => { + const dependencyTestCase = createMockSyncTestCase({ id: "dependency-id" }); + + vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( + "/mock/.octomind/dependency.yaml", + ); + vi.mocked(fs.readFileSync).mockReturnValue( + yaml.stringify(dependencyTestCase), + ); + vi.mocked(readTestCasesFromDir).mockReturnValue([dependencyTestCase]); + vi.mocked(draftPush).mockImplementation(async ({ testCases }) => { + const newTestCase = testCases.find( + (tc) => tc.dependencyId === "dependency-id", + ); + expect(newTestCase).toBeDefined(); + expect(newTestCase?.dependencyId).toBe("dependency-id"); + const id = newTestCase?.id ?? "fallback"; + return { + success: true, + versionIds: [], + syncDataByStableId: { [id]: { versionId: "version-123" } }, + }; + }); + + await create({ + testTargetId: "someId", + name: "Test With Dependency", + dependencyPath: "dependency.yaml", + }); + }); + + it("opens browser with correct URL", async () => { + mockSuccessfulDraftPush(); + + await create({ testTargetId: "someId", name: "Test Name" }); + + expect(open).toHaveBeenCalledWith( + expect.stringContaining( + "/testtargets/someId/testcases/version-123/localEdit", + ), + ); + }); +}); From 53246b3d7d3647cd639cb1f5220020ccae28ac13 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 15:45:51 +0100 Subject: [PATCH 16/20] improve mocking for create and edit --- tests/mocks.ts | 17 +++- tests/tools/yamlMutations/create.spec.ts | 28 +++--- tests/tools/yamlMutations/edit.spec.ts | 109 ++++------------------- 3 files changed, 47 insertions(+), 107 deletions(-) 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 index d9b5565..c48475e 100644 --- a/tests/tools/yamlMutations/create.spec.ts +++ b/tests/tools/yamlMutations/create.spec.ts @@ -17,7 +17,10 @@ import { } from "../../../src/tools/sync/yaml"; import { create } from "../../../src/tools/yamlMutations/create"; import { waitForLocalChangesToBeFinished } from "../../../src/tools/yamlMutations/waitForLocalChanges"; -import { createMockSyncTestCase } from "../../mocks"; +import { + createMockDraftPushResponse, + createMockSyncTestCase, +} from "../../mocks"; vi.mock("fs"); vi.mock("open"); @@ -28,17 +31,6 @@ vi.mock("../../../src/tools/sync/yaml"); vi.mock("../../../src/tools/sync/consistency"); vi.mock("../../../src/tools/yamlMutations/waitForLocalChanges"); -const mockSuccessfulDraftPush = () => { - vi.mocked(draftPush).mockImplementation(async ({ testCases }) => { - const newTestCase = testCases[0]; - return { - success: true, - versionIds: [], - syncDataByStableId: { [newTestCase.id]: { versionId: "version-123" } }, - }; - }); -}; - describe("create", () => { beforeEach(() => { console.log = vi.fn(); @@ -109,7 +101,9 @@ describe("create", () => { }); it("exits gracefully when creation is cancelled", async () => { - mockSuccessfulDraftPush(); + vi.mocked(draftPush).mockImplementation(async ({ testCases }) => + createMockDraftPushResponse(testCases[0].id), + ); vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue("cancelled"); await create({ testTargetId: "someId", name: "Test Name" }); @@ -120,7 +114,9 @@ describe("create", () => { }); it("exits gracefully when creation is finished", async () => { - mockSuccessfulDraftPush(); + vi.mocked(draftPush).mockImplementation(async ({ testCases }) => + createMockDraftPushResponse(testCases[0].id), + ); await create({ testTargetId: "someId", name: "Test Name" }); @@ -186,7 +182,9 @@ describe("create", () => { }); it("opens browser with correct URL", async () => { - mockSuccessfulDraftPush(); + vi.mocked(draftPush).mockImplementation(async ({ testCases }) => + createMockDraftPushResponse(testCases[0].id), + ); await create({ testTargetId: "someId", name: "Test Name" }); diff --git a/tests/tools/yamlMutations/edit.spec.ts b/tests/tools/yamlMutations/edit.spec.ts index 4602013..2639297 100644 --- a/tests/tools/yamlMutations/edit.spec.ts +++ b/tests/tools/yamlMutations/edit.spec.ts @@ -1,21 +1,21 @@ 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 { beforeEach, describe, expect, it, vi } from "vitest"; import yaml from "yaml"; 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 { 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"); @@ -27,18 +27,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(fs.readFileSync).mockReturnValue(yaml.stringify(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 +52,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,10 +60,6 @@ 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: "); await expect( @@ -71,14 +68,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 +76,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 +88,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 +101,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 +123,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" }); From 0feddddbd57acb0ca2332cf66d5274803bf1ff26 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 15:52:35 +0100 Subject: [PATCH 17/20] fix expect mocking, using called with instead. --- src/tools/yamlMutations/create.ts | 4 +- tests/tools/yamlMutations/create.spec.ts | 83 +++++++++++------------- 2 files changed, 40 insertions(+), 47 deletions(-) diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts index 4373ea8..5ec6085 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -28,7 +28,7 @@ type CreateOptions = { dependencyPath?: string; }; -const getDependecyTestCases = async ({ +const getDependencyTestCases = async ({ octomindRoot, dependencyPath, }: { @@ -127,7 +127,7 @@ export const create = async (options: CreateOptions): Promise => { const testCasesToPush: SyncTestCase[] = []; if (options.dependencyPath) { const { dependencyTestCase, relevantTestCases } = - await getDependecyTestCases({ + await getDependencyTestCases({ octomindRoot, dependencyPath: options.dependencyPath, }); diff --git a/tests/tools/yamlMutations/create.spec.ts b/tests/tools/yamlMutations/create.spec.ts index c48475e..d3c0089 100644 --- a/tests/tools/yamlMutations/create.spec.ts +++ b/tests/tools/yamlMutations/create.spec.ts @@ -31,13 +31,19 @@ vi.mock("../../../src/tools/sync/yaml"); vi.mock("../../../src/tools/sync/consistency"); vi.mock("../../../src/tools/yamlMutations/waitForLocalChanges"); +const GENERATED_TEST_ID = "00000000-0000-0000-0000-000000000000"; + describe("create", () => { 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: "test-id" }), + createMockSyncTestCase({ id: GENERATED_TEST_ID }), ); vi.mocked(readTestCasesFromDir).mockReturnValue([]); @@ -101,9 +107,6 @@ describe("create", () => { }); it("exits gracefully when creation is cancelled", async () => { - vi.mocked(draftPush).mockImplementation(async ({ testCases }) => - createMockDraftPushResponse(testCases[0].id), - ); vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue("cancelled"); await create({ testTargetId: "someId", name: "Test Name" }); @@ -114,40 +117,35 @@ describe("create", () => { }); it("exits gracefully when creation is finished", async () => { - vi.mocked(draftPush).mockImplementation(async ({ testCases }) => - createMockDraftPushResponse(testCases[0].id), - ); - await create({ testTargetId: "someId", name: "Test Name" }); expect(console.log).toHaveBeenCalledWith("Edited test case successfully"); expect(writeSingleTestCaseYaml).toHaveBeenCalledWith( "/mock/.octomind/new-test.yaml", - expect.objectContaining({ id: "test-id" }), + expect.objectContaining({ id: GENERATED_TEST_ID }), ); }); it("creates test case with correct properties", async () => { - vi.mocked(draftPush).mockImplementation(async ({ testCases }) => { - const newTestCase = testCases[0]; - expect(newTestCase).toMatchObject({ - version: "1", - description: "My New Test", - runStatus: "OFF", - localEditingStatus: "IN_PROGRESS", - elements: [], - prompt: "", - }); - expect(newTestCase.id).toBeDefined(); - expect(newTestCase.dependencyId).toBeUndefined(); - return { - success: true, - versionIds: [], - syncDataByStableId: { [newTestCase.id]: { versionId: "version-123" } }, - }; - }); - 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 () => { @@ -160,32 +158,27 @@ describe("create", () => { yaml.stringify(dependencyTestCase), ); vi.mocked(readTestCasesFromDir).mockReturnValue([dependencyTestCase]); - vi.mocked(draftPush).mockImplementation(async ({ testCases }) => { - const newTestCase = testCases.find( - (tc) => tc.dependencyId === "dependency-id", - ); - expect(newTestCase).toBeDefined(); - expect(newTestCase?.dependencyId).toBe("dependency-id"); - const id = newTestCase?.id ?? "fallback"; - return { - success: true, - versionIds: [], - syncDataByStableId: { [id]: { versionId: "version-123" } }, - }; - }); await create({ testTargetId: "someId", name: "Test With Dependency", dependencyPath: "dependency.yaml", }); - }); - it("opens browser with correct URL", async () => { - vi.mocked(draftPush).mockImplementation(async ({ testCases }) => - createMockDraftPushResponse(testCases[0].id), + expect(draftPush).toHaveBeenCalledWith( + { + testCases: expect.arrayContaining([ + expect.objectContaining({ + id: GENERATED_TEST_ID, + dependencyId: "dependency-id", + }), + ]), + }, + expect.anything(), ); + }); + it("opens browser with correct URL", async () => { await create({ testTargetId: "someId", name: "Test Name" }); expect(open).toHaveBeenCalledWith( From bb1d6ed8ed92b82256e26a946fa7f96e312d5605 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 15:55:07 +0100 Subject: [PATCH 18/20] mini naming fixes in test case --- tests/tools/yamlMutations/create.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/tools/yamlMutations/create.spec.ts b/tests/tools/yamlMutations/create.spec.ts index d3c0089..8c1d7de 100644 --- a/tests/tools/yamlMutations/create.spec.ts +++ b/tests/tools/yamlMutations/create.spec.ts @@ -31,9 +31,9 @@ vi.mock("../../../src/tools/sync/yaml"); vi.mock("../../../src/tools/sync/consistency"); vi.mock("../../../src/tools/yamlMutations/waitForLocalChanges"); -const GENERATED_TEST_ID = "00000000-0000-0000-0000-000000000000"; - describe("create", () => { + const GENERATED_TEST_ID = "00000000-0000-0000-0000-000000000000"; + beforeEach(() => { console.log = vi.fn(); @@ -149,7 +149,8 @@ describe("create", () => { }); it("includes dependency when dependencyPath is provided", async () => { - const dependencyTestCase = createMockSyncTestCase({ id: "dependency-id" }); + const dependencyId = "dependency-id"; + const dependencyTestCase = createMockSyncTestCase({ id: dependencyId }); vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( "/mock/.octomind/dependency.yaml", @@ -170,7 +171,7 @@ describe("create", () => { testCases: expect.arrayContaining([ expect.objectContaining({ id: GENERATED_TEST_ID, - dependencyId: "dependency-id", + dependencyId, }), ]), }, From dce59f305541f0e9762abf00ea3e14bf279c1e65 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 16:44:43 +0100 Subject: [PATCH 19/20] move load test case method --- src/tools/sync/yaml.ts | 9 +++++++++ src/tools/yamlMutations/create.ts | 5 +++-- src/tools/yamlMutations/edit.ts | 8 ++++++-- src/tools/yamlMutations/getRelevantTestCases.ts | 13 ------------- tests/tools/yamlMutations/create.spec.ts | 15 ++++++--------- tests/tools/yamlMutations/edit.spec.ts | 12 +++++------- 6 files changed, 29 insertions(+), 33 deletions(-) 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 index 5ec6085..c9a8d82 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -16,10 +16,11 @@ import { SyncDataByStableId, SyncTestCase } from "../sync/types"; import { buildFilename, buildFolderName, + loadTestCase, readTestCasesFromDir, writeSingleTestCaseYaml, } from "../sync/yaml"; -import { getRelevantTestCases, loadTestCase } from "./getRelevantTestCases"; +import { getRelevantTestCases } from "./getRelevantTestCases"; import { waitForLocalChangesToBeFinished } from "./waitForLocalChanges"; type CreateOptions = { @@ -111,7 +112,7 @@ const writeOutput = async ({ yaml.stringify(createResult), ); - console.log(`Edited test case successfully`); + console.log(`Created test case successfully`); console.log(diff); }; diff --git a/src/tools/yamlMutations/edit.ts b/src/tools/yamlMutations/edit.ts index b5f2c5b..b8fb58e 100644 --- a/src/tools/yamlMutations/edit.ts +++ b/src/tools/yamlMutations/edit.ts @@ -10,8 +10,12 @@ import { import { BASE_URL, client, handleError } from "../client"; import { checkForConsistency } from "../sync/consistency"; import { draftPush } from "../sync/push"; -import { readTestCasesFromDir, writeSingleTestCaseYaml } from "../sync/yaml"; -import { getRelevantTestCases, loadTestCase } from "./getRelevantTestCases"; +import { + loadTestCase, + readTestCasesFromDir, + writeSingleTestCaseYaml, +} from "../sync/yaml"; +import { getRelevantTestCases } from "./getRelevantTestCases"; import { waitForLocalChangesToBeFinished } from "./waitForLocalChanges"; type EditOptions = { diff --git a/src/tools/yamlMutations/getRelevantTestCases.ts b/src/tools/yamlMutations/getRelevantTestCases.ts index e63a4ed..d274c49 100644 --- a/src/tools/yamlMutations/getRelevantTestCases.ts +++ b/src/tools/yamlMutations/getRelevantTestCases.ts @@ -1,7 +1,3 @@ -import fs from "fs"; - -import yaml from "yaml"; - import { SyncTestCase } from "../sync/types"; type LinkedTestCaseId = { @@ -75,12 +71,3 @@ export const getRelevantTestCases = ( return result; }; - -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}`); - } -}; diff --git a/tests/tools/yamlMutations/create.spec.ts b/tests/tools/yamlMutations/create.spec.ts index 8c1d7de..eadbc73 100644 --- a/tests/tools/yamlMutations/create.spec.ts +++ b/tests/tools/yamlMutations/create.spec.ts @@ -1,8 +1,5 @@ -import fs from "fs"; - import open from "open"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import yaml from "yaml"; import { findOctomindFolder, @@ -12,6 +9,7 @@ import { draftPush } from "../../../src/tools/sync/push"; import { buildFilename, buildFolderName, + loadTestCase, readTestCasesFromDir, writeSingleTestCaseYaml, } from "../../../src/tools/sync/yaml"; @@ -22,7 +20,6 @@ import { createMockSyncTestCase, } from "../../mocks"; -vi.mock("fs"); vi.mock("open"); vi.mock("../../../src/helpers"); vi.mock("../../../src/tools/client"); @@ -75,7 +72,9 @@ describe("create", () => { vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( "/mock/.octomind/dependency.yaml", ); - vi.mocked(fs.readFileSync).mockReturnValue("this: is: invalid: "); + vi.mocked(loadTestCase).mockImplementation(() => { + throw new Error("Could not parse /mock/.octomind/dependency.yaml"); + }); await expect( create({ @@ -119,7 +118,7 @@ describe("create", () => { it("exits gracefully when creation is finished", async () => { await create({ testTargetId: "someId", name: "Test Name" }); - expect(console.log).toHaveBeenCalledWith("Edited test case successfully"); + expect(console.log).toHaveBeenCalledWith("Created test case successfully"); expect(writeSingleTestCaseYaml).toHaveBeenCalledWith( "/mock/.octomind/new-test.yaml", expect.objectContaining({ id: GENERATED_TEST_ID }), @@ -155,9 +154,7 @@ describe("create", () => { vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( "/mock/.octomind/dependency.yaml", ); - vi.mocked(fs.readFileSync).mockReturnValue( - yaml.stringify(dependencyTestCase), - ); + vi.mocked(loadTestCase).mockReturnValue(dependencyTestCase); vi.mocked(readTestCasesFromDir).mockReturnValue([dependencyTestCase]); await create({ diff --git a/tests/tools/yamlMutations/edit.spec.ts b/tests/tools/yamlMutations/edit.spec.ts index 2639297..2f84db3 100644 --- a/tests/tools/yamlMutations/edit.spec.ts +++ b/tests/tools/yamlMutations/edit.spec.ts @@ -1,15 +1,12 @@ -import fs from "fs"; - import open from "open"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import yaml from "yaml"; import { findOctomindFolder, getAbsoluteFilePathInOctomindRoot, } from "../../../src/helpers"; 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 { @@ -17,7 +14,6 @@ import { createMockSyncTestCase, } from "../../mocks"; -vi.mock("fs"); vi.mock("open"); vi.mock("../../../src/helpers"); vi.mock("../../../src/tools/client"); @@ -36,7 +32,7 @@ describe("edit", () => { vi.mocked(getAbsoluteFilePathInOctomindRoot).mockResolvedValue( "/mock/.octomind/test.yaml", ); - vi.mocked(fs.readFileSync).mockReturnValue(yaml.stringify(testCase)); + vi.mocked(loadTestCase).mockReturnValue(testCase); vi.mocked(readTestCasesFromDir).mockReturnValue([testCase]); vi.mocked(waitForLocalChangesToBeFinished).mockResolvedValue( createMockSyncTestCase({ id: "test-id" }), @@ -60,7 +56,9 @@ describe("edit", () => { }); it("throws if test case file cannot be parsed", async () => { - 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" }), From 5edcbd8afea592ceff2438b0225d5706ed2e3cb0 Mon Sep 17 00:00:00 2001 From: Fabian Both Date: Thu, 15 Jan 2026 16:48:06 +0100 Subject: [PATCH 20/20] split openAndPoll method --- src/tools/yamlMutations/create.ts | 29 ++++++++++---------------- tests/tools/yamlMutations/edit.spec.ts | 5 ++++- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/tools/yamlMutations/create.ts b/src/tools/yamlMutations/create.ts index c9a8d82..d819b50 100644 --- a/src/tools/yamlMutations/create.ts +++ b/src/tools/yamlMutations/create.ts @@ -61,17 +61,13 @@ const getDependencyTestCases = async ({ return { dependencyTestCase, relevantTestCases }; }; -const openBrowserAndPoll = async ({ - newTestCase, - syncData, +const openBrowser = async ({ + versionId, testTargetId, }: { - newTestCase: SyncTestCase; - syncData: SyncDataByStableId[number]; + versionId: string; testTargetId: string; -}) => { - const { versionId } = syncData; - +}): Promise => { const parsedBaseUrl = URL.parse(BASE_URL); const localEditingUrl = new URL( `${parsedBaseUrl?.protocol}//${parsedBaseUrl?.host}/testtargets/${testTargetId}/testcases/${versionId}/localEdit`, @@ -82,13 +78,6 @@ const openBrowserAndPoll = async ({ console.log( `Navigating to local url, open it manually if a browser didn't open already: ${localEditingUrl}`, ); - - const createResult = await waitForLocalChangesToBeFinished( - versionId, - newTestCase, - { testTargetId }, - ); - return createResult; }; const writeOutput = async ({ @@ -170,11 +159,15 @@ export const create = async (options: CreateOptions): Promise => { throw new Error(`Could not create test case with id '${newTestCase.id}'`); } - const createResult = await openBrowserAndPoll({ - newTestCase, - syncData, + await openBrowser({ + versionId: syncData.versionId, testTargetId: options.testTargetId, }); + const createResult = await waitForLocalChangesToBeFinished( + syncData.versionId, + newTestCase, + { testTargetId: options.testTargetId }, + ); const testCaseFolder = buildFolderName( newTestCase, diff --git a/tests/tools/yamlMutations/edit.spec.ts b/tests/tools/yamlMutations/edit.spec.ts index 2f84db3..dcb928c 100644 --- a/tests/tools/yamlMutations/edit.spec.ts +++ b/tests/tools/yamlMutations/edit.spec.ts @@ -6,7 +6,10 @@ import { getAbsoluteFilePathInOctomindRoot, } from "../../../src/helpers"; import { draftPush } from "../../../src/tools/sync/push"; -import { loadTestCase, 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 {