Compare commits

...

26 Commits

Author SHA1 Message Date
Bijin A B
bc09a26fa6 feat(hooks): refactor to initialise hook-manager only once per req execution 2026-02-12 22:18:32 +05:30
sanish-bruno
521f7332fd refactor: simplify HookManager by removing error collection and related options 2026-01-29 17:47:15 +05:30
sanish-bruno
3414c4fe2e rm: unintended changes 2026-01-29 14:59:56 +05:30
sanish-bruno
5b9511c50d refactor: remove cleanup function registration from HookManager and related tests 2026-01-29 14:51:13 +05:30
sanish-bruno
63e5fbab36 refactor: remove unnecessary parameters and comments in QuickJS VM execution 2026-01-29 14:19:37 +05:30
sanish-bruno
5e03846da6 refactor: simplify QuickJS VM execution by removing unnecessary cleanup logic 2026-01-28 19:41:35 +05:30
sanish-bruno
2a585b0e62 refactor: remove statistics tracking from HookManager and HooksRuntime 2026-01-28 19:13:59 +05:30
sanish-bruno
68bd6d5303 refactor: remove outdated comments and clean up hook execution documentation 2026-01-28 19:13:59 +05:30
sanish-bruno
60593575e3 refactor: remove unused imports and enhance collection-level hook updates
- Eliminated unused imports from collection.js to clean up the codebase.
- Added functionality to send UI updates after executing collection-level hooks in network/index.js, improving responsiveness and user experience.
2026-01-28 19:13:59 +05:30
sanish-bruno
5851693529 refactor: streamline hook execution by merging scripts and removing unused functions
- Consolidated hook management by merging hooks from collection, folders, and requests into a single script using mergeScripts.
- Removed the HooksExecutor and HooksConsolidator, simplifying the execution flow.
- Updated runSingleRequest and network IPC to utilize the new merged hooks approach for improved performance and clarity.
- Enhanced comments for better understanding of the hook execution process.
2026-01-28 19:13:58 +05:30
sanish-bruno
57351b74ec refactor: centralize BrunoRequest and BrunoResponse creation in HooksRuntime 2026-01-28 19:13:58 +05:30
sanish-bruno
10408a344a feat: enhance hook control flow and response handling 2026-01-28 19:13:58 +05:30
sanish-bruno
9b40dd4551 feat: add sendScriptEnvironmentUpdates function for improved UI updates 2026-01-28 19:13:58 +05:30
sanish-bruno
b9d1b43042 test: update hook tests to reflect increased request counts 2026-01-28 19:13:58 +05:30
sanish-bruno
b82f068c8c refactor: remove unused hook execution function and clean up code
- Eliminated the executeHooksForLevel function from run-single-request.js and network/index.js to streamline hook management.
- Updated comments for clarity and consistency in the remaining hook execution functions.
2026-01-28 19:13:58 +05:30
sanish-bruno
ef2498a898 rm: ununsed fn 2026-01-28 19:13:57 +05:30
sanish-bruno
3d319855bc feat: enhance hook functionality with runSingleRequestByPathname
- Introduced runSingleRequestByPathname to allow running requests at the collection level.
- Updated executeCollectionHooks to utilize the new function for improved hook execution.
- Added comprehensive tests for bru.runRequest in various hook scenarios, ensuring correct behavior and response handling.
- Removed redundant code and optimized hook management in collection.js.
2026-01-28 19:13:57 +05:30
sanish-bruno
2c474e8052 feat: introduce HooksExecutor and HooksConsolidator for optimized hook execution
- Added HooksExecutor to centralize hook execution logic across CLI and Electron.
- Implemented HooksConsolidator to improve performance by consolidating multiple hook levels into a single execution context.
- Refactored existing hook management to utilize the new executor and consolidator, enhancing efficiency and reducing resource usage.
- Updated runSingleRequest and network IPC to leverage consolidated hook execution for better performance.
- Introduced comprehensive tests for the new hooks functionality, ensuring reliability and correctness.
2026-01-28 19:13:57 +05:30
sanish-bruno
cc33299702 refactor: streamline hook management by removing unnecessary HookManager instances and optimizing hook execution flow 2026-01-28 19:13:57 +05:30
sanish-bruno
777707180e fix: pass runtime context to Bru instance in hooks runtime 2026-01-28 19:13:57 +05:30
sanish-bruno
34fcc5bbfb fix: init send event at the right time 2026-01-28 19:13:57 +05:30
sanish-bruno
de43ad00d8 rm: duplicate 2026-01-28 19:13:57 +05:30
sanish-bruno
60d5d5e98d fix: optimize quick js runtime 2026-01-28 19:13:57 +05:30
sanish-bruno
f6e2279fe3 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
2026-01-28 19:13:57 +05:30
sanish-bruno
80b4ab0561 fix: disable run collection icon once triggered to avoid multiple collection runs 2026-01-28 19:13:57 +05:30
sanish-bruno
4bb01ca0ac 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
2026-01-28 19:13:57 +05:30
103 changed files with 4634 additions and 193 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

