chore: merge hoppscotch/patch into hoppscotch/next

This commit is contained in:
James George 2026-02-18 11:34:09 +05:30
commit 28a6569f42
13 changed files with 876 additions and 300 deletions

View file

@ -369,6 +369,17 @@ const delegatePreRequestScriptRunner = (
const { preRequestScript } = request
const cleanScript = stripModulePrefix(preRequestScript)
// Short-circuit empty scripts to avoid unnecessary WASM initialization
if (cleanScript.trim().length === 0) {
return Promise.resolve(
E.right({
updatedEnvs: envs,
updatedCookies: cookies,
})
)
}
if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) {
// Strip `export {};\n` before executing in legacy sandbox to prevent syntax errors
@ -399,6 +410,19 @@ const runPostRequestScript = (
const { testScript } = request
const cleanScript = stripModulePrefix(testScript)
// Short-circuit empty scripts to avoid unnecessary WASM initialization
if (cleanScript.trim().length === 0) {
return Promise.resolve(
E.right({
tests: { descriptor: "root", expectResults: [], children: [] },
envs,
consoleEntries: [],
updatedCookies: cookies,
} satisfies SandboxTestResult)
)
}
if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) {
// Strip `export {};\n` before executing in legacy sandbox to prevent syntax errors
@ -481,7 +505,7 @@ export function runRESTRequest$(
if (cancelCalled) return E.left("cancellation" as const)
if (E.isLeft(preRequestScriptResult)) {
console.error(preRequestScriptResult.left)
console.error("[Pre-Request Script Error]", preRequestScriptResult.left)
return E.left("script_fail" as const)
}
@ -613,6 +637,11 @@ export function runRESTRequest$(
cookieJarService.cookieJar.value = newCookieMap
}
} else {
console.error(
"[Post-Request Script Error]",
postRequestScriptResult.left
)
tab.value.document.testResults = {
description: "",
expectResults: [],
@ -798,7 +827,7 @@ export async function runTestRunnerRequest(
cookieJarEntries
).then(async (preRequestScriptResult) => {
if (E.isLeft(preRequestScriptResult)) {
console.error(preRequestScriptResult.left)
console.error("[Pre-Request Script Error]", preRequestScriptResult.left)
return E.left("script_fail" as const)
}
@ -904,6 +933,13 @@ export async function runTestRunnerRequest(
updatedRequest: finalRequest,
})
}
// Post-request script failed
console.error(
"[Post-Request Script Error]",
postRequestScriptResult.left
)
const sandboxTestResult = {
description: "",
expectResults: [],

View file

@ -0,0 +1,206 @@
import { afterEach, describe, expect, test } from "vitest"
import { FaradayCage } from "faraday-cage"
import * as E from "fp-ts/Either"
import { runTest, fakeResponse, defaultRequest } from "~/utils/test-helpers"
import { runTestScript } from "~/web"
import { _setCagePromiseForTesting } from "~/utils/cage"
/**
* Verifies that the test runner properly recovers from script errors without
* stale state persisting across subsequent executions.
*/
describe("script error recovery", () => {
test("runtime error followed by valid script should not show stale error", async () => {
const errorScript = `
a(); // ReferenceError: a is not defined
hopp.test("Should not run", () => {
hopp.expect(hopp.response.statusCode).toBe(200);
});
`
const errorResult = await runTest(errorScript, {
global: [],
selected: [],
})()
expect(errorResult).toBeLeft()
const validScript = `
// a(); - commented out
hopp.test("Status code is 200", () => {
hopp.expect(hopp.response.statusCode).toBe(200);
});
`
const validResult = await runTest(validScript, {
global: [],
selected: [],
})()
expect(validResult).toEqualRight([
expect.objectContaining({
descriptor: "root",
children: [
expect.objectContaining({
descriptor: "Status code is 200",
expectResults: [
expect.objectContaining({
status: "pass",
message: expect.stringContaining("Expected '200' to be '200'"),
}),
],
}),
],
}),
])
})
test("multiple consecutive runtime errors should each be fresh", async () => {
const error1 = await runTest(`a();`, { global: [], selected: [] })()
expect(error1).toBeLeft()
const error2 = await runTest(`b();`, { global: [], selected: [] })()
expect(error2).toBeLeft()
const valid = await runTest(
`hopp.test("Works", () => { hopp.expect(true).toBe(true); });`,
{ global: [], selected: [] }
)()
expect(valid).toEqualRight([
expect.objectContaining({
descriptor: "root",
children: [
expect.objectContaining({
descriptor: "Works",
expectResults: [
expect.objectContaining({
status: "pass",
message: expect.stringContaining(
"Expected 'true' to be 'true'"
),
}),
],
}),
],
}),
])
})
test("syntax error followed by valid script should work", async () => {
const syntaxError = await runTest(`const x = ;`, {
global: [],
selected: [],
})()
expect(syntaxError).toBeLeft()
const valid = await runTest(
`hopp.test("Works", () => { hopp.expect(true).toBe(true); });`,
{ global: [], selected: [] }
)()
expect(valid).toEqualRight([
expect.objectContaining({
descriptor: "root",
children: [
expect.objectContaining({
descriptor: "Works",
expectResults: [
expect.objectContaining({
status: "pass",
message: expect.stringContaining(
"Expected 'true' to be 'true'"
),
}),
],
}),
],
}),
])
})
})
/**
* Exercises the production singleton path where a corrupted cage persists
* across calls. The retry-on-bootstrap-error logic should transparently
* recover so the user never sees the stale failure.
*/
describe("singleton cage retry on bootstrap error", () => {
afterEach(() => {
_setCagePromiseForTesting(null)
})
test("bootstrap error triggers retry on fresh cage", async () => {
const corruptedCage = await FaradayCage.create()
const originalRunCode = corruptedCage.runCode.bind(corruptedCage)
let callCount = 0
corruptedCage.runCode = ((...args: Parameters<typeof originalRunCode>) => {
callCount++
if (callCount === 1) {
// Simulate an infrastructure error on the first call
return Promise.resolve({
type: "error" as const,
err: new Error("cannot convert to object"),
})
}
return originalRunCode(...args)
}) as typeof originalRunCode
_setCagePromiseForTesting(Promise.resolve(corruptedCage))
const result = await runTestScript(
`hopp.test("Should work after retry", () => { hopp.expect(true).toBe(true); });`,
{
envs: { global: [], selected: [] },
request: defaultRequest,
response: fakeResponse,
cookies: null,
experimentalScriptingSandbox: true,
}
)
// The first call failed with an infra error, retry succeeded on a fresh cage
expect(callCount).toBe(1)
expect(E.isRight(result)).toBe(true)
if (E.isRight(result)) {
expect(result.right.tests).toEqual(
expect.objectContaining({
descriptor: "root",
children: [
expect.objectContaining({
descriptor: "Should work after retry",
expectResults: [expect.objectContaining({ status: "pass" })],
}),
],
})
)
}
})
test("user script errors do not trigger retry", async () => {
const cage = await FaradayCage.create()
const originalRunCode = cage.runCode.bind(cage)
let callCount = 0
cage.runCode = ((...args: Parameters<typeof originalRunCode>) => {
callCount++
return originalRunCode(...args)
}) as typeof originalRunCode
_setCagePromiseForTesting(Promise.resolve(cage))
const result = await runTestScript(`a();`, {
envs: { global: [], selected: [] },
request: defaultRequest,
response: fakeResponse,
cookies: null,
experimentalScriptingSandbox: true,
})
// User script error should NOT trigger retry — only one call to runCode
expect(E.isLeft(result)).toBe(true)
expect(callCount).toBe(1)
})
})

View file

@ -105,7 +105,7 @@ describe("hopp.request", () => {
})
).resolves.toEqualLeft(
expect.stringContaining(
`Script execution failed: hopp.request.${property} is read-only`
`Script execution failed: TypeError: hopp.request.${property} is read-only`
)
)
)
@ -124,7 +124,7 @@ describe("hopp.request", () => {
})
).resolves.toEqualLeft(
expect.stringContaining(
`Script execution failed: hopp.request.${property} is read-only`
`Script execution failed: TypeError: hopp.request.${property} is read-only`
)
)
)
@ -531,7 +531,9 @@ describe("hopp.request", () => {
}
)
).resolves.toEqualLeft(
expect.stringContaining(`Script execution failed: not a function`)
expect.stringContaining(
`Script execution failed: TypeError: not a function`
)
)
})

