feat: collection runner refinements (#4609)

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Anwarul Islam 2024-12-19 20:36:52 +06:00 committed by GitHub
parent 0ae21e2c2e
commit d9d656269c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 244 additions and 49 deletions

View file

@ -724,6 +724,7 @@
"duplicate_name_error": "Same name response already exists",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Headers",
"request_headers": "Request Headers",
"html": "HTML",
"image": "Image",
"json": "JSON",
@ -1224,10 +1225,12 @@
"advanced_settings": "Advanced Settings",
"stop_on_error": "Stop run if an error occurs",
"persist_responses": "Persist responses",
"keep_variable_values": "Keep variable values",
"collection_not_found": "Collection not found. May be deleted or moved.",
"empty_collection": "Collection is empty. Add requests to run.",
"no_response_persist": "The collection runner is presently configured not to persist responses. This setting prevents showing the response data. To modify this behavior, initiate a new run configuration.",
"select_request": "Select a request to see response and test results",
"response_body_lost_rerun": "Response body is lost. Run again the collection to get the response body.",
"cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.",
"cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.",
"cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.",

View file

@ -219,7 +219,7 @@ declare module 'vue' {
IconLucideRss: (typeof import("~icons/lucide/rss"))["default"]
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: (typeof import("~icons/lucide/verified"))["default"]
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']

View file

@ -136,7 +136,12 @@
})
}
"
@run-collection="emit('run-collection', $event)"
@run-collection="
emit('run-collection', {
collectionID: node.data.data.data.id,
path: node.id,
})
"
@click="
() => {
handleCollectionClick({
@ -233,7 +238,12 @@
})
}
"
@run-collection="emit('run-collection', $event)"
@run-collection="
emit('run-collection', {
collectionID: node.data.data.data.id,
path: node.id,
})
"
@click="
() => {
handleCollectionClick({
@ -662,7 +672,10 @@ const emit = defineEmits<{
(event: "expand-team-collection", payload: string): void
(event: "display-modal-add"): void
(event: "display-modal-import-export"): void
(event: "run-collection", collectionID: string): void
(
event: "run-collection",
payload: { collectionID: string; path: string }
): void
}>()
const getPath = (path: string) => {

View file

@ -109,7 +109,8 @@
@run-collection="
runCollectionHandler({
type: 'team-collections',
collectionID: $event,
collectionID: $event.collectionID,
path: $event.path,
})
"
@share-request="shareRequest"
@ -2866,8 +2867,28 @@ const setCollectionProperties = (newCollection: {
displayModalEditProperties(false)
}
const runCollectionHandler = (payload: CollectionRunnerData) => {
collectionRunnerData.value = payload
const runCollectionHandler = (
payload: CollectionRunnerData & {
path?: string
}
) => {
if (payload.path && collectionsType.value.type === "team-collections") {
const inheritedProperties =
teamCollectionAdapter.cascadeParentCollectionForHeaderAuth(payload.path)
if (inheritedProperties) {
collectionRunnerData.value = {
type: "team-collections",
collectionID: payload.collectionID,
inheritedProperties: inheritedProperties,
}
}
} else {
collectionRunnerData.value = {
type: "my-collections",
collectionID: payload.collectionID,
}
}
showCollectionsRunnerModal.value = true
}

View file

@ -3,19 +3,41 @@
<HttpResponseMeta :response="doc.response" :is-embed="false" />
<LensesResponseBodyRenderer
v-if="hasResponse"
v-model:document="doc"
:document="{
request: {
...doc,
response: null,
testResults: null,
},
response: doc.response,
testResults: doc.testResults,
}"
:is-editable="false"
:is-test-runner="true"
:show-response="showResponse"
/>
<HoppSmartPlaceholder
v-else
:src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('collection_runner.response_body_lost_rerun')}`"
:text="`${t('collection_runner.response_body_lost_rerun')}`"
>
</HoppSmartPlaceholder>
</div>
</template>
<script setup lang="ts">
import { useVModel } from "@vueuse/core"
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
import { useColorMode } from "~/composables/theming"
import { HoppRequestDocument } from "~/helpers/rest/document"
import { TestRunnerRequest } from "~/services/test-runner/test-runner.service"
const t = useI18n()
const colorMode = useColorMode()
const props = defineProps<{
showResponse: boolean
document: TestRunnerRequest
@ -29,7 +51,8 @@ const doc = useVModel(props, "document", emit)
const hasResponse = computed(
() =>
doc.value.response?.type === "success" ||
doc.value.response?.type === "fail"
(doc.value.response?.type === "success" ||
doc.value.response?.type === "fail") &&
doc.value.response?.body instanceof ArrayBuffer
)
</script>

View file

@ -1,7 +1,8 @@
<template>
<div class="flex items-stretch group ml-4 flex-col">
<button
class="w-full rounded px-4 py-3 transition cursor-pointer focus:outline-none hover:active hover:bg-primaryLight hover:text-secondaryDark"
class="w-full rounded px-4 py-3 transition cursor-pointer focus:outline-none hover:bg-primaryLight hover:text-secondaryDark"
:class="{ 'bg-primaryLight': isSelected }"
@click="selectRequest()"
>
<div class="flex gap-4 mb-1 items-center">

View file

@ -54,8 +54,10 @@
:tab="tab"
:collection-adapter="collectionAdapter"
:is-running="tab.document.status === 'running'"
:selected-request-path="selectedRequestPath"
@on-change-tab="showTestsType = $event as 'all' | 'passed' | 'failed'"
@on-select-request="onSelectRequest"
@request-path="onChangeRequestPath"
/>
</template>
<template #secondary>
@ -91,9 +93,9 @@
<HoppSmartPlaceholder
v-else-if="!selectedRequest"
:src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('collection_runner.select_request')}`"
:text="`${t('collection_runner.select_request')}`"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('collection_runner.response_body_lost_rerun')}`"
:text="`${t('collection_runner.response_body_lost_rerun')}`"
>
</HoppSmartPlaceholder>
</template>
@ -120,7 +122,7 @@
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { HoppCollection } from "@hoppscotch/data"
import { HoppCollection, HoppRESTHeader } from "@hoppscotch/data"
import { SmartTreeAdapter } from "@hoppscotch/ui"
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
@ -174,6 +176,9 @@ const emit = defineEmits<{
const tabs = useService(RESTTabService)
const tab = useVModel(props, "modelValue", emit)
const selectedRequestPath = computed(
() => tab.value.document.selectedRequestPath
)
const duration = computed(() => tab.value.document.testRunnerMeta.totalTime)
const avgResponseTime = computed(() =>
calculateAverageTime(
@ -203,6 +208,10 @@ const onSelectRequest = async (request: TestRunnerRequest) => {
tab.value.document.request = request
}
const onChangeRequestPath = (path: string) => {
tab.value.document.selectedRequestPath = path
}
const collectionName = computed(() =>
props.modelValue.document.type === "test-runner"
? props.modelValue.document.collection.name
@ -243,16 +252,41 @@ const runTests = async () => {
collectionType
)
const { auth, headers } = collectionInheritedProps ?? {
auth: { authActive: true, authType: "none" },
headers: [],
}
let resolvedCollection: HoppCollection = collection.value
// Accommodate collection properties for personal workspace
// TODO: Resolve the collection properties computation for team workspaces
const resolvedCollection = isPersonalWorkspace
? { ...collection.value, auth, headers }
: collection.value
if (!isPersonalWorkspace) {
const requestAuth = tab.value.document.inheritedProperties?.auth
.inheritedAuth ?? {
authActive: true,
authType: "none",
}
const requestHeaders = tab.value.document.inheritedProperties?.headers.map(
(header) => {
if (header.inheritedHeader) {
return header.inheritedHeader
}
return []
}
)
resolvedCollection = {
...collection.value,
auth: requestAuth,
headers: requestHeaders as HoppRESTHeader[],
}
} else {
const { auth, headers } = collectionInheritedProps ?? {
auth: { authActive: true, authType: "none" },
headers: [],
}
resolvedCollection = {
...collection.value,
auth,
headers,
}
}
testRunnerStopRef.value = false // when testRunnerStopRef is false, the test runner will start running
testRunnerService.runTests(tab, resolvedCollection, {

View file

@ -65,23 +65,25 @@
</span>
</HoppSmartCheckbox>
<!-- <HoppSmartCheckbox
<HoppSmartCheckbox
class="pr-2"
:on="config.keepVariableValues"
@change="
config.keepVariableValues = !config.keepVariableValues
"
>
<span>Keep variable values</span>
<span>
{{ t("collection_runner.keep_variable_values") }}
</span>
<HoppButtonSecondary
class="!py-0 pl-2"
v-tippy="{ theme: 'tooltip' }"
class="!py-0 pl-2"
to="https://docs.hoppscotch.io/documentation/features/inspections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</HoppSmartCheckbox> -->
</HoppSmartCheckbox>
</div>
</section>
</div>
@ -193,6 +195,7 @@ import { GQLError } from "~/helpers/backend/GQLClient"
import { cloneDeep } from "lodash-es"
import { getErrorMessage } from "~/helpers/runner/collection-tree"
import { getRESTCollectionByRefId } from "~/newstore/collections"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
const t = useI18n()
const toast = useToast()
@ -210,6 +213,7 @@ export type CollectionRunnerData =
| {
type: "team-collections"
collectionID: string
inheritedProperties?: HoppInheritedProperty
}
const props = defineProps<{
@ -246,7 +250,7 @@ const config = ref<TestRunnerConfig>({
delay: 500,
stopOnError: false,
persistResponses: true,
keepVariableValues: false,
keepVariableValues: true,
})
onMounted(() => {
@ -273,7 +277,6 @@ const runTests = async () => {
let tabIdToClose = null
if (props.sameTab) tabIdToClose = cloneDeep(tabs.currentTabID.value)
tabs.createNewTab({
type: "test-runner",
collectionType: props.collectionRunnerData.type,
@ -291,6 +294,10 @@ const runTests = async () => {
passedTests: 0,
totalTests: 0,
},
inheritedProperties:
"inheritedProperties" in props.collectionRunnerData
? props.collectionRunnerData.inheritedProperties
: undefined,
})
if (tabIdToClose) tabs.closeTab(tabIdToClose)

View file

@ -61,10 +61,10 @@
:request="node.data.data.data"
:request-i-d="node.id"
:parent-i-d="node.data.data.parentIndex"
:is-selected="node.data.isSelected"
:is-selected="node.id === selectedRequestPath"
:show-selection="showCheckbox"
:is-last-item="node.data.isLastItem"
@select-request="selectRequest(node.data.data.data)"
@select-request="selectRequest(node.data.data.data, node.id)"
/>
</template>
</HoppSmartTree>
@ -102,19 +102,22 @@ defineProps<{
tab: HoppTab<HoppTestRunnerDocument>
collectionAdapter: SmartTreeAdapter<any>
isRunning: boolean
selectedRequestPath: string
}>()
const emit = defineEmits<{
(e: "onSelectRequest", request: TestRunnerRequest): void
(e: "onChangeTab", event: string): void
(e: "requestPath", path: string): void
}>()
const selectedTestTab = ref<"all" | "passed" | "failed">("all")
const showCheckbox = ref(false)
const selectRequest = (request: TestRunnerRequest) => {
const selectRequest = (request: TestRunnerRequest, indexPath: string) => {
emit("onSelectRequest", request)
emit("requestPath", indexPath)
}
</script>

View file

@ -37,6 +37,18 @@
>
<HttpTestResult v-model="doc.testResults" />
</HoppSmartTab>
<HoppSmartTab
v-if="requestHeaders"
id="req-headers"
:label="t('response.request_headers')"
:info="`${requestHeaders?.length}`"
class="flex flex-1 flex-col"
>
<LensesHeadersRenderer
:model-value="requestHeaders"
:is-editable="false"
/>
</HoppSmartTab>
</HoppSmartTabs>
</template>
@ -50,11 +62,11 @@ import {
import { useI18n } from "@composables/i18n"
import { useVModel } from "@vueuse/core"
import { HoppRequestDocument } from "~/helpers/rest/document"
import { TestRunnerRequest } from "~/services/test-runner/test-runner.service"
const props = defineProps<{
document: HoppRequestDocument | TestRunnerRequest
document: HoppRequestDocument
isEditable: boolean
isTestRunner?: boolean
}>()
const emit = defineEmits<{
@ -65,7 +77,6 @@ const emit = defineEmits<{
const doc = useVModel(props, "document", emit)
const isSavable = computed(() => {
if (doc.value.type === "test-response") return false
return doc.value.response?.type === "success" && doc.value.saveContext
})
@ -104,6 +115,11 @@ const maybeHeaders = computed(() => {
return doc.value.response.headers
})
const requestHeaders = computed(() => {
if (!props.isTestRunner || !doc.value) return null
return doc.value.request.headers
})
const validLenses = computed(() => {
if (!doc.value.response) return []
return getSuitableLenses(doc.value.response)
@ -120,8 +136,6 @@ watch(
"results",
]
if (doc.value.type === "test-response") return
const { responseTabPreference } = doc.value
if (
@ -137,7 +151,7 @@ watch(
)
watch(selectedLensTab, (newLensID) => {
if (doc.value.type === "test-response") return
if (props.isTestRunner) return
doc.value.responseTabPreference = newLensID
})
</script>

View file

@ -40,6 +40,10 @@ import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { isJSONContentType } from "./utils/contenttypes"
import {
getTemporaryVariables,
setTemporaryVariables,
} from "./runner/temp_envs"
const secretEnvironmentService = getService(SecretEnvironmentService)
@ -74,10 +78,12 @@ export const combineEnvVariables = (variables: {
environments: {
selected: Environment["variables"]
global: Environment["variables"]
temp?: Environment["variables"]
}
requestVariables: Environment["variables"]
}) => [
...variables.requestVariables,
...(variables.environments.temp ?? []),
...variables.environments.selected,
...variables.environments.global,
]
@ -377,7 +383,17 @@ function updateEnvsAfterTestScript(runResult: E.Right<SandboxTestResult>) {
return updatedRunResult
}
export function runTestRunnerRequest(request: HoppRESTRequest): Promise<
/**
* Run the test runner request
* @param request The request to run
* @param persistEnv Whether to persist the environment variables after running the test script
* @returns The response and the test result
*/
export function runTestRunnerRequest(
request: HoppRESTRequest,
persistEnv = true
): Promise<
| E.Left<"script_fail">
| E.Right<{
response: HoppRESTResponse
@ -399,7 +415,10 @@ export function runTestRunnerRequest(request: HoppRESTRequest): Promise<
v: 1,
name: "Env",
variables: combineEnvVariables({
environments: envs.right,
environments: {
...envs.right,
temp: !persistEnv ? getTemporaryVariables() : [],
},
requestVariables: [],
}),
})
@ -431,7 +450,18 @@ export function runTestRunnerRequest(request: HoppRESTRequest): Promise<
runResult.right
)
updateEnvsAfterTestScript(runResult)
// Update the environment variables after running the test script when persistEnv is true. else store the updated environment variables in the store as a temporary variable.
if (persistEnv) {
updateEnvsAfterTestScript(runResult)
} else {
// Combine global and selected environment changes
const allChanges = [
...runResult.right.envs.global,
...runResult.right.envs.selected,
]
setTemporaryVariables(allChanges)
}
return E.right({
response: res,

View file

@ -14,7 +14,8 @@ export const replaceTemplateStringsInObjectValues = <
const restTabsService = getService(RESTTabService)
const requestVariables =
source === "REST"
source === "REST" &&
restTabsService.currentActiveTab.value.document.type === "request"
? restTabsService.currentActiveTab.value.document.request.requestVariables.map(
({ key, value }) => ({
key,

View file

@ -216,12 +216,15 @@ export const teamCollToHoppRESTColl = (
headers: [],
}
const { auth, headers } = parseCollectionData(data)
return makeCollection({
id: coll.id,
name: coll.title,
folders: coll.children?.map(teamCollToHoppRESTColl) ?? [],
requests: coll.requests?.map((x) => x.request) ?? [],
auth: data.auth ?? { authType: "inherit", authActive: true },
headers: data.headers ?? [],
auth: auth ?? { authType: "inherit", authActive: true },
headers: headers ?? [],
})
}

View file

@ -63,7 +63,7 @@ const unsecretEnvironments = (
}
}
export const getCombinedEnvVariables = () => {
export const getCombinedEnvVariables = (temp?: Environment["variables"]) => {
const reformedVars = unsecretEnvironments(
getCurrentEnvironment(),
getGlobalVariables()
@ -71,6 +71,7 @@ export const getCombinedEnvVariables = () => {
return {
global: cloneDeep(reformedVars.global),
selected: cloneDeep(reformedVars.selected),
temp: temp ? cloneDeep(temp) : [],
}
}
@ -79,6 +80,7 @@ export const getFinalEnvsFromPreRequest = (
envs: {
global: Environment["variables"]
selected: Environment["variables"]
temp: Environment["variables"]
}
): Promise<E.Either<string, TestResult["envs"]>> =>
runPreRequestScript(script, envs)

View file

@ -127,6 +127,12 @@ export type HoppTestRunnerDocument = {
*/
collectionID: string
/**
* Selected request id
* (if any)
*/
selectedRequestPath?: string
/**
* The request as it is in the document
*/
@ -166,6 +172,12 @@ export type HoppTestRunnerDocument = {
* (atleast as far as we can say)
*/
isDirty: boolean
/**
* The inherited properties from the parent collection also the collection itself
* (if any) - Used for team collections
*/
inheritedProperties?: HoppInheritedProperty
}
export type HoppRequestDocument = {

View file

@ -0,0 +1,20 @@
import { ref } from "vue"
import { GlobalEnvironmentVariable } from "@hoppscotch/data"
export const temporaryVariables = ref<GlobalEnvironmentVariable[]>([])
export function getTemporaryVariables() {
return temporaryVariables.value
}
export function setTemporaryVariables(variables: GlobalEnvironmentVariable[]) {
temporaryVariables.value = variables
}
export function clearTemporaryVariables() {
temporaryVariables.value = []
}
export function addTemporaryVariable(variable: GlobalEnvironmentVariable) {
temporaryVariables.value.push(variable)
}

View file

@ -537,6 +537,7 @@ export const REST_TAB_STATE_SCHEMA = z
response: z.nullable(HoppRESTResponseSchema),
testResults: z.optional(z.nullable(HoppTestResultSchema)),
isDirty: z.boolean(),
inheritedProperties: z.optional(HoppInheritedPropertySchema),
}),
z.object({
// !Versioned entity

View file

@ -268,7 +268,10 @@ export class TestRunnerService extends Service {
error: undefined,
})
const results = await runTestRunnerRequest(request)
const results = await runTestRunnerRequest(
request,
options.keepVariableValues
)
if (options.stopRef?.value) {
throw new Error("Test execution stopped")

View file

@ -598,14 +598,15 @@ function setupUserCollectionDuplicatedSubscription() {
)
// Incoming data transformed to the respective internal representations
const { auth, headers, _ref_id } =
const { auth, headers } =
data && data != "null"
? JSON.parse(data)
: {
auth: { authType: "inherit", authActive: false },
headers: [],
_ref_id: generateUniqueRefId("coll"),
}
// Duplicated collection will have a unique ref id
const _ref_id = generateUniqueRefId("coll")
const folders = transformDuplicatedCollections(childCollectionsJSONStr)
@ -1041,6 +1042,8 @@ function transformDuplicatedCollections(
? JSON.parse(data)
: { auth: { authType: "inherit", authActive: false }, headers: [] }
const _ref_id = generateUniqueRefId("coll")
const folders = transformDuplicatedCollections(childCollectionsJSONStr)
const requests = transformDuplicatedCollectionRequests(userRequests)
@ -1050,6 +1053,7 @@ function transformDuplicatedCollections(
name,
folders,
requests,
_ref_id,
v: 5,
auth,
headers: addDescriptionField(headers),