@@ -310,7 +310,7 @@ export default function RunnerResults({ collection }) {
<div className="flex flex-row gap-2">
<Button
type="submit"
disabled={shouldDisableCollectionRun || (configureMode && selectedRequestItems.length === 0) || isCollectionLoading}
disabled={shouldDisableCollectionRun || (configureMode && selectedRequestItems.length === 0) || isCollectionLoading || runnerInfo.status === 'started'}
onClick={runCollection}
>
{configureMode && selectedRequestItems.length > 0

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');
@@ -18,6 +18,9 @@ const constants = require('../constants');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection');
const { hasExecutableTestInScript } = require('../utils/request');
const { createSkippedFileResults } = require('../utils/run');
const { HooksRuntime, HookManager } = require('@usebruno/js');
const HOOK_EVENTS = HookManager.EVENTS;
const decomment = require('decomment');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
@@ -315,7 +318,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,7 +615,15 @@ const handler = async function (argv) {
});
const runtime = getJsSandboxRuntime(sandbox);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = runtime;
const collectionName = collection?.brunoConfig?.name;
const onConsoleLog = (type, args) => {
console[type](...args);
};
// Define runSingleRequestByPathname before hooks initialization so it's available at all hook levels
const runSingleRequestByPathname = async (relativeItemPathname) => {
const ext = FORMAT_CONFIG[collection.format].ext;
return new Promise(async (resolve, reject) => {
@@ -640,6 +651,42 @@ const handler = async function (argv) {
});
};
// Initialize collection-level hooks once (evaluated once, reused for before/after events)
let collectionHooksCtx = null;
{
collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionHooks = get(collectionRoot, 'request.script.hooks', '');
if (collectionHooks && collectionHooks.trim()) {
try {
const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime });
collectionHooksCtx = await hooksRuntime.runHooks({
hooksFile: decomment(collectionHooks),
request: {}, // Placeholder request for collection-level hooks
envVariables: envVars,
runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname: runSingleRequestByPathname,
collectionName
});
} catch (error) {
console.error('Error initializing collection-level hooks:', error);
}
}
}
// Call onBeforeCollectionRun hook before starting to run requests
if (collectionHooksCtx?.hookManager) {
try {
await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN, { collection });
} catch (error) {
console.error('Error executing beforeCollectionRun hook:', error);
}
}
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < requestItems.length) {
@@ -752,6 +799,17 @@ const handler = async function (argv) {
const skippedFileResults = createSkippedFileResults(global.brunoSkippedFiles || [], collectionPath);
results.push(...skippedFileResults);
// Call onAfterCollectionRun hook after all requests are done
if (collectionHooksCtx?.hookManager) {
try {
await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection });
} catch (error) {
console.error('Error executing afterCollectionRun hook:', error);
}
collectionHooksCtx.hookManager.dispose();
collectionHooksCtx = null;
}
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,11 @@ 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, HookManager, BrunoResponse } = require('@usebruno/js');
const HOOK_EVENTS = HookManager.EVENTS;
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
const { getTreePathFromCollectionToItem } = require('../utils/collection');
const https = require('https');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
@@ -34,6 +36,71 @@ const getCACertHostRegex = (domain) => {
return '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
};
/**
* Apply runner control signals from a script/hook result
* @param {object} result - Result from script/hook execution
* @param {object} state - Current runner state (modified in place)
* @param {string|undefined} state.nextRequestName - Current next request name
* @param {boolean} state.shouldStopRunnerExecution - Current stop flag
* @returns {object} Updated state with nextRequestName and shouldStopRunnerExecution
*/
const applyRunnerControlFromResult = (result, state) => {
if (result?.nextRequestName !== undefined) {
state.nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
state.shouldStopRunnerExecution = true;
}
return state;
};
/**
* Create a standardized skipped response object
* @param {object} options - Options for creating the response
* @param {string} options.filename - The relative file pathname
* @param {object} options.request - The request object
* @param {string} options.statusText - The reason for skipping
* @param {array} [options.preRequestTestResults] - Pre-request test results
* @param {array} [options.postResponseTestResults] - Post-response test results
* @param {string} [options.nextRequestName] - Next request name if set
* @param {boolean} [options.shouldStopRunnerExecution] - Stop execution flag
* @returns {object} Standardized skipped response object
*/
const createSkippedResponse = ({
filename,
request,
statusText,
preRequestTestResults = [],
postResponseTestResults = [],
nextRequestName,
shouldStopRunnerExecution = false
}) => {
return {
test: { filename },
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText,
data: null,
responseTime: 0
},
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults,
postResponseTestResults,
nextRequestName,
shouldStopRunnerExecution
};
};
/**
* Extract prompt variables from a request
* Tries to respect the hierarchy of the variables and avoid unnecessary prompts as much as possible
@@ -132,31 +199,12 @@ const runSingleRequest = async function (
if (promptVars.length > 0) {
const errorMsg = `Prompt variables detected in request. CLI execution is not supported for requests with prompt variables. \nPrompts: ${promptVars.join(', ')}`;
console.log(chalk.yellow(stripExtension(relativeItemPathname) + ' Skipped:') + chalk.dim(` (${errorMsg})`));
return {
test: {
filename: relativeItemPathname
},
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText: errorMsg,
data: null,
responseTime: 0
},
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults: [],
postResponseTestResults: [],
return createSkippedResponse({
filename: relativeItemPathname,
request,
statusText: errorMsg,
shouldStopRunnerExecution
};
});
}
request.__bruno__executionMode = 'cli';
@@ -164,9 +212,90 @@ const runSingleRequest = async function (
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = runtime;
// Get request tree path for hook execution
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
const collectionName = collection?.brunoConfig?.name;
// Initialize hooks once for this request lifecycle (reused for beforeRequest + afterResponse)
let hooksCtx = null;
const hooksFile = request.script?.hooks;
if (hooksFile && hooksFile.trim()) {
try {
const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime });
hooksCtx = await hooksRuntime.runHooks({
hooksFile,
request,
envVariables,
runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname: runSingleRequestByPathname,
collectionName
});
} catch (error) {
console.error('Error initializing hooks:', error);
}
}
/**
* Trigger a specific hook event using the initialized hooks context.
* Re-reads runner control signals from bru instance after handlers execute.
*/
const triggerHookEvent = async (hookEvent, eventData) => {
if (!hooksCtx?.hookManager) return null;
try {
const enrichedEventData = {
...eventData,
req: hooksCtx.req || eventData.req,
res: eventData.response ? new BrunoResponse(eventData.response) : (hooksCtx.res || eventData.res)
};
await hooksCtx.hookManager.call(hookEvent, enrichedEventData);
// Re-read runner control signals from bru instance AFTER handlers have executed
const bru = hooksCtx.__bru;
if (bru) {
hooksCtx.nextRequestName = bru.nextRequest;
hooksCtx.skipRequest = bru.skipRequest;
hooksCtx.stopExecution = bru.stopExecution;
}
return hooksCtx;
} catch (error) {
console.error(`Error executing hooks for ${hookEvent}:`, error);
return null;
}
};
// Call beforeRequest hooks before running pre-request scripts
// Hooks are called in registration order: collection -> folder(s) -> request
const beforeRequestEventData = { request, collection };
const beforeRequestHooksResult = await triggerHookEvent(
HOOK_EVENTS.HTTP_BEFORE_REQUEST,
beforeRequestEventData
);
// Check runner control from hooks
const runnerState = { nextRequestName, shouldStopRunnerExecution };
applyRunnerControlFromResult(beforeRequestHooksResult, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
if (beforeRequestHooksResult?.skipRequest) {
return createSkippedResponse({
filename: relativeItemPathname,
request,
statusText: 'request skipped via beforeRequest hook',
nextRequestName,
shouldStopRunnerExecution
});
}
// 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(
@@ -181,40 +310,18 @@ const runSingleRequest = async function (
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
applyRunnerControlFromResult(result, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
if (result?.skipRequest) {
return {
test: {
filename: relativeItemPathname
},
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText: 'request skipped via pre-request script',
data: null,
responseTime: 0
},
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
return createSkippedResponse({
filename: relativeItemPathname,
request,
statusText: 'request skipped via pre-request script',
preRequestTestResults: result?.results || [],
postResponseTestResults: [],
shouldStopRunnerExecution
};
});
}
preRequestTestResults = result?.results || [];
@@ -639,6 +746,25 @@ 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
const afterResponseEventData = { request, response, collection };
const afterResponseHooksResult = await triggerHookEvent(
HOOK_EVENTS.HTTP_AFTER_RESPONSE,
afterResponseEventData
);
// Dispose hooks context — done with all events for this request
if (hooksCtx?.hookManager) {
hooksCtx.hookManager.dispose();
}
// Check runner control from hooks
applyRunnerControlFromResult(afterResponseHooksResult, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
// run post-response vars
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
@@ -672,13 +798,9 @@ const runSingleRequest = async function (
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
applyRunnerControlFromResult(result, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
postResponseTestResults = result?.results || [];
logResults(postResponseTestResults, 'Post-Response Tests');
@@ -722,13 +844,9 @@ const runSingleRequest = async function (
);
testResults = get(result, 'results', []);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
applyRunnerControlFromResult(result, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
logResults(testResults, 'Tests');
} catch (error) {

View File

@@ -231,10 +231,12 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
let collectionPreReqScript = get(collectionRoot, 'request.script.req', '');
let collectionPostResScript = get(collectionRoot, 'request.script.res', '');
let collectionTests = get(collectionRoot, 'request.tests', '');
let collectionHooks = get(collectionRoot, 'request.script.hooks', '');
let combinedPreReqScript = [];
let combinedPostResScript = [];
let combinedTests = [];
let combinedHooks = [];
for (let i of requestTreePath) {
if (i.type === 'folder') {
const folderRoot = i?.draft || i?.root;
@@ -252,6 +254,11 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
if (tests && tests?.trim?.() !== '') {
combinedTests.push(tests);
}
let hooks = get(folderRoot, 'request.script.hooks', '');
if (hooks && hooks.trim() !== '') {
combinedHooks.push(hooks);
}
}
}
@@ -301,6 +308,21 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
];
request.tests = compact(testScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL);
}
// Handle hooks - always merged sequentially: collection -> folders -> request
let requestHooks = request?.script?.hooks || '';
const hooksScripts = [
collectionHooks,
...combinedHooks,
requestHooks
];
// Ensure request.script exists
if (!request.script) {
request.script = {};
}
request.script.hooks = compact(hooksScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL);
};
const findItem = (items = [], pathname) => {

View File

@@ -8,7 +8,8 @@ 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, HookManager, BrunoResponse } = require('@usebruno/js');
const HOOK_EVENTS = HookManager.EVENTS;
const { encodeUrl } = require('@usebruno/common').utils;
const { extractPromptVariables } = require('@usebruno/common').utils;
const { interpolateString } = require('./interpolate-string');
@@ -443,8 +444,141 @@ const registerNetworkIpc = (mainWindow) => {
});
};
const runPreRequest = async (
request,
/**
* Send script environment updates to the renderer
* This is a reusable helper for updating the UI after script/hook execution
* @param {object} options - Update options
* @param {object} options.scriptResult - The result from script/hook execution
* @param {object} options.collection - The collection object
* @param {string} options.collectionUid - The collection UID
* @param {string} [options.requestUid] - The request UID (optional, not needed for collection-level hooks)
* @param {boolean} [options.updateCookies=true] - Whether to update cookies
*/
const sendScriptEnvironmentUpdates = async ({
scriptResult,
collection,
collectionUid,
requestUid,
updateCookies = true
}) => {
if (!scriptResult) return;
mainWindow.webContents.send('main:script-environment-update', {
envVariables: scriptResult.envVariables,
runtimeVariables: scriptResult.runtimeVariables,
persistentEnvVariables: scriptResult.persistentEnvVariables,
...(requestUid && { requestUid }),
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: scriptResult.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
if (scriptResult.globalEnvironmentVariables) {
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
}
if (updateCookies) {
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
}
};
/**
* Initialize hooks by evaluating the hooks file once.
* Returns context for subsequent event triggering via triggerHookEvent().
* Caller must dispose hooksCtx.hookManager when done with all events.
* @param {object} options - Configuration options
* @returns {Promise<object|null>} Hooks context or null if no hooks/error
*/
const initHooks = async (options) => {
const hooksFile = options.request?.script?.hooks;
if (!hooksFile || !hooksFile.trim()) return null;
try {
const hooksRuntime = new HooksRuntime({ runtime: options.scriptingConfig?.runtime });
const result = await hooksRuntime.runHooks({
hooksFile,
request: options.request || {},
envVariables: options.envVars,
runtimeVariables: options.runtimeVariables,
collectionPath: options.collectionPath,
onConsoleLog: options.onConsoleLog,
processEnvVars: options.processEnvVars,
scriptingConfig: options.scriptingConfig,
runRequestByItemPathname: options.runRequestByItemPathname,
collectionName: options.collectionName
});
if (result) {
await sendScriptEnvironmentUpdates({
scriptResult: result,
collection: options.collection,
collectionUid: options.collectionUid,
requestUid: options.requestUid,
updateCookies: true
});
}
return result;
} catch (error) {
console.error('Error initializing hooks:', error);
options.onConsoleLog?.('error', [`Error initializing hooks: ${error.message}`]);
return null;
}
};
/**
* Trigger a specific hook event using an already-initialized hooks context.
* Re-reads runner control signals from bru instance after handlers execute.
* @param {object} hooksCtx - Context from initHooks()
* @param {string} hookEvent - Hook event to trigger
* @param {object} eventData - Data to pass to hook handlers
* @param {object} options - Configuration options
* @returns {Promise<object|null>} Updated hooks context or null if error
*/
const triggerHookEvent = async (hooksCtx, hookEvent, eventData, options) => {
if (!hooksCtx?.hookManager) return null;
try {
const enrichedEventData = {
...eventData,
req: hooksCtx.req || eventData.req,
res: eventData.response ? new BrunoResponse(eventData.response) : (hooksCtx.res || eventData.res)
};
await hooksCtx.hookManager.call(hookEvent, enrichedEventData);
// Re-read runner control signals from bru instance AFTER handlers have executed
// Handlers may have called bru.runner.setNextRequest(), skipRequest(), or stopExecution()
const bru = hooksCtx.__bru;
if (bru) {
hooksCtx.nextRequestName = bru.nextRequest;
hooksCtx.skipRequest = bru.skipRequest;
hooksCtx.stopExecution = bru.stopExecution;
}
await sendScriptEnvironmentUpdates({
scriptResult: hooksCtx,
collection: options.collection,
collectionUid: options.collectionUid,
requestUid: options.requestUid,
updateCookies: true
});
return hooksCtx;
} catch (error) {
console.error(`Error executing hooks for ${hookEvent}:`, error);
options.onConsoleLog?.('error', [`Error executing hooks for ${hookEvent}: ${error.message}`]);
return null;
}
};
const runPreRequest = async (request,
requestUid,
envVars,
collectionPath,
@@ -475,27 +609,13 @@ const registerNetworkIpc = (mainWindow) => {
collectionName
);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: scriptResult.envVariables,
runtimeVariables: scriptResult.runtimeVariables,
persistentEnvVariables: scriptResult.persistentEnvVariables,
await sendScriptEnvironmentUpdates({
scriptResult,
collection,
collectionUid,
requestUid,
collectionUid
updateCookies: true
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: scriptResult.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
}
// interpolate variables inside request
@@ -564,24 +684,13 @@ const registerNetworkIpc = (mainWindow) => {
);
if (result) {
mainWindow.webContents.send('main:script-environment-update', {
envVariables: result.envVariables,
runtimeVariables: result.runtimeVariables,
persistentEnvVariables: result.persistentEnvVariables,
await sendScriptEnvironmentUpdates({
scriptResult: result,
collection,
collectionUid,
requestUid,
collectionUid
updateCookies: false
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: result.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: result.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = result.globalEnvironmentVariables;
}
if (result?.error) {
@@ -609,27 +718,13 @@ const registerNetworkIpc = (mainWindow) => {
collectionName
);
mainWindow.webContents.send('main:script-environment-update', {
envVariables: scriptResult.envVariables,
runtimeVariables: scriptResult.runtimeVariables,
persistentEnvVariables: scriptResult.persistentEnvVariables,
await sendScriptEnvironmentUpdates({
scriptResult,
collection,
collectionUid,
requestUid,
collectionUid
updateCookies: true
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: scriptResult.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: scriptResult.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
const domainsWithCookiesPost = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesPost)));
}
return scriptResult;
};
@@ -676,10 +771,40 @@ const registerNetworkIpc = (mainWindow) => {
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
// Get request tree path for hooks execution
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
try {
request.signal = abortController.signal;
saveCancelToken(cancelTokenUid, abortController);
// Initialize hooks once for this request lifecycle
// Hooks are merged in prepareRequest via mergeScripts
// Hooks are called in registration order: collection -> folder(s) -> request
const hookOptions = {
request,
envVars,
runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName: collection?.name,
collection,
requestUid,
itemUid: item.uid,
collectionUid,
runInBackground,
notifyScriptExecution
};
const hooksCtx = await initHooks(hookOptions);
// Call beforeRequest hooks using initialized hooks context
const beforeRequestEventData = { request, collection, collectionUid };
await triggerHookEvent(hooksCtx, HOOK_EVENTS.HTTP_BEFORE_REQUEST, beforeRequestEventData, hookOptions);
let preRequestScriptResult = null;
let preRequestError = null;
try {
@@ -844,6 +969,18 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
cookiesStore.saveCookieJar();
// Call afterResponse hooks after response is received but before post-response scripts
// Hooks are called in registration order: collection -> folder(s) -> request
const afterResponseEventData = { request, response, collection, collectionUid };
// Call afterResponse hooks using already-initialized hooks context
await triggerHookEvent(hooksCtx, HOOK_EVENTS.HTTP_AFTER_RESPONSE, afterResponseEventData, hookOptions);
// Dispose hooks context — done with all events for this request
if (hooksCtx?.hookManager) {
hooksCtx.hookManager.dispose();
}
const runPostScripts = async () => {
let postResponseScriptResult = null;
let postResponseError = null;
@@ -945,23 +1082,14 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
runtimeVariables: testResults.runtimeVariables,
await sendScriptEnvironmentUpdates({
scriptResult: testResults,
collection,
collectionUid,
requestUid,
collectionUid
updateCookies: true
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: testResults.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
cookiesStore.saveCookieJar();
!runInBackground && notifyScriptExecution({
channel: 'main:run-request-event',
@@ -969,10 +1097,6 @@ const registerNetworkIpc = (mainWindow) => {
scriptType: 'test',
error: testError
});
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
cookiesStore.saveCookieJar();
}
};
if (isResponseStream) {
@@ -1171,6 +1295,65 @@ const registerNetworkIpc = (mainWindow) => {
cancelTokenUid
});
const isCollectionRun = folder?.uid === collection?.uid;
// Initialize collection-level hooks once (evaluated once, reused for before/after events)
let collectionHooksCtx = null;
if (isCollectionRun) {
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionHooks = get(collectionRoot, 'request.script.hooks', '');
if (collectionHooks && collectionHooks.trim()) {
try {
const hooksRuntime = new HooksRuntime({ runtime: scriptingConfig?.runtime });
collectionHooksCtx = await hooksRuntime.runHooks({
hooksFile: decomment(collectionHooks),
request: {}, // Placeholder request for collection-level hooks
envVariables: envVars,
runtimeVariables,
collectionPath,
onConsoleLog: (type, args) => {
console[type](...args);
mainWindow.webContents.send('main:console-log', {
type,
args
});
},
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName: collection?.name
});
if (collectionHooksCtx) {
await sendScriptEnvironmentUpdates({
scriptResult: collectionHooksCtx,
collection,
collectionUid,
updateCookies: true
});
}
} catch (error) {
console.error('Error initializing collection-level hooks:', error);
}
}
}
// Call onBeforeCollectionRun hook before starting to run requests
if (collectionHooksCtx?.hookManager) {
try {
await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_BEFORE_COLLECTION_RUN, { collection, collectionUid });
await sendScriptEnvironmentUpdates({
scriptResult: collectionHooksCtx,
collection,
collectionUid,
updateCookies: true
});
} catch (error) {
console.error('Error executing beforeCollectionRun hook:', error);
}
}
try {
let folderRequests = [];
@@ -1216,6 +1399,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 +1471,77 @@ const registerNetworkIpc = (mainWindow) => {
continue;
}
// Get request tree path for hooks execution (hooks are merged in prepareRequest via mergeScripts)
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Hook execution options
const hookOptions = {
request,
envVars,
runtimeVariables,
collectionPath,
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName: collection?.name,
collection,
onConsoleLog: (type, args) => {
console[type](...args);
mainWindow.webContents.send('main:console-log', {
type,
args
});
},
requestUid,
itemUid: item.uid,
collectionUid,
runInBackground: false,
notifyScriptExecution: (notification) => {
notifyScriptExecution({
...notification,
basePayload: eventData
});
}
};
// Initialize hooks once per request iteration (reused for beforeRequest + afterResponse)
const hooksCtx = await initHooks(hookOptions);
try {
// Call beforeRequest hooks using initialized hooks context
const beforeRequestEventData = { request, collection, collectionUid };
const beforeRequestHooksResult = await triggerHookEvent(
hooksCtx,
HOOK_EVENTS.HTTP_BEFORE_REQUEST,
beforeRequestEventData,
hookOptions
);
// Check runner control from hooks
if (beforeRequestHooksResult?.nextRequestName !== undefined) {
nextRequestName = beforeRequestHooksResult.nextRequestName;
}
if (beforeRequestHooksResult?.stopExecution) {
stopRunnerExecution = true;
}
if (beforeRequestHooksResult?.skipRequest) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'runner-request-skipped',
error: 'Request has been skipped from beforeRequest hook',
responseReceived: {
status: 'skipped',
statusText: 'request skipped via beforeRequest hook',
data: null,
responseTime: 0,
headers: null
},
...eventData
});
currentRequestIndex++;
continue;
}
let preRequestScriptResult;
let preRequestError = null;
try {
@@ -1509,6 +1763,29 @@ const registerNetworkIpc = (mainWindow) => {
}
}
// Call afterResponse hooks using already-initialized hooks context
const afterResponseEventData = { request, response, collection, collectionUid };
const afterResponseHooksResult = await triggerHookEvent(
hooksCtx,
HOOK_EVENTS.HTTP_AFTER_RESPONSE,
afterResponseEventData,
hookOptions
);
// Dispose hooks context — done with all events for this request
if (hooksCtx?.hookManager) {
hooksCtx.hookManager.dispose();
}
// Check runner control from hooks
if (afterResponseHooksResult?.nextRequestName !== undefined) {
nextRequestName = afterResponseHooksResult.nextRequestName;
}
if (afterResponseHooksResult?.stopExecution) {
stopRunnerExecution = true;
}
let postResponseScriptResult;
let postResponseError = null;
try {
@@ -1626,27 +1903,20 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
runtimeVariables: testResults.runtimeVariables,
collectionUid
await sendScriptEnvironmentUpdates({
scriptResult: testResults,
collection,
collectionUid,
requestUid: undefined,
updateCookies: true
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
notifyScriptExecution({
channel: 'main:run-folder-event',
basePayload: eventData,
scriptType: 'test',
error: testError
});
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
}
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {
@@ -1689,6 +1959,23 @@ const registerNetworkIpc = (mainWindow) => {
}
}
// Call onAfterCollectionRun hook after all requests are done
if (collectionHooksCtx?.hookManager) {
try {
await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection, collectionUid });
await sendScriptEnvironmentUpdates({
scriptResult: collectionHooksCtx,
collection,
collectionUid,
updateCookies: true
});
} catch (hookError) {
console.error('Error executing afterCollectionRun hook:', hookError);
}
collectionHooksCtx.hookManager.dispose();
collectionHooksCtx = null;
}
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
@@ -1698,6 +1985,24 @@ const registerNetworkIpc = (mainWindow) => {
});
} catch (error) {
console.log('error', error);
// Call onAfterCollectionRun hook even on error
if (collectionHooksCtx?.hookManager) {
try {
await collectionHooksCtx.hookManager.call(HOOK_EVENTS.RUNNER_AFTER_COLLECTION_RUN, { collection, collectionUid });
await sendScriptEnvironmentUpdates({
scriptResult: collectionHooksCtx,
collection,
collectionUid,
updateCookies: true
});
} catch (hookError) {
console.error('Error executing afterCollectionRun hook:', hookError);
}
collectionHooksCtx.hookManager.dispose();
collectionHooksCtx = null;
}
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',

View File

@@ -155,10 +155,12 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
let collectionPreReqScript = get(collectionRoot, 'request.script.req', '');
let collectionPostResScript = get(collectionRoot, 'request.script.res', '');
let collectionTests = get(collectionRoot, 'request.tests', '');
let collectionHooks = get(collectionRoot, 'request.script.hooks', '');
let combinedPreReqScript = [];
let combinedPostResScript = [];
let combinedTests = [];
let combinedHooks = [];
for (let i of requestTreePath) {
if (i.type === 'folder') {
const folderRoot = i?.draft || i?.root;
@@ -176,6 +178,11 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
if (tests && tests?.trim?.() !== '') {
combinedTests.push(tests);
}
let hooks = get(folderRoot, 'request.script.hooks', '');
if (hooks && hooks.trim() !== '') {
combinedHooks.push(hooks);
}
}
}
@@ -225,6 +232,21 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
];
request.tests = compact(testScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL);
}
// Handle hooks - always merged sequentially: collection -> folders -> request
let requestHooks = request?.script?.hooks || '';
const hooksScripts = [
collectionHooks,
...combinedHooks,
requestHooks
];
// Ensure request.script exists
if (!request.script) {
request.script = {};
}
request.script.hooks = compact(hooksScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL);
};
const flattenItems = (items = []) => {

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,44 @@ 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) => {
return this.#hookManager.on('runner:beforeCollectionRun', handler);
},
onAfterCollectionRun: (handler) => {
return this.#hookManager.on('runner:afterCollectionRun', handler);
}
},
http: {
onBeforeRequest: (handler) => {
return this.#hookManager.on('http:beforeRequest', handler);
},
onAfterResponse: (handler) => {
return this.#hookManager.on('http:afterResponse', handler);
}
}
};
}
}
module.exports = Bru;

View File

@@ -0,0 +1,237 @@
/**
* 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.
*
* 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 });
*
* Error Handling:
* - By default, errors in one handler don't stop other handlers from running
* - Errors are logged to console for debugging
*
* @class
*/
class HookManager {
/**
* HookManager lifecycle states
* @readonly
* @enum {string}
*/
static State = Object.freeze({
ACTIVE: 'active',
DISPOSED: 'disposed'
});
/**
* Standard hook event names used throughout the application.
* @readonly
*/
static 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'
});
constructor() {
this.listeners = {};
this._state = HookManager.State.ACTIVE;
}
/**
* Get the current state of the HookManager
* @returns {string} Current state ('active' or 'disposed')
*/
get state() {
return this._state;
}
/**
* Check if the HookManager is disposed
* @returns {boolean} True if disposed
*/
get isDisposed() {
return this._state === HookManager.State.DISPOSED;
}
/**
* Dispose of all resources held by this HookManager
* Clears all handlers and marks the manager as disposed
* Should be called when the HookManager is no longer needed
*/
dispose() {
if (this._state === HookManager.State.DISPOSED) {
return;
}
this._state = HookManager.State.DISPOSED;
// Clear all listeners
this.clearAll();
}
/**
* Validate that the HookManager is in a valid state for the operation
* @private
* @param {string} operation - Name of the operation being performed
* @throws {Error} If HookManager is disposed
*/
_validateState(operation) {
if (this._state === HookManager.State.DISPOSED) {
throw new Error(`Cannot ${operation}: HookManager has been disposed`);
}
}
/**
* Call all registered handlers for the given pattern(s)
* Supports both sync and async handlers - all handlers are awaited
* Error Isolation: Errors in one handler don't stop other handlers from running.
* Errors are logged to console but don't affect execution flow.
*
* @param {string|string[]} pattern - Event pattern(s) to trigger
* @param {*} data - Data to pass to handlers
* @returns {Promise<void>}
*/
async call(pattern, data) {
// Validate state - but allow calls on disposed manager to fail gracefully
if (this._state === HookManager.State.DISPOSED) {
console.warn('HookManager.call() called on disposed instance');
return;
}
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 (!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
* @throws {Error} If HookManager is disposed
*/
on(pattern, handler) {
this._validateState('register 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());
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;
}
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 (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
*/
async function callHandler(handler, data, event) {
const handlerName = handler?.name || 'anonymous';
try {
const result = handler(data);
// If handler returns a Promise, await it
if (result && typeof result.then === 'function') {
await result;
}
} catch (error) {
// Log the error with context
console.error(
`[Hook Error] Event: '${event}', Handler: '${handlerName}'`,
error?.message || error
);
}
}
module.exports = HookManager;

View File

@@ -2,6 +2,9 @@ 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 BrunoResponse = require('./bruno-response');
const { runScriptInNodeVm } = require('./sandbox/node-vm');
module.exports = {
@@ -9,5 +12,8 @@ module.exports = {
TestRuntime,
VarsRuntime,
AssertRuntime,
HooksRuntime,
HookManager,
BrunoResponse,
runScriptInNodeVm
};

View File

@@ -0,0 +1,155 @@
const { runScriptInNodeVm } = require('../sandbox/node-vm');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
const HookManager = require('../hook-manager');
const { cleanJson } = require('../utils');
const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
/**
* HooksRuntime manages the execution of hook scripts in a sandboxed environment.
*
* Hooks are now merged into a single script using mergeScripts() in collection.js,
* following the same pattern as pre-request, post-response, and test scripts.
*
* Features:
* - Lazy VM creation: VMs are only created when hooks are present
* - Shared HookManager: Can reuse an existing HookManager for handler registration
*
* @class
*/
class HooksRuntime {
/**
* Creates a new HooksRuntime instance
* @param {object} [props] - Configuration options
* @param {string} [props.runtime='quickjs'] - Runtime to use ('quickjs' or 'nodevm')
*/
constructor(props) {
this.runtime = props?.runtime || 'quickjs';
}
/**
* Check if hooks content is empty/whitespace
* @private
* @param {string} content - Content to check
* @returns {boolean} True if empty
*/
_isEmptyContent(content) {
return !content || !content.trim();
}
/**
* Run hooks script to register event handlers
* @param {object} options - Configuration options
* @param {string} [options.hooksFile] - The merged hooks script content
* @param {object} options.request - The request object (used for variable extraction and BrunoRequest creation)
* @param {object} [options.response] - The response object (used for BrunoResponse creation, only for afterResponse hooks)
* @param {object} options.envVariables - Environment variables
* @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, and req/res wrapper objects
*/
async runHooks(options) {
const {
hooksFile,
request,
response,
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(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables, activeHookManager);
// Create BrunoRequest and BrunoResponse wrappers (similar to ScriptRuntime)
const req = request ? new BrunoRequest(request) : null;
const res = response ? new BrunoResponse(response) : null;
const context = {
bru,
req,
res
};
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;
}
// Build result from current state — shared across all return paths
const buildResult = () => ({
hookManager: activeHookManager,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution,
__bru: bru,
req,
res
});
// Lazy VM creation: If no hooks file, return early without creating a VM
if (this._isEmptyContent(hooksFile)) {
return buildResult();
}
// Execute hooks script
// Note: Hooks need the VM to persist so registered handlers can be called later
if (this.runtime === 'nodevm') {
await runScriptInNodeVm({
script: hooksFile,
context,
collectionPath,
scriptingConfig
});
} else {
// QuickJS: persist the VM so hook handlers can be called later during the collection run
await executeQuickJsVmAsync({
script: hooksFile,
context: context,
collectionPath
});
}
return buildResult();
}
}
module.exports = HooksRuntime;

View File

@@ -1,5 +1,7 @@
const { cleanJson, cleanCircularJson } = require('../../../utils');
const { marshallToVm } = require('../utils');
const { createBrunoRequestShim } = require('./bruno-request');
const { createBrunoResponseShim } = require('./bruno-response');
const addBruShimToContext = (vm, bru) => {
const bruObject = vm.newObject();
@@ -394,6 +396,232 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'cookies', bruCookiesObject);
bruCookiesObject.dispose();
// Add hooks shim if bru.hooks exists
if (bru.hooks) {
// 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 logging/debugging (not used for lookup)
* @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');
}
// Try to duplicate the handle to own a reference
let handlerHandle;
try {
handlerHandle = handler.dup ? handler.dup() : handler;
} catch (e) {
handlerHandle = handler;
}
// Create native handler that executes the stored handle
// Returns a Promise so HookManager can await async handlers
// Capture handlerHandle directly in closure - no lookup needed
const nativeHandler = (data) => {
if (!handlerHandle || !vm) {
return Promise.resolve();
}
// Return the Promise from executeHandler so HookManager awaits it
return executeHandler(handlerHandle, vm, data);
};
// Register with native hook system
const unhook = nativeHookRegister(nativeHandler);
// Create unhook function (just calls native unhook - no handle cleanup needed)
const unhookFn = vm.newFunction('unhook', () => {
unhook();
});
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();

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

@@ -0,0 +1,180 @@
/**
* Unit tests for HookManager
*/
const HookManager = require('../src/hook-manager');
describe('HookManager', () => {
let hookManager;
beforeEach(() => {
hookManager = new HookManager();
});
afterEach(() => {
if (hookManager && !hookManager.isDisposed) {
hookManager.dispose();
}
});
describe('constructor', () => {
it('should initialize with empty listeners', () => {
expect(hookManager.listeners).toEqual({});
});
it('should initialize in active state', () => {
expect(hookManager.state).toBe(HookManager.State.ACTIVE);
expect(hookManager.isDisposed).toBe(false);
});
});
describe('on()', () => {
it('should register a handler for a pattern', () => {
const handler = jest.fn();
hookManager.on('test:event', handler);
expect(hookManager.listeners['test:event']).toContain(handler);
});
it('should return an unhook function', () => {
const handler = jest.fn();
const unhook = hookManager.on('test:event', handler);
expect(typeof unhook).toBe('function');
});
it('should register handlers for multiple patterns', () => {
const handler = jest.fn();
hookManager.on(['event1', 'event2'], handler);
expect(hookManager.listeners['event1']).toContain(handler);
expect(hookManager.listeners['event2']).toContain(handler);
});
it('should throw if handler is not a function', () => {
expect(() => hookManager.on('test', 'not a function')).toThrow(TypeError);
});
it('should throw if pattern is not string or array', () => {
expect(() => hookManager.on(123, jest.fn())).toThrow(TypeError);
});
it('should throw if same handler is registered twice', () => {
const handler = jest.fn();
hookManager.on('test', handler);
expect(() => hookManager.on('test', handler)).toThrow();
});
it('should throw if HookManager is disposed', () => {
hookManager.dispose();
expect(() => hookManager.on('test', jest.fn())).toThrow(/disposed/);
});
});
describe('unhook', () => {
it('should remove handler when unhook is called', () => {
const handler = jest.fn();
const unhook = hookManager.on('test', handler);
unhook();
expect(hookManager.listeners['test']).not.toContain(handler);
});
it('should remove handler only for specified pattern', () => {
const handler1 = jest.fn();
const handler2 = jest.fn();
hookManager.on('event1', handler1);
hookManager.on('event2', handler2);
const unhook = hookManager.on('event3', handler1);
unhook('event3');
expect(hookManager.listeners['event3']).not.toContain(handler1);
// handler1 should still be registered for event1
expect(hookManager.listeners['event1']).toContain(handler1);
});
});
describe('call()', () => {
it('should call registered handler', async () => {
const handler = jest.fn();
hookManager.on('test', handler);
await hookManager.call('test', { value: 1 });
expect(handler).toHaveBeenCalledWith({ value: 1 });
});
it('should call async handlers', async () => {
const handler = jest.fn().mockResolvedValue(undefined);
hookManager.on('test', handler);
await hookManager.call('test', { value: 1 });
expect(handler).toHaveBeenCalled();
});
it('should call multiple handlers in order', async () => {
const results = [];
const handler1 = jest.fn(() => results.push(1));
const handler2 = jest.fn(() => results.push(2));
hookManager.on('test', handler1);
hookManager.on('test', handler2);
await hookManager.call('test', {});
expect(results).toEqual([1, 2]);
});
it('should handle errors without stopping execution', async () => {
const errorHandler = jest.fn(() => { throw new Error('Test error'); });
const normalHandler = jest.fn();
hookManager.on('test', errorHandler);
hookManager.on('test', normalHandler);
await hookManager.call('test', {});
expect(normalHandler).toHaveBeenCalled();
});
it('should warn when called on disposed instance', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
hookManager.dispose();
await hookManager.call('test', {});
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('disposed'));
consoleSpy.mockRestore();
});
});
describe('clear()', () => {
it('should clear handlers for a pattern', () => {
hookManager.on('test', jest.fn());
hookManager.clear('test');
expect(hookManager.listeners['test']).toBeUndefined();
});
it('should clear handlers for multiple patterns', () => {
hookManager.on('event1', jest.fn());
hookManager.on('event2', jest.fn());
hookManager.clear(['event1', 'event2']);
expect(hookManager.listeners['event1']).toBeUndefined();
expect(hookManager.listeners['event2']).toBeUndefined();
});
});
describe('clearAll()', () => {
it('should clear all handlers', () => {
hookManager.on('event1', jest.fn());
hookManager.on('event2', jest.fn());
hookManager.clearAll();
expect(hookManager.listeners).toEqual({});
});
});
describe('dispose()', () => {
it('should mark as disposed', () => {
hookManager.dispose();
expect(hookManager.isDisposed).toBe(true);
expect(hookManager.state).toBe(HookManager.State.DISPOSED);
});
it('should clear all listeners', () => {
hookManager.on('test', jest.fn());
hookManager.dispose();
expect(hookManager.listeners).toEqual({});
});
it('should be idempotent', () => {
hookManager.on('test', jest.fn());
hookManager.dispose();
hookManager.dispose();
expect(hookManager.isDisposed).toBe(true);
expect(hookManager.listeners).toEqual({});
});
});
});

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,28 @@
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("request should have access to setup vars", function() {
const setupComplete = bru.getVar('setup-complete');
expect(setupComplete).to.equal('true');
});
}

View File

@@ -0,0 +1,198 @@
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());
console.log('[onBeforeCollectionRun] Setup complete - token and counters initialized');
});
// Test: bru.runRequest in onBeforeCollectionRun hook
bru.hooks.runner.onBeforeCollectionRun(async() => {
console.log('[onBeforeCollectionRun] Testing bru.runRequest in collection-level hook...');
try {
// Call another request using bru.runRequest
const response = await bru.runRequest('hooks/bru-run-request/helper-request');
// Store the response for verification in tests
if (response) {
bru.setVar('collection-run-request-response', response.data);
bru.setVar('collection-run-request-status', response.status);
bru.setVar('collection-run-request-tested', 'true');
console.log('[onBeforeCollectionRun] bru.runRequest succeeded:', response.data);
} else {
bru.setVar('collection-run-request-error', 'No response received');
console.log('[onBeforeCollectionRun] bru.runRequest failed - no response');
}
} catch (error) {
bru.setVar('collection-run-request-error', error.message || String(error));
console.log('[onBeforeCollectionRun] bru.runRequest error:', error);
}
console.log('[onBeforeCollectionRun] bru.runRequest test completed');
});
// ============================================
// 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-count');
bru.deleteVar('success-count');
bru.deleteVar('final-stats');
bru.deleteVar('cleanup-performed');
bru.deleteVar('collection-run-request-response');
bru.deleteVar('collection-run-request-status');
bru.deleteVar('collection-run-request-tested');
bru.deleteVar('collection-run-request-error');
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,49 @@
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() {
const before = bru.getVar('timeout-before');
expect(before).to.not.equal(10000);
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,17 @@
meta {
name: helper-request
type: http
seq: 1
}
get {
url: {{host}}/ping
body: none
auth: none
}
tests {
test("helper request returns pong", function() {
expect(res.getBody()).to.equal('pong');
});
}

View File

@@ -0,0 +1,49 @@
meta {
name: run-request-in-after-hook
type: http
seq: 3
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onAfterResponse(async ({ res }) => {
console.log('[afterResponse] Testing bru.runRequest API');
console.log('[afterResponse] Main request status:', res.getStatus());
// Call another request using bru.runRequest
const response = await bru.runRequest('hooks/bru-run-request/helper-request');
// Store the response body for verification in tests
if (response) {
bru.setVar('after-helper-response-body', response.data);
bru.setVar('after-helper-response-status', response.status);
console.log('[afterResponse] Helper request response:', response.data);
} else {
bru.setVar('after-helper-response-error', 'No response received');
console.log('[afterResponse] Helper request failed - no response');
}
console.log('[afterResponse] bru.runRequest test completed');
});
}
tests {
test("bru.runRequest in afterResponse hook should return helper response", function() {
const helperBody = bru.getVar('after-helper-response-body');
expect(helperBody).to.equal('pong');
});
test("bru.runRequest in afterResponse hook should return status 200", function() {
const helperStatus = bru.getVar('after-helper-response-status');
expect(helperStatus).to.equal(200);
});
test("main request should succeed", function() {
expect(res.getBody()).to.equal('pong');
});
}

View File

@@ -0,0 +1,48 @@
meta {
name: run-request-in-before-hook
type: http
seq: 2
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(async ({ req }) => {
console.log('[beforeRequest] Testing bru.runRequest API');
// Call another request using bru.runRequest
const response = await bru.runRequest('hooks/bru-run-request/helper-request');
// Store the response body for verification in tests
if (response) {
bru.setVar('helper-response-body', response.data);
bru.setVar('helper-response-status', response.status);
console.log('[beforeRequest] Helper request response:', response.data);
} else {
bru.setVar('helper-response-error', 'No response received');
console.log('[beforeRequest] Helper request failed - no response');
}
console.log('[beforeRequest] bru.runRequest test completed');
});
}
tests {
test("bru.runRequest in beforeRequest hook should return helper response", function() {
const helperBody = bru.getVar('helper-response-body');
expect(helperBody).to.equal('pong');
});
test("bru.runRequest in beforeRequest hook should return status 200", function() {
const helperStatus = bru.getVar('helper-response-status');
expect(helperStatus).to.equal(200);
});
test("main request should still succeed", function() {
expect(res.getBody()).to.equal('pong');
});
}

View File

@@ -0,0 +1,28 @@
meta {
name: verify-collection-run-request
type: http
seq: 4
}
get {
url: {{host}}/ping
body: none
auth: none
}
tests {
test("collection-level bru.runRequest should have set response body", function() {
const responseBody = bru.getVar('collection-run-request-response');
expect(responseBody).to.equal('pong');
});
test("collection-level bru.runRequest should have set response status", function() {
const responseStatus = bru.getVar('collection-run-request-status');
expect(responseStatus).to.equal(200);
});
test("collection-level bru.runRequest flag should be set", function() {
const flag = bru.getVar('collection-run-request-tested');
expect(flag).to.equal('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,45 @@
meta {
name: legacy-set-next-request-first
type: http
seq: 300
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] Testing legacy bru.setNextRequest() API');
// Clear any previous markers
bru.deleteVar('legacy-set-next-skipped-ran');
// Mark that this request started
bru.setVar('legacy-set-next-first-ran', 'true');
console.log('[beforeRequest] Legacy first request starting');
});
bru.hooks.http.onAfterResponse(({ res }) => {
console.log('[afterResponse] Using legacy bru.setNextRequest() to jump to target');
// Use the legacy API to jump to the target request
bru.setNextRequest('legacy-set-next-request-target');
console.log('[afterResponse] Called bru.setNextRequest("legacy-set-next-request-target")');
});
}
tests {
test("first request should execute successfully", function() {
expect(res.getStatus()).to.equal(200);
});
test("first request marker should be set", function() {
const ran = bru.getVar('legacy-set-next-first-ran');
expect(ran).to.equal('true');
});
}

View File

@@ -0,0 +1,30 @@
meta {
name: legacy-set-next-request-skipped
type: http
seq: 301
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] ERROR: This request should have been skipped by legacy setNextRequest!');
// If this request runs, set an error marker
bru.setVar('legacy-set-next-skipped-ran', 'true');
console.log('[beforeRequest] Setting error marker - legacy setNextRequest jump failed');
});
}
tests {
test("this request should be skipped due to legacy setNextRequest jump", function() {
// If we get here, the request ran when it should have been skipped
const ran = bru.getVar('legacy-set-next-skipped-ran');
expect(ran).to.be.undefined;
});
}

View File

@@ -0,0 +1,43 @@
meta {
name: legacy-set-next-request-target
type: http
seq: 302
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] Target request reached via legacy bru.setNextRequest() jump');
// Mark that target was reached
bru.setVar('legacy-set-next-target-reached', 'true');
console.log('[beforeRequest] Legacy target request starting');
});
}
tests {
test("target request should execute successfully", function() {
expect(res.getStatus()).to.equal(200);
});
test("target should have been reached via legacy API", function() {
const reached = bru.getVar('legacy-set-next-target-reached');
expect(reached).to.equal('true');
});
test("first request should have run before jump", function() {
const firstRan = bru.getVar('legacy-set-next-first-ran');
expect(firstRan).to.equal('true');
});
test("skipped request should NOT have run", function() {
const skippedRan = bru.getVar('legacy-set-next-skipped-ran');
expect(skippedRan).to.be.undefined;
});
}

