refactor: streamline hook execution by merging scripts and removing unused functions

- Consolidated hook management by merging hooks from collection, folders, and requests into a single script using mergeScripts.
- Removed the HooksExecutor and HooksConsolidator, simplifying the execution flow.
- Updated runSingleRequest and network IPC to utilize the new merged hooks approach for improved performance and clarity.
- Enhanced comments for better understanding of the hook execution process.
This commit is contained in:
sanish-bruno
2026-01-23 15:50:15 +05:30
parent 57351b74ec
commit 5851693529
10 changed files with 132 additions and 1421 deletions

View File

@@ -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
);

View File

@@ -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
};

View File

@@ -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<object|null>} 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<object|null>} 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

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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<HookLevel>} 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
};

View File

@@ -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<HookExecutionResult|null>} 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<HookExecutionResult|null>} 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<HookExecutionResult|null>} 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
};

View File

@@ -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<object>} [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<object>} 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;

View File

@@ -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();
});
});
});

View File

@@ -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
});
});
});