diff --git a/packages/hoppscotch-common/src/helpers/RequestRunner.ts b/packages/hoppscotch-common/src/helpers/RequestRunner.ts index df933927..52e223c9 100644 --- a/packages/hoppscotch-common/src/helpers/RequestRunner.ts +++ b/packages/hoppscotch-common/src/helpers/RequestRunner.ts @@ -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: [], diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-error-recovery.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-error-recovery.spec.ts new file mode 100644 index 00000000..404aa458 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-error-recovery.spec.ts @@ -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) => { + 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) => { + 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) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts index 72cfd579..0f800531 100644 --- a/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts +++ b/packages/hoppscotch-js-sandbox/src/__tests__/hopp-namespace/request.spec.ts @@ -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` + ) ) }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/utils/cage.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/utils/cage.spec.ts new file mode 100644 index 00000000..6657a86b --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/utils/cage.spec.ts @@ -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 (eval.js:1)\n", + }) + ).toBe(false) + + expect( + isInfraError({ + name: "TypeError", + message: "cannot convert to object", + stack: " at keys (native)\n at (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) + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts index caf4a7ba..fe818528 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts @@ -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) diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts index 082db152..6068a2ab 100644 --- a/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts +++ b/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts @@ -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>, + preRequestScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + cookies: Cookie[] | null, + hoppFetchHook?: HoppFetchHook +): Promise | "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 => { - return pipe( - TE.tryCatch( - async (): Promise => { - 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}`) } - ) - ) + })() } diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts index bdcda990..d06bf920 100644 --- a/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts @@ -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>, + testScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + response: TestResponse, + hoppFetchHook?: HoppFetchHook +): Promise | "retry"> => { + const testRunStack: TestDescriptor[] = [ + { descriptor: "root", expectResults: [], children: [] }, + ] + + let finalEnvs = envs + let finalTestResults = testRunStack + const testPromises: Promise[] = [] + + 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 => { - return pipe( - TE.tryCatch( - async (): Promise => { - const testRunStack: TestDescriptor[] = [ - { descriptor: "root", expectResults: [], children: [] }, - ] - - let finalEnvs = envs - let finalTestResults = testRunStack - const testPromises: Promise[] = [] - + 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}`) } - ) - ) + })() } diff --git a/packages/hoppscotch-js-sandbox/src/types/index.ts b/packages/hoppscotch-js-sandbox/src/types/index.ts index b4757372..c0e927d7 100644 --- a/packages/hoppscotch-js-sandbox/src/types/index.ts +++ b/packages/hoppscotch-js-sandbox/src/types/index.ts @@ -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 } diff --git a/packages/hoppscotch-js-sandbox/src/utils/cage.ts b/packages/hoppscotch-js-sandbox/src/utils/cage.ts index 04605964..049a3bf5 100644 --- a/packages/hoppscotch-js-sandbox/src/utils/cage.ts +++ b/packages/hoppscotch-js-sandbox/src/utils/cage.ts @@ -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 | 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 => { - // 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 | null +): void => { + if (!isTestEnvironment) { + throw new Error( + "_setCagePromiseForTesting is test-only and cannot be used in non-test environments" + ) + } + cagePromise = promise } diff --git a/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts b/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts index 903e505d..c80815b3 100644 --- a/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts +++ b/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts @@ -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`) or the string literal "retry" + * if a bootstrap error triggered a cage reset (caller should retry). + */ +const executePreRequestOnCage = async ( + cage: Awaited>, + preRequestScript: string, + envs: TestResult["envs"], + request: HoppRESTRequest, + cookies: Cookie[] | null, + hoppFetchHook?: HoppFetchHook +): Promise | "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> => { - 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}`) } } diff --git a/packages/hoppscotch-js-sandbox/src/web/pre-request/worker.ts b/packages/hoppscotch-js-sandbox/src/web/pre-request/worker.ts index e48c622e..511bc51a 100644 --- a/packages/hoppscotch-js-sandbox/src/web/pre-request/worker.ts +++ b/packages/hoppscotch-js-sandbox/src/web/pre-request/worker.ts @@ -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) + }` + ) } } diff --git a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts index 63ef6194..d9ee9854 100644 --- a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts +++ b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts @@ -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>, testScript: string, envs: TestResult["envs"], request: HoppRESTRequest, response: TestResponse, cookies: Cookie[] | null, hoppFetchHook?: HoppFetchHook -): Promise> => { +): Promise | "retry"> => { const testRunStack: TestDescriptor[] = [ { descriptor: "root", expectResults: [], children: [] }, ] @@ -57,77 +62,132 @@ const runPostRequestScriptWithFaradayCage = async ( let finalCookies = cookies const testPromises: Promise[] = [] - 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> => { + 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({ - 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}`) } } diff --git a/packages/hoppscotch-js-sandbox/src/web/test-runner/worker.ts b/packages/hoppscotch-js-sandbox/src/web/test-runner/worker.ts index 8bd1f76e..9b656a59 100644 --- a/packages/hoppscotch-js-sandbox/src/web/test-runner/worker.ts +++ b/packages/hoppscotch-js-sandbox/src/web/test-runner/worker.ts @@ -26,12 +26,17 @@ const executeScriptInContext = ( // Execute the script executeScript({ ...pw, response: responseObjHandle.right }) - return TE.right({ + 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) + }` + ) } }