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

@@ -344,26 +344,44 @@ pre.ace_editor {
.info-response {
color: var(--status-info-color);
&.outlined {
border: 1px solid var(--status-info-color);
}
}
.success-response {
color: var(--status-success-color);
&.outlined {
border: 1px solid var(--status-success-color);
}
}
.redirect-response {
color: var(--status-redirect-color);
&.outlined {
border: 1px solid var(--status-redirect-color);
}
}
.critical-error-response {
color: var(--status-critical-error-color);
&.outlined {
border: 1px solid var(--status-critical-error-color);
}
}
.server-error-response {
color: var(--status-server-error-color);
&.outlined {
border: 1px solid var(--status-server-error-color);
}
}
.missing-data-response {
color: var(--status-missing-data-color);
&.outlined {
border: 1px solid var(--status-missing-data-color);
}
}
.toasted-container {

View File

@@ -5,7 +5,9 @@
--font-size-tiny: 0.625rem;
--line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem;
--upper-runner-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem;
--upper-runner-sticky-fold: 4.5rem;
--upper-tertiary-sticky-fold: 8.25rem;
--upper-fourth-sticky-fold: 10.2rem;
--upper-mobile-primary-sticky-fold: 6.75rem;

View File

@@ -231,6 +231,8 @@
}
},
"collection": {
"title": "Collection",
"run": "Run Collection",
"created": "Collection created",
"different_parent": "Cannot reorder collection with different parent",
"edit": "Edit Collection",
@@ -341,6 +343,7 @@
"response": "No response received"
},
"environment": {
"heading": "Environment",
"add_to_global": "Add to Global",
"added": "Environment addition",
"create_new": "Create new environment",
@@ -448,6 +451,7 @@
"invalid_name": "Please provide a name for the folder",
"name_length_insufficient": "Folder name should be at least 3 characters long",
"new": "New Folder",
"run": "Run Folder",
"renamed": "Folder renamed"
},
"graphql": {
@@ -1069,7 +1073,10 @@
"tests": "Tests",
"types": "Types",
"variables": "Variables",
"websocket": "WebSocket"
"websocket": "WebSocket",
"all_tests": "All Tests",
"passed": "Passed",
"failed": "Failed"
},
"team": {
"already_member": "This email is associated with an existing user.",
@@ -1137,6 +1144,8 @@
"not_found": "Environment not found."
},
"test": {
"requests": "Requests",
"selection": "Selection",
"failed": "test failed",
"javascript_code": "JavaScript Code",
"learn": "Read documentation",
@@ -1144,7 +1153,14 @@
"report": "Test Report",
"results": "Test Results",
"script": "Script",
"snippets": "Snippets"
"snippets": "Snippets",
"run": "Run",
"run_again": "Run again",
"stop": "Stop",
"new_run": "New Run",
"iterations": "Iterations",
"duration": "Duration",
"avg_resp": "Avg. Response Time"
},
"websocket": {
"communication": "Communication",
@@ -1194,7 +1210,17 @@
"cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.",
"include_active_environment": "Include active environment:",
"cli": "CLI",
"ui": "Runner (coming soon)",
"delay": "Delay",
"ui": "Runner",
"running_collection": "Running collection",
"run_config": "Run Configuration",
"advanced_settings": "Advanced Settings",
"stop_on_error": "Stop run if an error occurs",
"persist_responses": "Persist responses",
"collection_not_found": "Collection not found. May be deleted or moved.",
"empty_collection": "Collection is empty. Add requests to run.",
"no_response_persist": "The collection runner is presently configured not to persist responses. This setting prevents showing the response data. To modify this behavior, initiate a new run configuration.",
"select_request": "Select a request to see response and test results",
"cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.",
"cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.",
"cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.",

View File

@@ -38,7 +38,7 @@
"@hoppscotch/data": "workspace:^",
"@hoppscotch/httpsnippet": "3.0.6",
"@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/ui": "0.2.1",
"@hoppscotch/ui": "0.2.2",
"@hoppscotch/vue-toasted": "0.1.0",
"@lezer/highlight": "1.2.0",
"@noble/curves": "1.6.0",

View File

@@ -7,6 +7,9 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
'(chore': fix broken runner for user collection)
'(feat': collection runner config in modal)
'(fix': run again function)
AccessTokens: typeof import('./components/accessTokens/index.vue')['default']
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
AccessTokensList: typeof import('./components/accessTokens/List.vue')['default']
@@ -14,6 +17,7 @@ declare module 'vue' {
AiexperimentsMergeView: typeof import('./components/aiexperiments/MergeView.vue')['default']
AiexperimentsModifyBodyModal: typeof import('./components/aiexperiments/ModifyBodyModal.vue')['default']
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: (typeof import("./components/app/Announcement.vue"))["default"]
AppBanner: typeof import('./components/app/Banner.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
@@ -41,6 +45,8 @@ declare module 'vue' {
AppSpotlightSearch: typeof import('./components/app/SpotlightSearch.vue')['default']
AppSupport: typeof import('./components/app/Support.vue')['default']
AppWhatsNewDialog: typeof import('./components/app/WhatsNewDialog.vue')['default']
ButtonPrimary: (typeof import("./../../hoppscotch-ui/src/components/button/Primary.vue"))["default"]
ButtonSecondary: (typeof import("./../../hoppscotch-ui/src/components/button/Secondary.vue"))["default"]
Collections: typeof import('./components/collections/index.vue')['default']
CollectionsAdd: typeof import('./components/collections/Add.vue')['default']
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
@@ -66,7 +72,7 @@ declare module 'vue' {
CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default']
CollectionsProperties: typeof import('./components/collections/Properties.vue')['default']
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsRunner: typeof import('./components/collections/Runner.vue')['default']
CollectionsRunner: (typeof import("./components/collections/Runner.vue"))["default"]
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
@@ -107,8 +113,10 @@ declare module 'vue' {
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: (typeof import("@hoppscotch/ui"))["HoppSmartAutoComplete"]
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartExpand: (typeof import("@hoppscotch/ui"))["HoppSmartExpand"]
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
@@ -129,6 +137,8 @@ declare module 'vue' {
HoppSmartTree: typeof import('@hoppscotch/ui')['HoppSmartTree']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
HoppTestEnv: (typeof import("@hoppscotch/ui"))["HoppTestEnv"]
HoppTestRunnerModal: (typeof import("@hoppscotch/ui"))["HoppTestRunnerModal"]
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
HttpAuthorizationAkamaiEG: typeof import('./components/http/authorization/AkamaiEG.vue')['default']
HttpAuthorizationApiKey: typeof import('./components/http/authorization/ApiKey.vue')['default']
@@ -143,6 +153,7 @@ declare module 'vue' {
HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default']
HttpCodegen: typeof import('./components/http/Codegen.vue')['default']
HttpCodegenModal: typeof import('./components/http/CodegenModal.vue')['default']
HttpCollectionRunner: (typeof import("./components/http/CollectionRunner.vue"))["default"]
HttpExampleLenseBodyRenderer: typeof import('./components/http/example/LenseBodyRenderer.vue')['default']
HttpExampleResponse: typeof import('./components/http/example/Response.vue')['default']
HttpExampleResponseMeta: typeof import('./components/http/example/ResponseMeta.vue')['default']
@@ -151,6 +162,7 @@ declare module 'vue' {
HttpHeaders: typeof import('./components/http/Headers.vue')['default']
HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default']
HttpKeyValue: typeof import('./components/http/KeyValue.vue')['default']
HttpOAuth2Authorization: (typeof import("./components/http/OAuth2Authorization.vue"))["default"]
HttpParameters: typeof import('./components/http/Parameters.vue')['default']
HttpPreRequestScript: typeof import('./components/http/PreRequestScript.vue')['default']
HttpRawBody: typeof import('./components/http/RawBody.vue')['default']
@@ -162,19 +174,36 @@ declare module 'vue' {
HttpResponse: typeof import('./components/http/Response.vue')['default']
HttpResponseInterface: typeof import('./components/http/ResponseInterface.vue')['default']
HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default']
HttpRunner: (typeof import("./components/http/Runner.vue"))["default"]
HttpSaveResponseName: typeof import('./components/http/SaveResponseName.vue')['default']
HttpSidebar: typeof import('./components/http/Sidebar.vue')['default']
HttpTabHead: typeof import('./components/http/TabHead.vue')['default']
HttpTestEnv: typeof import('./components/http/test/Env.vue')['default']
HttpTestFolder: typeof import('./components/http/test/Folder.vue')['default']
HttpTestRequest: typeof import('./components/http/test/Request.vue')['default']
HttpTestResponse: typeof import('./components/http/test/Response.vue')['default']
HttpTestResult: typeof import('./components/http/TestResult.vue')['default']
HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default']
HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default']
HttpTestResultFolder: typeof import('./components/http/test/ResultFolder.vue')['default']
HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default']
HttpTestResultRequest: typeof import('./components/http/test/ResultRequest.vue')['default']
HttpTestRunner: typeof import('./components/http/test/Runner.vue')['default']
HttpTestRunnerConfig: typeof import('./components/http/test/RunnerConfig.vue')['default']
HttpTestRunnerMeta: typeof import('./components/http/test/RunnerMeta.vue')['default']
HttpTestRunnerModal: typeof import('./components/http/test/RunnerModal.vue')['default']
HttpTestRunnerResult: typeof import('./components/http/test/RunnerResult.vue')['default']
HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpTestSelector: (typeof import("./components/http/test/Selector.vue"))["default"]
HttpTestSelectRequest: (typeof import("./components/http/test/SelectRequest.vue"))["default"]
HttpTestTestResult: typeof import('./components/http/test/TestResult.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideActivity: typeof import('~icons/lucide/activity')['default']
IconLucideAlertCircle: (typeof import("~icons/lucide/alert-circle"))["default"]
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: (typeof import("~icons/lucide/brush"))["default"]
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@@ -184,10 +213,12 @@ declare module 'vue' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucidePlay: (typeof import("~icons/lucide/play"))["default"]
IconLucidePlaySquare: (typeof import("~icons/lucide/play-square"))["default"]
IconLucideRss: (typeof import("~icons/lucide/rss"))["default"]
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideVerified: (typeof import("~icons/lucide/verified"))["default"]
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
@@ -214,6 +245,8 @@ declare module 'vue' {
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
ProfileShortcode: (typeof import("./components/profile/Shortcode.vue"))["default"]
ProfileShortcodes: (typeof import("./components/profile/Shortcodes.vue"))["default"]
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
@@ -228,14 +261,43 @@ declare module 'vue' {
ShareCustomizeModal: typeof import('./components/share/CustomizeModal.vue')['default']
ShareModal: typeof import('./components/share/Modal.vue')['default']
ShareRequest: typeof import('./components/share/Request.vue')['default']
ShareRequestModal: (typeof import("./components/share/RequestModal.vue"))["default"]
ShareShareRequestModal: (typeof import("./components/share/ShareRequestModal.vue"))["default"]
ShareTemplatesButton: typeof import('./components/share/templates/Button.vue')['default']
ShareTemplatesEmbeds: typeof import('./components/share/templates/Embeds.vue')['default']
ShareTemplatesLink: typeof import('./components/share/templates/Link.vue')['default']
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
SmartAnchor: (typeof import("./../../hoppscotch-ui/src/components/smart/Anchor.vue"))["default"]
SmartAutoComplete: (typeof import("./../../hoppscotch-ui/src/components/smart/AutoComplete.vue"))["default"]
SmartChangeLanguage: typeof import('./components/smart/ChangeLanguage.vue')['default']
SmartCheckbox: (typeof import("./../../hoppscotch-ui/src/components/smart/Checkbox.vue"))["default"]
SmartColorModePicker: typeof import('./components/smart/ColorModePicker.vue')['default']
SmartConfirmModal: (typeof import("./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue"))["default"]
SmartEncodingPicker: typeof import('./components/smart/EncodingPicker.vue')['default']
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
SmartExpand: (typeof import("./../../hoppscotch-ui/src/components/smart/Expand.vue"))["default"]
SmartFileChip: (typeof import("./../../hoppscotch-ui/src/components/smart/FileChip.vue"))["default"]
SmartInput: (typeof import("./../../hoppscotch-ui/src/components/smart/Input.vue"))["default"]
SmartIntersection: (typeof import("./../../hoppscotch-ui/src/components/smart/Intersection.vue"))["default"]
SmartItem: (typeof import("./../../hoppscotch-ui/src/components/smart/Item.vue"))["default"]
SmartLink: (typeof import("./../../hoppscotch-ui/src/components/smart/Link.vue"))["default"]
SmartModal: (typeof import("./../../hoppscotch-ui/src/components/smart/Modal.vue"))["default"]
SmartPicture: (typeof import("./../../hoppscotch-ui/src/components/smart/Picture.vue"))["default"]
SmartPlaceholder: (typeof import("./../../hoppscotch-ui/src/components/smart/Placeholder.vue"))["default"]
SmartProgressRing: (typeof import("./../../hoppscotch-ui/src/components/smart/ProgressRing.vue"))["default"]
SmartRadio: (typeof import("./../../hoppscotch-ui/src/components/smart/Radio.vue"))["default"]
SmartRadioGroup: (typeof import("./../../hoppscotch-ui/src/components/smart/RadioGroup.vue"))["default"]
SmartSelectWrapper: (typeof import("./../../hoppscotch-ui/src/components/smart/SelectWrapper.vue"))["default"]
SmartSlideOver: (typeof import("./../../hoppscotch-ui/src/components/smart/SlideOver.vue"))["default"]
SmartSpinner: (typeof import("./../../hoppscotch-ui/src/components/smart/Spinner.vue"))["default"]
SmartTab: (typeof import("./../../hoppscotch-ui/src/components/smart/Tab.vue"))["default"]
SmartTable: (typeof import("./../../hoppscotch-ui/src/components/smart/Table.vue"))["default"]
SmartTabs: (typeof import("./../../hoppscotch-ui/src/components/smart/Tabs.vue"))["default"]
SmartToggle: (typeof import("./../../hoppscotch-ui/src/components/smart/Toggle.vue"))["default"]
SmartTree: (typeof import("./../../hoppscotch-ui/src/components/smart/Tree.vue"))["default"]
SmartTreeBranch: (typeof import("./../../hoppscotch-ui/src/components/smart/TreeBranch.vue"))["default"]
SmartWindow: (typeof import("./../../hoppscotch-ui/src/components/smart/Window.vue"))["default"]
SmartWindows: (typeof import("./../../hoppscotch-ui/src/components/smart/Windows.vue"))["default"]
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
TabSecondary: typeof import('./components/tab/Secondary.vue')['default']
Teams: typeof import('./components/teams/index.vue')['default']

View File

@@ -157,10 +157,8 @@ watch(
() => props.show,
(show) => {
if (show) {
if (tabs.currentActiveTab.value.document.type === "example-response")
return
editingName.value = tabs.currentActiveTab.value.document.request.name
if (tabs.currentActiveTab.value.document.type === "request")
editingName.value = tabs.currentActiveTab.value.document.request.name
}
}
)

View File

@@ -74,7 +74,6 @@
@click="emit('add-folder')"
/>
<HoppButtonSecondary
v-if="collectionsType === 'team-collections'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlaySquare"
:title="t('collection_runner.run_collection')"
@@ -133,6 +132,18 @@
}
"
/>
<HoppSmartItem
ref="runCollectionAction"
:icon="IconPlaySquare"
:label="t('collection_runner.run_collection')"
:shortcut="['T']"
@click="
() => {
emit('run-collection', props.id)
hide()
}
"
/>
<HoppSmartItem
ref="edit"
:icon="IconEdit"
@@ -195,19 +206,6 @@
}
"
/>
<HoppSmartItem
v-if="collectionsType === 'team-collections'"
ref="runCollectionAction"
:icon="IconPlaySquare"
:label="t('collection_runner.run_collection')"
:shortcut="['T']"
@click="
() => {
emit('run-collection', props.id)
hide()
}
"
/>
</div>
</template>
</tippy>
@@ -298,6 +296,7 @@ const emit = defineEmits<{
(event: "toggle-children"): void
(event: "add-request"): void
(event: "add-folder"): void
(event: "run-collection"): void
(event: "edit-collection"): void
(event: "edit-properties"): void
(event: "duplicate-collection"): void

View File

@@ -114,7 +114,7 @@ const t = useI18n()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
loadingState?: boolean
modelValue?: string
requestContext: HoppRESTRequest | null
}>(),

View File

@@ -64,6 +64,12 @@
folder: node.data.data.data,
})
"
@run-collection="
emit('run-collection', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'collections' &&
emit('edit-collection', {
@@ -133,6 +139,12 @@
})
"
folder-type="folder"
@run-collection="
emit('run-collection', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@add-request="
node.data.type === 'folders' &&
emit('add-request', {
@@ -493,6 +505,13 @@ const emit = defineEmits<{
folder: HoppCollection
}
): void
(
event: "run-collection",
payload: {
collectionIndex: string
collection: HoppCollection
}
): void
(
event: "edit-collection",
payload: {

View File

@@ -1,149 +0,0 @@
<template>
<HoppSmartModal
dialog
:title="t('collection_runner.run_collection')"
@close="closeModal"
>
<template #body>
<HoppSmartTabs v-model="activeTab">
<HoppSmartTab id="cli" :label="t('collection_runner.cli')">
<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">{{
activeEnvironment
}}</span></span
>
</div>
<div
class="p-4 rounded-md bg-primaryLight text-secondaryDark select-text"
>
{{ generatedCLICommand }}
</div>
</div>
</HoppSmartTab>
<HoppSmartTab id="runner" disabled :label="t('collection_runner.ui')" />
</HoppSmartTabs>
</template>
<template #footer>
<div class="flex space-x-2">
<HoppButtonPrimary
: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 { useToast } from "~/composables/toast"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { SelectedEnvironmentIndex } from "~/newstore/environments"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
collectionID: string
environmentID?: string | null
selectedEnvironmentIndex: SelectedEnvironmentIndex
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const includeEnvironmentID = ref(false)
const activeTab = ref("cli")
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const activeEnvironment = computed(() => {
const selectedEnv = props.selectedEnvironmentIndex
if (selectedEnv.type === "TEAM_ENV") {
return selectedEnv.environment.name
}
return null
})
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 generatedCLICommand = computed(() => {
const { collectionID, environmentID } = props
const environmentFlag =
includeEnvironmentID.value && environmentID ? `-e ${environmentID}` : ""
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}`
})
const toggleIncludeEnvironment = () => {
includeEnvironmentID.value = !includeEnvironmentID.value
}
const copyCLICommandToClipboard = () => {
copyToClipboard(generatedCLICommand.value)
copyIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const closeModal = () => {
emit("hide-modal")
}
</script>

View File

@@ -33,6 +33,13 @@
:filter-text="filterTexts"
:save-request="saveRequest"
:picked="picked"
@run-collection="
runCollectionHandler({
type: 'my-collections',
collectionID: $event.collection._ref_id,
collectionIndex: $event.collectionIndex,
})
"
@add-folder="addFolder"
@add-request="addRequest"
@edit-request="editRequest"
@@ -99,7 +106,12 @@
@remove-folder="removeFolder"
@remove-request="removeRequest"
@remove-response="removeResponse"
@run-collection="runCollectionHandler"
@run-collection="
runCollectionHandler({
type: 'team-collections',
collectionID: $event,
})
"
@share-request="shareRequest"
@select-request="selectRequest"
@select-response="selectResponse"
@@ -193,11 +205,9 @@
/>
<!-- `selectedCollectionID` is guaranteed to be a string when `showCollectionsRunnerModal` is `true` -->
<CollectionsRunner
v-if="showCollectionsRunnerModal"
:collection-i-d="selectedCollectionID!"
:environment-i-d="activeEnvironmentID"
:selected-environment-index="selectedEnvironmentIndex"
<HttpTestRunnerModal
v-if="showCollectionsRunnerModal && collectionRunnerData"
:collection-runner-data="collectionRunnerData"
@hide-modal="showCollectionsRunnerModal = false"
/>
</div>
@@ -207,6 +217,7 @@
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import {
getDefaultRESTRequest,
HoppCollection,
HoppRESTAuth,
HoppRESTHeaders,
@@ -218,7 +229,7 @@ import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { cloneDeep, debounce, isEqual } from "lodash-es"
import { PropType, computed, nextTick, onMounted, ref, watch } from "vue"
import { useReadonlyStream, useStream } from "~/composables/stream"
import { useReadonlyStream } from "~/composables/stream"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { GQLError } from "~/helpers/backend/GQLClient"
import {
@@ -278,10 +289,7 @@ import {
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "~/newstore/collections"
import {
selectedEnvironmentIndex$,
setSelectedEnvironmentIndex,
} from "~/newstore/environments"
import { useLocalState } from "~/newstore/localstate"
import { currentReorderingStatus$ } from "~/newstore/reordering"
import { platform } from "~/platform"
@@ -292,7 +300,7 @@ import { TeamWorkspace, WorkspaceService } from "~/services/workspace.service"
import { RESTOptionTabs } from "../http/RequestOptions.vue"
import { Collection as NodeCollection } from "./MyCollections.vue"
import { EditingProperties } from "./Properties.vue"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { CollectionRunnerData } from "../http/test/RunnerModal.vue"
const t = useI18n()
const toast = useToast()
@@ -383,15 +391,6 @@ const teamLoadingCollections = useReadonlyStream(
[]
)
const teamEnvironmentAdapter = new TeamEnvironmentAdapter(undefined)
const teamEnvironmentList = useReadonlyStream(
teamEnvironmentAdapter.teamEnvironmentList$,
[]
)
const selectedEnvironmentIndex = useStream(
selectedEnvironmentIndex$,
{ type: "NO_ENV_SELECTED" },
setSelectedEnvironmentIndex
)
const {
cascadeParentCollectionForHeaderAuthForSearchResults,
@@ -692,8 +691,7 @@ const showConfirmModal = ref(false)
const showTeamModalAdd = ref(false)
const showCollectionsRunnerModal = ref(false)
const selectedCollectionID = ref<string | null>(null)
const activeEnvironmentID = ref<string | null | undefined>(null)
const collectionRunnerData = ref<CollectionRunnerData | null>(null)
const displayModalAdd = (show: boolean) => {
showModalAdd.value = show
@@ -837,7 +835,9 @@ const onAddRequest = (requestName: string) => {
if (!request) return
const newRequest = {
...cloneDeep(request),
...(tabs.currentActiveTab.value.document.type === "request"
? cloneDeep(tabs.currentActiveTab.value.document.request)
: getDefaultRESTRequest()),
name: requestName,
}
@@ -849,9 +849,9 @@ const onAddRequest = (requestName: string) => {
const { auth, headers } = cascadeParentCollectionForHeaderAuth(path, "rest")
tabs.createNewTab({
type: "request",
request: newRequest,
isDirty: false,
type: "request",
saveContext: {
originLocation: "user-collection",
folderPath: path,
@@ -904,9 +904,9 @@ const onAddRequest = (requestName: string) => {
const { auth, headers } =
teamCollectionAdapter.cascadeParentCollectionForHeaderAuth(path)
tabs.createNewTab({
type: "request",
request: newRequest,
isDirty: false,
type: "request",
saveContext: {
originLocation: "team-collection",
requestID: createRequestInCollection.id,
@@ -1973,13 +1973,13 @@ const selectRequest = (selectedRequest: {
requestID: requestIndex,
})
if (possibleTab) {
if (possibleTab && possibleTab.value.document.type === "request") {
tabs.setActiveTab(possibleTab.value.id)
} else {
tabs.createNewTab({
type: "request",
request: cloneDeep(request),
isDirty: false,
type: "request",
saveContext: {
originLocation: "team-collection",
requestID: requestIndex,
@@ -2004,9 +2004,9 @@ const selectRequest = (selectedRequest: {
} else {
// If not, open the request in a new tab
tabs.createNewTab({
type: "request",
request: cloneDeep(request),
isDirty: false,
type: "request",
saveContext: {
originLocation: "user-collection",
folderPath: folderPath!,
@@ -2866,25 +2866,9 @@ const setCollectionProperties = (newCollection: {
displayModalEditProperties(false)
}
const runCollectionHandler = (collectionID: string) => {
selectedCollectionID.value = collectionID
const runCollectionHandler = (payload: CollectionRunnerData) => {
collectionRunnerData.value = payload
showCollectionsRunnerModal.value = true
const activeWorkspace = workspace.value
const currentEnv = selectedEnvironmentIndex.value
if (["NO_ENV_SELECTED", "MY_ENV"].includes(currentEnv.type)) {
activeEnvironmentID.value = null
return
}
if (activeWorkspace.type === "team" && currentEnv.type === "TEAM_ENV") {
activeEnvironmentID.value = teamEnvironmentList.value.find(
(env) =>
env.teamID === activeWorkspace.teamID &&
env.environment.id === currentEnv.environment.id
)?.environment.id
}
}
const resolveConfirmModal = (title: string | null) => {

View File

@@ -298,9 +298,9 @@ const clearHistory = () => {
const tabs = useService(RESTTabService)
const useHistory = (entry: RESTHistoryEntry) => {
tabs.createNewTab({
type: "request",
request: entry.request,
isDirty: false,
type: "request",
})
}

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>

View File

@@ -50,9 +50,10 @@ import {
import { useI18n } from "@composables/i18n"
import { useVModel } from "@vueuse/core"
import { HoppRequestDocument } from "~/helpers/rest/document"
import { TestRunnerRequest } from "~/services/test-runner/test-runner.service"
const props = defineProps<{
document: HoppRequestDocument
document: HoppRequestDocument | TestRunnerRequest
isEditable: boolean
}>()
@@ -64,6 +65,7 @@ const emit = defineEmits<{
const doc = useVModel(props, "document", emit)
const isSavable = computed(() => {
if (doc.value.type === "test-response") return false
return doc.value.response?.type === "success" && doc.value.saveContext
})
@@ -118,6 +120,8 @@ watch(
"results",
]
if (doc.value.type === "test-response") return
const { responseTabPreference } = doc.value
if (
@@ -133,6 +137,7 @@ watch(
)
watch(selectedLensTab, (newLensID) => {
if (doc.value.type === "test-response") return
doc.value.responseTabPreference = newLensID
})
</script>

View File

@@ -381,7 +381,9 @@ const envVars = computed(() => {
tabs.currentActiveTab.value.document.type === "example-response"
? tabs.currentActiveTab.value.document.response.originalRequest
.requestVariables
: tabs.currentActiveTab.value.document.request.requestVariables
: tabs.currentActiveTab.value.document.type === "request"
? tabs.currentActiveTab.value.document.request.requestVariables
: []
return [
...requestVariables.map(({ active, key, value }) =>

View File

@@ -1,6 +1,7 @@
import {
Environment,
HoppRESTHeaders,
HoppRESTRequest,
HoppRESTRequestVariable,
} from "@hoppscotch/data"
import { SandboxTestResult, TestDescriptor } from "@hoppscotch/js-sandbox"
@@ -14,6 +15,7 @@ import { Observable, Subject } from "rxjs"
import { filter } from "rxjs/operators"
import { Ref } from "vue"
import { getService } from "~/modules/dioc"
import {
environmentsStore,
getCurrentEnvironment,
@@ -22,6 +24,10 @@ import {
setGlobalEnvVariables,
updateEnvironment,
} from "~/newstore/environments"
import {
SecretEnvironmentService,
SecretVariable,
} from "~/services/secret-environment.service"
import { HoppTab } from "~/services/tab"
import { updateTeamEnvironment } from "./backend/mutations/TeamEnvironment"
import { createRESTNetworkRequestStream } from "./network"
@@ -34,15 +40,10 @@ import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { isJSONContentType } from "./utils/contenttypes"
import {
SecretEnvironmentService,
SecretVariable,
} from "~/services/secret-environment.service"
import { getService } from "~/modules/dioc"
const secretEnvironmentService = getService(SecretEnvironmentService)
const getTestableBody = (
export const getTestableBody = (
res: HoppRESTResponse & { type: "success" | "fail" }
) => {
const contentTypeHeader = res.headers.find(
@@ -69,7 +70,7 @@ const getTestableBody = (
return x
}
const combineEnvVariables = (variables: {
export const combineEnvVariables = (variables: {
environments: {
selected: Environment["variables"]
global: Environment["variables"]
@@ -279,70 +280,12 @@ export function runRESTRequest$(
)
if (E.isRight(runResult)) {
const updatedGlobalEnvVariables = updateEnvironmentsWithSecret(
cloneDeep(runResult.right.envs.global),
"global"
)
const updatedSelectedEnvVariables = updateEnvironmentsWithSecret(
cloneDeep(runResult.right.envs.selected),
"selected"
)
// set the response in the tab so that multiple tabs can run request simultaneously
tab.value.document.response = res
const updatedRunResult = {
...runResult.right,
envs: {
global: updatedGlobalEnvVariables,
selected: updatedSelectedEnvVariables,
},
}
const updatedRunResult = updateEnvsAfterTestScript(runResult)
tab.value.document.testResults =
// @ts-expect-error Typescript can't figure out this inference for some reason
translateToSandboxTestResults(updatedRunResult)
const globalEnvVariables = updateEnvironmentsWithSecret(
runResult.right.envs.global,
"global"
)
setGlobalEnvVariables({
v: 1,
variables: globalEnvVariables,
})
if (
environmentsStore.value.selectedEnvironmentIndex.type === "MY_ENV"
) {
const env = getEnvironment({
type: "MY_ENV",
index: environmentsStore.value.selectedEnvironmentIndex.index,
})
updateEnvironment(
environmentsStore.value.selectedEnvironmentIndex.index,
{
name: env.name,
v: 1,
id: "id" in env ? env.id : "",
variables: updatedRunResult.envs.selected,
}
)
} else if (
environmentsStore.value.selectedEnvironmentIndex.type ===
"TEAM_ENV"
) {
const env = getEnvironment({
type: "TEAM_ENV",
})
pipe(
updateTeamEnvironment(
JSON.stringify(updatedRunResult.envs.selected),
environmentsStore.value.selectedEnvironmentIndex.teamEnvID,
env.name
)
)()
}
} else {
tab.value.document.testResults = {
description: "",
@@ -374,6 +317,160 @@ export function runRESTRequest$(
return [cancel, res]
}
function updateEnvsAfterTestScript(runResult: E.Right<SandboxTestResult>) {
const updatedGlobalEnvVariables = updateEnvironmentsWithSecret(
// @ts-expect-error Typescript can't figure out this inference for some reason
cloneDeep(runResult.right.envs.global),
"global"
)
const updatedSelectedEnvVariables = updateEnvironmentsWithSecret(
// @ts-expect-error Typescript can't figure out this inference for some reason
cloneDeep(runResult.right.envs.selected),
"selected"
)
const updatedRunResult = {
...runResult.right,
envs: {
global: updatedGlobalEnvVariables,
selected: updatedSelectedEnvVariables,
},
}
const globalEnvVariables = updateEnvironmentsWithSecret(
// @ts-expect-error Typescript can't figure out this inference for some reason
runResult.right.envs.global,
"global"
)
setGlobalEnvVariables({
v: 1,
variables: globalEnvVariables,
})
if (environmentsStore.value.selectedEnvironmentIndex.type === "MY_ENV") {
const env = getEnvironment({
type: "MY_ENV",
index: environmentsStore.value.selectedEnvironmentIndex.index,
})
updateEnvironment(environmentsStore.value.selectedEnvironmentIndex.index, {
name: env.name,
v: 1,
id: "id" in env ? env.id : "",
variables: updatedRunResult.envs.selected,
})
} else if (
environmentsStore.value.selectedEnvironmentIndex.type === "TEAM_ENV"
) {
const env = getEnvironment({
type: "TEAM_ENV",
})
pipe(
updateTeamEnvironment(
JSON.stringify(updatedRunResult.envs.selected),
environmentsStore.value.selectedEnvironmentIndex.teamEnvID,
env.name
)
)()
}
return updatedRunResult
}
export function runTestRunnerRequest(request: HoppRESTRequest): Promise<
| E.Left<"script_fail">
| E.Right<{
response: HoppRESTResponse
testResult: HoppTestResult
}>
| undefined
> {
return getFinalEnvsFromPreRequest(
request.preRequestScript,
getCombinedEnvVariables()
).then(async (envs) => {
if (E.isLeft(envs)) {
console.error(envs.left)
return E.left("script_fail" as const)
}
const effectiveRequest = await getEffectiveRESTRequest(request, {
id: "env-id",
v: 1,
name: "Env",
variables: combineEnvVariables({
environments: envs.right,
requestVariables: [],
}),
})
const [stream] = createRESTNetworkRequestStream(effectiveRequest)
const requestResult = stream
.pipe(filter((res) => res.type === "success" || res.type === "fail"))
.toPromise()
.then(async (res) => {
if (res?.type === "success" || res?.type === "fail") {
executedResponses$.next(
// @ts-expect-error Typescript can't figure out this inference for some reason
res
)
const runResult = await runTestScript(
res.req.testScript,
envs.right,
{
status: res.statusCode,
body: getTestableBody(res),
headers: res.headers,
}
)
if (E.isRight(runResult)) {
const sandboxTestResult = translateToSandboxTestResults(
runResult.right
)
updateEnvsAfterTestScript(runResult)
return E.right({
response: res,
testResult: sandboxTestResult,
})
}
const sandboxTestResult = {
description: "",
expectResults: [],
tests: [],
envDiff: {
global: {
additions: [],
deletions: [],
updations: [],
},
selected: {
additions: [],
deletions: [],
updations: [],
},
},
scriptError: true,
}
return E.right({
response: res,
testResult: sandboxTestResult,
})
}
})
if (requestResult) {
return requestResult
}
return E.left("script_fail")
})
}
const getAddedEnvVariables = (
current: Environment["variables"],
updated: Environment["variables"]

View File

@@ -281,15 +281,19 @@ export class HoppEnvironmentPlugin {
const currentTab = restTabs.currentActiveTab.value
const currentTabRequest =
currentTab.document.type === "request"
? currentTab.document.request
: currentTab.document.response.originalRequest
currentTab.document.type === "example-response"
? currentTab.document.response.originalRequest
: currentTab.document.request
watch(
currentTabRequest,
(reqVariables) => {
(request) => {
const requestVariables = request?.requestVariables
? request.requestVariables
: []
this.envs = [
...reqVariables.requestVariables.map(({ key, value }) => ({
...requestVariables.map(({ key, value }) => ({
key,
value,
sourceEnv: "RequestVariable",
@@ -308,9 +312,11 @@ export class HoppEnvironmentPlugin {
{ immediate: true, deep: true }
)
const requestVariables = currentTabRequest?.requestVariables ?? []
subscribeToStream(aggregateEnvsWithSecrets$, (envs) => {
this.envs = [
...currentTabRequest.requestVariables.map(({ key, value }) => ({
...requestVariables.map(({ key, value }) => ({
key,
value,
sourceEnv: "RequestVariable",

View File

@@ -1,8 +1,13 @@
import { HoppRESTRequest, HoppRESTRequestResponse } from "@hoppscotch/data"
import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { HoppTestResult } from "../types/HoppTestResult"
import {
HoppCollection,
HoppRESTRequest,
HoppRESTRequestResponse,
} from "@hoppscotch/data"
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { HoppTestResult } from "../types/HoppTestResult"
import { TestRunnerRequest } from "~/services/test-runner/test-runner.service"
export type HoppRESTSaveContext =
| {
@@ -50,9 +55,122 @@ export type HoppRESTSaveContext =
/**
* Defines a live 'document' (something that is open and being edited) in the app
*/
export type HoppCollectionSaveContext =
| {
/**
* The origin source of the request
*/
originLocation: "user-collection"
/**
* Path to the request folder
*/
folderPath: string
}
| {
/**
* The origin source of the request
*/
originLocation: "team-collection"
/**
* ID of the team
*/
teamID?: string
/**
* ID of the collection loaded
*/
collectionID?: string
/**
* ID of the request in the team
*/
requestID: string
}
| null
export type TestRunnerConfig = {
iterations: number
delay: number
stopOnError: boolean
persistResponses: boolean
keepVariableValues: boolean
}
export type HoppTestRunnerDocument = {
/**
* The document type
*/
type: "test-runner"
/**
* The test runner configuration
*/
config: TestRunnerConfig
/**
* initiate test runner on tab open
*/
status: "idle" | "running" | "stopped" | "error"
/**
* The collection as it is in the document
*/
collection: HoppCollection
/**
* The type of the collection
*/
collectionType: "my-collections" | "team-collections"
/**
* collection ID to be used for team collections
* (if it's my-collections, the _ref_id will be used as collectionID)
*/
collectionID: string
/**
* The request as it is in the document
*/
resultCollection?: HoppCollection
/**
* The test runner meta information
*/
testRunnerMeta: {
totalRequests: number
completedRequests: number
totalTests: number
passedTests: number
failedTests: number
totalTime: number
}
/**
* Selected test runner request
*/
request: TestRunnerRequest | null
/**
* The response of the selected request in collections after running the test
* (if any)
*/
response?: HoppRESTResponse | null
/**
* The test results of the selected request in collections after running the test
* (if any)
*/
testResults?: HoppTestResult | null
/**
* Whether the request has any unsaved changes
* (atleast as far as we can say)
*/
isDirty: boolean
}
export type HoppRequestDocument = {
/**
* The type of the document
* The document type
*/
type: "request"
@@ -134,4 +252,7 @@ export type HoppSavedExampleDocument = {
/**
* Defines a live 'document' (something that is open and being edited) in the app
*/
export type HoppTabDocument = HoppSavedExampleDocument | HoppRequestDocument
export type HoppTabDocument =
| HoppSavedExampleDocument
| HoppRequestDocument
| HoppTestRunnerDocument

View File

@@ -0,0 +1,179 @@
import { HoppCollection } from "@hoppscotch/data"
import { ChildrenResult, SmartTreeAdapter } from "@hoppscotch/ui/helpers"
import { computed, Ref } from "vue"
import { TestRunnerRequest } from "~/services/test-runner/test-runner.service"
export type Collection = {
type: "collections"
isLastItem: boolean
data: {
parentIndex: null
data: HoppCollection
}
}
type Folder = {
type: "folders"
isLastItem: boolean
data: {
parentIndex: string
data: HoppCollection
}
}
type Requests = {
type: "requests"
isLastItem: boolean
data: {
parentIndex: string
data: TestRunnerRequest
}
}
export type CollectionNode = Collection | Folder | Requests
export class TestRunnerCollectionsAdapter
implements SmartTreeAdapter<CollectionNode>
{
constructor(
public data: Ref<HoppCollection[]>,
private show: Ref<"all" | "passed" | "failed">
) {}
private shouldShowRequest(request: TestRunnerRequest): boolean {
// Always show requests that are still loading or haven't run yet
if (!request.testResults || request.isLoading) return true
const { passed, failed } = this.countTestResults(request.testResults)
switch (this.show.value) {
case "passed":
return passed > 0
case "failed":
return failed > 0
default:
return true
}
}
private countTestResults(testResult: any) {
let passed = 0
let failed = 0
// Count direct expect results
if (testResult.expectResults) {
for (const result of testResult.expectResults) {
if (result.status === "pass") passed++
else if (result.status === "fail") failed++
}
}
// Count nested test results
if (testResult.tests) {
for (const test of testResult.tests) {
const counts = this.countTestResults(test)
passed += counts.passed
failed += counts.failed
}
}
return { passed, failed }
}
navigateToFolderWithIndexPath(
collections: HoppCollection[],
indexPaths: number[]
) {
if (indexPaths.length === 0) return null
let target = collections[indexPaths.shift() as number]
while (indexPaths.length > 0)
target = target.folders[indexPaths.shift() as number]
return target !== undefined ? target : null
}
getChildren(id: string | null): Ref<ChildrenResult<any>> {
return computed(() => {
if (id === null) {
const data = this.data.value.map((item, index) => ({
id: `folder-${index.toString()}`,
data: {
type: "collections",
isLastItem: index === this.data.value.length - 1,
data: {
parentIndex: null,
data: item,
},
},
}))
return {
status: "loaded",
data: data,
} as ChildrenResult<Collection>
}
const childType = id.split("-")[0]
if (childType === "request") {
return {
status: "loaded",
data: [],
}
}
const folderId = id.split("-")[1]
const indexPath = folderId.split("/").map((x) => parseInt(x))
const item = this.navigateToFolderWithIndexPath(
this.data.value,
indexPath
)
if (item && Object.keys(item).length) {
// Always include all folders for smooth transitions
const folderData = item.folders.map((folder, index) => ({
id: `folder-${folderId}/${index}`,
data: {
isLastItem:
index === item.folders.length - 1 && item.requests.length === 0,
type: "folders",
isSelected: true,
data: {
parentIndex: id,
data: folder,
},
},
}))
const requestData = item.requests.map((request, index) => {
const shouldShow = this.shouldShowRequest(
request as TestRunnerRequest
)
return {
id: `request-${id}/${index}`,
data: {
isLastItem: index === item.requests.length - 1,
type: "requests",
isSelected: true,
hidden: !shouldShow,
data: {
parentIndex: id,
data: request,
},
},
}
})
return {
status: "loaded",
data: [...folderData, ...requestData],
} as ChildrenResult<Folder | Request>
}
return {
status: "loaded",
data: [],
}
})
}
}

View File

@@ -0,0 +1,42 @@
import { ComposerTranslation } from "vue-i18n"
import { GQLError } from "../backend/GQLClient"
export const getErrorMessage = (
err: GQLError<string>,
t: ComposerTranslation
) => {
console.error(err)
if (err.type === "network_error") {
return t("error.network_error")
}
switch (err.error) {
case "team_coll/short_title":
return t("collection.name_length_insufficient")
case "team/invalid_coll_id":
case "bug/team_coll/no_coll_id":
case "team_req/invalid_target_id":
return t("team.invalid_coll_id")
case "team/not_required_role":
return t("profile.no_permission")
case "team_req/not_required_role":
return t("profile.no_permission")
case "Forbidden resource":
return t("profile.no_permission")
case "team_req/not_found":
return t("team.no_request_found")
case "bug/team_req/no_req_id":
return t("team.no_request_found")
case "team/collection_is_parent_coll":
return t("team.parent_coll_move")
case "team/target_and_destination_collection_are_same":
return t("team.same_target_destination")
case "team/target_collection_is_already_root_collection":
return t("collection.invalid_root_move")
case "team_req/requests_not_from_same_collection":
return t("request.different_collection")
case "team/team_collections_have_different_parents":
return t("collection.different_parent")
default:
return t("error.something_went_wrong")
}
}

View File

@@ -1,4 +1,5 @@
import {
generateUniqueRefId,
HoppCollection,
HoppGQLAuth,
HoppGQLRequest,
@@ -514,6 +515,21 @@ const restCollectionDispatchers = defineDispatchers({
if (collection) {
const name = `${collection.name} - ${t("action.duplicate")}`
function recursiveChangeRefIdToAvoidConflicts(
collection: HoppCollection
): HoppCollection {
const newCollection = {
...collection,
_ref_id: generateUniqueRefId("coll"),
}
newCollection.folders = newCollection.folders.map((folder) =>
recursiveChangeRefIdToAvoidConflicts(folder)
)
return newCollection
}
const duplicatedCollection = {
...cloneDeep(collection),
name,
@@ -522,15 +538,18 @@ const restCollectionDispatchers = defineDispatchers({
: {}),
}
const duplicatedCollectionWithNewRefId =
recursiveChangeRefIdToAvoidConflicts(duplicatedCollection)
if (isRootCollection) {
newState.push(duplicatedCollection)
newState.push(duplicatedCollectionWithNewRefId)
} else {
const parentCollectionIndexPath = indexPaths.slice(0, -1)
const parentCollection = navigateToFolderWithIndexPath(state, [
...parentCollectionIndexPath,
])
parentCollection?.folders.push(duplicatedCollection)
parentCollection?.folders.push(duplicatedCollectionWithNewRefId)
}
}
@@ -1198,6 +1217,30 @@ export function getRESTCollection(collectionIndex: number) {
return restCollectionStore.value.state[collectionIndex]
}
export function getRESTCollectionByRefId(ref_id: string) {
function findCollection(
collection: HoppCollection,
ref_id: string
): HoppCollection | null {
if (collection._ref_id === ref_id) {
return collection
}
for (const folder of collection.folders) {
const found = findCollection(folder, ref_id)
if (found) {
return found
}
}
return null
}
for (const collection of restCollectionStore.value.state) {
const found = findCollection(collection, ref_id)
if (found) {
return found
}
}
}
export function editRESTCollection(
collectionIndex: number,
partialCollection: Partial<HoppCollection>

View File

@@ -88,7 +88,6 @@ import { usePageHead } from "@composables/head"
import { useI18n } from "@composables/i18n"
import { useService } from "dioc/vue"
import { computed, onBeforeUnmount, ref } from "vue"
import { defineActionHandler } from "~/helpers/actions"
import { connection, disconnect } from "~/helpers/graphql/connection"
import { getDefaultGQLRequest } from "~/helpers/graphql/default"

View File

@@ -18,7 +18,7 @@
:is-removable="activeTabs.length > 1"
:close-visibility="'hover'"
>
<template #tabhead>
<template v-if="tab.document.type === 'request'" #tabhead>
<HttpTabHead
:tab="tab"
:is-removable="activeTabs.length > 1"
@@ -44,16 +44,24 @@
</svg>
</span>
</template>
<HttpExampleResponseTab
v-if="tab.document.type === 'example-response'"
:model-value="tab"
@update:model-value="onTabUpdate"
/>
<!-- Render TabContents -->
<HttpTestRunner
v-if="tab.document.type === 'test-runner'"
:model-value="tab"
@update:model-value="onTabUpdate"
/>
<!-- When document.type === 'request' the tab type is HoppTab<HoppRequestDocument>-->
<HttpRequestTab
v-if="tab.document.type === 'request'"
:model-value="tab"
@update:model-value="onTabUpdate"
/>
<HttpExampleResponseTab
v-else-if="tab.document.type === 'example-response'"
:model-value="tab"
@update:model-value="onTabUpdate"
/>
<!-- END Render TabContents -->
</HoppSmartWindow>
<template #actions>
<EnvironmentsSelector class="h-full" />
@@ -211,9 +219,9 @@ const onTabUpdate = (tab: HoppTab<HoppRequestDocument>) => {
const addNewTab = () => {
const tab = tabs.createNewTab({
type: "request",
request: getDefaultRESTRequest(),
isDirty: false,
type: "request",
})
tabs.setActiveTab(tab.id)
@@ -222,6 +230,18 @@ const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
tabs.updateTabOrdering(e.oldIndex, e.newIndex)
}
const getTabName = (tab: HoppTab<HoppTabDocument>) => {
if (tab.document.type === "request") {
return tab.document.request.name
} else if (tab.document.type === "test-runner") {
return tab.document.collection.name
} else if (tab.document.type === "example-response") {
return tab.document.response.name
}
return "Unnamed tab"
}
const inspectionService = useService(InspectionService)
const removeTab = (tabID: string) => {
@@ -255,9 +275,9 @@ const duplicateTab = (tabID: string) => {
const tab = tabs.getTabRef(tabID)
if (tab.value && tab.value.document.type === "request") {
const newTab = tabs.createNewTab({
type: "request",
request: cloneDeep(tab.value.document.request),
isDirty: true,
type: "request",
})
tabs.setActiveTab(newTab.id)
}
@@ -268,14 +288,6 @@ const onResolveConfirmCloseAllTabs = () => {
confirmingCloseAllTabs.value = false
}
const getTabName = (tab: HoppTab<HoppTabDocument>) => {
if (tab.document.type === "request") {
return tab.document.request.name
} else if (tab.document.type === "example-response") {
return tab.document.response.name
}
}
const requestToRename = computed(() => {
if (!renameTabID.value) return null
const tab = tabs.getTabRef(renameTabID.value)
@@ -386,7 +398,10 @@ defineActionHandler("rest.request.open", ({ doc }) => {
tabs.createNewTab(doc)
})
defineActionHandler("request.rename", openReqRenameModal)
defineActionHandler("request.rename", () => {
if (tabs.currentActiveTab.value.document.type === "request")
openReqRenameModal(tabs.currentActiveTab.value.id)
})
defineActionHandler("tab.duplicate-tab", ({ tabID }) => {
duplicateTab(tabID ?? currentTabID.value)
})

View File

@@ -136,6 +136,7 @@ const addRequestToTab = () => {
const request: unknown = JSON.parse(data.right.shortcode?.request as string)
tabs.createNewTab({
type: "request",
request: safelyExtractRESTRequest(request, getDefaultRESTRequest()),
isDirty: false,
type: "request",

View File

@@ -35,7 +35,7 @@ export class ExtensionInspectorService extends Service implements Inspector {
this.inspection.registerInspector(this)
}
getInspections(req: Readonly<Ref<HoppRESTRequest>>) {
getInspections(req: Readonly<Ref<HoppRESTRequest | null>>) {
const currentExtensionStatus = this.extensionService.extensionStatus
const isExtensionInstalled = computed(
@@ -55,6 +55,8 @@ export class ExtensionInspectorService extends Service implements Inspector {
return computed(() => {
const results: InspectorResult[] = []
if (!req.value) return results
const url = req.value.endpoint
const localHostURLs = ["localhost", "127.0.0.1"]

View File

@@ -0,0 +1,11 @@
import { PersistableTabState } from "~/services/tab"
import { HoppUser } from "./auth"
import { HoppTabDocument } from "~/helpers/rest/document"
export type TabStatePlatformDef = {
loadTabStateFromSync: () => Promise<PersistableTabState<HoppTabDocument> | null>
writeCurrentTabState: (
user: HoppUser,
persistableTabState: PersistableTabState<HoppTabDocument>
) => Promise<void>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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