diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index c99c86bd680..aaec37eb7e3 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -1943,7 +1943,21 @@ "invalid_json" : "Invalid JSON file format", "dataset_iterations_info" : "Dataset contains {rows} rows, running {total} iterations as per your configuration.", "dataset_iterations_exceeds" : "Dataset contains {rows} rows, but {extra} iterations will be run as per your configuration.", - "no_failed_tests": "No tests failed" + "no_failed_tests": "No tests failed", + "request_selection": "Request Selection", + "select_requests": "Select Requests to Run", + "select_all": "Select All", + "deselect_all": "Deselect All", + "run_selected": "Run Selected", + "all_requests_selected": "All requests selected", + "expand_modal": "Expand", + "collapse_modal": "Collapse", + "loading_requests": "Loading requests...", + "select_tab": "Select", + "order_tab": "Order", + "drag_to_reorder": "Drag to reorder", + "will_run": "will run", + "reset_order": "Reset" }, "ai_experiments": { "generate_request_name": "Generate Request Name Using AI", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index d0329b81c8d..8b6a2cea7b7 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -216,6 +216,9 @@ declare module 'vue' { HttpTestEnv: typeof import('./components/http/test/Env.vue')['default'] HttpTestFolder: typeof import('./components/http/test/Folder.vue')['default'] HttpTestRequest: typeof import('./components/http/test/Request.vue')['default'] + HttpTestRequestRunOrder: typeof import('./components/http/test/RequestRunOrder.vue')['default'] + HttpTestRequestSelectionTree: typeof import('./components/http/test/RequestSelectionTree.vue')['default'] + HttpTestRequestSelectionTreeNode: typeof import('./components/http/test/RequestSelectionTreeNode.vue')['default'] HttpTestResponse: typeof import('./components/http/test/Response.vue')['default'] HttpTestResult: typeof import('./components/http/TestResult.vue')['default'] HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default'] diff --git a/packages/hoppscotch-common/src/components/http/test/RequestRunOrder.vue b/packages/hoppscotch-common/src/components/http/test/RequestRunOrder.vue new file mode 100644 index 00000000000..5897afd4566 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/RequestRunOrder.vue @@ -0,0 +1,269 @@ + + + + + + {{ t("collection_runner.request_selection") }} + + ({{ selectedCount }}/{{ items.length }}) + + + + + {{ t("collection_runner.deselect_all") }} + + | + + {{ t("collection_runner.select_all") }} + + | + + {{ t("collection_runner.reset_order") }} + + + + + + + + + + + + + + + + + {{ item.method }} + + + + + + {{ item.name }} + + + {{ item.folderPath.join(" › ") }} + + + + + + + + {{ t("collection_runner.loading_requests") }} + + + + + diff --git a/packages/hoppscotch-common/src/components/http/test/RequestSelectionTree.vue b/packages/hoppscotch-common/src/components/http/test/RequestSelectionTree.vue new file mode 100644 index 00000000000..277637eca27 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/RequestSelectionTree.vue @@ -0,0 +1,161 @@ + + + + + + + + {{ t("collection_runner.select_requests") }} + + + + {{ selectedCount }} / {{ totalCount }} + {{ totalCount === 1 ? t("count.request") : t("count.requests") }} + + + + + + + + + + + + + + + + diff --git a/packages/hoppscotch-common/src/components/http/test/RequestSelectionTreeNode.vue b/packages/hoppscotch-common/src/components/http/test/RequestSelectionTreeNode.vue new file mode 100644 index 00000000000..cc4d4fd488d --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/test/RequestSelectionTreeNode.vue @@ -0,0 +1,274 @@ + + + + + + + + + + + {{ folder.name }} + + + ({{ getFolderRequestCount(folder) }}) + + + + + + + + + + + + + {{ (request as HoppRESTRequest).method }} + + + {{ request.name }} + + + + + + + + + + + + + + + + {{ (request as HoppRESTRequest).method }} + + + {{ request.name }} + + + + + + + + diff --git a/packages/hoppscotch-common/src/components/http/test/RunnerModal.vue b/packages/hoppscotch-common/src/components/http/test/RunnerModal.vue index f285f394b59..1f5813a956e 100644 --- a/packages/hoppscotch-common/src/components/http/test/RunnerModal.vue +++ b/packages/hoppscotch-common/src/components/http/test/RunnerModal.vue @@ -3,14 +3,38 @@ dialog :title="t('collection_runner.run_collection')" :full-width-body="true" + :styles="modalStyles" @close="closeModal" > - + + + + + + + {{ t("collection_runner.loading_requests") }} + + + + + + {{ t("collection_runner.run_config") }} @@ -230,7 +254,8 @@ - + + @@ -273,6 +298,12 @@ + ({ keepVariableValues: true, }) +const requestSelection = ref({}) +const requestOrder = ref([]) +const collectionTreeForSelection = ref(null) +const isExpanded = ref(false) + +/** + * Flatten a collection to a list of request paths in natural traversal order + * (folders first, then requests — matching the runner's default order). + */ +const buildFlatOrder = (collection: HoppCollection): string[] => { + const paths: string[] = [] + const traverse = (node: HoppCollection, parentPath: string) => { + node.folders.forEach((folder, folderIdx) => { + const folderSeg = parentPath + ? `${parentPath}/folder_${folderIdx}` + : `folder_${folderIdx}` + traverse(folder as HoppCollection, folderSeg) + }) + node.requests.forEach((_: unknown, reqIdx: number) => { + paths.push( + parentPath ? `${parentPath}/request_${reqIdx}` : `request_${reqIdx}` + ) + }) + } + traverse(collection, "") + return paths +} +const modalStyles = computed(() => + isExpanded.value ? "max-w-6xl w-full" : "max-w-4xl w-full" +) + onMounted(async () => { if (props.prevConfig) { config.value = { ...config.value, ...props.prevConfig } } + + // Load collection tree for request selection + const tree = await getCollectionTree( + props.collectionRunnerData.type, + props.collectionRunnerData.collectionID + ) + + if (tree) { + collectionTreeForSelection.value = tree + // Initialize run order from natural traversal (if not already set from prevConfig) + if (requestOrder.value.length === 0) { + const flatPaths = buildFlatOrder(tree) + requestOrder.value = flatPaths + // Default: all requests selected + const allSelected: RequestSelectionState = {} + flatPaths.forEach((path) => { allSelected[path] = true }) + requestSelection.value = allSelected + } + } }) +// Sync requestSelection with config +watch(requestSelection, (newSelection) => { + config.value.requestSelection = newSelection +}, { deep: true }) + +// Sync requestOrder with config +watch(requestOrder, (newOrder) => { + config.value.requestOrder = newOrder +}, { deep: true }) + const runTests = async () => { const collectionTree = await getCollectionTree( props.collectionRunnerData.type, diff --git a/packages/hoppscotch-common/src/helpers/rest/document.ts b/packages/hoppscotch-common/src/helpers/rest/document.ts index bfff60723cd..9c070deb88d 100644 --- a/packages/hoppscotch-common/src/helpers/rest/document.ts +++ b/packages/hoppscotch-common/src/helpers/rest/document.ts @@ -95,6 +95,12 @@ export type HoppCollectionSaveContext = } | null +export type RequestSelectionState = { + // Map of request path to selection state + // Path format: "folder_0/folder_1/request_2" or "request_0" for root-level requests + [path: string]: boolean +} + export type TestRunnerConfig = { iterations: number delay: number @@ -108,6 +114,8 @@ export type TestRunnerConfig = { rawContent?: string // Store the original file content for persistence fileName?: string // Store the original file name } + requestSelection?: RequestSelectionState // Track selected/unselected requests + requestOrder?: string[] // Custom execution order — flat list of request paths in desired run order } export type HoppTestRunnerDocument = { diff --git a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts index 6b9c0fc84fc..73e8d730568 100644 --- a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts +++ b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts @@ -119,18 +119,30 @@ export class TestRunnerService extends Service { } // Run the collection for this iteration - await this.runTestCollection( - tab, - collection, - options, - [], - undefined, - undefined, - [], - undefined, - shouldResetCollection, - iterationData - ) + if (options.requestOrder && options.requestOrder.length > 0) { + // Custom execution order: use flat order with resolved context + await this.runTestsInCustomOrder( + tab, + collection, + options, + shouldResetCollection, + iterationData + ) + } else { + // Default: recursive traversal in natural collection order + await this.runTestCollection( + tab, + collection, + options, + [], + undefined, + undefined, + [], + undefined, + shouldResetCollection, + iterationData + ) + } // Add delay between iterations (except after the last one) if (iteration < iterations - 1 && options.delay && options.delay > 0) { @@ -226,6 +238,17 @@ export class TestRunnerService extends Service { throw new Error("Test execution stopped") } + // Check if this request should be executed based on selection state + const requestPath = this.buildRequestPath(parentPath, i) + const shouldExecute = this.shouldExecuteRequest( + requestPath, + options.requestSelection + ) + + if (!shouldExecute) { + continue // Skip this request if not selected + } + const request = collection.requests[i] as TestRunnerRequest const currentPath = [...parentPath, i] @@ -507,4 +530,213 @@ export class TestRunnerService extends Service { return { passed, failed } } + + /** + * Resolves a string path (e.g. "folder_0/folder_1/request_2") to the actual + * request plus its inherited auth, headers, and parent path array. + */ + private resolveRequestContext( + collection: HoppCollection, + pathStr: string + ): { + request: TestRunnerRequest + parentPath: number[] + requestIndex: number + inheritedAuth: HoppRESTRequest["auth"] + inheritedHeaders: HoppRESTHeaders + } | null { + const parts = pathStr.split("/") + let current: HoppCollection = collection + const parentPath: number[] = [] + + // Start with root-level auth/headers + let inheritedAuth: HoppRESTRequest["auth"] = + collection.auth?.authType === "inherit" + ? { authType: "none", authActive: false } + : collection.auth || { authType: "none", authActive: false } + + let inheritedHeaders: HoppRESTHeaders = [...(collection.headers || [])] + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const isLast = i === parts.length - 1 + + if (part.startsWith("folder_")) { + const folderIdx = parseInt(part.replace("folder_", ""), 10) + if (isLast) return null // path ends in folder, not a request + + const folder = current.folders[folderIdx] + if (!folder) return null + + // Accumulate auth/headers from this folder + inheritedAuth = + folder.auth?.authType === "inherit" && folder.auth?.authActive + ? inheritedAuth + : folder.auth || { authType: "none", authActive: false } + + inheritedHeaders = [...inheritedHeaders, ...(folder.headers || [])] + + parentPath.push(folderIdx) + current = folder as HoppCollection + } else if (part.startsWith("request_")) { + const reqIdx = parseInt(part.replace("request_", ""), 10) + const request = current.requests[reqIdx] + if (!request) return null + + return { + request: request as TestRunnerRequest, + parentPath, + requestIndex: reqIdx, + inheritedAuth, + inheritedHeaders, + } + } else { + return null // unknown segment + } + } + + return null + } + + /** + * Pre-populates all folder nodes in the result collection from the source + * collection. This is required before custom-order execution so that + * appendRequestToPath / updateRequestAtPath can navigate into sub-folders. + */ + private buildFolderSkeleton( + resultCollection: HoppCollection, + sourceCollection: HoppCollection, + path: number[] = [] + ): void { + sourceCollection.folders.forEach((folder, i) => { + const folderPath = [...path, i] + this.addFolderToPath(resultCollection, folderPath, { + ...cloneDeep(folder), + folders: [], + requests: [], + }) + this.buildFolderSkeleton( + resultCollection, + folder as HoppCollection, + folderPath + ) + }) + } + + /** + * Executes requests in the user-defined flat order stored in options.requestOrder. + * Auth/header inheritance is resolved for each request individually. + */ + private async runTestsInCustomOrder( + tab: Ref>, + collection: HoppCollection, + options: TestRunnerOptions, + shouldResetFoldersAndRequests: boolean, + iterationData?: any + ) { + // On the first iteration, pre-populate the folder tree in the result collection + // so that appendRequestToPath can navigate into sub-folders safely. + if (shouldResetFoldersAndRequests) { + this.buildFolderSkeleton(tab.value.document.resultCollection!, collection) + } + + // Sequential per-folder insertion counter so that the result collection always + // stores requests in the dragged custom order — not by their original array index. + // Without this, shouldReplaceAtIndex=true (first iteration) would write each + // request at its ORIGINAL index, recreating the default order on run 1. + const folderRequestCounters = new Map() + + for (const requestPath of options.requestOrder!) { + if (options.stopRef?.value) { + tab.value.document.status = "stopped" + throw new Error("Test execution stopped") + } + + // Honour selection: skip deselected requests + const shouldExecute = this.shouldExecuteRequest( + requestPath, + options.requestSelection + ) + if (!shouldExecute) continue + + // Resolve the request and its inherited context from the collection tree + const ctx = this.resolveRequestContext(collection, requestPath) + if (!ctx) continue + + const { request, parentPath, inheritedAuth, inheritedHeaders } = ctx + + // Use a sequential per-folder counter as the insertion index. + // This keeps the result collection in custom order regardless of the + // request's original position in the source collection. + const folderKey = parentPath.join("/") + const seqIndex = folderRequestCounters.get(folderKey) ?? 0 + folderRequestCounters.set(folderKey, seqIndex + 1) + + const fullPath = [...parentPath, seqIndex] + + // Add request slot to the result collection + this.appendRequestToPath( + tab.value.document.resultCollection!, + fullPath, + cloneDeep(request), + shouldResetFoldersAndRequests + ) + + // Apply inherited auth and headers + const finalRequest: TestRunnerRequest = { + ...request, + auth: + request.auth.authType === "inherit" && request.auth.authActive + ? inheritedAuth + : request.auth, + headers: [...inheritedHeaders, ...request.headers], + } + + await this.runTestRequest( + tab, + finalRequest, + collection, + options, + fullPath, + [], // inherited variables — simplified for custom order + shouldResetFoldersAndRequests, + iterationData + ) + + if (options.delay && options.delay > 0) { + try { + await delay(options.delay) + } catch (_error) { + if (options.stopRef?.value) { + tab.value.document.status = "stopped" + throw new Error("Test execution stopped") + } + } + } + } + } + + /** + * Builds a request path string from a path array + * Example: [0, 1, 2] -> "folder_0/folder_1/request_2" + */ + private buildRequestPath(parentPath: number[], requestIndex: number): string { + const folderPath = parentPath.map((idx) => `folder_${idx}`).join("/") + const requestPath = `request_${requestIndex}` + return folderPath ? `${folderPath}/${requestPath}` : requestPath + } + + /** + * Checks if a request should be executed based on selection state + * If no selection state is provided, all requests are executed + */ + private shouldExecuteRequest( + requestPath: string, + selectionState?: Record + ): boolean { + if (!selectionState || Object.keys(selectionState).length === 0) { + return true // Execute all if no selection state + } + return selectionState[requestPath] ?? false + } }