JosephW1
Tera Guru

Have you ever needed to skip a flow timer for a test?

 

Perhaps what you need to test occurs 48 long hours after your flow starts. To pass it, do we really have to gs.sleep(172,800,000) and go home for the weekend? Yuck, we hope not! There's a better way, there must be. No scenario should limit us to one test per week, after all.

 

Even though ServiceNow has yet to offer us an OOTB step config for skipping flow timers - they probably will eventually - we can make our own.

 

 

First, though, how do you skip a flow timer?

 

It's quite simple. You get the waiting flow context for the flow and target record, then you get the flow.fire sys_trigger for that context and make it execute, then you get the resultant flow.fire sysevent and make it execute, and then, viola, your flow will move to the next step. Oh, right, it's not all that simple after all. Well, anyway, that's how it's done. Now we need to package this logic into a script.

 

I want to give a shout out to @samfindlay94, as I learned this from this answer of his. Many people mentioned the sys_trigger, but he was the only one I saw that mentioned the resultant sysevent.

 

 

A word of caution: Avoid the OOTB server side script step config.

 

If we put our logic in instances of this step config, which is the primary OOTB step config that offers us to run server scripts of our choosing, then updating our logic after engine changes is more tedious. If we make 25 such test steps containing 25 duplicates of our logic, that's 25 test steps to update to make if ServiceNow ever breaks our logic. Instead, let's package the logic itself centrally somewhere & just reference it in our test steps, that way we only must do one update instead. For this purpose, let's put it in a new "step configuration".

 

I want to give a shout out to @codycotulla for his article that helped get me started on this. Thanks!

 

 

Installing the step configuration.

 

To keep this simple, I wrote the following script that will create or update this specific test config of mine on any instance. You can re-run it without fear of it creating duplicate records each time.

 

Open spoiler for script (also included as an attachment)