View File

@@ -0,0 +1,30 @@
meta {
name: set-next-null-should-not-run
type: http
seq: 901
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] ERROR: This request should NOT have run after setNextRequest(null)!');
// If this request runs, set an error marker
bru.setVar('set-next-null-error', 'Request ran when setNextRequest(null) should have stopped the runner');
console.log('[beforeRequest] Setting error marker - setNextRequest(null) failed');
});
}
tests {
test("this request should not run - setNextRequest(null) should have stopped the runner", function() {
// If we get here, the request ran when it should have been stopped
const error = bru.getVar('set-next-null-error');
expect(error).to.be.undefined;
});
}

View File

@@ -0,0 +1,45 @@
meta {
name: set-next-null-trigger
type: http
seq: 900
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] Testing bru.runner.setNextRequest(null) API');
// Clear any previous error markers
bru.deleteVar('set-next-null-error');
// Mark that this request started
bru.setVar('set-next-null-trigger-ran', 'true');
console.log('[beforeRequest] setNextRequest(null) trigger starting');
});
bru.hooks.http.onAfterResponse(({ res }) => {
console.log('[afterResponse] Setting next request to null to stop runner gracefully');
// Setting nextRequest to null should stop the runner gracefully
bru.runner.setNextRequest(null);
console.log('[afterResponse] Called bru.runner.setNextRequest(null) - runner should stop');
});
}
tests {
test("trigger request should execute successfully", function() {
expect(res.getStatus()).to.equal(200);
});
test("trigger marker should be set", function() {
const ran = bru.getVar('set-next-null-trigger-ran');
expect(ran).to.equal('true');
});
}

