feat: collection runner (#3600)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
Anwarul Islam
2024-11-26 16:26:09 +06:00
committed by GitHub
parent f091c1bdc5
commit e8ed938b4c
66 changed files with 3201 additions and 490 deletions

View File

@@ -89,6 +89,9 @@ export class ParameterMenuService extends Service implements ContextMenu {
const tabService = getService(RESTTabService)
if (tabService.currentActiveTab.value.document.type === "test-runner")
return
const currentActiveRequest =
tabService.currentActiveTab.value.document.type === "request"
? tabService.currentActiveTab.value.document.request

View File

@@ -55,9 +55,9 @@ export class URLMenuService extends Service implements ContextMenu {
}
this.restTab.createNewTab({
type: "request",
request: request,
isDirty: false,
type: "request",
})
}

View File

@@ -8,7 +8,6 @@ import { computed, markRaw, reactive } from "vue"
import { Component, Ref, ref, watch } from "vue"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { RESTTabService } from "../tab/rest"
/**
* Defines how to render the text in an Inspector Result
*/
@@ -127,18 +126,24 @@ export class InspectionService extends Service {
watch(
() => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id],
() => {
const currentTabRequest = computed(() =>
this.restTab.currentActiveTab.value.document.type === "request"
const currentTabRequest = computed(() => {
if (
this.restTab.currentActiveTab.value.document.type === "test-runner"
)
return null
return this.restTab.currentActiveTab.value.document.type === "request"
? this.restTab.currentActiveTab.value.document.request
: this.restTab.currentActiveTab.value.document.response
.originalRequest
)
})
const currentTabResponse = computed(() =>
this.restTab.currentActiveTab.value.document.type === "request"
? this.restTab.currentActiveTab.value.document.response
: null
)
const currentTabResponse = computed(() => {
if (this.restTab.currentActiveTab.value.document.type === "request") {
return this.restTab.currentActiveTab.value.document.response
}
return null
})
const reqRef = computed(() => currentTabRequest.value)
const resRef = computed(() => currentTabResponse.value)
@@ -147,6 +152,7 @@ export class InspectionService extends Service {
const debouncedRes = refDebounced(resRef, 1000, { maxWait: 2000 })
const inspectorRefs = Array.from(this.inspectors.values()).map((x) =>
// @ts-expect-error - This is a valid call
x.getInspections(debouncedReq, debouncedRes)
)

View File

@@ -43,7 +43,10 @@ export class AuthorizationInspectorService
const activeTabDocument =
this.restTabService.currentActiveTab.value.document
if (activeTabDocument.type === "example-response") {
if (
activeTabDocument.type === "example-response" ||
activeTabDocument.type === "test-runner"
) {
return null
}
@@ -60,6 +63,7 @@ export class AuthorizationInspectorService
req: Readonly<Ref<HoppRESTRequest | HoppRESTResponseOriginalRequest>>
) {
return computed(() => {
if (!req.value) return []
const currentInterceptorIDValue =
this.interceptorService.currentInterceptorID.value

View File

@@ -77,10 +77,12 @@ export class EnvironmentInspectorService extends Service implements Inspector {
const currentTabRequest =
currentTab.document.type === "request"
? currentTab.document.request
: currentTab.document.response.originalRequest
: currentTab.document.type === "example-response"
? currentTab.document.response.originalRequest
: null
const environmentVariables = [
...currentTabRequest.requestVariables,
...(currentTabRequest?.requestVariables ?? []),
...this.aggregateEnvsWithSecrets.value,
]
@@ -191,11 +193,13 @@ export class EnvironmentInspectorService extends Service implements Inspector {
const currentTabRequest =
currentTab.document.type === "request"
? currentTab.document.request
: currentTab.document.response.originalRequest
: currentTab.document.type === "example-response"
? currentTab.document.response.originalRequest
: null
const environmentVariables =
this.filterNonEmptyEnvironmentVariables([
...currentTabRequest.requestVariables.map((env) => ({
...(currentTabRequest?.requestVariables ?? []).map((env) => ({
...env,
secret: false,
sourceEnv: "RequestVariable",
@@ -300,6 +304,8 @@ export class EnvironmentInspectorService extends Service implements Inspector {
return computed(() => {
const results: InspectorResult[] = []
if (!req.value) return results
const headers = req.value.headers
const params = req.value.params

View File

@@ -40,6 +40,9 @@ export class HeaderInspectorService extends Service implements Inspector {
) {
return computed(() => {
const results: InspectorResult[] = []
if (!req.value) return results
const headers = req.value.headers
const headerKeys = Object.values(headers).map((header) => header.key)

View File

@@ -25,7 +25,7 @@ const DEFAULT_SETTINGS = getDefaultSettings()
export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
{
v: 4,
v: 5,
name: "Echo",
folders: [],
requests: [
@@ -51,7 +51,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
{
v: 4,
v: 5,
name: "Echo",
folders: [],
requests: [

View File

@@ -698,22 +698,22 @@ export class PersistenceService extends Service {
try {
if (restTabStateData) {
let parsedGqlTabStateData = JSON.parse(restTabStateData)
let parsedRESTTabStateData = JSON.parse(restTabStateData)
// Validate data read from localStorage
const result = REST_TAB_STATE_SCHEMA.safeParse(parsedGqlTabStateData)
const result = REST_TAB_STATE_SCHEMA.safeParse(parsedRESTTabStateData)
if (result.success) {
parsedGqlTabStateData = result.data
parsedRESTTabStateData = result.data
} else {
this.showErrorToast(restTabStateKey)
window.localStorage.setItem(
`${restTabStateKey}-backup`,
JSON.stringify(parsedGqlTabStateData)
JSON.stringify(parsedRESTTabStateData)
)
}
this.restTabService.loadTabsFromPersistedState(parsedGqlTabStateData)
this.restTabService.loadTabsFromPersistedState(parsedRESTTabStateData)
}
} catch (e) {
console.error(

View File

@@ -7,6 +7,7 @@ import {
HoppRESTRequest,
HoppRESTHeaders,
HoppRESTRequestResponse,
HoppCollection,
} from "@hoppscotch/data"
import { entityReference } from "verzod"
import { z } from "zod"
@@ -75,36 +76,13 @@ const SettingsDefSchema = z.object({
ENABLE_AI_EXPERIMENTS: z.optional(z.boolean()),
})
// Common properties shared across REST & GQL collections
const HoppCollectionSchemaCommonProps = z
.object({
v: z.number(),
name: z.string(),
id: z.optional(z.string()),
})
.strict()
const HoppRESTRequestSchema = entityReference(HoppRESTRequest)
const HoppGQLRequestSchema = entityReference(HoppGQLRequest)
// @ts-expect-error recursive schema
const HoppRESTCollectionSchema = HoppCollectionSchemaCommonProps.extend({
folders: z.array(z.lazy(() => HoppRESTCollectionSchema)),
requests: z.optional(z.array(HoppRESTRequestSchema)),
const HoppRESTCollectionSchema = entityReference(HoppCollection)
auth: z.optional(HoppRESTAuth),
headers: z.optional(HoppRESTHeaders),
}).strict()
// @ts-expect-error recursive schema
const HoppGQLCollectionSchema = HoppCollectionSchemaCommonProps.extend({
folders: z.array(z.lazy(() => HoppGQLCollectionSchema)),
requests: z.optional(z.array(HoppGQLRequestSchema)),
auth: z.optional(HoppGQLAuth),
headers: z.optional(z.array(GQLHeader)),
}).strict()
const HoppGQLCollectionSchema = entityReference(HoppCollection)
export const VUEX_SCHEMA = z.object({
postwoman: z.optional(
@@ -551,6 +529,33 @@ export const REST_TAB_STATE_SCHEMA = z
saveContext: z.optional(HoppRESTSaveContextSchema),
isDirty: z.boolean(),
}),
z.object({
type: z.literal("test-runner").catch("test-runner"),
config: z.object({
delay: z.number(),
iterations: z.number(),
keepVariableValues: z.boolean(),
persistResponses: z.boolean(),
stopOnError: z.boolean(),
}),
status: z.enum(["idle", "running", "stopped", "error"]),
collection: HoppRESTCollectionSchema,
collectionType: z.enum(["my-collections", "team-collections"]),
collectionID: z.optional(z.string()),
resultCollection: z.optional(HoppRESTCollectionSchema),
testRunnerMeta: z.object({
totalRequests: z.number(),
completedRequests: z.number(),
totalTests: z.number(),
passedTests: z.number(),
failedTests: z.number(),
totalTime: z.number(),
}),
request: z.nullable(entityReference(HoppRESTRequest)),
response: z.nullable(HoppRESTResponseSchema),
testResults: z.optional(z.nullable(HoppTestResultSchema)),
isDirty: z.boolean(),
}),
]),
})
),

View File

@@ -325,9 +325,9 @@ export class CollectionsSpotlightSearcherService
this.restTab.createNewTab(
{
type: "request",
request: req,
isDirty: false,
type: "request",
saveContext: {
originLocation: "user-collection",
folderPath: folderPath.join("/"),

View File

@@ -1,9 +1,9 @@
import { Container } from "dioc"
import { isEqual } from "lodash-es"
import { computed } from "vue"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTSaveContext, HoppTabDocument } from "~/helpers/rest/document"
import { TabService } from "./tab"
import { Container } from "dioc"
export class RESTTabService extends TabService<HoppTabDocument> {
public static readonly ID = "REST_TAB_SERVICE"
@@ -52,6 +52,8 @@ export class RESTTabService extends TabService<HoppTabDocument> {
public getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
for (const tab of this.tabMap.values()) {
// For `team-collection` request id can be considered unique
if (tab.document.type === "test-runner") continue
if (ctx?.originLocation === "team-collection") {
if (
tab.document.saveContext?.originLocation === "team-collection" &&

View File

@@ -0,0 +1,355 @@
import {
HoppCollection,
HoppRESTHeaders,
HoppRESTRequest,
} from "@hoppscotch/data"
import { Service } from "dioc"
import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash-es"
import { Ref } from "vue"
import { runTestRunnerRequest } from "~/helpers/RequestRunner"
import {
HoppTestRunnerDocument,
TestRunnerConfig,
} from "~/helpers/rest/document"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "~/helpers/types/HoppTestResult"
import { HoppTab } from "../tab"
export type TestRunnerOptions = {
stopRef: Ref<boolean>
} & TestRunnerConfig
export type TestRunnerRequest = HoppRESTRequest & {
type: "test-response"
response?: HoppRESTResponse | null
testResults?: HoppTestResult | null
isLoading?: boolean
error?: string
renderResults?: boolean
passedTests: number
failedTests: number
}
function delay(timeMS: number) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, timeMS)
return () => {
clearTimeout(timeout)
reject(new Error("Operation cancelled"))
}
})
}
export class TestRunnerService extends Service {
public static readonly ID = "TEST_RUNNER_SERVICE"
public runTests(
tab: Ref<HoppTab<HoppTestRunnerDocument>>,
collection: HoppCollection,
options: TestRunnerOptions
) {
// Reset the result collection
tab.value.document.status = "running"
tab.value.document.resultCollection = {
v: collection.v,
id: collection.id,
name: collection.name,
auth: collection.auth,
headers: collection.headers,
folders: [],
requests: [],
}
this.runTestCollection(tab, collection, options)
.then(() => {
tab.value.document.status = "stopped"
})
.catch((error) => {
if (
error instanceof Error &&
error.message === "Test execution stopped"
) {
tab.value.document.status = "stopped"
} else {
tab.value.document.status = "error"
console.error("Test runner failed:", error)
}
})
.finally(() => {
tab.value.document.status = "stopped"
})
}
private async runTestCollection(
tab: Ref<HoppTab<HoppTestRunnerDocument>>,
collection: HoppCollection,
options: TestRunnerOptions,
parentPath: number[] = [],
parentHeaders?: HoppRESTHeaders,
parentAuth?: HoppRESTRequest["auth"]
) {
try {
// Compute inherited auth and headers for this collection
const inheritedAuth =
collection.auth?.authType === "inherit" && collection.auth.authActive
? parentAuth || { authType: "none", authActive: false }
: collection.auth || { authType: "none", authActive: false }
const inheritedHeaders: HoppRESTHeaders = [
...(parentHeaders || []),
...collection.headers,
]
// Process folders progressively
for (let i = 0; i < collection.folders.length; i++) {
if (options.stopRef?.value) {
tab.value.document.status = "stopped"
throw new Error("Test execution stopped")
}
const folder = collection.folders[i]
const currentPath = [...parentPath, i]
// Add folder to the result collection
this.addFolderToPath(
tab.value.document.resultCollection!,
currentPath,
{
...cloneDeep(folder),
folders: [],
requests: [],
}
)
// Pass inherited headers and auth to the folder
await this.runTestCollection(
tab,
folder,
options,
currentPath,
inheritedHeaders,
inheritedAuth
)
}
// Process requests progressively
for (let i = 0; i < collection.requests.length; i++) {
if (options.stopRef?.value) {
tab.value.document.status = "stopped"
throw new Error("Test execution stopped")
}
const request = collection.requests[i] as TestRunnerRequest
const currentPath = [...parentPath, i]
// Add request to the result collection before execution
this.addRequestToPath(
tab.value.document.resultCollection!,
currentPath,
cloneDeep(request)
)
// Update the request with inherited headers and auth before execution
const finalRequest = {
...request,
auth:
request.auth.authType === "inherit" && request.auth.authActive
? inheritedAuth
: request.auth,
headers: [...inheritedHeaders, ...request.headers],
}
await this.runTestRequest(
tab,
finalRequest,
collection,
options,
currentPath
)
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")
}
}
}
}
} catch (error) {
if (
error instanceof Error &&
error.message === "Test execution stopped"
) {
throw error
}
tab.value.document.status = "error"
console.error("Collection execution failed:", error)
throw error
}
}
private addFolderToPath(
collection: HoppCollection,
path: number[],
folder: HoppCollection
) {
let current = collection
// Navigate to the parent folder
for (let i = 0; i < path.length - 1; i++) {
current = current.folders[path[i]]
}
// Add the folder at the specified index
if (path.length > 0) {
current.folders[path[path.length - 1]] = folder
}
}
private addRequestToPath(
collection: HoppCollection,
path: number[],
request: TestRunnerRequest
) {
let current = collection
// Navigate to the parent folder
for (let i = 0; i < path.length - 1; i++) {
current = current.folders[path[i]]
}
// Add the request at the specified index
if (path.length > 0) {
current.requests[path[path.length - 1]] = request
}
}
private updateRequestAtPath(
collection: HoppCollection,
path: number[],
updates: Partial<TestRunnerRequest>
) {
let current = collection
// Navigate to the parent folder
for (let i = 0; i < path.length - 1; i++) {
current = current.folders[path[i]]
}
// Update the request at the specified index
if (path.length > 0) {
const index = path[path.length - 1]
current.requests[index] = {
...current.requests[index],
...updates,
} as TestRunnerRequest
}
}
private async runTestRequest(
tab: Ref<HoppTab<HoppTestRunnerDocument>>,
request: TestRunnerRequest,
collection: HoppCollection,
options: TestRunnerOptions,
path: number[]
) {
if (options.stopRef?.value) {
throw new Error("Test execution stopped")
}
try {
// Update request status in the result collection
this.updateRequestAtPath(tab.value.document.resultCollection!, path, {
isLoading: true,
error: undefined,
})
const results = await runTestRunnerRequest(request)
if (options.stopRef?.value) {
throw new Error("Test execution stopped")
}
if (results && E.isRight(results)) {
const { response, testResult } = results.right
const { passed, failed } = this.getTestResultInfo(testResult)
tab.value.document.testRunnerMeta.totalTests += passed + failed
tab.value.document.testRunnerMeta.passedTests += passed
tab.value.document.testRunnerMeta.failedTests += failed
// Update request with results in the result collection
this.updateRequestAtPath(tab.value.document.resultCollection!, path, {
testResults: testResult,
response: options.persistResponses ? response : null,
isLoading: false,
})
if (response.type === "success" || response.type === "fail") {
tab.value.document.testRunnerMeta.totalTime +=
response.meta.responseDuration
tab.value.document.testRunnerMeta.completedRequests += 1
}
} else {
const errorMsg = "Request execution failed"
// Update request with error in the result collection
this.updateRequestAtPath(tab.value.document.resultCollection!, path, {
error: errorMsg,
isLoading: false,
})
if (options.stopOnError) {
tab.value.document.status = "stopped"
throw new Error("Test execution stopped due to error")
}
}
} catch (error) {
if (
error instanceof Error &&
error.message === "Test execution stopped"
) {
throw error
}
const errorMsg =
error instanceof Error ? error.message : "Unknown error occurred"
// Update request with error in the result collection
this.updateRequestAtPath(tab.value.document.resultCollection!, path, {
error: errorMsg,
isLoading: false,
})
if (options.stopOnError) {
tab.value.document.status = "stopped"
throw new Error("Test execution stopped due to error")
}
}
}
private getTestResultInfo(testResult: HoppTestData) {
let passed = 0
let failed = 0
for (const result of testResult.expectResults) {
if (result.status === "pass") {
passed++
} else if (result.status === "fail") {
failed++
}
}
for (const nestedTest of testResult.tests) {
const nestedResult = this.getTestResultInfo(nestedTest)
passed += nestedResult.passed
failed += nestedResult.failed
}
return { passed, failed }
}
}