feat: hooks runtime

add: hooks component

add support for hooks within bruno-lang

fix: hooks is not getting save

hooks implemtation

add hooks component within folders, requests

add: quick js shims for hooks

fix: garbage collected hook managers

send logs to main

rm: hook manager store

feat: introduce HOOK_EVENTS constant for improved hook management

add folder start/end events

add folder run events

rm: folder run related events

add cli support for hooks

support script:hooks instead of hooks

move hooks to script tab

make outer scope available within callback in safemode

added runner, req apis as an abstraction over event based hooks

fix: crash while editing folder hooks

rm: unused files

fix: self review changes

refactor, request specific hook manager deleted once

add: cm

rm: spaces

add prompt var

rm: indent

fix: lint

refactor: shims handling for hooks

fix: enable async calling in dev mode for gui, cli

fix: support async callbacks within safe mode

rm: vm instance

fix: review comments

fix: review comments

add cli tests for hooks

rm: client certs

fix: add hooks to oc yaml

fix: rename uid ot path name for better clarity, app crash when saving folder hooks

rm: console

rm: vm2 runtime leftover

rm: check

add: handler cleanup function

add: playwright test case for hooks

rm: review fixes

fix: review comments

add fallback hook manager

add fallback hook manager

fix: show error from hooks scripts within response pane

change: collection events name

feat: add name spaced hooks

fix: review comments

add: hooks specific collection for testing

use hooks manager as a private field

fix: tests

use collection from bruno-test within playwright

rm: databuffer test

fix: playwright test

rm: unintended changes

rm: file
This commit is contained in:
sanish-bruno
2025-11-12 15:38:40 +05:30
parent 5044241d17
commit 4bb01ca0ac
88 changed files with 3794 additions and 46 deletions

View File

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

View File

@@ -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 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="hooks">
Hooks
{hooks && hooks.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">
@@ -120,6 +135,21 @@ const Script = ({ collection }) => {
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
<TabsContent value="hooks" className="mt-2">
<CodeEditor
ref={hooksEditorRef}
collection={collection}
value={hooks || ''}
theme={displayedTheme}
onEdit={onHooksEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
</Tabs>
<div className="mt-12">

View File

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

View File

@@ -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 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="hooks">
Hooks
{hooks && hooks.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">
@@ -122,6 +138,21 @@ const Script = ({ collection, folder }) => {
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
<TabsContent value="hooks" className="mt-2">
<CodeEditor
ref={hooksEditorRef}
collection={collection}
value={hooks || ''}
theme={displayedTheme}
onEdit={onHooksEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
</Tabs>
<div className="mt-12">

View File

@@ -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 || [];

View File

@@ -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 ? <sup className="font-medium">{activeCounts.headers}</sup> : null,
auth: auth.mode !== 'none' ? <StatusDot /> : null,
vars: activeCounts.vars > 0 ? <sup className="font-medium">{activeCounts.vars}</sup> : null,
script: (script.req || script.res) ? (hasScriptError ? <StatusDot type="error" /> : <StatusDot />) : null,
script: (script.req || script.res || script.hooks) ? (hasScriptError ? <StatusDot type="error" /> : <StatusDot />) : null,
assert: activeCounts.assertions > 0 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
tests: tests?.length > 0 ? (hasTestError ? <StatusDot type="error" /> : <StatusDot />) : null,
docs: docs?.length > 0 ? <StatusDot /> : null,

View File

@@ -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 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="hooks">
Hooks
{hooks && hooks.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="pre-request-script-editor">
@@ -121,6 +137,22 @@ const Script = ({ item, collection }) => {
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
<TabsContent value="hooks" className="mt-2" dataTestId="hooks-editor">
<CodeEditor
ref={hooksEditorRef}
collection={collection}
value={hooks || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onHooksEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
</Tabs>
</div>
);

View File

@@ -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',

View File

@@ -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 [

View File

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

View File

@@ -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,

View File

@@ -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()',

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

@@ -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<void>}
*/
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>} 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:<collectionUid>', 'folder:<folderUid>', 'request:<requestUid>'
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',

View File

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

View File

@@ -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', []),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -716,6 +716,14 @@ ${indentString(script.req)}
${indentString(script.res)}
}
`;
}
if (script && script.hooks && script.hooks.length) {
bru += `script:hooks {
${indentString(script.hooks)}
}
`;
}

View File

@@ -411,6 +411,14 @@ ${indentString(script.req)}
${indentString(script.res)}
}
`;
}
if (script && script.hooks && script.hooks.length) {
bru += `script:hooks {
${indentString(script.hooks)}
}
`;
}

View File

@@ -1,4 +1,5 @@
export interface Script {
req?: string | null;
res?: string | null;
hooks?: string | null;
}

View File

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

View File

@@ -0,0 +1,2 @@
!.env
junit*.xml

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "hooks-comprehensive-tests",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
vars {
host: https://testbench-sanity.usebruno.com
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,8 @@
"node_modules",
".git"
],
"size": 0.001827239990234375,
"filesCount": 10,
"size": 0.0006170272827148438,
"filesCount": 5,
"protobuf": {
"protoFiles": [
{

View File

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

View File

@@ -0,0 +1,6 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/packages/bruno-tests/hooks-comprehensive-tests"
]
}

View File

@@ -0,0 +1,8 @@
{
"collections": [
{
"pathname": "{{projectRoot}}/packages/bruno-tests/hooks-comprehensive-tests",
"selectedEnvironment": "Prod"
}
]
}