UNPKG

@ply-ct/ply

Version:

REST API Automated Testing

443 lines (401 loc) 16 kB
import { Values } from './values'; import * as flowbee from './flowbee'; import { FlowResult, PlyFlow } from './flow'; import { Log, LogLevel } from './log'; import { Logger } from './logger'; import { PlyOptions, RunOptions } from './options'; import { Retrieval } from './retrieval'; import { Runtime } from './runtime'; import { Result, ResultPaths } from './result'; import { Suite } from './suite'; import { PlyStep, Step } from './step'; import { Request } from './request'; import { Skip } from './skip'; import * as util from './util'; import * as yaml from './yaml'; import { Plyee } from './ply'; /** * Suite representing a ply flow. */ export class FlowSuite extends Suite<Step> { /** * @param plyFlow PlyFlow * @param path relative path from tests location (forward slashes) * @param runtime info * @param logger * @param start zero-based start line * @param end zero-based end line */ constructor( public plyFlow: PlyFlow, readonly path: string, readonly runtime: Runtime, readonly logger: Log, readonly start: number = 0, readonly end: number ) { super(plyFlow.name, 'flow', path, runtime, logger, start, end); } /** * Override to execute flow itself if all steps are specified * @param steps */ async runTests( steps: Step[], values: Values, runOptions?: RunOptions, runNum?: number ): Promise<Result[]> { if (runOptions && Object.keys(runOptions).length > 0) { this.log.debug('RunOptions', runOptions); } if (runOptions?.requireTsNode) { require('ts-node/register'); } this.emitSuiteStarted(); // runtime values are a deep copy of passed values const runValues = JSON.parse(JSON.stringify(values)); this.runtime.responseMassagers = undefined; let results: Result[]; if (this.isFlowSpec(steps)) { results = [await this.runFlow(runValues, runOptions, runNum)]; } else { results = await this.runSteps(steps, runValues, runOptions); } this.emitSuiteFinished(); return results; } private getStep(stepId: string): Step { const step = this.all().find((step) => step.step.id === stepId); if (!step) throw new Error(`Step not found: ${stepId}`); return step; } async runFlow(values: Values, runOptions?: RunOptions, runNum?: number): Promise<FlowResult> { if (this.runtime.options?.parallel) { this.plyFlow = this.plyFlow.clone(); } this.plyFlow.onFlow((flowEvent) => { if (flowEvent.eventType === 'exec') { // emit test event (not for request -- emitted in requestSuite) const stepInstance = flowEvent.instance as flowbee.StepInstance; const step = this.getStep(stepInstance.stepId); if (step.step.path !== 'request') { this.emitTest(step); } } else { // exec not applicable for ply subscribers this.emitter?.emit('flow', flowEvent); if ( flowEvent.elementType === 'step' && (flowEvent.eventType === 'finish' || flowEvent.eventType === 'error') ) { const stepInstance = flowEvent.instance as flowbee.StepInstance; const step = this.getStep(stepInstance.stepId); if (step.step.path !== 'request') { if (flowEvent.eventType === 'error') { this.emitter?.emit('outcome', { plyee: new Plyee( this.runtime.options.testsLocation + '/' + this.path, step ).path, outcome: { status: 'Errored', message: stepInstance.message || '' } }); } else if (flowEvent.eventType === 'finish') { this.emitter?.emit('outcome', { plyee: new Plyee( this.runtime.options.testsLocation + '/' + this.path, step ).path, outcome: { status: runOptions?.submit ? 'Submitted' : 'Passed', message: '' } }); } } } } }); this.plyFlow.requestSuite.emitter = this.emitter; const start = Date.now(); const res = await this.plyFlow.run(this.runtime, values, runOptions, runNum); const flowResult: FlowResult = { flow: this.path, ...res, start, end: Date.now() }; if (this.plyFlow.flow.attributes?.return) { // don't evaluate result values if not used const retVals = this.plyFlow.getReturnValues( { ...values, ...this.plyFlow.results.values }, runOptions?.trusted ); if (retVals) { this.log.debug(`${this.name} return values`, retVals); flowResult.return = retVals; } } return flowResult; } /** * Run steps independently */ async runSteps(steps: Step[], values: Values, runOptions?: RunOptions): Promise<Result[]> { // flow values supersede file-based const flowValues = this.plyFlow.getFlowValues(values, runOptions); for (const flowValKey of Object.keys(flowValues)) { values[flowValKey] = flowValues[flowValKey]; } const requestSuite = new Suite<Request>( this.plyFlow.name, 'request', this.path, this.runtime, this.logger, 0, 0 ); requestSuite.callingFlowPath = this.plyFlow.flow.path; requestSuite.emitter = this.emitter; this.runtime.results.actual.clear(); const expectedExists = await this.runtime.results.expectedExists(); if (!expectedExists && runOptions?.submitIfExpectedMissing) { runOptions.submit = true; } if ( runOptions?.createExpected || (!expectedExists && runOptions?.createExpectedIfMissing) ) { this.logger.info(`Creating expected result: ${this.runtime.results.expected}`); this.runtime.results.expected.write(''); runOptions.createExpected = true; } const results: Result[] = []; // emit start event for synthetic flow this.emitter?.emit('flow', this.plyFlow.flowEvent('start', 'flow', this.plyFlow.instance)); this.plyFlow.instance.stepInstances = []; for (const step of steps) { this.emitTest(step); let subflow: flowbee.Subflow | undefined; const dot = step.name.indexOf('.'); if (dot > 0) { const subflowId = step.name.substring(0, dot); subflow = this.plyFlow.flow.subflows?.find((sub) => sub.id === subflowId); } const insts = this.plyFlow.instance.stepInstances?.filter((stepInst) => { return stepInst.stepId === step.step.id; }).length; const plyStep = new PlyStep( step.step, requestSuite, this.logger, this.plyFlow.flow, this.plyFlow.instance, subflow ); if (insts) { let maxLoops = this.runtime.options.maxLoops || 10; if (this.plyFlow.flow.attributes?.maxLoops) { maxLoops = parseInt(this.plyFlow.flow.attributes.maxLoops); } if (step.step.attributes?.maxLoops) { maxLoops = parseInt(step.step.attributes.maxLoops); } if (isNaN(maxLoops)) { this.plyFlow.stepError(plyStep, `Invalid maxLoops: ${maxLoops}`); } else if (insts + 1 > maxLoops) { this.plyFlow.stepError( plyStep, `Max loops (${maxLoops} reached for step: ${step.step.id}` ); } } this.emitter?.emit('flow', this.plyFlow.flowEvent('start', 'step', plyStep.instance)); const result = await plyStep.run(this.runtime, values, runOptions, 0, insts); if (result.status === 'Failed' || result.status === 'Errored') { this.emitter?.emit( 'flow', this.plyFlow.flowEvent('error', 'step', plyStep.instance) ); } else { this.emitter?.emit( 'flow', this.plyFlow.flowEvent('finish', 'step', plyStep.instance) ); } if (step.step.path !== 'request') { super.logOutcome( step, { status: result.status, message: result.message, start: plyStep.instance.start?.getTime(), diffs: result.diffs }, 0, 'Step' ); } results.push(result); if (plyStep.instance) this.plyFlow.instance.stepInstances.push(plyStep.instance); } // stop event for synthetic flow const evt = results.reduce( (overall, res) => res.status === 'Failed' || res.status === 'Errored' ? 'error' : overall, 'finish' ); this.emitter?.emit( 'flow', this.plyFlow.flowEvent(evt as flowbee.FlowEventType, 'flow', this.plyFlow.instance) ); return results; } /** * True if steps array is identical to flow steps. */ private isFlowSpec(steps: Step[]): boolean { if (steps.length !== this.size()) { return false; } const flowStepNames = Object.keys(this.tests); for (let i = 0; i < steps.length; i++) { if (steps[i].name !== flowStepNames[i]) { return false; } } return true; } /** * Returns all reachable unique steps in this.plyFlow. * These are the tests in this FlowSuite. */ getSteps(): Step[] { const steps: { step: flowbee.Step; subflow?: flowbee.Subflow }[] = []; const addSteps = (startStep: flowbee.Step, subflow?: flowbee.Subflow) => { const already = steps.find((step) => step.step.id === startStep.id); if (!already) { steps.push({ step: startStep, subflow }); if (startStep.links) { for (const link of startStep.links) { let outStep: flowbee.Step | undefined; if (subflow) { outStep = subflow.steps?.find((s) => s.id === link.to); } else { outStep = this.plyFlow.flow.steps?.find((s) => s.id === link.to); } if (outStep) { addSteps(outStep, subflow); } } } } }; this.plyFlow.flow.subflows ?.filter((sub) => sub.attributes?.when === 'Before') ?.forEach((before) => { before.steps?.forEach((step) => addSteps(step, before)); }); this.plyFlow.flow.steps?.forEach((step) => addSteps(step)); this.plyFlow.flow.subflows ?.filter((sub) => sub.attributes?.when === 'After') ?.forEach((after) => { after.steps?.forEach((step) => addSteps(step, after)); }); return steps.map((step) => { return { name: step.subflow ? `${step.subflow.id}-${step.step.id}` : step.step.id, type: 'flow', step: step.step, ...(step.subflow && { subflow: step.subflow }) }; }); } } export class FlowLoader { private skip: Skip | undefined; constructor(readonly locations: string[], private options: PlyOptions, private logger?: Log) { if (options.skip) { this.skip = new Skip(options.skip); } } async load(): Promise<FlowSuite[]> { const retrievals = this.locations.map((loc) => new Retrieval(loc)); // load flow files in parallel const promises = retrievals.map((retr) => this.loadSuite(retr)); const suites = await Promise.all(promises); suites.sort((s1, s2) => s1.name.localeCompare(s2.name)); return suites; } async loadSuite(retrieval: Retrieval): Promise<FlowSuite> { const contents = await retrieval.read(); if (typeof contents === 'undefined') { throw new Error('Cannot retrieve: ' + retrieval.location.absolute); } const resultPaths = await ResultPaths.create(this.options, retrieval); resultPaths.isFlowResult = true; return this.buildSuite(retrieval, contents, resultPaths); } buildSuite(retrieval: Retrieval, contents: string, resultPaths: ResultPaths): FlowSuite { const runtime = new Runtime(this.options, retrieval, resultPaths); const logger = this.logger || new Logger( { level: this.options.verbose ? LogLevel.debug : this.options.quiet ? LogLevel.error : LogLevel.info, prettyIndent: this.options.prettyIndent }, runtime.results.log ); // request suite comprising all requests configured in steps const requestSuite = new Suite<Request>( retrieval.location.base, 'request', retrieval.location.relativeTo(this.options.testsLocation), runtime, logger, 0, 0 ); const flowbeeFlow = FlowLoader.parse(contents, retrieval.location.path); requestSuite.callingFlowPath = flowbeeFlow.path; const plyFlow = new PlyFlow(flowbeeFlow, requestSuite, logger); const suite = new FlowSuite( plyFlow, retrieval.location.relativeTo(this.options.testsLocation), runtime, logger, 0, util.lines(contents).length - 1 ); for (const step of suite.getSteps()) { suite.add(step); } // mark if skipped if (this.skip?.isSkipped(suite.path)) { suite.skip = true; } return suite; } /** * Parse a flowbee flow from text (reproduced from flowbee.FlowDiagram) * @param text json or yaml * @param file file name */ static parse(text: string, file: string): flowbee.Flow { let flow: flowbee.Flow; if (text.startsWith('{')) { try { flow = JSON.parse(text); } catch (err: unknown) { throw new Error(`Failed to parse ${file}: ${err}`); } } else { flow = yaml.load(file, text); } if (!flow) throw new Error(`Unable to load from empty: ${file}`); flow.type = 'flow'; flow.path = file.replace(/\\/g, '/'); return flow; } }