diff --git a/README.md b/README.md index e1129ff..766b390 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,26 @@ Execute test cases to create a test report | `-b, --browser [type]` | Browser type [CHROMIUM, FIREFOX, SAFARI] | No | CHROMIUM | | `-r, --breakpoint [name]` | Breakpoint [DESKTOP, MOBILE, TABLET] | No | DESKTOP | +## execute-local + +Execute local YAML test cases + +**Usage:** `execute-local [options]` + +### Options + +| Option | Description | Required | Default | +|:-------|:----------|:---------|:--------| +| `-j, --json` | Output raw JSON response | No | | +| `-u, --url ` | url the tests should run against | Yes | | +| `-e, --environment-id [uuid]` | id of the environment you want to run against, if not provided will run all test cases against the default environment | No | | +| `-t, --test-target-id [uuid]` | id of the test target of the test case, if not provided will use the test target id from the config | No | | +| `--headless` | if we should run headless without the UI of playwright and the browser | No | | +| `--bypass-proxy` | bypass proxy when accessing the test target | No | | +| `--browser [CHROMIUM, FIREFOX, SAFARI]` | Browser type | No | CHROMIUM | +| `--breakpoint [DESKTOP, MOBILE, TABLET]` | Breakpoint | No | DESKTOP | +| `-s, --source ` | Source directory (defaults to current directory) | Yes | ./.octomind | + ## create-discovery Create a new test case discovery diff --git a/package.json b/package.json index def5b15..549a191 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@octomind/octomind", - "version": "3.5.1", + "version": "3.6.0", "description": "a command line client for octomind apis", "main": "./dist/index.js", "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402", diff --git a/src/cli.ts b/src/cli.ts index 70ce270..6be5942 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { testTargetIdCompleter, uninstallCompletion, } from "./completion"; -import { runDebugtopus } from "./debugtopus"; +import { executeLocalTestCases, runDebugtopus } from "./debugtopus"; import { resolveTestTargetId } from "./helpers"; import { startPrivateLocationWorker, stopPLW } from "./plw"; import { @@ -170,6 +170,35 @@ export const buildCmd = (): CompletableCommand => { ) .action(addTestTargetWrapper(executeTests)); + createCommandWithCommonOptions(program, "execute-local") + .completer(environmentIdCompleter) + .completer(testTargetIdCompleter) + .completer(optionsCompleter) + .description("Execute local YAML test cases") + .helpGroup("execute") + .requiredOption("-u, --url ", "url the tests should run against") + .option( + "-e, --environment-id [uuid]", + "id of the environment you want to run against, if not provided will run all test cases against the default environment", + ) + .option( + "-t, --test-target-id [uuid]", + "id of the test target of the test case, if not provided will use the test target id from the config", + ) + .option( + "--headless", + "if we should run headless without the UI of playwright and the browser", + ) + .option("--bypass-proxy", "bypass proxy when accessing the test target") + .option("--browser [CHROMIUM, FIREFOX, SAFARI]", "Browser type", "CHROMIUM") + .option("--breakpoint [DESKTOP, MOBILE, TABLET]", "Breakpoint", "DESKTOP") + .option( + "-s, --source ", + "Source directory (defaults to current directory)", + "./.octomind", + ) + .action(addTestTargetWrapper(executeLocalTestCases)); + createCommandWithCommonOptions(program, "test-report") .completer(testReportIdCompleter) .completer(testTargetIdCompleter) diff --git a/src/debugtopus/index.ts b/src/debugtopus/index.ts index a671fa0..c8de521 100644 --- a/src/debugtopus/index.ts +++ b/src/debugtopus/index.ts @@ -260,3 +260,76 @@ export const runDebugtopus = async (options: DebugtopusOptions) => { }); await runTests({ ...dirs, runMode: options.headless ? "headless" : "ui" }); }; + +export const executeLocalTestCases = async ( + options: DebugtopusOptions & { source: string }, +): Promise => { + const testCases = readTestCasesFromDir(options.source); + const body = { + testCases, + testTargetId: options.testTargetId, + executionUrl: options.url, + environmentId: options.environmentId, + }; + const { error, response } = await client.POST( + "/apiKey/beta/test-targets/{testTargetId}/code", + { + params: { + path: { + testTargetId: options.testTargetId, + }, + }, + body, + parseAs: "stream", + }, + ); + + if (error || !response?.body) { + handleError(error); + } + + const dirs = await prepareDirectories(); + + await readZipFromResponseBody(dirs, response); + + const config = await getPlaywrightConfig({ + testTargetId: options.testTargetId, + url: options.url, + outputDir: dirs.outputDir, + environmentId: options.environmentId, + headless: options.headless, + bypassProxy: options.bypassProxy, + browser: options.browser, + breakpoint: options.breakpoint, + }); + + if (!config) { + handleError("no config found"); + } + + writeFileSync(dirs.configFilePath, config); + + await runTests({ ...dirs, runMode: options.headless ? "headless" : "ui" }); +}; + +export const readZipFromResponseBody = async ( + dirs: TestDirectories, + response: Response, +): Promise => { + // Persist the ZIP to disk first to avoid streaming issues with unzipper + const zipPath = path.join(dirs.testDirectory, "bundle.zip"); + const zipWriteStream = createWriteStream(zipPath); + await pipeline( + Readable.fromWeb(response.body as ReadableStream), + zipWriteStream, + ); + + // Extract using unzipper's higher-level API + try { + const zipBuffer = await fs.readFile(zipPath); + const directory = await Open.buffer(zipBuffer); + await directory.extract({ path: dirs.testDirectory }); + } catch { + throw new Error(`Failed to extract ZIP at ${zipPath}`); + } +}; diff --git a/src/tools/sync/yml.ts b/src/tools/sync/yml.ts index fd2015c..ba78d26 100644 --- a/src/tools/sync/yml.ts +++ b/src/tools/sync/yml.ts @@ -140,6 +140,7 @@ const collectYamlFiles = (startDir: string): string[] => { try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { + current = stack.pop(); continue; } for (const entry of entries) { diff --git a/tests/debugtopous/index.spec.ts b/tests/debugtopous/index.spec.ts new file mode 100644 index 0000000..d167fdb --- /dev/null +++ b/tests/debugtopous/index.spec.ts @@ -0,0 +1,239 @@ +import { readZipFromResponseBody, executeLocalTestCases } from "../../src/debugtopus/index"; +import { ReadableStream } from "stream/web"; +import fs from "fs/promises"; +import { createWriteStream, existsSync, writeFileSync } from "fs"; +import { Open } from "unzipper"; +import { pipeline } from "node:stream/promises"; +import { client, handleError } from "../../src/tools/client"; +import { readTestCasesFromDir } from "../../src/tools/sync/yml"; +import { getPlaywrightConfig } from "../../src/tools/playwright"; +import { createMockSyncTestCase } from "../mocks"; +import { DeepMockProxy, mockDeep, mock } from "jest-mock-extended"; +import { ensureChromiumIsInstalled } from "../../src/debugtopus/installation"; + +jest.mock("fs/promises"); +jest.mock("fs"); +jest.mock("unzipper"); +jest.mock("../../src/tools/client"); +jest.mock("../../src/tools/sync/yml"); +jest.mock("../../src/tools/playwright"); +jest.mock("../../src/debugtopus/installation"); +jest.mock("child_process"); +jest.mock("node:stream/promises"); + +const mockedFs = fs as jest.Mocked; +const mockedCreateWriteStream = createWriteStream as jest.MockedFunction; +const mockedExistsSync = existsSync as jest.MockedFunction; +const mockedWriteFileSync = writeFileSync as jest.MockedFunction; +const mockedOpen = Open as jest.Mocked; +const mockedPipeline = pipeline as jest.MockedFunction; +const mockedClient = client as DeepMockProxy; +const mockedReadTestCasesFromDir = readTestCasesFromDir as jest.MockedFunction; +const mockedGetPlaywrightConfig = getPlaywrightConfig as jest.MockedFunction; +const mockedHandleError = handleError as jest.MockedFunction; +const mockedEnsureChromiumIsInstalled = ensureChromiumIsInstalled as jest.MockedFunction; + +describe("debugtopus", () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("readZipFromResponseBody", () => { + + it("should read a zip from a response body", async () => { + // Create a mock zip buffer (minimal valid zip file) + // This is a minimal ZIP file structure: local file header + central directory + const mockZipBuffer = Buffer.from([ + 0x50, 0x4B, 0x03, 0x04, // Local file header signature + 0x14, 0x00, // Version needed to extract + 0x00, 0x00, // General purpose bit flag + 0x08, 0x00, // Compression method + 0x00, 0x00, 0x00, 0x00, // Last mod file time and date + 0x00, 0x00, 0x00, 0x00, // CRC-32 + 0x05, 0x00, 0x00, 0x00, // Compressed size + 0x05, 0x00, 0x00, 0x00, // Uncompressed size + 0x04, 0x00, // File name length + 0x00, 0x00, // Extra field length + 0x74, 0x65, 0x73, 0x74, // File name: "test" + 0x68, 0x65, 0x6C, 0x6C, 0x6F, // File content: "hello" + ]); + + const mockReadableStream = new ReadableStream({ + start(controller) { + controller.enqueue(mockZipBuffer); + controller.close(); + }, + }); + + const mockResponse = { + body: mockReadableStream, + } as Response; + + const mockDirs = { + testDirectory: "/tmp/test-dir", + outputDir: "/tmp/test-dir/output", + configFilePath: "/tmp/test-dir/config.ts", + packageRootDir: "/tmp", + }; + + const mockWriteStream = { + write: jest.fn(), + end: jest.fn(), + on: jest.fn((event, callback) => { + if (event === "finish") { + setTimeout(() => callback(), 0); + } + return mockWriteStream; + }), + } as unknown as NodeJS.WritableStream; + + const mockDirectory = { + extract: jest.fn().mockResolvedValue(undefined), + }; + + mockedCreateWriteStream.mockReturnValue(mockWriteStream as any); + mockedPipeline.mockResolvedValue(undefined); + mockedFs.readFile.mockResolvedValue(mockZipBuffer); + mockedOpen.buffer = jest.fn().mockResolvedValue(mockDirectory); + + await readZipFromResponseBody(mockDirs, mockResponse); + + expect(mockedCreateWriteStream).toHaveBeenCalledWith( + "/tmp/test-dir/bundle.zip" + ); + expect(mockedPipeline).toHaveBeenCalled(); + expect(mockedFs.readFile).toHaveBeenCalledWith("/tmp/test-dir/bundle.zip"); + expect(mockedOpen.buffer).toHaveBeenCalledWith(mockZipBuffer); + expect(mockDirectory.extract).toHaveBeenCalledWith({ + path: "/tmp/test-dir", + }); + }); + + it("should throw an error if zip extraction fails", async () => { + const mockZipBuffer = Buffer.from([0x50, 0x4B, 0x03, 0x04]); + + const mockReadableStream = new ReadableStream({ + start(controller) { + controller.enqueue(mockZipBuffer); + controller.close(); + }, + }); + + const mockResponse = { + body: mockReadableStream, + } as Response; + + const mockDirs = { + testDirectory: "/tmp/test-dir", + outputDir: "/tmp/test-dir/output", + configFilePath: "/tmp/test-dir/config.ts", + packageRootDir: "/tmp", + }; + + const mockWriteStream = { + write: jest.fn(), + end: jest.fn(), + on: jest.fn((event, callback) => { + if (event === "finish") { + setTimeout(() => callback(), 0); + } + return mockWriteStream; + }), + } as unknown as NodeJS.WritableStream; + + mockedCreateWriteStream.mockReturnValue(mockWriteStream as any); + mockedPipeline.mockResolvedValue(undefined); + mockedFs.readFile.mockResolvedValue(mockZipBuffer); + mockedOpen.buffer = jest.fn().mockRejectedValue(new Error("Invalid zip")); + + await expect(readZipFromResponseBody(mockDirs, mockResponse)).rejects.toThrow( + "Failed to extract ZIP at /tmp/test-dir/bundle.zip" + ); + }); + + }) + + describe("executeLocalTestCases", () => { + + it("should execute local test cases from zip response body", async () => { + const mockTestCases = [createMockSyncTestCase()]; + const mockZipBuffer = Buffer.from([0x50, 0x4B, 0x03, 0x04]); + const mockConfig = "export default { testDir: './tests' };"; + const mockReadableStream = new ReadableStream({ + start(controller) { + controller.enqueue(mockZipBuffer); + controller.close(); + }, + }); + const mockResponse = mockDeep(); + Object.defineProperty(mockResponse, "body", { value: mockReadableStream, writable: true, configurable: true }); + const mockWriteStream = { write: jest.fn(), end: jest.fn(), on: jest.fn() } as any; + const mockDirectory = { extract: jest.fn().mockResolvedValue(undefined) }; + + mockedReadTestCasesFromDir.mockReturnValue(mockTestCases); + mockedClient.POST.mockResolvedValue({ error: undefined, response: mockResponse, data: undefined }); + mockedExistsSync.mockImplementation((path: Parameters[0]) => { + const pathStr = typeof path === "string" ? path : path.toString(); + return pathStr.includes("node_modules"); + }); + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.rm.mockResolvedValue(undefined); + mockedCreateWriteStream.mockReturnValue(mockWriteStream); + mockedPipeline.mockResolvedValue(undefined); + mockedFs.readFile.mockResolvedValue(mockZipBuffer); + mockedOpen.buffer = jest.fn().mockResolvedValue(mockDirectory); + mockedGetPlaywrightConfig.mockResolvedValue(mockConfig); + mockedEnsureChromiumIsInstalled.mockResolvedValue(undefined); + + const { exec } = require("child_process"); + (exec as jest.MockedFunction).mockImplementation((_command: string, _options: any, callback?: any) => { + if (callback) setImmediate(() => callback(null, { stdout: "", stderr: "" })); + return {} as any; + }); + + await executeLocalTestCases({ + testTargetId: "test-target-id", + url: "https://example.com", + source: "/test/source", + headless: true, + }); + + expect(mockedReadTestCasesFromDir).toHaveBeenCalledWith("/test/source"); + expect(mockedClient.POST).toHaveBeenCalledWith( + "/apiKey/beta/test-targets/{testTargetId}/code", + expect.objectContaining({ + params: { path: { testTargetId: "test-target-id" } }, + body: { testCases: mockTestCases, testTargetId: "test-target-id", executionUrl: "https://example.com", environmentId: undefined }, + parseAs: "stream", + }) + ); + expect(mockedGetPlaywrightConfig).toHaveBeenCalled(); + expect(mockedWriteFileSync).toHaveBeenCalledWith(expect.stringContaining("config.ts"), mockConfig); + }); + + it("should handle error when response is missing", async () => { + const mockTestCases = [createMockSyncTestCase()]; + + mockedReadTestCasesFromDir.mockReturnValue(mockTestCases); + mockedClient.POST.mockResolvedValue({ + error: undefined, + response: null as any, + data: undefined, + }); + + await expect( + executeLocalTestCases({ + testTargetId: "test-target-id", + url: "https://example.com", + source: "/test/source", + headless: true, + }) + ).rejects.toThrow(); + + expect(mockedHandleError).toHaveBeenCalled(); + }); + + }) + +})