Spoiler
(function createStepConfigAndDependencies() {
	var stepConfigName = 'JRW: Skip Flow Timer';
	gs.log('Creating/Updating the step config...', stepConfigName);
	
	// Reusable template for storing results
	var resultTemplate    = {};
	resultTemplate.insert = 0,
	resultTemplate.update = 0;
	resultTemplate.ignore = 0;
	
	// Convenience object for script-wide properties
	var result						= {};
	result.total				= JSON.parse(JSON.stringify(resultTemplate));
	result.sys_atf_step_config	= JSON.parse(JSON.stringify(resultTemplate));
	result.atf_input_variable	= JSON.parse(JSON.stringify(resultTemplate));
	result.sys_ui_policy		= JSON.parse(JSON.stringify(resultTemplate));
	
	// Create step config
	var stepConfig = (function createStepConfig(){
		var guid					= new GlideDigest().getMD5Hex(stepConfigName);  //hardcoded sys_id for consistency if config is backcloned but not test plans
		var gr						= new GlideRecord('sys_atf_step_config');
		gr.get(guid) || gr.initialize(); //0684991B243E0FC480AF4B460A0E0C3D
		gr.setNewGuidValue(guid);
		gr.name						= stepConfigName;
		gr.active					= 'true';
		gr.step_env					= '6c2bcea1870312009dccc9ded0e3ecca'; //Server - Independent
		gr.category					= '317c4dc20b202200a8d7a12cf6673aa8'; //Server
		gr.sys_scope				= 'global';
		gr.class_type				= 'script';
		gr.order					= '2650';
		gr.template_reminder		= 'Choose the flow, target table, and target record for which to skip a flow timer.';
		gr.html_description			= 'Skips a flow timer for the target record.';
		gr.description_generator	= "(function generateDescription() {\n\tvar description = \"Skips a flow timer\" + (step.inputs.add_worknote ? \" and adds a narrative worknote\" : \"\");\n\tdescription += \"\\nFlow = \" + step.inputs.flow.getDisplayValue();\n\tvar tableDescriptor = GlideTableDescriptor.get(step.inputs.source_table);\n\tvar tableLabel = tableDescriptor ? tableDescriptor.getLabel() : step.inputs.source_table;\n\tdescription += \"\\n\" + tableLabel + \" = \" + step.inputs.source_record.getDisplayValue();\n\treturn description;\n})();";
		gr.step_execution_generator	= "(function executeStep(inputs, outputs, stepResult, timeout) {\n\t// Define flow timer skip utility\n\tvar SkipFlowTimerUtil = Class.create();\n\tSkipFlowTimerUtil.prototype = {\n\t\tinitialize: function() {\n\t\t},\n\n\t\t// Re-usable method for retrieving a record based on the table name & a collection of field values\n\t\t_getRecord: function(tableName, fieldValues) {\n\t\t\t// Preferred Method: Sys ID (might fail if sys ids vary between environments)\n\t\t\tvar gr = new GlideRecord(tableName);\n\t\t\tif (fieldValues.sys_id && gr.get(fieldValues.sys_id)) {\n\t\t\t\treturn gr;\n\t\t\t}\n\t\t\t// Fallback Method: Misc Fields\n\t\t\tdelete fieldValues.sys_id;\n\t\t\tgr = new GlideRecord(tableName);\n\t\t\tfor (var field in fieldValues) {\n\t\t\t\tgr.addQuery(field, fieldValues[field]);\n\t\t\t}\n\t\t\tgr.query();\n\t\t\treturn gr.next() ? gr : false;\n\t\t},\n\n\t\t// The method for specifying the flow to use\n\t\tsetFlow: function(fieldValues) {\n\t\t\tthis._flow = this._getRecord('sys_hub_flow', fieldValues);\n\t\t\treturn this;\n\t\t},\n\n\t\t// The method for specifying the task to use\n\t\tsetSource: function(tableName, fieldValues) {\n\t\t\tthis._sourceRecord = this._getRecord(tableName, fieldValues);\n\t\t\treturn this;\n\t\t},\n\n\t\t// Use this to observe log statements without making any database update()s\n\t\tsetDebugMode: function(bool) {\n\t\t\tthis._debugMode = bool;\n\t\t\treturn this;\n\t\t},\n\n\t\t// Reusable database update() gated behind debug mode\n\t\t_update: function(gr) {\n\t\t\tif (!this._debugMode) {\n\t\t\t\tgr.update();\n\t\t\t}\n\t\t},\n\n\t\t// Method for specifying whether or not to add narrative work notes for the execution\n\t\tsetAddWorkNotes: function(bool, worknoteField) {\n\t\t\tthis._createWorkNotes = bool;\n\t\t\tthis._worknoteField = worknoteField || 'work_notes';\n\t\t\treturn this;\n\t\t},\n\n\t\t// Private method for adding work notes to the task\n\t\t_addWorkNote: function(message) {\n\t\t\tif (this._createWorkNotes) {\n\t\t\t\tthis._sourceRecord[this._worknoteField] = message;\n\t\t\t\tthis._update(this._sourceRecord); //honors debug mode\n\t\t\t}\n\t\t},\n\n\t\t// Private method for calculating the time between two GDTs\n\t\t_getDateDiffString: function(startGdt, endGdt) {\n\t\t\tvar duration = GlideDateTime.subtract(startGdt, endGdt);\n\t\t\tvar dayPart = duration.getDayPart() == 0 ? '' : duration.getDayPart() + ' ' + (duration.getDayPart() > 1 ? 's' : '') + ' '; // '', '1 day ', '2 days '\n\t\t\treturn dayPart + duration.getByFormat('HH:mm:ss');\n\t\t},\n\n\t\t// A private polyfill of JRW.format ({i}/{SYMBOL}/method-count replacer & whitespace preserver)\n\t\t// CODE SAMPLE: this._format('{CHECK} {0} {1}', ['Hello', 'World']);\n\t\t_format: function(/*var-len args*/) {\n\t\t\tvar arg = Array.prototype.slice.call(arguments);    //ECMA5 conversion of array-like arguments to array\n\t\t\tvar msg = arg.splice(0, 1)[0];                      //1st arg\n\t\t\tvar arr = arg[0] instanceof Array ? arg[0] : arg;   //use array argument or treat the remaining args as a variable-length array\n\t\t\t// Replace {i} placeholders\n\t\t\tarr.forEach(function(element, index) {\n\t\t\t\tmsg = msg.replace(new RegExp('{' + index + '}', 'g'), element); //regex will replace all occurrences\n\t\t\t});\n\t\t\t// Replace {SYMBOL} placeholders\n\t\t\tvar symbols\t\t= {};\n\t\t\tsymbols.FLAG\t= String.fromCharCode(0xD83C, 0xDFC1);\t//chequered flag\n\t\t\tsymbols.WARN\t= String.fromCharCode(0x26A0);\t\t\t//triangled !\n\t\t\tsymbols.CHECK\t= String.fromCharCode(0x2714);\t\t\t//thick check mark\n\t\t\tmsg = msg.replace(/{([A-Z]+)}/g, function(placeholder, propName) { //NOTE: this is undefined in string.replace functions\n\t\t\t\treturn symbols[propName] || placeholder; //keep placeholder if not found\n\t\t\t});\n\t\t\t// Replace {#[+|-]} placeholders (method count)\n\t\t\tthis._methodCount = typeof (this._methodCount) == 'number' ? this._methodCount : 0; //initialize method count\n\t\t\tvar self = this; //is needed since we lose access to this in the upcoming string.replace's replacer function\n\t\t\tmsg = msg.replace(/{#([+|-]?)}/g, function(placeholder, operator) { //NOTE: this is undefined in string.replace functions\n\t\t\t\tself._methodCount = self._methodCount + {'-': -1, '': 0, '+': 1}[operator]; //keep or [in|de]crement method count\n\t\t\t\treturn self._methodCount;\n\t\t\t});\n\t\t\t// Replace Whitespace (to defeat the whitespace remover)\n\t\t\tmsg = msg.replace(/ /g, String.fromCharCode(0x2005)); //replaces standard space with non-standard space w/the same width, to preserve whitespace\n\t\t\treturn msg;\n\t\t},\n\n\t\t// A private polyfill of JRW.log (persistent log source, indent level, placeholder replacement, log source ids, incremental log sources)\n\t\t// CODE SAMPLE: this._log(2, 'This message has an indent level of 2 and a log source of \"someSource\". {0} {1}', ['Hello', 'World'], 'someSource');\n\t\t_log: function(/*var-len args*/) {\n\t\t\t// Resolve aliased variable-length arguments\n\t\t\tvar args\t\t= Array.prototype.slice.call(arguments);\t\t\t\t\t\t\t\t\t\t\t\t\t//ECMA5 conversion of array-like arguments to array\n\t\t\tvar indent\t\t= typeof(args[0]) == 'number'\t? ' '.repeat(4 * args.splice(0, 1)[0]) : '';\t\t\t\t// 1st arg (optional) is the indent level (4 spaces per level)\n\t\t\tvar indentedMsg = indent + args.splice(0, 1)[0];\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t// 2nd arg (required) is the message (we pad it with the indent here)\n\t\t\tvar repArray\t= args[0] instanceof Array\t\t? args.splice(0, 1)[0] : [];\t\t\t\t\t\t\t\t// 3rd arg (optional) is the replacement array to inject into the message\n\t\t\tvar rawSource\t= typeof(args[0]) == 'string'\t? args.splice(0, 1)[0] : (this._logSource || this.type);\t// 4th arg (optional) is the raw log source (gets appended w/id & logcount)\n\t\t\tvar injectedMsg\t= this._format(indentedMsg, repArray);\n\t\t\t// Update log source variables (these will turn the above sample's log source into something like 'someSource (MRZY) 001')\n\t\t\tthis._uniqueId = this._uniqueId || Math.random().toString(36).slice(2).toUpperCase().substr(0, 4); //random 4-digit alphanumeric, persist in current execution once defined\n\t\t\tthis._logCount = this._logCount ? ++this._logCount : 1; //the cache of how many messages have been logged. increments on every this._log call\n\t\t\tthis._logCountText = '0'.repeat(3 - String(this._logCount).length) + this._logCount; //turns 1 into 001, 23 into 023, etc.\n\t\t\tgs.log(injectedMsg, rawSource + ' (' + this._uniqueId + ') ' + this._logCountText);\n\t\t},\n\n\t\t// Executes the utility. You must at least call setFlow and setSource prior to executing.\n\t\texecute: function() {\n\t\t\tif (!this._flow || !this._sourceRecord) {\n\t\t\t\treturn this._log('{WARN} No {0} Specified', [!this._flow ? 'Flow' : 'Task']);\n\t\t\t}\n\n\t\t\t// Misc preparation activities\n\t\t\tthis._log(0, 'Beginning to skip a \"{0}\" flow timer for {1}', [this._flow.name, this._sourceRecord.number]);\n\t\t\tthis._unfinishedSteps = ['flowContextLookup', 'skipTrigger', 'waitEvent', 'skipEvent'];\n\n\t\t\t// STEP 1: Get Active Flow Context For Task\n\t\t\tthis._log(1, '{#+}. Retrieving Flow Engine Context...');\n\t\t\tvar sfc = new GlideRecord('sys_flow_context');\n\t\t\tsfc.addQuery('flow', this._flow.sys_id);\n\t\t\tsfc.addQuery('source_record', this._sourceRecord.sys_id);\n\t\t\tsfc.addQuery('state', 'WAITING');\n\t\t\tsfc.query();\n\t\t\tif (sfc.next()) {\n\t\t\t\t// Log success\n\t\t\t\tthis._log(2, '{CHECK} Retrieved sys_flow_context {0}', [sfc.sys_id]);\n\t\t\t\tthis._unfinishedSteps = this._unfinishedSteps.filter(function(e){return e != 'flowContextLookup';});\n\t\t\t} else {\n\t\t\t\treturn this._log(2, '{WARN} Error: Unable to find an active flow context');\n\t\t\t}\n\n\t\t\t// STEP 2: Skip the sys_trigger (will trigger the creation of a sysevent)\n\t\t\tthis._log(1, '{#+}. Skipping the sys_trigger...');\n\t\t\tvar trigger = new GlideRecord('sys_trigger');\n\t\t\ttrigger.addQuery('document_key', sfc.sys_id);\n\t\t\ttrigger.addQuery('name', 'flow.fire');\n\t\t\ttrigger.addQuery('state', 0); //Ready\n\t\t\ttrigger.query();\n\t\t\tif (trigger.next()) {\n\t\t\t\t// Make sys_trigger execute now (which will queue the creation of a sysevent timer of the same original execution time)\n\t\t\t\ttrigger.next_action = gs.nowDateTime();\n\t\t\t\tthis._update(trigger); //honors debug mode\n\n\t\t\t\t// Log success (Cannot link to sys_trigger since it is deleted after it executes)\n\t\t\t\tthis._log(2, '{CHECK} Re-queued sys_trigger {0} to the current time', [trigger.sys_id]);\n\t\t\t\tthis._unfinishedSteps = this._unfinishedSteps.filter(function(e){return e != 'skipTrigger';});\n\t\t\t} else {\n\t\t\t\treturn this._log(2, '{WARN} Error: No ready sys_trigger found for the flow context');\n\t\t\t}\n\n\t\t\t// STEP 3: Wait for the executed sys_trigger's follow-up sysevent creation\n\t\t\tthis._log(1, '{#+}. Waiting for the sysevent...');\n\t\t\tvar event = {}; //pre-initialize for loop access\n\t\t\tvar timeout = {seconds: 30, msPerRerun: 500};\n\t\t\tfor (var i = 0; i < (timeout.seconds * 1000 / timeout.msPerRerun) && !event.isValidRecord(); i++) {\n\t\t\t\tevent = new GlideRecord('sysevent');\n\t\t\t\tevent.addQuery('instance', sfc.sys_id);\n\t\t\t\tevent.addQuery('name', 'flow.fire');\n\t\t\t\tevent.addQuery('state', 'ready');\n\t\t\t\tevent.query();\n\t\t\t\tevent.next();\n\t\t\t\tif (!event.isValidRecord()) {\n\t\t\t\t\tgs.sleep(timeout.msPerRerun);\n\t\t\t\t} else {\n\t\t\t\t\tthis._log(2, '{CHECK} Found sysevent {0}', [event.sys_id]);\n\t\t\t\t\tthis._unfinishedSteps = this._unfinishedSteps.filter(function(e){return e != 'waitEvent';});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// STEP 4: Skip the sysevent (after this, the flow will finally progress to the next step)\n\t\t\tif (event.isValidRecord()) {\n\t\t\t\t// Update Timer\n\t\t\t\tthis._log(1, '{#+}. Skipping the sysevent...');\n\t\t\t\tevent.process_on = gs.nowDateTime();\n\t\t\t\tthis._update(event); //honors debug mode\n\n\t\t\t\t// Post worknote & log success (can link to sysevent since it does not get deleted post-execution)\n\t\t\t\tthis._dateDiff = this._getDateDiffString(new GlideDateTime(), event.process_on.getGlideObject()); //cache for later\n\t\t\t\tvar cntxtLink = this._format('<a href=\"{0}\" target=\"_blank\">{1}</a>', ['sys_flow_context_list.do?sysparm_query=sys_id=' + sfc.sys_id, 'view flow context']);\n\t\t\t\tvar eventLink = this._format('<a href=\"{0}\" target=\"_blank\">{1}</a>', ['sysevent_list.do?sysparm_query=sys_id=' + event.sys_id, 'view event']);\n\t\t\t\tvar worknote = (this._format('[{0}] Skipped a \"{1}\" flow timer scheduled {2} from now. [{3}] [{4}]', [this.type, sfc.name, this._dateDiff, cntxtLink, eventLink]));\n\t\t\t\tthis._addWorkNote('[code]' + worknote.replace(/\\s/g, \" \") + '[/code]'); //remove _format's special whitespace, as it corrupts the html tags\n\t\t\t\tthis._log(2, '{CHECK} Rescheduled sysevent {0} to the now; was {1} from now', [event.sys_id, this._dateDiff]);\n\t\t\t\tthis._unfinishedSteps = this._unfinishedSteps.filter(function(e){return e != 'skipEvent';});\n\t\t\t} else {\n\t\t\t\tthis._log(2, '{WARN} Error: No ready sysevent found for the flow context.');\n\t\t\t}\n\n\t\t\t// Log the result \n\t\t\tthis._log(1, '{FLAG} Execution finished (unfinishedSteps: {1})', [JSON.stringify(this._unfinishedSteps)]);\n\t\t},\n\n\t\ttype: 'SkipFlowTimerUtil'\n\t};\n\n\t// Call skip utility using step config inputs\n\tvar skipFlowTimerUtil = new SkipFlowTimerUtil();\n\tskipFlowTimerUtil.setFlow({sys_id: inputs.flow});\n\t//skipFlowTimerUtil.setFlowAction(inputs.specify_flow_action, {sys_id: inputs.flow_action}); //maybe someday, if I'm ever able to connect the dots\n\tskipFlowTimerUtil.setSource(inputs.source_table, {sys_id: inputs.source_record});\n\tskipFlowTimerUtil.setAddWorkNotes(inputs.add_worknote, inputs.worknote_field);\n\t//skipFlowTimerUtil.setDebugMode(true); //only use while testing, will cause flow not to skip & just log stuff instead\n\tskipFlowTimerUtil.execute();\n\n\t// Pass the test if there are no unfinished steps\n\tstepResult[skipFlowTimerUtil._unfinishedSteps.length == 0 ? 'setSuccess' : 'setFailed']();\n\tif (skipFlowTimerUtil._unfinishedSteps.length > 0) {\n\t\treturn stepResult.setOutputMessage('Unfinished steps: ' + skipFlowTimerUtil._unfinishedSteps);\n\t}\n\tstepResult.setOutputMessage('Skipped a \"' + skipFlowTimerUtil._flow.name + '\" flow timer scheduled ' + skipFlowTimerUtil._dateDiff + ' from now');\n}(inputs, outputs, stepResult, timeout));";
		var action					= gr.isValidRecord() ? ( gr.changes() ? 'update' : 'ignore' ) : 'insert';
		gr.isValidRecord() ? gr.update() : gr.insert();
		result.total				[action]++;
		result[gr.sys_class_name]	[action]++;
		return gr;
	})();
	
	// Create input variables
	var inputVariables = {};
	var inputElementNames = {};
	inputVariables.flow = (function createInputVariableFlow(){
		var elementName		= 'flow';
		if (inputElementNames[elementName]) {
			return gs.log('error: input element name "' + elementName + '" is not unique', stepConfigName);
		}
		inputElementNames[elementName] = true;
		var guid			= new GlideDigest().getMD5Hex(elementName); //hardcoded sys_id for consistency if config is backcloned but not test plans
		var gr				= new GlideRecord('atf_input_variable');
		gr.get(guid) || gr.initialize(); //CFF5497121104C2B8E0CB41ED2083A9B
		gr.setNewGuidValue(guid);
		gr.element			= elementName;
		gr.model			= stepConfig.sys_id;
		gr.order			= (Object.keys(inputVariables).length + 1) * 100;
		gr.internal_type	= 'reference';
		gr.label			= 'Flow';
		gr.reference		= 'sys_hub_flow';
		gr.active			= 'true';
		gr.sys_scope		= 'global';
		gr.hint				= 'Flow whose waiting timer will be skipped';
		gr.mandatory		= 'true';
		gr.max_length		= '32';
		var action			= gr.isValidRecord() ? ( gr.changes() ? 'update' : 'ignore' ) : 'insert';
		gr.isValidRecord() ? gr.update() : gr.insert();
		result.total				[action]++;
		result[gr.sys_class_name]	[action]++;
		return gr;
	})();
	inputVariables.sourceTable = (function createInputVariableSourceTable(){
		var elementName		= 'source_table';
		if (inputElementNames[elementName]) {
			return gs.log('error: input element name "' + elementName + '" is not unique', stepConfigName);
		}
		inputElementNames[elementName] = true;
		var guid			= new GlideDigest().getMD5Hex(elementName); //hardcoded sys_id for consistency if config is backcloned but not test plans
		var gr				= new GlideRecord('atf_input_variable');
		gr.get(guid) || gr.initialize(); //D065963C2E556EFE4C50505369E26A5A
		gr.setNewGuidValue(guid);
		gr.element			= elementName;
		gr.model			= stepConfig.sys_id;
		gr.order			= (Object.keys(inputVariables).length + 1) * 100;
		gr.internal_type	= 'table_name';
		gr.label			= 'Source Table';
		gr.active			= 'true';
		gr.sys_scope		= 'global';
		gr.hint				= 'Table whose record is waiting on the flow timer';
		gr.mandatory		= 'true';
		gr.max_length		= '80';
		var action			= gr.isValidRecord() ? ( gr.changes() ? 'update' : 'ignore' ) : 'insert';
		gr.isValidRecord() ? gr.update() : gr.insert();
		result.total				[action]++;
		result[gr.sys_class_name]	[action]++;
		return gr;
	})();
	inputVariables.sourceRecord = (function createInputVariable(){
		var elementName		= 'source_record';
		if (inputElementNames[elementName]) {
			return gs.log('error: input element name "' + elementName + '" is not unique', stepConfigName);
		}
		inputElementNames[elementName] = true;
		var guid			= new GlideDigest().getMD5Hex(elementName); //hardcoded sys_id for consistency if config is backcloned but not test plans
		var gr				= new GlideRecord('atf_input_variable');
		gr.get(guid) || gr.initialize(); //A0E21A9EB02A2B53E1B6FBF980B3F0EF
		gr.setNewGuidValue(guid);
		gr.element				= elementName;
		gr.model				= stepConfig.sys_id;
		gr.order				= (Object.keys(inputVariables).length + 1) * 100;
		gr.internal_type		= 'document_id';
		gr.dependent			= 'source_table'; //document_id types use both dependent & dependent_on_field
		gr.dependent_on_field	= 'source_table';
		gr.label				= 'Source Record';
		gr.active				= 'true';
		gr.sys_scope			= 'global';
		gr.hint					= 'Record that is waiting on the flow timer';
		gr.mandatory			= 'true';
		gr.max_length			= '32';
		var action				= gr.isValidRecord() ? ( gr.changes() ? 'update' : 'ignore' ) : 'insert';
		gr.isValidRecord() ? gr.update() : gr.insert();
		result.total				[action]++;
		result[gr.sys_class_name]	[action]++;
		return gr;
	})();
	inputVariables.addWorknote = (function createInputVariableAddWorknote(){
		var elementName		= 'add_worknote';
		if (inputElementNames[elementName]) {
			return gs.log('error: input element name "' + elementName + '" is not unique', stepConfigName);
		}
		inputElementNames[elementName] = true;
		var guid			= new GlideDigest().getMD5Hex(elementName); //hardcoded sys_id for consistency if config is backcloned but not test plans
		var gr				= new GlideRecord('atf_input_variable');
		gr.get(guid) || gr.initialize(); //926626317A4646852038C5E0B96D5BA7
		gr.setNewGuidValue(guid);
		gr.element			= elementName;
		gr.model			= stepConfig.sys_id;
		gr.order			= (Object.keys(inputVariables).length + 1) * 100;
		gr.internal_type	= 'boolean';
		gr.default_value	= 'true';
		gr.label			= 'Add Worknote';
		gr.active			= 'true';
		gr.sys_scope		= 'global';
		gr.hint				= 'Will add narratgre work notes to the record';
		gr.max_length		= '40';
		var action				= gr.isValidRecord() ? ( gr.changes() ? 'update' : 'ignore' ) : 'insert';
		gr.isValidRecord() ? gr.update() : gr.insert();
		result.total				[action]++;
		result[gr.sys_class_name]	[action]++;
		return gr;
	})();
	inputVariables.worknoteField = (function createInputVariableWorknoteField(){
		var elementName		= 'worknote_field';
		if (inputElementNames[elementName]) {
			return gs.log('error: input element name "' + elementName + '" is not unique', stepConfigName);
		}
		inputElementNames[elementName] = true;
		var guid			= new GlideDigest().getMD5Hex(elementName); //hardcoded sys_id for consistency if config is backcloned but not test plans
		var gr				= new GlideRecord('atf_input_variable');
		gr.get(guid) || gr.initialize(); //9116268A0CB7677BC18AA32C4C581104
		gr.setNewGuidValue(guid);
		gr.element			= elementName;
		gr.model			= stepConfig.sys_id;
		gr.order			= (Object.keys(inputVariables).length + 1) * 100;
		gr.internal_type	= 'field_name';
		gr.dependent		= 'inputs.source_table'; //field_name types use dependent
		gr.default_value	= 'work_notes';
		gr.label			= 'Worknote Field';
		gr.active			= 'true';
		gr.sys_scope		= 'global';
		gr.hint				= 'The target table\'s worknotes field';
		gr.mandatory		= 'true';
		gr.max_length		= '80';
		var action				= gr.isValidRecord() ? ( gr.changes() ? 'update' : 'ignore' ) : 'insert';
		gr.isValidRecord() ? gr.update() : gr.insert();
		result.total				[action]++;
		result[gr.sys_class_name]	[action]++;
		return gr;
	})();
	
	// Create UI policies
	var uiPolicies = {};
	var uiPolicyNames = {};
	function getInputPath(inputElement) {
		return 'inputs.var__m_atf_input_variable_' + stepConfig.sys_id + '.' + inputElement;
	}
	uiPolicies.showWorknote = (function addUiPolicy() {
		var uiPolicyName	= 'Show worknote_field when add_worknote is true';
		if (uiPolicyNames[uiPolicyName]) {
			return gs.log('error: ui policy name "' + uiPolicyName + '" is not unique', stepConfigName);
		}
		uiPolicyNames[uiPolicyName] = true;
		var guid				= new GlideDigest().getMD5Hex(uiPolicyName); //hardcoded sys_id for consistency if config is backcloned but not test plans
		var gr					= new GlideRecord('sys_ui_policy');
		gr.get(guid) || gr.initialize(); //1736BD3FEEB323C8F4605C4DB02B1ED8
		gr.setNewGuidValue(guid);
		gr.short_description	= uiPolicyName;
		gr.table				= 'sys_atf_step';
		gr.order				= (Object.keys(uiPolicies).length + 1) * 100;
		gr.active				= 'true';
		gr.global				= 'true';
		gr.on_load				= 'true';
		gr.reverse_if_false		= 'true';
		gr.ui_type				= 10; //All
		gr.conditions			= getInputPath('add_worknote') + "=true^EQ";
		gr.isolate_script		= false;
		gr.run_scripts			= 'true';
		gr.script_true			= "function onCondition() {\n\tg_form.setVisible('" + getInputPath('worknote_field') + "', true);\n\tg_form.setMandatory('" + getInputPath('worknote_field') + "', true);\n}";
		gr.script_false			= "function onCondition() {\n\tg_form.setVisible('" + getInputPath('worknote_field') + "', false);\n\tg_form.setMandatory('" + getInputPath('worknote_field') + "', false);\n}";
		var action				= gr.isValidRecord() ? ( gr.changes() ? 'update' : 'ignore' ) : 'insert';
		gr.isValidRecord() ? gr.update() : gr.insert();
		result.total				[action]++;
		result[gr.sys_class_name]	[action]++;
		return gr;
	})();
	
	
	// Log a Summary
	gs.log(' > Finished executing script. Result: ' + JSON.stringify(result.total), stepConfigName);
	gs.log('  >> Full Result: ' + JSON.stringify(result), stepConfigName);
})();

 

 

