UNPKG

@ply-ct/ply

Version:

REST API Automated Testing

670 lines (634 loc) 24.4 kB
import { Values } from './values'; import * as flowbee from './flowbee'; import * as yaml from './yaml'; import { Request } from './request'; import { Response, PlyResponse } from './response'; import { Location } from './location'; import { Storage } from './storage'; import { Retrieval } from './retrieval'; import { Options, PlyOptions, RunOptions } from './options'; import { Log } from './log'; import { CodeLine, Code } from './code'; import { Compare, Diff } from './compare'; import { Yaml } from './yaml'; import * as util from './util'; import { Runs } from './runs/runs'; export type ResultStatus = 'Pending' | 'Passed' | 'Failed' | 'Errored' | 'Submitted' | 'Waiting'; export type ResultData = string | { [key: string]: any } | any[]; export interface Outcome { /** * Status of test execution */ status: ResultStatus; message?: string; data?: ResultData; /** * One-based line number of first diff, relative to starting line of test */ line?: number; /** * Diff message */ diff?: string; diffs?: Diff[]; start?: number; end?: number; } export interface Result extends Outcome { /** * Request name */ name: string; /** * Request with runtime substitutions, minus Authorization header */ request?: Request; /** * Response maybe with ignore headers removed, and formatted/sorted body content (per options). * If binary media type, response body is base64 encoded. */ response?: Response; } export interface ResultOptions { subflow?: string; level: number; comment?: string; withExpected?: boolean; } export class PlyResult implements Result { status: 'Pending' | 'Passed' | 'Failed' | 'Errored' | 'Submitted' | 'Waiting' = 'Pending'; message?: string; line = 0; diff?: string; request: Request; response: PlyResponse; graphQl?: string; constructor(readonly name: string, request: Request, response: PlyResponse) { this.request = { ...request }; this.request.headers = {}; Object.keys(request.headers).forEach((key) => { if (key !== 'Authorization') { this.request.headers[key] = request.headers[key]; } }); this.response = response; } /** * Returns the result with request/response bodies as objects (if parseable). */ getResult(options: Options): Result { return { name: this.name, status: this.status, message: this.message, request: this.request, response: this.response?.getResponse(this.response.runId, options) }; } merge(outcome: Outcome) { this.status = outcome.status; this.message = outcome.message; this.line = outcome.line || 0; this.diff = outcome.diff; } } export class Verifier { constructor( private readonly name: string, private readonly expectedYaml: Yaml, private readonly logger: Log ) {} /** * Verify expected vs actual results yaml after substituting values. * Diffs/messages always contain \n newlines. */ verify(actualYaml: Yaml, values: Values, runOptions?: RunOptions): Outcome { // this.logger.debug(`Expected:\n${this.expectedYaml}\n` + `Actual:\n${actualYaml}\n`); const expected = new Code(this.expectedYaml.text, '#'); const actual = new Code(actualYaml.text, '#'); const diffs = new Compare(this.logger).diffLines( expected.extractCode(), actual.extractCode(), values, runOptions?.trusted ); let firstDiffLine = 0; let diffMsg = ''; if (diffs) { let line = 1; let actLine = 1; for (let i = 0; i < diffs.length; i++) { const diff = diffs[i]; if (diff.removed) { const correspondingAdd = i < diffs.length - 1 && diffs[i + 1].added ? diffs[i + 1] : null; if (!diff.ignored) { if (!firstDiffLine) { firstDiffLine = line; } diffMsg += this.diffLine(line, diff.count); diffMsg += '\n'; diffMsg += this.prefix(diff.value, '- ', expected.lines, line); if (correspondingAdd) { diffMsg += this.prefix( correspondingAdd.value, '+ ', actual.lines, actLine ); } diffMsg += '===\n'; } line += diff.count; if (correspondingAdd) { i++; // corresponding add already covered actLine += correspondingAdd.count; } } else if (diff.added) { if (!diff.ignored) { // added with no corresponding remove if (!firstDiffLine) { firstDiffLine = line; } diffMsg += '-> ' + this.diffLine(actLine, diff.count); diffMsg += '\n'; diffMsg += this.prefix(diff.value, '+ ', actual.lines, actLine); diffMsg += '===\n'; } actLine += diff.count; } else { line += diff.count; actLine += diff.count; } } } if (firstDiffLine) { return { status: 'Failed', message: `Results differ from line ${this.diffLine(firstDiffLine, 1, this.name)}`, line: firstDiffLine, diff: diffMsg, diffs }; } else { return { status: 'Passed', line: 0 }; } } private diffLine(line: number, count = 1, name?: string): string { if (typeof this.expectedYaml.start === 'undefined') { return '' + line; } let dl = `${line + this.expectedYaml.start}`; if (count > 1) { dl += `-${line + this.expectedYaml.start + count - 1}`; } if (this.expectedYaml.start > 0) { dl += ' ('; if (name) { dl += name + ':'; } dl += line; if (count > 1) { dl += `-${line + count - 1}`; } dl += ')'; } return dl; } /** * Adds pre to the beginning of each line in str (except trailing newline). * Optional codeLines, start to restore comments. */ private prefix(str: string, pre: string, codeLines: CodeLine[], start: number): string { return util.lines(str).reduce((a, seg, i, arr) => { let line = i === arr.length - 1 && seg.length === 0 ? '' : pre + seg; if (line) { if (codeLines) { const codeLine = codeLines[start + i]; if (codeLine.comment) { line += codeLine.comment; } } line += '\n'; } return a + line; }, ''); } } export class ResultPaths { isFlowResult = false; private constructor( readonly expected: Retrieval, readonly actual: Storage, readonly options: Options, readonly log: Storage, readonly runs: Runs ) {} /** * excluding file extension */ private static bases( options: PlyOptions, retrieval: Retrieval, suiteName: string ): { expected: string; actual: string; log: string; runs: string } { if (suiteName.endsWith('.ply')) { suiteName = suiteName.substring(0, suiteName.length - 4); } if ( options.resultFollowsRelativePath && retrieval.location.isChildOf(options.testsLocation) ) { const relPath = retrieval.location.relativeTo(options.testsLocation); const parent = new Location(relPath).parent; // undefined if relPath is just filename const resultFilePath = parent ? parent + '/' + suiteName : suiteName; return { expected: options.expectedLocation + '/' + resultFilePath, actual: options.actualLocation + '/' + resultFilePath, log: options.logLocation + '/' + resultFilePath, runs: options.logLocation + '/runs/' + resultFilePath }; } else { // flatly use the specified paths return { expected: options.expectedLocation + '/' + suiteName, actual: options.actualLocation + '/' + suiteName, log: options.logLocation + '/' + suiteName, runs: options.logLocation + '/runs' }; } } /** * Figures out locations and file extensions for results. * Result file path relative to configured result location is the same as retrieval relative * to configured tests location. */ static async create( options: PlyOptions, retrieval: Retrieval, suiteName = retrieval.location.base ): Promise<ResultPaths> { const basePaths = this.bases(options, retrieval, suiteName); let ext = '.yaml'; if (!(await new Retrieval(basePaths.expected + ext).exists)) { if ( (await new Retrieval(basePaths.expected + '.yml').exists) || retrieval.location.ext === 'yml' ) { ext = '.yml'; } } return new ResultPaths( new Retrieval(basePaths.expected + ext), new Storage(basePaths.actual + ext), options, new Storage(basePaths.log + '.log'), new Runs(basePaths.runs) ); } /** * Figures out locations and file extensions for results. * Result file path relative to configured result location is the same as retrieval relative * to configured tests location. */ static createSync( options: PlyOptions, retrieval: Retrieval, suiteName = retrieval.location.base ): ResultPaths { const basePaths = this.bases(options, retrieval, suiteName); let ext = '.yaml'; if (!new Storage(basePaths.expected + '.yaml').exists) { if ( new Storage(basePaths.expected + '.yml').exists || retrieval.location.ext === 'yml' ) { ext = '.yml'; } } return new ResultPaths( new Retrieval(basePaths.expected + ext), new Storage(basePaths.actual + ext), options, new Storage(basePaths.log + '.log'), new Runs(basePaths.runs) ); } /** * Flow step result by id */ static extractById(yamlObj: any, id: string, instNum: number, indent = 2): any { const hyphen = id.indexOf('-'); if (hyphen > 0) { const subflowId = id.substring(0, hyphen); id = id.substring(hyphen + 1); for (const key of Object.keys(yamlObj)) { if ((!instNum || key.endsWith(`_${instNum}`)) && yamlObj[key].id === subflowId) { const subflowStart = (yamlObj[key].__start || 0) + 1; yamlObj = yaml.load(key, yaml.dump(yamlObj[key], indent), true); for (const value of Object.values(yamlObj)) { if ( typeof value === 'object' && typeof (value as any).__start === 'number' ) { (value as any).__start += subflowStart; } } break; } } } for (const key of Object.keys(yamlObj)) { if ((!instNum || key.endsWith(`_${instNum}`)) && yamlObj[key].id === id) { return yamlObj[key]; } } } /** * Newlines are always \n. */ async getExpectedYaml(name?: string, instNum = 0): Promise<Yaml> { let expected = await this.expected.read(); if (typeof expected === 'undefined') { throw new Error(`Expected result file not found: ${this.expected}`); } if (name) { let expectedObj; if (this.isFlowResult) { // name is (subflowId.)stepId expectedObj = ResultPaths.extractById( yaml.load(this.expected.toString(), expected, true), name, instNum, this.options.prettyIndent ); } else { expectedObj = yaml.load(this.expected.toString(), expected, true)[name]; } if (!expectedObj) { let label = `${this.expected}#${name}`; if (instNum > 0) label += ` (${instNum})`; throw new Error(`Expected result not found: ${label}`); } let expectedLines: string[]; if (this.isFlowResult) { // exclude step info from request expected const { __start: start, __end: _end, status: _status, result: _result, message: _message, ...rawObj } = expectedObj; const startLine = util.lines(expected)[start]; let indent = this.options.prettyIndent || 2; expected = yaml.dump(rawObj, indent); if (name.startsWith('f')) { indent += this.options.prettyIndent || 2; // extra indent for subflow } expectedLines = util .lines(expected) .map((l) => (l.trim().length > 0 ? l.padStart(l.length + indent) : l)); expectedLines.unshift(startLine); return { start, text: expectedLines.join('\n') }; } else { expectedLines = util.lines(expected); return { start: expectedObj.__start || 0, text: expectedLines.slice(expectedObj.__start, expectedObj.__end + 1).join('\n') }; } } else { return { start: 0, text: expected }; } } async expectedExists(name?: string, instNum = 0): Promise<boolean> { const expected = await this.expected.read(); if (typeof expected === 'undefined') return false; if (name) { const obj = yaml.load(this.expected.toString(), expected); if (this.isFlowResult) { return !!ResultPaths.extractById(obj, name, instNum); } else { return !!obj[name]; } } else { return true; } } /** * Newlines are always \n. Trailing \n is appended. */ getActualYaml(name?: string, instNum = 0): Yaml { const actual = this.actual.read(); if (typeof actual === 'undefined') { throw new Error(`Actual result file not found: ${this.actual}`); } if (name) { let actualObj; if (this.isFlowResult) { actualObj = ResultPaths.extractById( yaml.load(this.actual.toString(), actual, true), name, instNum, this.options.prettyIndent ); } else { actualObj = yaml.load(this.actual.toString(), actual, true)[name]; } if (!actualObj) { throw new Error(`Actual result not found: ${this.actual}#${name}`); } const actualLines = util.lines(actual); return { start: actualObj.__start || 0, text: actualLines.slice(actualObj.__start, actualObj.__end + 1).join('\n') + '\n' }; } else { return { start: 0, text: actual }; } } responsesFromActual(): { [key: string]: Response & { source: string } } { if (this.actual.exists) { return new ResponseParser(this.actual, this.options).parse(); } else { return {}; } } flowInstanceFromActual(flowPath: string): flowbee.FlowInstance | undefined { if (this.actual.exists) { return new ResultFlowParser(this.actual, this.options).parse(flowPath); } } } /** * Parses a request's response from actual results. */ export class ResponseParser { private actualYaml: string; private yamlLines: string[]; private actualObj: any; constructor(actualResult: Storage, private readonly options: Options) { const contents = actualResult.read(); if (typeof contents === 'undefined') { throw new Error(`Actual result not found: ${actualResult}`); } this.actualYaml = contents; this.yamlLines = util.lines(this.actualYaml); this.actualObj = yaml.load(actualResult.toString(), this.actualYaml, true); } parse(): { [key: string]: Response & { source: string } } { const responses: { [key: string]: Response & { source: string } } = {}; for (const requestName of Object.keys(this.actualObj)) { let resultObj = this.actualObj[requestName]; if (resultObj) { let submitted: Date | undefined; const submittedComment = util.lineComment(this.yamlLines[resultObj.__start]); if (submittedComment) { submitted = util.timeparse(submittedComment); } if (resultObj.response) { delete resultObj.status; // test status (for flows) // reparse result to get response line nums resultObj = yaml.load( requestName, yaml.dump(resultObj, this.options.prettyIndent || 2), true ); const responseObj = resultObj.response; let elapsedMs: Number | undefined; const elapsedMsComment = util.lineComment( this.yamlLines[resultObj.__start + responseObj.__start + 1] ); if (elapsedMsComment) { elapsedMs = parseInt( elapsedMsComment.substring(0, elapsedMsComment.length - 2) ); } const { __start, __end, ...response } = responseObj; response.source = `${requestName}:\n` + this.yamlLines .slice( resultObj.__start + responseObj.__start + 1, resultObj.__start + responseObj.__end ) .join('\n'); if (submitted) { response.submitted = submitted; } if (elapsedMs) { response.time = elapsedMs; } responses[requestName] = response; } } } return responses; } } /** * Parses a flow instance from actual results. */ export class ResultFlowParser { private actualYaml: string; private yamlLines: string[]; private actualObj: any; constructor(actualResult: Storage, private readonly options: Options) { const contents = actualResult.read(); if (typeof contents === 'undefined') { throw new Error(`Actual result not found: ${actualResult}`); } this.actualYaml = contents; this.yamlLines = util.lines(this.actualYaml); this.actualObj = yaml.load(actualResult.toString(), this.actualYaml, true); } parse(flowPath: string): flowbee.FlowInstance | undefined { const flowInstance: flowbee.FlowInstance = { id: '', flowPath, status: 'Pending' }; for (const key of Object.keys(this.actualObj)) { const obj = this.actualObj[key]; if (obj.id.startsWith('f')) { const subflowInstance: flowbee.SubflowInstance = { id: '', subflowId: obj.id, status: obj.status, flowInstanceId: '' }; this.parseStartEnd(subflowInstance, obj); // reparse subflow for step instance line numbers const subflowObj = yaml.load(key, yaml.dump(obj, 2), true); subflowInstance.stepInstances = this.getStepInstances(subflowObj, obj.__start + 1); if (!flowInstance.subflowInstances) flowInstance.subflowInstances = []; flowInstance.subflowInstances!.push(subflowInstance); } } flowInstance.stepInstances = this.getStepInstances(this.actualObj); flowInstance.stepInstances.forEach((stepInst) => { if (flowInstance.status === 'Pending') flowInstance.status = 'In Progress'; if (flowInstance.status === 'In Progress' && stepInst.status !== 'Completed') { flowInstance.status = stepInst.status; } }); if (flowInstance.status === 'In Progress') flowInstance.status = 'Completed'; return flowInstance; } getStepInstances(obj: any, offset = 0): flowbee.StepInstance[] { const stepInstances: flowbee.StepInstance[] = []; for (const stepKey of Object.keys(obj)) { const stepObj = obj[stepKey]; if (stepObj.id?.startsWith('s')) { const stepInstance: flowbee.StepInstance = { id: '', stepId: stepObj.id, status: stepObj.status, flowInstanceId: '' }; this.parseStartEnd(stepInstance, stepObj, offset); if (stepObj.request) { stepInstance.data = { request: yaml.dump(stepObj.request, this.options.prettyIndent || 2) }; if (stepObj.response) { stepInstance.data.response = yaml.dump( stepObj.response, this.options.prettyIndent || 2 ); } } else if (stepObj.data) { stepInstance.data = stepObj.data; } stepInstances.push(stepInstance); } } return stepInstances; } parseStartEnd( flowElementInstance: flowbee.StepInstance | flowbee.SubflowInstance, obj: any, offset = 0 ) { const startTimeComment = util.lineComment(this.yamlLines[obj.__start + offset]); if (startTimeComment) { flowElementInstance.start = util.timeparse(startTimeComment); if (flowElementInstance.start && this.yamlLines.length > obj.__end) { const elapsedMsComment = util.lineComment(this.yamlLines[obj.__end + offset]); if (elapsedMsComment) { const elapsedMs = parseInt( elapsedMsComment.substring(0, elapsedMsComment.length - 2) ); flowElementInstance.end = new Date( flowElementInstance.start.getTime() + elapsedMs ); } } } } }