From 0c2c00e86afc89651347952047bfb1d6d6809408 Mon Sep 17 00:00:00 2001 From: Vedanta Krishna Date: Mon, 16 Oct 2023 09:41:19 +0530 Subject: [PATCH] feat: add ability to skip request execution from script --- lib/sandbox/console.js | 5 +- lib/sandbox/cookie-store.js | 5 +- lib/sandbox/execute.js | 61 +++++++++---- lib/sandbox/execution.js | 9 ++ lib/sandbox/ping.js | 2 +- lib/sandbox/pmapi.js | 26 +++++- test/unit/sandbox-libraries/pm.test.js | 115 +++++++++++++++++++++++++ types/sandbox/prerequest.d.ts | 50 ++++++++++- types/sandbox/test.d.ts | 50 ++++++++++- 9 files changed, 293 insertions(+), 30 deletions(-) diff --git a/lib/sandbox/console.js b/lib/sandbox/console.js index f7c8a561..ce7a14f9 100644 --- a/lib/sandbox/console.js +++ b/lib/sandbox/console.js @@ -45,7 +45,7 @@ function replacer (key, value) { return value; } -function PostmanConsole (emitter, cursor, originalConsole) { +function PostmanConsole (emitter, cursor, originalConsole, execution) { const dispatch = function (level) { // create a dispatch function that emits events const args = arrayProtoSlice.call(arguments, 1); @@ -54,7 +54,8 @@ function PostmanConsole (emitter, cursor, originalConsole) { originalConsole[level].apply(originalConsole, args); } - emitter.dispatch(CONSOLE_EVENT, cursor, level, teleportJS.stringify(args, replacer)); + + emitter.dispatch(execution, CONSOLE_EVENT, cursor, level, teleportJS.stringify(args, replacer)); }; // setup variants of the logger based on log levels diff --git a/lib/sandbox/cookie-store.js b/lib/sandbox/cookie-store.js index e45281d1..7c0c2b63 100644 --- a/lib/sandbox/cookie-store.js +++ b/lib/sandbox/cookie-store.js @@ -14,12 +14,13 @@ const _ = require('lodash'), arrayProtoSlice = Array.prototype.slice; class PostmanCookieStore extends Store { - constructor (id, emitter, timers) { + constructor (id, emitter, timers, execution) { super(); this.id = id; // execution identifier this.emitter = emitter; this.timers = timers; + this.execution = execution; } } @@ -77,7 +78,7 @@ STORE_METHODS.forEach(function (method) { // Refer: https://github.com/postmanlabs/postman-app-support/issues/11064 setTimeout(() => { // finally, dispatch event over the bridge - this.emitter.dispatch(eventName, eventId, EVENT_STORE_ACTION, method, args); + this.emitter.dispatch(this.execution, eventName, eventId, EVENT_STORE_ACTION, method, args); }); }; }); diff --git a/lib/sandbox/execute.js b/lib/sandbox/execute.js index e2321ff5..d53f9374 100644 --- a/lib/sandbox/execute.js +++ b/lib/sandbox/execute.js @@ -26,10 +26,23 @@ module.exports = function (bridge, glob) { // @note we use a common scope for all executions. this causes issues when scripts are run inside the sandbox // in parallel, but we still use this way for the legacy "persistent" behaviour needed in environment const scope = Scope.create({ - eval: true, - ignore: ['require'], - block: ['bridge'] - }); + eval: true, + ignore: ['require'], + block: ['bridge'] + }), + originalBridgeDispatch = bridge.dispatch; + + bridge.dispatch = function (execution, ...args) { + // What is the purpose of overriding the dispatch method here? + // When the user invokes pm.execution.skipRequest(), our goal is to halt the current request's execution. + // Since we lack a foolproof method to completely halt the script's execution, our approach is to + // cease sending events to the bridge, creating the appearance that the script ahead never ran. + if (execution && execution.shouldSkipExecution) { + return; + } + + return originalBridgeDispatch.call(bridge, ...args); + }; // For caching required information provided during // initialization which will be used during execution @@ -49,7 +62,7 @@ module.exports = function (bridge, glob) { if (!template) { chai.use(require('chai-postman')(sdk, _, Ajv)); - return bridge.dispatch('initialize'); + return bridge.dispatch(null, 'initialize'); } const _module = { exports: {} }, @@ -66,7 +79,7 @@ module.exports = function (bridge, glob) { scope.exec(template, (err) => { if (err) { - return bridge.dispatch('initialize', err); + return bridge.dispatch(null, 'initialize', err); } const { chaiPlugin, initializeExecution: setupExecution } = (_module && _module.exports) || {}; @@ -79,7 +92,7 @@ module.exports = function (bridge, glob) { initializeExecution = setupExecution; } - bridge.dispatch('initialize'); + bridge.dispatch(null, 'initialize'); }); }); @@ -97,7 +110,8 @@ module.exports = function (bridge, glob) { */ bridge.on('execute', function (id, event, context, options) { if (!(id && _.isString(id))) { - return bridge.dispatch('error', new Error('sandbox: execution identifier parameter(s) missing')); + return bridge.dispatch(null, 'error', + new Error('sandbox: execution identifier parameter(s) missing')); } !options && (options = {}); @@ -136,8 +150,8 @@ module.exports = function (bridge, glob) { // For compatibility, dispatch the single assertion as an array. !Array.isArray(assertions) && (assertions = [assertions]); - bridge.dispatch(assertionEventName, options.cursor, assertions); - bridge.dispatch(EXECUTION_ASSERTION_EVENT, options.cursor, assertions); + bridge.dispatch(execution, assertionEventName, options.cursor, assertions); + bridge.dispatch(execution, EXECUTION_ASSERTION_EVENT, options.cursor, assertions); }; let waiting, @@ -148,8 +162,8 @@ module.exports = function (bridge, glob) { // create the controlled timers timers = new PostmanTimers(null, function (err) { if (err) { // propagate the error out of sandbox - bridge.dispatch(errorEventName, options.cursor, err); - bridge.dispatch(EXECUTION_ERROR_EVENT, options.cursor, err); + bridge.dispatch(execution, errorEventName, options.cursor, err); + bridge.dispatch(execution, EXECUTION_ERROR_EVENT, options.cursor, err); } }, function () { execution.return.async = true; @@ -169,8 +183,8 @@ module.exports = function (bridge, glob) { bridge.off(cookiesEventName); if (err) { // fire extra execution error event - bridge.dispatch(errorEventName, options.cursor, err); - bridge.dispatch(EXECUTION_ERROR_EVENT, options.cursor, err); + bridge.dispatch(null, errorEventName, options.cursor, err); + bridge.dispatch(null, EXECUTION_ERROR_EVENT, options.cursor, err); } // @note delete response from the execution object to avoid dispatching @@ -178,7 +192,13 @@ module.exports = function (bridge, glob) { execution.response && (delete execution.response); // fire the execution completion event - (dnd !== true) && bridge.dispatch(executionEventName, err || null, execution); + + // Note: We are sending null to dispatchEvent function + // because this event should be fired even if shouldSkipExecution is true as this event is + // used to complete the execution in the sandbox. All other events are fired only if + // shouldSkipExecution is false. + (dnd !== true) && bridge.dispatch(null, + executionEventName, err || null, execution); }); // if a timeout is set, we must ensure that all pending timers are cleared and an execution timeout event is @@ -207,14 +227,19 @@ module.exports = function (bridge, glob) { executeContext(scope, code, execution, // if a console is sent, we use it. otherwise this also prevents erroneous referencing to any console // inside this closure. - (new PostmanConsole(bridge, options.cursor, options.debug && glob.console)), + (new PostmanConsole(bridge, options.cursor, options.debug && glob.console, execution)), timers, ( new PostmanAPI(execution, function (request, callback) { var eventId = timers.setEvent(callback); - bridge.dispatch(executionRequestEventName, options.cursor, id, eventId, request); - }, dispatchAssertions, new PostmanCookieStore(id, bridge, timers), { + bridge.dispatch(execution, executionRequestEventName, options.cursor, id, eventId, request); + }, + /* onSkipRequest = */ () => { + execution.shouldSkipExecution = true; + timers.terminate(null); + }, + dispatchAssertions, new PostmanCookieStore(id, bridge, timers, execution), { disabledAPIs: initializationOptions.disabledAPIs }) ), diff --git a/lib/sandbox/execution.js b/lib/sandbox/execution.js index ab521ac9..08d7344a 100644 --- a/lib/sandbox/execution.js +++ b/lib/sandbox/execution.js @@ -27,6 +27,15 @@ class Execution { this.id = id; this.target = event.listen || PROPERTY.SCRIPT; this.legacy = options.legacy || {}; + + /** + * This property is set to true if user has called pm.execution.skipRequest() in the script. + * This is used to stop the execution of the current request. + * We stop sending events to the bridge if this is set to true. + * + * @type {Boolean} + */ + this.shouldSkipExecution = false; this.cursor = _.isObject(options.cursor) ? options.cursor : {}; this.data = _.get(context, PROPERTY.DATA, {}); diff --git a/lib/sandbox/ping.js b/lib/sandbox/ping.js index 7b4bccbf..be225dee 100644 --- a/lib/sandbox/ping.js +++ b/lib/sandbox/ping.js @@ -1,7 +1,7 @@ module.exports = { listener (pong) { return function (payload) { - this.dispatch(pong, payload); + this.dispatch(null, pong, payload); }; } }; diff --git a/lib/sandbox/pmapi.js b/lib/sandbox/pmapi.js index 6e860f37..d9a0e69c 100644 --- a/lib/sandbox/pmapi.js +++ b/lib/sandbox/pmapi.js @@ -44,12 +44,13 @@ const _ = require('lodash'), * * @param {Execution} execution - * @param {Function} onRequest - + * @param {Function} onSkipRequest - callback to execute when pm.execution.skipRequest() called * @param {Function} onAssertion - * @param {Object} cookieStore - * @param {Object} [options] - * @param {Array.} [options.disabledAPIs] - */ -function Postman (execution, onRequest, onAssertion, cookieStore, options = {}) { +function Postman (execution, onRequest, onSkipRequest, onAssertion, cookieStore, options = {}) { // @todo - ensure runtime passes data in a scope format let iterationData = new VariableScope(); @@ -253,6 +254,29 @@ function Postman (execution, onRequest, onAssertion, cookieStore, options = {}) } }, options.disabledAPIs); + _assignDefinedReadonly(this, /** @lends Postman.prototype */ { + /** + * Exposes handlers to control execution state + * + * @interface Execution + */ + + /** + * + * @type {Execution} + */ + execution: _assignDefinedReadonly({}, /** @lends Execution */ { + /** + * Stops the execution of current request. No line after this will be executed and + * if invoked from a pre-request script, the request will not be sent. + * + * @type {Function} skipRequest + * @instance + */ + skipRequest: onSkipRequest + }) + }); + // extend pm api with test runner abilities setupTestRunner(this, onAssertion); diff --git a/test/unit/sandbox-libraries/pm.test.js b/test/unit/sandbox-libraries/pm.test.js index eeea411e..4fca1d35 100644 --- a/test/unit/sandbox-libraries/pm.test.js +++ b/test/unit/sandbox-libraries/pm.test.js @@ -278,6 +278,121 @@ describe('sandbox library - pm api', function () { }, done); }); + it('should not execute any line after pm.execution.skipRequest in pre-request script', function (done) { + context.on('console', function (level, ...args) { + expect(args[1]).to.equal('pre-request log 1'); + }); + context.execute(` + preRequestScript: { + console.log('pre-request log 1'); + pm.execution.skipRequest(); + console.log('pre-request log 2'); + } + `, { + timeout: 200, + context: { + request: 'https://postman-echo.com/get?foo=bar' + } + }, function (err, execution) { + if (err) { return done(err); } + expect(execution).to.include({ shouldSkipExecution: true }); + + return done(); + }); + }); + + it(`should not execute any line after pm.execution.skipRequest in pre-request script, + even if the pm.execution.skipRequest invoked inside a try catch block`, function (done) { + context.on('console', function (level, ...args) { + expect(args[1]).to.equal('pre-request log 1'); + }); + context.execute(` + preRequestScript: { + console.log('pre-request log 1'); + try { + pm.execution.skipRequest(); + } catch (err) { + // ignore + } + console.log('pre-request log 2'); + } + `, { + timeout: 200, + context: { + request: 'https://postman-echo.com/get?foo=bar' + } + }, function (err, execution) { + if (err) { return done(err); } + expect(execution).to.include({ shouldSkipExecution: true }); + + return done(); + }); + }); + + it(`should not execute any line after pm.execution.skipRequest in pre-request script, + even if the pm.execution.skipRequest invoked inside an async function`, function (done) { + context.on('console', function (level, ...args) { + expect(args[1]).to.equal('pre-request log 1'); + }); + context.execute(` + preRequestScript: { + console.log('pre-request log 1'); + async function myAsyncFunction() { + pm.execution.skipRequest(); + } + + myAsyncFunction(); + console.log('pre-request log 2'); + } + `, { + timeout: 200, + context: { + request: 'https://postman-echo.com/get?foo=bar' + } + }, function (err, execution) { + if (err) { return done(err); } + expect(execution).to.include({ shouldSkipExecution: true }); + + return done(); + }); + }); + + it('should not reflect any variable change line after pm.execution.skipRequest in pre-request script', + function (done) { + context.on('console', function (level, ...args) { + expect(args[1]).to.equal('pre-request log 1'); + }); + context.execute(` + preRequestScript: { + async function myFun () { + console.log('pre-request log 1'); + + pm.variables.set('foo', 'bar'); + pm.execution.skipRequest(); + new Promise((res) => setTimeout(res, 100)) + pm.variables.set('foo', 'nobar'); + console.log('pre-request log 2'); + } + + myFun(); + + } + `, { + timeout: 200, + context: { + request: 'https://postman-echo.com/get?foo=bar' + } + }, function (err, execution) { + if (err) { return done(err); } + expect(execution).to.include({ shouldSkipExecution: true }); + expect(execution).to.deep.nested.include({ '_variables.values': [ + { value: 'bar', key: 'foo', type: 'any' } + ] }); + + return done(); + }); + }); + it('when serialized should not have assertion helpers added by sandbox', function (done) { context.execute(` var assert = require('assert'), diff --git a/types/sandbox/prerequest.d.ts b/types/sandbox/prerequest.d.ts index e12225cd..77e94c40 100644 --- a/types/sandbox/prerequest.d.ts +++ b/types/sandbox/prerequest.d.ts @@ -1,4 +1,4 @@ -// Type definitions for postman-sandbox 3.5.7 +// Type definitions for postman-sandbox 4.2.7 // Project: https://github.com/postmanlabs/postman-sandbox // Definitions by: PostmanLabs // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped @@ -14,8 +14,19 @@ declare interface PostmanLegacy { setNextRequest(requestName: string): void; } +/** + * @param execution - - + * @param onRequest - - + * @param onSkipRequest - callback to execute when pm.execution.skipRequest() called + * @param onAssertion - - + * @param cookieStore - - + * @param [options] - - + * @param [options.disabledAPIs] - - + */ declare class Postman { - constructor(bridge: EventEmitter, execution: Execution, onRequest: (...params: any[]) => any, cookieStore: any); + constructor(execution: Execution, onRequest: (...params: any[]) => any, onSkipRequest: (...params: any[]) => any, onAssertion: (...params: any[]) => any, cookieStore: any, options?: { + disabledAPIs?: string[]; + }); /** * The pm.info object contains information pertaining to the script being executed. * Useful information such as the request name, request Id, and iteration count are @@ -44,8 +55,11 @@ declare class Postman { visualizer: Visualizer; /** * Allows one to send request from script asynchronously. + * @param req - - + * @param callback - - */ sendRequest(req: import("postman-collection").Request | string, callback: (...params: any[]) => any): void; + execution: Execution; expect: Chai.ExpectStatic; } @@ -89,6 +103,17 @@ declare interface Visualizer { clear(): void; } +/** + * Exposes handlers to control execution state + */ +declare interface Execution { + /** + * Stops the execution of current request. No line after this will be executed and + * if invoked from a pre-request script, the request will not be sent. + */ + skipRequest: (...params: any[]) => any; +} + /** * The pm object encloses all information pertaining to the script being executed and * allows one to access a copy of the request being sent or the response received. @@ -96,25 +121,44 @@ declare interface Visualizer { */ declare var pm: Postman; -declare interface PostmanCookieJar { +/** + * @param cookieStore - - + */ +declare class PostmanCookieJar { + constructor(cookieStore: any); /** * Get the cookie value with the given name. + * @param url - - + * @param name - - + * @param callback - - */ get(url: string, name: string, callback: (...params: any[]) => any): void; /** * Get all the cookies for the given URL. + * @param url - - + * @param [options] - - + * @param callback - - */ getAll(url: string, options?: any, callback: (...params: any[]) => any): void; /** * Set or update a cookie. + * @param url - - + * @param name - - + * @param [value] - - + * @param [callback] - - */ set(url: string, name: string | any, value?: string | ((...params: any[]) => any), callback?: (...params: any[]) => any): void; /** * Remove single cookie with the given name. + * @param url - - + * @param name - - + * @param [callback] - - */ unset(url: string, name: string, callback?: (...params: any[]) => any): void; /** * Remove all the cookies for the given URL. + * @param url - - + * @param [callback] - - */ clear(url: string, callback?: (...params: any[]) => any): void; } diff --git a/types/sandbox/test.d.ts b/types/sandbox/test.d.ts index f9fff448..510c3d50 100644 --- a/types/sandbox/test.d.ts +++ b/types/sandbox/test.d.ts @@ -1,4 +1,4 @@ -// Type definitions for postman-sandbox 3.5.7 +// Type definitions for postman-sandbox 4.2.7 // Project: https://github.com/postmanlabs/postman-sandbox // Definitions by: PostmanLabs // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped @@ -14,8 +14,19 @@ declare interface PostmanLegacy { setNextRequest(requestName: string): void; } +/** + * @param execution - - + * @param onRequest - - + * @param onSkipRequest - callback to execute when pm.execution.skipRequest() called + * @param onAssertion - - + * @param cookieStore - - + * @param [options] - - + * @param [options.disabledAPIs] - - + */ declare class Postman { - constructor(bridge: EventEmitter, execution: Execution, onRequest: (...params: any[]) => any, cookieStore: any); + constructor(execution: Execution, onRequest: (...params: any[]) => any, onSkipRequest: (...params: any[]) => any, onAssertion: (...params: any[]) => any, cookieStore: any, options?: { + disabledAPIs?: string[]; + }); /** * The pm.info object contains information pertaining to the script being executed. * Useful information such as the request name, request Id, and iteration count are @@ -49,8 +60,11 @@ declare class Postman { visualizer: Visualizer; /** * Allows one to send request from script asynchronously. + * @param req - - + * @param callback - - */ sendRequest(req: import("postman-collection").Request | string, callback: (...params: any[]) => any): void; + execution: Execution; expect: Chai.ExpectStatic; } @@ -94,6 +108,17 @@ declare interface Visualizer { clear(): void; } +/** + * Exposes handlers to control execution state + */ +declare interface Execution { + /** + * Stops the execution of current request. No line after this will be executed and + * if invoked from a pre-request script, the request will not be sent. + */ + skipRequest: (...params: any[]) => any; +} + /** * The pm object encloses all information pertaining to the script being executed and * allows one to access a copy of the request being sent or the response received. @@ -101,25 +126,44 @@ declare interface Visualizer { */ declare var pm: Postman; -declare interface PostmanCookieJar { +/** + * @param cookieStore - - + */ +declare class PostmanCookieJar { + constructor(cookieStore: any); /** * Get the cookie value with the given name. + * @param url - - + * @param name - - + * @param callback - - */ get(url: string, name: string, callback: (...params: any[]) => any): void; /** * Get all the cookies for the given URL. + * @param url - - + * @param [options] - - + * @param callback - - */ getAll(url: string, options?: any, callback: (...params: any[]) => any): void; /** * Set or update a cookie. + * @param url - - + * @param name - - + * @param [value] - - + * @param [callback] - - */ set(url: string, name: string | any, value?: string | ((...params: any[]) => any), callback?: (...params: any[]) => any): void; /** * Remove single cookie with the given name. + * @param url - - + * @param name - - + * @param [callback] - - */ unset(url: string, name: string, callback?: (...params: any[]) => any): void; /** * Remove all the cookies for the given URL. + * @param url - - + * @param [callback] - - */ clear(url: string, callback?: (...params: any[]) => any): void; }