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:
@@ -135,9 +135,7 @@
|
||||
:key="`result-${index}`"
|
||||
class="flex items-center px-4 py-2"
|
||||
>
|
||||
<div
|
||||
class="flex flex-shrink flex-shrink-0 items-center overflow-x-auto"
|
||||
>
|
||||
<div class="flex flex-shrink-0 items-center overflow-x-auto">
|
||||
<component
|
||||
:is="result.status === 'pass' ? IconCheck : IconClose"
|
||||
class="svg-icons mr-4"
|
||||
@@ -146,7 +144,7 @@
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-shrink flex-shrink-0 items-center space-x-2 overflow-x-auto"
|
||||
class="flex flex-shrink-0 items-center space-x-2 overflow-x-auto"
|
||||
>
|
||||
<span
|
||||
v-if="result.message"
|
||||
@@ -237,9 +235,15 @@ import { useColorMode } from "~/composables/theming"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HoppTestResult | null | undefined
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: HoppTestResult | null | undefined
|
||||
showEmptyMessage?: boolean
|
||||
}>(),
|
||||
{
|
||||
showEmptyMessage: true,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: HoppTestResult | null | undefined): void
|
||||
|
||||
@@ -8,57 +8,87 @@
|
||||
</span>
|
||||
<div v-if="testResults.expectResults" class="divide-y divide-dividerLight">
|
||||
<HttpTestResultReport
|
||||
v-if="testResults.expectResults.length"
|
||||
v-if="testResults.expectResults.length && !shouldHideResultReport"
|
||||
:test-results="testResults"
|
||||
/>
|
||||
<div
|
||||
|
||||
<template
|
||||
v-for="(result, index) in testResults.expectResults"
|
||||
:key="`result-${index}`"
|
||||
class="flex items-center px-4 py-2"
|
||||
>
|
||||
<div
|
||||
class="flex flex-shrink flex-shrink-0 items-center overflow-x-auto"
|
||||
v-if="shouldShowResult(result.status)"
|
||||
class="flex items-center px-4 py-2"
|
||||
>
|
||||
<component
|
||||
:is="result.status === 'pass' ? IconCheck : IconClose"
|
||||
class="svg-icons mr-4"
|
||||
:class="
|
||||
result.status === 'pass' ? 'text-green-500' : 'text-red-500'
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-shrink flex-shrink-0 items-center space-x-2 overflow-x-auto"
|
||||
>
|
||||
<span v-if="result.message" class="inline-flex text-secondaryDark">
|
||||
{{ result.message }}
|
||||
</span>
|
||||
<span class="inline-flex text-secondaryLight">
|
||||
<icon-lucide-minus class="svg-icons mr-2" />
|
||||
{{
|
||||
result.status === "pass" ? t("test.passed") : t("test.failed")
|
||||
}}
|
||||
</span>
|
||||
<div class="flex flex-shrink-0 items-center overflow-x-auto">
|
||||
<component
|
||||
:is="result.status === 'pass' ? IconCheck : IconClose"
|
||||
class="svg-icons mr-4"
|
||||
:class="
|
||||
result.status === 'pass' ? 'text-green-500' : 'text-red-500'
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center space-x-2 overflow-x-auto"
|
||||
>
|
||||
<span
|
||||
v-if="result.message"
|
||||
class="inline-flex text-secondaryDark"
|
||||
>
|
||||
{{ result.message }}
|
||||
</span>
|
||||
<span class="inline-flex text-secondaryLight">
|
||||
<icon-lucide-minus class="svg-icons mr-2" />
|
||||
{{
|
||||
result.status === "pass" ? t("test.passed") : t("test.failed")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue"
|
||||
import { HoppTestResult } from "~/helpers/types/HoppTestResult"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { computed } from "vue"
|
||||
import {
|
||||
HoppTestResult,
|
||||
HoppTestExpectResult,
|
||||
} from "~/helpers/types/HoppTestResult"
|
||||
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconClose from "~icons/lucide/x"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps({
|
||||
testResults: {
|
||||
type: Object as PropType<HoppTestResult>,
|
||||
required: true,
|
||||
},
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
testResults: HoppTestResult
|
||||
showTestType: "all" | "passed" | "failed"
|
||||
}>(),
|
||||
{
|
||||
showTestType: "all",
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Determines if a test result should be displayed based on the filter type
|
||||
*/
|
||||
function shouldShowResult(status: HoppTestExpectResult["status"]): boolean {
|
||||
if (props.showTestType === "all") return true
|
||||
if (props.showTestType === "passed" && status === "pass") return true
|
||||
if (props.showTestType === "failed" && status === "fail") return true
|
||||
return false
|
||||
}
|
||||
|
||||
const shouldHideResultReport = computed(() => {
|
||||
if (props.showTestType === "all") return false
|
||||
|
||||
return props.testResults.expectResults.some(
|
||||
(result) => result.status === "pass" || result.status === "fail"
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<div class="flex flex-shrink flex-shrink-0 items-center overflow-x-auto">
|
||||
<div class="flex flex-shrink items-center overflow-x-auto">
|
||||
<component
|
||||
:is="getIcon(status)"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
@@ -8,9 +8,7 @@
|
||||
:class="getStyle(status)"
|
||||
:title="`${t(getTooltip(status))}`"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-shrink flex-shrink-0 items-center space-x-2 overflow-x-auto"
|
||||
>
|
||||
<div class="flex flex-shrink items-center space-x-2 overflow-x-auto">
|
||||
<span class="inline-flex text-secondaryDark">
|
||||
{{ env.key }}
|
||||
</span>
|
||||
@@ -51,7 +49,7 @@ type Props = {
|
||||
previousValue?: string
|
||||
}
|
||||
status: Status
|
||||
global: boolean
|
||||
global?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
|
||||
104
packages/hoppscotch-common/src/components/http/test/Env.vue
Normal file
104
packages/hoppscotch-common/src/components/http/test/Env.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<span v-if="show">
|
||||
{{ envName ?? t("filter.none") }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useService } from "dioc/vue"
|
||||
import { computed, watch } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useReadonlyStream, useStream } from "~/composables/stream"
|
||||
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
|
||||
import {
|
||||
environments$,
|
||||
selectedEnvironmentIndex$,
|
||||
setSelectedEnvironmentIndex,
|
||||
} from "~/newstore/environments"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
show?: boolean
|
||||
}>(),
|
||||
{
|
||||
show: true,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select-env", data: any): void
|
||||
}>()
|
||||
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
const workspace = workspaceService.currentWorkspace
|
||||
|
||||
const envName = computed(() => selectedEnv.value?.name ?? null)
|
||||
|
||||
const currentEnv = useStream(
|
||||
selectedEnvironmentIndex$,
|
||||
{ type: "NO_ENV_SELECTED" },
|
||||
setSelectedEnvironmentIndex
|
||||
)
|
||||
|
||||
const teamEnvironmentAdapter = new TeamEnvironmentAdapter(
|
||||
workspace.value.type === "team" ? workspace.value.teamID : undefined
|
||||
)
|
||||
const teamEnvironmentList = useReadonlyStream(
|
||||
teamEnvironmentAdapter.teamEnvironmentList$,
|
||||
[]
|
||||
)
|
||||
const myEnvironments = useReadonlyStream(environments$, [])
|
||||
const activeWorkspace = workspace.value
|
||||
|
||||
export type CurrentEnv =
|
||||
| {
|
||||
type: "MY_ENV"
|
||||
index: number
|
||||
name: string
|
||||
}
|
||||
| { type: "TEAM_ENV"; name: string; teamEnvID: string }
|
||||
| null
|
||||
|
||||
const selectedEnv = computed<CurrentEnv>(() => {
|
||||
if (
|
||||
activeWorkspace.type === "personal" &&
|
||||
currentEnv.value.type === "MY_ENV"
|
||||
) {
|
||||
const environment = myEnvironments.value[currentEnv.value.index]
|
||||
return {
|
||||
type: "MY_ENV",
|
||||
index: currentEnv.value.index,
|
||||
name: environment.name,
|
||||
}
|
||||
}
|
||||
|
||||
if (activeWorkspace.type === "team" && currentEnv.value.type === "TEAM_ENV") {
|
||||
const teamEnv = teamEnvironmentList.value.find((env) => {
|
||||
return (
|
||||
env.id ===
|
||||
(currentEnv.value.type === "TEAM_ENV" && currentEnv.value.teamEnvID)
|
||||
)
|
||||
})
|
||||
|
||||
if (teamEnv) {
|
||||
return {
|
||||
type: "TEAM_ENV",
|
||||
name: teamEnv.environment.name,
|
||||
teamEnvID: currentEnv.value.teamEnvID,
|
||||
}
|
||||
}
|
||||
}
|
||||
return null // Return null or a default value if no environment is selected
|
||||
})
|
||||
|
||||
watch(
|
||||
() => selectedEnv.value,
|
||||
(newVal) => {
|
||||
if (newVal) emit("select-env", newVal)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="h-1 w-full transition"></div>
|
||||
<div class="flex flex-col relative">
|
||||
<div
|
||||
class="absolute bg-accent opacity-0 pointer-events-none inset-0 z-1 transition"
|
||||
></div>
|
||||
<div
|
||||
class="flex items-stretch group relative z-3 cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<div class="flex items-center justify-center flex-1 min-w-0">
|
||||
<span
|
||||
class="flex items-center justify-center px-4 pointer-events-none"
|
||||
>
|
||||
<HoppSmartCheckbox
|
||||
v-if="showSelection"
|
||||
:on="isSelected"
|
||||
class="mr-2"
|
||||
/>
|
||||
<component :is="IconFolderOpen" class="svg-icons" />
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 transition pointer-events-none group-hover:text-secondaryDark"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ collectionName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { HoppCollection } from "@hoppscotch/data"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { TeamCollection } from "~/helpers/teams/TeamCollection"
|
||||
|
||||
type FolderType = "collection" | "folder"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id: string
|
||||
parentID?: string | null
|
||||
data: HoppCollection | TeamCollection
|
||||
/**
|
||||
* Collection component can be used for both collections and folders.
|
||||
* folderType is used to determine which one it is.
|
||||
*/
|
||||
folderType: FolderType
|
||||
isOpen: boolean
|
||||
isSelected?: boolean
|
||||
exportLoading?: boolean
|
||||
hasNoTeamAccess?: boolean
|
||||
collectionMoveLoading?: string[]
|
||||
isLastItem?: boolean
|
||||
showSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: "",
|
||||
parentID: null,
|
||||
folderType: "collection",
|
||||
isOpen: false,
|
||||
isSelected: false,
|
||||
exportLoading: false,
|
||||
hasNoTeamAccess: false,
|
||||
isLastItem: false,
|
||||
showSelection: false,
|
||||
}
|
||||
)
|
||||
|
||||
const options = ref<TippyComponent | null>(null)
|
||||
const collectionName = computed(() => {
|
||||
if ((props.data as HoppCollection).name)
|
||||
return (props.data as HoppCollection).name
|
||||
return (props.data as TeamCollection).title
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.exportLoading,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
options.value!.tippy?.hide()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="h-1 w-full transition"></div>
|
||||
<div class="flex items-stretch group">
|
||||
<div
|
||||
class="flex items-center justify-center flex-1 min-w-0 cursor-pointer pointer-events-auto"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center px-2 truncate pointer-events-none"
|
||||
:style="{ color: requestLabelColor }"
|
||||
>
|
||||
<HoppSmartCheckbox
|
||||
v-if="showSelection"
|
||||
:on="isSelected"
|
||||
:name="`request-${requestID}`"
|
||||
class="mx-2 ml-4"
|
||||
@change="selectRequest()"
|
||||
/>
|
||||
<span class="font-semibold truncate text-tiny">
|
||||
{{ request.method }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center flex-1 min-w-0 py-2 pr-2 pointer-events-none transition group-hover:text-secondaryDark"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ request.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isActive"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
|
||||
:title="`${t('collection.request_in_use')}`"
|
||||
>
|
||||
<span
|
||||
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
request: HoppRESTRequest
|
||||
requestID?: string
|
||||
parentID: string | null
|
||||
isActive?: boolean
|
||||
isSelected?: boolean
|
||||
showSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
parentID: null,
|
||||
isActive: false,
|
||||
isSelected: false,
|
||||
showSelection: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "select-request"): void
|
||||
}>()
|
||||
|
||||
const requestLabelColor = computed(() =>
|
||||
getMethodLabelColorClassOf(props.request.method)
|
||||
)
|
||||
|
||||
const selectRequest = () => {
|
||||
emit("select-request")
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="relative flex flex-1 flex-col">
|
||||
<HttpResponseMeta :response="doc.response" :is-embed="false" />
|
||||
<LensesResponseBodyRenderer
|
||||
v-if="hasResponse"
|
||||
v-model:document="doc"
|
||||
:is-editable="false"
|
||||
:show-response="showResponse"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { computed } from "vue"
|
||||
import { HoppRequestDocument } from "~/helpers/rest/document"
|
||||
import { TestRunnerRequest } from "~/services/test-runner/test-runner.service"
|
||||
|
||||
const props = defineProps<{
|
||||
showResponse: boolean
|
||||
document: TestRunnerRequest
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:tab", val: HoppRequestDocument): void
|
||||
}>()
|
||||
|
||||
const doc = useVModel(props, "document", emit)
|
||||
|
||||
const hasResponse = computed(
|
||||
() =>
|
||||
doc.value.response?.type === "success" ||
|
||||
doc.value.response?.type === "fail"
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="h-1 w-full transition"></div>
|
||||
<div class="flex flex-col relative">
|
||||
<div
|
||||
class="absolute bg-accent opacity-0 pointer-events-none inset-0 z-1 transition"
|
||||
></div>
|
||||
<div
|
||||
class="flex items-stretch group relative z-3 cursor-pointer pointer-events-auto"
|
||||
>
|
||||
<div class="flex items-center justify-center flex-1 min-w-0">
|
||||
<span
|
||||
class="flex items-center justify-center px-4 pointer-events-none"
|
||||
>
|
||||
<HoppSmartCheckbox
|
||||
v-if="showSelection"
|
||||
:on="isSelected"
|
||||
class="mr-2"
|
||||
/>
|
||||
<component :is="IconFolderOpen" class="svg-icons" />
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 transition pointer-events-none group-hover:text-secondaryDark"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ collectionName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { HoppCollection } from "@hoppscotch/data"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { TeamCollection } from "~/helpers/teams/TeamCollection"
|
||||
|
||||
type FolderType = "collection" | "folder"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id: string
|
||||
parentID?: string | null
|
||||
data: HoppCollection | TeamCollection
|
||||
/**
|
||||
* Collection component can be used for both collections and folders.
|
||||
* folderType is used to determine which one it is.
|
||||
*/
|
||||
folderType: FolderType
|
||||
isOpen: boolean
|
||||
isSelected?: boolean
|
||||
exportLoading?: boolean
|
||||
hasNoTeamAccess?: boolean
|
||||
collectionMoveLoading?: string[]
|
||||
isLastItem?: boolean
|
||||
showSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: "",
|
||||
parentID: null,
|
||||
folderType: "collection",
|
||||
isOpen: false,
|
||||
isSelected: false,
|
||||
exportLoading: false,
|
||||
hasNoTeamAccess: false,
|
||||
isLastItem: false,
|
||||
showSelection: false,
|
||||
}
|
||||
)
|
||||
|
||||
const options = ref<TippyComponent | null>(null)
|
||||
|
||||
const collectionName = computed(() => {
|
||||
if ((props.data as HoppCollection).name)
|
||||
return (props.data as HoppCollection).name
|
||||
return (props.data as TeamCollection).title
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.exportLoading,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
options.value!.tippy?.hide()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<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"
|
||||
@click="selectRequest()"
|
||||
>
|
||||
<div class="flex gap-4 mb-1">
|
||||
<span
|
||||
class="flex items-center justify-center truncate pointer-events-none"
|
||||
:style="{ color: requestLabelColor }"
|
||||
>
|
||||
<span class="font-bold truncate">
|
||||
{{ request.method }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-sm text-secondaryDark">
|
||||
{{ request.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="request.response?.statusCode"
|
||||
:class="[
|
||||
statusCategory.className,
|
||||
'outlined text-xs rounded-md px-2 flex items-center',
|
||||
]"
|
||||
>
|
||||
{{ `${request.response?.statusCode}` }}
|
||||
</span>
|
||||
<span v-if="isLoading" class="flex flex-col items-center">
|
||||
<HoppSmartSpinner />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-left text-secondaryLight text-sm">
|
||||
{{ request.endpoint }}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="request.error"
|
||||
class="py-2 pl-4 ml-4 mb-2 border-l text-red-500 border-red-500"
|
||||
>
|
||||
<span> {{ request.error }} </span>
|
||||
</div>
|
||||
<HttpTestTestResult
|
||||
v-if="request.testResults"
|
||||
:model-value="request.testResults"
|
||||
:show-test-type="showTestType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import findStatusGroup from "~/helpers/findStatusGroup"
|
||||
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
|
||||
import { TestRunnerRequest } from "~/services/test-runner/test-runner.service"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
request: TestRunnerRequest
|
||||
requestID?: string
|
||||
parentID: string | null
|
||||
isActive?: boolean
|
||||
isSelected?: boolean
|
||||
showSelection?: boolean
|
||||
showTestType: "all" | "passed" | "failed"
|
||||
}>(),
|
||||
{
|
||||
parentID: null,
|
||||
isActive: false,
|
||||
isSelected: false,
|
||||
showSelection: false,
|
||||
requestID: "",
|
||||
}
|
||||
)
|
||||
|
||||
const isLoading = computed(() => props.request?.isLoading)
|
||||
|
||||
const statusCategory = computed(() => {
|
||||
if (
|
||||
props.request?.response === null ||
|
||||
props.request?.response === undefined ||
|
||||
props.request?.response.type === "loading" ||
|
||||
props.request?.response.type === "network_fail" ||
|
||||
props.request?.response.type === "script_fail" ||
|
||||
props.request?.response.type === "fail" ||
|
||||
props.request?.response.type === "extension_error"
|
||||
)
|
||||
return {
|
||||
name: "error",
|
||||
className: "text-red-500",
|
||||
}
|
||||
return findStatusGroup(props.request?.response.statusCode)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "select-request"): void
|
||||
}>()
|
||||
|
||||
const requestLabelColor = computed(() =>
|
||||
getMethodLabelColorClassOf(props.request.method)
|
||||
)
|
||||
|
||||
const selectRequest = () => {
|
||||
emit("select-request")
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.active {
|
||||
@apply after:bg-accentLight;
|
||||
}
|
||||
</style>
|
||||
335
packages/hoppscotch-common/src/components/http/test/Runner.vue
Normal file
335
packages/hoppscotch-common/src/components/http/test/Runner.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="test-runner-primary">
|
||||
<template #primary>
|
||||
<div
|
||||
class="flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary sticky top-0 z-20"
|
||||
>
|
||||
<div class="inline-flex flex-1 gap-8">
|
||||
<HttpTestRunnerMeta
|
||||
:heading="t('collection.title')"
|
||||
:text="collectionName"
|
||||
/>
|
||||
|
||||
<template v-if="showResult">
|
||||
<HttpTestRunnerMeta :heading="t('environment.heading')">
|
||||
<HttpTestEnv />
|
||||
</HttpTestRunnerMeta>
|
||||
<!-- <HttpTestRunnerMeta :heading="t('test.iterations')" :text="'1'" /> -->
|
||||
<HttpTestRunnerMeta
|
||||
:heading="t('test.duration')"
|
||||
:text="duration ? msToHumanReadable(duration) : '...'"
|
||||
/>
|
||||
<HttpTestRunnerMeta
|
||||
:heading="t('test.avg_resp')"
|
||||
:text="
|
||||
avgResponseTime ? msToHumanReadable(avgResponseTime) : '...'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<HoppButtonPrimary
|
||||
v-if="showResult && tab.document.status === 'running'"
|
||||
:label="t('test.stop')"
|
||||
class="w-32"
|
||||
@click="stopTests()"
|
||||
/>
|
||||
<HoppButtonPrimary
|
||||
v-else
|
||||
:label="t('test.run_again')"
|
||||
class="w-32"
|
||||
@click="runAgain()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="showResult && tab.document.status !== 'running'"
|
||||
:icon="IconPlus"
|
||||
:label="t('test.new_run')"
|
||||
filled
|
||||
outline
|
||||
@click="newRun()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HttpTestRunnerResult
|
||||
v-if="showResult"
|
||||
:tab="tab"
|
||||
:collection-adapter="collectionAdapter"
|
||||
:is-running="tab.document.status === 'running'"
|
||||
@on-change-tab="showTestsType = $event as 'all' | 'passed' | 'failed'"
|
||||
@on-select-request="onSelectRequest"
|
||||
/>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<HttpTestResponse
|
||||
v-if="selectedRequest && selectedRequest.response"
|
||||
v-model:document="selectedRequest"
|
||||
:show-response="tab.document.config.persistResponses"
|
||||
/>
|
||||
|
||||
<HoppSmartPlaceholder
|
||||
v-else-if="
|
||||
!testRunnerConfig.persistResponses && !selectedRequest?.response
|
||||
"
|
||||
:src="`/images/states/${colorMode.value}/add_files.svg`"
|
||||
:alt="`${t('collection_runner.no_response_persist')}`"
|
||||
:text="`${t('collection_runner.no_response_persist')}`"
|
||||
>
|
||||
<template #body>
|
||||
<HoppButtonPrimary
|
||||
:label="t('test.new_run')"
|
||||
@click="showCollectionsRunnerModal = true"
|
||||
/>
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
|
||||
<div
|
||||
v-else-if="tab.document.status === 'running'"
|
||||
class="flex flex-col items-center gap-4 justify-center h-full"
|
||||
>
|
||||
<HoppSmartSpinner />
|
||||
<span> {{ t("collection_runner.running_collection") }}... </span>
|
||||
</div>
|
||||
|
||||
<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')}`"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
|
||||
<HttpTestRunnerModal
|
||||
v-if="showCollectionsRunnerModal"
|
||||
:same-tab="true"
|
||||
:collection-runner-data="
|
||||
tab.document.collectionType === 'my-collections'
|
||||
? {
|
||||
type: 'my-collections',
|
||||
collectionID: tab.document.collectionID,
|
||||
}
|
||||
: {
|
||||
type: 'team-collections',
|
||||
collectionID: tab.document.collectionID,
|
||||
}
|
||||
"
|
||||
@hide-modal="showCollectionsRunnerModal = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { HoppCollection } from "@hoppscotch/data"
|
||||
import { SmartTreeAdapter } from "@hoppscotch/ui"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import { pipe } from "fp-ts/lib/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { computed, nextTick, onMounted, ref } from "vue"
|
||||
import { useColorMode } from "~/composables/theming"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
getCompleteCollectionTree,
|
||||
teamCollToHoppRESTColl,
|
||||
} from "~/helpers/backend/helpers"
|
||||
import { HoppTestRunnerDocument } from "~/helpers/rest/document"
|
||||
import {
|
||||
CollectionNode,
|
||||
TestRunnerCollectionsAdapter,
|
||||
} from "~/helpers/runner/adapter"
|
||||
import { getErrorMessage } from "~/helpers/runner/collection-tree"
|
||||
import { getRESTCollectionByRefId } from "~/newstore/collections"
|
||||
import { HoppTab } from "~/services/tab"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import {
|
||||
TestRunnerRequest,
|
||||
TestRunnerService,
|
||||
} from "~/services/test-runner/test-runner.service"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const props = defineProps<{ modelValue: HoppTab<HoppTestRunnerDocument> }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: HoppTab<HoppTestRunnerDocument>): void
|
||||
}>()
|
||||
|
||||
const tabs = useService(RESTTabService)
|
||||
const tab = useVModel(props, "modelValue", emit)
|
||||
|
||||
const duration = computed(() => tab.value.document.testRunnerMeta.totalTime)
|
||||
const avgResponseTime = computed(() =>
|
||||
calculateAverageTime(
|
||||
tab.value.document.testRunnerMeta.totalTime,
|
||||
tab.value.document.testRunnerMeta.completedRequests
|
||||
)
|
||||
)
|
||||
|
||||
function msToHumanReadable(ms: number) {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const milliseconds = ms % 1000
|
||||
|
||||
let result = ""
|
||||
if (seconds > 0) {
|
||||
result += `${seconds}s `
|
||||
}
|
||||
result += `${milliseconds}ms`
|
||||
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
const selectedRequest = computed(() => tab.value.document.request)
|
||||
|
||||
const onSelectRequest = async (request: TestRunnerRequest) => {
|
||||
tab.value.document.request = null
|
||||
await nextTick() // HACK: To ensure the request is cleared before setting the new request. there is a bug in the response component that doesn't change to the valid lens when the response is changed.
|
||||
tab.value.document.request = request
|
||||
}
|
||||
|
||||
const collectionName = computed(() =>
|
||||
props.modelValue.document.type === "test-runner"
|
||||
? props.modelValue.document.collection.name
|
||||
: ""
|
||||
)
|
||||
|
||||
const testRunnerConfig = computed(() => tab.value.document.config)
|
||||
|
||||
const collection = computed(() => {
|
||||
return tab.value.document.collection
|
||||
})
|
||||
|
||||
// for re-run config
|
||||
const showCollectionsRunnerModal = ref(false)
|
||||
const selectedCollectionID = ref<string>()
|
||||
|
||||
const testRunnerStopRef = ref(false)
|
||||
const showResult = computed(() => {
|
||||
return (
|
||||
tab.value.document.status === "running" ||
|
||||
tab.value.document.status === "stopped" ||
|
||||
tab.value.document.status === "error"
|
||||
)
|
||||
})
|
||||
|
||||
const runTests = async () => {
|
||||
testRunnerStopRef.value = false // when testRunnerStopRef is false, the test runner will start running
|
||||
testRunnerService.runTests(tab, collection.value, {
|
||||
...testRunnerConfig.value,
|
||||
stopRef: testRunnerStopRef,
|
||||
})
|
||||
}
|
||||
|
||||
const stopTests = () => {
|
||||
testRunnerStopRef.value = true
|
||||
// when we manually stop the test runner, we need to update the tab document with the current state
|
||||
tab.value.document.testRunnerMeta = {
|
||||
...tab.value.document.testRunnerMeta,
|
||||
}
|
||||
}
|
||||
|
||||
const runAgain = async () => {
|
||||
tab.value.document.resultCollection = undefined
|
||||
await nextTick()
|
||||
resetRunnerState()
|
||||
const updatedCollection = await refetchCollectionTree()
|
||||
|
||||
if (updatedCollection) {
|
||||
if (checkIfCollectionIsEmpty(updatedCollection)) {
|
||||
tabs.closeTab(tab.value.id)
|
||||
toast.error(t("collection_runner.empty_collection"))
|
||||
return
|
||||
}
|
||||
|
||||
tab.value.document.collection = updatedCollection
|
||||
await nextTick()
|
||||
runTests()
|
||||
} else {
|
||||
tabs.closeTab(tab.value.id)
|
||||
toast.error(t("collection_runner.collection_not_found"))
|
||||
}
|
||||
}
|
||||
|
||||
const resetRunnerState = () => {
|
||||
tab.value.document.testRunnerMeta = {
|
||||
failedTests: 0,
|
||||
passedTests: 0,
|
||||
totalTests: 0,
|
||||
totalRequests: 0,
|
||||
totalTime: 0,
|
||||
completedRequests: 0,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (tab.value.document.status === "idle") runTests()
|
||||
if (
|
||||
tab.value.document.status === "stopped" ||
|
||||
tab.value.document.status === "error"
|
||||
) {
|
||||
}
|
||||
})
|
||||
|
||||
function calculateAverageTime(
|
||||
totalTime: number,
|
||||
completedRequests: number
|
||||
): number {
|
||||
return completedRequests > 0 ? Math.round(totalTime / completedRequests) : 0
|
||||
}
|
||||
|
||||
const newRun = () => {
|
||||
showCollectionsRunnerModal.value = true
|
||||
selectedCollectionID.value = collection.value.id
|
||||
}
|
||||
|
||||
const testRunnerService = useService(TestRunnerService)
|
||||
|
||||
const result = computed(() => {
|
||||
return tab.value.document.resultCollection
|
||||
? [tab.value.document.resultCollection]
|
||||
: []
|
||||
})
|
||||
|
||||
const showTestsType = ref<"all" | "passed" | "failed">("all")
|
||||
|
||||
const collectionAdapter: SmartTreeAdapter<CollectionNode> =
|
||||
new TestRunnerCollectionsAdapter(result, showTestsType)
|
||||
|
||||
/**
|
||||
* refetches the collection tree from the backend
|
||||
* @returns collection tree
|
||||
*/
|
||||
const refetchCollectionTree = async () => {
|
||||
if (!tab.value.document.collectionID) return
|
||||
const type = tab.value.document.collectionType
|
||||
if (type === "my-collections") {
|
||||
return getRESTCollectionByRefId(tab.value.document.collectionID)
|
||||
}
|
||||
|
||||
return pipe(
|
||||
getCompleteCollectionTree(tab.value.document.collectionID),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
toast.error(`${getErrorMessage(err, t)}`)
|
||||
return
|
||||
},
|
||||
async (coll) => {
|
||||
return teamCollToHoppRESTColl(coll)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
function checkIfCollectionIsEmpty(collection: HoppCollection): boolean {
|
||||
// Check if the collection has requests or if any child collection is non-empty
|
||||
return (
|
||||
collection.requests.length === 0 &&
|
||||
collection.folders.every((folder) => checkIfCollectionIsEmpty(folder))
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-secondaryLight mb-1 truncate">
|
||||
{{ heading }}
|
||||
</span>
|
||||
<span class="text-sm font-bold text-secondaryDark truncate">
|
||||
<slot>
|
||||
{{ text }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
heading: string
|
||||
text?: string
|
||||
}>(),
|
||||
{}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<HoppSmartModal
|
||||
dialog
|
||||
:title="t('collection_runner.run_collection')"
|
||||
@close="closeModal"
|
||||
>
|
||||
<template #body>
|
||||
<HoppSmartTabs v-model="activeTab">
|
||||
<HoppSmartTab id="test-runner" :label="t('collection_runner.ui')">
|
||||
<div
|
||||
class="flex-shrink-0 w-full h-full p-4 overflow-auto overflow-x-auto bg-primary"
|
||||
>
|
||||
<section>
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("collection_runner.run_config") }}
|
||||
</h4>
|
||||
<div class="mt-4">
|
||||
<!-- TODO: fix input component types. so that it accepts number -->
|
||||
<HoppSmartInput
|
||||
v-model="config.delay as any"
|
||||
type="number"
|
||||
:label="t('collection_runner.delay')"
|
||||
class="!rounded-r-none !border-r-0"
|
||||
input-styles="floating-input !rounded-r-none !border-r-0"
|
||||
>
|
||||
<template #button>
|
||||
<span
|
||||
class="px-4 py-2 font-semibold border rounded-r bg-primaryLight border-divider text-secondaryLight"
|
||||
>
|
||||
ms
|
||||
</span>
|
||||
</template>
|
||||
</HoppSmartInput>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-6">
|
||||
<span class="text-xs text-secondaryLight">
|
||||
{{ t("collection_runner.advanced_settings") }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-4 mt-4 items-start">
|
||||
<HoppSmartCheckbox
|
||||
class="pr-2"
|
||||
:on="config.stopOnError"
|
||||
@change="config.stopOnError = !config.stopOnError"
|
||||
>
|
||||
<span>
|
||||
{{ t("collection_runner.stop_on_error") }}
|
||||
</span>
|
||||
</HoppSmartCheckbox>
|
||||
|
||||
<HoppSmartCheckbox
|
||||
class="pr-2"
|
||||
:on="config.persistResponses"
|
||||
@change="config.persistResponses = !config.persistResponses"
|
||||
>
|
||||
<span>
|
||||
{{ t("collection_runner.persist_responses") }}
|
||||
</span>
|
||||
<HoppButtonSecondary
|
||||
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
|
||||
class="pr-2"
|
||||
:on="config.keepVariableValues"
|
||||
@change="
|
||||
config.keepVariableValues = !config.keepVariableValues
|
||||
"
|
||||
>
|
||||
<span>Keep variable values</span>
|
||||
<HoppButtonSecondary
|
||||
class="!py-0 pl-2"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/documentation/features/inspections"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
</HoppSmartCheckbox> -->
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
id="cli"
|
||||
:label="`${t('collection_runner.cli')} ${
|
||||
!CLICommand ? '(Team Collections Only)' : ''
|
||||
}`"
|
||||
:disabled="!CLICommand"
|
||||
>
|
||||
<HttpTestEnv :show="false" @select-env="setCurrentEnv" />
|
||||
|
||||
<div class="space-y-4 p-4">
|
||||
<p
|
||||
class="p-4 mb-4 border rounded-md text-amber-500 border-amber-600"
|
||||
>
|
||||
{{ cliCommandGenerationDescription }}
|
||||
</p>
|
||||
|
||||
<div v-if="environmentID" class="flex gap-x-2 items-center">
|
||||
<HoppSmartCheckbox
|
||||
:on="includeEnvironmentID"
|
||||
@change="toggleIncludeEnvironment"
|
||||
/>
|
||||
<span class="truncate"
|
||||
>{{ t("collection_runner.include_active_environment") }}
|
||||
<span class="text-secondaryDark">
|
||||
{{ currentEnv?.name }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-4 rounded-md bg-primaryLight text-secondaryDark select-text"
|
||||
>
|
||||
{{ CLICommand }}
|
||||
</div>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex space-x-2">
|
||||
<HoppButtonPrimary
|
||||
v-if="activeTab === 'test-runner'"
|
||||
:label="`${t('test.run')}`"
|
||||
:icon="IconPlay"
|
||||
outline
|
||||
@click="runTests"
|
||||
/>
|
||||
<HoppButtonPrimary
|
||||
v-else
|
||||
:label="`${t('action.copy')}`"
|
||||
:icon="copyIcon"
|
||||
outline
|
||||
@click="copyCLICommandToClipboard"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="`${t('action.close')}`"
|
||||
outline
|
||||
filled
|
||||
@click="closeModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { computed, ref } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
import { HoppCollection } from "@hoppscotch/data"
|
||||
import { useService } from "dioc/vue"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { TestRunnerConfig } from "~/helpers/rest/document"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconPlay from "~icons/lucide/play"
|
||||
import { CurrentEnv } from "./Env.vue"
|
||||
import { pipe } from "fp-ts/lib/function"
|
||||
import {
|
||||
getCompleteCollectionTree,
|
||||
teamCollToHoppRESTColl,
|
||||
} from "~/helpers/backend/helpers"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { getErrorMessage } from "~/helpers/runner/collection-tree"
|
||||
import { getRESTCollectionByRefId } from "~/newstore/collections"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const tabs = useService(RESTTabService)
|
||||
|
||||
const loadingCollection = ref(false)
|
||||
|
||||
export type CollectionRunnerData =
|
||||
| {
|
||||
type: "my-collections"
|
||||
// for my-collections it's actually _ref_id
|
||||
collectionID: string
|
||||
collectionIndex?: string
|
||||
}
|
||||
| {
|
||||
type: "team-collections"
|
||||
collectionID: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
sameTab?: boolean
|
||||
collectionRunnerData: CollectionRunnerData
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const includeEnvironmentID = ref(false)
|
||||
const activeTab = ref("test-runner")
|
||||
|
||||
const environmentID = ref("")
|
||||
const currentEnv = ref<CurrentEnv>(null)
|
||||
|
||||
function setCurrentEnv(payload: CurrentEnv) {
|
||||
currentEnv.value = payload
|
||||
if (payload?.type === "TEAM_ENV") {
|
||||
environmentID.value = payload.teamEnvID
|
||||
}
|
||||
}
|
||||
|
||||
const config = ref<TestRunnerConfig>({
|
||||
iterations: 1,
|
||||
delay: 500,
|
||||
stopOnError: false,
|
||||
persistResponses: true,
|
||||
keepVariableValues: false,
|
||||
})
|
||||
|
||||
const runTests = async () => {
|
||||
const collectionTree = await getCollectionTree(
|
||||
props.collectionRunnerData.type,
|
||||
props.collectionRunnerData.collectionID
|
||||
)
|
||||
|
||||
if (!collectionTree) {
|
||||
toast.error(t("collection_runner.collection_not_found"))
|
||||
return
|
||||
}
|
||||
|
||||
if (checkIfCollectionIsEmpty(collectionTree)) {
|
||||
toast.error(t("collection_runner.empty_collection"))
|
||||
return
|
||||
}
|
||||
|
||||
let tabIdToClose = null
|
||||
if (props.sameTab) tabIdToClose = cloneDeep(tabs.currentTabID.value)
|
||||
|
||||
tabs.createNewTab({
|
||||
type: "test-runner",
|
||||
collectionType: props.collectionRunnerData.type,
|
||||
collectionID: props.collectionRunnerData.collectionID,
|
||||
collection: collectionTree as HoppCollection,
|
||||
isDirty: false,
|
||||
config: config.value,
|
||||
status: "idle",
|
||||
request: null,
|
||||
testRunnerMeta: {
|
||||
completedRequests: 0,
|
||||
totalRequests: 0,
|
||||
totalTime: 0,
|
||||
failedTests: 0,
|
||||
passedTests: 0,
|
||||
totalTests: 0,
|
||||
},
|
||||
})
|
||||
|
||||
if (tabIdToClose) tabs.closeTab(tabIdToClose)
|
||||
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the collection tree from the backend
|
||||
* @param collection
|
||||
* @returns collection tree
|
||||
*/
|
||||
const getCollectionTree = async (
|
||||
type: CollectionRunnerData["type"],
|
||||
collectionID: string
|
||||
) => {
|
||||
if (!collectionID) return
|
||||
if (type === "my-collections") {
|
||||
return await getRESTCollectionByRefId(collectionID)
|
||||
}
|
||||
loadingCollection.value = true
|
||||
return pipe(
|
||||
getCompleteCollectionTree(collectionID),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
toast.error(`${getErrorMessage(err, t)}`)
|
||||
loadingCollection.value = false
|
||||
return
|
||||
},
|
||||
async (coll) => {
|
||||
loadingCollection.value = false
|
||||
return teamCollToHoppRESTColl(coll)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
function checkIfCollectionIsEmpty(collection: HoppCollection): boolean {
|
||||
// Check if the collection has requests or if any child collection is non-empty
|
||||
return (
|
||||
collection.requests.length === 0 &&
|
||||
collection.folders.every((folder) => checkIfCollectionIsEmpty(folder))
|
||||
)
|
||||
}
|
||||
|
||||
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const isCloudInstance = window.location.hostname === "hoppscotch.io"
|
||||
|
||||
const cliCommandGenerationDescription = computed(() => {
|
||||
if (isCloudInstance) {
|
||||
return t("collection_runner.cli_command_generation_description_cloud")
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_BACKEND_API_URL) {
|
||||
return t("collection_runner.cli_command_generation_description_sh")
|
||||
}
|
||||
|
||||
return t(
|
||||
"collection_runner.cli_command_generation_description_sh_with_server_url_placeholder"
|
||||
)
|
||||
})
|
||||
|
||||
const CLICommand = computed(() => {
|
||||
if (props.collectionRunnerData.type === "team-collections") {
|
||||
const collectionID = props.collectionRunnerData.collectionID
|
||||
const environmentFlag =
|
||||
includeEnvironmentID.value && environmentID.value
|
||||
? `-e ${environmentID.value}`
|
||||
: ""
|
||||
|
||||
const serverUrl = import.meta.env.VITE_BACKEND_API_URL?.endsWith("/v1")
|
||||
? // Removing `/v1` prefix
|
||||
import.meta.env.VITE_BACKEND_API_URL.slice(0, -3)
|
||||
: "<server_url>"
|
||||
|
||||
const serverFlag = isCloudInstance ? "" : `--server ${serverUrl}`
|
||||
|
||||
return `hopp test ${collectionID} ${environmentFlag} --token <access_token> ${serverFlag}`
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const toggleIncludeEnvironment = () => {
|
||||
includeEnvironmentID.value = !includeEnvironmentID.value
|
||||
}
|
||||
|
||||
const copyCLICommandToClipboard = () => {
|
||||
copyToClipboard(CLICommand.value ?? "")
|
||||
copyIcon.value = IconCheck
|
||||
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="sticky top-upperRunnerStickyFold z-10">
|
||||
<HoppSmartTabs
|
||||
v-model="selectedTestTab"
|
||||
styles="overflow-x-auto flex-shrink-0 bg-primary"
|
||||
render-inactive-tabs
|
||||
@update:model-value="emit('onChangeTab', $event)"
|
||||
>
|
||||
<HoppSmartTab
|
||||
:id="'all'"
|
||||
:label="`${t('tab.all_tests')}`"
|
||||
:info="tab.document.testRunnerMeta.totalTests.toString()"
|
||||
>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
:id="'passed'"
|
||||
:label="`${t('tab.passed')}`"
|
||||
:info="tab.document.testRunnerMeta.passedTests.toString()"
|
||||
>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
:id="'failed'"
|
||||
:label="`${t('tab.failed')}`"
|
||||
:info="tab.document.testRunnerMeta.failedTests.toString()"
|
||||
>
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center test-runner pr-2">
|
||||
<HoppSmartTree :expand-all="true" :adapter="collectionAdapter">
|
||||
<template #content="{ node }">
|
||||
<HttpTestResultFolder
|
||||
v-if="
|
||||
node.data.type === 'folders' &&
|
||||
node.data.data.data.requests.length > 0
|
||||
"
|
||||
:id="node.id"
|
||||
:parent-i-d="node.data.data.parentIndex"
|
||||
:data="node.data.data.data"
|
||||
:is-open="true"
|
||||
:is-selected="node.data.isSelected"
|
||||
:is-last-item="node.data.isLastItem"
|
||||
:show-selection="showCheckbox"
|
||||
folder-type="folder"
|
||||
/>
|
||||
|
||||
<HttpTestResultRequest
|
||||
v-if="node.data.type === 'requests' && !node.data.hidden"
|
||||
class="runner-request"
|
||||
:show-test-type="selectedTestTab"
|
||||
:request="node.data.data.data"
|
||||
:request-i-d="node.id"
|
||||
:parent-i-d="node.data.data.parentIndex"
|
||||
:is-selected="node.data.isSelected"
|
||||
:show-selection="showCheckbox"
|
||||
:is-last-item="node.data.isLastItem"
|
||||
@select-request="selectRequest(node.data.data.data)"
|
||||
/>
|
||||
</template>
|
||||
</HoppSmartTree>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SmartTreeAdapter } from "@hoppscotch/ui"
|
||||
import { ref } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { HoppTestRunnerDocument } from "~/helpers/rest/document"
|
||||
import { HoppTab } from "~/services/tab"
|
||||
import { TestRunnerRequest } from "~/services/test-runner/test-runner.service"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
tab: HoppTab<HoppTestRunnerDocument>
|
||||
collectionAdapter: SmartTreeAdapter<any>
|
||||
isRunning: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "onSelectRequest", request: TestRunnerRequest): void
|
||||
(e: "onChangeTab", event: string): void
|
||||
}>()
|
||||
|
||||
const selectedTestTab = ref<"all" | "passed" | "failed">("all")
|
||||
|
||||
const showCheckbox = ref(false)
|
||||
|
||||
const selectRequest = (request: TestRunnerRequest) => {
|
||||
emit("onSelectRequest", request)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.test-runner > div > div > div > div > div {
|
||||
margin-left: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.test-runner .runner-request {
|
||||
@apply ml-2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="
|
||||
testResults &&
|
||||
(testResults.expectResults.length ||
|
||||
testResults.tests.length ||
|
||||
haveEnvVariables)
|
||||
"
|
||||
>
|
||||
<div class="divide-y-4 divide-dividerLight border-b border-dividerLight">
|
||||
<div v-if="haveEnvVariables" class="flex flex-col">
|
||||
<details class="flex flex-col divide-y divide-dividerLight" open>
|
||||
<summary
|
||||
class="group flex min-w-0 flex-1 cursor-pointer items-center justify-between text-tiny text-secondaryLight transition focus:outline-none"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center justify-center truncate px-4 py-2 transition group-hover:text-secondary"
|
||||
>
|
||||
<icon-lucide-chevron-right
|
||||
class="indicator mr-2 flex flex-shrink-0"
|
||||
/>
|
||||
<span class="capitalize-first truncate">
|
||||
{{ t("environment.title") }}
|
||||
</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="divide-y divide-dividerLight">
|
||||
<div
|
||||
v-if="noEnvSelected && !globalHasAdditions"
|
||||
class="flex bg-bannerInfo p-4 text-secondaryDark"
|
||||
role="alert"
|
||||
>
|
||||
<icon-lucide-alert-triangle class="svg-icons mr-4" />
|
||||
<div class="flex flex-col">
|
||||
<p>
|
||||
{{ t("environment.no_environment_description") }}
|
||||
</p>
|
||||
<p class="mt-3 flex space-x-2">
|
||||
<HoppButtonSecondary
|
||||
:label="t('environment.add_to_global')"
|
||||
class="!bg-primary text-tiny"
|
||||
filled
|
||||
@click="addEnvToGlobal()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="t('environment.create_new')"
|
||||
class="!bg-primary text-tiny"
|
||||
filled
|
||||
@click="displayModalAdd(true)"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.global.additions"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="additions"
|
||||
global
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.global.updations"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="updations"
|
||||
global
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.global.deletions"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="deletions"
|
||||
global
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.selected.additions"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="additions"
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.selected.updations"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="updations"
|
||||
/>
|
||||
<HttpTestResultEnv
|
||||
v-for="(env, index) in testResults.envDiff.selected.deletions"
|
||||
:key="`env-${env.key}-${index}`"
|
||||
:env="env"
|
||||
status="deletions"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div
|
||||
v-if="testResults.tests"
|
||||
class="divide-y-4 divide-dividerLight test-results-entry"
|
||||
>
|
||||
<template
|
||||
v-for="(result, index) in testResults.tests"
|
||||
:key="`result-${index}`"
|
||||
>
|
||||
<HttpTestResultEntry
|
||||
v-if="shouldShowEntry(result)"
|
||||
:test-results="result"
|
||||
:show-test-type="props.showTestType"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="testResults.expectResults"
|
||||
class="divide-y divide-dividerLight"
|
||||
>
|
||||
<HttpTestResultReport
|
||||
v-if="testResults.expectResults.length"
|
||||
:test-results="testResults"
|
||||
/>
|
||||
<div
|
||||
v-for="(result, index) in testResults.expectResults"
|
||||
:key="`result-${index}`"
|
||||
class="flex items-center px-4 py-2"
|
||||
>
|
||||
<div class="flex flex-shrink-0 items-center overflow-x-auto">
|
||||
<component
|
||||
:is="result.status === 'pass' ? IconCheck : IconClose"
|
||||
class="svg-icons mr-4"
|
||||
:class="
|
||||
result.status === 'pass' ? 'text-green-500' : 'text-red-500'
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center space-x-2 overflow-x-auto"
|
||||
>
|
||||
<span
|
||||
v-if="result.message"
|
||||
class="inline-flex text-secondaryDark"
|
||||
>
|
||||
{{ result.message }}
|
||||
</span>
|
||||
<span class="inline-flex text-secondaryLight">
|
||||
<icon-lucide-minus class="svg-icons mr-2" />
|
||||
{{
|
||||
result.status === "pass"
|
||||
? t("test.passed")
|
||||
: t("test.failed")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
v-else-if="testResults && testResults.scriptError"
|
||||
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
|
||||
:alt="`${t('error.test_script_fail')}`"
|
||||
:heading="t('error.test_script_fail')"
|
||||
:text="t('helpers.test_script_fail')"
|
||||
>
|
||||
</HoppSmartPlaceholder>
|
||||
<template v-else>
|
||||
<div
|
||||
class="py-2 pl-4 ml-4 mb-2 text-secondaryLight border-secondaryLight border-l"
|
||||
>
|
||||
{{ t("empty.tests") }}
|
||||
</div>
|
||||
</template>
|
||||
<EnvironmentsMyDetails
|
||||
:show="showMyEnvironmentDetailsModal"
|
||||
action="new"
|
||||
:env-vars="getAdditionVars"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
/>
|
||||
<EnvironmentsTeamsDetails
|
||||
:show="showTeamEnvironmentDetailsModal"
|
||||
action="new"
|
||||
:env-vars="getAdditionVars"
|
||||
:editing-team-id="
|
||||
workspace.type === 'team' ? workspace.teamID : undefined
|
||||
"
|
||||
@hide-modal="displayModalAdd(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { computed, ref } from "vue"
|
||||
import { HoppTestData, HoppTestResult } from "~/helpers/types/HoppTestResult"
|
||||
import {
|
||||
globalEnv$,
|
||||
selectedEnvironmentIndex$,
|
||||
setSelectedEnvironmentIndex,
|
||||
} from "~/newstore/environments"
|
||||
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconClose from "~icons/lucide/x"
|
||||
|
||||
import { GlobalEnvironment } from "@hoppscotch/data"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import { useColorMode } from "~/composables/theming"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: HoppTestResult | null | undefined
|
||||
showTestType: "all" | "passed" | "failed"
|
||||
showEmptyMessage?: boolean
|
||||
}>(),
|
||||
{
|
||||
showEmptyMessage: true,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: HoppTestResult | null | undefined): void
|
||||
}>()
|
||||
|
||||
const testResults = useVModel(props, "modelValue", emit)
|
||||
|
||||
const shouldShowEntry = (result: HoppTestData) => {
|
||||
if (props.showTestType === "all") return true
|
||||
if (props.showTestType === "passed")
|
||||
return result.expectResults.some((x) => x.status === "pass")
|
||||
if (props.showTestType === "failed")
|
||||
return result.expectResults.some((x) => x.status === "fail")
|
||||
return false
|
||||
}
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
const workspace = workspaceService.currentWorkspace
|
||||
|
||||
const showMyEnvironmentDetailsModal = ref(false)
|
||||
const showTeamEnvironmentDetailsModal = ref(false)
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
if (workspace.value.type === "personal")
|
||||
showMyEnvironmentDetailsModal.value = shouldDisplay
|
||||
else showTeamEnvironmentDetailsModal.value = shouldDisplay
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "addition" environment variables
|
||||
* @returns Array of objects with key-value pairs of arguments
|
||||
*/
|
||||
const getAdditionVars = () =>
|
||||
testResults?.value?.envDiff?.selected?.additions
|
||||
? testResults.value.envDiff.selected.additions
|
||||
: []
|
||||
|
||||
const haveEnvVariables = computed(() => {
|
||||
if (!testResults.value) return false
|
||||
return (
|
||||
testResults.value.envDiff.global.additions.length ||
|
||||
testResults.value.envDiff.global.updations.length ||
|
||||
testResults.value.envDiff.global.deletions.length ||
|
||||
testResults.value.envDiff.selected.additions.length ||
|
||||
testResults.value.envDiff.selected.updations.length ||
|
||||
testResults.value.envDiff.selected.deletions.length
|
||||
)
|
||||
})
|
||||
|
||||
const selectedEnvironmentIndex = useStream(
|
||||
selectedEnvironmentIndex$,
|
||||
{ type: "NO_ENV_SELECTED" },
|
||||
setSelectedEnvironmentIndex
|
||||
)
|
||||
|
||||
const globalEnvVars = useReadonlyStream(globalEnv$, {} as GlobalEnvironment)
|
||||
|
||||
const noEnvSelected = computed(
|
||||
() => selectedEnvironmentIndex.value.type === "NO_ENV_SELECTED"
|
||||
)
|
||||
|
||||
const globalHasAdditions = computed(() => {
|
||||
if (!testResults.value?.envDiff.selected.additions) return false
|
||||
return (
|
||||
testResults.value.envDiff.selected.additions.every(
|
||||
(x) =>
|
||||
globalEnvVars.value.variables.findIndex((y) => isEqual(x, y)) !== -1
|
||||
) ?? false
|
||||
)
|
||||
})
|
||||
|
||||
const addEnvToGlobal = () => {
|
||||
if (!testResults.value?.envDiff.selected.additions) return
|
||||
|
||||
invokeAction("modals.global.environment.update", {
|
||||
variables: testResults.value.envDiff.selected.additions,
|
||||
isSecret: false,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user