import { ExecException } from "child_process"; import fs from "fs"; import path from "path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { HoppErrorCode } from "../../../types/errors"; import { getErrorCode, getTestJsonFilePath, runCLI, runCLIWithNetworkRetry, } from "../../utils"; describe("hopp test [options] ", { timeout: 100000 }, () => { const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; describe("Test `hopp test ` command:", () => { describe("Argument parsing", () => { test("Errors with the code `INVALID_ARGUMENT` for not supplying enough arguments", async () => { const args = "test"; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Errors with the code `INVALID_ARGUMENT` for an invalid command", async () => { const args = "invalid-arg"; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); }); describe("Supplied collection export file validations", () => { test("Errors with the code `FILE_NOT_FOUND` if the supplied collection export file doesn't exist", async () => { const args = "test notfound.json"; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("FILE_NOT_FOUND"); }); test("Errors with the code UNKNOWN_ERROR if the supplied collection export file content isn't valid JSON", async () => { const args = `test ${getTestJsonFilePath("malformed-coll.json", "collection")}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("UNKNOWN_ERROR"); }); test("Errors with the code `MALFORMED_COLLECTION` if the supplied collection export file content is malformed", async () => { const args = `test ${getTestJsonFilePath("malformed-coll-2.json", "collection")}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("MALFORMED_COLLECTION"); }); test("Errors with the code `INVALID_FILE_TYPE` if the supplied collection export file doesn't end with the `.json` extension", async () => { const args = `test ${getTestJsonFilePath("notjson-coll.txt", "collection")}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_FILE_TYPE"); }); test("Fails if the collection file includes scripts with incorrect API usage and failed assertions", async () => { const args = `test ${getTestJsonFilePath("fails-coll.json", "collection")}`; const { error } = await runCLI(args); expect(error).not.toBeNull(); expect(error).toMatchObject({ code: 1, }); }); }); describe("Versioned entities", () => { describe("Collections & Requests", () => { const testFixtures = [ { fileName: "coll-v1-req-v0.json", collVersion: 1, reqVersion: 0 }, { fileName: "coll-v1-req-v1.json", collVersion: 1, reqVersion: 1 }, { fileName: "coll-v2-req-v2.json", collVersion: 2, reqVersion: 2 }, { fileName: "coll-v2-req-v3.json", collVersion: 2, reqVersion: 3 }, ]; testFixtures.forEach(({ collVersion, fileName, reqVersion }) => { test(`Successfully processes a supplied collection export file where the collection is based on the "v${collVersion}" schema and the request following the "v${reqVersion}" schema`, async () => { const args = `test ${getTestJsonFilePath(fileName, "collection")}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); }); describe("Mixed versions", () => { test("Successfully processes children based on valid version ranges", async () => { const args = `test ${getTestJsonFilePath("valid-mixed-versions-coll.json", "collection")}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Errors with the code `MALFORMED_COLLECTION` if the children fall out of valid version ranges", async () => { const args = `test ${getTestJsonFilePath("invalid-mixed-versions-coll.json", "collection")}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("MALFORMED_COLLECTION"); }); }); }); describe("Environments", () => { const testFixtures = [ { fileName: "env-v0.json", version: 0 }, { fileName: "env-v1.json", version: 1 }, { fileName: "env-v2.json", version: 2 }, ]; testFixtures.forEach(({ fileName, version }) => { test(`Successfully processes the supplied collection and environment export files where the environment is based on the "v${version}" schema`, async () => { const ENV_PATH = getTestJsonFilePath(fileName, "environment"); const args = `test ${getTestJsonFilePath("sample-coll.json", "collection")} --env ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); }); }); }); test("Successfully processes a supplied collection export file of the expected format", async () => { const args = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Successfully inherits/overrides authorization and headers specified at the root collection at deeply nested collections", async () => { const args = `test ${getTestJsonFilePath( "collection-level-auth-headers-coll.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Successfully inherits/overrides authorization and headers at each level with multiple child collections", async () => { const args = `test ${getTestJsonFilePath( "multiple-child-collections-auth-headers-coll.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Successfully inherits collection variables into folders without their own variables", async () => { const args = `test ${getTestJsonFilePath( "collection-with-variables.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Persists environment variables set in the pre-request script for consumption in the test script", async () => { const args = `test ${getTestJsonFilePath( "pre-req-script-env-var-persistence-coll.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("The `Content-Type` header takes priority over the value set at the request body", async () => { const args = `test ${getTestJsonFilePath( "content-type-header-scenarios.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Strips comments from JSONC request bodies", async () => { const args = `test ${getTestJsonFilePath( "jsonc-body-coll.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); describe("OAuth 2 Authorization type with Authorization Code Grant Type", () => { test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { const args = `test ${getTestJsonFilePath( "oauth2-auth-code-coll.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); }); describe("multipart/form-data content type", () => { test("Successfully derives the relevant headers based and sends the form data in the request body", async () => { const args = `test ${getTestJsonFilePath( "oauth2-auth-code-coll.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); }); test("Successfully display console logs and recognizes platform APIs in the experimental scripting sandbox", async () => { const args = `test ${getTestJsonFilePath( "test-scripting-sandbox-modes-coll.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); const expectedStaticParts = [ "https://example.com/path?foo=bar&baz=qux", "'0': 72", "'12': 33", "Decoded: Hello, world!", "Hello after 1s", ]; expectedStaticParts.forEach((part) => { expect(result.stdout).toContain(part); }); const every500msCount = (result.stdout.match(/Every 500ms/g) || []) .length; expect(every500msCount).toBeGreaterThanOrEqual(3); }); test("Fails to display console logs and recognize platform APIs in the legacy scripting sandbox", async () => { const args = `test ${getTestJsonFilePath( "test-scripting-sandbox-modes-coll.json", "collection" )} --legacy-sandbox`; const { error, stdout } = await runCLI(args); expect(error).toBeTruthy(); expect(stdout).not.toContain("https://example.com/path?foo=bar&baz=qux"); expect(stdout).not.toContain("Encoded"); }); test("Ensures tests run in sequence order based on request path", async () => { // Expected order of collection runs const expectedOrder = [ "root-collection-request", "folder-1/folder-1-request", "folder-1/folder-11/folder-11-request", "folder-1/folder-12/folder-12-request", "folder-1/folder-13/folder-13-request", "folder-2/folder-2-request", "folder-2/folder-21/folder-21-request", "folder-2/folder-22/folder-22-request", "folder-2/folder-23/folder-23-request", "folder-3/folder-3-request", "folder-3/folder-31/folder-31-request", "folder-3/folder-32/folder-32-request", "folder-3/folder-33/folder-33-request", ]; const normalizePath = (path: string) => path.replace(/\\/g, "/"); const extractRunningOrder = (stdout: string): string[] => [...stdout.matchAll(/Running:.*?\/(.*?)\r?\n/g)].map( ([, path]) => normalizePath(path.replace(/\x1b\[\d+m/g, "")) // Remove ANSI codes and normalize paths ); const args = `test ${getTestJsonFilePath( "multiple-child-collections-auth-headers-coll.json", "collection" )}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(extractRunningOrder(result.stdout)).toStrictEqual(expectedOrder); expect(result.error).toBeNull(); }); /** * Tests pm.sendRequest() functionality with external HTTP endpoints. * * Network Resilience Strategy: * - Retries once (2 total attempts) on transient network errors * - Detects and logs specific errors (ECONNRESET, ETIMEDOUT, etc.) * - Validates JUnit XML completeness (60+ test suites) before accepting success * - Auto-skips on network failures to prevent blocking PRs */ test("Supports the new scripting API method additions under the `hopp` and `pm` namespaces and validates JUnit report structure", async () => { // First, run without JUnit report to ensure basic functionality works const basicArgs = `test ${getTestJsonFilePath( "scripting-revamp-coll.json", "collection" )}`; const basicResult = await runCLIWithNetworkRetry(basicArgs); if (basicResult === null) return; expect(basicResult.error).toBeNull(); // Then, run with JUnit report and validate structure const junitPath = path.join( __dirname, "scripting-revamp-snapshot-junit.xml" ); if (fs.existsSync(junitPath)) { fs.unlinkSync(junitPath); } const junitArgs = `test ${getTestJsonFilePath( "scripting-revamp-coll.json", "collection" )} --reporter-junit ${junitPath}`; // Enhanced retry for JUnit run - also validate output completeness const runWithValidation = async () => { const minExpectedTestSuites = 60; // Should have 67+ test suites const maxAttempts = 2; // Only retry once (2 total attempts) const extractNetworkError = (output: string): string => { const econnresetMatch = output.match(/ECONNRESET/i); const eaiAgainMatch = output.match(/EAI_AGAIN/i); const enotfoundMatch = output.match(/ENOTFOUND/i); const etimedoutMatch = output.match(/ETIMEDOUT/i); const econnrefusedMatch = output.match(/ECONNREFUSED/i); if (econnresetMatch) return "ECONNRESET (connection reset by peer)"; if (eaiAgainMatch) return "EAI_AGAIN (DNS lookup timeout)"; if (enotfoundMatch) return "ENOTFOUND (DNS lookup failed)"; if (etimedoutMatch) return "ETIMEDOUT (connection timeout)"; if (econnrefusedMatch) return "ECONNREFUSED (connection refused)"; return "Unknown network error"; }; for (let attempt = 0; attempt < maxAttempts; attempt++) { if (fs.existsSync(junitPath)) { fs.unlinkSync(junitPath); } const result = await runCLI(junitArgs); // Check for transient errors in output (network or httpbin 5xx) const output = `${result.stdout}\n${result.stderr}`; const hasNetworkError = /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED|REQUEST_ERROR/i.test( output ); const hasHttpbin5xx = /httpbin\.org is down \(5xx\)|httpbin\.org is down \(503\)/i.test( output ); // If successful and JUnit file exists, validate completeness if (!result.error && fs.existsSync(junitPath)) { const xml = fs.readFileSync(junitPath, "utf-8"); const testsuiteCount = (xml.match(/= minExpectedTestSuites && !hasHttpbin5xx) { return result; } // Incomplete output or httpbin issues - retry once if transient if ( (hasNetworkError || hasHttpbin5xx) && attempt < maxAttempts - 1 ) { const errorDetail = hasHttpbin5xx ? "httpbin.org 5xx response" : `incomplete output (${testsuiteCount}/${minExpectedTestSuites} test suites) with ${extractNetworkError(output)}`; console.log( `⚠️ Transient error detected: ${errorDetail}. Retrying once...` ); await new Promise((r) => setTimeout(r, 2000)); continue; } } // Non-transient error - fail fast if (result.error && !hasNetworkError && !hasHttpbin5xx) { return result; } // Transient error - retry once const isLastAttempt = attempt === maxAttempts - 1; if (!isLastAttempt) { const errorDetail = hasHttpbin5xx ? "httpbin.org 5xx response" : extractNetworkError(output); console.log( `⚠️ Transient error detected: ${errorDetail}. Retrying once...` ); await new Promise((r) => setTimeout(r, 2000)); continue; } // Last attempt exhausted due to transient issues - skip test to avoid blocking PR const errorDetail = hasHttpbin5xx ? "httpbin.org service degradation (5xx)" : extractNetworkError(output); console.warn( `⚠️ Skipping test: Retry exhausted due to ${errorDetail}. External services may be unavailable.` ); return null; // Signal to skip test } // Should never reach here - all paths above should return throw new Error("Unexpected: retry loop completed without returning"); }; const junitResult = await runWithValidation(); if (junitResult === null) return; expect(junitResult.error).toBeNull(); const junitXml = fs.readFileSync(junitPath, "utf-8"); // Validate structural invariants using regex parsing. // Validate no testcases have "root" as name (would indicate assertions at root level). const testcaseRootPattern = /]*name="root"/; expect(junitXml).not.toMatch(testcaseRootPattern); // Validate test structure: testcases should have meaningful names from test blocks const testcasePattern = / m[1] ); // Ensure we have testcases expect(testcaseNames.length).toBeGreaterThan(0); // Ensure no empty testcase names for (const name of testcaseNames) { expect(name.length).toBeGreaterThan(0); expect(name).not.toBe("root"); } // Validate presence of key test groups instead of snapshot comparison // This is more reliable for CI as network responses can vary // 1. Correct number of test suites const testsuitePattern = / { const args = `test ${getTestJsonFilePath( "collection-level-scripts-coll.json", "collection" )}`; const defaultResult = await runCLIWithNetworkRetry(args); if (defaultResult === null) return; expect(defaultResult.error).toBeNull(); const legacyResult = await runCLIWithNetworkRetry(`${args} --legacy-sandbox`); if (legacyResult === null) return; expect(legacyResult.error).toBeNull(); }); }); describe("Test `hopp test --env ` command:", () => { describe("Supplied environment export file validations", () => { describe("Argument parsing", () => { test("Errors with the code `INVALID_ARGUMENT` if no file is supplied", async () => { const args = `${VALID_TEST_ARGS} --env`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); }); test("Errors with the code `INVALID_FILE_TYPE` if the supplied environment export file doesn't end with the `.json` extension", async () => { const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath( "notjson-coll.txt", "collection" )}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_FILE_TYPE"); }); test("Errors with the code `FILE_NOT_FOUND` if the supplied environment export file doesn't exist", async () => { const args = `${VALID_TEST_ARGS} --env notfound.json`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("FILE_NOT_FOUND"); }); test("Errors with the code `MALFORMED_ENV_FILE` on supplying a malformed environment export file", async () => { const ENV_PATH = getTestJsonFilePath( "malformed-envs.json", "environment" ); const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("MALFORMED_ENV_FILE"); }); test("Errors with the code `BULK_ENV_FILE` on supplying an environment export file based on the bulk environment export format", async () => { const ENV_PATH = getTestJsonFilePath("bulk-envs.json", "environment"); const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("BULK_ENV_FILE"); }); }); test("Successfully resolves values from the supplied environment export file", async () => { const COLL_PATH = getTestJsonFilePath( "env-flag-tests-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment"); const args = `test ${COLL_PATH} --env ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Successfully resolves environment variables referenced in the request body", async () => { const COLL_PATH = getTestJsonFilePath( "req-body-env-vars-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "req-body-env-vars-envs.json", "environment" ); const args = `test ${COLL_PATH} --env ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Works with short `-e` flag", async () => { const COLL_PATH = getTestJsonFilePath( "env-flag-tests-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment"); const args = `test ${COLL_PATH} -e ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); describe("Secret environment variables", () => { // Reads secret environment values from system environment test("Successfully picks the values for secret environment variables from `process.env` and persists the variables set from the pre-request script", async () => { const env = { ...process.env, secretBearerToken: "test-token", secretBasicAuthUsername: "test-user", secretBasicAuthPassword: "test-pass", secretQueryParamValue: "secret-query-param-value", secretBodyValue: "secret-body-value", secretHeaderValue: "secret-header-value", }; const COLL_PATH = getTestJsonFilePath( "secret-envs-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath("secret-envs.json", "environment"); const args = `test ${COLL_PATH} --env ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args, { env }); if (result === null) return; expect(result.stdout).toContain( "https://httpbin.org/basic-auth/*********/*********" ); expect(result.error).toBeNull(); }); test("Successfully picks the values for secret environment variables set directly in the environment export file and persists the environment variables set from the pre-request script", async () => { const COLL_PATH = getTestJsonFilePath( "secret-envs-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "secret-supplied-values-envs.json", "environment" ); const args = `test ${COLL_PATH} --env ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.stdout).toContain( "https://httpbin.org/basic-auth/*********/*********" ); expect(result.error).toBeNull(); }); test("Setting values for secret environment variables from the pre-request script overrides values set at the supplied environment export file", async () => { const COLL_PATH = getTestJsonFilePath( "secret-envs-persistence-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "secret-supplied-values-envs.json", "environment" ); const args = `test ${COLL_PATH} --env ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.stdout).toContain( "https://httpbin.org/basic-auth/*********/*********" ); expect(result.error).toBeNull(); }); test("Persists secret environment variable values set from the pre-request script for consumption in the request and post-request script context", async () => { const COLL_PATH = getTestJsonFilePath( "secret-envs-persistence-scripting-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "secret-envs-persistence-scripting-envs.json", "environment" ); const args = `test ${COLL_PATH} --env ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); }); describe("Request variables", () => { test("Picks active request variables and ignores inactive entries alongside the usage of environment variables", async () => { const env = { ...process.env, secretBasicAuthPasswordEnvVar: "password", }; const COLL_PATH = getTestJsonFilePath( "request-vars-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "request-vars-envs.json", "environment" ); const args = `test ${COLL_PATH} --env ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args, { env }); if (result === null) return; expect(result.stdout).toContain( "https://echo.hoppscotch.io/********/********" ); expect(result.error).toBeNull(); }); }); describe("AWS Signature Authorization type", () => { test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { const env = { ...process.env, secretKey: "test-secret-key", serviceToken: "test-token", }; const COLL_PATH = getTestJsonFilePath( "aws-signature-auth-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "aws-signature-auth-envs.json", "environment" ); const args = `test ${COLL_PATH} -e ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args, { env }); if (result === null) return; expect(result.error).toBeNull(); }); }); describe("Digest Authorization type", () => { /** * NOTE: This test is being skipped because the test endpoint is no longer resolving * TODO: Find a reliable public endpoint that supports Digest Auth and re-enable this test */ test.skip("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => { const COLL_PATH = getTestJsonFilePath( "digest-auth-success-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "digest-auth-envs.json", "environment" ); const args = `test ${COLL_PATH} -e ${ENV_PATH}`; const { error } = await runCLI(args); expect(error).toBeNull(); }); }); test("Supports disabling request retries", async () => { const COLL_PATH = getTestJsonFilePath( "digest-auth-failure-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "digest-auth-envs.json", "environment" ); const args = `test ${COLL_PATH} -e ${ENV_PATH}`; const { error } = await runCLI(args); expect(error).toBeTruthy(); }); describe("HAWK Authentication", () => { test("Correctly generates and attaches authorization headers to the request ", async () => { const COLL_PATH = getTestJsonFilePath( "hawk-auth-success-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "hawk-auth-envs.json", "environment" ); const args = `test ${COLL_PATH} -e ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); }); }); describe("Test `hopp test --delay ` command:", () => { describe("Argument parsing", () => { test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => { const args = `${VALID_TEST_ARGS} --delay`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Errors with the code `INVALID_ARGUMENT` on supplying an invalid delay value", async () => { const args = `${VALID_TEST_ARGS} --delay 'NaN'`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); }); test("Successfully performs delayed request execution for a valid delay value", async () => { const args = `${VALID_TEST_ARGS} --delay 1`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); test("Works with the short `-d` flag", async () => { const args = `${VALID_TEST_ARGS} -d 1`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.error).toBeNull(); }); }); // Future TODO: Enable once a proper e2e test environment is set up locally describe.skip("Test `hopp test --env --token --server ` command:", () => { const { REQ_BODY_ENV_VARS_COLL_ID, COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID, REQ_BODY_ENV_VARS_ENVS_ID, PERSONAL_ACCESS_TOKEN, } = process.env; if ( !REQ_BODY_ENV_VARS_COLL_ID || !COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID || !REQ_BODY_ENV_VARS_ENVS_ID || !PERSONAL_ACCESS_TOKEN ) { return; } const SERVER_URL = "https://stage-shc.hoppscotch.io/backend"; describe("Argument parsing", () => { test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--token` flag", async () => { const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--server` flag", async () => { const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --server`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); }); describe("Workspace access validations", () => { const INVALID_COLLECTION_ID = "invalid-coll-id"; const INVALID_ENVIRONMENT_ID = "invalid-env-id"; const INVALID_ACCESS_TOKEN = "invalid-token"; test("Errors with the code `TOKEN_INVALID` if the supplied access token is invalid", async () => { const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token ${INVALID_ACCESS_TOKEN} --server ${SERVER_URL}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("TOKEN_INVALID"); }); test("Errors with the code `INVALID_ID` if the supplied collection ID is invalid", async () => { const args = `test ${INVALID_COLLECTION_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ID"); }); test("Errors with the code `INVALID_ID` if the supplied environment ID is invalid", async () => { const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${INVALID_ENVIRONMENT_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ID"); }); test("Errors with the code `INVALID_SERVER_URL` if not supplying a valid SH instance server URL", async () => { // FE URL of the staging SHC instance const INVALID_SERVER_URL = "https://stage-shc.hoppscotch.io"; const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${INVALID_SERVER_URL}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_SERVER_URL"); }); test("Errors with the code `SERVER_CONNECTION_REFUSED` if supplying an SH instance server URL that doesn't follow URL semantics", async () => { const INVALID_URL = "invalid-url"; const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${INVALID_URL}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("SERVER_CONNECTION_REFUSED"); }); }); test("Successfully retrieves a collection with the ID", async () => { const args = `test ${COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; const { error } = await runCLI(args); expect(error).toBeNull(); }); test("Successfully retrieves collections and environments from a workspace using their respective IDs", async () => { const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; const { error } = await runCLI(args); expect(error).toBeNull(); }); test("Supports specifying collection file path along with environment ID", async () => { const COLL_PATH = getTestJsonFilePath( "req-body-env-vars-coll.json", "collection" ); const args = `test ${COLL_PATH} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; const { error } = await runCLI(args); expect(error).toBeNull(); }); test("Supports specifying environment file path along with collection ID", async () => { const ENV_PATH = getTestJsonFilePath( "req-body-env-vars-envs.json", "environment" ); const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`; const { error } = await runCLI(args); expect(error).toBeNull(); }); test("Supports specifying both collection and environment file paths", async () => { const COLL_PATH = getTestJsonFilePath( "req-body-env-vars-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "req-body-env-vars-envs.json", "environment" ); const args = `test ${COLL_PATH} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN}`; const { error } = await runCLI(args); expect(error).toBeNull(); }); }); describe("Test`hopp test --env --reporter-junit [path]", () => { const genPath = path.resolve("hopp-cli-test"); // Helper function to replace dynamic values before generating test snapshots // Currently scoped to JUnit report generation const replaceDynamicValuesInStr = (input: string): string => input .replace(/(time|timestamp)="[^"]+"/g, (_, attr) => `${attr}="${attr}"`) // Strip QuickJS GC assertion errors - these are non-deterministic // and appear after script errors when scope disposal fails // Pattern matches multi-line format ending with ]] .replace( /\n\s*Then, failed to dispose scope: Aborted\(Assertion failed[^\]]*\]\]/g, "" ); beforeAll(() => { fs.mkdirSync(genPath); }); afterAll(() => { fs.rmdirSync(genPath, { recursive: true }); }); test("Report export fails with the code `REPORT_EXPORT_FAILED` while encountering an error during path creation", async () => { const exportPath = "hopp-junit-report.xml"; const COLL_PATH = getTestJsonFilePath("passes-coll.json", "collection"); const invalidPath = process.platform === "win32" ? "Z:/non-existent-path/report.xml" : "/non-existent/report.xml"; const args = `test ${COLL_PATH} --reporter-junit ${invalidPath}`; const { stdout, stderr } = await runCLI(args, { cwd: path.resolve("hopp-cli-test"), }); const out = getErrorCode(stderr); expect(out).toBe("REPORT_EXPORT_FAILED"); expect(stdout).not.toContain( `Successfully exported the JUnit report to: ${exportPath}` ); }); test("Generates a JUnit report at the default path", async () => { const exportPath = "hopp-junit-report.xml"; const COLL_PATH = getTestJsonFilePath( "test-junit-report-export-coll.json", "collection" ); const args = `test ${COLL_PATH} --reporter-junit`; // Use retry logic to handle transient network errors (ECONNRESET, etc.) // that can corrupt JUnit XML structure and cause snapshot mismatches const maxAttempts = 2; // Only retry once (2 total attempts) let lastResult: Awaited> | null = null; let lastFileContents = ""; for (let attempt = 0; attempt < maxAttempts; attempt++) { lastResult = await runCLI(args, { cwd: path.resolve("hopp-cli-test"), }); // Read JUnit XML file const fileContents = fs .readFileSync(path.resolve(genPath, exportPath)) .toString(); lastFileContents = fileContents; const hasNetworkErrorInXML = /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( fileContents ) || (/REQUEST_ERROR/i.test(fileContents) && !/Invalid URL/i.test(fileContents)); if (!hasNetworkErrorInXML) { break; } // Network error detected - retry once if not last attempt if (attempt < maxAttempts - 1) { console.log( `⚠️ Network error detected in JUnit XML (ECONNRESET/DNS). Retrying once to get clean snapshot...` ); // Delete corrupted XML file before retry try { fs.unlinkSync(path.resolve(genPath, exportPath)); } catch {} await new Promise((r) => setTimeout(r, 2000)); continue; } // Last attempt exhausted - skip test to avoid false positive console.warn( `⚠️ Skipping snapshot test: Network errors persisted in JUnit XML after retry. External services may be degraded.` ); return; // Skip test - don't fail on infrastructure issues } expect(lastResult?.stdout).not.toContain( `Overwriting the pre-existing path: ${exportPath}` ); expect(lastResult?.stdout).toContain( `Successfully exported the JUnit report to: ${exportPath}` ); expect(replaceDynamicValuesInStr(lastFileContents)).toMatchSnapshot(); }); test("Generates a JUnit report at the specified path", async () => { const exportPath = "outer-dir/inner-dir/report.xml"; const COLL_PATH = getTestJsonFilePath( "test-junit-report-export-coll.json", "collection" ); const args = `test ${COLL_PATH} --reporter-junit ${exportPath}`; // Use retry logic to handle transient network errors (ECONNRESET, etc.) // that can corrupt JUnit XML structure and cause snapshot mismatches const maxAttempts = 2; // Only retry once (2 total attempts) let lastResult: Awaited> | null = null; let lastFileContents = ""; for (let attempt = 0; attempt < maxAttempts; attempt++) { lastResult = await runCLI(args, { cwd: path.resolve("hopp-cli-test"), }); // Read JUnit XML file const fileContents = fs .readFileSync(path.resolve(genPath, exportPath)) .toString(); lastFileContents = fileContents; const hasNetworkErrorInXML = /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( fileContents ) || (/REQUEST_ERROR/i.test(fileContents) && !/Invalid URL/i.test(fileContents)); if (!hasNetworkErrorInXML) { break; } // Network error detected - retry once if not last attempt if (attempt < maxAttempts - 1) { console.log( `⚠️ Network error detected in JUnit XML (ECONNRESET/DNS). Retrying once to get clean snapshot...` ); // Delete corrupted XML file before retry try { fs.unlinkSync(path.resolve(genPath, exportPath)); } catch {} await new Promise((r) => setTimeout(r, 2000)); continue; } // Last attempt exhausted - skip test to avoid false positive console.warn( `⚠️ Skipping snapshot test: Network errors persisted in JUnit XML after retry. External services may be degraded.` ); return; // Skip test - don't fail on infrastructure issues } expect(lastResult?.stdout).not.toContain( `Overwriting the pre-existing path: ${exportPath}` ); expect(lastResult?.stdout).toContain( `Successfully exported the JUnit report to: ${exportPath}` ); expect(replaceDynamicValuesInStr(lastFileContents)).toMatchSnapshot(); }); test("Generates a JUnit report for a collection with authorization/headers set at the collection level", async () => { const exportPath = "hopp-junit-report.xml"; const COLL_PATH = getTestJsonFilePath( "collection-level-auth-headers-coll.json", "collection" ); const args = `test ${COLL_PATH} --reporter-junit`; // Use retry logic to handle transient network errors (ECONNRESET, etc.) // that can corrupt JUnit XML structure and cause snapshot mismatches const maxAttempts = 2; // Only retry once (2 total attempts) let lastResult: Awaited> | null = null; let lastFileContents = ""; for (let attempt = 0; attempt < maxAttempts; attempt++) { lastResult = await runCLI(args, { cwd: path.resolve("hopp-cli-test"), }); // Read JUnit XML file const fileContents = fs .readFileSync(path.resolve(genPath, exportPath)) .toString(); lastFileContents = fileContents; const hasNetworkErrorInXML = /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( fileContents ) || (/REQUEST_ERROR/i.test(fileContents) && !/Invalid URL/i.test(fileContents)); if (!hasNetworkErrorInXML) { break; } // Network error detected - retry once if not last attempt if (attempt < maxAttempts - 1) { console.log( `⚠️ Network error detected in JUnit XML (ECONNRESET/DNS). Retrying once to get clean snapshot...` ); // Delete corrupted XML file before retry try { fs.unlinkSync(path.resolve(genPath, exportPath)); } catch {} await new Promise((r) => setTimeout(r, 2000)); continue; } // Last attempt exhausted - skip test to avoid false positive console.warn( `⚠️ Skipping snapshot test: Network errors persisted in JUnit XML after retry. External services may be degraded.` ); return; // Skip test - don't fail on infrastructure issues } expect(lastResult?.stdout).toContain( `Overwriting the pre-existing path: ${exportPath}` ); expect(lastResult?.stdout).toContain( `Successfully exported the JUnit report to: ${exportPath}` ); expect(replaceDynamicValuesInStr(lastFileContents)).toMatchSnapshot(); }); test("Generates a JUnit report for a collection referring to environment variables", async () => { const exportPath = "hopp-junit-report.xml"; const COLL_PATH = getTestJsonFilePath( "req-body-env-vars-coll.json", "collection" ); const ENV_PATH = getTestJsonFilePath( "req-body-env-vars-envs.json", "environment" ); const args = `test ${COLL_PATH} --env ${ENV_PATH} --reporter-junit`; // Use retry logic to handle transient network errors (ECONNRESET, etc.) // that can corrupt JUnit XML structure and cause snapshot mismatches const maxAttempts = 2; // Only retry once (2 total attempts) let lastResult: Awaited> | null = null; let lastFileContents = ""; for (let attempt = 0; attempt < maxAttempts; attempt++) { lastResult = await runCLI(args, { cwd: path.resolve("hopp-cli-test"), }); // Read JUnit XML file const fileContents = fs .readFileSync(path.resolve(genPath, exportPath)) .toString(); lastFileContents = fileContents; const hasNetworkErrorInXML = /ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED/i.test( fileContents ) || (/REQUEST_ERROR/i.test(fileContents) && !/Invalid URL/i.test(fileContents)); if (!hasNetworkErrorInXML) { break; } // Network error detected - retry once if not last attempt if (attempt < maxAttempts - 1) { console.log( `⚠️ Network error detected in JUnit XML (ECONNRESET/DNS). Retrying once to get clean snapshot...` ); // Delete corrupted XML file before retry try { fs.unlinkSync(path.resolve(genPath, exportPath)); } catch {} await new Promise((r) => setTimeout(r, 2000)); continue; } // Last attempt exhausted - skip test to avoid false positive console.warn( `⚠️ Skipping snapshot test: Network errors persisted in JUnit XML after retry. External services may be degraded.` ); return; // Skip test - don't fail on infrastructure issues } expect(lastResult?.stdout).toContain( `Overwriting the pre-existing path: ${exportPath}` ); expect(lastResult?.stdout).toContain( `Successfully exported the JUnit report to: ${exportPath}` ); expect(replaceDynamicValuesInStr(lastFileContents)).toMatchSnapshot(); }); }); describe("Test `hopp test --iteration-count ` command:", () => { const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; test("Errors with the code `INVALID_ARGUMENT` on not supplying an iteration count", async () => { const args = `${VALID_TEST_ARGS} --iteration-count`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Errors with the code `INVALID_ARGUMENT` on supplying an invalid iteration count", async () => { const args = `${VALID_TEST_ARGS} --iteration-count NaN`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Errors with the code `INVALID_ARGUMENT` on supplying an iteration count below `1`", async () => { const args = `${VALID_TEST_ARGS} --iteration-count -5`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Successfully executes all requests in the collection iteratively based on the specified iteration count", async () => { const iterationCount = 3; const args = `${VALID_TEST_ARGS} --iteration-count ${iterationCount}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; Array.from({ length: 3 }).forEach((_, idx) => expect(result.stdout).include(`Iteration: ${idx + 1}/${iterationCount}`) ); expect(result.error).toBeNull(); }); test("Doesn't log iteration count if the value supplied is `1`", async () => { const args = `${VALID_TEST_ARGS} --iteration-count 1`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; expect(result.stdout).not.include(`Iteration: 1/1`); expect(result.error).toBeNull(); }); }); describe("Test `hopp test --iteration-data ` command:", () => { describe("Supplied data export file validations", () => { const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; test("Errors with the code `INVALID_ARGUMENT` if no file is supplied", async () => { const args = `${VALID_TEST_ARGS} --iteration-data`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); test("Errors with the code `INVALID_DATA_FILE_TYPE` if the supplied data file doesn't end with the `.csv` extension", async () => { const args = `${VALID_TEST_ARGS} --iteration-data ${getTestJsonFilePath( "notjson-coll.txt", "collection" )}`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("INVALID_DATA_FILE_TYPE"); }); test("Errors with the code `FILE_NOT_FOUND` if the supplied data export file doesn't exist", async () => { const args = `${VALID_TEST_ARGS} --iteration-data notfound.csv`; const { stderr } = await runCLI(args); const out = getErrorCode(stderr); expect(out).toBe("FILE_NOT_FOUND"); }); }); test("Prioritizes values from the supplied data export file over environment variables with relevant fallbacks for missing entries", async () => { const COLL_PATH = getTestJsonFilePath( "iteration-data-tests-coll.json", "collection" ); const ITERATION_DATA_PATH = getTestJsonFilePath( "iteration-data-export.csv", "environment" ); const ENV_PATH = getTestJsonFilePath( "iteration-data-envs.json", "environment" ); const args = `test ${COLL_PATH} --iteration-data ${ITERATION_DATA_PATH} -e ${ENV_PATH}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; const iterationCount = 3; Array.from({ length: iterationCount }).forEach((_, idx) => expect(result.stdout).include(`Iteration: ${idx + 1}/${iterationCount}`) ); expect(result.error).toBeNull(); }); test("Iteration count takes priority if supplied instead of inferring from the iteration data size", async () => { const COLL_PATH = getTestJsonFilePath( "iteration-data-tests-coll.json", "collection" ); const ITERATION_DATA_PATH = getTestJsonFilePath( "iteration-data-export.csv", "environment" ); const ENV_PATH = getTestJsonFilePath( "iteration-data-envs.json", "environment" ); const iterationCount = 5; const args = `test ${COLL_PATH} --iteration-data ${ITERATION_DATA_PATH} -e ${ENV_PATH} --iteration-count ${iterationCount}`; const result = await runCLIWithNetworkRetry(args); if (result === null) return; Array.from({ length: iterationCount }).forEach((_, idx) => expect(result.stdout).include(`Iteration: ${idx + 1}/${iterationCount}`) ); expect(result.error).toBeNull(); }); }); });