Utilizing the new step configuration.

 

Now we just need to utilize this step configuration in our test plans. You can do that following these steps:

0. Run the above background script in your instance

1. Pull up your relevant test plan

2. Click to add a new test step.

3. Choose the 'Server' category and then choose 'JRW: Skip Flow Timer'

                    JosephW1_0-1745077552667.png

4. Choose a FlowSource TableSource RecordAdd Worknote, and Worknote Field, and save the step.

5. Now from your test plan you can see the step config and a summary of its configuration.

                    JosephW1_0-1745098384422.png

6. A work note is added that lets you easily see any time a flow timer is skipped, and how much time the timer had remaining. You can disable these by setting the "Add worknote" input variable accordingly.

                    JosephW1_0-1745294486384.png

 

 

 

Congratulations on making and using the custom step config!

 

Enjoy using this custom step config to skip your flow timers until ServiceNow gives us an OOTB one! Please pay it forward by finding other crucial and missing step configs that are needed, creating them, and posting them in the community. If you do, please post a link to your article below, in the comments for this article, so that I can thank you and add it to the list of related articles below. Thanks!

 

 

Related articles

 

Here are some related articles on creating custom ATF step configs.

Create a Custom ATF UI Step Configuration @codycotulla 

Custom Step Configuration for Pausing an ATF Test for Debugging @codycotulla 

 

 

Extra: There's one thing you can help me with for improving this specific step config.

 

I'd like to be able to add Specify flow action (true/false, default: false) and Flow action reference inputs to the step config, but I don't know how to go from the sys_flow_context to the flow action. It's obviously possible since the flow designer is able to tell you which flow action a given record is on. If you know how to connect the data, please post it below, thanks!!

 

Also, devs can use the SkipFlowTimerUtil above to skip flow timers for any records, even outside ATF. This might help you in some dev testing someday.

 

Updates:

  • 4.19.25: Updated step config's execution script to remove dependency on JRW: An Enhanced Logging Utility. If you already ran the script, please re-run it. It won't create a duplicate, it'll just update your version! I designed it like that so that it's easy to update.
  • 4.21.25: Added a UI policy to hide the worknotes_field input conditionally. Standardized the input element names. Condensed work note into a one-liner.
Comments
samfindlay94
Tera Contributor

Thanks for the shoutout man! Love this article ❤️

Version history
Last update:
‎04-24-2025 08:00 AM
Updated by:
Contributors