feat: enhance hook control flow and response handling

This commit is contained in:
sanish-bruno
2026-01-23 00:13:29 +05:30
parent 9b40dd4551
commit 10408a344a
14 changed files with 530 additions and 84 deletions

View File

@@ -38,6 +38,71 @@ const getCACertHostRegex = (domain) => {
return '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
};
/**
* Apply runner control signals from a script/hook result
* @param {object} result - Result from script/hook execution
* @param {object} state - Current runner state (modified in place)
* @param {string|undefined} state.nextRequestName - Current next request name
* @param {boolean} state.shouldStopRunnerExecution - Current stop flag
* @returns {object} Updated state with nextRequestName and shouldStopRunnerExecution
*/
const applyRunnerControlFromResult = (result, state) => {
if (result?.nextRequestName !== undefined) {
state.nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
state.shouldStopRunnerExecution = true;
}
return state;
};
/**
* Create a standardized skipped response object
* @param {object} options - Options for creating the response
* @param {string} options.filename - The relative file pathname
* @param {object} options.request - The request object
* @param {string} options.statusText - The reason for skipping
* @param {array} [options.preRequestTestResults] - Pre-request test results
* @param {array} [options.postResponseTestResults] - Post-response test results
* @param {string} [options.nextRequestName] - Next request name if set
* @param {boolean} [options.shouldStopRunnerExecution] - Stop execution flag
* @returns {object} Standardized skipped response object
*/
const createSkippedResponse = ({
filename,
request,
statusText,
preRequestTestResults = [],
postResponseTestResults = [],
nextRequestName,
shouldStopRunnerExecution = false
}) => {
return {
test: { filename },
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText,
data: null,
responseTime: 0
},
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults,
postResponseTestResults,
nextRequestName,
shouldStopRunnerExecution
};
};
/**
* Extract prompt variables from a request
* Tries to respect the hierarchy of the variables and avoid unnecessary prompts as much as possible
@@ -136,31 +201,12 @@ const runSingleRequest = async function (
if (promptVars.length > 0) {
const errorMsg = `Prompt variables detected in request. CLI execution is not supported for requests with prompt variables. \nPrompts: ${promptVars.join(', ')}`;
console.log(chalk.yellow(stripExtension(relativeItemPathname) + ' Skipped:') + chalk.dim(` (${errorMsg})`));
return {
test: {
filename: relativeItemPathname
},
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText: errorMsg,
data: null,
responseTime: 0
},
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults: [],
postResponseTestResults: [],
return createSkippedResponse({
filename: relativeItemPathname,
request,
statusText: errorMsg,
shouldStopRunnerExecution
};
});
}
request.__bruno__executionMode = 'cli';
@@ -194,12 +240,28 @@ const runSingleRequest = async function (
// Hooks are called in registration order: collection -> folder(s) -> request
const beforeRequestEventData = { request, req: new BrunoRequest(request), collection };
await executeAllHooksConsolidated(
const beforeRequestHooksResult = await executeAllHooksConsolidated(
{ collectionHooks, folderHooks, requestHooks },
HOOK_EVENTS.HTTP_BEFORE_REQUEST,
beforeRequestEventData
);
// Check runner control from hooks
const runnerState = { nextRequestName, shouldStopRunnerExecution };
applyRunnerControlFromResult(beforeRequestHooksResult, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
if (beforeRequestHooksResult?.skipRequest) {
return createSkippedResponse({
filename: relativeItemPathname,
request,
statusText: 'request skipped via beforeRequest hook',
nextRequestName,
shouldStopRunnerExecution
});
}
// run pre request script
const requestScriptFile = get(request, 'script.req');
if (requestScriptFile?.length) {
@@ -216,40 +278,18 @@ const runSingleRequest = async function (
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
applyRunnerControlFromResult(result, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
if (result?.skipRequest) {
return {
test: {
filename: relativeItemPathname
},
request: {
method: request.method,
url: request.url,
headers: request.headers,
data: request.data
},
response: {
status: 'skipped',
statusText: 'request skipped via pre-request script',
data: null,
responseTime: 0
},
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
return createSkippedResponse({
filename: relativeItemPathname,
request,
statusText: 'request skipped via pre-request script',
preRequestTestResults: result?.results || [],
postResponseTestResults: [],
shouldStopRunnerExecution
};
});
}
preRequestTestResults = result?.results || [];
@@ -685,12 +725,17 @@ const runSingleRequest = async function (
collection
};
await executeAllHooksConsolidated(
const afterResponseHooksResult = await executeAllHooksConsolidated(
{ collectionHooks, folderHooks, requestHooks },
HOOK_EVENTS.HTTP_AFTER_RESPONSE,
afterResponseEventData
);
// Check runner control from hooks
applyRunnerControlFromResult(afterResponseHooksResult, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
// run post-response vars
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
@@ -724,13 +769,9 @@ const runSingleRequest = async function (
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
applyRunnerControlFromResult(result, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
postResponseTestResults = result?.results || [];
logResults(postResponseTestResults, 'Post-Response Tests');
@@ -774,13 +815,9 @@ const runSingleRequest = async function (
);
testResults = get(result, 'results', []);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
}
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
applyRunnerControlFromResult(result, runnerState);
nextRequestName = runnerState.nextRequestName;
shouldStopRunnerExecution = runnerState.shouldStopRunnerExecution;
logResults(testResults, 'Tests');
} catch (error) {

View File

@@ -1478,13 +1478,37 @@ const registerNetworkIpc = (mainWindow) => {
// Call beforeRequest hooks using consolidated approach when multiple levels have hooks
const beforeRequestEventData = { request, req: new BrunoRequest(request), collection, collectionUid };
await executeAllHooksConsolidated(
const beforeRequestHooksResult = await executeAllHooksConsolidated(
{ collectionHooks, folderHooks, requestHooks },
HOOK_EVENTS.HTTP_BEFORE_REQUEST,
beforeRequestEventData,
hookOptions
);
// Check runner control from hooks
if (beforeRequestHooksResult?.nextRequestName !== undefined) {
nextRequestName = beforeRequestHooksResult.nextRequestName;
}
if (beforeRequestHooksResult?.stopExecution) {
stopRunnerExecution = true;
}
if (beforeRequestHooksResult?.skipRequest) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'runner-request-skipped',
error: 'Request has been skipped from beforeRequest hook',
responseReceived: {
status: 'skipped',
statusText: 'request skipped via beforeRequest hook',
data: null,
responseTime: 0,
headers: null
},
...eventData
});
currentRequestIndex++;
continue;
}
let preRequestScriptResult;
let preRequestError = null;
try {
@@ -1716,13 +1740,21 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid
};
await executeAllHooksConsolidated(
const afterResponseHooksResult = await executeAllHooksConsolidated(
{ collectionHooks, folderHooks, requestHooks },
HOOK_EVENTS.HTTP_AFTER_RESPONSE,
afterResponseEventData,
hookOptions
);
// Check runner control from hooks
if (afterResponseHooksResult?.nextRequestName !== undefined) {
nextRequestName = afterResponseHooksResult.nextRequestName;
}
if (afterResponseHooksResult?.stopExecution) {
stopRunnerExecution = true;
}
let postResponseScriptResult;
let postResponseError = null;
try {

View File

@@ -157,6 +157,15 @@ const executeConsolidatedHooks = async (extractedHooks, hookEvent, eventData, op
if (result?.hookManager) {
await result.hookManager.call(hookEvent, eventData);
// IMPORTANT: Re-capture runner control values AFTER hooks have been called
// The hooks may have called bru.runner.setNextRequest(), bru.runner.skipRequest(), etc.
// These values are stored on the bru instance which is returned in result.__bru
if (result.__bru) {
result.nextRequestName = result.__bru.nextRequest;
result.skipRequest = result.__bru.skipRequest;
result.stopExecution = result.__bru.stopExecution;
}
}
return result;

View File

@@ -153,7 +153,11 @@ class HooksRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution,
__bru: bru
};
}
@@ -176,7 +180,11 @@ class HooksRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution,
__bru: bru
};
}
@@ -199,7 +207,11 @@ class HooksRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution,
__bru: bru
};
}
@@ -257,7 +269,11 @@ class HooksRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution,
__bru: bru
};
}
@@ -328,7 +344,11 @@ class HooksRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution,
__bru: bru
};
}
@@ -350,7 +370,12 @@ class HooksRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
persistentEnvVariables: bru.persistentEnvVariables,
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables)
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution,
// Include bru reference so callers can read updated values after hook execution
__bru: bru
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,10 +10,10 @@ test.describe.serial('Hooks feature', () => {
await runCollection(page, 'hooks-comprehensive-tests');
await validateRunnerResults(page, {
totalRequests: 45,
passed: 45,
totalRequests: 51,
passed: 50,
failed: 0,
skipped: 0
skipped: 1
});
});
});
@@ -26,10 +26,10 @@ test.describe.serial('Hooks feature', () => {
await runCollection(page, 'hooks-comprehensive-tests');
await validateRunnerResults(page, {
totalRequests: 45,
passed: 45,
totalRequests: 51,
passed: 50,
failed: 0,
skipped: 0
skipped: 1
});
});
});