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

@@ -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

View File

@@ -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>

View File

@@ -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>(), {

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>