diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 7dcdbd383..5d0cb8bb8 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -6,10 +6,10 @@ const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lod const prepareRequest = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); const { interpolateString, interpolateObject } = require('./interpolate-string'); -const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, HooksRuntime, HooksExecutor } = require('@usebruno/js'); +const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, HooksRuntime } = require('@usebruno/js'); const { stripExtension } = require('../utils/filesystem'); const { getOptions } = require('../utils/bru'); -const { extractHooks, getTreePathFromCollectionToItem, HOOK_EVENTS } = require('../utils/collection'); +const { getTreePathFromCollectionToItem, HOOK_EVENTS } = require('../utils/collection'); const https = require('https'); const { HttpProxyAgent } = require('http-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); @@ -211,17 +211,17 @@ const runSingleRequest = async function ( const scriptingConfig = get(brunoConfig, 'scripts', {}); scriptingConfig.runtime = runtime; - // Get request tree path for hook extraction + // Get request tree path for hook execution const requestTreePath = getTreePathFromCollectionToItem(collection, item); const collectionName = collection?.brunoConfig?.name; - // Extract hooks for all levels - const { collectionHooks, folderHooks, requestHooks } = extractHooks(collection, request, requestTreePath); - - // Helper function to execute all hooks using consolidated approach - const executeAllHooksConsolidated = async (extractedHooks, hookEvent, eventData) => { - return HooksExecutor.executeAllHookLevels(extractedHooks, hookEvent, eventData, { + // Helper function to execute merged hooks (hooks are merged in prepareRequest via mergeScripts) + const executeHooks = async (hookEvent, eventData) => { + const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); + const result = await hooksRuntime.runHooks({ + hooksFile: request.script?.hooks, request, + response: eventData.response, envVariables, runtimeVariables, collectionPath, @@ -231,15 +231,37 @@ const runSingleRequest = async function ( runRequestByItemPathname: runSingleRequestByPathname, collectionName }); + + if (result?.hookManager) { + // Enrich eventData with runtime-created req/res wrappers + const enrichedEventData = { + ...eventData, + req: result.req || eventData.req, + res: result.res || eventData.res + }; + await result.hookManager.call(hookEvent, enrichedEventData); + + // Re-read runner control signals from bru instance AFTER handlers have executed + // Handlers may have called bru.runner.setNextRequest(), skipRequest(), or stopExecution() + // which update the bru instance but were not captured in the initial result + const bru = result.__bru; + if (bru) { + result.nextRequestName = bru.nextRequest; + result.skipRequest = bru.skipRequest; + result.stopExecution = bru.stopExecution; + } + + result.hookManager.dispose(); + } + + return result; }; // Call beforeRequest hooks before running pre-request scripts // Hooks are called in registration order: collection -> folder(s) -> request - // Note: BrunoRequest is now created inside HooksRuntime for consistency with ScriptRuntime const beforeRequestEventData = { request, collection }; - const beforeRequestHooksResult = await executeAllHooksConsolidated( - { collectionHooks, folderHooks, requestHooks }, + const beforeRequestHooksResult = await executeHooks( HOOK_EVENTS.HTTP_BEFORE_REQUEST, beforeRequestEventData ); @@ -714,12 +736,9 @@ const runSingleRequest = async function ( // Call afterResponse hooks after response is received but before post-response scripts // Hooks are called in registration order: collection -> folder(s) -> request - // Uses consolidated execution when multiple levels have hooks (more efficient) - // Note: BrunoRequest and BrunoResponse are now created inside HooksRuntime for consistency with ScriptRuntime const afterResponseEventData = { request, response, collection }; - const afterResponseHooksResult = await executeAllHooksConsolidated( - { collectionHooks, folderHooks, requestHooks }, + const afterResponseHooksResult = await executeHooks( HOOK_EVENTS.HTTP_AFTER_RESPONSE, afterResponseEventData ); diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js index 095a96cfb..92d18dffe 100644 --- a/packages/bruno-cli/src/utils/collection.js +++ b/packages/bruno-cli/src/utils/collection.js @@ -234,10 +234,12 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { let collectionPreReqScript = get(collectionRoot, 'request.script.req', ''); let collectionPostResScript = get(collectionRoot, 'request.script.res', ''); let collectionTests = get(collectionRoot, 'request.tests', ''); + let collectionHooks = get(collectionRoot, 'request.script.hooks', ''); let combinedPreReqScript = []; let combinedPostResScript = []; let combinedTests = []; + let combinedHooks = []; for (let i of requestTreePath) { if (i.type === 'folder') { const folderRoot = i?.draft || i?.root; @@ -255,6 +257,11 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { if (tests && tests?.trim?.() !== '') { combinedTests.push(tests); } + + let hooks = get(folderRoot, 'request.script.hooks', ''); + if (hooks && hooks.trim() !== '') { + combinedHooks.push(hooks); + } } } @@ -304,6 +311,21 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { ]; request.tests = compact(testScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); } + + // Handle hooks - always merged sequentially: collection -> folders -> request + let requestHooks = request?.script?.hooks || ''; + const hooksScripts = [ + collectionHooks, + ...combinedHooks, + requestHooks + ]; + + // Ensure request.script exists + if (!request.script) { + request.script = {}; + } + + request.script.hooks = compact(hooksScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); }; const findItem = (items = [], pathname) => { @@ -372,52 +394,6 @@ const mergeAuth = (collection, request, requestTreePath) => { } }; -/** - * Extract hooks from collection, folders, and request for registration. - * Unlike mergeScripts, this returns separate hooks for each level to allow - * one-time registration at each level. - * - * @param {object} collection - Collection object - * @param {object} request - Request object (prepared request, may not have hooks) - * @param {array} requestTreePath - Path from collection to request - * @returns {object} Object containing hooks at each level - */ -const extractHooks = (collection, request, requestTreePath) => { - const collectionRoot = collection?.draft?.root || collection?.root || {}; - const collectionHooks = get(collectionRoot, 'request.script.hooks', ''); - - const folderHooks = []; - let requestHooks = ''; - - for (let i of requestTreePath) { - if (i.type === 'folder') { - const folderRoot = i?.draft || i?.root; - const hooks = get(folderRoot, 'request.script.hooks', ''); - if (hooks && hooks.trim() !== '') { - folderHooks.push({ - folderPathname: i.pathname, // Use pathname as unique identifier - hooks: hooks - }); - } - } else if (i.type !== 'folder') { - // This is the request item - get hooks from it - const itemRoot = i?.draft || i?.root || i; - requestHooks = get(itemRoot, 'request.script.hooks', '') || ''; - } - } - - // Fallback: try to get from request object if not found in tree path - if (!requestHooks) { - requestHooks = get(request, 'script.hooks', '') || get(request, 'hooks', '') || ''; - } - - return { - collectionHooks, - folderHooks, - requestHooks - }; -}; - /** * Hook event names used throughout the application. * This object is frozen to prevent accidental modifications and improve maintainability. @@ -659,6 +635,5 @@ module.exports = { getAllRequestsInFolder, getAllRequestsAtFolderRoot, getCallStack, - extractHooks, HOOK_EVENTS }; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 14c68d044..4d46c8aaa 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -8,7 +8,7 @@ const mime = require('mime-types'); const { ipcMain } = require('electron'); const { each, get, extend, cloneDeep, merge } = require('lodash'); const { NtlmClient } = require('axios-ntlm'); -const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime, HooksRuntime, HooksExecutor } = require('@usebruno/js'); +const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime, HooksRuntime } = require('@usebruno/js'); const { encodeUrl } = require('@usebruno/common').utils; const { extractPromptVariables } = require('@usebruno/common').utils; const { interpolateString } = require('./interpolate-string'); @@ -24,7 +24,7 @@ const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseData const { chooseFileToSave, writeFile, getCollectionFormat, hasRequestExtension } = require('../../utils/filesystem'); const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies'); const { createFormData } = require('../../utils/form-data'); -const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence, extractHooks, HOOK_EVENTS } = require('../../utils/collection'); +const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence, HOOK_EVENTS } = require('../../utils/collection'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials } = require('../../utils/oauth2'); const { preferencesUtil } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); @@ -497,10 +497,20 @@ const registerNetworkIpc = (mainWindow) => { * @param {object} options - Configuration options * @returns {Promise} Execution result or null if error */ - const executeAllHooksConsolidated = async (extractedHooks, hookEvent, eventData, options) => { + /** + * Execute merged hooks for a specific event + * @param {string} hookEvent - Hook event to trigger + * @param {object} eventData - Data to pass to hook handlers + * @param {object} options - Configuration options + * @returns {Promise} Execution result or null if error + */ + const executeHooks = async (hookEvent, eventData, options) => { try { - const result = await HooksExecutor.executeAllHookLevels(extractedHooks, hookEvent, eventData, { + const hooksRuntime = new HooksRuntime({ runtime: options.scriptingConfig?.runtime }); + const result = await hooksRuntime.runHooks({ + hooksFile: options.request?.script?.hooks, request: options.request || {}, + response: eventData.response, envVariables: options.envVars, runtimeVariables: options.runtimeVariables, collectionPath: options.collectionPath, @@ -511,6 +521,28 @@ const registerNetworkIpc = (mainWindow) => { collectionName: options.collectionName }); + if (result?.hookManager) { + // Enrich eventData with runtime-created req/res wrappers + const enrichedEventData = { + ...eventData, + req: result.req || eventData.req, + res: result.res || eventData.res + }; + await result.hookManager.call(hookEvent, enrichedEventData); + + // Re-read runner control signals from bru instance AFTER handlers have executed + // Handlers may have called bru.runner.setNextRequest(), skipRequest(), or stopExecution() + // which update the bru instance but were not captured in the initial result + const bru = result.__bru; + if (bru) { + result.nextRequestName = bru.nextRequest; + result.skipRequest = bru.skipRequest; + result.stopExecution = bru.stopExecution; + } + + result.hookManager.dispose(); + } + // Send UI updates if we have a result if (result) { await sendScriptEnvironmentUpdates({ @@ -524,7 +556,7 @@ const registerNetworkIpc = (mainWindow) => { return result; } catch (error) { - console.error(`Error executing consolidated hooks for ${hookEvent}:`, error); + console.error(`Error executing hooks for ${hookEvent}:`, error); options.onConsoleLog?.('error', [`Error executing hooks for ${hookEvent}: ${error.message}`]); if (!options.runInBackground && options.notifyScriptExecution && typeof options.notifyScriptExecution === 'function') { options.notifyScriptExecution({ @@ -735,19 +767,16 @@ const registerNetworkIpc = (mainWindow) => { const scriptingConfig = get(brunoConfig, 'scripts', {}); scriptingConfig.runtime = getJsSandboxRuntime(collection); - // Get request tree path for hooks registration + // Get request tree path for hooks execution const requestTreePath = getTreePathFromCollectionToItem(collection, item); try { request.signal = abortController.signal; saveCancelToken(cancelTokenUid, abortController); - // Extract hooks for all levels - const { collectionHooks, folderHooks, requestHooks } = extractHooks(collection, request, requestTreePath); - // Call beforeRequest hooks before running pre-request scripts + // Hooks are merged in prepareRequest via mergeScripts // Hooks are called in registration order: collection -> folder(s) -> request - // Note: BrunoRequest is now created inside HooksRuntime for consistency with ScriptRuntime const beforeRequestEventData = { request, collection, collectionUid }; const hookOptions = { request, @@ -767,9 +796,8 @@ const registerNetworkIpc = (mainWindow) => { notifyScriptExecution }; - // Call beforeRequest hooks using consolidated approach when multiple levels have hooks - await executeAllHooksConsolidated( - { collectionHooks, folderHooks, requestHooks }, + // Call beforeRequest hooks using merged hooks + await executeHooks( HOOK_EVENTS.HTTP_BEFORE_REQUEST, beforeRequestEventData, hookOptions @@ -941,12 +969,10 @@ const registerNetworkIpc = (mainWindow) => { // Call afterResponse hooks after response is received but before post-response scripts // Hooks are called in registration order: collection -> folder(s) -> request - // Note: BrunoRequest and BrunoResponse are now created inside HooksRuntime for consistency with ScriptRuntime const afterResponseEventData = { request, response, collection, collectionUid }; - // Call afterResponse hooks using consolidated approach when multiple levels have hooks - await executeAllHooksConsolidated( - { collectionHooks, folderHooks, requestHooks }, + // Call afterResponse hooks using merged hooks + await executeHooks( HOOK_EVENTS.HTTP_AFTER_RESPONSE, afterResponseEventData, hookOptions @@ -1432,9 +1458,8 @@ const registerNetworkIpc = (mainWindow) => { continue; } - // Get request tree path for hooks extraction + // Get request tree path for hooks execution (hooks are merged in prepareRequest via mergeScripts) const requestTreePath = getTreePathFromCollectionToItem(collection, item); - const { collectionHooks, folderHooks, requestHooks } = extractHooks(collection, request, requestTreePath); // Hook execution options const hookOptions = { @@ -1467,12 +1492,10 @@ const registerNetworkIpc = (mainWindow) => { }; try { - // Call beforeRequest hooks using consolidated approach when multiple levels have hooks - // Note: BrunoRequest is now created inside HooksRuntime for consistency with ScriptRuntime + // Call beforeRequest hooks using merged hooks const beforeRequestEventData = { request, collection, collectionUid }; - const beforeRequestHooksResult = await executeAllHooksConsolidated( - { collectionHooks, folderHooks, requestHooks }, + const beforeRequestHooksResult = await executeHooks( HOOK_EVENTS.HTTP_BEFORE_REQUEST, beforeRequestEventData, hookOptions @@ -1723,12 +1746,10 @@ const registerNetworkIpc = (mainWindow) => { } } - // Call afterResponse hooks using consolidated approach when multiple levels have hooks - // Note: BrunoRequest and BrunoResponse are now created inside HooksRuntime for consistency with ScriptRuntime + // Call afterResponse hooks using merged hooks const afterResponseEventData = { request, response, collection, collectionUid }; - const afterResponseHooksResult = await executeAllHooksConsolidated( - { collectionHooks, folderHooks, requestHooks }, + const afterResponseHooksResult = await executeHooks( HOOK_EVENTS.HTTP_AFTER_RESPONSE, afterResponseEventData, hookOptions diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 91d9e1c22..97c80e5eb 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -157,10 +157,12 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { let collectionPreReqScript = get(collectionRoot, 'request.script.req', ''); let collectionPostResScript = get(collectionRoot, 'request.script.res', ''); let collectionTests = get(collectionRoot, 'request.tests', ''); + let collectionHooks = get(collectionRoot, 'request.script.hooks', ''); let combinedPreReqScript = []; let combinedPostResScript = []; let combinedTests = []; + let combinedHooks = []; for (let i of requestTreePath) { if (i.type === 'folder') { const folderRoot = i?.draft || i?.root; @@ -178,6 +180,11 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { if (tests && tests?.trim?.() !== '') { combinedTests.push(tests); } + + let hooks = get(folderRoot, 'request.script.hooks', ''); + if (hooks && hooks.trim() !== '') { + combinedHooks.push(hooks); + } } } @@ -227,52 +234,21 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { ]; request.tests = compact(testScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); } -}; -/** - * Extract hooks from collection, folders, and request for registration. - * Unlike mergeScripts, this returns separate hooks for each level to allow - * one-time registration at each level. - * - * @param {object} collection - Collection object - * @param {object} request - Request object (prepared request, may not have hooks) - * @param {array} requestTreePath - Path from collection to request - * @returns {object} Object containing hooks at each level - */ -const extractHooks = (collection, request, requestTreePath) => { - const collectionRoot = collection?.draft?.root || collection?.root || {}; - const collectionHooks = get(collectionRoot, 'request.script.hooks', ''); - - const folderHooks = []; - let requestHooks = ''; - - for (let i of requestTreePath) { - if (i.type === 'folder') { - const folderRoot = i?.draft || i?.root; - const hooks = get(folderRoot, 'request.script.hooks', ''); - if (hooks && hooks.trim() !== '') { - folderHooks.push({ - folderPathname: i.pathname, // Use pathname as unique identifier - hooks: hooks - }); - } - } else if (i.type !== 'folder') { - // This is the request item - get hooks from it - const itemRoot = i?.draft || i?.root || i; - requestHooks = get(itemRoot, 'request.script.hooks', '') || ''; - } - } - - // Fallback: try to get from request object if not found in tree path - if (!requestHooks) { - requestHooks = get(request, 'script.hooks', '') || get(request, 'hooks', '') || ''; - } - - return { + // Handle hooks - always merged sequentially: collection -> folders -> request + let requestHooks = request?.script?.hooks || ''; + const hooksScripts = [ collectionHooks, - folderHooks, + ...combinedHooks, requestHooks - }; + ]; + + // Ensure request.script exists + if (!request.script) { + request.script = {}; + } + + request.script.hooks = compact(hooksScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); }; /** @@ -812,6 +788,5 @@ module.exports = { getFormattedCollectionOauth2Credentials, sortByNameThenSequence, resolveInheritedSettings, - extractHooks, HOOK_EVENTS }; diff --git a/packages/bruno-js/src/index.js b/packages/bruno-js/src/index.js index aeb891f77..53d6f6782 100644 --- a/packages/bruno-js/src/index.js +++ b/packages/bruno-js/src/index.js @@ -3,8 +3,6 @@ const TestRuntime = require('./runtime/test-runtime'); const VarsRuntime = require('./runtime/vars-runtime'); const AssertRuntime = require('./runtime/assert-runtime'); const HooksRuntime = require('./runtime/hooks-runtime'); -const HooksConsolidator = require('./runtime/hooks-consolidator'); -const HooksExecutor = require('./runtime/hooks-executor'); const HookManager = require('./hook-manager'); const { runScriptInNodeVm } = require('./sandbox/node-vm'); @@ -14,8 +12,6 @@ module.exports = { VarsRuntime, AssertRuntime, HooksRuntime, - HooksConsolidator, - HooksExecutor, HookManager, runScriptInNodeVm }; diff --git a/packages/bruno-js/src/runtime/hooks-consolidator.js b/packages/bruno-js/src/runtime/hooks-consolidator.js deleted file mode 100644 index ec2f713d8..000000000 --- a/packages/bruno-js/src/runtime/hooks-consolidator.js +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Hooks Consolidator Utility - * - * This module provides utilities to consolidate multiple hook scripts (collection, folder, request levels) - * into a single IIFE execution. This improves performance by: - * - Creating only one VM instance instead of multiple - * - Executing all hook levels sequentially within that VM - * - Maintaining proper variable scoping with nested IIFEs - * - * Each level's hooks are wrapped in their own IIFE to ensure: - * - Isolated variable scope per level - * - Each level gets its own Bru instance with appropriate variable context - * - Handlers capture the correct Bru instance in their closure - * - Errors in one level don't break other levels - */ - -const decomment = require('decomment'); - -/** - * Hook level types for identification - * @readonly - * @enum {string} - */ -const HOOK_LEVEL = Object.freeze({ - COLLECTION: 'collection', - FOLDER: 'folder', - REQUEST: 'request' -}); - -/** - * Represents a single hook level with its script and metadata - * @typedef {Object} HookLevel - * @property {string} level - The level type (collection, folder, request) - * @property {string} script - The hooks script content - * @property {string} [identifier] - Unique identifier (e.g., folder pathname) - * @property {Object} [variables] - Level-specific variables - */ - -/** - * Configuration for building a consolidated hook script - * @typedef {Object} ConsolidatorConfig - * @property {string} [collectionHooks] - Collection-level hooks script - * @property {Array<{folderPathname: string, hooks: string}>} [folderHooks] - Folder-level hooks - * @property {string} [requestHooks] - Request-level hooks script - * @property {boolean} [removeComments=true] - Whether to remove comments from scripts - */ - -/** - * Result of consolidating hooks - * @typedef {Object} ConsolidatedResult - * @property {string} script - The consolidated IIFE script - * @property {Array} levels - Metadata about included levels - * @property {boolean} hasHooks - Whether any hooks were found - */ - -/** - * Escapes a string for safe inclusion in JavaScript code - * Handles special characters that could break the script - * @param {string} str - String to escape - * @returns {string} Escaped string - */ -const escapeForTemplate = (str) => { - if (!str) return ''; - // Replace backticks and ${} to prevent template literal injection - return str - .replace(/\\/g, '\\\\') - .replace(/`/g, '\\`') - .replace(/\$\{/g, '\\${'); -}; - -/** - * Wraps a hook script in an IIFE with error handling - * The IIFE captures the appropriate bru instance for that level - * @param {string} script - Hook script content - * @param {string} level - Hook level identifier for error reporting - * @param {string} [identifier] - Additional identifier (e.g., folder path) - * @returns {string} Wrapped script - */ -const wrapInIIFE = (script, level, identifier = '') => { - if (!script || !script.trim()) { - return ''; - } - - const levelId = identifier ? `${level}:${identifier}` : level; - - // The IIFE creates a scoped execution context - // The bru variable is expected to be set in the outer scope before this IIFE runs - // Each level will have its own bru instance with appropriate variable context - return ` -// === ${level.toUpperCase()} HOOKS${identifier ? ` (${identifier})` : ''} === -await (async () => { - const __hookLevel = '${escapeForTemplate(levelId)}'; - try { -${script} - } catch (__hookError) { - __consolidatedErrors.push({ - level: __hookLevel, - error: __hookError?.message || String(__hookError), - stack: __hookError?.stack - }); - if (typeof __onHookError === 'function') { - __onHookError(__hookLevel, __hookError); - } - } -})(); -`; -}; - -/** - * Processes hook script by optionally removing comments - * @param {string} script - Script to process - * @param {boolean} removeComments - Whether to remove comments - * @returns {string} Processed script - */ -const processScript = (script, removeComments = true) => { - if (!script || !script.trim()) { - return ''; - } - - if (removeComments) { - try { - return decomment(script); - } catch (e) { - // If decomment fails, return original script - return script; - } - } - - return script; -}; - -/** - * Builds a consolidated hook script from multiple levels - * - * The consolidated script structure: - * 1. Initializes shared state (error collection, bru instances tracking) - * 2. Executes each level's hooks in an IIFE with its own scope - * 3. Each level's handlers capture the bru instance available in that scope - * 4. Returns collected errors and any other results - * - * @param {ConsolidatorConfig} config - Configuration for consolidation - * @returns {ConsolidatedResult} Consolidated script and metadata - */ -const buildConsolidatedScript = (config) => { - const { - collectionHooks = '', - folderHooks = [], - requestHooks = '', - removeComments = true - } = config; - - const levels = []; - const scriptParts = []; - - // Process collection hooks - const processedCollectionHooks = processScript(collectionHooks, removeComments); - if (processedCollectionHooks && processedCollectionHooks.trim()) { - levels.push({ - level: HOOK_LEVEL.COLLECTION, - script: processedCollectionHooks, - identifier: 'root' - }); - scriptParts.push(wrapInIIFE(processedCollectionHooks, HOOK_LEVEL.COLLECTION)); - } - - // Process folder hooks (in order from collection to request) - if (Array.isArray(folderHooks)) { - for (const folder of folderHooks) { - if (!folder || !folder.hooks) continue; - - const processedFolderHooks = processScript(folder.hooks, removeComments); - if (processedFolderHooks && processedFolderHooks.trim()) { - levels.push({ - level: HOOK_LEVEL.FOLDER, - script: processedFolderHooks, - identifier: folder.folderPathname - }); - scriptParts.push(wrapInIIFE(processedFolderHooks, HOOK_LEVEL.FOLDER, folder.folderPathname)); - } - } - } - - // Process request hooks - const processedRequestHooks = processScript(requestHooks, removeComments); - if (processedRequestHooks && processedRequestHooks.trim()) { - levels.push({ - level: HOOK_LEVEL.REQUEST, - script: processedRequestHooks, - identifier: 'current' - }); - scriptParts.push(wrapInIIFE(processedRequestHooks, HOOK_LEVEL.REQUEST)); - } - - const hasHooks = levels.length > 0; - - if (!hasHooks) { - return { - script: '', - levels: [], - hasHooks: false - }; - } - - // Build the full consolidated script - // Note: The outer scope provides `bru` and `__onHookError` variables - // The script assumes these are set up by the runtime before execution - const consolidatedScript = ` -// Consolidated hooks script - generated by hooks-consolidator -// Contains ${levels.length} hook level(s): ${levels.map((l) => l.level).join(', ')} - -// Shared error collection for all levels -const __consolidatedErrors = []; - -// Execute all hook levels sequentially -${scriptParts.join('\n')} - -// Return collected errors (if any) for reporting -if (__consolidatedErrors.length > 0) { - __hookResult = { errors: __consolidatedErrors }; -} -`; - - return { - script: consolidatedScript.trim(), - levels, - hasHooks: true - }; -}; - -/** - * Creates level metadata for tracking what was included in consolidation - * @param {ConsolidatorConfig} config - Configuration used for consolidation - * @returns {Array<{level: string, identifier: string}>} Level metadata - */ -const getLevelMetadata = (config) => { - const { collectionHooks, folderHooks, requestHooks } = config; - const metadata = []; - - if (collectionHooks && collectionHooks.trim()) { - metadata.push({ level: HOOK_LEVEL.COLLECTION, identifier: 'root' }); - } - - if (Array.isArray(folderHooks)) { - for (const folder of folderHooks) { - if (folder?.hooks && folder.hooks.trim()) { - metadata.push({ level: HOOK_LEVEL.FOLDER, identifier: folder.folderPathname }); - } - } - } - - if (requestHooks && requestHooks.trim()) { - metadata.push({ level: HOOK_LEVEL.REQUEST, identifier: 'current' }); - } - - return metadata; -}; - -/** - * Extracts hook configuration from extracted hooks object - * Converts the format from extractHooks() to ConsolidatorConfig format - * @param {Object} extractedHooks - Object from extractHooks() function - * @param {string} extractedHooks.collectionHooks - Collection hooks script - * @param {Array} extractedHooks.folderHooks - Array of folder hooks - * @param {string} extractedHooks.requestHooks - Request hooks script - * @returns {ConsolidatorConfig} Configuration for consolidation - */ -const fromExtractedHooks = (extractedHooks) => { - const { collectionHooks = '', folderHooks = [], requestHooks = '' } = extractedHooks || {}; - - return { - collectionHooks, - folderHooks, - requestHooks, - removeComments: true - }; -}; - -module.exports = { - // Main functions - buildConsolidatedScript, - - // Utility functions - wrapInIIFE, - processScript, - escapeForTemplate, - - // Analysis functions - getLevelMetadata, - fromExtractedHooks, - - // Constants - HOOK_LEVEL -}; diff --git a/packages/bruno-js/src/runtime/hooks-executor.js b/packages/bruno-js/src/runtime/hooks-executor.js deleted file mode 100644 index de33b7418..000000000 --- a/packages/bruno-js/src/runtime/hooks-executor.js +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Hooks Executor Utility - * - * This module provides a centralized hook execution logic that can be used by both - * CLI and Electron implementations. It eliminates code duplication and ensures - * consistent behavior across different execution contexts. - * - * Features: - * - Unified hook execution for collection, folder, and request levels - * - Support for both individual and consolidated execution modes - * - Proper error isolation and reporting - * - Hook manager lifecycle management - */ - -const HooksRuntime = require('./hooks-runtime'); -const decomment = require('decomment'); - -/** - * Hook event names used throughout the application - * @readonly - * @enum {string} - */ -const HOOK_EVENTS = Object.freeze({ - HTTP_BEFORE_REQUEST: 'http:beforeRequest', - HTTP_AFTER_RESPONSE: 'http:afterResponse', - RUNNER_BEFORE_COLLECTION_RUN: 'runner:beforeCollectionRun', - RUNNER_AFTER_COLLECTION_RUN: 'runner:afterCollectionRun' -}); - -/** - * Creates execution options for hooks - * @typedef {Object} HookExecutionOptions - * @property {object} request - The request object - * @property {object} envVariables - Environment variables - * @property {object} runtimeVariables - Runtime variables - * @property {string} collectionPath - Collection path - * @property {function} [onConsoleLog] - Console log callback - * @property {object} processEnvVars - Process environment variables - * @property {object} scriptingConfig - Scripting configuration - * @property {function} [runRequestByItemPathname] - Function to run requests - * @property {string} collectionName - Collection name - * @property {HookManager} [hookManager] - Existing HookManager to use - */ - -/** - * Result from hook execution - * @typedef {Object} HookExecutionResult - * @property {HookManager} hookManager - The HookManager instance - * @property {object} envVariables - Updated environment variables - * @property {object} runtimeVariables - Updated runtime variables - * @property {object} persistentEnvVariables - Persistent environment variables - * @property {object} globalEnvironmentVariables - Global environment variables - * @property {Array} [errors] - Any errors that occurred during execution - */ - -/** - * Executes hooks for a single level (collection, folder, or request) - * This is the individual execution mode - one VM per level - * - * @param {string} hooksFile - The hooks script content - * @param {string} hookEvent - The hook event to trigger (e.g., HOOK_EVENTS.HTTP_BEFORE_REQUEST) - * @param {object} eventData - Data to pass to hook handlers - * @param {HookExecutionOptions} options - Execution options - * @returns {Promise} Execution result or null if no hooks - */ -const executeHooksForLevel = async (hooksFile, hookEvent, eventData, options) => { - if (!hooksFile || !hooksFile.trim()) { - return null; - } - - const { - request, - envVariables, - runtimeVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig, - runRequestByItemPathname, - collectionName - } = options; - - // Extract response from eventData if available (for afterResponse hooks) - const response = eventData?.response; - - try { - const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); - const result = await hooksRuntime.runHooks({ - hooksFile: decomment(hooksFile), - request, - response, - envVariables, - runtimeVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig, - runRequestByItemPathname, - collectionName - }); - - if (result?.hookManager) { - // Enrich eventData with runtime-created req/res wrappers - const enrichedEventData = { - ...eventData, - req: result.req || eventData.req, - res: result.res || eventData.res - }; - await result.hookManager.call(hookEvent, enrichedEventData); - // Dispose HookManager to free VM resources - if (typeof result.hookManager.dispose === 'function') { - result.hookManager.dispose(); - } - } - - return result; - } catch (error) { - if (onConsoleLog) { - onConsoleLog('error', [`Error executing hooks for ${hookEvent}: ${error.message}`]); - } - console.error(`Error executing hooks for ${hookEvent}:`, error); - return null; - } -}; - -/** - * Executes hooks for multiple levels using consolidated execution - * This batches all hook levels into a single VM execution for better performance - * - * @param {object} extractedHooks - Hooks extracted from collection/folder/request - * @param {string} extractedHooks.collectionHooks - Collection-level hooks - * @param {Array} extractedHooks.folderHooks - Folder-level hooks array - * @param {string} extractedHooks.requestHooks - Request-level hooks - * @param {string} hookEvent - The hook event to trigger - * @param {object} eventData - Data to pass to hook handlers - * @param {HookExecutionOptions} options - Execution options - * @returns {Promise} Execution result or null if no hooks - */ -const executeConsolidatedHooks = async (extractedHooks, hookEvent, eventData, options) => { - const { - request, - envVariables, - runtimeVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig, - runRequestByItemPathname, - collectionName - } = options; - - // Extract response from eventData if available (for afterResponse hooks) - const response = eventData?.response; - - try { - const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime }); - const result = await hooksRuntime.runHooks({ - consolidated: true, - consolidatedHooks: extractedHooks, - request, - response, - envVariables, - runtimeVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig, - runRequestByItemPathname, - collectionName - }); - - if (result?.hookManager) { - // Enrich eventData with runtime-created req/res wrappers - const enrichedEventData = { - ...eventData, - req: result.req || eventData.req, - res: result.res || eventData.res - }; - await result.hookManager.call(hookEvent, enrichedEventData); - - // IMPORTANT: Re-capture runner control values AFTER hooks have been called - // The hooks may have called bru.runner.setNextRequest(), bru.runner.skipRequest(), etc. - // These values are stored on the bru instance which is returned in result.__bru - if (result.__bru) { - result.nextRequestName = result.__bru.nextRequest; - result.skipRequest = result.__bru.skipRequest; - result.stopExecution = result.__bru.stopExecution; - } - } - - return result; - } catch (error) { - if (onConsoleLog) { - onConsoleLog('error', [`Error executing consolidated hooks for ${hookEvent}: ${error.message}`]); - } - console.error(`Error executing consolidated hooks for ${hookEvent}:`, error); - return null; - } -}; - -/** - * Executes all hook levels in sequence (collection -> folders -> request) - * Always uses consolidated execution for better performance - * - * @param {object} extractedHooks - Hooks extracted from all levels - * @param {string} hookEvent - The hook event to trigger - * @param {object} eventData - Data to pass to hook handlers - * @param {HookExecutionOptions} options - Execution options - * @returns {Promise} Execution result or null if no hooks - */ -const executeAllHookLevels = async (extractedHooks, hookEvent, eventData, options) => { - // Always use consolidated execution - single VM for all levels - const result = await executeConsolidatedHooks(extractedHooks, hookEvent, eventData, options); - if (result?.hookManager && typeof result.hookManager.dispose === 'function') { - result.hookManager.dispose(); - } - return result; -}; - -/** - * Creates a reusable hook executor with cached configuration - * Useful for collection runs where the same configuration is used for multiple requests - * - * @param {HookExecutionOptions} baseOptions - Base options to use for all executions - * @returns {object} Hook executor with pre-configured methods - */ -const createHookExecutor = (baseOptions) => { - return { - /** - * Execute hooks for a single level - */ - executeLevel: (hooksFile, hookEvent, eventData, overrideOptions = {}) => { - return executeHooksForLevel(hooksFile, hookEvent, eventData, { - ...baseOptions, - ...overrideOptions - }); - }, - - /** - * Execute consolidated hooks - */ - executeConsolidated: (extractedHooks, hookEvent, eventData, overrideOptions = {}) => { - return executeConsolidatedHooks(extractedHooks, hookEvent, eventData, { - ...baseOptions, - ...overrideOptions - }); - }, - - /** - * Execute all hook levels - */ - executeAll: (extractedHooks, hookEvent, eventData, overrideOptions = {}) => { - return executeAllHookLevels(extractedHooks, hookEvent, eventData, { - ...baseOptions, - ...overrideOptions - }); - } - }; -}; - -module.exports = { - // Core execution functions - executeHooksForLevel, - executeConsolidatedHooks, - executeAllHookLevels, - - // Factory function - createHookExecutor, - - // Constants - HOOK_EVENTS -}; diff --git a/packages/bruno-js/src/runtime/hooks-runtime.js b/packages/bruno-js/src/runtime/hooks-runtime.js index 42c441c6a..21e049882 100644 --- a/packages/bruno-js/src/runtime/hooks-runtime.js +++ b/packages/bruno-js/src/runtime/hooks-runtime.js @@ -5,14 +5,15 @@ const BrunoResponse = require('../bruno-response'); const HookManager = require('../hook-manager'); const { cleanJson } = require('../utils'); const { executeQuickJsVmAsync } = require('../sandbox/quickjs'); -const { buildConsolidatedScript, fromExtractedHooks } = require('./hooks-consolidator'); /** * HooksRuntime manages the execution of hook scripts in a sandboxed environment. * - * Optimizations: + * Hooks are now merged into a single script using mergeScripts() in collection.js, + * following the same pattern as pre-request, post-response, and test scripts. + * + * Features: * - Lazy VM creation: VMs are only created when hooks are present - * - Consolidated execution: Multiple hook levels can be batched into a single VM * - Shared HookManager: Can reuse an existing HookManager for handler registration * * @class @@ -28,8 +29,7 @@ class HooksRuntime { // Track statistics for performance monitoring this._stats = { vmCreations: 0, - consolidatedRuns: 0, - singleLevelRuns: 0, + runs: 0, skippedRuns: 0 }; } @@ -55,7 +55,7 @@ class HooksRuntime { /** * Run hooks script to register event handlers * @param {object} options - Configuration options - * @param {string} [options.hooksFile] - The hooks script content (for single-level execution) + * @param {string} [options.hooksFile] - The merged hooks script content * @param {object} options.request - The request object (used for variable extraction and BrunoRequest creation) * @param {object} [options.response] - The response object (used for BrunoResponse creation, only for afterResponse hooks) * @param {object} options.envVariables - Environment variables @@ -67,11 +67,6 @@ class HooksRuntime { * @param {function} [options.runRequestByItemPathname] - Function to run requests * @param {string} options.collectionName - Collection name * @param {HookManager} [options.hookManager] - Existing HookManager instance to use (for shared hook registration) - * @param {boolean} [options.consolidated=false] - Whether to use consolidated execution mode - * @param {object} [options.consolidatedHooks] - Consolidated hooks data (when consolidated=true) - * @param {string} [options.consolidatedHooks.collectionHooks] - Collection-level hooks script - * @param {Array} [options.consolidatedHooks.folderHooks] - Array of folder hooks - * @param {string} [options.consolidatedHooks.requestHooks] - Request-level hooks script * @returns {object} Result containing the hookManager instance, and req/res wrapper objects */ async runHooks(options) { @@ -87,9 +82,7 @@ class HooksRuntime { scriptingConfig, runRequestByItemPathname, collectionName, - hookManager, - consolidated = false, - consolidatedHooks + hookManager } = options; const activeHookManager = hookManager || new HookManager(); const globalEnvironmentVariables = request?.globalEnvironmentVariables || {}; @@ -99,31 +92,6 @@ class HooksRuntime { const requestVariables = request?.requestVariables || {}; const promptVariables = request?.promptVariables || {}; - // Consolidated execution mode: build and execute consolidated script - if (consolidated && consolidatedHooks) { - return this._runConsolidatedHooks({ - consolidatedHooks, - request, - response, - envVariables, - runtimeVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig, - runRequestByItemPathname, - collectionName, - activeHookManager, - globalEnvironmentVariables, - oauth2CredentialVariables, - collectionVariables, - folderVariables, - requestVariables, - promptVariables - }); - } - - // Single-level execution mode (original behavior) // Pass activeHookManager to Bru so it uses the same instance (whether provided or newly created) const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables, activeHookManager); @@ -174,7 +142,7 @@ class HooksRuntime { }; } - this._stats.singleLevelRuns++; + this._stats.runs++; this._stats.vmCreations++; // Execute hooks script @@ -231,187 +199,6 @@ class HooksRuntime { res }; } - - /** - * Run consolidated hooks execution - all levels in a single VM run - * This is more efficient when there are multiple hook levels (collection, folder, request) - * @private - * @param {object} options - Execution options - * @returns {Promise} Result containing hookManager and variable states - */ - async _runConsolidatedHooks(options) { - const { - consolidatedHooks, - request, - response, - envVariables, - runtimeVariables, - collectionPath, - onConsoleLog, - processEnvVars, - scriptingConfig, - runRequestByItemPathname, - collectionName, - activeHookManager, - globalEnvironmentVariables, - oauth2CredentialVariables, - collectionVariables, - folderVariables, - requestVariables, - promptVariables - } = options; - - // Build consolidated script from all hook levels - const config = fromExtractedHooks(consolidatedHooks); - const { script: consolidatedScript, hasHooks, levels } = buildConsolidatedScript(config); - - // Lazy VM creation: If no hooks, return early without creating a VM - if (!hasHooks || !consolidatedScript) { - this._stats.skippedRuns++; - const bru = new Bru( - this.runtime, - envVariables, - runtimeVariables, - processEnvVars, - collectionPath, - collectionVariables, - folderVariables, - requestVariables, - globalEnvironmentVariables, - oauth2CredentialVariables, - collectionName, - promptVariables, - activeHookManager - ); - // Create BrunoRequest and BrunoResponse wrappers - const req = request ? new BrunoRequest(request) : null; - const res = response ? new BrunoResponse(response) : null; - return { - hookManager: activeHookManager, - envVariables: cleanJson(envVariables), - runtimeVariables: cleanJson(runtimeVariables), - persistentEnvVariables: bru.persistentEnvVariables, - globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), - nextRequestName: bru.nextRequest, - skipRequest: bru.skipRequest, - stopExecution: bru.stopExecution, - __bru: bru, - req, - res - }; - } - - this._stats.consolidatedRuns++; - this._stats.vmCreations++; - - // Create a single Bru instance for consolidated execution - // All hook levels will share this instance's HookManager - const bru = new Bru( - this.runtime, - envVariables, - runtimeVariables, - processEnvVars, - collectionPath, - collectionVariables, - folderVariables, - requestVariables, - globalEnvironmentVariables, - oauth2CredentialVariables, - collectionName, - promptVariables, - activeHookManager - ); - - // Create BrunoRequest and BrunoResponse wrappers (similar to ScriptRuntime) - const req = request ? new BrunoRequest(request) : null; - const res = response ? new BrunoResponse(response) : null; - - // Prepare context with error handling callback - const context = { - bru, - req, - res, - __hookResult: null, - __onHookError: (level, error) => { - if (onConsoleLog) { - onConsoleLog('error', [`[Hook Error] ${level}: ${error?.message || error}`]); - } - } - }; - - // Add custom console logger - if (onConsoleLog && typeof onConsoleLog === 'function') { - const customLogger = (type) => { - return (...args) => { - onConsoleLog(type, cleanJson(args)); - }; - }; - context.console = { - log: customLogger('log'), - debug: customLogger('debug'), - info: customLogger('info'), - warn: customLogger('warn'), - error: customLogger('error') - }; - } - - // Add runRequest function if provided - if (runRequestByItemPathname) { - context.bru.runRequest = runRequestByItemPathname; - } - - // Execute consolidated script - if (this.runtime === 'nodevm') { - await runScriptInNodeVm({ - script: consolidatedScript, - context, - collectionPath, - scriptingConfig - }); - - return { - hookManager: activeHookManager, - envVariables: cleanJson(envVariables), - runtimeVariables: cleanJson(runtimeVariables), - persistentEnvVariables: bru.persistentEnvVariables, - globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), - nextRequestName: bru.nextRequest, - skipRequest: bru.skipRequest, - stopExecution: bru.stopExecution, - __bru: bru, - req, - res - }; - } - - // For QuickJS, persist the VM so hook handlers can be called later - const result = await executeQuickJsVmAsync({ - script: consolidatedScript, - context: context, - collectionPath, - persistVm: true - }); - - // Register VM cleanup with HookManager - if (result?.cleanup && typeof activeHookManager.registerCleanup === 'function') { - activeHookManager.registerCleanup(result.cleanup); - } - - return { - hookManager: activeHookManager, - envVariables: cleanJson(envVariables), - runtimeVariables: cleanJson(runtimeVariables), - persistentEnvVariables: bru.persistentEnvVariables, - globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), - nextRequestName: bru.nextRequest, - skipRequest: bru.skipRequest, - stopExecution: bru.stopExecution, - // Include bru reference so callers can read updated values after hook execution - __bru: bru, - req, - res - }; - } } module.exports = HooksRuntime; diff --git a/packages/bruno-js/tests/hooks-consolidator.spec.js b/packages/bruno-js/tests/hooks-consolidator.spec.js deleted file mode 100644 index 346ffa28d..000000000 --- a/packages/bruno-js/tests/hooks-consolidator.spec.js +++ /dev/null @@ -1,333 +0,0 @@ -const { describe, it, expect } = require('@jest/globals'); -const { - buildConsolidatedScript, - wrapInIIFE, - processScript, - escapeForTemplate, - getLevelMetadata, - fromExtractedHooks, - HOOK_LEVEL -} = require('../src/runtime/hooks-consolidator'); - -describe('hooks-consolidator', () => { - describe('HOOK_LEVEL constants', () => { - it('should have correct level values', () => { - expect(HOOK_LEVEL.COLLECTION).toBe('collection'); - expect(HOOK_LEVEL.FOLDER).toBe('folder'); - expect(HOOK_LEVEL.REQUEST).toBe('request'); - }); - - it('should be frozen', () => { - expect(Object.isFrozen(HOOK_LEVEL)).toBe(true); - }); - }); - - describe('escapeForTemplate', () => { - it('should handle empty strings', () => { - expect(escapeForTemplate('')).toBe(''); - expect(escapeForTemplate(null)).toBe(''); - expect(escapeForTemplate(undefined)).toBe(''); - }); - - it('should escape backticks', () => { - expect(escapeForTemplate('Hello `world`')).toBe('Hello \\`world\\`'); - }); - - it('should escape template literals', () => { - expect(escapeForTemplate('Value: ${value}')).toBe('Value: \\${value}'); - }); - - it('should escape backslashes', () => { - expect(escapeForTemplate('path\\to\\file')).toBe('path\\\\to\\\\file'); - }); - - it('should handle complex strings', () => { - const input = 'const msg = `Hello ${name}\\n`;'; - const expected = 'const msg = \\`Hello \\${name}\\\\n\\`;'; - expect(escapeForTemplate(input)).toBe(expected); - }); - }); - - describe('processScript', () => { - it('should return empty string for empty input', () => { - expect(processScript('')).toBe(''); - expect(processScript(null)).toBe(''); - expect(processScript(' ')).toBe(''); - }); - - it('should remove single-line comments by default', () => { - const script = ` - // This is a comment - const x = 1; - `; - const result = processScript(script); - expect(result).not.toContain('This is a comment'); - expect(result).toContain('const x = 1'); - }); - - it('should remove multi-line comments by default', () => { - const script = ` - /* This is a - multi-line comment */ - const x = 1; - `; - const result = processScript(script); - expect(result).not.toContain('multi-line comment'); - expect(result).toContain('const x = 1'); - }); - - it('should preserve script when removeComments is false', () => { - const script = '// comment\nconst x = 1;'; - const result = processScript(script, false); - expect(result).toContain('// comment'); - }); - }); - - describe('wrapInIIFE', () => { - it('should return empty string for empty script', () => { - expect(wrapInIIFE('', 'collection')).toBe(''); - expect(wrapInIIFE(' ', 'collection')).toBe(''); - expect(wrapInIIFE(null, 'collection')).toBe(''); - }); - - it('should wrap script in async IIFE', () => { - const script = 'console.log("hello");'; - const result = wrapInIIFE(script, 'collection'); - expect(result).toContain('await (async () => {'); - expect(result).toContain('console.log("hello");'); - expect(result).toContain('})();'); - }); - - it('should include level identifier', () => { - const result = wrapInIIFE('const x = 1;', 'collection'); - expect(result).toContain('__hookLevel = \'collection\''); - expect(result).toContain('COLLECTION HOOKS'); - }); - - it('should include folder identifier when provided', () => { - const result = wrapInIIFE('const x = 1;', 'folder', '/path/to/folder'); - expect(result).toContain('__hookLevel = \'folder:/path/to/folder\''); - expect(result).toContain('FOLDER HOOKS'); - expect(result).toContain('/path/to/folder'); - }); - - it('should include error handling', () => { - const result = wrapInIIFE('const x = 1;', 'request'); - expect(result).toContain('try {'); - expect(result).toContain('} catch (__hookError)'); - expect(result).toContain('__consolidatedErrors.push'); - expect(result).toContain('__onHookError'); - }); - }); - - describe('buildConsolidatedScript', () => { - it('should return empty result for no hooks', () => { - const result = buildConsolidatedScript({}); - expect(result.hasHooks).toBe(false); - expect(result.script).toBe(''); - expect(result.levels).toHaveLength(0); - }); - - it('should handle collection hooks only', () => { - const result = buildConsolidatedScript({ - collectionHooks: 'bru.hooks.http.onBeforeRequest(() => {});' - }); - expect(result.hasHooks).toBe(true); - expect(result.levels).toHaveLength(1); - expect(result.levels[0].level).toBe(HOOK_LEVEL.COLLECTION); - expect(result.script).toContain('COLLECTION HOOKS'); - expect(result.script).toContain('bru.hooks.http.onBeforeRequest'); - }); - - it('should handle request hooks only', () => { - const result = buildConsolidatedScript({ - requestHooks: 'bru.hooks.http.onAfterResponse(() => {});' - }); - expect(result.hasHooks).toBe(true); - expect(result.levels).toHaveLength(1); - expect(result.levels[0].level).toBe(HOOK_LEVEL.REQUEST); - expect(result.script).toContain('REQUEST HOOKS'); - }); - - it('should handle multiple folder hooks', () => { - const result = buildConsolidatedScript({ - folderHooks: [ - { folderPathname: '/folder1', hooks: 'const f1 = 1;' }, - { folderPathname: '/folder2', hooks: 'const f2 = 2;' } - ] - }); - expect(result.hasHooks).toBe(true); - expect(result.levels).toHaveLength(2); - expect(result.levels[0].level).toBe(HOOK_LEVEL.FOLDER); - expect(result.levels[0].identifier).toBe('/folder1'); - expect(result.levels[1].identifier).toBe('/folder2'); - expect(result.script).toContain('/folder1'); - expect(result.script).toContain('/folder2'); - }); - - it('should consolidate all levels in correct order', () => { - const result = buildConsolidatedScript({ - collectionHooks: 'const collectionVar = "collection";', - folderHooks: [ - { folderPathname: '/folder1', hooks: 'const folder1Var = "folder1";' }, - { folderPathname: '/folder2', hooks: 'const folder2Var = "folder2";' } - ], - requestHooks: 'const requestVar = "request";' - }); - - expect(result.hasHooks).toBe(true); - expect(result.levels).toHaveLength(4); - expect(result.levels[0].level).toBe(HOOK_LEVEL.COLLECTION); - expect(result.levels[1].level).toBe(HOOK_LEVEL.FOLDER); - expect(result.levels[2].level).toBe(HOOK_LEVEL.FOLDER); - expect(result.levels[3].level).toBe(HOOK_LEVEL.REQUEST); - - // Verify order in script (collection before folders before request) - const collectionIndex = result.script.indexOf('COLLECTION HOOKS'); - const folder1Index = result.script.indexOf('/folder1'); - const folder2Index = result.script.indexOf('/folder2'); - const requestIndex = result.script.indexOf('REQUEST HOOKS'); - - expect(collectionIndex).toBeLessThan(folder1Index); - expect(folder1Index).toBeLessThan(folder2Index); - expect(folder2Index).toBeLessThan(requestIndex); - }); - - it('should skip empty folder hooks', () => { - const result = buildConsolidatedScript({ - collectionHooks: 'const x = 1;', - folderHooks: [ - { folderPathname: '/folder1', hooks: '' }, - { folderPathname: '/folder2', hooks: 'const y = 2;' }, - { folderPathname: '/folder3', hooks: ' ' } - ] - }); - - expect(result.levels).toHaveLength(2); - expect(result.levels[0].level).toBe(HOOK_LEVEL.COLLECTION); - expect(result.levels[1].level).toBe(HOOK_LEVEL.FOLDER); - expect(result.levels[1].identifier).toBe('/folder2'); - }); - - it('should include error collection in script', () => { - const result = buildConsolidatedScript({ - collectionHooks: 'const x = 1;' - }); - expect(result.script).toContain('const __consolidatedErrors = []'); - expect(result.script).toContain('__hookResult'); - }); - - it('should remove comments by default', () => { - const result = buildConsolidatedScript({ - collectionHooks: '// This is a comment\nconst x = 1;' - }); - expect(result.script).not.toContain('This is a comment'); - expect(result.script).toContain('const x = 1'); - }); - - it('should preserve comments when removeComments is false', () => { - const result = buildConsolidatedScript({ - collectionHooks: '// This is a comment\nconst x = 1;', - removeComments: false - }); - expect(result.script).toContain('This is a comment'); - }); - }); - - describe('getLevelMetadata', () => { - it('should return empty array for no hooks', () => { - expect(getLevelMetadata({})).toHaveLength(0); - }); - - it('should include collection level', () => { - const result = getLevelMetadata({ collectionHooks: 'const x = 1;' }); - expect(result).toHaveLength(1); - expect(result[0].level).toBe(HOOK_LEVEL.COLLECTION); - expect(result[0].identifier).toBe('root'); - }); - - it('should include all levels', () => { - const result = getLevelMetadata({ - collectionHooks: 'const x = 1;', - folderHooks: [ - { folderPathname: '/folder1', hooks: 'const y = 2;' } - ], - requestHooks: 'const z = 3;' - }); - - expect(result).toHaveLength(3); - expect(result[0].level).toBe(HOOK_LEVEL.COLLECTION); - expect(result[1].level).toBe(HOOK_LEVEL.FOLDER); - expect(result[1].identifier).toBe('/folder1'); - expect(result[2].level).toBe(HOOK_LEVEL.REQUEST); - }); - }); - - describe('fromExtractedHooks', () => { - it('should handle empty input', () => { - const result = fromExtractedHooks(null); - expect(result.collectionHooks).toBe(''); - expect(result.folderHooks).toEqual([]); - expect(result.requestHooks).toBe(''); - expect(result.removeComments).toBe(true); - }); - - it('should convert extracted hooks format', () => { - const extracted = { - collectionHooks: 'collection script', - folderHooks: [ - { folderPathname: '/folder1', hooks: 'folder script' } - ], - requestHooks: 'request script' - }; - - const result = fromExtractedHooks(extracted); - expect(result.collectionHooks).toBe('collection script'); - expect(result.folderHooks).toEqual(extracted.folderHooks); - expect(result.requestHooks).toBe('request script'); - expect(result.removeComments).toBe(true); - }); - }); - - describe('integration: consolidated script execution simulation', () => { - it('should generate valid JavaScript', () => { - const config = { - collectionHooks: ` - bru.hooks.http.onBeforeRequest((data) => { - console.log('Collection beforeRequest'); - }); - `, - folderHooks: [ - { - folderPathname: '/api/users', - hooks: ` - bru.hooks.http.onBeforeRequest((data) => { - console.log('Folder beforeRequest'); - }); - ` - } - ], - requestHooks: ` - bru.hooks.http.onAfterResponse((data) => { - console.log('Request afterResponse'); - }); - ` - }; - - const result = buildConsolidatedScript(config); - - // The script should be syntactically valid (this is a basic check) - expect(result.hasHooks).toBe(true); - expect(result.script).toContain('__consolidatedErrors'); - expect(result.script).toContain('await (async () =>'); - expect(result.levels).toHaveLength(3); - - // Try to parse it (without executing) to verify syntax - // Note: We can't actually execute it without the bru context - expect(() => { - // This will throw if syntax is invalid - new Function('bru', '__onHookError', '__hookResult', result.script); - }).not.toThrow(); - }); - }); -}); diff --git a/packages/bruno-js/tests/hooks-executor.spec.js b/packages/bruno-js/tests/hooks-executor.spec.js deleted file mode 100644 index aba3cda9a..000000000 --- a/packages/bruno-js/tests/hooks-executor.spec.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Unit tests for Hooks Executor - */ -const { - executeHooksForLevel, - executeConsolidatedHooks, - executeAllHookLevels, - createHookExecutor, - HOOK_EVENTS -} = require('../src/runtime/hooks-executor'); - -// Mock the HooksRuntime -jest.mock('../src/runtime/hooks-runtime', () => { - return jest.fn().mockImplementation(() => ({ - runHooks: jest.fn().mockResolvedValue({ - hookManager: { - call: jest.fn().mockResolvedValue(undefined), - dispose: jest.fn() - }, - envVariables: {}, - runtimeVariables: {}, - persistentEnvVariables: {}, - globalEnvironmentVariables: {} - }) - })); -}); - -describe('Hooks Executor', () => { - const mockOptions = { - request: { url: 'http://test.com' }, - envVariables: {}, - runtimeVariables: {}, - collectionPath: '/test/collection', - onConsoleLog: jest.fn(), - processEnvVars: {}, - scriptingConfig: { runtime: 'quickjs' }, - runRequestByItemPathname: jest.fn(), - collectionName: 'Test Collection' - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('HOOK_EVENTS', () => { - it('should have correct event names', () => { - expect(HOOK_EVENTS.HTTP_BEFORE_REQUEST).toBe('http:beforeRequest'); - expect(HOOK_EVENTS.HTTP_AFTER_RESPONSE).toBe('http:afterResponse'); - expect(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN).toBe('runner:beforeCollectionRun'); - expect(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN).toBe('runner:afterCollectionRun'); - }); - - it('should be frozen', () => { - expect(Object.isFrozen(HOOK_EVENTS)).toBe(true); - }); - }); - - describe('executeHooksForLevel()', () => { - it('should return null for empty hooks file', async () => { - const result = await executeHooksForLevel('', HOOK_EVENTS.HTTP_BEFORE_REQUEST, {}, mockOptions); - expect(result).toBeNull(); - }); - - it('should return null for whitespace-only hooks file', async () => { - const result = await executeHooksForLevel(' ', HOOK_EVENTS.HTTP_BEFORE_REQUEST, {}, mockOptions); - expect(result).toBeNull(); - }); - - it('should execute hooks for valid hooks file', async () => { - const HooksRuntime = require('../src/runtime/hooks-runtime'); - await executeHooksForLevel('console.log("test")', HOOK_EVENTS.HTTP_BEFORE_REQUEST, {}, mockOptions); - expect(HooksRuntime).toHaveBeenCalled(); - }); - - it('should handle errors gracefully', async () => { - const HooksRuntime = require('../src/runtime/hooks-runtime'); - HooksRuntime.mockImplementationOnce(() => ({ - runHooks: jest.fn().mockRejectedValue(new Error('Test error')) - })); - - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - const result = await executeHooksForLevel('test()', HOOK_EVENTS.HTTP_BEFORE_REQUEST, {}, mockOptions); - expect(result).toBeNull(); - consoleSpy.mockRestore(); - }); - }); - - describe('executeConsolidatedHooks()', () => { - it('should execute consolidated hooks', async () => { - const HooksRuntime = require('../src/runtime/hooks-runtime'); - const extractedHooks = { - collectionHooks: 'collection()', - folderHooks: [{ hooks: 'folder()' }], - requestHooks: 'request()' - }; - - await executeConsolidatedHooks(extractedHooks, HOOK_EVENTS.HTTP_BEFORE_REQUEST, {}, mockOptions); - expect(HooksRuntime).toHaveBeenCalled(); - }); - - it('should handle errors gracefully', async () => { - const HooksRuntime = require('../src/runtime/hooks-runtime'); - HooksRuntime.mockImplementationOnce(() => ({ - runHooks: jest.fn().mockRejectedValue(new Error('Test error')) - })); - - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - const result = await executeConsolidatedHooks( - { collectionHooks: 'test()', folderHooks: [], requestHooks: '' }, - HOOK_EVENTS.HTTP_BEFORE_REQUEST, - {}, - mockOptions - ); - expect(result).toBeNull(); - consoleSpy.mockRestore(); - }); - }); - - describe('executeAllHookLevels()', () => { - it('should always use consolidated execution', async () => { - const extractedHooks = { - collectionHooks: 'collection()', - folderHooks: [{ hooks: 'folder()' }], - requestHooks: 'request()' - }; - - const HooksRuntime = require('../src/runtime/hooks-runtime'); - await executeAllHookLevels( - extractedHooks, - HOOK_EVENTS.HTTP_BEFORE_REQUEST, - {}, - mockOptions - ); - - // Should always use consolidated execution - const runtimeInstance = HooksRuntime.mock.results[0].value; - expect(runtimeInstance.runHooks).toHaveBeenCalledWith( - expect.objectContaining({ consolidated: true }) - ); - }); - }); - - describe('createHookExecutor()', () => { - it('should create executor with base options', () => { - const executor = createHookExecutor(mockOptions); - expect(executor).toBeDefined(); - expect(typeof executor.executeLevel).toBe('function'); - expect(typeof executor.executeConsolidated).toBe('function'); - expect(typeof executor.executeAll).toBe('function'); - }); - - it('should allow overriding options', async () => { - const executor = createHookExecutor(mockOptions); - const newConsoleLog = jest.fn(); - - await executor.executeLevel('test()', HOOK_EVENTS.HTTP_BEFORE_REQUEST, {}, { - onConsoleLog: newConsoleLog - }); - - // The override should be merged with base options - // Actual verification depends on implementation details - }); - }); -});