@ply-ct/ply
Version:
REST API Automated Testing
593 lines • 27.4 kB
JavaScript
"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