fix: auto-recover from corrupted sandbox state (#5874)
This commit is contained in:
parent
ff906b7c96
commit
a22389cda0
13 changed files with 876 additions and 300 deletions
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
)
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
)
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue