UNPKG

@ply-ct/ply

Version:

REST API Automated Testing

718 lines (673 loc) 29 kB
import { Values } from './values'; import * as yaml from './yaml'; import * as util from './util'; import { TestType, Test, PlyTest } from './test'; import { Result, Outcome, Verifier, PlyResult, ResultPaths } from './result'; import { Location } from './location'; import { Storage } from './storage'; import { Log, LogLevel } from './log'; import { Runtime, DecoratedSuite, CallingCaseInfo } from './runtime'; import { RunOptions } from './options'; import { SUITE, TEST, RESULTS, RUN_ID } from './names'; import { Retrieval } from './retrieval'; import { EventEmitter } from 'events'; import { Plyee } from './ply'; import { PlyEvent, SuiteEvent, OutcomeEvent } from './event'; import { PlyResponse, ResponseMassagers } from './response'; import { TsCompileOptions } from './compile'; import StackTracey from 'stacktracey'; import { isLogger } from './logger'; export interface Tests<T extends Test> { [key: string]: T; } /** * A suite represents one ply requests file (.ply.yaml), one ply case file (.ply.ts), * or one flow file (.ply.flow); * * Suites cannot be nested. * * TODO: separate RequestSuite and CaseSuite (like FlowSuite) * instead of conditional logic in this class. */ export class Suite<T extends Test> { readonly tests: Tests<T> = {}; emitter?: EventEmitter; skip = false; callingFlowPath?: string; /** * @param name suite name * @param type request|case|flow * @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 * @param className? className for decorated suites * @param outFile? outputFile for decorated suites (absolute) */ constructor( readonly name: string, readonly type: TestType, readonly path: string, readonly runtime: Runtime, readonly logger: Log, readonly start: number = 0, readonly end: number, readonly className?: string, readonly outFile?: string ) {} add(test: T) { this.tests[test.name] = test; } get(name: string): T | undefined { return this.tests[name]; } all(): T[] { return Object.values(this.tests); } size(): number { return Object.keys(this.tests).length; } *[Symbol.iterator]() { yield* this.all()[Symbol.iterator](); } get log(): Log { return this.logger; } /** * Run one test, write actual result, and verify vs expected. * @param runNum iterating * @param instNum looping * @returns result indicating outcome */ async run( name: string, values: Values, runOptions?: RunOptions, runNum?: number, instNum?: number ): Promise<Result>; /** * Run specified tests, write actual results, and verify vs expected. * @returns result array indicating outcomes */ async run( names: string[], values: Values, runOptions?: RunOptions, runNum?: number ): Promise<Result[]>; /** * Run all tests, write actual results, and verify vs expected. * @returns result array indicating outcomes */ async run(values: Values, runOptions?: RunOptions): Promise<Result[]>; async run( namesOrValues: object | string | string[], valuesOrRunOptions?: object | RunOptions, runOptions?: RunOptions, runNum?: number, instNum?: number ): Promise<Result | Result[]> { if (typeof namesOrValues === 'string') { const name = namesOrValues; const test = this.get(name); if (!test) { throw new Error(`Test not found: ${name}`); } const results = await this.runTests( [test], valuesOrRunOptions || {}, runOptions, runNum, instNum ); return results[0]; } else if (Array.isArray(namesOrValues)) { const names = typeof namesOrValues === 'string' ? [namesOrValues] : namesOrValues; const tests = names.map((name) => { const test = this.get(name); if (!test) { throw new Error(`Test not found: ${name}`); } return test; }, this); return await this.runTests(tests, valuesOrRunOptions || {}, runOptions, runNum); } else { // run all tests return await this.runTests(this.all(), namesOrValues, valuesOrRunOptions, runNum); } } /** * Tests within a suite are always run sequentially. * @param tests */ async runTests( tests: T[], values: Values, runOptions?: RunOptions, runNum = 0, instNum = 0 ): Promise<Result[]> { if (runOptions && Object.keys(runOptions).length > 0) { this.log.debug('RunOptions', runOptions); } if (runOptions?.requireTsNode) { require('ts-node/register'); } // runtime values are a deep copy of passed values const runValues = this.callingFlowPath ? values : JSON.parse(JSON.stringify(values)); // runId unique per suite exec (already populated for flow requests) if (!runValues[RUN_ID]) { runValues[RUN_ID] = util.genId(); } this.runtime.responseMassagers = undefined; let callingCaseInfo: CallingCaseInfo | undefined; try { if (this.className) { this.emitSuiteStarted(); // running a case suite -- // initialize the decorated suite let testFile; if (runOptions?.useDist && this.outFile) { testFile = this.outFile; } else { testFile = this.runtime.testsLocation.toString() + '/' + this.path; } const mod = await import(testFile); const clsName = Object.keys(mod).find((key) => key === this.className); if (!clsName) { throw new Error(`Suite class ${this.className} not found in ${testFile}`); } const inst = new mod[clsName](); this.runtime.decoratedSuite = new DecoratedSuite(inst); this.runtime.results.actual.remove(); } else { // running a request suite if (!this.callingFlowPath) { callingCaseInfo = await this.getCallingCaseInfo(runOptions); if (callingCaseInfo) { this.runtime.results = callingCaseInfo.results; if (isLogger(this.logger)) { this.logger.storage = callingCaseInfo.results.log; } } else { this.emitSuiteStarted(); this.runtime.results.actual.remove(); } } } } catch (err: any) { // all tests are Errored this.logger.error(err.message, err); const results: Result[] = []; for (const test of tests) { const result = { name: test.name, status: 'Errored', message: '' + err.message } as Result; results.push(result); this.logOutcome(test, result, runNum); } return results; } const padActualStart = callingCaseInfo ? false : this.declaredStartsWith(tests); let expectedExists = await this.runtime.results.expected.exists; const results: Result[] = []; // within a suite, tests are run sequentially for (let i = 0; i < tests.length; i++) { const test = tests[i]; const start = Date.now(); if (test.type === 'case') { this.runtime.results.actual.append(test.name + ':\n'); } let result: Result; try { this.logger.debug(`Running ${test.type}: ${test.name}`); this.emitTest(test); // determine wanted headers (for requests) if (test.type === 'request') { this.runtime.responseMassagers = await this.getResponseMassagers( test.name, callingCaseInfo?.caseName, instNum ); } result = await (test as unknown as PlyTest).run( this.runtime, runValues, runOptions, runNum ); let actualYaml: yaml.Yaml; if (test.type === 'request') { const plyResult = result as PlyResult; let indent = callingCaseInfo ? this.runtime.options.prettyIndent : 0; let subflow: string | undefined; if (this.callingFlowPath && test.name.startsWith('f')) { subflow = test.name.substring(1); indent += this.runtime.options.prettyIndent; // subflow extra indent } actualYaml = { start: 0, text: this.buildResultYaml(plyResult, indent) }; let stepName = (test as any).stepName; if (stepName) { if (instNum) stepName += `_${instNum}`; this.runtime.updateResult(stepName, actualYaml.text.trimEnd(), { level: 0, withExpected: runOptions?.createExpected, subflow }); } else { this.runtime.results.actual.append(actualYaml.text); } if (!callingCaseInfo) { if (expectedExists && this.callingFlowPath) { // expectedExists based on specific request step expectedExists = await this.runtime.results.expectedExists( test.name, instNum ); } const isFirst = i === 0 && !this.callingFlowPath; result = this.handleResultRunOptions( test, result, actualYaml, isFirst, expectedExists, runOptions, runNum ) || result; // status could be 'Submitted' if runOptions so specify if (result.status === 'Pending') { // verify request result (otherwise wait until case/flow is complete) const expectedYaml = await this.runtime.results.getExpectedYaml( test.name, instNum ); if (expectedYaml.start > 0 || this.callingFlowPath) { // flows need to re-read actual even if not padding actualYaml = this.runtime.results.getActualYaml(test.name, instNum); this.runtime.padActualStart(test.name, instNum); } const verifier = new Verifier(test.name, expectedYaml, this.logger); this.log.debug( `Comparing ${this.runtime.results.expected.location} vs ${this.runtime.results.actual.location}` ); const outcome = { ...verifier.verify(actualYaml, runValues, runOptions), start }; result = { ...(result as Result), ...outcome }; this.logOutcome(test, result, runNum); } } this.addResult(results, result, runValues); } else { // case or flow complete -- verify result actualYaml = this.runtime.results.getActualYaml(test.name, instNum); result = this.handleResultRunOptions( test, result, actualYaml, i === 0, expectedExists, runOptions, runNum ) || result; // for cases status could be 'Submitted' if runOptions so specify (this check is handled at step level for flows) if (result.status === 'Pending' || this.type === 'flow') { const expectedYaml = await this.runtime.results.getExpectedYaml( test.name, instNum ); if (padActualStart && expectedYaml.start > actualYaml.start) { this.runtime.results.actual.padLines( actualYaml.start, expectedYaml.start - actualYaml.start ); } const verifier = new Verifier(test.name, expectedYaml, this.logger); this.log.debug( `Comparing ${this.runtime.results.expected.location} vs ${this.runtime.results.actual.location}` ); // NOTE: By using this.runtime.values we're unadvisedly taking advantage of the prototype's shared runtime object property // (https://stackoverflow.com/questions/17088635/javascript-object-properties-shared-across-instances). // This allows us to accumulate programmatic values changes like those in updateRating() in movieCrud.ply.ts // so that they can be accessed when verifying here, even though the changes are not present the passed 'values' parameter. // TODO: Revisit when implementing a comprehensive values specification mechanism. const outcome = { ...verifier.verify(actualYaml, runValues, runOptions), start }; result = { ...(result as Result), ...outcome }; this.logOutcome(test, result, runNum); } this.addResult(results, result, runValues); } } catch (err: any) { this.logger.error(err.message, err); result = { name: test.name, status: 'Errored', message: err.message, start }; if (err.request) (result as any).request = err.request; this.addResult(results, result, runValues); this.logOutcome(test, result, runNum); } if ( this.runtime.options.bail && result.status !== 'Passed' && result.status !== 'Submitted' ) { break; } } if (!callingCaseInfo && !this.callingFlowPath) { this.emitSuiteFinished(); } return results; } emitSuiteStarted() { if (this.emitter) { this.emitter.emit('suite', { plyee: this.runtime.options.testsLocation + '/' + this.path, type: this.type, status: 'Started' } as SuiteEvent); } } emitTest(test: T) { if (this.emitter) { const plyEvent: PlyEvent = { plyee: new Plyee(this.runtime.options.testsLocation + '/' + this.path, test).path }; this.emitter.emit('test', plyEvent); } } emitSuiteFinished() { if (this.emitter) { this.emitter.emit('suite', { plyee: this.runtime.options.testsLocation + '/' + this.path, type: this.type, status: 'Finished' } as SuiteEvent); } } private declaredStartsWith(tests: T[]): boolean { const names = Object.keys(this.tests); for (let i = 0; i < names.length; i++) { if (tests.length > i && names[i] !== tests[i].name) { return false; } } return true; } /** * Translates request/response bodies to objects and * adds to array. Also adds to values object for downstream access. * @param results * @param result */ private addResult(results: Result[], result: Result, runValues: Values) { let plyResult; if (result instanceof PlyResult) { plyResult = result as PlyResult; } else if (result.request && result.response instanceof PlyResponse) { plyResult = new PlyResult(result.name, result.request, result.response); plyResult.merge(result); } if (plyResult) { result = plyResult.getResult(this.runtime.options); } let resultsVal = runValues[RESULTS]; if (!resultsVal) { resultsVal = {}; runValues[RESULTS] = resultsVal; } resultsVal[result.name] = result; results.push(result); } private async getResponseMassagers( requestName: string, caseName?: string, instNum = 0 ): Promise<ResponseMassagers | undefined> { if (await this.runtime.results.expectedExists(caseName ? caseName : requestName, instNum)) { const yml = await this.runtime.results.getExpectedYaml( caseName ? caseName : requestName ); let obj = yaml.load(this.runtime.results.expected.toString(), yml.text); if (obj) { if (this.callingFlowPath) { obj = Object.values(obj)[0]; } else { obj = obj[caseName ? caseName : requestName]; if (caseName) { obj = obj[requestName]; } } } if (obj) { const response = obj?.response; if (response) { const massagers: ResponseMassagers = {}; massagers.headers = response.headers ? Object.keys(response.headers) : []; if (response.body && util.isJson(response.body)) { for (const meta of yaml.metas(yml.text)) { if (meta.startsWith('sort(') && meta.endsWith(')')) { const comma = meta.indexOf(','); if (comma > 0 && comma < meta.length - 1) { let expr = meta.substring(5, comma).trim(); if (expr.startsWith('`') && expr.endsWith('`')) { expr = expr.substring(1, expr.length - 1); } if (expr.startsWith('${') && expr.endsWith('}')) { let value = meta .substring(comma + 1, meta.length - 1) .trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.substring(1, value.length - 1); } if (expr && value) { massagers.arraySorts = { ...(massagers.arraySorts || {}), [expr]: value }; } } } } } } return massagers; } } } } private handleResultRunOptions( test: Test, result: Result, actualYaml: yaml.Yaml, isFirst: boolean, expectedExists: boolean, runOptions?: RunOptions, runNum?: number ): Result | undefined { if (runOptions?.submit || (!expectedExists && runOptions?.submitIfExpectedMissing)) { const res = { name: test.name, status: 'Submitted', request: result.request, response: result.response } as Result; this.logOutcome(test, res, runNum); return res; } if ( runOptions?.createExpected || (!expectedExists && runOptions?.createExpectedIfMissing) ) { if (this.runtime.results.expected.location.isUrl) { throw new Error('Run option createExpected not supported for remote results'); } runOptions.createExpected = true; // remember for downstream tests // remove comments const actualYamlText = util .lines(actualYaml.text) .reduce((yamlLines: string[], line) => { const lastHash = line.lastIndexOf('#'); yamlLines.push( lastHash === -1 ? line : line.substring(0, lastHash - 1).trimRight() ); return yamlLines; }, []) .join('\n'); const stepName = (test as any).stepName; if (!stepName) { // not in a flow: don't mess with previous behavior const expected = new Storage(this.runtime.results.expected.location.toString()); if (isFirst) { this.log.info(`Creating expected result: ${expected}`); expected.write(actualYamlText); } else { expected.append(actualYamlText); } } } } logOutcome(test: Test, outcome: Outcome, runNum?: number, label?: string, values?: Values) { outcome.end = Date.now(); const ms = outcome.start ? ` in ${outcome.end - outcome.start} ms` : ''; const testLabel = label || test.type.charAt(0).toLocaleUpperCase() + test.type.substring(1); const id = this.logger.level === LogLevel.debug && (test as any).id ? ` (${(test as any).id})` : ''; let message: string = ''; if (outcome.status === 'Passed') { message = `${testLabel} '${test.name}'${id} PASSED${ms}`; this.logger.info(message); } else if (outcome.status === 'Failed') { const diff = outcome.diff ? '\n' + outcome.diff : ''; message = `${testLabel} '${test.name}'${id} FAILED${ms}: ${outcome.message}${diff}`; this.logger.error(message); } else if (outcome.status === 'Errored') { message = `${testLabel} '${test.name}'${id} ERRORED${ms}: ${outcome.message}`; this.logger.error(message); } else if (outcome.status === 'Submitted') { message = `${testLabel} '${test.name}'${id} SUBMITTED${ms}`; this.logger.info(message); } if (this.type !== 'flow') { // flows are logged through their requestSuites this.runtime.results.runs.writeRun(this.path, test, outcome, message, values, runNum); } if (this.emitter) { this.emitter.emit('outcome', { plyee: new Plyee(this.runtime.options.testsLocation + '/' + this.path, test).path, outcome } as OutcomeEvent); } } /** * Use stack trace to find calling case info (if any) for request. */ private async getCallingCaseInfo( runOptions?: RunOptions ): Promise<CallingCaseInfo | undefined> { const stack = new StackTracey(); const items = stack.items.filter((item) => !item.file.startsWith('node:internal/')); const plyCaseInvoke = items.findIndex((item) => { return item.callee === 'PlyCase.run' || item.callee === 'async PlyCase.run'; }); if (plyCaseInvoke > 0) { const element = items[plyCaseInvoke - 1]; const dot = element.callee.indexOf('.'); if (dot > 0 && dot < element.callee.length - 1) { let clsName = element.callee.substring(0, dot); if (clsName.startsWith('async ')) { clsName = clsName.substring(6); } const mod = await import(element.file); const cls = mod[clsName]; const suiteName = cls[SUITE].name; const mthName = element.callee.substring(dot + 1); const mth = cls.prototype[mthName]; const caseName = mth[TEST].name; let source = element.file; if (runOptions?.useDist || !source) { // Note: this doesn't work with ts compiler option outFile (relies on outDir) const outDir = new TsCompileOptions(this.runtime.options).outDir; const relLoc = new Location(new Location(element.file).relativeTo(outDir)); source = relLoc.parent + '/' + relLoc.base + '.ts'; } const results = await ResultPaths.create( this.runtime.options, new Retrieval(source), suiteName ); return { results, suiteName, caseName }; } } } /** * Always contains \n newlines. Includes trailing newline. */ private buildResultYaml(result: PlyResult, indent: number): string { const { name: _name, type: _type, submitted: _submitted, ...leanRequest } = result.request; if (result.graphQl) { leanRequest.body = result.graphQl; // restore graphQl for better comparisons } const { runId: _requestId, time: _time, ...leanResponse } = result.response; let invocationObject = { [result.name]: { request: leanRequest, response: leanResponse } }; let yml = yaml.dump(invocationObject, this.runtime.options.prettyIndent); // parse for line numbers const baseName = this.runtime.results.actual.location.base; invocationObject = yaml.load(baseName, yml, true); let ymlLines = yml.split('\n'); if (indent) { ymlLines = ymlLines.map((line, i) => { if (i < ymlLines.length - 1) { return line.padStart(line.length + indent); } else { return line; } }); } const invocation = invocationObject[result.name] as any; if (typeof invocation.__start !== 'undefined') { const outcomeLine = invocation.__start; if (result.request.submitted) { ymlLines[outcomeLine] += ` # ${util.timestamp(result.request.submitted)}`; } if (typeof result.response.time !== 'undefined') { const responseMs = result.response.time + ' ms'; const requestYml = yaml.dump( { request: invocation.request }, this.runtime.options.prettyIndent ); ymlLines[outcomeLine + requestYml.split('\n').length] += ` # ${responseMs}`; } } if (this.callingFlowPath) { // name already appended with step output ymlLines.shift(); } yml = ymlLines.join('\n'); return yml; } }