@ply-ct/ply
Version:
REST API Automated Testing
522 lines (472 loc) • 18.8 kB
text/typescript
import * as path from 'path';
import { rimraf } from 'rimraf';
import { EventEmitter } from 'events';
import { Options, Config, PlyOptions, RunOptions, Defaults } from './options';
import { Suite } from './suite';
import { Test } from './test';
import { Request } from './request';
import { Case } from './case';
import { CaseLoader } from './cases';
import { RequestLoader } from './requests';
import { Result } from './result';
import { TsCompileOptions } from './compile';
import { Log, LogLevel } from './log';
import { Logger } from './logger';
import * as util from './util';
import { FlowLoader, FlowSuite } from './flows';
import { ValuesBuilder } from './values';
import { PlyRunner } from './runner';
import { ReporterFactory } from './report/report';
import { OverallResults } from './runs/model';
export class Ply {
readonly options: PlyOptions;
constructor(options?: Options, private logger?: Log) {
this.options = Object.assign({}, new Defaults(), options || new Config().options);
}
/**
* Load request from .ply file
*/
async loadRequest(location: string): Promise<Request> {
const suite = await this.loadRequestSuite(location);
if (suite.size() === 0) throw new Error(`No request found in: ${suite.name}`);
return suite.all()[0];
}
loadRequestSync(location: string): Request {
const suite = this.loadRequestSuiteSync(location);
if (suite.size() === 0) throw new Error(`No request found in: ${suite.name}`);
return suite.all()[0];
}
/**
* Load request suites.
* @param locations can be URLs or file paths
*/
async loadRequests(location: string): Promise<Suite<Request>[]>;
async loadRequests(...locations: string[]): Promise<Suite<Request>[]>;
async loadRequests(locations: string[], ...moreLocations: string[]): Promise<Suite<Request>[]>;
async loadRequests(
locations: string | string[],
...moreLocations: string[]
): Promise<Suite<Request>[]> {
if (typeof locations === 'string') {
locations = [locations];
}
if (moreLocations) {
locations = [...locations, ...moreLocations];
}
const requestLoader = new RequestLoader(locations, this.options, this.logger);
return await requestLoader.load();
}
/**
* Throws if location or suite not found
*/
async loadRequestSuite(location: string): Promise<Suite<Request>> {
const requestSuites = await this.loadRequests([location]);
if (requestSuites.length === 0) {
throw new Error(`No request suite found in: ${location}`);
}
return requestSuites[0];
}
/**
* Load request suites.
* @param locations can be URLs or file paths
*/
loadRequestsSync(location: string): Suite<Request>[];
loadRequestsSync(...locations: string[]): Suite<Request>[];
loadRequestsSync(locations: string[], ...moreLocations: string[]): Suite<Request>[];
loadRequestsSync(locations: string | string[], ...moreLocations: string[]): Suite<Request>[] {
if (typeof locations === 'string') {
locations = [locations];
}
if (moreLocations) {
locations = [...locations, ...moreLocations];
}
const requestLoader = new RequestLoader(locations, this.options, this.logger);
return requestLoader.sync();
}
/**
* Throws if location or suite not found
*/
loadRequestSuiteSync(location: string): Suite<Request> {
const requestSuites = this.loadRequestsSync([location]);
if (requestSuites.length === 0) {
throw new Error(`No request suite found in: ${location}`);
}
return requestSuites[0];
}
async loadSuite(location: string): Promise<Suite<Request>> {
return await this.loadRequestSuite(location);
}
loadSuiteSync(location: string): Suite<Request> {
return this.loadRequestSuiteSync(location);
}
/**
* Throws if location or suite not found
*/
async loadCaseSuites(location: string): Promise<Suite<Case>[]> {
const caseSuites = await this.loadCases([location]);
if (caseSuites.length === 0) {
throw new Error(`No case suite found in: ${location}`);
}
return caseSuites;
}
async loadCases(file: string): Promise<Suite<Case>[]>;
async loadCases(...files: string[]): Promise<Suite<Case>[]>;
async loadCases(files: string[], ...moreFiles: string[]): Promise<Suite<Case>[]>;
async loadCases(files: string | string[], ...moreFiles: string[]): Promise<Suite<Case>[]> {
if (typeof files === 'string') {
files = [files];
}
if (moreFiles) {
files = [...files, ...moreFiles];
}
const compileOptions = new TsCompileOptions(this.options);
const caseLoader = new CaseLoader(files, this.options, compileOptions, this.logger);
const suites = await caseLoader.load();
return suites;
}
/**
* Throws if location or suite not found
*/
async loadFlowSuites(location: string): Promise<FlowSuite[]> {
const flowSuites = await this.loadFlows([location]);
if (flowSuites.length === 0) {
throw new Error(`No flow suite found in: ${location}`);
}
return flowSuites;
}
async loadFlow(file: string): Promise<FlowSuite> {
const flows = await this.loadFlows(file);
if (flows.length === 0) throw new Error(`No flow found in: ${file}`);
return flows[0];
}
async loadFlows(file: string): Promise<FlowSuite[]>;
async loadFlows(...files: string[]): Promise<FlowSuite[]>;
async loadFlows(files: string[], ...moreFiles: string[]): Promise<FlowSuite[]>;
async loadFlows(files: string | string[], ...moreFiles: string[]): Promise<FlowSuite[]> {
if (typeof files === 'string') {
files = [files];
}
if (moreFiles) {
files = [...files, ...moreFiles];
}
const flowLoader = new FlowLoader(files, this.options, this.logger);
const suites = await flowLoader.load();
return suites;
}
}
/**
* A Plyee is a test (request/case), or a suite.
*
* Format: <absolute_suite_file_forward_slashes>#<optional_case_suite>^<test_name>
* eg: c:/ply/ply/test/ply/requests/movie-queries.ply.yaml#moviesByYearAndRating
* or: /Users/me/ply/ply/test/ply/cases/movieCrud.ply.ts#movie-crud^add new movie
* or for a suite: /Users/me/ply/ply/test/ply/requests/movie-queries.ply.yaml
* (TODO: handle caseFile#suite^case)
*/
export class Plyee {
readonly path: string;
private hash: number;
private hat: number;
constructor(suite: string, test: Test);
constructor(path: string);
constructor(pathOrSuite: string, test?: Test) {
if (test) {
this.path =
util.fwdSlashes(path.normalize(path.resolve(pathOrSuite))) + `#${test.name}`;
} else {
const hash = pathOrSuite.indexOf('#');
if (hash === 0 || hash > pathOrSuite.length - 2) {
throw new Error(`Invalid path: ${pathOrSuite}`);
}
if (hash === -1 && pathOrSuite.endsWith('.ply')) {
// path and test will be the same
this.path = util.fwdSlashes(path.normalize(path.resolve(pathOrSuite)));
} else {
const base = pathOrSuite.substring(0, hash);
const frag = pathOrSuite.substring(hash + 1);
this.path = util.fwdSlashes(path.normalize(path.resolve(base))) + `#${frag}`;
}
}
this.hash = this.path.indexOf('#');
this.hat = this.path.lastIndexOf('^');
if (this.hat < this.hash || this.hat < this.path.length - 1) {
this.hat = -1;
}
}
get location(): string {
if (this.hash > 0) {
return this.path.substring(0, this.hash);
} else {
return this.path;
}
}
get suite(): string {
if (this.hat > 0) {
return this.path.substring(this.hash + 1, this.hat);
} else {
return this.location;
}
}
get test(): string | undefined {
if (this.hash > 0) {
if (this.hat > 0) {
return this.path.substring(this.hat + 1);
} else {
return this.path.substring(this.hash + 1);
}
} else if (this.path.endsWith('.ply')) {
return this.path;
}
}
toString(): string {
return this.path;
}
static isRequest(path: string): boolean {
return path.endsWith('.ply') || path.endsWith('.yml') || path.endsWith('.yaml');
}
static isCase(path: string): boolean {
return path.endsWith('.ts');
}
static isFlow(path: string): boolean {
return path.endsWith('.flow');
}
/**
* Maps plyee paths to Plyee by Suite.
*/
static requests(paths: string[]): Map<string, Plyee[]> {
return this.collect(paths, (plyee) => Plyee.isRequest(plyee.location));
}
static cases(paths: string[]): Map<string, Plyee[]> {
return this.collect(paths, (plyee) => Plyee.isCase(plyee.location));
}
static flows(paths: string[]): Map<string, Plyee[]> {
return this.collect(paths, (plyee) => Plyee.isFlow(plyee.location));
}
/**
* Returns a map of unique suite location to Plyee[]
* @param paths test paths
* @param test (optional) for matching
*/
static collect(paths: string[], test?: (plyee: Plyee) => boolean): Map<string, Plyee[]> {
const map = new Map<string, Plyee[]>();
for (const path of paths) {
const plyee = new Plyee(path);
if (!test || test(plyee)) {
let plyees = map.get(plyee.location);
if (!plyees) {
plyees = [];
map.set(plyee.location, plyees);
}
plyees.push(plyee);
}
}
return map;
}
}
/**
* Utility for executing multiple tests, organized into their respective suites.
* Used by both CLI and vscode-ply.
*/
export class Plier extends EventEmitter {
private readonly ply: Ply;
/**
* general purpose logger not associated with suite (goes to console)
*/
readonly logger: Log;
get options() {
return this.ply.options;
}
constructor(options?: Options, logger?: Log) {
// @ts-ignore node 12 takes no params
super({ captureRejections: true });
const opts = Object.assign({}, new Defaults(), options || new Config().options);
this.logger =
logger ||
new Logger({
level: opts.verbose ? LogLevel.debug : opts.quiet ? LogLevel.error : LogLevel.info,
prettyIndent: opts.prettyIndent
});
this.ply = new Ply(options, this.logger);
}
/**
* Plyees should be test paths (not suites).
*/
async run(
plyees: string[],
runOptions?: RunOptions,
plyVersion?: string
): Promise<OverallResults> {
const version = plyVersion || (await util.plyVersion());
if (version) this.logger.info('Ply version', version);
this.logger.debug('Options', this.options);
const plyValues = new ValuesBuilder(this.options.valuesFiles, this.logger);
let values = await plyValues.read();
if (runOptions?.values) {
// runOptions values override file files
values = { ...values, ...runOptions.values };
}
this.logger.debug('Values', values);
// remove all previous runs
await rimraf(`${this.options.logLocation}/runs`);
let promises: Promise<Result[]>[] = []; // for parallel exec
const overall: OverallResults = {
Passed: 0,
Failed: 0,
Errored: 0,
Pending: 0,
Submitted: 0,
Waiting: 0
};
// requests
const requestTests = new Map<Suite<Request>, string[]>();
for (const [loc, requestPlyee] of Plyee.requests(plyees)) {
const requestSuite = await this.ply.loadRequestSuite(loc);
const tests = requestPlyee.map((plyee) => {
if (!plyee.test) {
throw new Error(`Plyee is not a test: ${plyee}`);
}
if (plyee.test.endsWith('.ply') && requestSuite.size()) {
return requestSuite.all().values().next().value!.name;
}
return plyee.test;
});
requestSuite.emitter = this;
requestTests.set(requestSuite, tests);
}
const requestRunner = new PlyRunner(this.ply.options, requestTests, plyValues, this.logger);
await requestRunner.runSuiteTests(values, runOptions);
// TODO overall results should not count each request, but suites?
if (this.ply.options.parallel) {
promises = [...promises, ...requestRunner.promises];
} else {
requestRunner.results.forEach((result) => overall[result.status]++);
}
// flows
const flowTests = new Map<FlowSuite, string[]>();
for (const [loc, flowPlyee] of Plyee.flows(plyees)) {
const tests = flowPlyee.map((plyee) => {
if (!plyee.test) {
throw new Error(`Plyee is not a test: ${plyee}`);
}
return plyee.test;
});
const flowSuites = await this.ply.loadFlowSuites(loc);
for (const flowSuite of flowSuites) {
// should only be one per loc
flowSuite.emitter = this;
flowTests.set(flowSuite, tests);
}
}
const flowRunner = new PlyRunner(this.ply.options, flowTests, plyValues, this.logger);
await flowRunner.runSuiteTests(values, runOptions);
if (this.ply.options.parallel) {
promises = [...promises, ...flowRunner.promises];
} else {
flowRunner.results.forEach((result) => overall[result.status]++);
}
// cases
const caseTests = new Map<Suite<Case>, string[]>();
for (const [loc, casePlyee] of Plyee.cases(plyees)) {
const tests = casePlyee.map((plyee) => {
if (!plyee.test) {
throw new Error(`Plyee is not a test: ${plyee}`);
}
return plyee.test;
});
const caseSuites = await this.ply.loadCaseSuites(loc);
for (const caseSuite of caseSuites) {
// should only be one per loc
caseSuite.emitter = this;
caseTests.set(caseSuite, tests);
}
}
const caseRunner = new PlyRunner(this.ply.options, caseTests, plyValues, this.logger);
await caseRunner.runSuiteTests(values, runOptions);
if (this.ply.options.parallel) {
promises = [...promises, ...caseRunner.promises];
} else {
caseRunner.results.forEach((result) => overall[result.status]++);
}
if (this.ply.options.parallel) {
const allResults = await Promise.all(promises);
allResults.forEach((results) => results.forEach((res) => overall[res.status]++));
}
if (this.options.reporter) {
const factory = new ReporterFactory(this.options.reporter);
const reporter = await factory.createReporter();
await reporter.report({
format: factory.format,
output:
this.options.outputFile ||
`${this.options.logLocation}/ply-runs.${factory.format}`,
runsLocation: `${this.options.logLocation}/runs`,
logger: this.logger,
indent: this.options.prettyIndent
});
}
return overall;
}
/**
* Finds plyees from suites and tests.
* @param paths suite/test paths
*/
async find(paths: string[]): Promise<string[]> {
const plyees: string[] = [];
for (const path of paths) {
if (path.indexOf('#') > 0) {
plyees.push(path);
} else {
// suite
if (Plyee.isRequest(path)) {
const requestSuite = await this.ply.loadRequestSuite(path);
if (!requestSuite.skip) {
if (path.endsWith('.ply')) {
plyees.push(path);
} else {
for (const request of requestSuite) {
plyees.push(
this.ply.options.testsLocation +
'/' +
requestSuite.path +
'#' +
request.name
);
}
}
}
} else if (Plyee.isCase(path)) {
const caseSuites = await this.ply.loadCaseSuites(path);
for (const caseSuite of caseSuites) {
if (!caseSuite.skip) {
for (const testCase of caseSuite) {
plyees.push(
this.ply.options.testsLocation +
'/' +
caseSuite.path +
'#' +
testCase.name
);
}
}
}
} else if (Plyee.isFlow(path)) {
const flowSuites = await this.ply.loadFlowSuites(path);
for (const flowSuite of flowSuites) {
if (!flowSuite.skip) {
for (const step of flowSuite) {
plyees.push(
this.ply.options.testsLocation +
'/' +
flowSuite.path +
'#' +
step.name
);
}
}
}
}
}
}
return plyees;
}
}