diff --git a/src/stages.js b/src/stages.js new file mode 100644 index 0000000..1248570 --- /dev/null +++ b/src/stages.js @@ -0,0 +1,149 @@ +import exec from 'k6/execution' + +// parseDuration parses the provided string as an Integer number +// in millisecond precision +function parseDuration(str) { + if (str == null || str.length < 1) { + throw new Error('str is empty') + } + + // the sum in millisecond of the parsed duration + let d = 0; + + // current contains the partial seen number + // it's reset when a time unit is found + let current = ''; + + + // it tracks the seen time units + // and it denies eventual duplicated + let seen = {} + + for (let i = 0; i < str.length; i++) { + // append the current char if it's a number or a decimal separator + if (isNumber(str[i]) || str[i] == '.') { + current += str[i] + } + + // return if the next char is not a time unit + if (str[i+1] == null || isNumber(str[i+1]) || str[i+1] == '.') { + continue + } + + let v = parseFloat(current, 10) + let next = str[i+1] + + switch (next) { + case 'd': + d += v*24*60*60*1000 + break; + case 'h': + d += v*60*60*1000 + break; + case 'm': + if (i + 2 < str.length && str[i+2] == 's') { + // millisecond is the maximum precision + // truncate eventual decimal + d += Math.trunc(v) + i++ + next = 'ms' + } else { + d += v*60*1000 + } + break; + case 's': + d += v*1000 + break; + default: + throw new Error(`${next} is an unsupported time unit`) + } + if (seen[next]) { + throw new Error(`${next} time unit is provided multiple times`) + } + seen[next] = true + i++ + current = '' + } + // flush in case no time unit has been provided + // for the latest group + if (current.length > 0) { + d += parseFloat(current, 10) + } + return d +} + +// isNumber return true if the c character is a number +function isNumber(c) { + return c >= '0' && c <= '9' +} + +// getCurrentStageIndex returns the computed index of the running stage. +function getCurrentStageIndex() { + let scenario = exec.test.options.scenarios[exec.scenario.name] + if (scenario == null) { + throw new Error(`the exec.test.options object doesn't contain the current scenario ${exec.scenario.name}`) + } + if (scenario.stages == null) { + throw new Error(`only ramping-vus or ramping-arravial-rate supports stages, it is not possible to get a stage index on other executors.`) + } + + if (scenario.stages.length < 1) { + throw new Error(`the current scenario ${scenario.name} doesn't contain any stage`) + } + + let sum = 0; + let elapsed = new Date() - exec.scenario.startTime + for (let i = 0; i < scenario.stages.length; i++) { + sum += parseDuration(scenario.stages[i].duration) + if (elapsed < sum) { + return i + } + } + + return scenario.stages.length-1 +} + +// tagWithCurrentStageIndex adds a tag with a `stage` key +// and the index of the current running stage as value. +function tagWithCurrentStageIndex() { + exec.vu.tags['stage'] = getCurrentStageIndex() +} + +// tagWithCurrentStageProfile adds a tag with a `stage` key +// and the profile (ramp-up, steady or ramp-down) computed +// from the current running stage. +function tagWithCurrentStageProfile() { + //ramp-up when previous.target < current.target + //ramp-down when previous.target > current.target + //steady when prevuious.target = current.target + + let getStageProfile = function() { + let currentIndex = getCurrentStageIndex() + if (currentIndex < 1) { + return 'ramp-up' + } + + let stages = exec.test.options.scenarios[exec.scenario.name].stages + let current = stages[currentIndex] + let previous = stages[currentIndex-1] + + if (current.target > previous.target) { + return 'ramp-up' + } + + if (previous.target == current.target) { + return 'steady' + } + + return 'ramp-down' + } + + exec.vu.tags['stage_profile'] = getStageProfile() +} + +export { + parseDuration, + getCurrentStageIndex, + tagWithCurrentStageIndex, + tagWithCurrentStageProfile, +} diff --git a/tests/stages.js b/tests/stages.js new file mode 100644 index 0000000..27034d0 --- /dev/null +++ b/tests/stages.js @@ -0,0 +1,247 @@ +import { describe, expect } from 'https://jslib.k6.io/k6chaijs/4.3.4.0/index.js' +import exec from 'k6/execution' +import { sleep } from 'k6' + +import { + parseDuration, + getCurrentStageIndex, + tagWithCurrentStageIndex, + tagWithCurrentStageProfile, +} from '../src/stages.js' + + +export let options = { + scenarios: { + 'parseDuration': { + executor: 'shared-iterations', + iterations: 1, + exec: 'testParseDuration' + }, + 'getCurrentStageIndex': { + executor: 'ramping-vus', + stages: [ + { duration: "1s10ms", target: 1 }, + { duration: "1s", target: 1 }, + ], + exec: 'testGetCurrentStageIndex' + }, + 'tagWithCurrentStageIndex': { + executor: 'ramping-vus', + stages: [ + { duration: "1s", target: 1 }, + { duration: "1s", target: 1 }, + ], + exec: 'testTagWithCurrentStageIndex' + }, + 'tagWithRampUpWhenOnlyOne': { + executor: 'ramping-vus', + stages: [ + { duration: "0.1s", target: 1 } + ], + exec: 'testTagWithRampUpProfileWhenOnlyOne' + }, + 'tagWithRampUp': { + executor: 'ramping-vus', + stages: [ + { duration: "0.1s", target: 1 }, + { duration: "0.1s", target: 2 } + ], + exec: 'testTagWithRampUp' + }, + 'tagWithSteady': { + executor: 'ramping-vus', + stages: [ + { duration: "0.1s", target: 1 }, + { duration: "0.1s", target: 1 } + ], + exec: 'testTagWithSteady' + }, + 'tagWithRampDown': { + executor: 'ramping-vus', + stages: [ + { duration: "0.1s", target: 2 }, + { duration: "0.1s", target: 1 } + ], + exec: 'testTagWithRampDown' + }, + 'unsupported-executor': { + executor: 'shared-iterations', + iterations: 1, + exec: 'testUnsupportedExecutor' + } + } +} + +export function testGetCurrentStageIndex() { + describe('getCurrentStageIndex', () => { + describe('returns the first stage when is in t0', () => { + expect(getCurrentStageIndex()).to.be.equal(0) + }) + + sleep(1.2) + + describe('returns the next stage if the iteration exceedes the expected duration', () => { + expect(getCurrentStageIndex()).to.be.equal(1) + }) + + sleep(1) + + describe('returns the last stage when the iteration exceedes the total expected duration', () => { + expect(getCurrentStageIndex()).to.be.equal(1) + }) + }) +} + +export function testTagWithCurrentStageIndex() { + describe('tagWithCurrentStageIndex', () => { + describe('tags with the current stage', () => { + tagWithCurrentStageIndex() + expect(exec.vu.tags['stage']).to.be.equal(`${exec.scenario.iterationInTest}`) + }) + + // it forces to get only two iterations + sleep(1) + }) +} + +export function testTagWithRampUpProfileWhenOnlyOne() { + describe('tagWithCurrentStageProfile', () => { + describe(`tags with ramp-up profile when only one stage`, () => { + tagWithCurrentStageProfile() + expect(exec.vu.tags['stage_profile']).to.be.equal('ramp-up') + }) + }) +} + +export function testTagWithRampUp() { + describe('tagWithCurrentStageProfile', () => { + describe(`tags with ramp-up profile when the current stage has a target greater than previous`, () => { + if (exec.vu.iterationInScenario !== 1) { + return + } + tagWithCurrentStageProfile() + expect(exec.vu.tags['stage_profile']).to.be.equal('ramp-up') + }) + sleep(0.1) + }) +} + +export function testTagWithSteady() { + describe('tagWithCurrentStageProfile', () => { + describe(`tags with steady profile when the current stage has the same target of the previous`, () => { + if (exec.vu.iterationInScenario !== 1) { + return + } + tagWithCurrentStageProfile() + expect(exec.vu.tags['stage_profile']).to.be.equal('steady') + }) + sleep(0.1) + }) +} + +export function testTagWithRampDown() { +describe('tagWithCurrentStageProfile', () => { + describe(`tags with ramp-down profile when the current stage has a target less than previous`, () => { + if (exec.vu.iterationInScenario !== 1) { + return + } + tagWithCurrentStageProfile() + expect(exec.vu.tags['stage_profile']).to.be.equal('ramp-down') + }) + sleep(0.1) +}) +} + +export function testUnsupportedExecutor() { + describe('throw Error if the executor is not supported', () => { + describe('getCurrentStageIndex', () => { + expect(getCurrentStageIndex).to.throw() + }) + describe('tagWithCurrentStageIndex', () => { + expect(tagWithCurrentStageIndex).to.throw() + }) + describe('tagWithCurrentStageProfile', () => { + expect(tagWithCurrentStageProfile).to.throw() + }) + }) +} + +export function testParseDuration() { + describe('parseDuration', () => { + let testcase = '1d5h31m20s9ms' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(((86400+18000+1860+20)*1000)+9) + }) + + testcase = '5.2h' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(18720*1000) + }) + + testcase = '1d5.2h31m20s9ms' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(((86400+18720+1860+20)*1000)+9) + }) + + // ms is the maximum precision so this case is truncated + testcase = '9.3ms' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(9) + }) + + testcase = '1531209' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(1531209) + }) + + testcase = '9.h1.s' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(32401*1000) + }) + + testcase = '9.h1.s' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(32401*1000) + }) + + testcase = '1s12' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(1012) + }) + + testcase = '1h-1s' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(3601*1000) + }) + + testcase = '-1h 1s' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(3601*1000) + }) + + testcase = '0' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(0) + }) + + testcase = '0s' + describe(testcase, () => { + expect(parseDuration(testcase)).to.be.equal(0) + }) + + testcase = '1s 1s 1s' + describe(testcase, () => { + expect(() => { return parseDuration(testcase) }).to.throw() + }) + + testcase = '1w' + describe(testcase, () => { + expect(() => { return parseDuration(testcase) }).to.throw() + }) + + testcase = '' + describe(testcase, () => { + expect(() => { return parseDuration(testcase) }).to.throw() + }) +}) +}