View file

@ -0,0 +1,50 @@
import { describe, expect, test } from "vitest"
import { isInfraError } from "~/utils/cage"
describe("isInfraError", () => {
test("identifies Error instances as infrastructure errors", () => {
expect(isInfraError(new Error("test error"))).toBe(true)
})
test("identifies Error subclasses as infrastructure errors", () => {
class QuickJSUnwrapError extends Error {
constructor(message: string) {
super(message)
this.name = "QuickJSUnwrapError"
}
}
expect(
isInfraError(new QuickJSUnwrapError("cannot convert to object"))
).toBe(true)
})
test("identifies WASM initialization errors", () => {
expect(isInfraError(new Error("wasm init failed"))).toBe(true)
})
test("does not classify plain objects from QuickJS dump() as infrastructure", () => {
// QuickJS dump() produces plain objects for user script errors — NOT Error instances
expect(
isInfraError({
name: "ReferenceError",
message: "a is not defined",
stack: " at <anonymous> (eval.js:1)\n",
})
).toBe(false)
expect(
isInfraError({
name: "TypeError",
message: "cannot convert to object",
stack: " at keys (native)\n at <anonymous> (eval.js:1)\n",
})
).toBe(false)
})
test("handles non-object and null errors gracefully", () => {
expect(isInfraError("string error")).toBe(false)
expect(isInfraError(null)).toBe(false)
expect(isInfraError(undefined)).toBe(false)
})
})

