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

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ pids
*.pid
*.seed
*.pid.lock
*.env
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

View File

@@ -25,7 +25,7 @@
"devDependencies": {
"@commitlint/cli": "19.5.0",
"@commitlint/config-conventional": "19.5.0",
"@hoppscotch/ui": "0.2.1",
"@hoppscotch/ui": "0.2.2",
"@types/node": "22.7.6",
"cross-env": "7.0.3",
"http-server": "14.1.1",

View File

@@ -79,7 +79,7 @@ export const WORKSPACE_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Workspa
collectionID: "clx1ldkzs005t10f8rp5u60q7",
teamID: "clws3hg58000011o8h07glsb1",
title: "RequestA",
request: `{"v":"${RESTReqSchemaVersion}","id":"clpttpdq00003qp16kut6doqv","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestA","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the root collection\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Set at root collection\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Bearer BearerToken\\");\\n});","preRequestScript":"","requestVariables":[]}`,
request: `{"v":"${RESTReqSchemaVersion}","id":"clpttpdq00003qp16kut6doqv","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestA","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the root collection\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Set at root collection\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Bearer BearerToken\\");\\n});","preRequestScript":"","requestVariables":[],"responses":{}}`,
},
],
},
@@ -233,6 +233,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: HoppC
'pw.test("Correctly inherits auth and headers from the root collection", ()=> {\n pw.expect(pw.response.body.headers["x-test-header"]).toBe("Set at root collection");\n pw.expect(pw.response.body.headers["authorization"]).toBe("Bearer BearerToken");\n});',
preRequestScript: "",
requestVariables: [],
responses: {},
},
],
auth: {

View File

@@ -31,12 +31,14 @@ const migrateCollections = (collections: unknown[]): HoppCollection[] => {
);
}
return collectionSchemaParsedResult.data.map((collection) => {
return {
...collection,
folders: migrateCollections(collection.folders),
};
});
return collectionSchemaParsedResult.data.map(
({ _ref_id, folders, ...rest }) => {
return {
...rest,
folders: migrateCollections(folders),
};
}
);
};
describe("workspace-access", () => {

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

View File

@@ -36,6 +36,7 @@
"homepage": "https://github.com/hoppscotch/hoppscotch#readme",
"devDependencies": {
"@types/lodash": "4.17.10",
"@types/uuid": "10.0.0",
"typescript": "5.6.3",
"vite": "5.4.9"
},
@@ -44,6 +45,7 @@
"io-ts": "2.2.21",
"lodash": "4.17.21",
"parser-ts": "0.7.0",
"uuid": "10.0.0",
"verzod": "0.2.2",
"zod": "3.23.8"
}

View File

@@ -4,22 +4,25 @@ import V1_VERSION from "./v/1"
import V2_VERSION from "./v/2"
import V3_VERSION from "./v/3"
import V4_VERSION from "./v/4"
import V5_VERSION from "./v/5"
import { z } from "zod"
import { translateToNewRequest } from "../rest"
import { translateToGQLRequest } from "../graphql"
import { generateUniqueRefId } from "../utils/collection"
const versionedObject = z.object({
v: z.number(),
})
export const HoppCollection = createVersionedEntity({
latestVersion: 4,
latestVersion: 5,
versionMap: {
1: V1_VERSION,
2: V2_VERSION,
3: V3_VERSION,
4: V4_VERSION,
5: V5_VERSION,
},
getVersion(data) {
const versionCheck = versionedObject.safeParse(data)
@@ -35,7 +38,7 @@ export const HoppCollection = createVersionedEntity({
export type HoppCollection = InferredEntity<typeof HoppCollection>
export const CollectionSchemaVersion = 4
export const CollectionSchemaVersion = 5
/**
* Generates a Collection object. This ignores the version number object
@@ -46,6 +49,7 @@ export function makeCollection(x: Omit<HoppCollection, "v">): HoppCollection {
return {
v: CollectionSchemaVersion,
...x,
_ref_id: x._ref_id ? x._ref_id : generateUniqueRefId("coll"),
}
}
@@ -72,6 +76,7 @@ export function translateToNewRESTCollection(x: any): HoppCollection {
})
if (x.id) obj.id = x.id
if (x._ref_id) obj._ref_id = x._ref_id
return obj
}
@@ -99,6 +104,7 @@ export function translateToNewGQLCollection(x: any): HoppCollection {
})
if (x.id) obj.id = x.id
if (x._ref_id) obj._ref_id = x._ref_id
return obj
}

View File

@@ -6,7 +6,7 @@ import { HoppRESTAuth } from "../../rest/v/8"
import { V3_SCHEMA, v3_baseCollectionSchema } from "./3"
const v4_baseCollectionSchema = v3_baseCollectionSchema.extend({
export const v4_baseCollectionSchema = v3_baseCollectionSchema.extend({
v: z.literal(4),
auth: z.union([HoppRESTAuth, HoppGQLAuth]),
})
@@ -19,7 +19,7 @@ type Output = z.output<typeof v4_baseCollectionSchema> & {
folders: Output[]
}
const V4_SCHEMA: z.ZodType<Output, z.ZodTypeDef, Input> =
export const V4_SCHEMA: z.ZodType<Output, z.ZodTypeDef, Input> =
v4_baseCollectionSchema.extend({
folders: z.lazy(() => z.array(V4_SCHEMA)),
})

View File

@@ -0,0 +1,36 @@
import { defineVersion } from "verzod"
import { z } from "zod"
import { V4_SCHEMA, v4_baseCollectionSchema } from "./4"
import { generateUniqueRefId } from "../../utils/collection"
const v5_baseCollectionSchema = v4_baseCollectionSchema.extend({
v: z.literal(5),
_ref_id: z.string().optional(),
})
type Input = z.input<typeof v5_baseCollectionSchema> & {
folders: Input[]
}
type Output = z.output<typeof v5_baseCollectionSchema> & {
folders: Output[]
}
const V5_SCHEMA: z.ZodType<Output, z.ZodTypeDef, Input> =
v5_baseCollectionSchema.extend({
folders: z.lazy(() => z.array(V5_SCHEMA)),
})
export default defineVersion({
initial: false,
schema: V5_SCHEMA,
// @ts-expect-error
up(old: z.infer<typeof V4_SCHEMA>) {
return {
...old,
v: 5 as const,
_ref_id: generateUniqueRefId("coll"),
}
},
})

View File

@@ -5,3 +5,4 @@ export * from "./rawKeyValue"
export * from "./environment"
export * from "./global-environment"
export * from "./predefinedVariables"
export * from "./utils/collection"

View File

@@ -0,0 +1,13 @@
import { v4 as uuidV4 } from "uuid"
/**
* Generate a unique reference ID
* @param prefix Prefix to add to the generated ID
* @returns The generated reference ID
*/
export const generateUniqueRefId = (prefix = "") => {
const timestamp = Date.now().toString(36)
const randomPart = uuidV4()
return `${prefix}_${timestamp}_${randomPart}`
}

View File

@@ -48,6 +48,7 @@ import {
updateRESTRequestOrder,
} from "@hoppscotch/common/newstore/collections"
import {
generateUniqueRefId,
GQLHeader,
HoppCollection,
HoppGQLRequest,
@@ -70,6 +71,7 @@ function initCollectionsSync() {
gqlCollectionsSyncer.startStoreSync()
// TODO: fix collection schema transformation on backend maybe?
loadUserCollections("REST")
loadUserCollections("GQL")
@@ -94,6 +96,7 @@ function initCollectionsSync() {
type ExportedUserCollectionREST = {
id?: string
_ref_id?: string
folders: ExportedUserCollectionREST[]
requests: Array<HoppRESTRequest & { id: string }>
name: string
@@ -102,6 +105,7 @@ type ExportedUserCollectionREST = {
type ExportedUserCollectionGQL = {
id?: string
_ref_id?: string
folders: ExportedUserCollectionGQL[]
requests: Array<HoppGQLRequest & { id: string }>
name: string
@@ -130,11 +134,13 @@ function exportedCollectionToHoppCollection(
: {
auth: { authType: "inherit", authActive: false },
headers: [],
_ref_id: generateUniqueRefId("coll"),
}
return {
id: restCollection.id,
v: 4,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 5,
name: restCollection.name,
folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
@@ -192,11 +198,13 @@ function exportedCollectionToHoppCollection(
: {
auth: { authType: "inherit", authActive: false },
headers: [],
_ref_id: generateUniqueRefId("coll"),
}
return {
id: gqlCollection.id,
v: 4,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 5,
name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType)
@@ -366,6 +374,7 @@ function setupUserCollectionCreatedSubscription() {
: {
auth: { authType: "inherit", authActive: false },
headers: [],
_ref_id: generateUniqueRefId("coll"),
}
runDispatchWithOutSyncing(() => {
@@ -374,7 +383,8 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 4,
v: 5,
_ref_id: data._ref_id,
auth: data.auth,
headers: addDescriptionField(data.headers),
})
@@ -382,7 +392,8 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title,
folders: [],
requests: [],
v: 4,
v: 5,
_ref_id: data._ref_id,
auth: data.auth,
headers: addDescriptionField(data.headers),
})
@@ -587,12 +598,13 @@ function setupUserCollectionDuplicatedSubscription() {
)
// Incoming data transformed to the respective internal representations
const { auth, headers } =
const { auth, headers, _ref_id } =
data && data != "null"
? JSON.parse(data)
: {
auth: { authType: "inherit", authActive: false },
headers: [],
_ref_id: generateUniqueRefId("coll"),
}
const folders = transformDuplicatedCollections(childCollectionsJSONStr)
@@ -607,7 +619,8 @@ function setupUserCollectionDuplicatedSubscription() {
name,
folders,
requests,
v: 3,
v: 5,
_ref_id,
auth,
headers: addDescriptionField(headers),
}
@@ -1037,7 +1050,7 @@ function transformDuplicatedCollections(
name,
folders,
requests,
v: 3,
v: 5,
auth,
headers: addDescriptionField(headers),
}

View File

@@ -9,7 +9,11 @@ import {
settingsStore,
} from "@hoppscotch/common/newstore/settings"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import {
generateUniqueRefId,
HoppCollection,
HoppRESTRequest,
} from "@hoppscotch/data"
import { getSyncInitFunction, StoreSyncDefinitionOf } from "../../lib/sync"
import { createMapper } from "../../lib/sync/mapper"
@@ -53,6 +57,7 @@ const recursivelySyncCollections = async (
authActive: true,
},
headers: collection.headers ?? [],
_ref_id: collection._ref_id,
}
const res = await createRESTRootUserCollection(
collection.name,
@@ -69,9 +74,11 @@ const recursivelySyncCollections = async (
authActive: true,
},
headers: [],
_ref_id: generateUniqueRefId("coll"),
}
collection.id = parentCollectionID
collection._ref_id = returnedData._ref_id ?? generateUniqueRefId("coll")
collection.auth = returnedData.auth
collection.headers = returnedData.headers
removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath)
@@ -86,6 +93,7 @@ const recursivelySyncCollections = async (
authActive: true,
},
headers: collection.headers ?? [],
_ref_id: collection._ref_id,
}
const res = await createRESTChildUserCollection(
@@ -105,9 +113,11 @@ const recursivelySyncCollections = async (
authActive: true,
},
headers: [],
_ref_id: generateUniqueRefId("coll"),
}
collection.id = childCollectionId
collection._ref_id = returnedData._ref_id ?? generateUniqueRefId("coll")
collection.auth = returnedData.auth
collection.headers = returnedData.headers

View File

@@ -0,0 +1,38 @@
import { PersistableTabState } from "@hoppscotch/common/services/tab"
import { HoppTabDocument } from "@hoppscotch/common/helpers/rest/document"
import { HoppUser } from "@hoppscotch/common/platform/auth"
import { TabStatePlatformDef } from "@hoppscotch/common/platform/tab"
import { def as platformAuth } from "@platform/auth"
import { getCurrentRestSession, updateUserSession } from "./tabState.api"
import { SessionType } from "../../api/generated/graphql"
import * as E from "fp-ts/Either"
async function writeCurrentTabState(
_: HoppUser,
persistableTabState: PersistableTabState<HoppTabDocument>
) {
await updateUserSession(JSON.stringify(persistableTabState), SessionType.Rest)
}
async function loadTabStateFromSync(): Promise<PersistableTabState<HoppTabDocument> | null> {
const currentUser = platformAuth.getCurrentUser()
if (!currentUser)
throw new Error("Cannot load request from sync without login")
const res = await getCurrentRestSession()
if (E.isRight(res)) {
const currentRESTSession = res.right.me.currentRESTSession
return currentRESTSession ? JSON.parse(currentRESTSession) : null
} else {
}
return null
}
export const def: TabStatePlatformDef = {
loadTabStateFromSync,
writeCurrentTabState,
}

View File

@@ -11,6 +11,7 @@ export default {
upperSecondaryStickyFold: "var(--upper-secondary-sticky-fold)",
upperTertiaryStickyFold: "var(--upper-tertiary-sticky-fold)",
upperFourthStickyFold: "var(--upper-fourth-sticky-fold)",
upperRunnerStickyFold: "var(--upper-runner-sticky-fold)",
upperMobilePrimaryStickyFold: "var(--upper-mobile-primary-sticky-fold)",
upperMobileSecondaryStickyFold:
"var(--upper-mobile-secondary-sticky-fold)",

145
pnpm-lock.yaml generated
View File

@@ -30,8 +30,8 @@ importers:
specifier: 19.5.0
version: 19.5.0
'@hoppscotch/ui':
specifier: 0.2.1
version: 0.2.1(eslint@9.12.0(jiti@2.3.3))(terser@5.34.1)(typescript@5.6.3)(vite@5.4.9(@types/node@22.7.6)(sass@1.80.3)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))
specifier: 0.2.2
version: 0.2.2(eslint@9.12.0(jiti@2.3.3))(terser@5.34.1)(typescript@5.6.3)(vite@5.4.9(@types/node@22.7.6)(sass@1.80.3)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))
'@types/node':
specifier: 22.7.6
version: 22.7.6
@@ -499,8 +499,8 @@ importers:
specifier: workspace:^
version: link:../hoppscotch-js-sandbox
'@hoppscotch/ui':
specifier: 0.2.1
version: 0.2.1(eslint@8.57.0)(terser@5.34.1)(typescript@5.3.3)(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.3.3))
specifier: 0.2.2
version: 0.2.2(eslint@8.57.0)(terser@5.34.1)(typescript@5.3.3)(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.3.3))
'@hoppscotch/vue-toasted':
specifier: 0.1.0
version: 0.1.0(vue@3.5.12(typescript@5.3.3))
@@ -904,6 +904,9 @@ importers:
parser-ts:
specifier: 0.7.0
version: 0.7.0(fp-ts@2.16.9)
uuid:
specifier: 10.0.0
version: 10.0.0
verzod:
specifier: 0.2.2
version: 0.2.2(zod@3.23.8)
@@ -914,6 +917,9 @@ importers:
'@types/lodash':
specifier: 4.17.10
version: 4.17.10
'@types/uuid':
specifier: 10.0.0
version: 10.0.0
typescript:
specifier: 5.6.3
version: 5.6.3
@@ -3760,6 +3766,12 @@ packages:
peerDependencies:
vue: 3.5.12
'@hoppscotch/ui@0.2.2':
resolution: {integrity: sha512-rDRfG9onpmlDCO2KjJZN6UIlFC5Ewif689guvtVCZh9a+soy9nUUTbwMHI9913oBIJpbZ4GTLHGpdCl1YHUiVQ==}
engines: {node: '>=16'}
peerDependencies:
vue: 3.5.12
'@hoppscotch/vue-sonner@1.2.3':
resolution: {integrity: sha512-P1gyvHHLsPeB8lsLP5SrqwQatuwOKtbsP83sKhyIV3WL2rJj3+DiFfqo2ErNBa+Sl0gM68o1V+wuOS7zbR//6g==}
@@ -3882,26 +3894,26 @@ packages:
resolution: {integrity: sha512-GG428DkrrWCMhxRMRQZjuS7zmSUzarYcaHJqG9VB8dXAxw4iQDoKVQ7ChJRB6ZtsCsX3Jse1PEUlHrJiyQrOTg==}
engines: {node: '>= 16'}
'@intlify/message-compiler@10.0.0':
resolution: {integrity: sha512-OcaWc63NC/9p1cMdgoNKBj4d61BH8sUW1Hfs6YijTd9656ZR4rNqXAlRnBrfS5ABq0vjQjpa8VnyvH9hK49yBw==}
engines: {node: '>= 16'}
'@intlify/message-compiler@10.0.4':
resolution: {integrity: sha512-AFbhEo10DP095/45EauinQJ5hJ3rJUmuuqltGguvc3WsvezZN+g8qNHLGWKu60FHQVizMrQY7VJ+zVlBXlQQkQ==}
engines: {node: '>= 16'}
'@intlify/message-compiler@11.0.0-beta.1':
resolution: {integrity: sha512-yMXfN4hg/EeSdtWfmoMrwB9X4TXwkBoZlTIpNydQaW9y0tSJHGnUPRoahtkbsyACCm9leSJINLY4jQ0rK6BK0Q==}
engines: {node: '>= 16'}
'@intlify/message-compiler@9.3.0-beta.20':
resolution: {integrity: sha512-hwqQXyTnDzAVZ300SU31jO0+3OJbpOdfVU6iBkrmNpS7t2HRnVACo0EwcEXzJa++4EVDreqz5OeqJbt+PeSGGA==}
engines: {node: '>= 16'}
'@intlify/shared@10.0.0':
resolution: {integrity: sha512-6ngLfI7DOTew2dcF9WMJx+NnMWghMBhIiHbGg+wRvngpzD5KZJZiJVuzMsUQE1a5YebEmtpTEfUrDp/NqVGdiw==}
engines: {node: '>= 16'}
'@intlify/shared@10.0.4':
resolution: {integrity: sha512-ukFn0I01HsSgr3VYhYcvkTCLS7rGa0gw4A4AMpcy/A9xx/zRJy7PS2BElMXLwUazVFMAr5zuiTk3MQeoeGXaJg==}
engines: {node: '>= 16'}
'@intlify/shared@11.0.0-beta.1':
resolution: {integrity: sha512-Md/4T/QOx7wZ7zqVzSsMx2M/9Mx/1nsgsjXS5SFIowFKydqUhMz7K+y7pMFh781aNYz+rGXYwad8E9/+InK9SA==}
engines: {node: '>= 16'}
'@intlify/shared@9.3.0-beta.20':
resolution: {integrity: sha512-RucSPqh8O9FFxlYUysQTerSw0b9HIRpyoN1Zjogpm0qLiHK+lBNSa5sh1nCJ4wSsNcjphzgpLQCyR60GZlRV8g==}
engines: {node: '>= 16'}
@@ -10381,6 +10393,7 @@ packages:
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
deprecated: |-
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
qs@6.11.0:
@@ -15394,30 +15407,6 @@ snapshots:
stringify-object: 3.3.0
yargs: 17.7.2
'@hoppscotch/ui@0.2.1(eslint@8.57.0)(terser@5.34.1)(typescript@5.3.3)(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.3.3))':
dependencies:
'@boringer-avatars/vue3': 0.2.1(vue@3.5.12(typescript@5.3.3))
'@fontsource-variable/inter': 5.1.0
'@fontsource-variable/material-symbols-rounded': 5.1.3
'@fontsource-variable/roboto-mono': 5.1.0
'@hoppscotch/vue-sonner': 1.2.3
'@hoppscotch/vue-toasted': 0.1.0(vue@3.5.12(typescript@5.3.3))
'@vitejs/plugin-legacy': 2.3.0(terser@5.34.1)(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))
'@vueuse/core': 8.9.4(vue@3.5.12(typescript@5.3.3))
fp-ts: 2.16.9
lodash-es: 4.17.21
path: 0.12.7
vite-plugin-eslint: 1.8.1(eslint@8.57.0)(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))
vue: 3.5.12(typescript@5.3.3)
vue-promise-modals: 0.1.0(typescript@5.3.3)
vuedraggable-es: 4.1.1(vue@3.5.12(typescript@5.3.3))
transitivePeerDependencies:
- '@vue/composition-api'
- eslint
- terser
- typescript
- vite
'@hoppscotch/ui@0.2.1(eslint@8.57.0)(terser@5.34.1)(typescript@5.3.3)(vite@5.4.9(@types/node@22.7.6)(sass@1.80.3)(terser@5.34.1))(vue@3.5.12(typescript@5.3.3))':
dependencies:
'@boringer-avatars/vue3': 0.2.1(vue@3.5.12(typescript@5.3.3))
@@ -15490,6 +15479,54 @@ snapshots:
- typescript
- vite
'@hoppscotch/ui@0.2.2(eslint@8.57.0)(terser@5.34.1)(typescript@5.3.3)(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.3.3))':
dependencies:
'@boringer-avatars/vue3': 0.2.1(vue@3.5.12(typescript@5.3.3))
'@fontsource-variable/inter': 5.0.15
'@fontsource-variable/material-symbols-rounded': 5.0.16
'@fontsource-variable/roboto-mono': 5.0.16
'@hoppscotch/vue-sonner': 1.2.3
'@hoppscotch/vue-toasted': 0.1.0(vue@3.5.12(typescript@5.3.3))
'@vitejs/plugin-legacy': 2.3.0(terser@5.34.1)(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))
'@vueuse/core': 8.9.4(vue@3.5.12(typescript@5.3.3))
fp-ts: 2.16.9
lodash-es: 4.17.21
path: 0.12.7
vite-plugin-eslint: 1.8.1(eslint@8.57.0)(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))
vue: 3.5.12(typescript@5.3.3)
vue-promise-modals: 0.1.0(typescript@5.3.3)
vuedraggable-es: 4.1.1(vue@3.5.12(typescript@5.3.3))
transitivePeerDependencies:
- '@vue/composition-api'
- eslint
- terser
- typescript
- vite
'@hoppscotch/ui@0.2.2(eslint@9.12.0(jiti@2.3.3))(terser@5.34.1)(typescript@5.6.3)(vite@5.4.9(@types/node@22.7.6)(sass@1.80.3)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))':
dependencies:
'@boringer-avatars/vue3': 0.2.1(vue@3.5.12(typescript@5.6.3))
'@fontsource-variable/inter': 5.0.15
'@fontsource-variable/material-symbols-rounded': 5.0.16
'@fontsource-variable/roboto-mono': 5.0.16
'@hoppscotch/vue-sonner': 1.2.3
'@hoppscotch/vue-toasted': 0.1.0(vue@3.5.12(typescript@5.6.3))
'@vitejs/plugin-legacy': 2.3.0(terser@5.34.1)(vite@5.4.9(@types/node@22.7.6)(sass@1.80.3)(terser@5.34.1))
'@vueuse/core': 8.9.4(vue@3.5.12(typescript@5.6.3))
fp-ts: 2.16.9
lodash-es: 4.17.21
path: 0.12.7
vite-plugin-eslint: 1.8.1(eslint@9.12.0(jiti@2.3.3))(vite@5.4.9(@types/node@22.7.6)(sass@1.80.3)(terser@5.34.1))
vue: 3.5.12(typescript@5.6.3)
vue-promise-modals: 0.1.0(typescript@5.6.3)
vuedraggable-es: 4.1.1(vue@3.5.12(typescript@5.6.3))
transitivePeerDependencies:
- '@vue/composition-api'
- eslint
- terser
- typescript
- vite
'@hoppscotch/vue-sonner@1.2.3': {}
'@hoppscotch/vue-toasted@0.1.0(vue@3.5.12(typescript@5.3.3))':
@@ -15588,8 +15625,8 @@ snapshots:
'@intlify/bundle-utils@3.4.0(vue-i18n@10.0.4(vue@3.5.12(typescript@5.3.3)))':
dependencies:
'@intlify/message-compiler': 10.0.0
'@intlify/shared': 10.0.0
'@intlify/message-compiler': 11.0.0-beta.1
'@intlify/shared': 11.0.0-beta.1
jsonc-eslint-parser: 1.4.1
source-map: 0.6.1
yaml-eslint-parser: 0.3.2
@@ -15613,8 +15650,8 @@ snapshots:
'@intlify/bundle-utils@9.0.0-beta.0(vue-i18n@10.0.4(vue@3.5.12(typescript@5.6.3)))':
dependencies:
'@intlify/message-compiler': 10.0.0
'@intlify/shared': 10.0.0
'@intlify/message-compiler': 11.0.0-beta.1
'@intlify/shared': 11.0.0-beta.1
acorn: 8.12.1
escodegen: 2.1.0
estree-walker: 2.0.2
@@ -15630,33 +15667,33 @@ snapshots:
'@intlify/message-compiler': 10.0.4
'@intlify/shared': 10.0.4
'@intlify/message-compiler@10.0.0':
dependencies:
'@intlify/shared': 10.0.0
source-map-js: 1.2.1
'@intlify/message-compiler@10.0.4':
dependencies:
'@intlify/shared': 10.0.4
source-map-js: 1.2.1
'@intlify/message-compiler@11.0.0-beta.1':
dependencies:
'@intlify/shared': 11.0.0-beta.1
source-map-js: 1.2.1
'@intlify/message-compiler@9.3.0-beta.20':
dependencies:
'@intlify/shared': 9.3.0-beta.20
source-map-js: 1.2.1
'@intlify/shared@10.0.0': {}
'@intlify/shared@10.0.4': {}
'@intlify/shared@11.0.0-beta.1': {}
'@intlify/shared@9.3.0-beta.20': {}
'@intlify/unplugin-vue-i18n@5.2.0(@vue/compiler-dom@3.5.12)(eslint@9.12.0(jiti@2.3.3))(rollup@4.24.0)(typescript@5.6.3)(vue-i18n@10.0.4(vue@3.5.12(typescript@5.6.3)))(vue@3.5.12(typescript@5.6.3))(webpack-sources@3.2.3)':
dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3))
'@intlify/bundle-utils': 9.0.0-beta.0(vue-i18n@10.0.4(vue@3.5.12(typescript@5.6.3)))
'@intlify/shared': 10.0.0
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@10.0.0)(@vue/compiler-dom@3.5.12)(vue-i18n@10.0.4(vue@3.5.12(typescript@5.6.3)))(vue@3.5.12(typescript@5.6.3))
'@intlify/shared': 11.0.0-beta.1
'@intlify/vue-i18n-extensions': 7.0.0(@intlify/shared@11.0.0-beta.1)(@vue/compiler-dom@3.5.12)(vue-i18n@10.0.4(vue@3.5.12(typescript@5.6.3)))(vue@3.5.12(typescript@5.6.3))
'@rollup/pluginutils': 5.1.2(rollup@4.24.0)
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3)
@@ -15682,7 +15719,7 @@ snapshots:
'@intlify/vite-plugin-vue-i18n@6.0.1(vite@4.5.0(@types/node@18.18.8)(sass@1.80.3)(terser@5.34.1))(vue-i18n@10.0.4(vue@3.5.12(typescript@4.9.5)))':
dependencies:
'@intlify/bundle-utils': 7.0.0(vue-i18n@10.0.4(vue@3.5.12(typescript@4.9.5)))
'@intlify/shared': 10.0.0
'@intlify/shared': 11.0.0-beta.1
'@rollup/pluginutils': 4.2.1
debug: 4.3.7
fast-glob: 3.3.2
@@ -15696,7 +15733,7 @@ snapshots:
'@intlify/vite-plugin-vue-i18n@7.0.0(vite@5.4.9(@types/node@22.7.6)(sass@1.79.5)(terser@5.34.1))(vue-i18n@10.0.4(vue@3.5.12(typescript@5.3.3)))':
dependencies:
'@intlify/bundle-utils': 3.4.0(vue-i18n@10.0.4(vue@3.5.12(typescript@5.3.3)))
'@intlify/shared': 10.0.0
'@intlify/shared': 11.0.0-beta.1
'@rollup/pluginutils': 4.2.1
debug: 4.3.7
fast-glob: 3.3.2
@@ -15710,7 +15747,7 @@ snapshots:
'@intlify/vite-plugin-vue-i18n@7.0.0(vite@5.4.9(@types/node@22.7.6)(sass@1.80.3)(terser@5.34.1))(vue-i18n@10.0.4(vue@3.5.12(typescript@5.3.3)))':
dependencies:
'@intlify/bundle-utils': 3.4.0(vue-i18n@10.0.4(vue@3.5.12(typescript@5.3.3)))
'@intlify/shared': 10.0.0
'@intlify/shared': 11.0.0-beta.1
'@rollup/pluginutils': 4.2.1
debug: 4.3.7
fast-glob: 3.3.2
@@ -15721,11 +15758,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@10.0.0)(@vue/compiler-dom@3.5.12)(vue-i18n@10.0.4(vue@3.5.12(typescript@5.6.3)))(vue@3.5.12(typescript@5.6.3))':
'@intlify/vue-i18n-extensions@7.0.0(@intlify/shared@11.0.0-beta.1)(@vue/compiler-dom@3.5.12)(vue-i18n@10.0.4(vue@3.5.12(typescript@5.6.3)))(vue@3.5.12(typescript@5.6.3))':
dependencies:
'@babel/parser': 7.25.7
optionalDependencies:
'@intlify/shared': 10.0.0
'@intlify/shared': 11.0.0-beta.1
'@vue/compiler-dom': 3.5.12
vue: 3.5.12(typescript@5.6.3)
vue-i18n: 10.0.4(vue@3.5.12(typescript@5.6.3))