@ply-ct/ply
Version:
REST API Automated Testing
596 lines (543 loc) • 20.6 kB
text/typescript
import { minimatch } from 'minimatch';
import { Values, isExpression } from './values';
import * as flowbee from './flowbee';
import { Listener, TypedEvent } from './event';
import { Log, LogLevel } from './log';
import { RunOptions } from './options';
import { Runtime } from './runtime';
import { PlyStep } from './step';
import { Suite } from './suite';
import { Request } from './request';
import { Result, ResultOptions } from './result';
import { RUN_ID } from './names';
import * as util from './util';
import { replaceLine } from './replace';
import { RESULTS } from './names';
export interface Flow {
flow: flowbee.Flow;
instance: flowbee.FlowInstance;
}
export interface Subflow {
subflow: flowbee.Subflow;
instance: flowbee.SubflowInstance;
}
export interface FlowResult extends Result {
/**
* flow path
*/
flow: string;
/**
* return values
*/
return?: Values;
}
export class FlowStepResults {
private results: (Result & { stepId: string })[] = [];
constructor(readonly flow: string) {}
add(stepId: string, result: Result) {
this.results.push({ ...result, stepId });
}
get latest(): Result {
if (this.results.length > 0) {
return this.results[this.results.length - 1];
} else {
return { name: this.flow, status: 'Pending', message: '' };
}
}
latestBad(): boolean {
return this.latest.status === 'Failed' || this.latest.status === 'Errored';
}
get overall(): Result {
let anySubmitted = false;
for (const result of this.results) {
if (result.status === 'Failed' || result.status === 'Errored') {
return result;
} else if (result.status === 'Submitted') {
anySubmitted = true;
}
}
return {
name: this.flow,
status: anySubmitted ? 'Submitted' : 'Passed',
message: ''
};
}
/**
* Step result values
*/
get values(): Values {
return this.results.reduce((values, result) => {
if (result.data) {
let data = result.data;
if (typeof data === 'object') {
data = { ...data }; // clone array or object
}
let plyResults = values[RESULTS];
if (!plyResults) {
plyResults = {};
values[RESULTS] = plyResults;
}
let stepResult = plyResults[result.stepId];
if (!stepResult) {
stepResult = {};
plyResults[result.stepId] = stepResult;
}
if (typeof data === 'object' && !Array.isArray(data)) {
if (data.request) {
stepResult.request = data.request;
if (
typeof stepResult.request.body === 'string' &&
util.isJson(stepResult.request.body)
) {
stepResult.request.body = JSON.parse(stepResult.request.body);
}
}
if (data.response) {
stepResult.response = data.response;
if (
typeof stepResult.response.body === 'string' &&
util.isJson(stepResult.response.body)
) {
stepResult.response.body = JSON.parse(stepResult.response.body);
}
}
} else {
stepResult.data = data;
}
}
return values;
}, {} as Values);
}
}
export class PlyFlow implements Flow {
readonly name: string;
readonly type = 'flow';
start = 0;
end?: number | undefined;
instance: flowbee.FlowInstance;
results: FlowStepResults;
maxLoops = 0;
private _onFlow = new TypedEvent<flowbee.FlowEvent>();
onFlow(listener: Listener<flowbee.FlowEvent>) {
this._onFlow.on(listener);
}
constructor(
readonly flow: flowbee.Flow,
readonly requestSuite: Suite<Request>,
private readonly logger: Log
) {
this.name = flowbee.getFlowName(flow);
this.instance = this.newInstance();
this.results = new FlowStepResults(this.name);
}
clone(): PlyFlow {
return new PlyFlow(this.flow, this.requestSuite, this.logger);
}
newInstance() {
const id = util.genId();
this.instance = {
id,
runId: id,
flowPath: this.flow.path,
status: 'Pending'
};
return this.instance;
}
/**
* Flow input values
*/
getFlowValues(values: Values, runOptions?: RunOptions): Values {
const flowValues: Values = {};
if (this.flow.attributes?.values) {
const rows = JSON.parse(this.flow.attributes.values);
for (const row of rows) {
let rowVal: any = row[1];
if (rowVal?.trim().length) {
if (isExpression(rowVal)) {
rowVal = replaceLine(rowVal, values, {
trusted: runOptions?.trusted,
logger: this.logger
});
}
const numVal = Number(row[1]);
if (!isNaN(numVal)) rowVal = numVal;
else if (row[1] === 'true' || row[1] === 'false') rowVal = row[1] === 'true';
else if (util.isJson(row[1])) rowVal = JSON.parse(row[1]);
flowValues[row[0]] = rowVal;
}
// run values override even flow-configured vals
if (runOptions?.values) {
const runValue =
runOptions.values[row[0]] || runOptions.values[`\${${row[0]}}`];
if (runValue) flowValues[row[0]] = runValue;
}
}
}
return flowValues;
}
/**
* returns missing value names
*/
validateValues(values: Values): string[] {
const missingRequired: string[] = [];
if (this.flow.attributes?.values) {
const rows = JSON.parse(this.flow.attributes.values);
for (const row of rows) {
if (
row[2] === 'true' &&
(values[row[0]] === undefined || values[rows[0]] === null)
) {
missingRequired.push(row[0]);
}
}
}
return missingRequired;
}
/**
* Flow output values
*/
getReturnValues(values: Values, trusted = false): Values | undefined {
if (this.flow.attributes?.return) {
const rows = JSON.parse(this.flow.attributes.return);
const returnVals: Values = {};
for (const row of rows) {
const expr = row[1];
const val = replaceLine(expr, values, { trusted, logger: this.logger });
if (val.trim().length) {
returnVals[row[0]] = val;
}
}
return returnVals;
}
}
/**
* Run a ply flow.
*/
async run(
runtime: Runtime,
values: Values,
runOptions?: RunOptions,
runNum?: number
): Promise<Result> {
this.newInstance();
this.results = new FlowStepResults(this.name);
values[RUN_ID] = this.instance.runId || util.genId();
// flow values supersede file-based
const flowValues = this.getFlowValues(values, runOptions);
for (const flowValKey of Object.keys(flowValues)) {
values[flowValKey] = flowValues[flowValKey];
}
this.maxLoops = runtime.options.maxLoops || 10;
if (this.flow.attributes?.maxLoops) {
this.maxLoops = parseInt(this.flow.attributes.maxLoops);
}
if (this.flow.attributes?.bail === 'true') {
runtime.options.bail = true;
}
runtime.results.actual.write('');
const expectedExists = await this.requestSuite.runtime.results.expectedExists();
if (!expectedExists && runOptions?.submitIfExpectedMissing) {
runOptions.submit = true;
}
if (
runOptions?.createExpected ||
(!expectedExists && runOptions?.createExpectedIfMissing)
) {
this.logger.info(`Creating expected result: ${runtime.results.expected}`);
runtime.results.expected.write('');
runOptions.createExpected = true;
}
const startStep = this.flow.steps?.find((s) => s.path === 'start');
if (!startStep) {
throw new Error(`Cannot find start step in flow: ${this.flow.path}`);
}
const runId = this.logger.level === LogLevel.debug ? ` (${this.instance.runId})` : '';
this.logger.info(`Running flow '${this.name}'${runId}`);
this.instance.status = 'In Progress';
this.instance.values = values as flowbee.Values;
this.instance.start = new Date();
this.emit('start', 'flow', this.instance);
await this.runSubflows(this.getSubflows('Before'), runtime, values, runOptions, runNum);
if (this.results.latestBad() && runtime.options.bail) {
return this.endFlow();
}
await this.exec(startStep, runtime, values, undefined, runOptions, runNum);
if (this.results.latestBad() && runtime.options.bail) {
return this.endFlow();
}
await this.runSubflows(this.getSubflows('After'), runtime, values, runOptions, runNum);
return this.endFlow();
}
private endFlow(): Result {
this.instance.end = new Date();
if (this.instance.status === 'Errored' || this.instance.status === 'Failed') {
this.emit('error', 'flow', this.instance);
} else {
this.instance.status = 'Completed';
this.emit('finish', 'flow', this.instance);
}
return this.results.overall;
}
/**
* Executes a step within a flow and recursively executes the following step(s).
*/
async exec(
step: flowbee.Step,
runtime: Runtime,
values: Values,
incomingLinkId?: string,
runOptions?: RunOptions,
runNum?: number,
subflow?: Subflow
) {
await this.runSubflows(
this.getSubflows('Before', step),
runtime,
values,
runOptions,
runNum
);
if (this.results.latestBad() && runtime.options.bail) {
return;
}
let insts = 0;
if (this.instance.stepInstances && !runtime.options.parallel) {
insts = this.instance.stepInstances.filter((si) => si.stepId === step.id)?.length;
}
const plyStep = new PlyStep(
step,
this.requestSuite,
this.logger,
this.flow,
this.instance,
subflow?.subflow
);
if (insts) {
let maxLoops = this.maxLoops;
if (step.path === 'start') maxLoops = 1;
if (step.attributes?.maxLoops) {
maxLoops = parseInt(step.attributes.maxLoops);
}
if (isNaN(maxLoops)) this.stepError(plyStep, `Invalid maxLoops: ${maxLoops}`);
else if (insts + 1 > maxLoops) {
this.stepError(plyStep, `Max loops (${maxLoops} reached for step: ${step.id}`);
}
}
if (subflow) {
if (!subflow.instance.stepInstances) {
subflow.instance.stepInstances = [];
}
subflow.instance.stepInstances.push(plyStep.instance);
} else {
if (!this.instance.stepInstances) {
this.instance.stepInstances = [];
}
this.instance.stepInstances.push(plyStep.instance);
}
this.emit('start', 'step', plyStep.instance);
this.logger.info('Executing step', plyStep.name);
this.emit('exec', 'step', plyStep.instance);
let result: Result | undefined;
if (runtime.options.validate) {
// perform any preflight validations
if (plyStep.step.path === 'start') {
const missing = this.validateValues(values);
if (missing.length) {
plyStep.instance.status = 'Errored';
result = {
name: this.name,
status: 'Errored',
message: `Missing required value(s): ${missing.join(', ')}`
};
}
}
}
if (!result) {
result = await plyStep.run(runtime, values, runOptions, runNum, insts);
}
result.start = plyStep.instance.start?.getTime();
result.end = plyStep.instance.end?.getTime();
result.data = plyStep.instance.data;
this.results.add(plyStep.name, result);
if (runtime.options.verbose) {
this.requestSuite.logOutcome(plyStep, result, runNum, plyStep.stepName, values);
} else if ((plyStep.step.path === 'start' || plyStep.step.path === 'stop') && !subflow) {
// non-verbose only start/stop step values are logged
const { [RESULTS]: _results, [RUN_ID]: _runId, ...loggedValues } = values;
this.requestSuite.logOutcome(plyStep, result, runNum, plyStep.stepName, loggedValues);
} else {
this.requestSuite.logOutcome(plyStep, result, runNum, plyStep.stepName);
}
if (result.status === 'Waiting') {
return;
}
if (this.results.latestBad()) {
this.instance.status = plyStep.instance.status;
if (subflow) subflow.instance.status = plyStep.instance.status;
this.emit('error', 'step', plyStep.instance);
if (result.status === 'Errored' || runtime.options.bail) {
return;
}
} else {
this.emit('finish', 'step', plyStep.instance);
}
await this.runSubflows(
this.getSubflows('After', step),
runtime,
values,
runOptions,
runNum
);
if (this.results.latestBad() && runtime.options.bail) {
return;
}
const outSteps: { [linkId: string]: flowbee.Step } = {};
if (step.links) {
for (const link of step.links) {
const result = plyStep.instance.result?.trim();
if ((!result && !link.result) || result === link.result) {
let outStep: flowbee.Step | undefined;
if (subflow) {
outStep = subflow.subflow.steps?.find((s) => s.id === link.to);
} else {
outStep = this.flow.steps?.find((s) => s.id === link.to);
}
if (outStep) {
outSteps[link.id] = outStep;
} else {
this.stepError(
plyStep,
`No such step: ${link.to} (linked from ${link.id})`
);
}
}
}
}
if (Object.keys(outSteps).length === 0 && step.path !== 'stop') {
this.stepError(
plyStep,
`No outbound link from step ${step.id} matches result: ${plyStep.instance.result}`
);
}
// steps can execute in parallel
await Promise.all(
Object.keys(outSteps).map((linkId) =>
this.exec(outSteps[linkId], runtime, values, linkId, runOptions, runNum, subflow)
)
);
}
getSubflows(type: 'Before' | 'After', step?: flowbee.Step): Subflow[] {
const subflows =
this.flow.subflows?.filter((sub) => {
if (sub.attributes?.when === type) {
const steps = sub.attributes?.steps;
if (step) {
return steps ? minimatch(step.name, steps) : false;
} else {
return !steps;
}
}
}) || [];
const flowInstanceId = this.instance.id;
return subflows.map((subflow) => {
return {
subflow,
instance: {
id: Date.now().toString(16),
flowInstanceId,
subflowId: subflow.id,
runId: Date.now().toString(16),
flowPath: this.flow.path,
status: 'Pending'
}
};
});
}
async runSubflows(
subflows: Subflow[],
runtime: Runtime,
values: Values,
runOptions?: RunOptions,
runNum?: number
) {
for (const subflow of subflows) {
const startStep = subflow.subflow.steps?.find((s) => s.path === 'start');
if (!startStep) {
throw new Error(`Cannot find start step in subflow: ${subflow.subflow.id}`);
}
subflow.instance.status = 'In Progress';
subflow.instance.start = new Date();
if (!this.instance.subflowInstances) {
this.instance.subflowInstances = [];
}
this.instance.subflowInstances.push(subflow.instance);
this.emit('start', 'subflow', subflow.instance);
this.logger.info('Executing subflow', subflow.subflow.name);
const resOpts: ResultOptions = {
level: 0,
withExpected: runOptions?.createExpected,
subflow: subflow.subflow.name
};
runtime.appendResult(`${subflow.subflow.name}:`, {
...resOpts,
level: 0,
comment: util.timestamp(subflow.instance.start)
});
runtime.appendResult(`id: ${subflow.subflow.id}`, {
...resOpts,
level: 1
});
await this.exec(startStep, runtime, values, undefined, runOptions, runNum, subflow);
subflow.instance.end = new Date();
const elapsed = subflow.instance.end.getTime() - subflow.instance.start.getTime();
if (this.results.latestBad()) {
subflow.instance.status =
this.results.latest.status === 'Errored' ? 'Errored' : 'Failed';
runtime.updateResult(subflow.subflow.name, `status: ${subflow.instance.status}`, {
...resOpts,
level: 1,
comment: `${elapsed} ms`
});
this.emit('error', 'subflow', subflow.instance);
if (runtime.options.bail) {
return;
}
} else {
subflow.instance.status = 'Completed';
runtime.updateResult(subflow.subflow.name, `status: ${subflow.instance.status}`, {
...resOpts,
level: 1,
comment: `${elapsed} ms`
});
this.emit('finish', 'subflow', subflow.instance);
}
}
}
stepError(plyStep: PlyStep, message: string) {
plyStep.instance.status = 'Errored';
plyStep.instance.message = message;
this.emit('error', 'step', plyStep.instance);
this.emit('error', 'flow', this.instance);
throw new Error(message);
}
flowEvent(
eventType: flowbee.FlowEventType,
elementType: flowbee.FlowElementType,
instance: flowbee.FlowInstance | flowbee.SubflowInstance | flowbee.StepInstance
): flowbee.FlowEvent {
return {
eventType,
elementType,
flowPath: this.flow.path,
flowInstanceId: this.instance.id,
instance
};
}
private emit(
eventType: flowbee.FlowEventType,
elementType: flowbee.FlowElementType,
instance: flowbee.FlowInstance | flowbee.SubflowInstance | flowbee.StepInstance
) {
this._onFlow.emit(this.flowEvent(eventType, elementType, instance));
}
}