View File

@@ -0,0 +1,45 @@
meta {
name: set-next-request-first
type: http
seq: 200
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] Testing bru.runner.setNextRequest() API');
// Clear any previous markers
bru.deleteVar('set-next-request-skipped-ran');
// Mark that this request started
bru.setVar('set-next-request-first-ran', 'true');
console.log('[beforeRequest] First request starting');
});
bru.hooks.http.onAfterResponse(({ res }) => {
console.log('[afterResponse] Setting next request to jump to target');
// Jump to the target request, skipping the intermediate request
bru.runner.setNextRequest('set-next-request-target');
console.log('[afterResponse] Called bru.runner.setNextRequest("set-next-request-target")');
});
}
tests {
test("first request should execute successfully", function() {
expect(res.getStatus()).to.equal(200);
});
test("first request marker should be set", function() {
const ran = bru.getVar('set-next-request-first-ran');
expect(ran).to.equal('true');
});
}

View File

@@ -0,0 +1,30 @@
meta {
name: set-next-request-skipped
type: http
seq: 201
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] ERROR: This request should have been skipped by setNextRequest!');
// If this request runs, set an error marker
bru.setVar('set-next-request-skipped-ran', 'true');
console.log('[beforeRequest] Setting error marker - setNextRequest jump failed');
});
}
tests {
test("this request should be skipped due to setNextRequest jump", function() {
// If we get here, the request ran when it should have been skipped
const ran = bru.getVar('set-next-request-skipped-ran');
expect(ran).to.be.undefined;
});
}

