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