mirror of
https://github.com/hoppscotch/hoppscotch.git
synced 2026-10-24 12:11:35 +08:00
fix: tighten the offline command parser
Renames, method, interceptor and collection-name commands no longer fire on loose matches.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { parseAppActionCommand, splitCommands } from "../app-actions"
|
||||
|
||||
describe("set_interceptor", () => {
|
||||
it.each([
|
||||
["select interceptor browser", "browser"],
|
||||
["select interceptor proxy", "proxy"],
|
||||
["switch interceptor native", "native"],
|
||||
["set the interceptor to the browser", "browser"],
|
||||
["select the proxy interceptor", "proxy"],
|
||||
["use the extension agent", "extension"],
|
||||
["switch to agent", "agent"],
|
||||
["use proxy", "proxy"],
|
||||
])("reads the interceptor from %s", (text, interceptor) => {
|
||||
expect(parseAppActionCommand(text)).toEqual({
|
||||
name: "set_interceptor",
|
||||
input: { interceptor },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
"set header User-Agent: foo",
|
||||
"set header Proxy-Authorization: Basic abc",
|
||||
])("leaves the header edit %s alone", (text) => {
|
||||
expect(parseAppActionCommand(text)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("create_collection", () => {
|
||||
it.each([
|
||||
"create a collection for the auth flows",
|
||||
"create a collection with my requests",
|
||||
"make a new collection",
|
||||
])("needs a name for %s", (text) => {
|
||||
expect(parseAppActionCommand(text)?.name).not.toBe("create_collection")
|
||||
})
|
||||
|
||||
it.each([
|
||||
["create a collection called Auth flows", "Auth flows"],
|
||||
["create collection Users", "Users"],
|
||||
["create a collection for login named Login", "Login"],
|
||||
['create a collection "for the win"', "for the win"],
|
||||
])("names the collection from %s", (text, name) => {
|
||||
expect(parseAppActionCommand(text)).toEqual({
|
||||
name: "create_collection",
|
||||
input: { name },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("splitCommands", () => {
|
||||
it("splits a tab action from the run", () => {
|
||||
expect(splitCommands("open a new tab and run the request")).toEqual([
|
||||
"open a new tab",
|
||||
"run the request",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { parseRawKeyValueEntries } from "@hoppscotch/data"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { applyToolCall } from "../commands"
|
||||
import { applyToolCall, runChatCommand, toRawKeyValueLines } from "../commands"
|
||||
|
||||
const withBody = (body: string, contentType = "application/json") => ({
|
||||
...getDefaultRESTRequest(),
|
||||
@@ -68,3 +69,122 @@ describe("set_body", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("toRawKeyValueLines", () => {
|
||||
it("escapes decoded values the raw parser would split", () => {
|
||||
const lines = toRawKeyValueLines("note=line1%0Aline2&a%3Ab=1&plain=x")
|
||||
|
||||
expect(parseRawKeyValueEntries(lines)).toEqual([
|
||||
{ key: "note", value: "line1\nline2", active: true },
|
||||
{ key: "a:b", value: "1", active: true },
|
||||
{ key: "plain", value: "x", active: true },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps simple pairs readable", () => {
|
||||
expect(toRawKeyValueLines("a=1&b=two+words")).toBe("a: 1\nb: two words")
|
||||
})
|
||||
})
|
||||
|
||||
describe("runChatCommand rename", () => {
|
||||
const req = () => ({
|
||||
...getDefaultRESTRequest(),
|
||||
headers: [{ key: "X-Old", value: "v", active: true, description: "" }],
|
||||
})
|
||||
|
||||
it.each([
|
||||
"rename header X-Old to X-New",
|
||||
"set the name of header X-Old to X-New",
|
||||
"rename the collection to Auth",
|
||||
"rename param page to p",
|
||||
])("leaves the request alone for %s", (text) => {
|
||||
const r = req()
|
||||
const res = runChatCommand(r, text)
|
||||
|
||||
expect(res.handled).toBe(false)
|
||||
expect(r.name).toBe("Untitled")
|
||||
expect(r.headers).toEqual(req().headers)
|
||||
})
|
||||
|
||||
it.each([
|
||||
["rename the request to Get users", "Get users"],
|
||||
["rename to Login", "Login"],
|
||||
["rename it to `List items`", "List items"],
|
||||
["set the request name to Delete user", "Delete user"],
|
||||
["change the name of this request to Fetch", "Fetch"],
|
||||
["set the name to Search", "Search"],
|
||||
])("renames the request for %s", (text, name) => {
|
||||
const r = req()
|
||||
const res = runChatCommand(r, text)
|
||||
|
||||
expect(res.changed).toBe(true)
|
||||
expect(r.name).toBe(name)
|
||||
expect(r.method).toBe("GET")
|
||||
})
|
||||
})
|
||||
|
||||
describe("runChatCommand method", () => {
|
||||
it("sets the URL without reading a verb out of it", () => {
|
||||
const r = getDefaultRESTRequest()
|
||||
runChatCommand(
|
||||
r,
|
||||
"set the request url to https://api.example.com/delete-item"
|
||||
)
|
||||
|
||||
expect(r.method).toBe("GET")
|
||||
expect(r.endpoint).toBe("https://api.example.com/delete-item")
|
||||
})
|
||||
|
||||
it.each([
|
||||
["set method to post", "POST"],
|
||||
["change the HTTP method to PUT", "PUT"],
|
||||
["use the DELETE method", "DELETE"],
|
||||
["make the request a PATCH", "PATCH"],
|
||||
["make it a POST request", "POST"],
|
||||
["switch to HEAD", "HEAD"],
|
||||
["set request to POST", "POST"],
|
||||
["change request to PUT", "PUT"],
|
||||
["use POST for this request", "POST"],
|
||||
])("sets the method for %s", (text, method) => {
|
||||
const r = getDefaultRESTRequest()
|
||||
const res = runChatCommand(r, text)
|
||||
|
||||
expect(res.changed).toBe(true)
|
||||
expect(r.method).toBe(method)
|
||||
})
|
||||
|
||||
it("does not read a method from a header edit", () => {
|
||||
const r = getDefaultRESTRequest()
|
||||
runChatCommand(r, "set header X-HTTP-Method-Override: PUT")
|
||||
|
||||
expect(r.method).toBe("GET")
|
||||
expect(r.headers).toEqual([
|
||||
{
|
||||
key: "X-HTTP-Method-Override",
|
||||
value: "PUT",
|
||||
active: true,
|
||||
description: "",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("reports an unchanged method as no change", () => {
|
||||
const r = getDefaultRESTRequest()
|
||||
|
||||
expect(runChatCommand(r, "set method to GET").changed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("set_method", () => {
|
||||
it("reports no change when the method already matches", () => {
|
||||
const r = getDefaultRESTRequest()
|
||||
|
||||
expect(applyToolCall(r, "set_method", { method: "get" }).changed).toBe(
|
||||
false
|
||||
)
|
||||
expect(applyToolCall(r, "set_method", { method: "POST" }).changed).toBe(
|
||||
true
|
||||
)
|
||||
expect(r.method).toBe("POST")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,15 +125,19 @@ export function parseAppActionCommand(text: string): AppActionCall | null {
|
||||
|
||||
// Interceptor / connection agent change
|
||||
if (
|
||||
/\binterceptor\b|\bagent\b|\bproxy\b/i.test(t) &&
|
||||
/\binterceptor\b|(?<![\w-])(?:agent|proxy)(?![\w-])/i.test(t) &&
|
||||
/\b(?:use|switch|change|set|select)\b/i.test(t)
|
||||
) {
|
||||
const hint = t
|
||||
.replace(/.*\b(?:to|use)\b\s*/i, "")
|
||||
.replace(/\binterceptor\b/gi, "")
|
||||
let hint = t
|
||||
.replace(/[.!?]+$/, "")
|
||||
.replace(/^(?:the|a|an)\s+/i, "")
|
||||
.replace(/.*\b(?:to|use)\b\s*/i, "")
|
||||
.replace(/^(?:switch|change|set|select)\b\s*/i, "")
|
||||
.replace(/\binterceptors?\b/gi, " ")
|
||||
.replace(/^\s*(?:the|a|an)\s+/i, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
// "Agent" and "Proxy" are interceptor names too: drop them only as a noun.
|
||||
if (hint.includes(" ")) hint = hint.replace(/\s+(?:agent|proxy)$/i, "")
|
||||
return { name: "set_interceptor", input: { interceptor: hint } }
|
||||
}
|
||||
|
||||
@@ -157,14 +161,32 @@ export function parseAppActionCommand(text: string): AppActionCall | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Collection: create
|
||||
const createColl = looksLikeQuestion
|
||||
? null
|
||||
: t.match(
|
||||
/\b(?:create|new|make)\b[^]*?\bcollection\b(?:\s+(?:called|named))?\s*["'`]?([\w][\w .-]*?)["'`]?\s*[.!?]*$/i
|
||||
)
|
||||
if (createColl && !isEnvStopword(createColl[1].trim())) {
|
||||
return { name: "create_collection", input: { name: createColl[1].trim() } }
|
||||
// Collection: create. A bare "collection for …" names no collection.
|
||||
// Separate matches keep the lazy scans from nesting on long input.
|
||||
if (
|
||||
!looksLikeQuestion &&
|
||||
/\b(?:create|new|make)\b[^]*?\bcollection\b/i.test(t)
|
||||
) {
|
||||
const named = t.match(
|
||||
/\bcollection\b[^]*?\b(?:called|named)\s+(["'`]?)([\w][\w .-]*?)["'`]?\s*[.!?]*$/i
|
||||
)
|
||||
const bare = named
|
||||
? null
|
||||
: t.match(
|
||||
/\b(?:create|new|make)\b[^]*?\bcollection\s+(["'`]?)([\w][\w .-]*?)["'`]?\s*[.!?]*$/i
|
||||
)
|
||||
const collName = (named ?? bare)?.[2].trim() ?? ""
|
||||
if (
|
||||
(named ||
|
||||
(bare &&
|
||||
(bare[1] ||
|
||||
!/^(?:for|to|with|of|about|from|in|into|on|that|which|containing|using|called|named)\b/i.test(
|
||||
collName
|
||||
)))) &&
|
||||
!isEnvStopword(collName)
|
||||
) {
|
||||
return { name: "create_collection", input: { name: collName } }
|
||||
}
|
||||
}
|
||||
|
||||
// Collection: save the current request into one
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type {
|
||||
HoppGQLRequest,
|
||||
HoppRESTRequest,
|
||||
HoppRESTReqBody,
|
||||
import {
|
||||
rawKeyValueEntriesToString,
|
||||
type HoppGQLRequest,
|
||||
type HoppRESTRequest,
|
||||
type HoppRESTReqBody,
|
||||
} from "@hoppscotch/data"
|
||||
|
||||
export interface ChatCommandResult {
|
||||
@@ -139,16 +140,18 @@ export function toRawKeyValueLines(body: string): string {
|
||||
return part
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
.split("&")
|
||||
.filter(Boolean)
|
||||
.map((pair) => {
|
||||
const eq = pair.indexOf("=")
|
||||
const key = eq === -1 ? pair : pair.slice(0, eq)
|
||||
const value = eq === -1 ? "" : pair.slice(eq + 1)
|
||||
return `${decode(key)}: ${decode(value)}`
|
||||
})
|
||||
.join("\n")
|
||||
// Escaped so a decoded newline or colon survives the raw parser.
|
||||
return rawKeyValueEntriesToString(
|
||||
trimmed
|
||||
.split("&")
|
||||
.filter(Boolean)
|
||||
.map((pair) => {
|
||||
const eq = pair.indexOf("=")
|
||||
const key = eq === -1 ? pair : pair.slice(0, eq)
|
||||
const value = eq === -1 ? "" : pair.slice(eq + 1)
|
||||
return { key: decode(key), value: decode(value), active: true }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,11 +219,15 @@ export function runChatCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// Rename request
|
||||
m = t.match(
|
||||
/\b(?:rename|(?:set|change|update)\s+(?:the\s+)?name)\b.*?(?:to|=|:)?\s*["'`]?(.+?)["'`]?$/i
|
||||
)
|
||||
if (m && /\b(?:rename|name)\b/i.test(t)) {
|
||||
// Rename request: only the request itself, or a bare "rename to …"
|
||||
m =
|
||||
t.match(
|
||||
/\brename\s+(?:(?:the|this|my|current)\s+)*(?:(?:request|tab|it)\s+)?(?:to|as)\s+["'`]?(.+?)["'`]?$/i
|
||||
) ??
|
||||
t.match(
|
||||
/\b(?:set|change|update)\s+(?:(?:the|this|my|current)\s+)*(?:(?:request|tab)(?:'s)?\s+)?name(?:\s+of\s+(?:(?:the|this|my|current)\s+)*(?:request|tab|it))?\s*(?:to|as|=|:)\s*["'`]?(.+?)["'`]?$/i
|
||||
)
|
||||
if (m) {
|
||||
if (!req) return NEED_REQUEST
|
||||
const reqName = m[1].trim()
|
||||
if (reqName) {
|
||||
@@ -232,6 +239,10 @@ export function runChatCommand(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Renaming a header, collection… isn't an offline edit.
|
||||
if (/^(?:rename|(?:set|change|update)\s+(?:the\s+)?name\s+of)\b/i.test(t)) {
|
||||
return { handled: false, reply: "" }
|
||||
}
|
||||
|
||||
// Remove header
|
||||
m = t.match(/\b(?:remove|delete|drop)\b.*?\bheader\b\s+["'`]?([\w.-]+)/i)
|
||||
@@ -273,21 +284,32 @@ export function runChatCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// Method
|
||||
// Method: names "method", or "make the request a POST" / "switch to GET"
|
||||
if (
|
||||
/\b(?:set|change|switch|make|use)\b/i.test(t) &&
|
||||
/\bmethod\b|\brequest\b/i.test(t)
|
||||
!/\b(?:url|endpoint|address|body|headers?|param(?:eter)?s?|variables?)\b/i.test(
|
||||
t
|
||||
)
|
||||
) {
|
||||
const mm = t
|
||||
.toUpperCase()
|
||||
.match(new RegExp(`\\b(${CHAT_HTTP_METHODS.join("|")})\\b`))
|
||||
const verbs = CHAT_HTTP_METHODS.join("|")
|
||||
const mm =
|
||||
t.match(new RegExp(`\\bmethod\\b[^]*?\\b(${verbs})\\b`, "i")) ??
|
||||
t.match(new RegExp(`\\b(${verbs})\\b[^]*?\\bmethod\\b`, "i")) ??
|
||||
t.match(
|
||||
new RegExp(
|
||||
`\\b(?:set|change|switch|make|use)\\s+(?:(?:(?:the|this)\\s+)?request\\s+|it\\s+|this\\s+)?(?:(?:to|as|into)\\s+)?(?:an?\\s+)?(${verbs})(?:\\s+(?:for\\s+(?:the\\s+|this\\s+)?)?request)?\\s*[.!]*$`,
|
||||
"i"
|
||||
)
|
||||
)
|
||||
if (mm) {
|
||||
if (!req) return NEED_REQUEST
|
||||
req.method = mm[1]
|
||||
const method = mm[1].toUpperCase()
|
||||
const changed = req.method !== method
|
||||
req.method = method
|
||||
return {
|
||||
handled: true,
|
||||
changed: true,
|
||||
reply: `✓ Set the method to ${code(mm[1])}.`,
|
||||
changed,
|
||||
reply: `✓ Set the method to ${code(method)}.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,10 +583,11 @@ export function applyToolCall(
|
||||
changed: false,
|
||||
reply: `Unsupported method ${code(method)}. Use one of: ${CHAT_HTTP_METHODS.join(", ")}.`,
|
||||
}
|
||||
const changed = req.method !== method
|
||||
req.method = method
|
||||
return {
|
||||
handled: true,
|
||||
changed: true,
|
||||
changed,
|
||||
reply: `✓ Set the method to ${code(method)}.`,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user