fix: inspections bugs (#3277)

* fix: environment add bug in inspection

* chore: add 127.0.0.1 in url inspection

* chore: update browserextension inspection help url

* fix: team env not showing bug in selector

* chore: rework inspector systems to be reactive

* chore: handling tab changes gracefully

* refactor: move out url interceptor from the platform

* chore: add view function in inspector service to get views into the list

* fix: interceptors not kicking in on initial load

* fix: don't show no internet connection error unless browser deems so

* chore: fix tests

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Nivedin
2023-08-28 17:43:46 +05:30
committed by GitHub
parent fd162e242c
commit 4adac4af38
25 changed files with 945 additions and 588 deletions

View File

@@ -21,7 +21,12 @@
<label for="value" class="font-semibold min-w-10">{{ <label for="value" class="font-semibold min-w-10">{{
t("environment.value") t("environment.value")
}}</label> }}</label>
<input type="text" :value="value" class="input" /> <input
v-model="editingValue"
type="text"
class="input"
:placeholder="t('environment.value')"
/>
</div> </div>
<div class="flex items-center space-x-8 ml-2"> <div class="flex items-center space-x-8 ml-2">
<label for="scope" class="font-semibold min-w-10"> <label for="scope" class="font-semibold min-w-10">
@@ -105,9 +110,12 @@ watch(
scope.value = { scope.value = {
type: "global", type: "global",
} }
editingName.value = ""
replaceWithVariable.value = false replaceWithVariable.value = false
editingName.value = ""
editingValue.value = ""
} }
editingName.value = props.name
editingValue.value = props.value
} }
) )
@@ -132,6 +140,7 @@ const scope = ref<Scope>({
const replaceWithVariable = ref(false) const replaceWithVariable = ref(false)
const editingName = ref(props.name) const editingName = ref(props.name)
const editingValue = ref(props.value)
const addEnvironment = async () => { const addEnvironment = async () => {
if (!editingName.value) { if (!editingName.value) {
@@ -141,13 +150,13 @@ const addEnvironment = async () => {
if (scope.value.type === "global") { if (scope.value.type === "global") {
addGlobalEnvVariable({ addGlobalEnvVariable({
key: editingName.value, key: editingName.value,
value: props.value, value: editingValue.value,
}) })
toast.success(`${t("environment.updated")}`) toast.success(`${t("environment.updated")}`)
} else if (scope.value.type === "my-environment") { } else if (scope.value.type === "my-environment") {
addEnvironmentVariable(scope.value.index, { addEnvironmentVariable(scope.value.index, {
key: editingName.value, key: editingName.value,
value: props.value, value: editingValue.value,
}) })
toast.success(`${t("environment.updated")}`) toast.success(`${t("environment.updated")}`)
} else { } else {
@@ -155,7 +164,7 @@ const addEnvironment = async () => {
...scope.value.environment.environment.variables, ...scope.value.environment.environment.variables,
{ {
key: editingName.value, key: editingName.value,
value: props.value, value: editingValue.value,
}, },
] ]
await pipe( await pipe(
@@ -182,7 +191,7 @@ const addEnvironment = async () => {
//replace the currenttab endpoint containing the value in the text with variablename //replace the currenttab endpoint containing the value in the text with variablename
currentActiveTab.value.document.request.endpoint = currentActiveTab.value.document.request.endpoint =
currentActiveTab.value.document.request.endpoint.replace( currentActiveTab.value.document.request.endpoint.replace(
props.value, editingValue.value,
variableName variableName
) )
} }

View File

@@ -32,6 +32,7 @@
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<HoppSmartItem <HoppSmartItem
v-if="!isScopeSelector"
:label="`${t('environment.no_environment')}`" :label="`${t('environment.no_environment')}`"
:info-icon=" :info-icon="
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED' selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
@@ -48,6 +49,21 @@
} }
" "
/> />
<HoppSmartItem
v-else-if="isScopeSelector && modelValue"
:label="t('environment.global')"
:icon="IconGlobe"
:info-icon="modelValue.type === 'global' ? IconCheck : undefined"
:active-info-icon="modelValue.type === 'global'"
@click="
() => {
$emit('update:modelValue', {
type: 'global',
})
hide()
}
"
/>
<HoppSmartTabs <HoppSmartTabs
v-model="selectedEnvTab" v-model="selectedEnvTab"
:styles="`sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-0 top-0 bg-primary ${ :styles="`sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-0 top-0 bg-primary ${
@@ -66,14 +82,14 @@
:key="`gen-${index}`" :key="`gen-${index}`"
:icon="IconLayers" :icon="IconLayers"
:label="gen.name" :label="gen.name"
:info-icon="index === selectedEnv.index ? IconCheck : undefined" :info-icon="isEnvActive(index) ? IconCheck : undefined"
:active-info-icon="index === selectedEnv.index" :active-info-icon="isEnvActive(index)"
@click=" @click="
() => { () => {
selectedEnvironmentIndex = { handleEnvironmentChange(index, {
type: 'MY_ENV', type: 'my-environment',
index: index, environment: gen,
} })
hide() hide()
} }
" "
@@ -113,18 +129,14 @@
:key="`gen-team-${index}`" :key="`gen-team-${index}`"
:icon="IconLayers" :icon="IconLayers"
:label="gen.environment.name" :label="gen.environment.name"
:info-icon=" :info-icon="isEnvActive(gen.id) ? IconCheck : undefined"
gen.id === selectedEnv.teamEnvID ? IconCheck : undefined :active-info-icon="isEnvActive(gen.id)"
"
:active-info-icon="gen.id === selectedEnv.teamEnvID"
@click=" @click="
() => { () => {
selectedEnvironmentIndex = { handleEnvironmentChange(index, {
type: 'TEAM_ENV', type: 'team-environment',
teamEnvID: gen.id, environment: gen,
teamID: gen.teamID, })
environment: gen.environment,
}
hide() hide()
} }
" "
@@ -285,6 +297,7 @@ import IconCheck from "~icons/lucide/check"
import IconLayers from "~icons/lucide/layers" import IconLayers from "~icons/lucide/layers"
import IconEye from "~icons/lucide/eye" import IconEye from "~icons/lucide/eye"
import IconEdit from "~icons/lucide/edit" import IconEdit from "~icons/lucide/edit"
import IconGlobe from "~icons/lucide/globe"
import { TippyComponent } from "vue-tippy" import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { GQLError } from "~/helpers/backend/GQLClient" import { GQLError } from "~/helpers/backend/GQLClient"
@@ -295,11 +308,39 @@ import {
selectedEnvironmentIndex$, selectedEnvironmentIndex$,
setSelectedEnvironmentIndex, setSelectedEnvironmentIndex,
} from "~/newstore/environments" } from "~/newstore/environments"
import { workspaceStatus$ } from "~/newstore/workspace" import { changeWorkspace, workspaceStatus$ } from "~/newstore/workspace"
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter" import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core" import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { invokeAction } from "~/helpers/actions" import { invokeAction } from "~/helpers/actions"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { Environment } from "@hoppscotch/data"
import { onMounted } from "vue"
import { onLoggedIn } from "~/composables/auth"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useLocalState } from "~/newstore/localstate"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
type Scope =
| {
type: "global"
}
| {
type: "my-environment"
environment: Environment
index: number
}
| {
type: "team-environment"
environment: TeamEnvironment
}
const props = defineProps<{
isScopeSelector?: boolean
modelValue?: Scope
}>()
const emit = defineEmits<{
(e: "update:modelValue", data: Scope): void
}>()
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
const mdAndLarger = breakpoints.greater("md") const mdAndLarger = breakpoints.greater("md")
@@ -314,6 +355,38 @@ const myEnvironments = useReadonlyStream(environments$, [])
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" }) const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const teamListFetched = ref(false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
})
}
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
}
}
}
)
// TeamEnv List Adapter
const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined) const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined)
const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false) const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false)
const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null) const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null)
@@ -322,6 +395,70 @@ const teamEnvironmentList = useReadonlyStream(
[] []
) )
const handleEnvironmentChange = (
index: number,
env?:
| {
type: "my-environment"
environment: Environment
}
| {
type: "team-environment"
environment: TeamEnvironment
}
) => {
if (props.isScopeSelector && env) {
if (env.type === "my-environment") {
emit("update:modelValue", {
type: "my-environment",
environment: env.environment,
index,
})
} else if (env.type === "team-environment") {
emit("update:modelValue", {
type: "team-environment",
environment: env.environment,
})
}
} else {
if (env && env.type === "my-environment") {
selectedEnvironmentIndex.value = {
type: "MY_ENV",
index,
}
} else if (env && env.type === "team-environment") {
selectedEnvironmentIndex.value = {
type: "TEAM_ENV",
teamEnvID: env.environment.id,
teamID: env.environment.teamID,
environment: env.environment.environment,
}
}
}
}
const isEnvActive = (id: string | number) => {
if (props.isScopeSelector) {
if (props.modelValue?.type === "my-environment") {
return props.modelValue.index === id
} else if (props.modelValue?.type === "team-environment") {
return (
props.modelValue?.type === "team-environment" &&
props.modelValue.environment &&
props.modelValue.environment.id === id
)
}
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return selectedEnv.value.index === id
} else {
return (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnv.value.teamEnvID === id
)
}
}
}
const selectedEnvironmentIndex = useStream( const selectedEnvironmentIndex = useStream(
selectedEnvironmentIndex$, selectedEnvironmentIndex$,
{ type: "NO_ENV_SELECTED" }, { type: "NO_ENV_SELECTED" },
@@ -349,34 +486,90 @@ watch(
) )
const selectedEnv = computed(() => { const selectedEnv = computed(() => {
if (selectedEnvironmentIndex.value.type === "MY_ENV") { if (props.isScopeSelector) {
const environment = if (props.modelValue?.type === "my-environment") {
myEnvironments.value[selectedEnvironmentIndex.value.index] return {
return { type: "MY_ENV",
type: "MY_ENV", index: props.modelValue.index,
index: selectedEnvironmentIndex.value.index, name: props.modelValue.environment?.name,
name: environment.name, }
variables: environment.variables, } else if (props.modelValue?.type === "team-environment") {
}
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
return { return {
type: "TEAM_ENV", type: "TEAM_ENV",
name: teamEnv.environment.name, name: props.modelValue.environment.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID, teamEnvID: props.modelValue.environment.id,
variables: teamEnv.environment.variables, }
} else {
return { type: "global", name: "Global" }
}
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
const environment =
myEnvironments.value[selectedEnvironmentIndex.value.index]
return {
type: "MY_ENV",
index: selectedEnvironmentIndex.value.index,
name: environment.name,
variables: environment.variables,
}
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
return {
type: "TEAM_ENV",
name: teamEnv.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
variables: teamEnv.environment.variables,
}
} else {
return { type: "NO_ENV_SELECTED" }
} }
} else { } else {
return { type: "NO_ENV_SELECTED" } return { type: "NO_ENV_SELECTED" }
} }
} else { }
return { type: "NO_ENV_SELECTED" } })
// Set the selected environment as initial scope value
onMounted(() => {
if (props.isScopeSelector) {
if (
selectedEnvironmentIndex.value.type === "MY_ENV" &&
selectedEnvironmentIndex.value.index !== undefined
) {
emit("update:modelValue", {
type: "my-environment",
environment: myEnvironments.value[selectedEnvironmentIndex.value.index],
index: selectedEnvironmentIndex.value.index,
})
} else if (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID &&
teamEnvironmentList.value &&
teamEnvironmentList.value.length > 0
) {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
emit("update:modelValue", {
type: "team-environment",
environment: teamEnv,
})
}
} else {
emit("update:modelValue", {
type: "global",
})
}
} }
}) })

View File

@@ -300,7 +300,7 @@ watch(
defineActionHandler("modals.environment.add", ({ envName, variableName }) => { defineActionHandler("modals.environment.add", ({ envName, variableName }) => {
editingVariableName.value = envName editingVariableName.value = envName
editingVariableValue.value = variableName if (variableName) editingVariableValue.value = variableName
displayModalNew(true) displayModalNew(true)
}) })
</script> </script>

View File

@@ -508,30 +508,17 @@ const changeTab = (tab: ComputedHeader["source"]) => {
const inspectionService = useService(InspectionService) const inspectionService = useService(InspectionService)
const allTabResults = inspectionService.tabs const headerKeyResults = inspectionService.getResultViewFor(
currentTabID.value,
(result) =>
result.locations.type === "header" && result.locations.position === "key"
)
const headerKeyResults = computed(() => { const headerValueResults = inspectionService.getResultViewFor(
return ( currentTabID.value,
allTabResults.value (result) =>
.get(currentTabID.value) result.locations.type === "header" && result.locations.position === "value"
.filter( )
(result) =>
result.locations.type === "header" &&
result.locations.position === "key"
) ?? []
)
})
const headerValueResults = computed(() => {
return (
allTabResults.value
.get(currentTabID.value)
.filter(
(result) =>
result.locations.type === "header" &&
result.locations.position === "value"
) ?? []
)
})
const getInspectorResult = (results: InspectorResult[], index: number) => { const getInspectorResult = (results: InspectorResult[], index: number) => {
return results.filter((result) => { return results.filter((result) => {

View File

@@ -178,7 +178,7 @@ import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle" import IconCircle from "~icons/lucide/circle"
import IconTrash from "~icons/lucide/trash" import IconTrash from "~icons/lucide/trash"
import IconWrapText from "~icons/lucide/wrap-text" import IconWrapText from "~icons/lucide/wrap-text"
import { computed, reactive, ref, watch } from "vue" import { reactive, ref, watch } from "vue"
import { flow, pipe } from "fp-ts/function" import { flow, pipe } from "fp-ts/function"
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array" import * as A from "fp-ts/Array"
@@ -409,30 +409,18 @@ const clearContent = () => {
const inspectionService = useService(InspectionService) const inspectionService = useService(InspectionService)
const allTabResults = inspectionService.tabs const parameterKeyResults = inspectionService.getResultViewFor(
currentTabID.value,
(result) =>
result.locations.type === "parameter" && result.locations.position === "key"
)
const parameterKeyResults = computed(() => { const parameterValueResults = inspectionService.getResultViewFor(
return ( currentTabID.value,
allTabResults.value (result) =>
.get(currentTabID.value) result.locations.type === "parameter" &&
.filter( result.locations.position === "value"
(result) => )
result.locations.type === "parameter" &&
result.locations.position === "key"
) ?? []
)
})
const parameterValueResults = computed(() => {
return (
allTabResults.value
.get(currentTabID.value)
.filter(
(result) =>
result.locations.type === "parameter" &&
result.locations.position === "value"
) ?? []
)
})
const getInspectorResult = (results: InspectorResult[], index: number) => { const getInspectorResult = (results: InspectorResult[], index: number) => {
return results.filter((result) => { return results.filter((result) => {

View File

@@ -642,9 +642,5 @@ const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
const inspectionService = useService(InspectionService) const inspectionService = useService(InspectionService)
const allTabResults = inspectionService.tabs const tabResults = inspectionService.getResultViewFor(currentTabID.value)
const tabResults = computed(() => {
return allTabResults.value.get(currentTabID.value) ?? []
})
</script> </script>

View File

@@ -145,13 +145,8 @@ const statusCategory = computed(() => {
const inspectionService = useService(InspectionService) const inspectionService = useService(InspectionService)
const allTabResults = inspectionService.tabs const tabResults = inspectionService.getResultViewFor(
currentTabID.value,
const tabResults = computed(() => { (result) => result.locations.type === "response"
return ( )
allTabResults.value
.get(currentTabID.value)
?.filter((result) => result.locations.type === "response") ?? []
)
})
</script> </script>

View File

@@ -7,6 +7,7 @@ import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { getDefaultRESTRequest } from "./default" import { getDefaultRESTRequest } from "./default"
import { HoppTestResult } from "../types/HoppTestResult" import { HoppTestResult } from "../types/HoppTestResult"
import { platform } from "~/platform" import { platform } from "~/platform"
import { nextTick } from "vue"
export type HoppRESTTab = { export type HoppRESTTab = {
id: string id: string
@@ -178,7 +179,9 @@ export function closeTab(tabID: string) {
tabOrdering.value.splice(tabOrdering.value.indexOf(tabID), 1) tabOrdering.value.splice(tabOrdering.value.indexOf(tabID), 1)
tabMap.delete(tabID) nextTick(() => {
tabMap.delete(tabID)
})
} }
export function closeOtherTabs(tabID: string) { export function closeOtherTabs(tabID: string) {

View File

@@ -94,7 +94,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, onBeforeMount, watch } from "vue" import { ref, onMounted, onBeforeUnmount, onBeforeMount } from "vue"
import { safelyExtractRESTRequest } from "@hoppscotch/data" import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams" import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { useRoute } from "vue-router" import { useRoute } from "vue-router"
@@ -140,7 +140,6 @@ import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection" import { InspectionService } from "~/services/inspection"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector" import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector" import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { URLInspectorService } from "~/services/inspection/inspectors/url.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector" import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
const savingRequest = ref(false) const savingRequest = ref(false)
@@ -215,6 +214,8 @@ const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
updateTabOrdering(e.oldIndex, e.newIndex) updateTabOrdering(e.oldIndex, e.newIndex)
} }
const inspectionService = useService(InspectionService)
const removeTab = (tabID: string) => { const removeTab = (tabID: string) => {
const tabState = getTabRef(tabID).value const tabState = getTabRef(tabID).value
@@ -465,17 +466,10 @@ defineActionHandler("request.duplicate-tab", ({ tabID }) => {
duplicateTab(tabID) duplicateTab(tabID)
}) })
const inspectionService = useService(InspectionService)
useService(HeaderInspectorService) useService(HeaderInspectorService)
useService(EnvironmentInspectorService) useService(EnvironmentInspectorService)
useService(URLInspectorService)
useService(ResponseInspectorService) useService(ResponseInspectorService)
for (const inspectorDef of platform.additionalInspectors ?? []) {
watch( useService(inspectorDef.service)
() => currentTabID.value, }
() => {
inspectionService.initializeTabInspectors()
},
{ immediate: true }
)
</script> </script>

View File

@@ -8,6 +8,7 @@ import { TabStatePlatformDef } from "./tab"
import { AnalyticsPlatformDef } from "./analytics" import { AnalyticsPlatformDef } from "./analytics"
import { InterceptorsPlatformDef } from "./interceptors" import { InterceptorsPlatformDef } from "./interceptors"
import { HoppModule } from "~/modules" import { HoppModule } from "~/modules"
import { InspectorsPlatformDef } from "./inspectors"
export type PlatformDef = { export type PlatformDef = {
ui?: UIPlatformDef ui?: UIPlatformDef
@@ -22,6 +23,7 @@ export type PlatformDef = {
tabState: TabStatePlatformDef tabState: TabStatePlatformDef
} }
interceptors: InterceptorsPlatformDef interceptors: InterceptorsPlatformDef
additionalInspectors?: InspectorsPlatformDef
platformFeatureFlags: { platformFeatureFlags: {
exportAsGIST: boolean exportAsGIST: boolean
} }

View File

@@ -0,0 +1,16 @@
import { Service } from "dioc"
import { Inspector } from "~/services/inspection"
/**
* Defines an added interceptor by the platform
*/
export type PlatformInspectorsDef = {
// We are keeping this as the only mode for now
// So that if we choose to add other modes, we can do without breaking
type: "service"
service: typeof Service<unknown> & { ID: string } & {
new (): Service & Inspector
}
}
export type InspectorsPlatformDef = PlatformInspectorsDef[]

View File

@@ -1,15 +1,17 @@
import { TestContainer } from "dioc/testing" import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest" import { describe, expect, it, vi } from "vitest"
import { URLInspectorService } from "../url.inspector" import { ExtensionInspectorService } from "../extension.inspector"
import { InspectionService } from "../../index" import { InspectionService } from "~/services/inspection"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { ref } from "vue"
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
vi.mock("~/modules/i18n", () => ({ vi.mock("~/modules/i18n", () => ({
__esModule: true, __esModule: true,
getI18n: () => (x: string) => x, getI18n: () => (x: string) => x,
})) }))
describe("URLInspectorService", () => { describe("ExtensionInspectorService", () => {
it("registers with the inspection service upon initialization", () => { it("registers with the inspection service upon initialization", () => {
const container = new TestContainer() const container = new TestContainer()
@@ -19,7 +21,7 @@ describe("URLInspectorService", () => {
registerInspector: registerInspectorFn, registerInspector: registerInspectorFn,
}) })
const urlInspector = container.bind(URLInspectorService) const urlInspector = container.bind(ExtensionInspectorService)
expect(registerInspectorFn).toHaveBeenCalledOnce() expect(registerInspectorFn).toHaveBeenCalledOnce()
expect(registerInspectorFn).toHaveBeenCalledWith(urlInspector) expect(registerInspectorFn).toHaveBeenCalledWith(urlInspector)
@@ -28,55 +30,57 @@ describe("URLInspectorService", () => {
describe("getInspectorFor", () => { describe("getInspectorFor", () => {
it("should return an inspector result when localhost is in URL and extension is not available", () => { it("should return an inspector result when localhost is in URL and extension is not available", () => {
const container = new TestContainer() const container = new TestContainer()
const urlInspector = container.bind(URLInspectorService) const urlInspector = container.bind(ExtensionInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://localhost:8000/api/data", endpoint: "http://localhost:8000/api/data",
} })
const result = urlInspector.getInspectorFor(req) const result = urlInspector.getInspections(req)
expect(result).toContainEqual( expect(result.value).toContainEqual(
expect.objectContaining({ id: "url", isApplicable: true }) expect.objectContaining({ id: "url", isApplicable: true })
) )
}) })
it("should not return an inspector result when localhost is not in URL", () => { it("should not return an inspector result when localhost is not in URL", () => {
const container = new TestContainer() const container = new TestContainer()
const urlInspector = container.bind(URLInspectorService)
const req = { container.bindMock(ExtensionInterceptorService, {
extensionStatus: ref("unknown-origin" as const),
})
const urlInspector = container.bind(ExtensionInspectorService)
const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data", endpoint: "http://example.com/api/data",
} })
const result = urlInspector.getInspectorFor(req) const result = urlInspector.getInspections(req)
expect(result).toHaveLength(0) expect(result.value).toHaveLength(0)
}) })
it("should add the correct text to the results when extension is not installed", () => { it("should add the correct text to the results when extension is not installed", () => {
vi.mock("~/newstore/HoppExtension", async () => {
const { BehaviorSubject }: any = await vi.importActual("rxjs")
return {
__esModule: true,
extensionStatus$: new BehaviorSubject("waiting"),
}
})
const container = new TestContainer() const container = new TestContainer()
const urlInspector = container.bind(URLInspectorService)
const req = { container.bindMock(ExtensionInterceptorService, {
extensionStatus: ref("waiting" as const),
})
const urlInspector = container.bind(ExtensionInspectorService)
const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://localhost:8000/api/data", endpoint: "http://localhost:8000/api/data",
} })
const result = urlInspector.getInspectorFor(req) const result = urlInspector.getInspections(req)
expect(result).toHaveLength(1) expect(result.value).toHaveLength(1)
expect(result[0]).toMatchObject({ expect(result.value[0]).toMatchObject({
text: { type: "text", text: "inspections.url.extension_not_installed" }, text: { type: "text", text: "inspections.url.extension_not_installed" },
}) })
}) })

View File

@@ -0,0 +1,107 @@
import { Service } from "dioc"
import {
InspectionService,
Inspector,
InspectorResult,
} from "~/services/inspection"
import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { computed, markRaw } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import { Ref } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
/**
* This inspector is responsible for inspecting the URL of a request.
* It checks if the URL contains localhost and if the extension is installed.
* It also provides an action to enable the extension.
*
* NOTE: Initializing this service registers it as a inspector with the Inspection Service.
*/
export class ExtensionInspectorService extends Service implements Inspector {
public static readonly ID = "EXTENSION_INSPECTOR_SERVICE"
private t = getI18n()
public readonly inspectorID = "extension"
private readonly interceptorService = this.bind(InterceptorService)
private readonly extensionService = this.bind(ExtensionInterceptorService)
private readonly inspection = this.bind(InspectionService)
constructor() {
super()
this.inspection.registerInspector(this)
}
getInspections(req: Readonly<Ref<HoppRESTRequest>>) {
const currentExtensionStatus = this.extensionService.extensionStatus
const isExtensionInstalled = computed(
() => currentExtensionStatus.value === "available"
)
const EXTENSIONS_ENABLED = computed(
() => this.interceptorService.currentInterceptorID.value === "extension"
)
return computed(() => {
const results: InspectorResult[] = []
const url = req.value.endpoint
const localHostURLs = ["localhost", "127.0.0.1"]
const isContainLocalhost = localHostURLs.some((host) =>
url.includes(host)
)
if (
isContainLocalhost &&
(!EXTENSIONS_ENABLED.value || !isExtensionInstalled.value)
) {
let text
if (!isExtensionInstalled.value) {
if (currentExtensionStatus.value === "unknown-origin") {
text = this.t("inspections.url.extension_unknown_origin")
} else {
text = this.t("inspections.url.extension_not_installed")
}
} else if (!EXTENSIONS_ENABLED.value) {
text = this.t("inspections.url.extention_not_enabled")
} else {
text = this.t("inspections.url.localhost")
}
results.push({
id: "url",
icon: markRaw(IconAlertTriangle),
text: {
type: "text",
text: text,
},
action: {
text: this.t("inspections.url.extention_enable_action"),
apply: () => {
this.interceptorService.currentInterceptorID.value = "extension"
},
},
severity: 2,
isApplicable: true,
locations: {
type: "url",
},
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/documentation/features/interceptor#browser-extension",
},
})
}
return results
})
}
}

View File

@@ -61,7 +61,7 @@ describe("EnvironmentMenuService", () => {
expect(actionsMock.invokeAction).toHaveBeenCalledWith( expect(actionsMock.invokeAction).toHaveBeenCalledWith(
"modals.environment.add", "modals.environment.add",
{ {
envName: "test", envName: "",
variableName: test, variableName: test,
} }
) )

View File

@@ -42,7 +42,7 @@ export class EnvironmentMenuService extends Service implements ContextMenu {
icon: markRaw(IconPlusCircle), icon: markRaw(IconPlusCircle),
action: () => { action: () => {
invokeAction("modals.environment.add", { invokeAction("modals.environment.add", {
envName: "test", envName: "",
variableName: text, variableName: text,
}) })
}, },

View File

@@ -1,7 +1,9 @@
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { refDebounced } from "@vueuse/core"
import { Service } from "dioc" import { Service } from "dioc"
import { computed, markRaw, reactive } from "vue"
import { Component, Ref, ref, watch } from "vue" import { Component, Ref, ref, watch } from "vue"
import { currentActiveTab, currentTabID } from "~/helpers/rest/tab" import { currentActiveTab } from "~/helpers/rest/tab"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
/** /**
@@ -80,15 +82,16 @@ export interface Inspector {
*/ */
inspectorID: string inspectorID: string
/** /**
* Returns the inspector results for the request * Returns the inspector results for the request.
* @param req The request to inspect * NOTE: The refs passed down are readonly and are debounced to avoid performance issues
* @param res The response to inspect * @param req The ref to the request to inspect
* @returns The inspector results * @param res The ref to the response to inspect
* @returns The ref to the inspector results
*/ */
getInspectorFor: ( getInspections: (
req: HoppRESTRequest, req: Readonly<Ref<HoppRESTRequest>>,
res?: HoppRESTResponse res: Readonly<Ref<HoppRESTResponse | null | undefined>>
) => InspectorResult[] ) => Ref<InspectorResult[]>
} }
/** /**
@@ -98,38 +101,73 @@ export interface Inspector {
export class InspectionService extends Service { export class InspectionService extends Service {
public static readonly ID = "INSPECTION_SERVICE" public static readonly ID = "INSPECTION_SERVICE"
private inspectors: Map<string, Inspector> = new Map() private inspectors: Map<string, Inspector> = reactive(new Map())
public tabs: Ref<Map<string, InspectorResult[]>> = ref(new Map()) private tabs: Ref<Map<string, InspectorResult[]>> = ref(new Map())
constructor() {
super()
this.initializeListeners()
}
/** /**
* Registers a inspector with the inspection service * Registers a inspector with the inspection service
* @param inspector The inspector instance to register * @param inspector The inspector instance to register
*/ */
public registerInspector(inspector: Inspector) { public registerInspector(inspector: Inspector) {
this.inspectors.set(inspector.inspectorID, inspector) // markRaw is required here so that the inspector is not made reactive
this.inspectors.set(inspector.inspectorID, markRaw(inspector))
} }
public initializeTabInspectors() { private initializeListeners() {
watch( watch(
currentActiveTab.value, () => [this.inspectors.entries(), currentActiveTab.value.id],
(tab) => { () => {
if (!tab) return const reqRef = computed(() => currentActiveTab.value.document.request)
const req = currentActiveTab.value.document.request const resRef = computed(() => currentActiveTab.value.response)
const res = currentActiveTab.value.response
const inspectors = Array.from(this.inspectors.values()).map((x) => const debouncedReq = refDebounced(reqRef, 1000, { maxWait: 2000 })
x.getInspectorFor(req, res) const debouncedRes = refDebounced(resRef, 1000, { maxWait: 2000 })
const inspectorRefs = Array.from(this.inspectors.values()).map((x) =>
x.getInspections(debouncedReq, debouncedRes)
) )
this.tabs.value.set(
currentTabID.value, const activeInspections = computed(() =>
inspectors.flatMap((x) => x) inspectorRefs.flatMap((x) => x!.value)
)
watch(
() => [...inspectorRefs.flatMap((x) => x!.value)],
() => {
this.tabs.value.set(
currentActiveTab.value.id,
activeInspections.value
)
},
{ immediate: true }
) )
}, },
{ immediate: true, deep: true } { immediate: true, flush: "pre" }
) )
} }
public deleteTabInspectorResult(tabID: string) { public deleteTabInspectorResult(tabID: string) {
// TODO: Move Tabs into a service and implement this with an event instead
this.tabs.value.delete(tabID) this.tabs.value.delete(tabID)
} }
/**
* Returns a reactive view into the inspector results for a specific tab
* @param tabID The ID of the tab to get the results for
* @param filter The filter to apply to the results.
* @returns The ref into the inspector results, if the tab doesn't exist, a ref into an empty array is returned
*/
public getResultViewFor(
tabID: string,
filter: (x: InspectorResult) => boolean = () => true
) {
return computed(() => this.tabs.value.get(tabID)?.filter(filter) ?? [])
}
} }

View File

@@ -3,16 +3,23 @@ import { describe, expect, it, vi } from "vitest"
import { EnvironmentInspectorService } from "../environment.inspector" import { EnvironmentInspectorService } from "../environment.inspector"
import { InspectionService } from "../../index" import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { ref } from "vue"
vi.mock("~/modules/i18n", () => ({ vi.mock("~/modules/i18n", () => ({
__esModule: true, __esModule: true,
getI18n: () => (x: string) => x, getI18n: () => (x: string) => x,
})) }))
vi.mock("~/newstore/environments", () => ({ vi.mock("~/newstore/environments", async () => {
__esModule: true, const { BehaviorSubject }: any = await vi.importActual("rxjs")
getAggregateEnvs: () => [{ key: "EXISTING_ENV_VAR", value: "test_value" }],
})) return {
__esModule: true,
aggregateEnvs$: new BehaviorSubject([
{ key: "EXISTING_ENV_VAR", value: "test_value" },
]),
}
})
describe("EnvironmentInspectorService", () => { describe("EnvironmentInspectorService", () => {
it("registers with the inspection service upon initialization", () => { it("registers with the inspection service upon initialization", () => {
@@ -35,14 +42,14 @@ describe("EnvironmentInspectorService", () => {
const container = new TestContainer() const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService) const envInspector = container.bind(EnvironmentInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "<<UNDEFINED_ENV_VAR>>", endpoint: "<<UNDEFINED_ENV_VAR>>",
} })
const result = envInspector.getInspectorFor(req) const result = envInspector.getInspections(req)
expect(result).toContainEqual( expect(result.value).toContainEqual(
expect.objectContaining({ expect.objectContaining({
id: "environment", id: "environment",
isApplicable: true, isApplicable: true,
@@ -58,31 +65,31 @@ describe("EnvironmentInspectorService", () => {
const container = new TestContainer() const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService) const envInspector = container.bind(EnvironmentInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "<<EXISTING_ENV_VAR>>", endpoint: "<<EXISTING_ENV_VAR>>",
} })
const result = envInspector.getInspectorFor(req) const result = envInspector.getInspections(req)
expect(result).toHaveLength(0) expect(result.value).toHaveLength(0)
}) })
it("should return an inspector result when the headers contain undefined environment variables", () => { it("should return an inspector result when the headers contain undefined environment variables", () => {
const container = new TestContainer() const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService) const envInspector = container.bind(EnvironmentInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data", endpoint: "http://example.com/api/data",
headers: [ headers: [
{ key: "<<UNDEFINED_ENV_VAR>>", value: "some-value", active: true }, { key: "<<UNDEFINED_ENV_VAR>>", value: "some-value", active: true },
], ],
} })
const result = envInspector.getInspectorFor(req) const result = envInspector.getInspections(req)
expect(result).toContainEqual( expect(result.value).toContainEqual(
expect.objectContaining({ expect.objectContaining({
id: "environment", id: "environment",
isApplicable: true, isApplicable: true,
@@ -98,34 +105,34 @@ describe("EnvironmentInspectorService", () => {
const container = new TestContainer() const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService) const envInspector = container.bind(EnvironmentInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data", endpoint: "http://example.com/api/data",
headers: [ headers: [
{ key: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true }, { key: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true },
], ],
} })
const result = envInspector.getInspectorFor(req) const result = envInspector.getInspections(req)
expect(result).toHaveLength(0) expect(result.value).toHaveLength(0)
}) })
it("should return an inspector result when the params contain undefined environment variables", () => { it("should return an inspector result when the params contain undefined environment variables", () => {
const container = new TestContainer() const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService) const envInspector = container.bind(EnvironmentInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data", endpoint: "http://example.com/api/data",
params: [ params: [
{ key: "<<UNDEFINED_ENV_VAR>>", value: "some-value", active: true }, { key: "<<UNDEFINED_ENV_VAR>>", value: "some-value", active: true },
], ],
} })
const result = envInspector.getInspectorFor(req) const result = envInspector.getInspections(req)
expect(result).toContainEqual( expect(result.value).toContainEqual(
expect.objectContaining({ expect.objectContaining({
id: "environment", id: "environment",
isApplicable: true, isApplicable: true,
@@ -141,18 +148,18 @@ describe("EnvironmentInspectorService", () => {
const container = new TestContainer() const container = new TestContainer()
const envInspector = container.bind(EnvironmentInspectorService) const envInspector = container.bind(EnvironmentInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data", endpoint: "http://example.com/api/data",
headers: [], headers: [],
params: [ params: [
{ key: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true }, { key: "<<EXISTING_ENV_VAR>>", value: "some-value", active: true },
], ],
} })
const result = envInspector.getInspectorFor(req) const result = envInspector.getInspections(req)
expect(result).toHaveLength(0) expect(result.value).toHaveLength(0)
}) })
}) })
}) })

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"
import { HeaderInspectorService } from "../header.inspector" import { HeaderInspectorService } from "../header.inspector"
import { InspectionService } from "../../index" import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { ref } from "vue"
vi.mock("~/modules/i18n", () => ({ vi.mock("~/modules/i18n", () => ({
__esModule: true, __esModule: true,
@@ -30,15 +31,15 @@ describe("HeaderInspectorService", () => {
const container = new TestContainer() const container = new TestContainer()
const headerInspector = container.bind(HeaderInspectorService) const headerInspector = container.bind(HeaderInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data", endpoint: "http://example.com/api/data",
headers: [{ key: "Cookie", value: "some-cookie", active: true }], headers: [{ key: "Cookie", value: "some-cookie", active: true }],
} })
const result = headerInspector.getInspectorFor(req) const result = headerInspector.getInspections(req)
expect(result).toContainEqual( expect(result.value).toContainEqual(
expect.objectContaining({ id: "header", isApplicable: true }) expect.objectContaining({ id: "header", isApplicable: true })
) )
}) })
@@ -47,15 +48,15 @@ describe("HeaderInspectorService", () => {
const container = new TestContainer() const container = new TestContainer()
const headerInspector = container.bind(HeaderInspectorService) const headerInspector = container.bind(HeaderInspectorService)
const req = { const req = ref({
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data", endpoint: "http://example.com/api/data",
headers: [{ key: "Authorization", value: "Bearer abcd", active: true }], headers: [{ key: "Authorization", value: "Bearer abcd", active: true }],
} })
const result = headerInspector.getInspectorFor(req) const result = headerInspector.getInspections(req)
expect(result).toHaveLength(0) expect(result.value).toHaveLength(0)
}) })
}) })
}) })

View File

@@ -0,0 +1,245 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"
import { ResponseInspectorService } from "../response.inspector"
import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { ref } from "vue"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
describe("ResponseInspectorService", () => {
beforeEach(() => {
vi.stubGlobal("navigator", {
onLine: true,
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it("registers with the inspection service upon initialization", () => {
const container = new TestContainer()
const registerInspectorFn = vi.fn()
container.bindMock(InspectionService, {
registerInspector: registerInspectorFn,
})
const responseInspector = container.bind(ResponseInspectorService)
expect(registerInspectorFn).toHaveBeenCalledOnce()
expect(registerInspectorFn).toHaveBeenCalledWith(responseInspector)
})
describe("getInspectorFor", () => {
it("should return an empty array when response is undefined", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const result = responseInspector.getInspections(req, ref(undefined))
expect(result.value).toHaveLength(0)
})
it("should return an inspector result when response type is not success or status code is not 200 and if the network is not available", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const res = ref<HoppRESTResponse>({
type: "network_fail",
error: new Error("test"),
req: req.value,
})
vi.stubGlobal("navigator", {
onLine: false,
})
const result = responseInspector.getInspections(req, res)
expect(result.value).toContainEqual(
expect.objectContaining({ id: "url", isApplicable: true })
)
})
it("should return no inspector result when response type is not success or status code is not 200 and if the network is not available", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const res = ref<HoppRESTResponse>({
type: "network_fail",
error: new Error("test"),
req: req.value,
})
const result = responseInspector.getInspections(req, res)
expect(result.value).toHaveLength(0)
})
it("should handle network_fail responses and return nothing when no network is present", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const res = ref<HoppRESTResponse>({
type: "network_fail",
error: new Error("test"),
req: req.value,
})
vi.stubGlobal("navigator", {
onLine: false,
})
const result = responseInspector.getInspections(req, res)
expect(result.value).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.network_error" },
})
)
})
it("should handle network_fail responses and return nothing when network is present", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const res = ref<HoppRESTResponse>({
type: "network_fail",
error: new Error("test"),
req: req.value,
})
const result = responseInspector.getInspections(req, res)
expect(result.value).toHaveLength(0)
})
it("should handle fail responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const res = ref<HoppRESTResponse>({
type: "fail",
statusCode: 500,
body: Uint8Array.from([]),
headers: [],
meta: { responseDuration: 0, responseSize: 0 },
req: req.value,
})
const result = responseInspector.getInspections(req, res)
expect(result.value).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.default_error" },
})
)
})
it("should handle 404 responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const res = ref<HoppRESTResponse>({
type: "success",
statusCode: 404,
body: Uint8Array.from([]),
headers: [],
meta: { responseDuration: 0, responseSize: 0 },
req: req.value,
})
const result = responseInspector.getInspections(req, res)
expect(result.value).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.404_error" },
})
)
})
it("should handle 401 responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const res = ref<HoppRESTResponse>({
type: "success",
statusCode: 401,
body: Uint8Array.from([]),
headers: [],
meta: { responseDuration: 0, responseSize: 0 },
req: req.value,
})
const result = responseInspector.getInspections(req, res)
expect(result.value).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.401_error" },
})
)
})
it("should handle successful responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = ref({
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
})
const res = ref<HoppRESTResponse>({
type: "success",
statusCode: 200,
body: Uint8Array.from([]),
headers: [],
meta: { responseDuration: 0, responseSize: 0 },
req: req.value,
})
const result = responseInspector.getInspections(req, res)
expect(result.value).toHaveLength(0)
})
})
})

View File

@@ -1,151 +0,0 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { ResponseInspectorService } from "../response.inspector"
import { InspectionService } from "../../index"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
describe("ResponseInspectorService", () => {
it("registers with the inspection service upon initialization", () => {
const container = new TestContainer()
const registerInspectorFn = vi.fn()
container.bindMock(InspectionService, {
registerInspector: registerInspectorFn,
})
const responseInspector = container.bind(ResponseInspectorService)
expect(registerInspectorFn).toHaveBeenCalledOnce()
expect(registerInspectorFn).toHaveBeenCalledWith(responseInspector)
})
describe("getInspectorFor", () => {
it("should return an empty array when response is undefined", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const result = responseInspector.getInspectorFor(req, undefined)
expect(result).toHaveLength(0)
})
it("should return an inspector result when response type is not success or status code is not 200", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "network_fail", statusCode: 400 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({ id: "url", isApplicable: true })
)
})
it("should handle network_fail responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "network_fail", statusCode: 500 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.network_error" },
})
)
})
it("should handle fail responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "fail", statusCode: 500 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.default_error" },
})
)
})
it("should handle 404 responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "success", statusCode: 404 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.404_error" },
})
)
})
it("should handle 401 responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "success", statusCode: 401 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toContainEqual(
expect.objectContaining({
text: { type: "text", text: "inspections.response.401_error" },
})
)
})
it("should handle successful responses", () => {
const container = new TestContainer()
const responseInspector = container.bind(ResponseInspectorService)
const req = {
...getDefaultRESTRequest(),
endpoint: "http://example.com/api/data",
}
const res = { type: "success", statusCode: 200 }
const result = responseInspector.getInspectorFor(req, res)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -6,11 +6,13 @@ import {
InspectorResult, InspectorResult,
} from ".." } from ".."
import { Service } from "dioc" import { Service } from "dioc"
import { Ref, markRaw, ref } from "vue" import { Ref, markRaw } from "vue"
import IconPlusCircle from "~icons/lucide/plus-circle" import IconPlusCircle from "~icons/lucide/plus-circle"
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { getAggregateEnvs } from "~/newstore/environments" import { aggregateEnvs$ } from "~/newstore/environments"
import { invokeAction } from "~/helpers/actions" import { invokeAction } from "~/helpers/actions"
import { computed } from "vue"
import { useStreamStatic } from "~/composables/stream"
const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g
@@ -34,6 +36,10 @@ export class EnvironmentInspectorService extends Service implements Inspector {
private readonly inspection = this.bind(InspectionService) private readonly inspection = this.bind(InspectionService)
private aggregateEnvs = useStreamStatic(aggregateEnvs$, [], () => {
/* noop */
})[0]
constructor() { constructor() {
super() super()
@@ -49,11 +55,11 @@ export class EnvironmentInspectorService extends Service implements Inspector {
*/ */
private validateEnvironmentVariables = ( private validateEnvironmentVariables = (
target: any[], target: any[],
results: Ref<InspectorResult[]>,
locations: InspectorLocation locations: InspectorLocation
) => { ) => {
const env = getAggregateEnvs() const newErrors: InspectorResult[] = []
const envKeys = env.map((e) => e.key)
const envKeys = this.aggregateEnvs.value.map((e) => e.key)
target.forEach((element, index) => { target.forEach((element, index) => {
if (isENVInString(element)) { if (isENVInString(element)) {
@@ -83,7 +89,7 @@ export class EnvironmentInspectorService extends Service implements Inspector {
} }
} }
if (!envKeys.includes(formattedExEnv)) { if (!envKeys.includes(formattedExEnv)) {
results.value.push({ newErrors.push({
id: "environment", id: "environment",
text: { text: {
type: "text", type: "text",
@@ -96,8 +102,8 @@ export class EnvironmentInspectorService extends Service implements Inspector {
text: this.t("inspections.environment.add_environment"), text: this.t("inspections.environment.add_environment"),
apply: () => { apply: () => {
invokeAction("modals.environment.add", { invokeAction("modals.environment.add", {
envName: "test", envName: formattedExEnv,
variableName: formattedExEnv, variableName: "",
}) })
}, },
}, },
@@ -114,54 +120,61 @@ export class EnvironmentInspectorService extends Service implements Inspector {
} }
} }
}) })
return newErrors
} }
/** getInspections(req: Readonly<Ref<HoppRESTRequest>>) {
* Returns the inspector results for the request return computed(() => {
* It checks if any env is used in the request ie, url, headers, params const results: InspectorResult[] = []
* and checks if the env is defined in the environment using the validateEnvironmentVariables function
* @param req The request to inspect
* @returns The inspector results
*/
getInspectorFor(req: HoppRESTRequest): InspectorResult[] {
const results = ref<InspectorResult[]>([])
const headers = req.headers const headers = req.value.headers
const params = req.params const params = req.value.params
this.validateEnvironmentVariables([req.endpoint], results, { results.push(
type: "url", ...this.validateEnvironmentVariables([req.value.endpoint], {
type: "url",
})
)
const headerKeys = Object.values(headers).map((header) => header.key)
results.push(
...this.validateEnvironmentVariables(headerKeys, {
type: "header",
position: "key",
})
)
const headerValues = Object.values(headers).map((header) => header.value)
results.push(
...this.validateEnvironmentVariables(headerValues, {
type: "header",
position: "value",
})
)
const paramsKeys = Object.values(params).map((param) => param.key)
results.push(
...this.validateEnvironmentVariables(paramsKeys, {
type: "parameter",
position: "key",
})
)
const paramsValues = Object.values(params).map((param) => param.value)
results.push(
...this.validateEnvironmentVariables(paramsValues, {
type: "parameter",
position: "value",
})
)
return results
}) })
const headerKeys = Object.values(headers).map((header) => header.key)
this.validateEnvironmentVariables(headerKeys, results, {
type: "header",
position: "key",
})
const headerValues = Object.values(headers).map((header) => header.value)
this.validateEnvironmentVariables(headerValues, results, {
type: "header",
position: "value",
})
const paramsKeys = Object.values(params).map((param) => param.key)
this.validateEnvironmentVariables(paramsKeys, results, {
type: "parameter",
position: "key",
})
const paramsValues = Object.values(params).map((param) => param.value)
this.validateEnvironmentVariables(paramsValues, results, {
type: "parameter",
position: "value",
})
return results.value
} }
} }

View File

@@ -2,7 +2,7 @@ import { Service } from "dioc"
import { InspectionService, Inspector, InspectorResult } from ".." import { InspectionService, Inspector, InspectorResult } from ".."
import { getI18n } from "~/modules/i18n" import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { markRaw, ref } from "vue" import { Ref, computed, markRaw } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle" import IconAlertTriangle from "~icons/lucide/alert-triangle"
/** /**
@@ -26,53 +26,50 @@ export class HeaderInspectorService extends Service implements Inspector {
this.inspection.registerInspector(this) this.inspection.registerInspector(this)
} }
/** private cookiesCheck(headerKey: string) {
* Checks if the header contains cookies const cookieKeywords = ["Cookie", "Set-Cookie", "Cookie2", "Set-Cookie2"]
* @param req The request to inspect
* @returns The inspector results
*/
getInspectorFor(req: HoppRESTRequest): InspectorResult[] {
const results = ref<InspectorResult[]>([])
const cookiesCheck = (headerKey: string) => { return cookieKeywords.includes(headerKey)
const cookieKeywords = ["Cookie", "Set-Cookie", "Cookie2", "Set-Cookie2"] }
return cookieKeywords.includes(headerKey) getInspections(req: Readonly<Ref<HoppRESTRequest>>) {
} return computed(() => {
const results: InspectorResult[] = []
const headers = req.headers const headers = req.value.headers
const headerKeys = Object.values(headers).map((header) => header.key) const headerKeys = Object.values(headers).map((header) => header.key)
const isContainCookies = headerKeys.includes("Cookie") const isContainCookies = headerKeys.includes("Cookie")
if (isContainCookies) { if (isContainCookies) {
headerKeys.forEach((headerKey, index) => { headerKeys.forEach((headerKey, index) => {
if (cookiesCheck(headerKey)) { if (this.cookiesCheck(headerKey)) {
results.value.push({ results.push({
id: "header", id: "header",
icon: markRaw(IconAlertTriangle), icon: markRaw(IconAlertTriangle),
text: { text: {
type: "text", type: "text",
text: this.t("inspections.header.cookie"), text: this.t("inspections.header.cookie"),
}, },
severity: 2, severity: 2,
isApplicable: true, isApplicable: true,
locations: { locations: {
type: "header", type: "header",
position: "key", position: "key",
key: headerKey, key: headerKey,
index: index, index: index,
}, },
doc: { doc: {
text: this.t("action.learn_more"), text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/", link: "https://docs.hoppscotch.io/",
}, },
}) })
} }
}) })
} }
return results.value return results
})
} }
} }

View File

@@ -2,9 +2,11 @@ import { Service } from "dioc"
import { InspectionService, Inspector, InspectorResult } from ".." import { InspectionService, Inspector, InspectorResult } from ".."
import { getI18n } from "~/modules/i18n" import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { markRaw, ref } from "vue" import { markRaw } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle" import IconAlertTriangle from "~icons/lucide/alert-triangle"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { Ref } from "vue"
import { computed } from "vue"
/** /**
* This inspector is responsible for inspecting the response of a request. * This inspector is responsible for inspecting the response of a request.
@@ -27,47 +29,50 @@ export class ResponseInspectorService extends Service implements Inspector {
this.inspection.registerInspector(this) this.inspection.registerInspector(this)
} }
getInspectorFor( getInspections(
req: HoppRESTRequest, _req: Readonly<Ref<HoppRESTRequest>>,
res: HoppRESTResponse | undefined res: Readonly<Ref<HoppRESTResponse | null | undefined>>
): InspectorResult[] { ) {
const results = ref<InspectorResult[]>([]) return computed(() => {
if (!res) return results.value const results: InspectorResult[] = []
if (!res.value) return results
const hasErrors = res && (res.type !== "success" || res.statusCode !== 200) const hasErrors =
res && (res.value.type !== "success" || res.value.statusCode !== 200)
let text let text: string | undefined = undefined
if (res.type === "network_fail") { if (res.value.type === "network_fail" && !navigator.onLine) {
text = this.t("inspections.response.network_error") text = this.t("inspections.response.network_error")
} else if (res.type === "fail") { } else if (res.value.type === "fail") {
text = this.t("inspections.response.default_error") text = this.t("inspections.response.default_error")
} else if (res.type === "success" && res.statusCode === 404) { } else if (res.value.type === "success" && res.value.statusCode === 404) {
text = this.t("inspections.response.404_error") text = this.t("inspections.response.404_error")
} else if (res.type === "success" && res.statusCode === 401) { } else if (res.value.type === "success" && res.value.statusCode === 401) {
text = this.t("inspections.response.401_error") text = this.t("inspections.response.401_error")
} }
if (hasErrors && text) { if (hasErrors && text) {
results.value.push({ results.push({
id: "url", id: "url",
icon: markRaw(IconAlertTriangle), icon: markRaw(IconAlertTriangle),
text: { text: {
type: "text", type: "text",
text: text, text: text,
}, },
severity: 2, severity: 2,
isApplicable: true, isApplicable: true,
locations: { locations: {
type: "response", type: "response",
}, },
doc: { doc: {
text: this.t("action.learn_more"), text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/", link: "https://docs.hoppscotch.io/",
}, },
}) })
} }
return results.value return results
})
} }
} }

View File

@@ -1,96 +0,0 @@
import { Service } from "dioc"
import { InspectionService, Inspector, InspectorResult } from ".."
import { getI18n } from "~/modules/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { computed, markRaw, ref } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import { useReadonlyStream } from "~/composables/stream"
import { extensionStatus$ } from "~/newstore/HoppExtension"
import { useSetting } from "~/composables/settings"
import { applySetting, toggleSetting } from "~/newstore/settings"
/**
* This inspector is responsible for inspecting the URL of a request.
* It checks if the URL contains localhost and if the extension is installed.
* It also provides an action to enable the extension.
*
* NOTE: Initializing this service registers it as a inspector with the Inspection Service.
*/
export class URLInspectorService extends Service implements Inspector {
public static readonly ID = "URL_INSPECTOR_SERVICE"
private t = getI18n()
public readonly inspectorID = "url"
private readonly inspection = this.bind(InspectionService)
constructor() {
super()
this.inspection.registerInspector(this)
}
getInspectorFor(req: HoppRESTRequest): InspectorResult[] {
const PROXY_ENABLED = useSetting("PROXY_ENABLED")
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
const isExtensionInstalled = computed(() => {
return currentExtensionStatus.value === "available"
})
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
const results = ref<InspectorResult[]>([])
const url = req.endpoint
const isContainLocalhost = url.includes("localhost")
if (
isContainLocalhost &&
(!EXTENSIONS_ENABLED.value || !isExtensionInstalled.value)
) {
let text
if (!isExtensionInstalled.value) {
if (currentExtensionStatus.value === "unknown-origin") {
text = this.t("inspections.url.extension_unknown_origin")
} else {
text = this.t("inspections.url.extension_not_installed")
}
} else if (!EXTENSIONS_ENABLED.value) {
text = this.t("inspections.url.extention_not_enabled")
} else {
text = this.t("inspections.url.localhost")
}
results.value.push({
id: "url",
icon: markRaw(IconAlertTriangle),
text: {
type: "text",
text: text,
},
action: {
text: this.t("inspections.url.extention_enable_action"),
apply: () => {
applySetting("EXTENSIONS_ENABLED", true)
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
},
},
severity: 2,
isApplicable: true,
locations: {
type: "url",
},
doc: {
text: this.t("action.learn_more"),
link: "https://docs.hoppscotch.io/",
},
})
}
return results.value
}
}

View File

@@ -7,6 +7,7 @@ import { def as historyDef } from "./platform/history/history.platform"
import { def as tabStateDef } from "./platform/tabState/tabState.platform" import { def as tabStateDef } from "./platform/tabState/tabState.platform"
import { browserInterceptor } from "@hoppscotch/common/platform/std/interceptors/browser" import { browserInterceptor } from "@hoppscotch/common/platform/std/interceptors/browser"
import { proxyInterceptor } from "@hoppscotch/common/platform/std/interceptors/proxy" import { proxyInterceptor } from "@hoppscotch/common/platform/std/interceptors/proxy"
import { ExtensionInspectorService } from "@hoppscotch/common/platform/std/inspections/extension.inspector"
import { ExtensionInterceptorService } from "@hoppscotch/common/platform/std/interceptors/extension" import { ExtensionInterceptorService } from "@hoppscotch/common/platform/std/interceptors/extension"
import { stdFooterItems } from "@hoppscotch/common/platform/std/ui/footerItem" import { stdFooterItems } from "@hoppscotch/common/platform/std/ui/footerItem"
import { stdSupportOptionItems } from "@hoppscotch/common/platform/std/ui/supportOptionsItem" import { stdSupportOptionItems } from "@hoppscotch/common/platform/std/ui/supportOptionsItem"
@@ -32,6 +33,9 @@ createHoppApp("#app", {
{ type: "service", service: ExtensionInterceptorService }, { type: "service", service: ExtensionInterceptorService },
], ],
}, },
additionalInspectors: [
{ type: "service", service: ExtensionInspectorService },
],
platformFeatureFlags: { platformFeatureFlags: {
exportAsGIST: false, exportAsGIST: false,
}, },