View File

@@ -0,0 +1,43 @@
meta {
name: set-next-request-target
type: http
seq: 202
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] Target request reached via setNextRequest jump');
// Mark that target was reached
bru.setVar('set-next-request-target-reached', 'true');
console.log('[beforeRequest] Target request starting');
});
}
tests {
test("target request should execute successfully", function() {
expect(res.getStatus()).to.equal(200);
});
test("target should have been reached", function() {
const reached = bru.getVar('set-next-request-target-reached');
expect(reached).to.equal('true');
});
test("first request should have run before jump", function() {
const firstRan = bru.getVar('set-next-request-first-ran');
expect(firstRan).to.equal('true');
});
test("skipped request should NOT have run", function() {
const skippedRan = bru.getVar('set-next-request-skipped-ran');
expect(skippedRan).to.be.undefined;
});
}

View File

@@ -0,0 +1,32 @@
meta {
name: skip-request-in-before-hook
type: http
seq: 100
}
get {
url: {{host}}/ping
body: none
auth: none
}
script:hooks {
bru.hooks.http.onBeforeRequest(({ req }) => {
console.log('[beforeRequest] Testing bru.runner.skipRequest() API');
// Set a marker to confirm the hook executed
bru.setVar('skip-request-hook-executed', 'true');
// Skip this request - the main request should not execute
bru.runner.skipRequest();
console.log('[beforeRequest] Called bru.runner.skipRequest() - request should be skipped');
});
}
tests {
test("hook should have executed before skip", function() {
const hookExecuted = bru.getVar('skip-request-hook-executed');
expect(hookExecuted).to.equal('true');
});
}

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

@@ -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: 51,
passed: 50,
failed: 0,
skipped: 1
});
});
});
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: 51,
passed: 50,
failed: 0,
skipped: 1
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More