View file

@ -386,7 +386,7 @@ const createScriptingModule = (
type: ModuleType,
bootstrapCode: string,
config: ModuleConfig,
captureHook?: { capture?: () => void }
captureHook?: { capture?: () => void; bootstrapError?: unknown }
) => {
return defineCageModule((ctx) => {
// Track test promises for keepAlive (only for post-request scripts)
@ -479,13 +479,15 @@ const createScriptingModule = (
sandboxInputsObj
)
// Extract the test execution chain promise from the bootstrap function's return value
// Track bootstrap state for error detection
let testExecutionChainPromise: any = null
if (bootstrapResult.error) {
console.error(
"[SCRIPTING] Bootstrap function error:",
ctx.vm.dump(bootstrapResult.error)
)
const bootstrapError = ctx.vm.dump(bootstrapResult.error)
if (captureHook) {
captureHook.bootstrapError = bootstrapError
}
bootstrapResult.error.dispose()
} else if (bootstrapResult.value) {
testExecutionChainPromise = bootstrapResult.value
@ -539,11 +541,11 @@ const createScriptingModule = (
export const preRequestModule = (
config: PreRequestModuleConfig,
captureHook?: { capture?: () => void }
captureHook?: { capture?: () => void; bootstrapError?: unknown }
) => createScriptingModule("pre", preRequestBootstrapCode, config, captureHook)
export const postRequestModule = (
config: PostRequestModuleConfig,
captureHook?: { capture?: () => void }
captureHook?: { capture?: () => void; bootstrapError?: unknown }
) =>
createScriptingModule("post", postRequestBootstrapCode, config, captureHook)

View file

@ -1,11 +1,87 @@
import { Cookie, HoppRESTRequest } from "@hoppscotch/data"
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/lib/TaskEither"
import { cloneDeep } from "lodash"
import { defaultModules, preRequestModule } from "~/cage-modules"
import { HoppFetchHook, SandboxPreRequestResult, TestResult } from "~/types"
import { acquireCage } from "~/utils/cage"
import { acquireCage, resetCage, isInfraError } from "~/utils/cage"
/**
* Runs a pre-request script on the given cage instance.
* Returns the result or "retry" if a bootstrap error triggered a cage reset.
*/
const executePreRequestOnCage = async (
cage: Awaited<ReturnType<typeof acquireCage>>,
preRequestScript: string,
envs: TestResult["envs"],
request: HoppRESTRequest,
cookies: Cookie[] | null,
hoppFetchHook?: HoppFetchHook
): Promise<E.Either<string, SandboxPreRequestResult> | "retry"> => {
let finalEnvs = envs
let finalRequest = request
let finalCookies = cookies
const captureHook: { capture?: () => void; bootstrapError?: unknown } = {}
const result = await cage.runCode(preRequestScript, [
...defaultModules({
hoppFetchHook,
}),
preRequestModule(
{
envs: cloneDeep(envs),
request: cloneDeep(request),
cookies: cookies ? cloneDeep(cookies) : null,
handleSandboxResults: ({ envs, request, cookies }) => {
finalEnvs = envs
finalRequest = request
finalCookies = cookies
},
},
captureHook
),
])
if (result.type === "error") {
const bootstrapFailed = captureHook.bootstrapError !== undefined
const errorToAnalyze = bootstrapFailed
? captureHook.bootstrapError
: result.err
if (bootstrapFailed || isInfraError(errorToAnalyze)) {
resetCage()
return "retry"
}
if (
result.err !== null &&
typeof result.err === "object" &&
"message" in result.err
) {
const name =
"name" in result.err && typeof result.err.name === "string"
? result.err.name
: ""
const prefix = name ? `${name}: ` : ""
return E.left(`Script execution failed: ${prefix}${result.err.message}`)
}
return E.left(`Script execution failed: ${String(result.err)}`)
}
if (captureHook.capture) {
captureHook.capture()
}
return E.right({
updatedEnvs: finalEnvs,
updatedRequest: finalRequest,
updatedCookies: finalCookies,
})
}
export const runPreRequestScriptWithFaradayCage = (
preRequestScript: string,
@ -14,64 +90,47 @@ export const runPreRequestScriptWithFaradayCage = (
cookies: Cookie[] | null,
hoppFetchHook?: HoppFetchHook
): TE.TaskEither<string, SandboxPreRequestResult> => {
return pipe(
TE.tryCatch(
async (): Promise<SandboxPreRequestResult> => {
let finalEnvs = envs
let finalRequest = request
let finalCookies = cookies
return () =>
(async () => {
try {
const cage = await acquireCage()
try {
const captureHook: { capture?: () => void } = {}
const firstAttempt = await executePreRequestOnCage(
cage,
preRequestScript,
envs,
request,
cookies,
hoppFetchHook
)
const result = await cage.runCode(preRequestScript, [
...defaultModules({
hoppFetchHook,
}),
preRequestModule(
{
envs: cloneDeep(envs),
request: cloneDeep(request),
cookies: cookies ? cloneDeep(cookies) : null,
handleSandboxResults: ({ envs, request, cookies }) => {
finalEnvs = envs
finalRequest = request
finalCookies = cookies
},
},
captureHook
),
])
if (captureHook.capture) {
captureHook.capture()
}
if (result.type === "error") {
throw result.err
}
return {
updatedEnvs: finalEnvs,
updatedRequest: finalRequest,
updatedCookies: finalCookies,
}
} finally {
// Don't dispose cage here - returned objects may still be accessed.
// Rely on garbage collection for cleanup.
}
},
(error) => {
if (error !== null && typeof error === "object" && "message" in error) {
const reason = `${"name" in error ? error.name : ""}: ${error.message}`
return `Script execution failed: ${reason}`
if (firstAttempt !== "retry") {
return firstAttempt
}
return `Script execution failed: ${String(error)}`
// Bootstrap error detected and cage was reset — retry once on a fresh cage
const freshCage = await acquireCage()
const retryResult = await executePreRequestOnCage(
freshCage,
preRequestScript,
envs,
request,
cookies,
hoppFetchHook
)
if (retryResult === "retry") {
return E.left(
"Script execution failed: sandbox initialization error (persistent)"
)
}
return retryResult
} catch (error) {
const name =
error instanceof Error && error.name ? `${error.name}: ` : ""
const message = error instanceof Error ? error.message : String(error)
return E.left(`Script execution failed: ${name}${message}`)
}
)
)
})()
}

View file

@ -1,6 +1,6 @@
import { HoppRESTRequest } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { cloneDeep } from "lodash"
import { defaultModules, postRequestModule } from "~/cage-modules"
@ -10,7 +10,117 @@ import {
TestResponse,
TestResult,
} from "~/types"
import { acquireCage } from "~/utils/cage"
import { acquireCage, resetCage, isInfraError } from "~/utils/cage"
/**
* Runs a post-request/test script on the given cage instance.
* Returns the result or "retry" if a bootstrap error triggered a cage reset.
*/
const executeTestOnCage = async (
cage: Awaited<ReturnType<typeof acquireCage>>,
testScript: string,
envs: TestResult["envs"],
request: HoppRESTRequest,
response: TestResponse,
hoppFetchHook?: HoppFetchHook
): Promise<E.Either<string, TestResult> | "retry"> => {
const testRunStack: TestDescriptor[] = [
{ descriptor: "root", expectResults: [], children: [] },
]
let finalEnvs = envs
let finalTestResults = testRunStack
const testPromises: Promise<void>[] = []
const captureHook: { capture?: () => void; bootstrapError?: unknown } = {}
const result = await cage.runCode(testScript, [
...defaultModules({
hoppFetchHook,
}),
postRequestModule(
{
envs: cloneDeep(envs),
testRunStack: cloneDeep(testRunStack),
request: cloneDeep(request),
response: cloneDeep(response),
// TODO: Post type update, accommodate for cookies although platform support is limited
cookies: null,
handleSandboxResults: ({ envs, testRunStack }) => {
finalEnvs = envs
finalTestResults = testRunStack
},
onTestPromise: (promise) => {
testPromises.push(promise)
},
},
captureHook
),
])
if (result.type === "error") {
const bootstrapFailed = captureHook.bootstrapError !== undefined
const errorToAnalyze = bootstrapFailed
? captureHook.bootstrapError
: result.err
if (bootstrapFailed || isInfraError(errorToAnalyze)) {
resetCage()
return "retry"
}
if (
result.err !== null &&
typeof result.err === "object" &&
"message" in result.err
) {
const name =
"name" in result.err && typeof result.err.name === "string"
? result.err.name
: ""
const prefix = name ? `${name}: ` : ""
return E.left(`Script execution failed: ${prefix}${result.err.message}`)
}
return E.left(`Script execution failed: ${String(result.err)}`)
}
// Execute tests sequentially to support dependent tests that share variables.
if (testPromises.length > 0) {
for (let i = 0; i < testPromises.length; i++) {
await testPromises[i]
}
}
if (captureHook.capture) {
captureHook.capture()
}
// Check for uncaught runtime errors (ReferenceError, TypeError, etc.) in test callbacks.
// These should fail the entire test run, NOT be reported as testcases.
const runtimeErrors = finalTestResults
.flatMap((t) => t.children)
.flatMap((child) => child.expectResults || [])
.filter(
(r) =>
r.status === "error" &&
/^(ReferenceError|TypeError|SyntaxError|RangeError|URIError|EvalError|AggregateError|InternalError|Error):/.test(
r.message
)
)
if (runtimeErrors.length > 0) {
return E.left(`Script execution failed: ${runtimeErrors[0].message}`)
}
const safeTestResults = cloneDeep(finalTestResults)
const safeEnvs = cloneDeep(finalEnvs)
return E.right({
tests: safeTestResults,
envs: safeEnvs,
})
}
export const runPostRequestScriptWithFaradayCage = (
testScript: string,
@ -19,111 +129,47 @@ export const runPostRequestScriptWithFaradayCage = (
response: TestResponse,
hoppFetchHook?: HoppFetchHook
): TE.TaskEither<string, TestResult> => {
return pipe(
TE.tryCatch(
async (): Promise<TestResult> => {
const testRunStack: TestDescriptor[] = [
{ descriptor: "root", expectResults: [], children: [] },
]
let finalEnvs = envs
let finalTestResults = testRunStack
const testPromises: Promise<void>[] = []
return () =>
(async () => {
try {
const cage = await acquireCage()
// Wrap entire execution in try-catch to handle QuickJS GC errors that can occur at any point
try {
const captureHook: { capture?: () => void } = {}
const firstAttempt = await executeTestOnCage(
cage,
testScript,
envs,
request,
response,
hoppFetchHook
)
const result = await cage.runCode(testScript, [
...defaultModules({
hoppFetchHook,
}),
postRequestModule(
{
envs: cloneDeep(envs),
testRunStack: cloneDeep(testRunStack),
request: cloneDeep(request),
response: cloneDeep(response),
// TODO: Post type update, accommodate for cookies although platform support is limited
cookies: null,
handleSandboxResults: ({ envs, testRunStack }) => {
finalEnvs = envs
finalTestResults = testRunStack
},
onTestPromise: (promise) => {
testPromises.push(promise)
},
},
captureHook
),
])
// Check for script execution errors first
if (result.type === "error") {
// Just throw the error - it will be wrapped by the TaskEither error handler
throw result.err
}
// Execute tests sequentially to support dependent tests that share variables.
// Concurrent execution would cause race conditions when tests rely on values
// from earlier tests (e.g., authToken set in one test, used in another).
if (testPromises.length > 0) {
// Execute each test promise one at a time, waiting for completion
for (let i = 0; i < testPromises.length; i++) {
await testPromises[i]
}
}
// Capture results AFTER all async tests complete
// This prevents showing intermediate/failed state
if (captureHook.capture) {
captureHook.capture()
}
// Check for uncaught runtime errors (ReferenceError, TypeError, etc.) in test callbacks
// These should fail the entire test run, NOT be reported as testcases
// Validation errors (invalid assertion arguments) don't have "Error:" prefix - they're descriptive
// Examples: "Expected toHaveLength to be called for an array or string"
const runtimeErrors = finalTestResults
.flatMap((t) => t.children)
.flatMap((child) => child.expectResults || [])
.filter(
(r) =>
r.status === "error" &&
/^(ReferenceError|TypeError|SyntaxError|RangeError|URIError|EvalError|AggregateError|InternalError|Error):/.test(
r.message
)
)
if (runtimeErrors.length > 0) {
// Throw the runtime error directly (message already contains error type)
throw runtimeErrors[0].message
}
// Deep clone results to break connection to QuickJS runtime objects,
// preventing GC errors when runtime is freed.
const safeTestResults = cloneDeep(finalTestResults)
const safeEnvs = cloneDeep(finalEnvs)
return {
tests: safeTestResults,
envs: safeEnvs,
}
} finally {
// Don't dispose cage here - returned objects may still be accessed.
// Rely on garbage collection for cleanup.
}
},
(error) => {
if (error !== null && typeof error === "object" && "message" in error) {
const reason = `${"name" in error ? error.name : ""}: ${error.message}`
return `Script execution failed: ${reason}`
if (firstAttempt !== "retry") {
return firstAttempt
}
return `Script execution failed: ${String(error)}`
// Bootstrap error detected and cage was reset — retry once on a fresh cage
const freshCage = await acquireCage()
const retryResult = await executeTestOnCage(
freshCage,
testScript,
envs,
request,
response,
hoppFetchHook
)
if (retryResult === "retry") {
return E.left(
"Script execution failed: sandbox initialization error (persistent)"
)
}
return retryResult
} catch (error) {
const name =
error instanceof Error && error.name ? `${error.name}: ` : ""
const message = error instanceof Error ? error.message : String(error)
return E.left(`Script execution failed: ${name}${message}`)
}
)
)
})()
}

View file

@ -162,7 +162,9 @@ export type TestResult = {
export type GlobalEnvItem = TestResult["envs"]["global"][number]
export type SelectedEnvItem = TestResult["envs"]["selected"][number]
export type SandboxTestResult = TestResult & { tests: TestDescriptor } & {
export type SandboxTestResult = {
tests: TestDescriptor
envs: TestResult["envs"]
consoleEntries?: ConsoleEntry[]
updatedCookies: Cookie[] | null
}

View file

@ -1,26 +1,69 @@
import { FaradayCage } from "faraday-cage"
// Cached cage instance to avoid repeated WASM module allocations.
let cachedCage: FaradayCage | null = null
let cagePromise: Promise<FaradayCage> | null = null
// Detect if running in a test environment
const isTestEnvironment =
typeof process !== "undefined" && process.env.VITEST === "true"
/**
* Returns a FaradayCage instance, creating and caching it on first access.
* In test environments, always creates a fresh cage to avoid QuickJS GC corruption.
* Determines if an error indicates an infrastructure failure (not a user script error).
*
* FaradayCage/QuickJS errors arrive in two shapes:
*
* 1. **User script errors** `cage.runCode()` returns `{ type: "error" }` where
* `result.err` is a plain object from QuickJS `dump()` (NOT `instanceof Error`).
*
* 2. **Infrastructure errors** Thrown by host-side module setup (e.g.
* `QuickJSUnwrapError`, marshal failures, WASM init). These are real
* `Error` instances.
*
* `instanceof Error` reliably discriminates between the two.
*/
export const isInfraError = (err: unknown): boolean => err instanceof Error
export const resetCage = (): void => {
cagePromise = null
}
/**
* Returns a cached FaradayCage singleton (production) or a fresh instance (tests).
*
* In test environments, a fresh cage is created by default. Tests that need to
* exercise the singleton/retry path can override this via `_setCagePromiseForTesting()`.
*/
export const acquireCage = async (): Promise<FaradayCage> => {
// In test environments, create a fresh cage to avoid GC corruption
if (isTestEnvironment) {
if (cagePromise) {
return cagePromise.catch((err) => {
cagePromise = null
throw err
})
}
return FaradayCage.create()
}
// In production, cache the cage for performance
if (!cachedCage) {
cachedCage = await FaradayCage.create()
if (!cagePromise) {
cagePromise = FaradayCage.create().catch((err) => {
cagePromise = null
throw err
})
}
return cachedCage
return cagePromise
}
/**
* Injects a cage promise into the singleton slot. Test-only allows tests to
* exercise the singleton/retry path that is normally skipped in test environments.
*/
export const _setCagePromiseForTesting = (
promise: Promise<FaradayCage> | null
): void => {
if (!isTestEnvironment) {
throw new Error(
"_setCagePromiseForTesting is test-only and cannot be used in non-test environments"
)
}
cagePromise = promise
}

View file

@ -9,7 +9,7 @@ import {
} from "~/types"
import { defaultModules, preRequestModule } from "~/cage-modules"
import { acquireCage } from "~/utils/cage"
import { acquireCage, resetCage, isInfraError } from "~/utils/cage"
import { Cookie, HoppRESTRequest } from "@hoppscotch/data"
import Worker from "./worker?worker&inline"
@ -35,6 +35,86 @@ const runPreRequestScriptWithWebWorker = (
})
}
/**
* Runs a pre-request script on the given cage instance.
* Returns the result (`Either<string, SandboxPreRequestResult>`) or the string literal "retry"
* if a bootstrap error triggered a cage reset (caller should retry).
*/
const executePreRequestOnCage = async (
cage: Awaited<ReturnType<typeof acquireCage>>,
preRequestScript: string,
envs: TestResult["envs"],
request: HoppRESTRequest,
cookies: Cookie[] | null,
hoppFetchHook?: HoppFetchHook
): Promise<E.Either<string, SandboxPreRequestResult> | "retry"> => {
const consoleEntries: ConsoleEntry[] = []
let finalEnvs = envs
let finalRequest = request
let finalCookies = cookies
const captureHook: { capture?: () => void; bootstrapError?: unknown } = {}
const result = await cage.runCode(preRequestScript, [
...defaultModules({
handleConsoleEntry: (consoleEntry) => consoleEntries.push(consoleEntry),
hoppFetchHook,
}),
preRequestModule(
{
envs: cloneDeep(envs),
request: cloneDeep(request),
cookies: cookies ? cloneDeep(cookies) : null,
handleSandboxResults: ({ envs, request, cookies }) => {
finalEnvs = envs
finalRequest = request
finalCookies = cookies
},
},
captureHook
),
])
if (result.type === "error") {
const bootstrapFailed = captureHook.bootstrapError !== undefined
const errorToAnalyze = bootstrapFailed
? captureHook.bootstrapError
: result.err
if (bootstrapFailed || isInfraError(errorToAnalyze)) {
resetCage()
return "retry"
}
if (
result.err !== null &&
typeof result.err === "object" &&
"message" in result.err
) {
const name =
"name" in result.err && typeof result.err.name === "string"
? result.err.name
: ""
const prefix = name ? `${name}: ` : ""
return E.left(`Script execution failed: ${prefix}${result.err.message}`)
}
return E.left(`Script execution failed: ${String(result.err)}`)
}
if (captureHook.capture) {
captureHook.capture()
}
return E.right({
updatedEnvs: finalEnvs,
consoleEntries,
updatedRequest: finalRequest,
updatedCookies: finalCookies,
} satisfies SandboxPreRequestResult)
}
const runPreRequestScriptWithFaradayCage = async (
preRequestScript: string,
envs: TestResult["envs"],
@ -42,64 +122,45 @@ const runPreRequestScriptWithFaradayCage = async (
cookies: Cookie[] | null,
hoppFetchHook?: HoppFetchHook
): Promise<E.Either<string, SandboxPreRequestResult>> => {
const consoleEntries: ConsoleEntry[] = []
let finalEnvs = envs
let finalRequest = request
let finalCookies = cookies
const cage = await acquireCage()
try {
// Create a hook object to receive the capture function from the module
const captureHook: { capture?: () => void } = {}
const cage = await acquireCage()
const result = await cage.runCode(preRequestScript, [
...defaultModules({
handleConsoleEntry: (consoleEntry) => consoleEntries.push(consoleEntry),
hoppFetchHook,
}),
const firstAttempt = await executePreRequestOnCage(
cage,
preRequestScript,
envs,
request,
cookies,
hoppFetchHook
)
preRequestModule(
{
envs: cloneDeep(envs),
request: cloneDeep(request),
cookies: cookies ? cloneDeep(cookies) : null,
handleSandboxResults: ({ envs, request, cookies }) => {
finalEnvs = envs
finalRequest = request
finalCookies = cookies
},
},
captureHook
),
])
if (result.type === "error") {
if (
result.err !== null &&
typeof result.err === "object" &&
"message" in result.err
) {
return E.left(`Script execution failed: ${result.err.message}`)
}
return E.left(`Script execution failed: ${String(result.err)}`)
if (firstAttempt !== "retry") {
return firstAttempt
}
// Capture results only on successful execution
if (captureHook.capture) {
captureHook.capture()
// Bootstrap error detected and cage was reset — retry once on a fresh cage
const freshCage = await acquireCage()
const retryResult = await executePreRequestOnCage(
freshCage,
preRequestScript,
envs,
request,
cookies,
hoppFetchHook
)
if (retryResult === "retry") {
// Two consecutive bootstrap failures — don't loop, report the error
return E.left(
"Script execution failed: sandbox initialization error (persistent)"
)
}
return E.right({
updatedEnvs: finalEnvs,
consoleEntries,
updatedRequest: finalRequest,
updatedCookies: finalCookies,
} satisfies SandboxPreRequestResult)
} finally {
// Don't dispose cage here - returned objects may still be accessed.
// Rely on garbage collection for cleanup.
return retryResult
} catch (error) {
const name = error instanceof Error && error.name ? `${error.name}: ` : ""
const message = error instanceof Error ? error.message : String(error)
return E.left(`Script execution failed: ${name}${message}`)
}
}

View file

@ -21,7 +21,11 @@ const executeScriptInContext = (
updatedCookies: null,
})
} catch (error) {
return TE.left(`Script execution failed: ${(error as Error).message}`)
return TE.left(
`Script execution failed: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}

View file

@ -11,7 +11,7 @@ import {
TestResponse,
TestResult,
} from "~/types"
import { acquireCage } from "~/utils/cage"
import { acquireCage, resetCage, isInfraError } from "~/utils/cage"
import { preventCyclicObjects } from "~/utils/shared"
import { Cookie, HoppRESTRequest } from "@hoppscotch/data"
@ -39,14 +39,19 @@ const runPostRequestScriptWithWebWorker = (
})
}
const runPostRequestScriptWithFaradayCage = async (
/**
* Runs a post-request/test script on the given cage instance.
* Returns the result or "retry" if a bootstrap error triggered a cage reset.
*/
const executeTestOnCage = async (
cage: Awaited<ReturnType<typeof acquireCage>>,
testScript: string,
envs: TestResult["envs"],
request: HoppRESTRequest,
response: TestResponse,
cookies: Cookie[] | null,
hoppFetchHook?: HoppFetchHook
): Promise<E.Either<string, SandboxTestResult>> => {
): Promise<E.Either<string, SandboxTestResult> | "retry"> => {
const testRunStack: TestDescriptor[] = [
{ descriptor: "root", expectResults: [], children: [] },
]
@ -57,77 +62,132 @@ const runPostRequestScriptWithFaradayCage = async (
let finalCookies = cookies
const testPromises: Promise<void>[] = []
const cage = await acquireCage()
const captureHook: { capture?: () => void; bootstrapError?: unknown } = {}
try {
// Create a hook object to receive the capture function from the module
const captureHook: { capture?: () => void } = {}
const result = await cage.runCode(testScript, [
...defaultModules({
handleConsoleEntry: (consoleEntry) => consoleEntries.push(consoleEntry),
hoppFetchHook,
}),
const result = await cage.runCode(testScript, [
...defaultModules({
handleConsoleEntry: (consoleEntry) => consoleEntries.push(consoleEntry),
hoppFetchHook,
}),
postRequestModule(
{
envs: cloneDeep(envs),
testRunStack: cloneDeep(testRunStack),
request: cloneDeep(request),
response: cloneDeep(response),
cookies: cookies ? cloneDeep(cookies) : null,
handleSandboxResults: ({ envs, testRunStack, cookies }) => {
finalEnvs = envs
finalTestResults = testRunStack
finalCookies = cookies
},
onTestPromise: (promise) => {
testPromises.push(promise)
},
postRequestModule(
{
envs: cloneDeep(envs),
testRunStack: cloneDeep(testRunStack),
request: cloneDeep(request),
response: cloneDeep(response),
cookies: cookies ? cloneDeep(cookies) : null,
handleSandboxResults: ({ envs, testRunStack, cookies }) => {
finalEnvs = envs
finalTestResults = testRunStack
finalCookies = cookies
},
captureHook
),
])
onTestPromise: (promise) => {
testPromises.push(promise)
},
},
captureHook
),
])
// Check for script execution errors first
if (result.type === "error") {
if (
result.err !== null &&
typeof result.err === "object" &&
"message" in result.err
) {
return E.left(`Script execution failed: ${result.err.message}`)
}
if (result.type === "error") {
const bootstrapFailed = captureHook.bootstrapError !== undefined
const errorToAnalyze = bootstrapFailed
? captureHook.bootstrapError
: result.err
return E.left(`Script execution failed: ${String(result.err)}`)
if (bootstrapFailed || isInfraError(errorToAnalyze)) {
resetCage()
return "retry"
}
// Wait for async test functions before capturing results.
if (testPromises.length > 0) {
await Promise.all(testPromises)
if (
result.err !== null &&
typeof result.err === "object" &&
"message" in result.err
) {
const name =
"name" in result.err && typeof result.err.name === "string"
? result.err.name
: ""
const prefix = name ? `${name}: ` : ""
return E.left(`Script execution failed: ${prefix}${result.err.message}`)
}
// Capture results AFTER all async tests complete
// This prevents showing intermediate/failed state in UI
if (captureHook.capture) {
captureHook.capture()
return E.left(`Script execution failed: ${String(result.err)}`)
}
// Wait for async test functions before capturing results.
if (testPromises.length > 0) {
await Promise.all(testPromises)
}
if (captureHook.capture) {
captureHook.capture()
}
const safeTestResults = cloneDeep(finalTestResults[0])
const safeEnvs = cloneDeep(finalEnvs)
const safeConsoleEntries = cloneDeep(consoleEntries)
const safeCookies = finalCookies ? cloneDeep(finalCookies) : null
return E.right({
tests: safeTestResults,
envs: safeEnvs,
consoleEntries: safeConsoleEntries,
updatedCookies: safeCookies,
} satisfies SandboxTestResult)
}
const runPostRequestScriptWithFaradayCage = async (
testScript: string,
envs: TestResult["envs"],
request: HoppRESTRequest,
response: TestResponse,
cookies: Cookie[] | null,
hoppFetchHook?: HoppFetchHook
): Promise<E.Either<string, SandboxTestResult>> => {
try {
const cage = await acquireCage()
const firstAttempt = await executeTestOnCage(
cage,
testScript,
envs,
request,
response,
cookies,
hoppFetchHook
)
if (firstAttempt !== "retry") {
return firstAttempt
}
// Deep clone results to prevent mutable references causing UI flickering.
const safeTestResults = cloneDeep(finalTestResults[0])
// Bootstrap error detected and cage was reset — retry once on a fresh cage
const freshCage = await acquireCage()
const retryResult = await executeTestOnCage(
freshCage,
testScript,
envs,
request,
response,
cookies,
hoppFetchHook
)
const safeEnvs = cloneDeep(finalEnvs)
const safeConsoleEntries = cloneDeep(consoleEntries)
const safeCookies = finalCookies ? cloneDeep(finalCookies) : null
if (retryResult === "retry") {
return E.left(
"Script execution failed: sandbox initialization error (persistent)"
)
}
return E.right(<SandboxTestResult>{
tests: safeTestResults,
envs: safeEnvs,
consoleEntries: safeConsoleEntries,
updatedCookies: safeCookies,
})
} finally {
// FaradayCage relies on garbage collection for cleanup.
return retryResult
} catch (error) {
const name = error instanceof Error && error.name ? `${error.name}: ` : ""
const message = error instanceof Error ? error.message : String(error)
return E.left(`Script execution failed: ${name}${message}`)
}
}

View file

@ -26,12 +26,17 @@ const executeScriptInContext = (
// Execute the script
executeScript({ ...pw, response: responseObjHandle.right })
return TE.right(<SandboxTestResult>{
return TE.right({
tests: testRunStack[0],
envs: updatedEnvs,
})
updatedCookies: null,
} satisfies SandboxTestResult)
} catch (error) {
return TE.left(`Script execution failed: ${(error as Error).message}`)
return TE.left(
`Script execution failed: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}