diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0fa88640a..62ac81f6a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -100,12 +100,19 @@ jobs:
npm install
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
+ - name: Run comprehensive hooks tests
+ run: |
+ cd packages/bruno-tests/hooks-comprehensive-tests
+ node ../../bruno-cli/bin/bru.js run --env Prod --output junit-hooks.xml --format junit
+
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
- files: packages/bruno-tests/collection/junit.xml
+ files: |
+ packages/bruno-tests/collection/junit.xml
+ packages/bruno-tests/hooks-comprehensive-tests/junit-hooks.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
index a45375183..ad8977efa 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
-import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
+import { updateCollectionRequestScript, updateCollectionResponseScript, updateCollectionHooksScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
@@ -14,8 +14,10 @@ const Script = ({ collection }) => {
const dispatch = useDispatch();
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
+ const hooksEditorRef = useRef(null);
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
+ const hooks = collection.draft?.root ? get(collection, 'draft.root.request.script.hooks', '') : get(collection, 'root.request.script.hooks', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
@@ -45,6 +47,8 @@ const Script = ({ collection }) => {
preRequestEditorRef.current.editor.refresh();
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
+ } else if (activeTab === 'hooks' && hooksEditorRef.current?.editor) {
+ hooksEditorRef.current.editor.refresh();
}
}, 0);
@@ -69,6 +73,13 @@ const Script = ({ collection }) => {
);
};
+ const onHooksEdit = (value) => {
+ dispatch(updateCollectionHooksScript({
+ hooks: value,
+ collectionUid: collection.uid
+ }));
+ };
+
const handleSave = () => {
dispatch(saveCollectionSettings(collection.uid));
};
@@ -89,6 +100,10 @@ const Script = ({ collection }) => {
Post Response
{responseScript && responseScript.trim().length > 0 && }
+
+ Hooks
+ {hooks && hooks.trim().length > 0 && }
+
@@ -120,6 +135,21 @@ const Script = ({ collection }) => {
showHintsFor={['req', 'res', 'bru']}
/>
+
+
+
+
diff --git a/packages/bruno-app/src/components/CollectionSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/index.js
index dec9252c0..a368a2759 100644
--- a/packages/bruno-app/src/components/CollectionSettings/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/index.js
@@ -29,7 +29,7 @@ const CollectionSettings = ({ collection }) => {
};
const root = collection?.draft?.root || collection?.root;
- const hasScripts = root?.request?.script?.res || root?.request?.script?.req;
+ const hasScripts = root?.request?.script?.res || root?.request?.script?.req || root?.request?.script?.hooks;
const hasTests = root?.request?.tests;
const hasDocs = root?.docs;
diff --git a/packages/bruno-app/src/components/FolderSettings/Script/index.js b/packages/bruno-app/src/components/FolderSettings/Script/index.js
index 4bcddd199..8f9cec3b7 100644
--- a/packages/bruno-app/src/components/FolderSettings/Script/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Script/index.js
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
-import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
+import { updateFolderRequestScript, updateFolderResponseScript, updateFolderHooksScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
@@ -14,8 +14,10 @@ const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
+ const hooksEditorRef = useRef(null);
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
+ const hooks = folder.draft ? get(folder, 'draft.request.script.hooks', '') : get(folder, 'root.request.script.hooks', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
@@ -45,6 +47,8 @@ const Script = ({ collection, folder }) => {
preRequestEditorRef.current.editor.refresh();
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
+ } else if (activeTab === 'hooks' && hooksEditorRef.current?.editor) {
+ hooksEditorRef.current.editor.refresh();
}
}, 0);
@@ -71,6 +75,14 @@ const Script = ({ collection, folder }) => {
);
};
+ const onHooksEdit = (value) => {
+ dispatch(updateFolderHooksScript({
+ hooks: value,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ }));
+ };
+
const handleSave = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
@@ -91,6 +103,10 @@ const Script = ({ collection, folder }) => {
Post Response
{responseScript && responseScript.trim().length > 0 &&
}
+
+ Hooks
+ {hooks && hooks.trim().length > 0 && }
+
@@ -122,6 +138,21 @@ const Script = ({ collection, folder }) => {
showHintsFor={['req', 'res', 'bru']}
/>
+
+
+
+
diff --git a/packages/bruno-app/src/components/FolderSettings/index.js b/packages/bruno-app/src/components/FolderSettings/index.js
index e29f6e56e..f46f0ac55 100644
--- a/packages/bruno-app/src/components/FolderSettings/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/index.js
@@ -21,7 +21,7 @@ const FolderSettings = ({ collection, folder }) => {
}
const folderRoot = folder?.draft || folder?.root;
- const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
+ const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req || folderRoot?.request?.script?.hooks;
const hasTests = folderRoot?.request?.tests;
const headers = folderRoot?.request?.headers || [];
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
index 9bad0ec47..2a22a3dfd 100644
--- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
@@ -87,7 +87,7 @@ const HttpRequestPane = ({ item, collection }) => {
);
const indicators = useMemo(() => {
- const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
+ const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage || item.hooksScriptErrorMessage;
const hasTestError = item.testScriptErrorMessage;
return {
@@ -96,7 +96,7 @@ const HttpRequestPane = ({ item, collection }) => {
headers: activeCounts.headers > 0 ? {activeCounts.headers} : null,
auth: auth.mode !== 'none' ? : null,
vars: activeCounts.vars > 0 ? {activeCounts.vars} : null,
- script: (script.req || script.res) ? (hasScriptError ? : ) : null,
+ script: (script.req || script.res || script.hooks) ? (hasScriptError ? : ) : null,
assert: activeCounts.assertions > 0 ? {activeCounts.assertions} : null,
tests: tests?.length > 0 ? (hasTestError ? : ) : null,
docs: docs?.length > 0 ? : null,
diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js
index 1d49f394f..1f65dc808 100644
--- a/packages/bruno-app/src/components/RequestPane/Script/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Script/index.js
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
-import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
+import { updateRequestScript, updateResponseScript, updateRequestHooksScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
@@ -12,8 +12,10 @@ const Script = ({ item, collection }) => {
const dispatch = useDispatch();
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
+ const hooksEditorRef = useRef(null);
const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req');
const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res');
+ const hooks = item.draft ? get(item, 'draft.request.script.hooks', '') : get(item, 'request.script.hooks', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
@@ -44,6 +46,8 @@ const Script = ({ item, collection }) => {
preRequestEditorRef.current.editor.refresh();
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
+ } else if (activeTab === 'hooks' && hooksEditorRef.current?.editor) {
+ hooksEditorRef.current.editor.refresh();
}
}, 0);
@@ -70,6 +74,14 @@ const Script = ({ item, collection }) => {
);
};
+ const onHooksEdit = (value) => {
+ dispatch(updateRequestHooksScript({
+ hooks: value,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ }));
+ };
+
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
@@ -88,6 +100,10 @@ const Script = ({ item, collection }) => {
Post Response
{hasPostResponseScript && }
+
+ Hooks
+ {hooks && hooks.trim().length > 0 && }
+
@@ -121,6 +137,22 @@ const Script = ({ item, collection }) => {
showHintsFor={['req', 'res', 'bru']}
/>
+
+
+
+
);
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
index c542fb438..f0fd93e3a 100644
--- a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
@@ -4,9 +4,10 @@ import ErrorBanner from 'ui/ErrorBanner';
const ScriptError = ({ item, onClose }) => {
const preRequestError = item?.preRequestScriptErrorMessage;
const postResponseError = item?.postResponseScriptErrorMessage;
+ const hooksError = item?.hookScriptErrorMessage;
const testScriptError = item?.testScriptErrorMessage;
- if (!preRequestError && !postResponseError && !testScriptError) return null;
+ if (!preRequestError && !postResponseError && !testScriptError && !hooksError) return null;
const errors = [];
@@ -24,6 +25,13 @@ const ScriptError = ({ item, onClose }) => {
});
}
+ if (hooksError) {
+ errors.push({
+ title: 'Hooks Script Error',
+ message: hooksError
+ });
+ }
+
if (testScriptError) {
errors.push({
title: 'Test Script Error',
diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js
index 450c798c9..9c9db3fd3 100644
--- a/packages/bruno-app/src/components/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/index.js
@@ -86,10 +86,10 @@ const ResponsePane = ({ item, collection }) => {
});
useEffect(() => {
- if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage) {
+ if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage || item?.hookScriptErrorMessage) {
setShowScriptErrorCard(true);
}
- }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage]);
+ }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage, item?.hookScriptErrorMessage]);
const selectTab = (tab) => {
dispatch(
@@ -116,7 +116,7 @@ const ResponsePane = ({ item, collection }) => {
}, [response.size, response.dataBuffer]);
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
- const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
+ const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage || item?.hookScriptErrorMessage;
const allTabs = useMemo(() => {
return [
diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
index 7aedf12f9..460e49fbf 100644
--- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
@@ -21,17 +21,17 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const { requestSent, responseReceived, testResults, assertionResults, preRequestTestResults, postResponseTestResults, error } = item;
useEffect(() => {
- if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage) {
+ if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage || item?.hookScriptErrorMessage) {
setShowScriptErrorCard(true);
}
- }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage]);
+ }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage, item?.hookScriptErrorMessage]);
const headers = get(item, 'responseReceived.headers', []);
const status = get(item, 'responseReceived.status', 0);
const size = get(item, 'responseReceived.size', 0);
const duration = get(item, 'responseReceived.duration', 0);
- const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
+ const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage || item?.hookScriptErrorMessage;
const selectTab = (tab) => setSelectedTab(tab);
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index 7fe15aa08..38438743f 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -1716,6 +1716,20 @@ export const collectionsSlice = createSlice({
}
}
},
+ updateRequestHooksScript: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && isItemARequest(item)) {
+ if (!item.draft) {
+ item.draft = cloneDeep(item);
+ }
+ set(item.draft, 'request.script.hooks', action.payload.hooks);
+ }
+ }
+ },
updateRequestMethod: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -2078,6 +2092,18 @@ export const collectionsSlice = createSlice({
set(collection, 'draft.root.request.tests', action.payload.tests);
}
},
+ updateCollectionHooksScript: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ if (!collection.draft) {
+ collection.draft = {
+ root: cloneDeep(collection.root)
+ };
+ }
+ set(collection.draft, 'root.request.script.hooks', action.payload.hooks);
+ }
+ },
updateCollectionDocs: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -2334,6 +2360,16 @@ export const collectionsSlice = createSlice({
set(folder, 'draft.request.tests', action.payload.tests);
}
},
+ updateFolderHooksScript: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ if (!folder.draft) {
+ folder.draft = cloneDeep(folder.root);
+ }
+ set(folder.draft, 'request.script.hooks', action.payload.hooks);
+ }
+ },
updateFolderAuth: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (!collection) return;
@@ -2828,6 +2864,7 @@ export const collectionsSlice = createSlice({
item.preRequestScriptErrorMessage = null;
item.postResponseScriptErrorMessage = null;
item.testScriptErrorMessage = null;
+ item.hookScriptErrorMessage = null;
},
runRequestEvent: (state, action) => {
const { itemUid, collectionUid, type, requestUid } = action.payload;
@@ -2851,6 +2888,10 @@ export const collectionsSlice = createSlice({
item.testScriptErrorMessage = action.payload.errorMessage;
}
+ if (type === 'hooks-script-execution') {
+ item.hookScriptErrorMessage = action.payload.errorMessage;
+ }
+
if (type === 'request-queued') {
const { cancelTokenUid } = action.payload;
// ignore if request is already in progress or completed
@@ -2990,6 +3031,11 @@ export const collectionsSlice = createSlice({
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.preRequestScriptErrorMessage = action.payload.errorMessage;
}
+
+ if (type === 'hooks-script-execution') {
+ const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
+ item.hookScriptErrorMessage = action.payload.errorMessage;
+ }
}
},
resetCollectionRunner: (state, action) => {
@@ -3487,6 +3533,7 @@ export const {
updateRequestScript,
updateResponseScript,
updateRequestTests,
+ updateRequestHooksScript,
updateRequestMethod,
updateRequestProtoPath,
addAssertion,
@@ -3509,6 +3556,7 @@ export const {
updateFolderRequestScript,
updateFolderResponseScript,
updateFolderTests,
+ updateFolderHooksScript,
addCollectionHeader,
updateCollectionHeader,
deleteCollectionHeader,
@@ -3521,6 +3569,7 @@ export const {
updateCollectionRequestScript,
updateCollectionResponseScript,
updateCollectionTests,
+ updateCollectionHooksScript,
updateCollectionDocs,
updateCollectionProxy,
updateCollectionClientCertificates,
diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js
index 6db1fd370..206b0c861 100644
--- a/packages/bruno-app/src/utils/codemirror/autocomplete.js
+++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js
@@ -87,6 +87,13 @@ const STATIC_API_HINTS = {
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()',
+ 'bru.hooks',
+ 'bru.hooks.http',
+ 'bru.hooks.http.onBeforeRequest(callback)',
+ 'bru.hooks.http.onAfterResponse(callback)',
+ 'bru.hooks.runner',
+ 'bru.hooks.runner.onBeforeCollectionRun(callback)',
+ 'bru.hooks.runner.onAfterCollectionRun(callback)',
'bru.interpolate(str)',
'bru.cookies',
'bru.cookies.jar()',
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 2ef277128..6137e333b 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -522,6 +522,9 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
if (script?.res?.length) {
di.root.request.script.res = script?.res;
}
+ if (script?.hooks?.length) {
+ di.root.request.script.hooks = script?.hooks;
+ }
}
// folder level vars
if (Object.keys(vars)?.length) {
@@ -607,6 +610,9 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
if (script?.res?.length) {
collectionToSave.root.request.script.res = script?.res;
}
+ if (script?.hooks?.length) {
+ collectionToSave.root.request.script.hooks = script?.hooks;
+ }
}
// collection level vars
if (Object.keys(vars)?.length) {
diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js
index 78b9dc259..5040ae028 100644
--- a/packages/bruno-cli/src/commands/run.js
+++ b/packages/bruno-cli/src/commands/run.js
@@ -2,7 +2,7 @@ const fs = require('fs');
const chalk = require('chalk');
const path = require('path');
const yaml = require('js-yaml');
-const { forOwn, cloneDeep } = require('lodash');
+const { forOwn, cloneDeep, get } = require('lodash');
const { getRunnerSummary } = require('@usebruno/common/runner');
const { exists, isFile, isDirectory } = require('../utils/filesystem');
const { runSingleRequest } = require('../runner/run-single-request');
@@ -15,9 +15,10 @@ const { rpad } = require('../utils/common');
const { getOptions } = require('../utils/bru');
const { parseDotEnv, parseEnvironment } = require('@usebruno/filestore');
const constants = require('../constants');
-const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection');
+const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG, HOOK_EVENTS, getOrCreateHookManager } = require('../utils/collection');
const { hasExecutableTestInScript } = require('../utils/request');
const { createSkippedFileResults } = require('../utils/run');
+const HookManager = require('@usebruno/js/src/hook-manager');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
@@ -315,7 +316,7 @@ const handler = async function (argv) {
const collectionPath = process.cwd();
let collection = createCollectionJsonFromPathname(collectionPath);
- const { root: collectionRoot, brunoConfig } = collection;
+ let { root: collectionRoot, brunoConfig } = collection;
if (clientCertConfig) {
try {
@@ -612,6 +613,49 @@ const handler = async function (argv) {
});
const runtime = getJsSandboxRuntime(sandbox);
+ const scriptingConfig = get(brunoConfig, 'scripts', {});
+ scriptingConfig.runtime = runtime;
+
+ // Create HookManager map to share HookManagers across requests
+ const hookManagersMap = new Map();
+ const collectionName = collection?.brunoConfig?.name;
+ const onConsoleLog = (type, args) => {
+ console[type](...args);
+ };
+
+ // Register collection-level hooks once at the start
+ collectionRoot = collection?.draft?.root || collection?.root || {};
+ const collectionHooks = get(collectionRoot, 'request.script.hooks', '');
+ const collectionHookManagerKey = `collection:${collection.pathname}`;
+ let collectionHookManager = null;
+
+ if (collectionHooks && collectionHooks.trim()) {
+ const hookManagerOptions = {
+ request: {}, // Placeholder request for hook registration
+ envVariables: envVars,
+ runtimeVariables,
+ collectionPath,
+ onConsoleLog,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname: null, // Not available at collection level
+ collectionName
+ };
+ collectionHookManager = await getOrCreateHookManager(hookManagersMap, collectionHookManagerKey, collectionHooks, hookManagerOptions);
+ } else {
+ // Create empty HookManager for collection even if no hooks
+ collectionHookManager = new HookManager();
+ hookManagersMap.set(collectionHookManagerKey, collectionHookManager);
+ }
+
+ // Call onBeforeCollectionRun hook before starting to run requests
+ if (collectionHookManager) {
+ try {
+ await collectionHookManager.call(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN, { collection });
+ } catch (error) {
+ console.error('Error calling onBeforeCollectionRun hooks:', error);
+ }
+ }
const runSingleRequestByPathname = async (relativeItemPathname) => {
const ext = FORMAT_CONFIG[collection.format].ext;
@@ -632,7 +676,8 @@ const handler = async function (argv) {
collectionRoot,
runtime,
collection,
- runSingleRequestByPathname
+ runSingleRequestByPathname,
+ hookManagersMap
);
resolve(res?.response);
}
@@ -657,7 +702,8 @@ const handler = async function (argv) {
collectionRoot,
runtime,
collection,
- runSingleRequestByPathname
+ runSingleRequestByPathname,
+ hookManagersMap
);
const isLastRun = currentRequestIndex === requestItems.length - 1;
@@ -752,6 +798,18 @@ const handler = async function (argv) {
const skippedFileResults = createSkippedFileResults(global.brunoSkippedFiles || [], collectionPath);
results.push(...skippedFileResults);
+ // Call onAfterCollectionRun hook after all requests are done
+ if (collectionHookManager) {
+ try {
+ await collectionHookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection });
+ } catch (error) {
+ console.error('Error calling onAfterCollectionRun hooks:', error);
+ }
+ }
+
+ // Cleanup: Clear hook managers map (will be garbage collected)
+ hookManagersMap.clear();
+
const summary = printRunSummary(results);
const runCompletionTime = new Date().toISOString();
const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);
diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js
index 5fb8de3a7..d453fd517 100644
--- a/packages/bruno-cli/src/runner/run-single-request.js
+++ b/packages/bruno-cli/src/runner/run-single-request.js
@@ -6,9 +6,13 @@ 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 } = require('@usebruno/js');
+const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, HooksRuntime } = require('@usebruno/js');
+const HookManager = require('@usebruno/js/src/hook-manager');
+const BrunoRequest = require('@usebruno/js/src/bruno-request');
+const BrunoResponse = require('@usebruno/js/src/bruno-response');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
+const { extractHooks, getTreePathFromCollectionToItem, HOOK_EVENTS, getOrCreateHookManager } = require('../utils/collection');
const https = require('https');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
@@ -93,7 +97,8 @@ const runSingleRequest = async function (
collectionRoot,
runtime,
collection,
- runSingleRequestByPathname
+ runSingleRequestByPathname,
+ hookManagersMap
) {
const { pathname: itemPathname } = item;
const relativeItemPathname = path.relative(collectionPath, itemPathname);
@@ -164,9 +169,65 @@ const runSingleRequest = async function (
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = runtime;
+ // Get request tree path for hook extraction
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+ const collectionName = collection?.brunoConfig?.name;
+
+ // Get or create HookManagers for each level using shared map
+ let allHookManagers = [];
+ if (hookManagersMap) {
+ try {
+ const { collectionHooks, folderHooks, requestHooks } = extractHooks(collection, request, requestTreePath);
+
+ const hookManagerOptions = {
+ request,
+ envVars: envVariables, // Will be mapped to envVariables in runHooks
+ runtimeVariables,
+ collectionPath,
+ onConsoleLog,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname: runSingleRequestByPathname,
+ collectionName
+ };
+
+ // Collection-level HookManager (shared across all requests)
+ const collectionHookManagerKey = `collection:${collection.pathname}`;
+ const collectionHookManager = await getOrCreateHookManager(hookManagersMap, collectionHookManagerKey, collectionHooks, hookManagerOptions);
+
+ // Folder-level HookManagers (in order from collection to request)
+ const folderHookManagers = [];
+ for (const folderHook of folderHooks) {
+ // folderPathname is set by extractHooks (i.pathname)
+ const folderHookManagerKey = `folder:${folderHook.folderPathname}`;
+ const folderHookManager = await getOrCreateHookManager(hookManagersMap, folderHookManagerKey, folderHook.hooks, hookManagerOptions);
+ folderHookManagers.push(folderHookManager);
+ }
+
+ // Request-level HookManager (unique per request)
+ const requestHookManagerKey = item.pathname;
+ const requestHookManager = await getOrCreateHookManager(hookManagersMap, requestHookManagerKey, requestHooks, hookManagerOptions);
+
+ // Combine all HookManagers in order: collection -> folder(s) -> request
+ allHookManagers = [collectionHookManager, ...folderHookManagers, requestHookManager];
+ } catch (error) {
+ console.error('Error getting/creating hook managers:', error);
+ }
+ }
+
+ // Call beforeRequest hooks before running pre-request scripts
+ // Hooks are called in registration order: collection -> folder(s) -> request
+ for (const hookManager of allHookManagers) {
+ try {
+ const req = new BrunoRequest(request);
+ await hookManager.call(HOOK_EVENTS.HTTP_BEFORE_REQUEST, { request, req, collection });
+ } catch (error) {
+ console.error('Error calling beforeRequest hooks:', error);
+ }
+ }
+
// run pre request script
const requestScriptFile = get(request, 'script.req');
- const collectionName = collection?.brunoConfig?.name;
if (requestScriptFile?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runRequestScript(
@@ -190,6 +251,11 @@ const runSingleRequest = async function (
}
if (result?.skipRequest) {
+ // Clean up request-level hook manager if request is skipped
+ if (hookManagersMap && allHookManagers.length > 0) {
+ const requestHookManagerKey = item.pathname;
+ hookManagersMap.delete(requestHookManagerKey);
+ }
return {
test: {
filename: relativeItemPathname
@@ -546,6 +612,7 @@ const runSingleRequest = async function (
if (request.ntlmConfig) {
axiosInstance = NtlmClient(request.ntlmConfig, axiosInstance.defaults);
+
delete request.ntlmConfig;
}
@@ -639,6 +706,26 @@ const runSingleRequest = async function (
// Log pre-request test results
logResults(preRequestTestResults, 'Pre-Request Tests');
+ // Call afterResponse hooks after response is received but before post-response scripts
+ // Hooks are called in registration order: collection -> folder(s) -> request
+ for (const hookManager of allHookManagers) {
+ try {
+ const req = new BrunoRequest(request);
+ const res = new BrunoResponse(response);
+ await hookManager.call(HOOK_EVENTS.HTTP_AFTER_RESPONSE, { request, response, req, res, collection });
+ } catch (error) {
+ console.error('Error calling afterResponse hooks:', error);
+ }
+ }
+
+ // Clean up request-level hook manager after request completes
+ // Requests are only run once, so we can safely remove the hook manager to free memory
+ // TODO: we probably don't even have to store the request level hook manager in the first place
+ if (hookManagersMap && allHookManagers.length > 0) {
+ const requestHookManagerKey = item.pathname;
+ hookManagersMap.delete(requestHookManagerKey);
+ }
+
// run post-response vars
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
@@ -766,6 +853,11 @@ const runSingleRequest = async function (
shouldStopRunnerExecution
};
} catch (err) {
+ // Clean up request-level hook manager on error
+ if (hookManagersMap) {
+ const requestHookManagerKey = item.pathname;
+ hookManagersMap.delete(requestHookManagerKey);
+ }
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js
index 9d06ee54e..1f38fcb38 100644
--- a/packages/bruno-cli/src/utils/collection.js
+++ b/packages/bruno-cli/src/utils/collection.js
@@ -6,6 +6,9 @@ const { sanitizeName } = require('./filesystem');
const { parseRequest, parseCollection, parseFolder, stringifyCollection, stringifyFolder, stringifyEnvironment, stringifyRequest } = require('@usebruno/filestore');
const constants = require('../constants');
const chalk = require('chalk');
+const { HooksRuntime } = require('@usebruno/js');
+const HookManager = require('@usebruno/js/src/hook-manager');
+const decomment = require('decomment');
const FORMAT_CONFIG = {
yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' },
@@ -369,6 +372,117 @@ 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.
+ */
+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'
+});
+
+/**
+ * Get or create HookManager for a specific level (collection, folder, or request)
+ * @param {Map} hookManagersMap - Map storing HookManagers by key
+ * @param {string} key - Unique identifier (collection:${pathname}, folder:${pathname}, or request uid/pathname)
+ * @param {string} hooksFile - Hooks file content for this level
+ * @param {object} options - Options for hook registration
+ * @param {object} options.request - Request object
+ * @param {object} options.envVars - Environment variables (or envVariables)
+ * @param {object} options.runtimeVariables - Runtime variables
+ * @param {string} options.collectionPath - Collection path
+ * @param {function} options.onConsoleLog - Console log callback
+ * @param {object} options.processEnvVars - Process environment variables
+ * @param {object} options.scriptingConfig - Scripting configuration
+ * @param {function} options.runRequestByItemPathname - Function to run requests
+ * @param {string} options.collectionName - Collection name
+ * @returns {Promise
} HookManager instance for this level
+ */
+const getOrCreateHookManager = async (hookManagersMap, key, hooksFile, options = {}) => {
+ // Return existing HookManager if already created
+ if (hookManagersMap.has(key)) {
+ return hookManagersMap.get(key);
+ }
+
+ // Create new HookManager and register hooks
+ const hookManager = new HookManager();
+ hookManagersMap.set(key, hookManager);
+
+ if (hooksFile && hooksFile.trim()) {
+ const hooksRuntime = new HooksRuntime({ runtime: options.scriptingConfig?.runtime });
+ try {
+ await hooksRuntime.runHooks({
+ hooksFile: decomment(hooksFile),
+ hookManager,
+ request: options.request || {},
+ envVariables: options.envVars || options.envVariables || {},
+ runtimeVariables: options.runtimeVariables || {},
+ collectionPath: options.collectionPath,
+ onConsoleLog: options.onConsoleLog,
+ processEnvVars: options.processEnvVars || {},
+ scriptingConfig: options.scriptingConfig || {},
+ runRequestByItemPathname: options.runRequestByItemPathname,
+ collectionName: options.collectionName
+ });
+ } catch (error) {
+ console.error(`Error registering hooks for ${key}:`, error);
+ if (options.onConsoleLog) {
+ options.onConsoleLog('error', [`Error registering hooks for ${key}: ${error.message}`]);
+ }
+ }
+ }
+
+ return hookManager;
+};
+
const getAllRequestsInFolder = (folderItems = [], recursive = true) => {
let requests = [];
@@ -598,5 +712,8 @@ module.exports = {
mergeAuth,
getAllRequestsInFolder,
getAllRequestsAtFolderRoot,
- getCallStack
+ getCallStack,
+ extractHooks,
+ HOOK_EVENTS,
+ getOrCreateHookManager
};
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index 7aea48f39..97aedcccf 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -8,7 +8,10 @@ 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 } = require('@usebruno/js');
+const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime, HooksRuntime } = require('@usebruno/js');
+// BrunoRequest and BrunoResponse are not exported from main index, require directly
+const BrunoRequest = require('@usebruno/js/src/bruno-request');
+const BrunoResponse = require('@usebruno/js/src/bruno-response');
const { encodeUrl } = require('@usebruno/common').utils;
const { extractPromptVariables } = require('@usebruno/common').utils;
const { interpolateString } = require('./interpolate-string');
@@ -24,7 +27,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 } = require('../../utils/collection');
+const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence, extractHooks, HOOK_EVENTS, getOrCreateHookManager } = require('../../utils/collection');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials } = require('../../utils/oauth2');
const { preferencesUtil } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
@@ -36,6 +39,7 @@ const registerGrpcEventHandlers = require('./grpc-event-handlers');
const { registerWsEventHandlers } = require('./ws-event-handlers');
const { getCertsAndProxyConfig } = require('./cert-utils');
const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils;
+const HookManager = require('@usebruno/js/src/hook-manager');
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
@@ -443,8 +447,189 @@ const registerNetworkIpc = (mainWindow) => {
});
};
- const runPreRequest = async (
+ /**
+ * Register all hooks along the request path tree to a new HookManager instance.
+ * Hooks are registered in order: collection -> folder(s) -> request
+ * The HookManager will be garbage collected after the request run completes.
+ *
+ * @param {object} options - Configuration options
+ * @param {object} options.collection - Collection object
+ * @param {object} options.request - Request object
+ * @param {array} options.requestTreePath - Path from collection to request
+ * @param {object} options.envVars - Environment variables
+ * @param {object} options.runtimeVariables - Runtime variables
+ * @param {string} options.collectionPath - Collection path
+ * @param {object} options.processEnvVars - Process environment variables
+ * @param {object} options.scriptingConfig - Scripting configuration
+ * @param {function} options.runRequestByItemPathname - Function to run requests
+ * @param {string} options.collectionName - Collection name
+ * @param {function} options.onConsoleLog - Console log callback
+ * @returns {HookManager} HookManager instance with all hooks registered
+ */
+ /**
+ * Generic function to register hooks from a hooks file to a HookManager
+ * @param {object} options - Configuration options
+ * @param {string} options.hooksFile - The hooks script content
+ * @param {HookManager} options.hookManager - HookManager instance to register hooks to
+ * @param {object} options.request - Request object (used for variable extraction)
+ * @param {object} options.envVars - Environment variables
+ * @param {object} options.runtimeVariables - Runtime variables
+ * @param {string} options.collectionPath - Collection path
+ * @param {object} options.processEnvVars - Process environment variables
+ * @param {object} options.scriptingConfig - Scripting configuration
+ * @param {function} options.runRequestByItemPathname - Function to run requests
+ * @param {string} options.collectionName - Collection name
+ * @param {function} options.onConsoleLog - Console log callback
+ * @returns {Promise}
+ */
+ const registerHooks = async ({
+ hooksFile,
+ hookManager,
request,
+ envVars,
+ runtimeVariables,
+ collectionPath,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName,
+ onConsoleLog
+ }) => {
+ if (!hooksFile || !hooksFile.trim()) {
+ return;
+ }
+
+ const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime });
+
+ await hooksRuntime.runHooks({
+ hooksFile: decomment(hooksFile),
+ request,
+ envVariables: envVars,
+ runtimeVariables,
+ collectionPath,
+ onConsoleLog,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName,
+ hookManager
+ });
+ };
+
+ /**
+ * Register all hooks for a standalone request run
+ * Creates a single HookManager with all hooks (collection, folder, request)
+ * @param {object} options - Configuration options
+ * @returns {Promise} HookManager instance with all hooks registered
+ */
+ const registerHooksForRequest = async ({
+ collection,
+ request,
+ requestTreePath,
+ envVars,
+ runtimeVariables,
+ collectionPath,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName,
+ onConsoleLog,
+ requestUid,
+ itemUid,
+ collectionUid,
+ runInBackground,
+ notifyScriptExecution
+ }) => {
+ // Create a new HookManager for this request run
+ const hookManager = new HookManager();
+
+ // Extract hooks from different levels
+ const { collectionHooks, folderHooks, requestHooks } = extractHooks(collection, request, requestTreePath);
+
+ // Register collection-level hooks
+ try {
+ await registerHooks({
+ hooksFile: collectionHooks,
+ hookManager,
+ request,
+ envVars,
+ runtimeVariables,
+ collectionPath,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName,
+ onConsoleLog
+ });
+ } catch (error) {
+ console.error('Error registering collection hooks:', error);
+ onConsoleLog?.('error', [`Error registering collection hooks: ${error.message}`]);
+ !runInBackground && notifyScriptExecution && notifyScriptExecution({
+ channel: 'main:run-request-event',
+ basePayload: { requestUid, collectionUid, itemUid },
+ scriptType: 'hooks',
+ error
+ });
+ }
+
+ // Register folder-level hooks (in order from collection to request)
+ for (const folderHook of folderHooks) {
+ try {
+ await registerHooks({
+ hooksFile: folderHook.hooks,
+ hookManager,
+ request,
+ envVars,
+ runtimeVariables,
+ collectionPath,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName,
+ onConsoleLog
+ });
+ } catch (error) {
+ console.error('Error registering folder hooks:', error);
+ onConsoleLog?.('error', [`Error registering folder hooks: ${error.message}`]);
+ !runInBackground && notifyScriptExecution && notifyScriptExecution({
+ channel: 'main:run-request-event',
+ basePayload: { requestUid, collectionUid, itemUid },
+ scriptType: 'hooks',
+ error
+ });
+ }
+ }
+
+ // Register request-level hooks
+ try {
+ await registerHooks({
+ hooksFile: requestHooks,
+ hookManager,
+ request,
+ envVars,
+ runtimeVariables,
+ collectionPath,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName,
+ onConsoleLog
+ });
+ } catch (error) {
+ console.error('Error registering request hooks:', error);
+ onConsoleLog?.('error', [`Error registering request hooks: ${error.message}`]);
+ !runInBackground && notifyScriptExecution && notifyScriptExecution({
+ channel: 'main:run-request-event',
+ basePayload: { requestUid, collectionUid, itemUid },
+ scriptType: 'hooks',
+ error
+ });
+ }
+
+ return hookManager;
+ };
+
+ const runPreRequest = async (request,
requestUid,
envVars,
collectionPath,
@@ -676,10 +861,53 @@ const registerNetworkIpc = (mainWindow) => {
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
+ // Get request tree path for hooks registration
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+
try {
request.signal = abortController.signal;
saveCancelToken(cancelTokenUid, abortController);
+ // Register all hooks along the request path tree to a new HookManager for this request run
+ const hookManager = await registerHooksForRequest({
+ collection,
+ request,
+ requestTreePath,
+ envVars,
+ runtimeVariables,
+ collectionPath,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName: collection?.name,
+ onConsoleLog,
+ requestUid,
+ itemUid: item.uid,
+ collectionUid,
+ runInBackground,
+ notifyScriptExecution
+ });
+
+ // Call pre-request hooks before running pre-request scripts
+ // Hooks are called in registration order: collection -> folder(s) -> request
+ if (hookManager) {
+ try {
+ // Create req object for hook callbacks (res is not available yet)
+ const req = new BrunoRequest(request);
+ await hookManager.call(HOOK_EVENTS.HTTP_BEFORE_REQUEST, { request, req, collection, collectionUid });
+ } catch (error) {
+ console.error('Error calling pre-request hooks:', error);
+ onConsoleLog?.('error', [`Error calling pre-request hooks: ${error.message}`]);
+
+ !runInBackground && notifyScriptExecution({
+ channel: 'main:run-request-event',
+ basePayload: { requestUid, collectionUid, itemUid: item.uid },
+ scriptType: 'hooks',
+ error
+ });
+ }
+ }
+
let preRequestScriptResult = null;
let preRequestError = null;
try {
@@ -844,6 +1072,27 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
cookiesStore.saveCookieJar();
+ // Call post-response hooks after response is received but before post-response scripts
+ // Use the same HookManager that was created for this request run
+ if (hookManager) {
+ try {
+ // Create req and res objects for hook callbacks
+ const req = new BrunoRequest(request);
+ const res = new BrunoResponse(response);
+ await hookManager.call(HOOK_EVENTS.HTTP_AFTER_RESPONSE, { request, response, req, res, collection, collectionUid });
+ } catch (error) {
+ console.error('Error calling post-response hooks:', error);
+ onConsoleLog?.('error', [`Error calling post-response hooks: ${error.message}`]);
+
+ !runInBackground && notifyScriptExecution({
+ channel: 'main:run-request-event',
+ basePayload: { requestUid, collectionUid, itemUid: item.uid },
+ scriptType: 'hooks',
+ error
+ });
+ }
+ }
+
const runPostScripts = async () => {
let postResponseScriptResult = null;
let postResponseError = null;
@@ -1163,6 +1412,57 @@ const registerNetworkIpc = (mainWindow) => {
folder = collection;
}
+ // Create a map to store HookManagers for this collection/folder run
+ // Key format: 'collection:', 'folder:', 'request:'
+ const hookManagersMap = new Map();
+
+ // Register collection-level hooks immediately
+ const collectionHookManagerOptions = {
+ request: {}, // Placeholder request for hook registration
+ envVars,
+ runtimeVariables,
+ collectionPath,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName: collection?.name,
+ onConsoleLog: (type, args) => {
+ console[type](...args);
+ mainWindow.webContents.send('main:console-log', {
+ type,
+ args
+ });
+ }
+ };
+
+ const collectionRoot = collection?.draft?.root || collection?.root || {};
+ const collectionHooks = get(collectionRoot, 'request.script.hooks', '');
+ const collectionHookManagerKey = `collection:${collectionPath}`;
+ await getOrCreateHookManager(hookManagersMap, collectionHookManagerKey, collectionHooks, collectionHookManagerOptions);
+ const isCollectionRun = folder?.uid === collection?.uid;
+
+ // If a folder is being run (not the collection itself), register folder hooks along the folder path tree
+ if (folder && !isCollectionRun) {
+ const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
+
+ // Extract folder hooks from the folder tree path
+ for (const pathItem of folderTreePath) {
+ if (pathItem.type === 'folder') {
+ const folderRoot = pathItem?.draft || pathItem?.root;
+ const folderHooks = get(folderRoot, 'request.script.hooks', '');
+ if (folderHooks && folderHooks.trim() !== '') {
+ const folderHookManagerKey = `folder:${pathItem.pathname}`;
+ await getOrCreateHookManager(hookManagersMap, folderHookManagerKey, folderHooks, collectionHookManagerOptions);
+ }
+ }
+ }
+ }
+
+ if (isCollectionRun) {
+ const collectionHookManager = hookManagersMap.get(collectionHookManagerKey);
+ await collectionHookManager.call(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN, { collection, collectionUid });
+ }
+
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-started',
isRecursive: recursive,
@@ -1216,6 +1516,7 @@ const registerNetworkIpc = (mainWindow) => {
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
+
while (currentRequestIndex < folderRequests.length) {
// user requested to cancel runner
if (abortController.signal.aborted) {
@@ -1287,7 +1588,69 @@ const registerNetworkIpc = (mainWindow) => {
continue;
}
+ // Get request tree path for hooks registration
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+ const { collectionHooks, folderHooks, requestHooks } = extractHooks(collection, request, requestTreePath);
+
+ // Get or create HookManagers for each level
+ const hookManagerOptions = {
+ request,
+ envVars,
+ runtimeVariables,
+ collectionPath,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName: collection?.name,
+ onConsoleLog: (type, args) => {
+ console[type](...args);
+ mainWindow.webContents.send('main:console-log', {
+ type,
+ args
+ });
+ },
+ notifyScriptExecution,
+ eventData
+ };
+
+ // Collection-level HookManager
+ const collectionHookManagerKey = `collection:${collectionPath}`;
+ const collectionHookManager = await getOrCreateHookManager(hookManagersMap, collectionHookManagerKey, collectionHooks, hookManagerOptions);
+
+ // Folder-level HookManagers (in order from collection to request)
+ const folderHookManagers = [];
+ for (const folderHook of folderHooks) {
+ const folderHookManagerKey = `folder:${folderHook.folderPathname}`;
+ const folderHookManager = await getOrCreateHookManager(hookManagersMap, folderHookManagerKey, folderHook.hooks, hookManagerOptions);
+ folderHookManagers.push(folderHookManager);
+ }
+
+ // Request-level HookManager
+ const requestHookManagerKey = item.pathname;
+ const requestHookManager = await getOrCreateHookManager(hookManagersMap, requestHookManagerKey, requestHooks, hookManagerOptions);
+
+ // Combine all HookManagers in order: collection -> folders -> request
+ const allHookManagers = [collectionHookManager, ...folderHookManagers, requestHookManager];
+
try {
+ // Call pre-request hooks before running pre-request scripts
+ // Hooks are called in registration order: collection -> folder(s) -> request
+ for (const hookManager of allHookManagers) {
+ try {
+ const req = new BrunoRequest(request);
+ await hookManager.call(HOOK_EVENTS.HTTP_BEFORE_REQUEST, { request, req, collection, collectionUid });
+ } catch (error) {
+ console.error('Error calling pre-request hooks:', error);
+
+ notifyScriptExecution({
+ channel: 'main:run-folder-event',
+ basePayload: eventData,
+ scriptType: 'hooks',
+ error
+ });
+ }
+ }
+
let preRequestScriptResult;
let preRequestError = null;
try {
@@ -1339,6 +1702,10 @@ const registerNetworkIpc = (mainWindow) => {
}
if (preRequestScriptResult?.skipRequest) {
+ // Clean up request-level hook manager if request is skipped
+ if (hookManagersMap) {
+ hookManagersMap.delete(item.pathname);
+ }
mainWindow.webContents.send('main:run-folder-event', {
type: 'runner-request-skipped',
error: 'Request has been skipped from pre-request script',
@@ -1509,6 +1876,32 @@ const registerNetworkIpc = (mainWindow) => {
}
}
+ // Call post-response hooks after response is received but before post-response scripts
+ // Hooks are called in registration order: collection -> folder(s) -> request
+ for (const hookManager of allHookManagers) {
+ try {
+ const req = new BrunoRequest(request);
+ const res = new BrunoResponse(response);
+ await hookManager.call(HOOK_EVENTS.HTTP_AFTER_RESPONSE, { request, response, req, res, collection, collectionUid });
+ } catch (error) {
+ console.error('Error calling post-response hooks:', error);
+
+ notifyScriptExecution({
+ channel: 'main:run-folder-event',
+ basePayload: eventData,
+ scriptType: 'hooks',
+ error
+ });
+ }
+ }
+
+ // Clean up request-level hook manager after request completes
+ // Requests are only run once, so we can safely remove the hook manager to free memory
+ // TODO: we probably don't even have to store the request level hook manager in the first place
+ if (hookManagersMap) {
+ hookManagersMap.delete(item.pathname);
+ }
+
let postResponseScriptResult;
let postResponseError = null;
try {
@@ -1689,6 +2082,17 @@ const registerNetworkIpc = (mainWindow) => {
}
}
+ // Call collection run end hooks
+ if (isCollectionRun) {
+ const collectionHookManager = hookManagersMap.get(collectionHookManagerKey);
+ if (collectionHookManager) {
+ await collectionHookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection, collectionUid });
+ }
+ }
+
+ // Cleanup: Clear hook managers map (will be garbage collected)
+ hookManagersMap.clear();
+
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
@@ -1698,6 +2102,18 @@ const registerNetworkIpc = (mainWindow) => {
});
} catch (error) {
console.log('error', error);
+
+ // Call collection run end hooks even on error
+ if (isCollectionRun) {
+ const collectionHookManager = hookManagersMap.get(collectionHookManagerKey);
+ if (collectionHookManager) {
+ await collectionHookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection, collectionUid });
+ }
+ }
+
+ // Cleanup: Clear hook managers map even on error
+ hookManagersMap.clear();
+
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js
index 016786d29..203abff7f 100644
--- a/packages/bruno-electron/src/utils/collection.js
+++ b/packages/bruno-electron/src/utils/collection.js
@@ -4,6 +4,8 @@ const { getRequestUid, getExampleUid } = require('../cache/requestUids');
const { uuid } = require('./common');
const os = require('os');
const { preferencesUtil } = require('../store/preferences');
+const { HooksRuntime, HookManager } = require('@usebruno/js');
+const decomment = require('decomment');
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
@@ -227,6 +229,126 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
}
};
+/**
+ * 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.
+ */
+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'
+});
+
+/**
+ * Get or create HookManager for a specific level (collection, folder, or request)
+ * @param {Map} hookManagersMap - Map storing HookManagers by key
+ * @param {string} uid - Unique identifier (collectionUid, folderUid, or requestUid)
+ * @param {string} hooksFile - Hooks file content for this level
+ * @param {object} options - Options for hook registration
+ * @param {object} options.request - Request object
+ * @param {object} options.envVars - Environment variables
+ * @param {object} options.runtimeVariables - Runtime variables
+ * @param {string} options.collectionPath - Collection path
+ * @param {function} options.onConsoleLog - Console log callback
+ * @param {object} options.processEnvVars - Process environment variables
+ * @param {object} options.scriptingConfig - Scripting configuration
+ * @param {function} options.runRequestByItemPathname - Function to run requests
+ * @param {string} options.collectionName - Collection name
+ * @returns {Promise} HookManager instance for this level
+ */
+const getOrCreateHookManager = async (hookManagersMap, uid, hooksFile, options = {}) => {
+ // Return existing HookManager if already created
+ if (hookManagersMap.has(uid)) {
+ return hookManagersMap.get(uid);
+ }
+
+ // Create new HookManager and register hooks
+ const hookManager = new HookManager();
+ hookManagersMap.set(uid, hookManager);
+
+ if (hooksFile && hooksFile.trim()) {
+ const hooksRuntime = new HooksRuntime({ runtime: options.scriptingConfig?.runtime });
+ try {
+ await hooksRuntime.runHooks({
+ hooksFile: decomment(hooksFile),
+ hookManager,
+ request: options.request || {},
+ envVariables: options.envVars || options.envVariables || {},
+ runtimeVariables: options.runtimeVariables || {},
+ collectionPath: options.collectionPath,
+ onConsoleLog: options.onConsoleLog,
+ processEnvVars: options.processEnvVars || {},
+ scriptingConfig: options.scriptingConfig || {},
+ runRequestByItemPathname: options.runRequestByItemPathname,
+ collectionName: options.collectionName
+ });
+ } catch (error) {
+ console.error(`Error registering hooks for ${uid}:`, error);
+ if (options.onConsoleLog) {
+ options.onConsoleLog('error', [`Error registering hooks for ${uid}: ${error.message}`]);
+ }
+ // Notify frontend if notification parameters are provided (for collection runner)
+ if (options.notifyScriptExecution && options.eventData) {
+ options.notifyScriptExecution({
+ channel: 'main:run-folder-event',
+ basePayload: options.eventData,
+ scriptType: 'hooks',
+ error
+ });
+ }
+ }
+ }
+
+ return hookManager;
+};
+
const flattenItems = (items = []) => {
const flattenedItems = [];
@@ -752,5 +874,8 @@ module.exports = {
getEnvVars,
getFormattedCollectionOauth2Credentials,
sortByNameThenSequence,
- resolveInheritedSettings
+ resolveInheritedSettings,
+ extractHooks,
+ HOOK_EVENTS,
+ getOrCreateHookManager
};
diff --git a/packages/bruno-filestore/src/formats/bru/index.ts b/packages/bruno-filestore/src/formats/bru/index.ts
index ba000e1d6..91833228c 100644
--- a/packages/bruno-filestore/src/formats/bru/index.ts
+++ b/packages/bruno-filestore/src/formats/bru/index.ts
@@ -279,7 +279,8 @@ export const stringifyBruCollection = (json: any, isFolder?: boolean): string =>
headers: _.get(json, 'request.headers', []),
script: {
req: _.get(json, 'request.script.req', ''),
- res: _.get(json, 'request.script.res', '')
+ res: _.get(json, 'request.script.res', ''),
+ hooks: _.get(json, 'request.script.hooks', '')
},
vars: {
req: _.get(json, 'request.vars.req', []),
diff --git a/packages/bruno-filestore/src/formats/yml/common/scripts.ts b/packages/bruno-filestore/src/formats/yml/common/scripts.ts
index 70e6b9ad1..943296b7d 100644
--- a/packages/bruno-filestore/src/formats/yml/common/scripts.ts
+++ b/packages/bruno-filestore/src/formats/yml/common/scripts.ts
@@ -19,6 +19,12 @@ export const toOpenCollectionScripts = (request: BrunoFolderRequest | BrunoHttpR
code: request.script.res.trim()
});
}
+ if (request?.script?.hooks?.trim().length) {
+ ocScripts.push({
+ type: 'hooks',
+ code: request.script.hooks.trim()
+ });
+ }
if (request?.tests?.trim().length) {
ocScripts.push({
type: 'tests',
@@ -30,7 +36,7 @@ export const toOpenCollectionScripts = (request: BrunoFolderRequest | BrunoHttpR
};
export const toBrunoScripts = (scripts: Scripts | null | undefined): {
- script?: { req?: string; res?: string };
+ script?: { req?: string; res?: string; hooks?: string };
tests?: string;
} | undefined => {
if (!scripts || !Array.isArray(scripts) || scripts.length === 0) {
@@ -38,7 +44,7 @@ export const toBrunoScripts = (scripts: Scripts | null | undefined): {
}
const brunoScripts: {
- script?: { req?: string; res?: string };
+ script?: { req?: string; res?: string; hooks?: string };
tests?: string;
} = {};
@@ -58,6 +64,12 @@ export const toBrunoScripts = (scripts: Scripts | null | undefined): {
if (script.type === 'tests' && script.code) {
brunoScripts.tests = script.code;
}
+ if (script.type === 'hooks' && script.code) {
+ if (!brunoScripts.script) {
+ brunoScripts.script = {};
+ }
+ brunoScripts.script.hooks = script.code;
+ }
}
return Object.keys(brunoScripts).length > 0 ? brunoScripts : undefined;
diff --git a/packages/bruno-filestore/src/formats/yml/items/parseGraphQLRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseGraphQLRequest.ts
index dc698bc20..a216f1c3f 100644
--- a/packages/bruno-filestore/src/formats/yml/items/parseGraphQLRequest.ts
+++ b/packages/bruno-filestore/src/formats/yml/items/parseGraphQLRequest.ts
@@ -37,7 +37,8 @@ const parseGraphQLRequest = (ocRequest: GraphQLRequest): BrunoItem => {
},
script: {
req: null,
- res: null
+ res: null,
+ hooks: null
},
vars: {
req: [],
@@ -57,6 +58,9 @@ const parseGraphQLRequest = (ocRequest: GraphQLRequest): BrunoItem => {
if (scripts.script.res) {
brunoRequest.script.res = scripts.script.res;
}
+ if (scripts.script.hooks) {
+ brunoRequest.script.hooks = scripts.script.hooks;
+ }
}
if (scripts?.tests) {
brunoRequest.tests = scripts.tests;
diff --git a/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts b/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts
index dc7a4ddbd..afee8c4bf 100644
--- a/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts
+++ b/packages/bruno-filestore/src/formats/yml/items/parseHttpRequest.ts
@@ -35,7 +35,8 @@ const parseHttpRequest = (ocRequest: HttpRequest): BrunoItem => {
},
script: {
req: null,
- res: null
+ res: null,
+ hooks: null
},
vars: {
req: [],
@@ -55,6 +56,9 @@ const parseHttpRequest = (ocRequest: HttpRequest): BrunoItem => {
if (scripts.script.res) {
brunoRequest.script.res = scripts.script.res;
}
+ if (scripts.script.hooks) {
+ brunoRequest.script.hooks = scripts.script.hooks;
+ }
}
if (scripts?.tests) {
brunoRequest.tests = scripts.tests;
diff --git a/packages/bruno-filestore/src/formats/yml/parseCollection.ts b/packages/bruno-filestore/src/formats/yml/parseCollection.ts
index 8fe96a7c4..edda74b63 100644
--- a/packages/bruno-filestore/src/formats/yml/parseCollection.ts
+++ b/packages/bruno-filestore/src/formats/yml/parseCollection.ts
@@ -139,7 +139,8 @@ const parseCollection = (ymlString: string): ParsedCollection => {
auth: null,
script: {
req: null,
- res: null
+ res: null,
+ hooks: null
},
vars: {
req: [],
@@ -171,6 +172,9 @@ const parseCollection = (ymlString: string): ParsedCollection => {
if (scripts.script.res) {
collectionRoot.request.script.res = scripts.script.res;
}
+ if (scripts.script.hooks) {
+ collectionRoot.request.script.hooks = scripts.script.hooks;
+ }
}
if (scripts?.tests) {
collectionRoot.request.tests = scripts.tests;
diff --git a/packages/bruno-filestore/src/formats/yml/parseFolder.ts b/packages/bruno-filestore/src/formats/yml/parseFolder.ts
index 4c8323706..e666a1f39 100644
--- a/packages/bruno-filestore/src/formats/yml/parseFolder.ts
+++ b/packages/bruno-filestore/src/formats/yml/parseFolder.ts
@@ -29,7 +29,8 @@ const parseFolder = (ymlString: string): FolderRoot => {
auth: null,
script: {
req: null,
- res: null
+ res: null,
+ hooks: null
},
vars: {
req: [],
@@ -63,6 +64,9 @@ const parseFolder = (ymlString: string): FolderRoot => {
if (scripts.script.res) {
folderRoot.request.script.res = scripts.script.res;
}
+ if (scripts.script.hooks) {
+ folderRoot.request.script.hooks = scripts.script.hooks;
+ }
}
if (scripts?.tests) {
folderRoot.request.tests = scripts.tests;
diff --git a/packages/bruno-filestore/src/formats/yml/stringifyCollection.ts b/packages/bruno-filestore/src/formats/yml/stringifyCollection.ts
index c3ae1cdaf..baf26585e 100644
--- a/packages/bruno-filestore/src/formats/yml/stringifyCollection.ts
+++ b/packages/bruno-filestore/src/formats/yml/stringifyCollection.ts
@@ -50,6 +50,7 @@ const hasRequestAuth = (collectionRoot: any): boolean => {
const hasRequestScripts = (collectionRoot: any): boolean => {
return (collectionRoot.request?.script?.req)
|| (collectionRoot.request?.script?.res)
+ || (collectionRoot.request?.script?.hooks)
|| (collectionRoot.request?.tests);
};
diff --git a/packages/bruno-filestore/src/formats/yml/stringifyFolder.ts b/packages/bruno-filestore/src/formats/yml/stringifyFolder.ts
index 493d7ac65..ff202fcd7 100644
--- a/packages/bruno-filestore/src/formats/yml/stringifyFolder.ts
+++ b/packages/bruno-filestore/src/formats/yml/stringifyFolder.ts
@@ -27,6 +27,7 @@ const hasRequestAuth = (folderRoot: FolderRoot): boolean => {
const hasRequestScripts = (folderRoot: FolderRoot): boolean => {
return Boolean((folderRoot.request?.script?.req)
|| (folderRoot.request?.script?.res)
+ || (folderRoot.request?.script?.hooks)
|| (folderRoot.request?.tests));
};
diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js
index 9f9cfe45e..fef697295 100644
--- a/packages/bruno-js/src/bru.js
+++ b/packages/bruno-js/src/bru.js
@@ -7,7 +7,10 @@ const { jar: createCookieJar } = require('@usebruno/requests').cookies;
const variableNameRegex = /^[\w-.]*$/;
class Bru {
- constructor(runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables) {
+ // Private class field - truly private, not accessible from outside the class
+ #hookManager;
+
+ constructor(runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables, hookManager) {
this.envVariables = envVariables || {};
this.runtimeVariables = runtimeVariables || {};
this.promptVariables = promptVariables || {};
@@ -19,6 +22,9 @@ class Bru {
this.oauth2CredentialVariables = oauth2CredentialVariables || {};
this.collectionPath = collectionPath;
this.collectionName = collectionName;
+ // Store HookManager in private class field - truly private, not accessible from outside
+ this.#hookManager = hookManager || null;
+ this._initializeHooksConvenienceMethods();
this.sendRequest = sendRequest;
this.runtime = runtime;
this.cookies = {
@@ -284,6 +290,56 @@ class Bru {
isSafeMode() {
return this.runtime === 'quickjs';
}
+
+ /**
+ * Initialize hooks convenience methods if hookManager is available
+ * This creates a namespaced hooks object with only the convenience methods
+ * The HookManager itself is kept private using a private class field - truly inaccessible from outside
+ */
+ _initializeHooksConvenienceMethods() {
+ if (!this.#hookManager) {
+ // Create empty hooks object if no hookManager
+ this.hooks = {
+ runner: {},
+ http: {}
+ };
+ return;
+ }
+
+ // Create namespaced hooks object with only convenience methods
+ // Users cannot access the HookManager directly (no .on() or .call() methods)
+ // The HookManager is stored in a private class field and is truly private
+ this.hooks = {
+ runner: {
+ onBeforeCollectionRun: (handler) => {
+ if (!this.#hookManager) {
+ throw new Error('HookManager is not available');
+ }
+ return this.#hookManager.on('runner:beforeCollectionRun', handler);
+ },
+ onAfterCollectionRun: (handler) => {
+ if (!this.#hookManager) {
+ throw new Error('HookManager is not available');
+ }
+ return this.#hookManager.on('runner:afterCollectionRun', handler);
+ }
+ },
+ http: {
+ onBeforeRequest: (handler) => {
+ if (!this.#hookManager) {
+ throw new Error('HookManager is not available');
+ }
+ return this.#hookManager.on('http:beforeRequest', handler);
+ },
+ onAfterResponse: (handler) => {
+ if (!this.#hookManager) {
+ throw new Error('HookManager is not available');
+ }
+ return this.#hookManager.on('http:afterResponse', handler);
+ }
+ }
+ };
+ }
}
module.exports = Bru;
diff --git a/packages/bruno-js/src/hook-manager.js b/packages/bruno-js/src/hook-manager.js
new file mode 100644
index 000000000..8cc419f1e
--- /dev/null
+++ b/packages/bruno-js/src/hook-manager.js
@@ -0,0 +1,178 @@
+/**
+ * HookManager provides a simple event system for registering and calling hooks (event listeners).
+ *
+ * Hooks can be registered for specific string patterns or arrays of patterns. The special pattern '*' acts as a wildcard.
+ *
+ * Usage examples:
+ *
+ * Note: The `on()` method is internal only. Users should use the namespaced convenience methods:
+ * - bru.hooks.http.onBeforeRequest(handler)
+ * - bru.hooks.http.onAfterResponse(handler)
+ * - bru.hooks.runner.onBeforeCollectionRun(handler)
+ * - bru.hooks.runner.onAfterCollectionRun(handler)
+ *
+ * Unregister handler by calling `unhook`
+ * unhook()
+ * or unregister for a specific pattern
+ * unhook('beforeRequest')
+ *
+ * Call hooks for a single event (internal use)
+ * hookManager.call('beforeRequest', { request, req, collection });
+ *
+ * @class
+ */
+class HookManager {
+ constructor() {
+ this.listeners = {};
+ }
+
+ /**
+ * Call all registered handlers for the given pattern(s)
+ * Supports both sync and async handlers - all handlers are awaited
+ * Wildcard handlers ('*') are called for every pattern, in addition to specific pattern handlers
+ * @param {string|string[]} pattern - Event pattern(s) to trigger
+ * @param {*} data - Data to pass to handlers
+ * @returns {Promise} Promise that resolves when all handlers complete
+ */
+ async call(pattern, data) {
+ if (typeof pattern !== 'string' && !Array.isArray(pattern)) {
+ throw new TypeError('Pattern must be a string or an array of strings.');
+ }
+
+ const patternList = [].concat(pattern).map((d) => String(d).trim());
+ const hasWildcard = patternList.includes('*');
+
+ if (hasWildcard) {
+ for (const ptn of Object.keys(this.listeners)) {
+ const handlers = this.listeners[ptn];
+ for (const handler of handlers) {
+ await callHandler(handler, data, ptn);
+ }
+ }
+ return;
+ }
+
+ // Call handlers for each specific pattern
+ for (const ptn of patternList) {
+ if (!this.listeners[ptn]) continue;
+ for (const handler of this.listeners[ptn]) {
+ await callHandler(handler, data, ptn);
+ }
+ }
+ }
+
+ /**
+ * Register a handler for the given pattern(s)
+ * @param {string|string[]} pattern - Event pattern(s) to listen to
+ * @param {Function} handler - Handler function to call
+ * @returns {Function} Unhook function to remove the handler
+ */
+ on(pattern, handler) {
+ if (typeof pattern !== 'string' && !Array.isArray(pattern)) {
+ throw new TypeError('Pattern must be a string or an array of strings.');
+ }
+
+ if (typeof handler !== 'function') {
+ throw new TypeError('Handler must be a function.');
+ }
+
+ const patternList = [].concat(pattern).map((d) => String(d).trim());
+ const hasWildcard = patternList.includes('*');
+
+ if (hasWildcard) {
+ (this.listeners['*'] ||= []).push(handler);
+ return this._createUnhook(patternList, handler);
+ }
+
+ for (const ptn of patternList) {
+ this.listeners[ptn] ||= [];
+
+ // Check if handler is already registered
+ const exists = this.listeners[ptn].some((d) => Object.is(d, handler));
+ if (exists) {
+ throw new Error(`${handler.name ?? 'anonymous'} handler was registered twice for hook pattern '${ptn}'`);
+ }
+
+ this.listeners[ptn].push(handler);
+ }
+
+ return this._createUnhook(patternList, handler);
+ }
+
+ /**
+ * Create an unhook function for the given patterns and handler
+ * @private
+ */
+ _createUnhook(patternList, handler) {
+ const self = this;
+ return function unhook(specific) {
+ let patterns = [];
+ if (specific) {
+ patterns = [].concat(specific).map((d) => String(d).trim());
+ } else {
+ patterns = patternList;
+ }
+
+ const hasStar = patterns.includes('*');
+
+ if (hasStar && self.listeners['*']) {
+ self.listeners['*'] = self.listeners['*'].filter((d) => !Object.is(d, handler));
+ }
+
+ for (const ptn of patterns) {
+ if (!self.listeners[ptn]) continue;
+ self.listeners[ptn] = self.listeners[ptn].filter((d) => !Object.is(d, handler));
+ }
+ };
+ }
+
+ /**
+ * Clear all handlers for the given pattern(s)
+ * @param {string|string[]} pattern - Event pattern(s) to clear
+ */
+ clear(pattern) {
+ if (typeof pattern !== 'string' && !Array.isArray(pattern)) {
+ throw new TypeError('Pattern must be a string or an array of strings.');
+ }
+
+ const patternList = [].concat(pattern).map((d) => String(d).trim());
+
+ for (const ptn of patternList) {
+ if (ptn === '*') {
+ delete this.listeners['*'];
+ } else if (this.listeners[ptn]) {
+ delete this.listeners[ptn];
+ }
+ }
+ }
+
+ /**
+ * Clear all registered handlers
+ */
+ clearAll() {
+ this.listeners = {};
+ }
+}
+
+/**
+ * Safely call a handler function with error handling
+ * Supports both sync and async handlers
+ * @private
+ * @param {Function} handler - Handler function to call
+ * @param {*} data - Data to pass to handler
+ * @param {string} event - Event name for error reporting
+ * @returns {Promise} Promise that resolves when handler completes
+ */
+async function callHandler(handler, data, event) {
+ try {
+ const result = handler(data);
+ // If handler returns a Promise, await it
+ if (result && typeof result.then === 'function') {
+ await result;
+ }
+ } catch (error) {
+ console.error(`Failed to execute handler for event: '${event}' with handler: '${handler?.name ?? 'anonymous'}'`, error);
+ }
+}
+
+module.exports = HookManager;
diff --git a/packages/bruno-js/src/index.js b/packages/bruno-js/src/index.js
index 06c3a6504..53d6f6782 100644
--- a/packages/bruno-js/src/index.js
+++ b/packages/bruno-js/src/index.js
@@ -2,6 +2,8 @@ const ScriptRuntime = require('./runtime/script-runtime');
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 HookManager = require('./hook-manager');
const { runScriptInNodeVm } = require('./sandbox/node-vm');
module.exports = {
@@ -9,5 +11,7 @@ module.exports = {
TestRuntime,
VarsRuntime,
AssertRuntime,
+ HooksRuntime,
+ HookManager,
runScriptInNodeVm
};
diff --git a/packages/bruno-js/src/runtime/hooks-runtime.js b/packages/bruno-js/src/runtime/hooks-runtime.js
new file mode 100644
index 000000000..3cd2a834e
--- /dev/null
+++ b/packages/bruno-js/src/runtime/hooks-runtime.js
@@ -0,0 +1,119 @@
+const { runScriptInNodeVm } = require('../sandbox/node-vm');
+const Bru = require('../bru');
+const HookManager = require('../hook-manager');
+const { cleanJson } = require('../utils');
+const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
+class HooksRuntime {
+ constructor(props) {
+ this.runtime = props?.runtime || 'quickjs';
+ }
+
+ /**
+ * Run hooks script to register event handlers
+ * @param {object} options - Configuration options
+ * @param {string} options.hooksFile - The hooks script content
+ * @param {object} options.request - The request object (used for variable extraction only)
+ * @param {object} options.envVariables - Environment variables
+ * @param {object} options.runtimeVariables - Runtime variables
+ * @param {string} options.collectionPath - Collection path
+ * @param {function} [options.onConsoleLog] - Console log callback
+ * @param {object} options.processEnvVars - Process environment variables
+ * @param {object} options.scriptingConfig - Scripting configuration
+ * @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)
+ * @returns {object} Result containing the hookManager instance
+ */
+ async runHooks(options) {
+ const {
+ hooksFile,
+ request,
+ envVariables,
+ runtimeVariables,
+ collectionPath,
+ onConsoleLog,
+ processEnvVars,
+ scriptingConfig,
+ runRequestByItemPathname,
+ collectionName,
+ hookManager
+ } = options;
+ const activeHookManager = hookManager || new HookManager();
+ const globalEnvironmentVariables = request?.globalEnvironmentVariables || {};
+ const oauth2CredentialVariables = request?.oauth2CredentialVariables || {};
+ const collectionVariables = request?.collectionVariables || {};
+ const folderVariables = request?.folderVariables || {};
+ const requestVariables = request?.requestVariables || {};
+ const promptVariables = request?.promptVariables || {};
+ // Pass activeHookManager to Bru so it uses the same instance (whether provided or newly created)
+ const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables, activeHookManager);
+
+ const context = {
+ bru
+ };
+
+ 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')
+ };
+ }
+
+ if (runRequestByItemPathname) {
+ context.bru.runRequest = runRequestByItemPathname;
+ }
+
+ // If no hooks file, return early with the hookManager
+ if (!hooksFile || !hooksFile.length) {
+ return {
+ hookManager: activeHookManager,
+ envVariables: cleanJson(envVariables),
+ runtimeVariables: cleanJson(runtimeVariables),
+ persistentEnvVariables: bru.persistentEnvVariables,
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
+ };
+ }
+
+ // Execute hooks script
+ if (this.runtime === 'nodevm') {
+ await runScriptInNodeVm({
+ script: hooksFile,
+ context,
+ collectionPath,
+ scriptingConfig
+ });
+
+ return {
+ hookManager: activeHookManager,
+ envVariables: cleanJson(envVariables),
+ runtimeVariables: cleanJson(runtimeVariables),
+ persistentEnvVariables: bru.persistentEnvVariables,
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
+ };
+ }
+
+ await executeQuickJsVmAsync({
+ script: hooksFile,
+ context: context,
+ collectionPath
+ });
+
+ return {
+ hookManager: activeHookManager,
+ envVariables: cleanJson(envVariables),
+ runtimeVariables: cleanJson(runtimeVariables),
+ persistentEnvVariables: bru.persistentEnvVariables,
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
+ };
+ }
+}
+
+module.exports = HooksRuntime;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
index 84ca86a54..ebb7c7953 100644
--- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
@@ -1,5 +1,8 @@
const { cleanJson, cleanCircularJson } = require('../../../utils');
const { marshallToVm } = require('../utils');
+const { createBrunoRequestShim } = require('./bruno-request');
+const { createBrunoResponseShim } = require('./bruno-response');
+const uuid = require('uuid');
const addBruShimToContext = (vm, bru) => {
const bruObject = vm.newObject();
@@ -394,6 +397,289 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'cookies', bruCookiesObject);
bruCookiesObject.dispose();
+ // Store handler handles - we need a Map (not WeakMap) because we need to look up by string ID
+ // WeakMap only allows object keys, but we need string-based lookup for handlerId
+ // Proper cleanup via unhook() and cleanupHandlerHandles() prevents memory leaks
+ const handlerIdToHandle = new Map(); // handlerId (string) -> handle (for lookup and cleanup)
+
+ // Cleanup function to dispose handler handles and prevent memory leaks
+ const cleanupHandlerHandles = () => {
+ if (handlerIdToHandle.size === 0) {
+ return;
+ }
+
+ try {
+ // Dispose all handler handles
+ handlerIdToHandle.forEach((handle, handlerId) => {
+ try {
+ if (handle && typeof handle.dispose === 'function') {
+ handle.dispose();
+ }
+ } catch (e) {
+ // Ignore disposal errors for individual handles
+ // Log only if it's not a UseAfterFree error
+ const errorMsg = e?.message || String(e);
+ if (!errorMsg.includes('UseAfterFree') && !errorMsg.includes('Lifetime not alive')) {
+ console.warn(`Error disposing handler handle ${handlerId}:`, e.message);
+ }
+ }
+ });
+
+ // Clear the Map
+ handlerIdToHandle.clear();
+ } catch (error) {
+ // Ignore cleanup errors
+ console.warn('Error during handler handles cleanup:', error.message);
+ }
+ };
+
+ // Add hooks shim if bru.hooks exists
+ if (bru.hooks) {
+ const hooksObject = vm.newObject();
+
+ // Execute handler using the original function handle from the VM
+ // Returns a Promise that resolves when the handler completes (supports async handlers)
+ const executeHandler = async (handlerHandle, vmInstance, data) => {
+ if (!handlerHandle) {
+ return Promise.resolve();
+ }
+ if (!vmInstance) {
+ return Promise.resolve();
+ }
+
+ try {
+ // Verify handler is still a function in the VM
+ const handlerType = vmInstance.typeof(handlerHandle);
+ if (handlerType !== 'function') {
+ return Promise.resolve();
+ }
+
+ // Prepare data (clean circular refs) - use try-catch to prevent stack overflow
+ let cleanedData;
+ try {
+ cleanedData = { ...cleanCircularJson(data) };
+ } catch (e) {
+ // If cleaning fails due to circular refs or stack overflow, use minimal data
+ console.warn('Error cleaning hook data, using minimal data:', e.message);
+ cleanedData = {};
+ }
+
+ // Create data object in VM
+ const dataHandle = vmInstance.newObject();
+
+ // Add all cleaned data properties
+ Object.keys(cleanedData).forEach((key) => {
+ if (key !== 'req' && key !== 'res') {
+ const value = marshallToVm(cleanedData[key], vmInstance);
+ vmInstance.setProp(dataHandle, key, value);
+ value.dispose();
+ }
+ });
+
+ // Add req/res shim objects to data if provided
+ // In QuickJS, when you setProp, the parent object takes ownership
+ // We dispose them after setting to avoid keeping extra references
+ // but dataHandle will maintain the reference until it's disposed
+ if (data.req) {
+ const reqShim = createBrunoRequestShim(vmInstance, data.req);
+ vmInstance.setProp(dataHandle, 'req', reqShim);
+ // Dispose the original handle - dataHandle now owns the reference
+ reqShim.dispose();
+ }
+
+ if (data.res) {
+ const resShim = createBrunoResponseShim(vmInstance, data.res);
+ vmInstance.setProp(dataHandle, 'res', resShim);
+ // Dispose the original handle - dataHandle now owns the reference
+ resShim.dispose();
+ }
+
+ // Call the original handler function
+ // Use vmInstance.global as context to ensure proper scope access
+ const result = vmInstance.callFunction(handlerHandle, vmInstance.global, dataHandle);
+ // Dispose dataHandle - this will clean up all child references
+ dataHandle.dispose();
+
+ if (result.error) {
+ const error = vmInstance.dump(result.error);
+ result.error.dispose();
+ const errorMsg = error?.message || error?.toString() || String(error);
+ if (!errorMsg.includes('UseAfterFree') && !errorMsg.includes('Lifetime not alive')) {
+ console.error('Error in hook handler:', error);
+ }
+ return;
+ }
+
+ // Check if the result is a Promise (async handler) and await it
+ // This is crucial for handlers that need to complete before the request proceeds
+ const resultType = vmInstance.typeof(result.value);
+
+ // Only try to resolve as Promise if it's an object (Promises are objects in JS)
+ // For non-object values (undefined, null, primitives), just dispose and return
+ if (resultType !== 'object') {
+ result.value.dispose();
+ return;
+ }
+
+ // Check if the object has a .then property (duck-typing for Promise)
+ let isPromise = false;
+ try {
+ const thenProp = vmInstance.getProp(result.value, 'then');
+ isPromise = vmInstance.typeof(thenProp) === 'function';
+ thenProp.dispose();
+ } catch (e) {
+ // If we can't check for .then, assume it's not a promise
+ isPromise = false;
+ }
+
+ if (!isPromise) {
+ // Not a promise, just dispose and return
+ result.value.dispose();
+ return;
+ }
+
+ // It's a Promise - await it using resolvePromise
+ try {
+ const resolvedResult = await vmInstance.resolvePromise(result.value);
+ result.value.dispose();
+
+ if (resolvedResult.error) {
+ const error = vmInstance.dump(resolvedResult.error);
+ resolvedResult.error.dispose();
+ const errorMsg = error?.message || error?.toString() || String(error);
+ if (!errorMsg.includes('UseAfterFree') && !errorMsg.includes('Lifetime not alive')) {
+ console.error('Error in async hook handler:', error);
+ }
+ } else {
+ resolvedResult.value.dispose();
+ }
+ } catch (promiseError) {
+ // If resolvePromise fails, just dispose the value
+ try {
+ result.value.dispose();
+ } catch (e) {
+ // Ignore disposal errors
+ }
+ }
+ } catch (error) {
+ const errorMsg = error?.message || error?.toString() || String(error);
+ if (!errorMsg.includes('UseAfterFree') && !errorMsg.includes('Lifetime not alive')) {
+ console.error('Error executing hook handler:', error);
+ }
+ }
+ };
+
+ /**
+ * Creates a hook function that registers a handler with the native hook system.
+ * This helper eliminates code duplication across different hook types.
+ *
+ * @param {string} handlerIdPrefix - Prefix for the unique handler ID
+ * @param {Function} nativeHookRegister - Function to register with native hooks (e.g., bru.hooks.http.onBeforeRequest)
+ * @param {boolean} validateHandler - Whether to validate handler is a function (default: true)
+ * @returns {Function} VM function that can be registered as a hook
+ */
+ const createHookFunction = (handlerIdPrefix, nativeHookRegister, validateHandler = true) => {
+ return vm.newFunction(handlerIdPrefix, function (handler) {
+ // Validate handler if required
+ if (validateHandler && vm.typeof(handler) !== 'function') {
+ throw new Error('Handler must be a function');
+ }
+
+ // Create unique handler ID
+ const handlerId = `${handlerIdPrefix}-${uuid.v4()}`;
+
+ // Try to duplicate the handle to own a reference
+ let handlerHandle;
+ try {
+ handlerHandle = handler.dup ? handler.dup() : handler;
+ } catch (e) {
+ handlerHandle = handler;
+ }
+
+ // Store the handle - we need Map (not WeakMap) because we need string-based lookup
+ handlerIdToHandle.set(handlerId, handlerHandle);
+
+ // Create native handler that executes the stored handle
+ // Returns a Promise so HookManager can await async handlers
+ const nativeHandler = (data) => {
+ const storedHandle = handlerIdToHandle.get(handlerId);
+ if (!storedHandle || !vm) {
+ return Promise.resolve();
+ }
+ // Return the Promise from executeHandler so HookManager awaits it
+ return executeHandler(storedHandle, vm, data);
+ };
+
+ // Register with native hook system
+ const unhook = nativeHookRegister(nativeHandler);
+
+ // Create unhook function
+ const unhookFn = vm.newFunction('unhook', () => {
+ unhook();
+
+ // Clean up handler handle
+ if (handlerIdToHandle.has(handlerId)) {
+ const storedHandle = handlerIdToHandle.get(handlerId);
+ try {
+ if (storedHandle && storedHandle.dispose) {
+ storedHandle.dispose();
+ }
+ } catch (e) {
+ // Ignore disposal errors
+ }
+ handlerIdToHandle.delete(handlerId);
+ }
+ });
+
+ return unhookFn;
+ });
+ };
+
+ // Add namespaced hooks structure
+ if (bru.hooks) {
+ const hooksNamespacedObject = vm.newObject();
+
+ // HTTP hooks namespace
+ if (bru.hooks.http) {
+ const httpHooksObject = vm.newObject();
+
+ if (typeof bru.hooks.http.onBeforeRequest === 'function') {
+ const onBeforeRequest = createHookFunction('onBeforeRequest', (nativeHandler) => bru.hooks.http.onBeforeRequest(nativeHandler), false);
+ onBeforeRequest.consume((handle) => vm.setProp(httpHooksObject, 'onBeforeRequest', handle));
+ }
+
+ if (typeof bru.hooks.http.onAfterResponse === 'function') {
+ const onAfterResponse = createHookFunction('onAfterResponse', (nativeHandler) => bru.hooks.http.onAfterResponse(nativeHandler), false);
+ onAfterResponse.consume((handle) => vm.setProp(httpHooksObject, 'onAfterResponse', handle));
+ }
+
+ vm.setProp(hooksNamespacedObject, 'http', httpHooksObject);
+ httpHooksObject.dispose();
+ }
+
+ // Runner hooks namespace
+ if (bru.hooks.runner) {
+ const runnerHooksObject = vm.newObject();
+
+ if (typeof bru.hooks.runner.onBeforeCollectionRun === 'function') {
+ const onBeforeCollectionRun = createHookFunction('onBeforeCollectionRun', (nativeHandler) => bru.hooks.runner.onBeforeCollectionRun(nativeHandler), true);
+ onBeforeCollectionRun.consume((handle) => vm.setProp(runnerHooksObject, 'onBeforeCollectionRun', handle));
+ }
+
+ if (typeof bru.hooks.runner.onAfterCollectionRun === 'function') {
+ const onAfterCollectionRun = createHookFunction('onAfterCollectionRun', (nativeHandler) => bru.hooks.runner.onAfterCollectionRun(nativeHandler), true);
+ onAfterCollectionRun.consume((handle) => vm.setProp(runnerHooksObject, 'onAfterCollectionRun', handle));
+ }
+
+ vm.setProp(hooksNamespacedObject, 'runner', runnerHooksObject);
+ runnerHooksObject.dispose();
+ }
+
+ vm.setProp(bruObject, 'hooks', hooksNamespacedObject);
+ hooksNamespacedObject.dispose();
+ }
+ }
+
vm.setProp(bruObject, 'runner', bruRunnerObject);
vm.setProp(vm.global, 'bru', bruObject);
bruObject.dispose();
@@ -454,6 +740,9 @@ const addBruShimToContext = (vm, bru) => {
};
};
`);
+
+ // Always return cleanup function; it is a no-op if no hooks were registered
+ return cleanupHandlerHandles;
};
module.exports = addBruShimToContext;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-request.js b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-request.js
index b65acffca..464490b5c 100644
--- a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-request.js
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-request.js
@@ -1,6 +1,6 @@
const { marshallToVm } = require('../utils');
-const addBrunoRequestShimToContext = (vm, req) => {
+const createBrunoRequestShim = (vm, req) => {
const reqObject = vm.newObject();
const url = marshallToVm(req.getUrl(), vm);
@@ -163,8 +163,14 @@ const addBrunoRequestShimToContext = (vm, req) => {
vm.setProp(reqObject, 'getTags', getTags);
getTags.dispose();
+ return reqObject;
+};
+
+const addBrunoRequestShimToContext = (vm, req) => {
+ const reqObject = createBrunoRequestShim(vm, req);
vm.setProp(vm.global, 'req', reqObject);
reqObject.dispose();
};
module.exports = addBrunoRequestShimToContext;
+module.exports.createBrunoRequestShim = createBrunoRequestShim;
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js
index df0fabe60..7206a8ef3 100644
--- a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js
@@ -1,6 +1,6 @@
const { marshallToVm } = require('../utils');
-const addBrunoResponseShimToContext = (vm, res) => {
+const createBrunoResponseShim = (vm, res) => {
let resFn = vm.newFunction('res', function (exprStr) {
return marshallToVm(res(vm.dump(exprStr)), vm);
});
@@ -80,8 +80,14 @@ const addBrunoResponseShimToContext = (vm, res) => {
vm.setProp(resFn, 'getSize', getSize);
getSize.dispose();
+ return resFn;
+};
+
+const addBrunoResponseShimToContext = (vm, res) => {
+ const resFn = createBrunoResponseShim(vm, res);
vm.setProp(vm.global, 'res', resFn);
resFn.dispose();
};
module.exports = addBrunoResponseShimToContext;
+module.exports.createBrunoResponseShim = createBrunoResponseShim;
diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js
index 0538cc048..98c982cef 100644
--- a/packages/bruno-lang/v2/src/bruToJson.js
+++ b/packages/bruno-lang/v2/src/bruToJson.js
@@ -153,9 +153,10 @@ const grammar = ohm.grammar(`Bru {
example = "example" st* "{" nl* examplecontent tagend
examplecontent = (~tagend any)*
- script = scriptreq | scriptres
+ script = scriptreq | scriptres | scripthooks
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
scriptres = "script:post-response" st* "{" nl* textblock tagend
+ scripthooks = "script:hooks" st* "{" nl* textblock tagend
tests = "tests" st* "{" nl* textblock tagend
docs = "docs" st* "{" nl* textblock tagend
}`);
@@ -1017,6 +1018,13 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
+ scripthooks(_1, _2, _3, _4, textblock, _5) {
+ return {
+ script: {
+ hooks: outdentString(textblock.sourceString)
+ }
+ };
+ },
tests(_1, _2, _3, _4, textblock, _5) {
return {
tests: outdentString(textblock.sourceString)
diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js
index 8fe6eced4..377197cd2 100644
--- a/packages/bruno-lang/v2/src/collectionBruToJson.js
+++ b/packages/bruno-lang/v2/src/collectionBruToJson.js
@@ -72,9 +72,10 @@ const grammar = ohm.grammar(`Bru {
authwsse = "auth:wsse" dictionary
authapikey = "auth:apikey" dictionary
- script = scriptreq | scriptres
+ script = scriptreq | scriptres | scripthooks
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
scriptres = "script:post-response" st* "{" nl* textblock tagend
+ scripthooks = "script:hooks" st* "{" nl* textblock tagend
tests = "tests" st* "{" nl* textblock tagend
docs = "docs" st* "{" nl* textblock tagend
}`);
@@ -546,6 +547,13 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
+ scripthooks(_1, _2, _3, _4, textblock, _5) {
+ return {
+ script: {
+ hooks: outdentString(textblock.sourceString)
+ }
+ };
+ },
tests(_1, _2, _3, _4, textblock, _5) {
return {
tests: outdentString(textblock.sourceString)
diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js
index 420ca90d7..7834da492 100644
--- a/packages/bruno-lang/v2/src/jsonToBru.js
+++ b/packages/bruno-lang/v2/src/jsonToBru.js
@@ -716,6 +716,14 @@ ${indentString(script.req)}
${indentString(script.res)}
}
+`;
+ }
+
+ if (script && script.hooks && script.hooks.length) {
+ bru += `script:hooks {
+${indentString(script.hooks)}
+}
+
`;
}
diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/v2/src/jsonToCollectionBru.js
index cc5428996..b556df78b 100644
--- a/packages/bruno-lang/v2/src/jsonToCollectionBru.js
+++ b/packages/bruno-lang/v2/src/jsonToCollectionBru.js
@@ -411,6 +411,14 @@ ${indentString(script.req)}
${indentString(script.res)}
}
+`;
+ }
+
+ if (script && script.hooks && script.hooks.length) {
+ bru += `script:hooks {
+${indentString(script.hooks)}
+}
+
`;
}
diff --git a/packages/bruno-schema-types/src/common/scripts.ts b/packages/bruno-schema-types/src/common/scripts.ts
index 612325388..66cbd167d 100644
--- a/packages/bruno-schema-types/src/common/scripts.ts
+++ b/packages/bruno-schema-types/src/common/scripts.ts
@@ -1,4 +1,5 @@
export interface Script {
req?: string | null;
res?: string | null;
+ hooks?: string | null;
}
diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js
index 09b40e1ed..e58c09fdd 100644
--- a/packages/bruno-schema/src/collections/index.js
+++ b/packages/bruno-schema/src/collections/index.js
@@ -401,7 +401,8 @@ const requestSchema = Yup.object({
body: requestBodySchema,
script: Yup.object({
req: Yup.string().nullable(),
- res: Yup.string().nullable()
+ res: Yup.string().nullable(),
+ hooks: Yup.string().nullable()
})
.noUnknown(true)
.strict(),
@@ -437,7 +438,8 @@ const grpcRequestSchema = Yup.object({
.required('body is required'),
script: Yup.object({
req: Yup.string().nullable(),
- res: Yup.string().nullable()
+ res: Yup.string().nullable(),
+ hooks: Yup.string().nullable()
})
.noUnknown(true)
.strict(),
@@ -475,7 +477,8 @@ const wsRequestSchema = Yup.object({
.required('body is required'),
script: Yup.object({
req: Yup.string().nullable(),
- res: Yup.string().nullable()
+ res: Yup.string().nullable(),
+ hooks: Yup.string().nullable()
})
.noUnknown(true)
.strict(),
@@ -510,7 +513,8 @@ const folderRootSchema = Yup.object({
auth: authSchema,
script: Yup.object({
req: Yup.string().nullable(),
- res: Yup.string().nullable()
+ res: Yup.string().nullable(),
+ hooks: Yup.string().nullable()
})
.noUnknown(true)
.strict()
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/.gitignore b/packages/bruno-tests/hooks-comprehensive-tests/.gitignore
new file mode 100644
index 000000000..6c9a74c31
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/.gitignore
@@ -0,0 +1,2 @@
+!.env
+junit*.xml
\ No newline at end of file
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/async-collection-run-end.bru b/packages/bruno-tests/hooks-comprehensive-tests/async-collection-run-end.bru
new file mode 100644
index 000000000..b3ebb2027
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/async-collection-run-end.bru
@@ -0,0 +1,19 @@
+meta {
+ name: async-collection-run-end
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("request should execute successfully", function() {
+ // This request helps track request count for onAfterCollectionRun
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/async-collection-run-start.bru b/packages/bruno-tests/hooks-comprehensive-tests/async-collection-run-start.bru
new file mode 100644
index 000000000..49d46a2f7
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/async-collection-run-start.bru
@@ -0,0 +1,30 @@
+meta {
+ name: async-collection-run-start
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("async onBeforeCollectionRun hook should have executed", function() {
+ const started = bru.getVar('async-collection-run-started');
+ expect(started).to.equal('true');
+ });
+
+ test("collection run should wait for async setup", function() {
+ // If we get here, the collection run waited for the async hook
+ const setupComplete = bru.getEnvVar('async-setup-complete');
+ expect(setupComplete).to.equal('true');
+ });
+
+ test("request should execute after async setup", function() {
+ // Verify request executed after async hook completed
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/bruno.json b/packages/bruno-tests/hooks-comprehensive-tests/bruno.json
new file mode 100644
index 000000000..77ef8ccaa
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/bruno.json
@@ -0,0 +1,9 @@
+{
+ "version": "1",
+ "name": "hooks-comprehensive-tests",
+ "type": "collection",
+ "ignore": [
+ "node_modules",
+ ".git"
+ ]
+}
\ No newline at end of file
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/collection-run-end-cleanup.bru b/packages/bruno-tests/hooks-comprehensive-tests/collection-run-end-cleanup.bru
new file mode 100644
index 000000000..b83b53d7d
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/collection-run-end-cleanup.bru
@@ -0,0 +1,20 @@
+meta {
+ name: collection-run-end-cleanup
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("request should execute successfully", function() {
+ // This request helps track request count for onAfterCollectionRun
+ // Actual onAfterCollectionRun verification is done in verify-collection-run-end.bru
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/collection-run-start-setup.bru b/packages/bruno-tests/hooks-comprehensive-tests/collection-run-start-setup.bru
new file mode 100644
index 000000000..9ff540f3e
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/collection-run-start-setup.bru
@@ -0,0 +1,34 @@
+meta {
+ name: collection-run-start-setup
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("setup should have set token", function() {
+ const token = bru.getVar('setup-token');
+ expect(token).to.equal('mock-token-12345');
+ });
+
+ test("setup should have set env token", function() {
+ const apiToken = bru.getEnvVar('api-token');
+ expect(apiToken).to.equal('mock-token-12345');
+ });
+
+ test("setup should have initialized counters", function() {
+ const requestCounter = bru.getVar('request-counter');
+ expect(requestCounter).to.equal('0');
+ });
+
+ test("request should have access to setup vars", function() {
+ const setupComplete = bru.getVar('setup-complete');
+ expect(setupComplete).to.equal('true');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/collection.bru b/packages/bruno-tests/hooks-comprehensive-tests/collection.bru
new file mode 100644
index 000000000..869208e51
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/collection.bru
@@ -0,0 +1,174 @@
+script:hooks {
+ // ============================================
+ // Comprehensive Hooks Test Collection
+ // This collection tests all available hooks:
+ // - HTTP Hooks: onBeforeRequest, onAfterResponse (tested in individual request files)
+ // - Runner Hooks: onBeforeCollectionRun, onAfterCollectionRun (tested here)
+ // ============================================
+
+ // ============================================
+ // Runner Hooks: onBeforeCollectionRun Tests
+ // ============================================
+
+ // Test: Sync onBeforeCollectionRun hook
+ bru.hooks.runner.onBeforeCollectionRun(() => {
+ console.log('[onBeforeCollectionRun] Sync hook - Collection run starting...');
+
+ // Test: Can use bru APIs
+ bru.setVar('collection-run-started', 'true');
+ bru.setVar('start-time', Date.now().toString());
+
+ // Test: Can set collection-level vars
+ bru.setEnvVar('collection-setup-complete', 'true');
+
+ // Test: Can use getEnvName
+ const envName = bru.getEnvName();
+ bru.setVar('env-name-at-start', envName);
+
+ // Test: Can use getCollectionName
+ const collectionName = bru.getCollectionName();
+ bru.setVar('collection-name-at-start', collectionName);
+
+ console.log('[onBeforeCollectionRun] Sync setup complete');
+ });
+
+ // Test: Async onBeforeCollectionRun hook
+ bru.hooks.runner.onBeforeCollectionRun(async() => {
+ console.log('[onBeforeCollectionRun] Async hook - Starting async setup...');
+
+ // Test: Can use async/await
+ await bru.sleep(1000);
+
+ // Test: Can use bru APIs in async context
+ bru.setVar('async-collection-run-started', 'true');
+ bru.setVar('async-start-time', Date.now().toString());
+
+ // Test: Can set env vars
+ bru.setEnvVar('async-setup-complete', 'true');
+
+ await bru.sleep(500);
+ console.log('[onBeforeCollectionRun] Async setup completed');
+ });
+
+ // Test: onBeforeCollectionRun with setup operations
+ bru.hooks.runner.onBeforeCollectionRun(async() => {
+ console.log('[onBeforeCollectionRun] Setup hook - Performing setup operations...');
+
+ // Simulate setup operations like fetching tokens, initializing data, etc.
+ await bru.sleep(500);
+
+ // Test: Setup - Set initial vars
+ bru.setVar('setup-token', 'mock-token-12345');
+ bru.setVar('setup-complete', 'true');
+
+ // Test: Setup - Set env vars
+ bru.setEnvVar('api-token', 'mock-token-12345');
+ bru.setEnvVar('setup-timestamp', Date.now().toString());
+
+ // Test: Setup - Initialize counters
+ bru.setVar('request-counter', '0');
+ bru.setVar('success-counter', '0');
+
+ console.log('[onBeforeCollectionRun] Setup complete - token and counters initialized');
+ });
+
+ // ============================================
+ // onAfterCollectionRun Hook Tests
+ // ============================================
+
+ // Test: Sync onAfterCollectionRun hook
+ bru.hooks.runner.onAfterCollectionRun(() => {
+ console.log('[onAfterCollectionRun] Sync hook - Collection run ending...');
+
+ // Test: Can use bru APIs
+ // Test: Can read final state (read before other hooks delete)
+ const requestCount = bru.getVar('request-count') || '0';
+ console.log('[onAfterCollectionRun] Final request count:', requestCount);
+
+ // Test: Can set final env vars
+ bru.setEnvVar('collection-run-complete', 'true');
+
+ console.log('[onAfterCollectionRun] Sync cleanup complete');
+ });
+
+ // Test: Async onAfterCollectionRun hook
+ bru.hooks.runner.onAfterCollectionRun(async() => {
+ console.log('[onAfterCollectionRun] Async hook - Starting async cleanup...');
+
+ // Test: Can use async/await
+ await bru.sleep(1000);
+
+ // Test: Can use bru APIs in async context
+ // Test: Can read final state (read before cleanup hook deletes)
+ const requestCount = bru.getVar('request-count') || '0';
+ console.log('[onAfterCollectionRun] Async cleanup - Final request count:', requestCount);
+
+ // Test: Can set final env vars
+ bru.setEnvVar('async-cleanup-complete', 'true');
+
+ await bru.sleep(500);
+ console.log('[onAfterCollectionRun] Async cleanup completed');
+ });
+
+ // Test: onAfterCollectionRun with cleanup operations
+ bru.hooks.runner.onAfterCollectionRun(async() => {
+ console.log('[onAfterCollectionRun] Cleanup hook - Performing cleanup operations...');
+
+ // Test: Cleanup - Read final statistics (read before cleanup)
+ const requestCount = bru.getVar('request-count') || '0';
+ const successCount = bru.getVar('success-count') || '0';
+
+ console.log('[onAfterCollectionRun] Final statistics:', {
+ requests: parseInt(requestCount),
+ successes: parseInt(successCount)
+ });
+
+ // Test: Cleanup - Set final env vars
+ bru.setEnvVar('collection-run-finished', 'true');
+ bru.setEnvVar('final-request-count', requestCount);
+
+ // Cleanup: Remove all test variables created during the run
+ bru.deleteVar('collection-run-started');
+ bru.deleteVar('start-time');
+ bru.deleteVar('env-name-at-start');
+ bru.deleteVar('collection-name-at-start');
+ bru.deleteVar('async-collection-run-started');
+ bru.deleteVar('async-start-time');
+ bru.deleteVar('setup-token');
+ bru.deleteVar('setup-complete');
+ bru.deleteVar('request-counter');
+ bru.deleteVar('success-counter');
+ bru.deleteVar('request-count');
+ bru.deleteVar('success-count');
+ bru.deleteVar('final-stats');
+ bru.deleteVar('cleanup-performed');
+
+ await bru.sleep(300);
+ console.log('[onAfterCollectionRun] Cleanup complete - all test variables removed');
+ });
+
+ // ============================================
+ // HTTP Hooks: Collection-level HTTP hooks
+ // Individual request files in hooks/ subdirectories test HTTP hooks at request level
+ // This collection-level hook tracks requests for runner hook tests
+ // ============================================
+
+ // Collection-level HTTP hook: Track requests for onAfterCollectionRun tests
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ const count = bru.getVar('request-count') || 0;
+ bru.setVar('request-count', parseInt(count) + 1);
+ if (res.getStatus() === 200) {
+ const successCount = bru.getVar('success-count') || 0;
+ bru.setVar('success-count', parseInt(successCount) + 1);
+ } else {
+ console.log('[onAfterResponse] Request failed:');
+ }
+ });
+
+ // Collection-level HTTP hook: Example onBeforeRequest hook
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ // This hook runs before every request in the collection
+ // Individual request files in hooks/ subdirectories have their own request-level hooks
+ console.log('[Collection-level HTTP Hook] Before request:', req.getName());
+ });
+}
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/environments/Prod.bru b/packages/bruno-tests/hooks-comprehensive-tests/environments/Prod.bru
new file mode 100644
index 000000000..6db8838d7
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/environments/Prod.bru
@@ -0,0 +1,3 @@
+vars {
+ host: https://testbench-sanity.usebruno.com
+}
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/after-response-read-response.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/after-response-read-response.bru
new file mode 100644
index 000000000..48adc2aa7
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/after-response-read-response.bru
@@ -0,0 +1,92 @@
+meta {
+ name: after-response-read-response
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:post-response {
+ console.log("res.getSize()",res.getSize())
+}
+
+script:hooks {
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ // Test: Read status code
+ const status = res.getStatus();
+ bru.setVar('response-status', status.toString());
+
+ // Test: Read status text
+ const statusText = res.getStatusText();
+ bru.setVar('response-status-text', statusText);
+
+ // Test: Read headers
+ const headers = res.getHeaders();
+ bru.setVar('response-has-headers', (Object.keys(headers).length > 0).toString());
+
+ // Test: Read specific header
+ const contentType = res.getHeader('content-type');
+ if (contentType) {
+ bru.setVar('response-content-type', contentType);
+ }
+
+ // Test: Read body
+ const body = res.getBody();
+ bru.setVar('response-body', body);
+
+ // Test: Read response time
+ const responseTime = res.getResponseTime();
+ bru.setVar('response-time', responseTime.toString());
+
+ // Test: Read URL
+ const url = res.getUrl();
+ bru.setVar('response-url', url);
+
+ // Test: Read size
+ const size = res.getSize();
+ bru.setVar('response-size', size.body.toString());
+
+ console.log('[afterResponse] Read all response properties');
+ });
+}
+
+tests {
+ test("should have read status code", function() {
+ const status = bru.getVar('response-status');
+ expect(status).to.equal('200');
+ });
+
+ test("should have read status text", function() {
+ const statusText = bru.getVar('response-status-text');
+ expect(statusText).to.equal('OK');
+ });
+
+ test("should have read headers", function() {
+ const hasHeaders = bru.getVar('response-has-headers');
+ expect(hasHeaders).to.equal('true');
+ });
+
+ test("should have read body", function() {
+ const body = bru.getVar('response-body');
+ expect(body).to.equal('pong');
+ });
+
+ test("should have read response time", function() {
+ const responseTime = bru.getVar('response-time');
+ expect(parseInt(responseTime)).to.be.a('number');
+ });
+
+ test("should have read URL", function() {
+ const url = bru.getVar('response-url');
+ expect(url).to.include('/ping');
+ });
+
+ test("should have read size", function() {
+ const size = bru.getVar('response-size');
+ expect(parseInt(size)).to.be.at.least(0);
+ });
+}
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/after-response-with-bru-apis.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/after-response-with-bru-apis.bru
new file mode 100644
index 000000000..a3fd9102c
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/after-response-with-bru-apis.bru
@@ -0,0 +1,61 @@
+meta {
+ name: after-response-with-bru-apis
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onAfterResponse(({ req, res }) => {
+ // Test: Can use bru APIs based on response
+ const status = res.getStatus();
+ const body = res.getBody();
+
+ // Test: Set vars based on response
+ if (status === 200) {
+ bru.setVar('request-successful', 'true');
+ bru.setVar('response-data', body);
+ }
+
+ // Test: Can use setEnvVar
+ bru.setEnvVar('last-response-status', status.toString());
+ bru.setEnvVar('last-response-time', res.getResponseTime().toString());
+
+ // Test: Can use interpolate
+ const interpolated = bru.interpolate('Status: {{last-response-status}}');
+ bru.setVar('interpolated-status', interpolated);
+
+ // Test: Store response data for later use
+ bru.setVar('last-response-body', body);
+ bru.setVar('last-request-url', req.getUrl());
+
+ console.log('[afterResponse] Stored response data using bru APIs');
+ });
+}
+
+tests {
+ test("should have set success flag based on response", function() {
+ const successful = bru.getVar('request-successful');
+ expect(successful).to.equal('true');
+ });
+
+ test("should have stored response data", function() {
+ const responseData = bru.getVar('response-data');
+ expect(responseData).to.equal('pong');
+ });
+
+ test("should have set env vars from response", function() {
+ const lastStatus = bru.getEnvVar('last-response-status');
+ expect(lastStatus).to.equal('200');
+ });
+
+ test("should have stored last response body", function() {
+ const body = bru.getVar('last-response-body');
+ expect(body).to.equal('pong');
+ });
+}
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/async-after-response.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/async-after-response.bru
new file mode 100644
index 000000000..801e9e175
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/async-after-response.bru
@@ -0,0 +1,63 @@
+meta {
+ name: async-after-response
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onAfterResponse(async({ req, res }) => {
+ console.log('[afterResponse] Starting async hook...');
+
+ // Test: Can use async/await
+ await bru.sleep(1000);
+
+ // Test: Can access req and res in async context
+ const reqUrl = req.getUrl();
+ const status = res.getStatus();
+ const body = res.getBody();
+
+ console.log('[afterResponse] Request URL:', reqUrl);
+ console.log('[afterResponse] Response:', status, body);
+
+ // Test: Can use bru APIs in async context
+ bru.setVar('async-afterResponse-executed', 'true');
+ bru.setVar('async-response-status', status.toString());
+ bru.setVar('async-response-body', body);
+
+ // Test: Can read headers
+ const headers = res.getHeaders();
+ bru.setVar('response-header-count', Object.keys(headers).length.toString());
+
+ await bru.sleep(500);
+ console.log('[afterResponse] Async hook completed');
+ });
+}
+
+tests {
+ test("async afterResponse hook should have executed", function() {
+ const executed = bru.getVar('async-afterResponse-executed');
+ expect(executed).to.equal('true');
+ });
+
+ test("should have captured response in async hook", function() {
+ const status = bru.getVar('async-response-status');
+ expect(status).to.equal('200');
+ });
+
+ test("should have captured response body in async hook", function() {
+ const body = bru.getVar('async-response-body');
+ expect(body).to.equal('pong');
+ });
+
+ test("should have read headers in async hook", function() {
+ const headerCount = bru.getVar('response-header-count');
+ expect(parseInt(headerCount)).to.be.at.least(0);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/multiple-after-response.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/multiple-after-response.bru
new file mode 100644
index 000000000..740d22f4b
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/multiple-after-response.bru
@@ -0,0 +1,60 @@
+meta {
+ name: multiple-after-response
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ // First hook
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ console.log('[afterResponse 1] First hook executing');
+ bru.setVar('hook-order', '1');
+ const status = res.getStatus();
+ bru.setVar('status-from-hook-1', status.toString());
+ });
+
+ // Second hook
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ console.log('[afterResponse 2] Second hook executing');
+ const order = bru.getVar('hook-order') || '';
+ bru.setVar('hook-order', order + ',2');
+
+ const status = res.getStatus();
+ bru.setVar('status-from-hook-2', status.toString());
+ });
+
+ // Third hook (async)
+ bru.hooks.http.onAfterResponse(async({ res }) => {
+ console.log('[afterResponse 3] Third hook (async) executing');
+ await bru.sleep(200);
+ const order = bru.getVar('hook-order') || '';
+ bru.setVar('hook-order', order + ',3');
+
+ const status = res.getStatus();
+ bru.setVar('status-from-hook-3', status.toString());
+ });
+}
+
+tests {
+ test("all afterResponse hooks should execute in order", function() {
+ const order = bru.getVar('hook-order');
+ expect(order).to.equal('1,2,3');
+ });
+
+ test("all hooks should have access to response", function() {
+ const status1 = bru.getVar('status-from-hook-1');
+ const status2 = bru.getVar('status-from-hook-2');
+ const status3 = bru.getVar('status-from-hook-3');
+
+ expect(status1).to.equal('200');
+ expect(status2).to.equal('200');
+ expect(status3).to.equal('200');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/sync-after-response.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/sync-after-response.bru
new file mode 100644
index 000000000..4c48ecedd
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/afterResponse/sync-after-response.bru
@@ -0,0 +1,64 @@
+meta {
+ name: sync-after-response
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onAfterResponse(({ req, res }) => {
+ // Test: Can access req object
+ const reqUrl = req.getUrl();
+ const reqName = req.getName();
+ const reqMethod = req.getMethod();
+
+ console.log('[afterResponse] Request:', reqMethod, reqUrl, reqName);
+
+ // Test: Can access res object
+ const status = res.getStatus();
+ const statusText = res.getStatusText();
+ const body = res.getBody();
+ const responseTime = res.getResponseTime();
+
+ console.log('[afterResponse] Response:', status, statusText, 'Time:', responseTime);
+
+ // Test: Can use bru APIs
+ bru.setVar('afterResponse-executed', 'true');
+ bru.setVar('response-status', status.toString());
+ bru.setVar('response-body', body);
+ bru.setVar('response-time', responseTime.toString());
+
+ console.log('[afterResponse] Hook executed successfully');
+ });
+}
+
+tests {
+ test("afterResponse hook should have executed", function() {
+ const executed = bru.getVar('afterResponse-executed');
+ expect(executed).to.equal('true');
+ });
+
+ test("should have captured response status in hook", function() {
+ const status = bru.getVar('response-status');
+ expect(status).to.equal('200');
+ });
+
+ test("should have captured response body in hook", function() {
+ const body = bru.getVar('response-body');
+ expect(body).to.equal('pong');
+ });
+
+ test("should have captured response time in hook", function() {
+ const responseTime = bru.getVar('response-time');
+ const parsed = parseInt(responseTime);
+ expect(parsed).to.be.a('number');
+ expect(isNaN(parsed)).to.be.false;
+ expect(parsed).to.be.greaterThan(0);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/collection-folder-vars.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/collection-folder-vars.bru
new file mode 100644
index 000000000..3e3ff614f
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/collection-folder-vars.bru
@@ -0,0 +1,39 @@
+meta {
+ name: collection-folder-vars
+ type: http
+ seq: 6
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ // These may return undefined if not set, which is valid
+ bru.setVar('collection-var', bru.getCollectionVar('testCollectionVar') || 'not-set');
+ bru.setVar('folder-var', bru.getFolderVar('testFolderVar') || 'not-set');
+ bru.setVar('request-var', bru.getRequestVar('testRequestVar') || 'not-set');
+ });
+}
+
+tests {
+ test("bru.getCollectionVar - callable in hook", function() {
+ // Just verify the function is callable and returns expected type
+ const val = bru.getVar('collection-var');
+ expect(val).to.be.a('string');
+ });
+
+ test("bru.getFolderVar - callable in hook", function() {
+ const val = bru.getVar('folder-var');
+ expect(val).to.be.a('string');
+ });
+
+ test("bru.getRequestVar - callable in hook", function() {
+ const val = bru.getVar('request-var');
+ expect(val).to.be.a('string');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/context.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/context.bru
new file mode 100644
index 000000000..b6ae8500d
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/context.bru
@@ -0,0 +1,40 @@
+meta {
+ name: context
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setVar('hook-collection-name', bru.getCollectionName());
+ bru.setVar('hook-env-name', bru.getEnvName());
+ bru.setVar('hook-process-env', bru.getProcessEnv('PATH'));
+ });
+}
+
+tests {
+ test("bru.getCollectionName - returns collection name", function() {
+ const name = bru.getVar('hook-collection-name');
+ expect(name).to.be.a('string');
+ expect(name.length).to.be.greaterThan(0);
+ });
+
+ test("bru.getEnvName - returns environment name", function() {
+ const envName = bru.getVar('hook-env-name');
+ expect(envName).to.equal('Prod');
+ });
+
+ test("bru.getProcessEnv - returns process env value", function() {
+ // PATH is typically available in all environments
+ const path = bru.getProcessEnv('PATH');
+ const storedPath = bru.getVar('hook-process-env');
+ expect(path).to.equal(storedPath);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/env-variables.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/env-variables.bru
new file mode 100644
index 000000000..86bc1218a
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/env-variables.bru
@@ -0,0 +1,29 @@
+meta {
+ name: env-variables
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setEnvVar('hook-env-test', 'env-value-from-hook');
+ });
+}
+
+tests {
+ test("bru.getEnvVar - read existing env var", function() {
+ const host = bru.getEnvVar('host');
+ expect(host).to.include('http');
+ });
+
+ test("bru.setEnvVar/getEnvVar - set in hook", function() {
+ expect(bru.getEnvVar('hook-env-test')).to.equal('env-value-from-hook');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/interpolate.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/interpolate.bru
new file mode 100644
index 000000000..226602b8b
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/interpolate.bru
@@ -0,0 +1,32 @@
+meta {
+ name: interpolate
+ type: http
+ seq: 4
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setVar('test-value', 'interpolated');
+ bru.setVar('interpolate-result', bru.interpolate('Result: {{test-value}}'));
+ bru.setVar('interpolate-env', bru.interpolate('Host: {{host}}'));
+ });
+}
+
+tests {
+ test("bru.interpolate - with runtime variable", function() {
+ expect(bru.getVar('interpolate-result')).to.equal('Result: interpolated');
+ });
+
+ test("bru.interpolate - with env variable", function() {
+ const result = bru.getVar('interpolate-env');
+ expect(result).to.include('Host:');
+ expect(result).to.include('http');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/sleep.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/sleep.bru
new file mode 100644
index 000000000..d1154293a
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/sleep.bru
@@ -0,0 +1,31 @@
+meta {
+ name: sleep
+ type: http
+ seq: 5
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(async ({ req }) => {
+ const start = Date.now();
+ await bru.sleep(100);
+ bru.setVar('sleep-elapsed', Date.now() - start);
+ });
+}
+
+tests {
+ test("bru.sleep - delays execution", function() {
+ const elapsed = bru.getVar('sleep-elapsed');
+ expect(elapsed).to.be.at.least(100);
+ });
+
+ test("request succeeds after async hook", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/variables.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/variables.bru
new file mode 100644
index 000000000..3bba27a6c
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/bru/variables.bru
@@ -0,0 +1,39 @@
+meta {
+ name: variables
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setVar('hook-string', 'hello');
+ bru.setVar('hook-number', 42);
+ bru.setVar('hook-object', { key: 'value' });
+ bru.setVar('hook-array', [1, 2, 3]);
+ });
+}
+
+tests {
+ test("bru.setVar/getVar - string", function() {
+ expect(bru.getVar('hook-string')).to.equal('hello');
+ });
+
+ test("bru.setVar/getVar - number", function() {
+ expect(bru.getVar('hook-number')).to.equal(42);
+ });
+
+ test("bru.setVar/getVar - object", function() {
+ expect(bru.getVar('hook-object')).to.deep.equal({ key: 'value' });
+ });
+
+ test("bru.setVar/getVar - array", function() {
+ expect(bru.getVar('hook-array')).to.deep.equal([1, 2, 3]);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/auth-timeout.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/auth-timeout.bru
new file mode 100644
index 000000000..a78c9440a
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/auth-timeout.bru
@@ -0,0 +1,47 @@
+meta {
+ name: auth-timeout
+ type: http
+ seq: 6
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setVar('auth-mode', req.getAuthMode());
+ bru.setVar('exec-mode', req.getExecutionMode());
+
+ // Test timeout methods
+ bru.setVar('timeout-before', req.getTimeout());
+ req.setTimeout(10000);
+ bru.setVar('timeout-after', req.getTimeout());
+
+ // Test max redirects
+ req.setMaxRedirects(5);
+ });
+}
+
+tests {
+ test("req.getAuthMode - returns auth mode", function() {
+ const mode = bru.getVar('auth-mode');
+ expect(mode).to.equal('none');
+ });
+
+ test("req.getExecutionMode - returns execution mode", function() {
+ const mode = bru.getVar('exec-mode');
+ expect(['standalone', 'runner', 'cli', undefined]).to.include(mode);
+ });
+
+ test("req.getTimeout/setTimeout - manages timeout", function() {
+ expect(bru.getVar('timeout-after')).to.equal(10000);
+ });
+
+ test("request succeeds with configured options", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/body.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/body.bru
new file mode 100644
index 000000000..a29fb3ac3
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/body.bru
@@ -0,0 +1,53 @@
+meta {
+ name: body
+ type: http
+ seq: 4
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "original": "value",
+ "count": 1
+ }
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ const body = req.getBody();
+ bru.setVar('original-field', body.original);
+
+ // Modify body
+ req.setBody({
+ ...body,
+ modified: true,
+ count: body.count + 1,
+ added: 'new-field'
+ });
+ });
+}
+
+tests {
+ test("req.getBody - reads original body", function() {
+ expect(bru.getVar('original-field')).to.equal('value');
+ });
+
+ test("req.setBody - preserves original fields", function() {
+ expect(res.getBody().original).to.equal('value');
+ });
+
+ test("req.setBody - adds new fields", function() {
+ expect(res.getBody().modified).to.equal(true);
+ expect(res.getBody().added).to.equal('new-field');
+ });
+
+ test("req.setBody - modifies existing fields", function() {
+ expect(res.getBody().count).to.equal(2);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/headers.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/headers.bru
new file mode 100644
index 000000000..8da8ebb8b
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/headers.bru
@@ -0,0 +1,58 @@
+meta {
+ name: headers
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{host}}/headers
+ body: none
+ auth: none
+}
+
+headers {
+ X-Original: original-value
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ // Test getHeader
+ bru.setVar('read-original', req.getHeader('X-Original'));
+
+ // Test setHeader - add new
+ req.setHeader('X-Added-In-Hook', 'hook-value');
+
+ // Test setHeader - modify existing
+ req.setHeader('X-Original', 'modified-value');
+
+ // Test getHeaders
+ bru.setVar('headers-count', Object.keys(req.getHeaders()).length);
+
+ // Test setHeaders - bulk add
+ const headers = req.getHeaders();
+ req.setHeaders({ ...headers, 'X-Bulk-1': 'bulk-1', 'X-Bulk-2': 'bulk-2' });
+ });
+}
+
+tests {
+ test("req.getHeader - reads header value", function() {
+ expect(bru.getVar('read-original')).to.equal('original-value');
+ });
+
+ test("req.setHeader - adds new header", function() {
+ const body = res.getBody();
+ expect(body['x-added-in-hook']).to.equal('hook-value');
+ });
+
+ test("req.setHeader - modifies existing header", function() {
+ const body = res.getBody();
+ expect(body['x-original']).to.equal('modified-value');
+ });
+
+ test("req.setHeaders - bulk adds headers", function() {
+ const body = res.getBody();
+ expect(body['x-bulk-1']).to.equal('bulk-1');
+ expect(body['x-bulk-2']).to.equal('bulk-2');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/metadata.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/metadata.bru
new file mode 100644
index 000000000..aab454f6b
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/metadata.bru
@@ -0,0 +1,43 @@
+meta {
+ name: metadata
+ type: http
+ seq: 5
+ tags: [
+ hook-test
+ api-test
+ ]
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setVar('req-name', req.getName());
+ bru.setVar('req-tags', req.getTags());
+ bru.setVar('req-timeout-before', req.getTimeout());
+
+ req.setTimeout(5000);
+ bru.setVar('req-timeout-after', req.getTimeout());
+ });
+}
+
+tests {
+ test("req.getName - returns request name", function() {
+ expect(bru.getVar('req-name')).to.equal('metadata');
+ });
+
+ test("req.getTags - returns tags array", function() {
+ const tags = bru.getVar('req-tags');
+ expect(tags).to.include('hook-test');
+ expect(tags).to.include('api-test');
+ });
+
+ test("req.setTimeout - changes timeout", function() {
+ expect(bru.getVar('req-timeout-after')).to.equal(5000);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/method.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/method.bru
new file mode 100644
index 000000000..e46d72a84
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/method.bru
@@ -0,0 +1,28 @@
+meta {
+ name: method
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/headers
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setVar('original-method', req.getMethod());
+ });
+}
+
+tests {
+ test("req.getMethod - returns HTTP method", function() {
+ expect(bru.getVar('original-method')).to.equal('GET');
+ });
+
+ test("req.getMethod - available in tests", function() {
+ expect(req.getMethod()).to.equal('GET');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/url.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/url.bru
new file mode 100644
index 000000000..524ef43f6
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/req/url.bru
@@ -0,0 +1,30 @@
+meta {
+ name: url
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/headers
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setVar('original-url', req.getUrl());
+ req.setUrl(req.getUrl().replace('/headers', '/ping'));
+ });
+}
+
+tests {
+ test("req.getUrl - returns original URL", function() {
+ expect(bru.getVar('original-url')).to.include('/headers');
+ });
+
+ test("req.setUrl - changes request endpoint", function() {
+ // Response should be 'pong' from /ping endpoint
+ expect(res.getBody()).to.equal('pong');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/body.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/body.bru
new file mode 100644
index 000000000..19eccfd4f
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/body.bru
@@ -0,0 +1,46 @@
+meta {
+ name: body
+ type: http
+ seq: 3
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "message": "original"
+ }
+}
+
+script:hooks {
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ const body = res.getBody();
+ bru.setVar('original-message', body.message);
+
+ // Modify response body
+ res.setBody({
+ ...body,
+ message: 'modified-in-hook',
+ addedInHook: true
+ });
+ });
+}
+
+tests {
+ test("res.getBody - reads response body", function() {
+ expect(bru.getVar('original-message')).to.equal('original');
+ });
+
+ test("res.setBody - modifies response", function() {
+ expect(res.getBody().message).to.equal('modified-in-hook');
+ });
+
+ test("res.setBody - adds fields", function() {
+ expect(res.getBody().addedInHook).to.equal(true);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/headers.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/headers.bru
new file mode 100644
index 000000000..43c32dcf5
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/headers.bru
@@ -0,0 +1,31 @@
+meta {
+ name: headers
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ bru.setVar('res-content-type', res.getHeader('content-type'));
+ bru.setVar('res-headers-count', Object.keys(res.getHeaders()).length);
+ });
+}
+
+tests {
+ test("res.getHeader - returns header value", function() {
+ const contentType = bru.getVar('res-content-type');
+ expect(contentType).to.be.a('string');
+ });
+
+ test("res.getHeaders - returns all headers", function() {
+ const count = bru.getVar('res-headers-count');
+ expect(count).to.be.greaterThan(0);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/metadata.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/metadata.bru
new file mode 100644
index 000000000..f50336bd4
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/metadata.bru
@@ -0,0 +1,45 @@
+meta {
+ name: metadata
+ type: http
+ seq: 4
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ bru.setVar('res-time', res.getResponseTime());
+ bru.setVar('res-url', res.getUrl());
+ bru.setVar('res-size', res.getSize());
+ });
+}
+
+tests {
+ test("res.getResponseTime - returns response time", function() {
+ const time = bru.getVar('res-time');
+ expect(time).to.be.a('number');
+ expect(time).to.be.greaterThan(0);
+ });
+
+ test("res.getUrl - returns request URL", function() {
+ const url = bru.getVar('res-url');
+ expect(url).to.include('/ping');
+ });
+
+ test("res.getSize - returns size object", function() {
+ const size = bru.getVar('res-size');
+ expect(size).to.have.property('header');
+ expect(size).to.have.property('body');
+ expect(size).to.have.property('total');
+ });
+
+ test("res.getSize - total equals header + body", function() {
+ const size = bru.getVar('res-size');
+ expect(size.total).to.equal(size.header + size.body);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/status.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/status.bru
new file mode 100644
index 000000000..aa863af95
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/res/status.bru
@@ -0,0 +1,34 @@
+meta {
+ name: status
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ bru.setVar('res-status', res.getStatus());
+ bru.setVar('res-status-text', res.getStatusText());
+ });
+}
+
+tests {
+ test("res.getStatus - returns status code", function() {
+ expect(bru.getVar('res-status')).to.equal(200);
+ });
+
+ test("res.getStatusText - returns status text", function() {
+ const statusText = bru.getVar('res-status-text');
+ expect(statusText).to.be.a('string');
+ });
+
+ test("res.getStatus - accessible in tests", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/auth-injection.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/auth-injection.bru
new file mode 100644
index 000000000..7f43a3b35
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/auth-injection.bru
@@ -0,0 +1,40 @@
+meta {
+ name: auth-injection
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/headers
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ req.setHeader('Authorization', 'Bearer mock-token-12345');
+ req.setHeader('X-API-Key', 'api-key-67890');
+ req.setHeader('X-Request-ID', 'req-' + Date.now());
+ });
+
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ const body = res.getBody();
+ bru.setVar('auth-header-echoed', body['authorization']);
+ bru.setVar('api-key-echoed', body['x-api-key']);
+ });
+}
+
+tests {
+ test("Authorization header is injected", function() {
+ expect(bru.getVar('auth-header-echoed')).to.equal('Bearer mock-token-12345');
+ });
+
+ test("API key header is injected", function() {
+ expect(bru.getVar('api-key-echoed')).to.equal('api-key-67890');
+ });
+
+ test("Request succeeds with injected auth", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/both-hooks-req-res.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/both-hooks-req-res.bru
new file mode 100644
index 000000000..e3cf323df
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/both-hooks-req-res.bru
@@ -0,0 +1,61 @@
+meta {
+ name: both-hooks-req-res
+ type: http
+ seq: 3
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "test": "data"
+ }
+}
+
+script:hooks {
+ // beforeRequest: Only req is available
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ bru.setVar('before-req-url', req.getUrl());
+ bru.setVar('before-req-method', req.getMethod());
+ bru.setVar('before-req-name', req.getName());
+ req.setHeader('X-Modified-In-Before', 'yes');
+ });
+
+ // afterResponse: Both req and res are available
+ bru.hooks.http.onAfterResponse(({ req, res }) => {
+ // Can still access req in afterResponse
+ bru.setVar('after-req-url', req.getUrl());
+ bru.setVar('after-req-method', req.getMethod());
+ bru.setVar('after-req-header', req.getHeader('X-Modified-In-Before'));
+
+ // Access res
+ bru.setVar('after-res-status', res.getStatus());
+ bru.setVar('after-res-body', res.getBody());
+ });
+}
+
+tests {
+ test("req is accessible in beforeRequest", function() {
+ expect(bru.getVar('before-req-url')).to.include('/api/echo/json');
+ expect(bru.getVar('before-req-method')).to.equal('POST');
+ });
+
+ test("req is accessible in afterResponse", function() {
+ expect(bru.getVar('after-req-url')).to.include('/api/echo/json');
+ expect(bru.getVar('after-req-method')).to.equal('POST');
+ });
+
+ test("modifications from beforeRequest visible in afterResponse", function() {
+ expect(bru.getVar('after-req-header')).to.equal('yes');
+ });
+
+ test("res is accessible in afterResponse", function() {
+ expect(bru.getVar('after-res-status')).to.equal(200);
+ expect(bru.getVar('after-res-body')).to.have.property('test');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/request-transform.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/request-transform.bru
new file mode 100644
index 000000000..d303373c0
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/api-integration/workflows/request-transform.bru
@@ -0,0 +1,53 @@
+meta {
+ name: request-transform
+ type: http
+ seq: 2
+}
+
+post {
+ url: {{host}}/api/echo/json
+ body: json
+ auth: none
+}
+
+body:json {
+ {
+ "users": [
+ {"id": 1, "name": "Alice"},
+ {"id": 2, "name": "Bob"}
+ ]
+ }
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ const body = req.getBody();
+ // Transform: add timestamp to each user
+ body.users = body.users.map(u => ({ ...u, timestamp: Date.now() }));
+ body.requestTime = new Date().toISOString();
+ req.setBody(body);
+ });
+
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ const body = res.getBody();
+ // Transform: add summary
+ res.setBody({
+ data: body,
+ summary: { userCount: body.users.length }
+ });
+ });
+}
+
+tests {
+ test("Request body is transformed", function() {
+ const body = res.getBody();
+ expect(body.data.users[0]).to.have.property('timestamp');
+ expect(body.data).to.have.property('requestTime');
+ });
+
+ test("Response body is enriched", function() {
+ const body = res.getBody();
+ expect(body.summary.userCount).to.equal(2);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/async-before-request.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/async-before-request.bru
new file mode 100644
index 000000000..fb79c9a78
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/async-before-request.bru
@@ -0,0 +1,55 @@
+meta {
+ name: async-before-request
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(async ({ req }) => {
+ console.log('[beforeRequest] Starting async hook...');
+
+ // Test: Can use async/await
+ await bru.sleep(1000);
+
+ // Test: Can access req object in async context
+ const url = req.getUrl();
+ const method = req.getMethod();
+
+ console.log('[beforeRequest] Request:', method, url);
+
+ // Test: Can use bru APIs in async context
+ bru.setVar('async-beforeRequest-executed', 'true');
+ bru.setVar('async-request-method', method);
+
+ // Test: Can modify request
+ req.setHeader('X-Async-Header', 'async-before-request');
+
+ await bru.sleep(500);
+ console.log('[beforeRequest] Async hook completed');
+ });
+}
+
+tests {
+ test("async beforeRequest hook should have executed", function() {
+ const executed = bru.getVar('async-beforeRequest-executed');
+ expect(executed).to.equal('true');
+ });
+
+ test("should have captured request method in async hook", function() {
+ const method = bru.getVar('async-request-method');
+ expect(method).to.equal('GET');
+ });
+
+ test("request should execute after async hook completes", function() {
+ // If we get here, the request executed after the hook
+ // The hook had 1.5 seconds of sleep, so this test verifies waiting
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/before-request-modify-request.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/before-request-modify-request.bru
new file mode 100644
index 000000000..b4c8078d4
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/before-request-modify-request.bru
@@ -0,0 +1,45 @@
+meta {
+ name: before-request-modify-request
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ console.log('[beforeRequest] Original URL:', req.getUrl());
+ console.log('[beforeRequest] Original method:', req.getMethod());
+
+ // Test: Modify URL
+ const originalUrl = req.getUrl();
+ req.setUrl(originalUrl + '?modified=true');
+
+ // Test: Modify headers
+ req.setHeader('X-Modified-By', 'beforeRequest-hook');
+ req.setHeader('X-Original-URL', originalUrl);
+
+ // Test: Get existing headers
+ const existingHeaders = req.getHeaders();
+ bru.setVar('header-count', Object.keys(existingHeaders).length.toString());
+
+ console.log('[beforeRequest] Modified URL:', req.getUrl());
+ });
+}
+
+tests {
+ test("request should have been modified by hook", function() {
+ // Verify hook executed
+ const headerCount = bru.getVar('header-count');
+ expect(parseInt(headerCount)).to.be.at.least(2);
+ });
+
+ test("response should be successful", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/before-request-with-bru-apis.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/before-request-with-bru-apis.bru
new file mode 100644
index 000000000..56cd9d709
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/before-request-with-bru-apis.bru
@@ -0,0 +1,73 @@
+meta {
+ name: before-request-with-bru-apis
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ // Test: Can use getEnvVar
+ const host = bru.getEnvVar('host');
+ console.log('[beforeRequest] Host from env:', host);
+
+ // Test: Can use setVar/getVar
+ bru.setVar('test-var', 'test-value');
+ const testVar = bru.getVar('test-var');
+
+ // Test: Can use setEnvVar
+ bru.setEnvVar('hook-set-env-var', 'env-value-from-hook');
+
+ // Test: Can use getCollectionVar
+ const collectionVar = bru.getCollectionVar('collection-var');
+ if (collectionVar) {
+ bru.setVar('got-collection-var', collectionVar);
+ }
+
+ // Test: Can use interpolate
+ const interpolated = bru.interpolate('{{host}}/ping');
+ bru.setVar('interpolated-url', interpolated);
+
+ // Test: Can use getEnvName
+ const envName = bru.getEnvName();
+ bru.setVar('env-name', envName);
+
+ // Test: Can use getCollectionName
+ const collectionName = bru.getCollectionName();
+ bru.setVar('collection-name', collectionName);
+
+ console.log('[beforeRequest] All bru APIs tested');
+ });
+}
+
+tests {
+ test("should have set and retrieved var in hook", function() {
+ const testVar = bru.getVar('test-var');
+ expect(testVar).to.equal('test-value');
+ });
+
+ test("should have set env var in hook", function() {
+ const envVar = bru.getEnvVar('hook-set-env-var');
+ expect(envVar).to.equal('env-value-from-hook');
+ });
+
+ test("should have interpolated URL", function() {
+ const interpolated = bru.getVar('interpolated-url');
+ expect(interpolated).to.include('/ping');
+ });
+
+ test("should have captured env name", function() {
+ const envName = bru.getVar('env-name');
+ expect(envName).to.be.a('string');
+ });
+
+ test("should have captured collection name", function() {
+ const collectionName = bru.getVar('collection-name');
+ expect(collectionName).to.be.a('string');
+ });
+}
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/multiple-before-request.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/multiple-before-request.bru
new file mode 100644
index 000000000..6fbaceb58
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/multiple-before-request.bru
@@ -0,0 +1,54 @@
+meta {
+ name: multiple-before-request
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ // First hook
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ console.log('[beforeRequest 1] First hook executing');
+ bru.setVar('hook-order', '1');
+ req.setHeader('X-Hook-Order', '1');
+ });
+
+ // Second hook
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ console.log('[beforeRequest 2] Second hook executing');
+ const order = bru.getVar('hook-order') || '';
+ bru.setVar('hook-order', order + ',2');
+
+ const existingOrder = req.getHeader('X-Hook-Order');
+ req.setHeader('X-Hook-Order', existingOrder + ',2');
+ });
+
+ // Third hook (async)
+ bru.hooks.http.onBeforeRequest(async({ req }) => {
+ console.log('[beforeRequest 3] Third hook (async) executing');
+ await bru.sleep(200);
+ const order = bru.getVar('hook-order') || '';
+ bru.setVar('hook-order', order + ',3');
+
+ const existingOrder = req.getHeader('X-Hook-Order');
+ req.setHeader('X-Hook-Order', existingOrder + ',3');
+ });
+}
+
+tests {
+ test("all beforeRequest hooks should execute in order", function() {
+ const order = bru.getVar('hook-order');
+ expect(order).to.equal('1,2,3');
+ });
+
+ test("request should have headers from all hooks", function() {
+ // Verify hooks executed sequentially
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/sync-before-request.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/sync-before-request.bru
new file mode 100644
index 000000000..cbda6e907
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/beforeRequest/sync-before-request.bru
@@ -0,0 +1,47 @@
+meta {
+ name: sync-before-request
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ // Test: Can access req object
+ const url = req.getUrl();
+ console.log('[beforeRequest] Request URL:', url);
+
+ // Test: Can modify request headers
+ req.setHeader('X-Test-Header', 'sync-before-request');
+
+ // Test: Can use bru APIs
+ bru.setVar('beforeRequest-executed', 'true');
+ bru.setVar('request-url', url);
+
+ console.log('[beforeRequest] Hook executed successfully');
+ });
+}
+
+tests {
+ test("beforeRequest hook should have executed", function() {
+ const executed = bru.getVar('beforeRequest-executed');
+ expect(executed).to.equal('true');
+ });
+
+ test("should have access to request URL in hook", function() {
+ const url = bru.getVar('request-url');
+ expect(url).to.include('/ping');
+ });
+
+ test("request should have custom header set by hook", function() {
+ // Note: This would need to be verified via response if server echoes headers
+ // For now, we verify the hook executed
+ expect(true).to.be.true;
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/combined/all-hooks-together.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/combined/all-hooks-together.bru
new file mode 100644
index 000000000..e97844717
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/combined/all-hooks-together.bru
@@ -0,0 +1,36 @@
+meta {
+ name: all-hooks-together
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ // beforeRequest hook
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ console.log('[beforeRequest] Before request');
+ bru.setVar('hook-execution-order', 'beforeRequest');
+ });
+
+ // afterResponse hook
+ bru.hooks.http.onAfterResponse(({ req, res }) => {
+ console.log('[afterResponse] After response');
+ const order = bru.getVar('hook-execution-order') || '';
+ bru.setVar('hook-execution-order', order + ',afterResponse');
+ });
+}
+
+tests {
+ test("request-level hooks should execute in correct order", function() {
+ const order = bru.getVar('hook-execution-order');
+ // Verify beforeRequest and afterResponse executed
+ expect(order).to.include('beforeRequest');
+ expect(order).to.include('afterResponse');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/combined/hook-chaining.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/combined/hook-chaining.bru
new file mode 100644
index 000000000..50f392f7a
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/combined/hook-chaining.bru
@@ -0,0 +1,48 @@
+meta {
+ name: hook-chaining
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ // beforeRequest uses data (simulating data from onBeforeCollectionRun)
+ bru.hooks.http.onBeforeRequest(({ req }) => {
+ console.log('[beforeRequest] Using setup data');
+ // Simulate token from collection-level setup
+ const token = bru.getVar('test-token') || 'default-token';
+ req.setHeader('X-Auth-Token', token);
+ bru.setVar('token-used-in-beforeRequest', 'true');
+ });
+
+ // afterResponse reads and stores response data
+ bru.hooks.http.onAfterResponse(({ res }) => {
+ console.log('[afterResponse] Storing response data');
+ const status = res.getStatus();
+ bru.setVar('last-response-status', status.toString());
+ bru.setVar('response-stored', 'true');
+ });
+}
+
+tests {
+ test("beforeRequest should have used token", function() {
+ const tokenUsed = bru.getVar('token-used-in-beforeRequest');
+ expect(tokenUsed).to.equal('true');
+ });
+
+ test("afterResponse should have stored response data", function() {
+ const responseStored = bru.getVar('response-stored');
+ expect(responseStored).to.equal('true');
+ });
+
+ test("should have stored last response status", function() {
+ const lastStatus = bru.getVar('last-response-status');
+ expect(lastStatus).to.equal('200');
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/edge-cases/empty-hook-callback.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/edge-cases/empty-hook-callback.bru
new file mode 100644
index 000000000..1b690d9d7
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/edge-cases/empty-hook-callback.bru
@@ -0,0 +1,29 @@
+meta {
+ name: empty-hook-callback
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ // Test: Empty hook callback
+ bru.hooks.http.onBeforeRequest(() => {
+ // Empty callback - should not cause errors
+ });
+
+ bru.hooks.http.onAfterResponse(() => {
+ // Empty callback - should not cause errors
+ });
+}
+
+tests {
+ test("empty hook callbacks should not cause errors", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/edge-cases/hook-with-no-data.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/edge-cases/hook-with-no-data.bru
new file mode 100644
index 000000000..6ddb64d4b
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/edge-cases/hook-with-no-data.bru
@@ -0,0 +1,43 @@
+meta {
+ name: hook-with-no-data
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ // Test: Hook that doesn't use data parameter
+ bru.hooks.http.onBeforeRequest(() => {
+ // Don't use the data parameter, just use bru APIs
+ bru.setVar('hook-executed-without-data', 'true');
+ console.log('[beforeRequest] Hook executed without using data parameter');
+ });
+
+ bru.hooks.http.onAfterResponse(() => {
+ // Don't use the data parameter
+ bru.setVar('afterResponse-executed-without-data', 'true');
+ console.log('[afterResponse] Hook executed without using data parameter');
+ });
+}
+
+tests {
+ test("hook should execute without using data parameter", function() {
+ const executed = bru.getVar('hook-executed-without-data');
+ expect(executed).to.equal('true');
+ });
+
+ test("afterResponse should execute without using data parameter", function() {
+ const executed = bru.getVar('afterResponse-executed-without-data');
+ expect(executed).to.equal('true');
+ });
+
+ test("request should execute successfully", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/errors/error-in-async-hook.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/errors/error-in-async-hook.bru
new file mode 100644
index 000000000..d378778b3
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/errors/error-in-async-hook.bru
@@ -0,0 +1,50 @@
+meta {
+ name: error-in-async-hook
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(async () => {
+ console.log('[beforeRequest] About to throw error in async hook');
+ await bru.sleep(500);
+
+ // Intentionally throw an error to test error handling
+ try {
+ throw new Error('Test error in async hook');
+ } catch (error) {
+ bru.setVar('async-error-caught-in-hook', 'true');
+ bru.setVar('async-error-message', error.message);
+ console.log('[beforeRequest] Async error caught:', error.message);
+ }
+ });
+
+ bru.hooks.http.onAfterResponse(async () => {
+ await bru.sleep(200);
+ // This should still execute even if beforeRequest had an error
+ bru.setVar('async-afterResponse-executed', 'true');
+ });
+}
+
+tests {
+ test("async error should have been caught in hook", function() {
+ const errorCaught = bru.getVar('async-error-caught-in-hook');
+ expect(errorCaught).to.equal('true');
+ });
+
+ test("async afterResponse should execute even if beforeRequest had error", function() {
+ const afterResponseExecuted = bru.getVar('async-afterResponse-executed');
+ expect(afterResponseExecuted).to.equal('true');
+ });
+
+ test("request should still execute", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/hooks/errors/error-in-sync-hook.bru b/packages/bruno-tests/hooks-comprehensive-tests/hooks/errors/error-in-sync-hook.bru
new file mode 100644
index 000000000..bc68834a2
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/hooks/errors/error-in-sync-hook.bru
@@ -0,0 +1,47 @@
+meta {
+ name: error-in-sync-hook
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+script:hooks {
+ bru.hooks.http.onBeforeRequest(() => {
+ console.log('[beforeRequest] About to throw error');
+ // Intentionally throw an error to test error handling
+ try {
+ throw new Error('Test error in sync hook');
+ } catch (error) {
+ bru.setVar('error-caught-in-hook', 'true');
+ bru.setVar('error-message', error.message);
+ console.log('[beforeRequest] Error caught:', error.message);
+ }
+ });
+
+ bru.hooks.http.onAfterResponse(() => {
+ // This should still execute even if beforeRequest had an error
+ bru.setVar('afterResponse-executed', 'true');
+ });
+}
+
+tests {
+ test("error should have been caught in hook", function() {
+ const errorCaught = bru.getVar('error-caught-in-hook');
+ expect(errorCaught).to.equal('true');
+ });
+
+ test("afterResponse should execute even if beforeRequest had error", function() {
+ const afterResponseExecuted = bru.getVar('afterResponse-executed');
+ expect(afterResponseExecuted).to.equal('true');
+ });
+
+ test("request should still execute", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/sync-collection-run-end.bru b/packages/bruno-tests/hooks-comprehensive-tests/sync-collection-run-end.bru
new file mode 100644
index 000000000..d67777622
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/sync-collection-run-end.bru
@@ -0,0 +1,18 @@
+meta {
+ name: sync-collection-run-end
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("request should execute successfully", function() {
+ expect(res.getStatus()).to.equal(200);
+ });
+}
+
diff --git a/packages/bruno-tests/hooks-comprehensive-tests/sync-collection-run-start.bru b/packages/bruno-tests/hooks-comprehensive-tests/sync-collection-run-start.bru
new file mode 100644
index 000000000..f796e57af
--- /dev/null
+++ b/packages/bruno-tests/hooks-comprehensive-tests/sync-collection-run-start.bru
@@ -0,0 +1,39 @@
+meta {
+ name: sync-collection-run-start
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{host}}/ping
+ body: none
+ auth: none
+}
+
+tests {
+ test("onBeforeCollectionRun hook should have executed", function() {
+ const started = bru.getVar('collection-run-started');
+ expect(started).to.equal('true');
+ });
+
+ test("should have set start time", function() {
+ const startTime = bru.getVar('start-time');
+ expect(parseInt(startTime)).to.be.a('number');
+ });
+
+ test("should have set env var in onBeforeCollectionRun", function() {
+ const setupComplete = bru.getEnvVar('collection-setup-complete');
+ expect(setupComplete).to.equal('true');
+ });
+
+ test("should have captured env name at start", function() {
+ const envName = bru.getVar('env-name-at-start');
+ expect(envName).to.be.a('string');
+ });
+
+ test("should have captured collection name at start", function() {
+ const collectionName = bru.getVar('collection-name-at-start');
+ expect(collectionName).to.be.a('string');
+ });
+}
+
diff --git a/tests/protobuf/collection/bruno.json b/tests/protobuf/collection/bruno.json
index 83a645561..dc1ca140d 100644
--- a/tests/protobuf/collection/bruno.json
+++ b/tests/protobuf/collection/bruno.json
@@ -6,8 +6,8 @@
"node_modules",
".git"
],
- "size": 0.001827239990234375,
- "filesCount": 10,
+ "size": 0.0006170272827148438,
+ "filesCount": 5,
"protobuf": {
"protoFiles": [
{
diff --git a/tests/scripting/hooks/hooks.spec.ts b/tests/scripting/hooks/hooks.spec.ts
new file mode 100644
index 000000000..81df52997
--- /dev/null
+++ b/tests/scripting/hooks/hooks.spec.ts
@@ -0,0 +1,36 @@
+import { test } from '../../../playwright';
+import { setSandboxMode, runCollection, validateRunnerResults } from '../../utils/page';
+
+test.describe.serial('Hooks feature', () => {
+ test.describe('developer mode', () => {
+ test('should execute all hooks comprehensively', async ({ pageWithUserData: page }) => {
+ test.setTimeout(5 * 60 * 1000);
+
+ await setSandboxMode(page, 'hooks-comprehensive-tests', 'developer');
+ await runCollection(page, 'hooks-comprehensive-tests');
+
+ await validateRunnerResults(page, {
+ totalRequests: 41,
+ passed: 41,
+ failed: 0,
+ skipped: 0
+ });
+ });
+ });
+
+ test.describe('safe mode', () => {
+ test('should execute all hooks comprehensively', async ({ pageWithUserData: page }) => {
+ test.setTimeout(5 * 60 * 1000);
+
+ await setSandboxMode(page, 'hooks-comprehensive-tests', 'safe');
+ await runCollection(page, 'hooks-comprehensive-tests');
+
+ await validateRunnerResults(page, {
+ totalRequests: 41,
+ passed: 41,
+ failed: 0,
+ skipped: 0
+ });
+ });
+ });
+});
diff --git a/tests/scripting/hooks/init-user-data/preferences.json b/tests/scripting/hooks/init-user-data/preferences.json
new file mode 100644
index 000000000..85112dd08
--- /dev/null
+++ b/tests/scripting/hooks/init-user-data/preferences.json
@@ -0,0 +1,6 @@
+{
+ "maximized": true,
+ "lastOpenedCollections": [
+ "{{projectRoot}}/packages/bruno-tests/hooks-comprehensive-tests"
+ ]
+}
\ No newline at end of file
diff --git a/tests/scripting/hooks/init-user-data/ui-state-snapshot.json b/tests/scripting/hooks/init-user-data/ui-state-snapshot.json
new file mode 100644
index 000000000..216e1bb19
--- /dev/null
+++ b/tests/scripting/hooks/init-user-data/ui-state-snapshot.json
@@ -0,0 +1,8 @@
+{
+ "collections": [
+ {
+ "pathname": "{{projectRoot}}/packages/bruno-tests/hooks-comprehensive-tests",
+ "selectedEnvironment": "Prod"
+ }
+ ]
+}
\ No newline at end of file