UNPKG

@ply-ct/ply

Version:

REST API Automated Testing

593 lines 27.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Suite = void 0; const yaml = __importStar(require("./yaml")); const util = __importStar(require("./util")); const result_1 = require("./result"); const location_1 = require("./location"); const storage_1 = require("./storage"); const log_1 = require("./log"); const runtime_1 = require("./runtime"); const names_1 = require("./names"); const retrieval_1 = require("./retrieval"); const ply_1 = require("./ply"); const response_1 = require("./response"); const compile_1 = require("./compile"); const stacktracey_1 = __importDefault(require("stacktracey")); const logger_1 = require("./logger"); /** * 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. */ class Suite { /** * @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(name, type, path, runtime, logger, start = 0, end, className, outFile) { this.name = name; this.type = type; this.path = path; this.runtime = runtime; this.logger = logger; this.start = start; this.end = end; this.className = className; this.outFile = outFile; this.tests = {}; this.skip = false; } add(test) { this.tests[test.name] = test; } get(name) { return this.tests[name]; } all() { return Object.values(this.tests); } size() { return Object.keys(this.tests).length; } *[Symbol.iterator]() { yield* this.all()[Symbol.iterator](); } get log() { return this.logger; } async run(namesOrValues, valuesOrRunOptions, runOptions, runNum, instNum) { 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, values, runOptions, runNum = 0, instNum = 0) { if (runOptions && Object.keys(runOptions).length > 0) { this.log.debug('RunOptions', runOptions); } if (runOptions === null || runOptions === void 0 ? void 0 : 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[names_1.RUN_ID]) { runValues[names_1.RUN_ID] = util.genId(); } this.runtime.responseMassagers = undefined; let callingCaseInfo; try { if (this.className) { this.emitSuiteStarted(); // running a case suite -- // initialize the decorated suite let testFile; if ((runOptions === null || runOptions === void 0 ? void 0 : runOptions.useDist) && this.outFile) { testFile = this.outFile; } else { testFile = this.runtime.testsLocation.toString() + '/' + this.path; } const mod = await Promise.resolve().then(() => __importStar(require(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 runtime_1.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 ((0, logger_1.isLogger)(this.logger)) { this.logger.storage = callingCaseInfo.results.log; } } else { this.emitSuiteStarted(); this.runtime.results.actual.remove(); } } } } catch (err) { // all tests are Errored this.logger.error(err.message, err); const results = []; for (const test of tests) { const result = { name: test.name, status: 'Errored', message: '' + err.message }; 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 = []; // 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; 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 === null || callingCaseInfo === void 0 ? void 0 : callingCaseInfo.caseName, instNum); } result = await test.run(this.runtime, runValues, runOptions, runNum); let actualYaml; if (test.type === 'request') { const plyResult = result; let indent = callingCaseInfo ? this.runtime.options.prettyIndent : 0; let subflow; 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.stepName; if (stepName) { if (instNum) stepName += `_${instNum}`; this.runtime.updateResult(stepName, actualYaml.text.trimEnd(), { level: 0, withExpected: runOptions === null || runOptions === void 0 ? void 0 : 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 result_1.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, ...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 result_1.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, ...outcome }; this.logOutcome(test, result, runNum); } this.addResult(results, result, runValues); } } catch (err) { this.logger.error(err.message, err); result = { name: test.name, status: 'Errored', message: err.message, start }; if (err.request) result.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' }); } } emitTest(test) { if (this.emitter) { const plyEvent = { plyee: new ply_1.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' }); } } declaredStartsWith(tests) { 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 */ addResult(results, result, runValues) { let plyResult; if (result instanceof result_1.PlyResult) { plyResult = result; } else if (result.request && result.response instanceof response_1.PlyResponse) { plyResult = new result_1.PlyResult(result.name, result.request, result.response); plyResult.merge(result); } if (plyResult) { result = plyResult.getResult(this.runtime.options); } let resultsVal = runValues[names_1.RESULTS]; if (!resultsVal) { resultsVal = {}; runValues[names_1.RESULTS] = resultsVal; } resultsVal[result.name] = result; results.push(result); } async getResponseMassagers(requestName, caseName, instNum = 0) { 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 === null || obj === void 0 ? void 0 : obj.response; if (response) { const massagers = {}; 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; } } } } handleResultRunOptions(test, result, actualYaml, isFirst, expectedExists, runOptions, runNum) { if ((runOptions === null || runOptions === void 0 ? void 0 : runOptions.submit) || (!expectedExists && (runOptions === null || runOptions === void 0 ? void 0 : runOptions.submitIfExpectedMissing))) { const res = { name: test.name, status: 'Submitted', request: result.request, response: result.response }; this.logOutcome(test, res, runNum); return res; } if ((runOptions === null || runOptions === void 0 ? void 0 : runOptions.createExpected) || (!expectedExists && (runOptions === null || runOptions === void 0 ? void 0 : 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, line) => { const lastHash = line.lastIndexOf('#'); yamlLines.push(lastHash === -1 ? line : line.substring(0, lastHash - 1).trimRight()); return yamlLines; }, []) .join('\n'); const stepName = test.stepName; if (!stepName) { // not in a flow: don't mess with previous behavior const expected = new storage_1.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, outcome, runNum, label, 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 === log_1.LogLevel.debug && test.id ? ` (${test.id})` : ''; let message = ''; 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 ply_1.Plyee(this.runtime.options.testsLocation + '/' + this.path, test).path, outcome }); } } /** * Use stack trace to find calling case info (if any) for request. */ async getCallingCaseInfo(runOptions) { const stack = new stacktracey_1.default(); 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 Promise.resolve().then(() => __importStar(require(element.file))); const cls = mod[clsName]; const suiteName = cls[names_1.SUITE].name; const mthName = element.callee.substring(dot + 1); const mth = cls.prototype[mthName]; const caseName = mth[names_1.TEST].name; let source = element.file; if ((runOptions === null || runOptions === void 0 ? void 0 : runOptions.useDist) || !source) { // Note: this doesn't work with ts compiler option outFile (relies on outDir) const outDir = new compile_1.TsCompileOptions(this.runtime.options).outDir; const relLoc = new location_1.Location(new location_1.Location(element.file).relativeTo(outDir)); source = relLoc.parent + '/' + relLoc.base + '.ts'; } const results = await result_1.ResultPaths.create(this.runtime.options, new retrieval_1.Retrieval(source), suiteName); return { results, suiteName, caseName }; } } } /** * Always contains \n newlines. Includes trailing newline. */ buildResultYaml(result, indent) { 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]; 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; } } exports.Suite = Suite; //# sourceMappingURL=suite.js.map