From 2629708049d8ffe576c4b5e59ca144e7782b99c6 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 20 Aug 2024 10:32:41 +0200 Subject: [PATCH 1/2] refactor: models in qase-javascript-commons Migrate some models from the interface to the class --- qase-javascript-commons/package.json | 2 +- qase-javascript-commons/src/models/index.ts | 4 +- .../src/models/step-execution.ts | 9 +++- .../src/models/test-execution.ts | 13 ++++- .../src/models/test-result.ts | 47 +++++++++++++------ .../src/models/test-step.ts | 23 ++++++++- 6 files changed, 76 insertions(+), 22 deletions(-) diff --git a/qase-javascript-commons/package.json b/qase-javascript-commons/package.json index 4d0cbc8b..0958b3d0 100644 --- a/qase-javascript-commons/package.json +++ b/qase-javascript-commons/package.json @@ -1,6 +1,6 @@ { "name": "qase-javascript-commons", - "version": "2.1.1", + "version": "2.1.2", "description": "Qase JS Reporters", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/qase-javascript-commons/src/models/index.ts b/qase-javascript-commons/src/models/index.ts index d5127285..19ae4e0e 100644 --- a/qase-javascript-commons/src/models/index.ts +++ b/qase-javascript-commons/src/models/index.ts @@ -1,6 +1,6 @@ -export { type TestResultType, Relation, Suite, SuiteData } from './test-result'; +export { TestResultType, Relation, Suite, SuiteData } from './test-result'; export { TestExecution, TestStatusEnum } from './test-execution'; -export { type TestStepType, StepType } from './test-step'; +export { TestStepType, StepType } from './test-step'; export { StepStatusEnum } from './step-execution'; export { Attachment } from './attachment'; export { Report } from './report'; diff --git a/qase-javascript-commons/src/models/step-execution.ts b/qase-javascript-commons/src/models/step-execution.ts index 3de761ea..9d3db7f4 100644 --- a/qase-javascript-commons/src/models/step-execution.ts +++ b/qase-javascript-commons/src/models/step-execution.ts @@ -5,9 +5,16 @@ export enum StepStatusEnum { skipped = 'skipped', } -export interface StepExecution { +export class StepExecution { start_time: number | null; status: StepStatusEnum; end_time: number | null; duration: number | null; + + constructor() { + this.status = StepStatusEnum.passed; + this.start_time = null; + this.end_time = null; + this.duration = null; + } } diff --git a/qase-javascript-commons/src/models/test-execution.ts b/qase-javascript-commons/src/models/test-execution.ts index b6f99ddf..d052490e 100644 --- a/qase-javascript-commons/src/models/test-execution.ts +++ b/qase-javascript-commons/src/models/test-execution.ts @@ -10,11 +10,20 @@ export enum TestStatusEnum { invalid = 'invalid', } -export interface TestExecution { +export class TestExecution { start_time: number | null; status: TestStatusEnum; end_time: number | null; duration: number | null; stacktrace: string | null; thread: string | null; -} \ No newline at end of file + + constructor() { + this.status = TestStatusEnum.passed; + this.start_time = null; + this.end_time = null; + this.duration = null; + this.stacktrace = null; + this.thread = null; + } +} diff --git a/qase-javascript-commons/src/models/test-result.ts b/qase-javascript-commons/src/models/test-result.ts index 6681fa07..efdc5514 100644 --- a/qase-javascript-commons/src/models/test-result.ts +++ b/qase-javascript-commons/src/models/test-result.ts @@ -2,21 +2,38 @@ import { TestStepType } from './test-step'; import { Attachment } from './attachment'; import { TestExecution } from './test-execution'; -export type TestResultType = { - id: string - title: string - signature: string - run_id: number | null - testops_id: number | number[] | null - execution: TestExecution - fields: Record - attachments: Attachment[] - steps: TestStepType[] - params: Record - author: string | null - relations: Relation | null - muted: boolean - message: string | null +export class TestResultType { + id: string; + title: string; + signature: string; + run_id: number | null; + testops_id: number | number[] | null; + execution: TestExecution; + fields: Record; + attachments: Attachment[]; + steps: TestStepType[]; + params: Record; + author: string | null; + relations: Relation | null; + muted: boolean; + message: string | null; + + constructor(title: string) { + this.id = ''; + this.title = title; + this.signature = ''; + this.run_id = null; + this.testops_id = null; + this.execution = new TestExecution(); + this.fields = {}; + this.attachments = []; + this.steps = []; + this.params = {}; + this.author = null; + this.relations = null; + this.muted = false; + this.message = null; + } } export type Relation = { diff --git a/qase-javascript-commons/src/models/test-step.ts b/qase-javascript-commons/src/models/test-step.ts index 1928b638..a2e47672 100644 --- a/qase-javascript-commons/src/models/test-step.ts +++ b/qase-javascript-commons/src/models/test-step.ts @@ -9,7 +9,7 @@ export enum StepType { GHERKIN = 'gherkin', } -export type TestStepType = { +export class TestStepType { id: string; step_type: StepType; data: StepTextData | StepGherkinData; @@ -17,4 +17,25 @@ export type TestStepType = { execution: StepExecution; attachments: Attachment[]; steps: TestStepType[]; + + constructor(type: StepType = StepType.TEXT) { + this.id = ''; + this.step_type = type; + this.parent_id = null; + this.execution = new StepExecution(); + this.attachments = []; + this.steps = []; + if (type === StepType.TEXT) { + this.data = { + action: '', + expected_result: null, + }; + } else { + this.data = { + keyword: '', + name: '', + line: 0, + }; + } + } } From aad0d98085938949d40f6f6b51c8a65d43d7376b Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Tue, 20 Aug 2024 12:50:14 +0200 Subject: [PATCH 2/2] feature: add WDIO reporter --- README.md | 1 + package-lock.json | 181 ++++++++++- package.json | 1 + qase-wdio/.eslintignore | 1 + qase-wdio/.gitignore | 1 + qase-wdio/.npmignore | 7 + qase-wdio/.prettierignore | 2 + qase-wdio/LICENSE | 201 +++++++++++++ qase-wdio/README.md | 145 +++++++++ qase-wdio/changelog.md | 5 + qase-wdio/package.json | 43 +++ qase-wdio/src/events.ts | 12 + qase-wdio/src/index.ts | 4 + qase-wdio/src/models.ts | 22 ++ qase-wdio/src/options.ts | 11 + qase-wdio/src/reporter.ts | 548 ++++++++++++++++++++++++++++++++++ qase-wdio/src/step.ts | 105 +++++++ qase-wdio/src/storage.ts | 54 ++++ qase-wdio/src/utils.ts | 22 ++ qase-wdio/src/wdio.ts | 107 +++++++ qase-wdio/tsconfig.build.json | 9 + qase-wdio/tsconfig.json | 7 + 22 files changed, 1486 insertions(+), 3 deletions(-) create mode 100644 qase-wdio/.eslintignore create mode 100644 qase-wdio/.gitignore create mode 100644 qase-wdio/.npmignore create mode 100644 qase-wdio/.prettierignore create mode 100644 qase-wdio/LICENSE create mode 100644 qase-wdio/README.md create mode 100644 qase-wdio/changelog.md create mode 100644 qase-wdio/package.json create mode 100644 qase-wdio/src/events.ts create mode 100644 qase-wdio/src/index.ts create mode 100644 qase-wdio/src/models.ts create mode 100644 qase-wdio/src/options.ts create mode 100644 qase-wdio/src/reporter.ts create mode 100644 qase-wdio/src/step.ts create mode 100644 qase-wdio/src/storage.ts create mode 100644 qase-wdio/src/utils.ts create mode 100644 qase-wdio/src/wdio.ts create mode 100644 qase-wdio/tsconfig.build.json create mode 100644 qase-wdio/tsconfig.json diff --git a/README.md b/README.md index b8bf124c..5ca77dd8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ If your project is using a v1 reporter, check out the reporter's readme for the | Playwright | `playwright-qase-reporter` | [✅ released](https://github.com/qase-tms/qase-javascript/tree/main/qase-playwright#readme) | [🗿deprecated](https://github.com/qase-tms/qase-javascript/tree/master/qase-playwright#readme) | | Testcafe | `testcafe-reporter-qase` | [✅ released](https://github.com/qase-tms/qase-javascript/tree/main/qase-testcafe#readme) | [🗿deprecated](https://github.com/qase-tms/qase-javascript/tree/master/qase-testcafe#readme) | | Mocha | `mocha-qase-reporter` | not available | [🧪 open beta](https://github.com/qase-tms/qase-javascript/tree/main/qase-mocha#readme) | +| WebDriverIO | `wdio-qase-reporter` | not available | [🧪 open beta](https://github.com/qase-tms/qase-javascript/tree/main/qase-wdio#readme) | | **Qase JavaScript SDK** | | Common functions library | `qase-javascript-commons` | [✅ released](https://github.com/qase-tms/qase-javascript/tree/main/qase-javascript-commons#readme) | not available | | JavaScript API client | `qaseio` | [✅ released](https://github.com/qase-tms/qase-javascript/tree/main/qaseio#readme) | [🗿deprecated](https://github.com/qase-tms/qase-javascript/tree/master/qaseio#readme) | diff --git a/package-lock.json b/package-lock.json index 99baf877..71c42990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "qase-playwright", "qase-testcafe", "qase-mocha", + "qase-wdio", "./examples/*" ], "devDependencies": { @@ -3739,6 +3740,106 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@wdio/logger": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-8.38.0.tgz", + "integrity": "sha512-kcHL86RmNbcQP+Gq/vQUGlArfU6IIcbbnNp32rRIraitomZow+iEoc519rdQmSVusDozMS5DZthkgDdxK+vz6Q==", + "dependencies": { + "chalk": "^5.1.2", + "loglevel": "^1.6.0", + "loglevel-plugin-prefix": "^0.8.4", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": "^16.13 || >=18" + } + }, + "node_modules/@wdio/logger/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@wdio/logger/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@wdio/logger/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@wdio/reporter": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@wdio/reporter/-/reporter-8.39.0.tgz", + "integrity": "sha512-XahXhmaA1okdwg4/ThHFSqy/41KywxhbtszPcTzyXB+9INaqFNHA1b1vvWs0mrD5+tTtKbg4caTcEHVJU4iv0w==", + "dependencies": { + "@types/node": "^20.1.0", + "@wdio/logger": "8.38.0", + "@wdio/types": "8.39.0", + "diff": "^5.0.0", + "object-inspect": "^1.12.0" + }, + "engines": { + "node": "^16.13 || >=18" + } + }, + "node_modules/@wdio/reporter/node_modules/@types/node": { + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@wdio/reporter/node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@wdio/types": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@wdio/types/-/types-8.39.0.tgz", + "integrity": "sha512-86lcYROTapOJuFd9ouomFDfzDnv3Kn+jE0RmqfvN9frZAeLVJ5IKjX9M6HjplsyTZhjGO1uCaehmzx+HJus33Q==", + "dependencies": { + "@types/node": "^20.1.0" + }, + "engines": { + "node": "^16.13 || >=18" + } + }, + "node_modules/@wdio/types/node_modules/@types/node": { + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/2-thenable": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/2-thenable/-/2-thenable-1.0.0.tgz", @@ -5255,6 +5356,11 @@ "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.16.3.tgz", "integrity": "sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg==" }, + "node_modules/csv-stringify": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.5.1.tgz", + "integrity": "sha512-+9lpZfwpLntpTIEpFbwQyWuW/hmI/eHuJZD1XzeZpfZTqkf1fyvBbBLXTJJMsBuuS11uTShMqPwzx4A6ffXgRQ==" + }, "node_modules/cucumberjs-qase-reporter": { "resolved": "qase-cucumberjs", "link": true @@ -10146,6 +10252,23 @@ "node": ">=8" } }, + "node_modules/loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loglevel-plugin-prefix": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.8.4.tgz", + "integrity": "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -14485,8 +14608,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/uni-global": { "version": "1.0.0", @@ -14731,6 +14853,10 @@ "makeerror": "1.0.12" } }, + "node_modules/wdio-qase-reporter": { + "resolved": "qase-wdio", + "link": true + }, "node_modules/webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -15103,7 +15229,7 @@ } }, "qase-javascript-commons": { - "version": "2.1.1", + "version": "2.1.2", "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", @@ -15281,6 +15407,55 @@ "testcafe": ">=2.0.0" } }, + "qase-wdio": { + "version": "1.0.0-beta.1", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^20.1.0", + "@wdio/reporter": "^8.39.0", + "@wdio/types": "^8.39.0", + "csv-stringify": "^6.0.4", + "qase-javascript-commons": "~2.1.1", + "strip-ansi": "^7.1.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "qase-wdio/node_modules/@types/node": { + "version": "20.14.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", + "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "qase-wdio/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "qase-wdio/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "qaseio": { "version": "2.2.0", "license": "Apache-2.0", diff --git a/package.json b/package.json index aec1be66..e32975a6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "qase-playwright", "qase-testcafe", "qase-mocha", + "qase-wdio", "./examples/*" ], "devDependencies": { diff --git a/qase-wdio/.eslintignore b/qase-wdio/.eslintignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/qase-wdio/.eslintignore @@ -0,0 +1 @@ +dist diff --git a/qase-wdio/.gitignore b/qase-wdio/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/qase-wdio/.gitignore @@ -0,0 +1 @@ +dist diff --git a/qase-wdio/.npmignore b/qase-wdio/.npmignore new file mode 100644 index 00000000..db8051a6 --- /dev/null +++ b/qase-wdio/.npmignore @@ -0,0 +1,7 @@ +.* +**/tsconfig.json +**/jest.config.js +examples +test +coverage +src diff --git a/qase-wdio/.prettierignore b/qase-wdio/.prettierignore new file mode 100644 index 00000000..a60f9ce8 --- /dev/null +++ b/qase-wdio/.prettierignore @@ -0,0 +1,2 @@ +/dist +/coverage diff --git a/qase-wdio/LICENSE b/qase-wdio/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/qase-wdio/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/qase-wdio/README.md b/qase-wdio/README.md new file mode 100644 index 00000000..67a4bfbf --- /dev/null +++ b/qase-wdio/README.md @@ -0,0 +1,145 @@ +# Qase TMS WebDriverIO reporter + +Publish results simple and easy. + +To install the latest beta version, run: + +```sh +npm install -D wdio-qase-reporter@beta +``` + +## Getting started + +The WebDriverIO reporter can auto-generate test cases +and suites from your test data. +Test results of subsequent test runs will match the same test cases +as long as their names and file paths don't change. + +You can also annotate the tests with the IDs of existing test cases +from Qase.io before executing tests. It's a more reliable way to bind +autotests to test cases, that persists when you rename, move, or +parameterize your tests. + +For example: + +### Mocha/Jasmine + +```typescript +import {qase} from "wdio-qase-reporter"; + +describe('My First Test', () => { + it('Several ids', () => { + qase.id(1); + expect(true).to.equal(true); + }); + + // a test can check multiple test cases + it('Correct test', () => { + qase.id([2, 3]); + expect(true).to.equal(true); + }); + + it('With steps',async () => { + await qase.step('Step 1', async (s1) => { + await s1.step('Step 1.1', async () => { + // do something + s1.attach({name: 'screenshot.png', type: 'image/png', content: await browser.takeScreenshot()}) + }) + + qase.attach({name: 'log.txt', content: 'test', type: 'text/plain'}) + + await s1.step('Step 1.2', async () => { + // do something + }) + }) + expect(true).to.equal(true); + }); +}); +``` + +### Cucumber + +```gherkin +Feature: Test user role + + @QaseId=3 + Scenario: Login + Given I test login +``` + +To execute WebDriverIO tests and report them to Qase.io, run the command: + +```bash +QASE_MODE=testops wdio run ./wdio.conf.ts +``` + +

+ +

+ +A test run will be performed and available at: + +``` +https://app.qase.io/run/QASE_PROJECT_CODE +``` + +## Configuration + +Qase WebDriverIO reporter can be configured in multiple ways: + +- using a separate config file `qase.config.json`, +- using environment variables (they override the values from the configuration files). + +For a full list of configuration options, see +the [Configuration reference](../qase-javascript-commons/README.md#configuration). + +Example `qase.config.json` config: + +```json +{ + "mode": "testops", + "debug": true, + "testops": { + "api": { + "token": "api_key" + }, + "project": "project_code", + "run": { + "complete": true + } + } +} +``` + +Also, you need to configure the reporter using the `wdio.conf.ts` file: + +```ts +// wdio.conf.ts +import WDIOQaseReporter from 'wdio-qase-reporter'; +import type { Options } from '@wdio/types'; + +export const config: Options.Testrunner = { + reporters: [[WDIOQaseReporter, { + disableWebdriverStepsReporting: true, + disableWebdriverScreenshotsReporting: true, + useCucumber: false, + }]], + // ... other options +}; +``` + +Additional options of the reporter in the `wdio.conf.ts` file: + +- `disableWebdriverStepsReporting` - optional parameter(`false` by default), in order to log only custom steps to the reporter. +- `disableWebdriverScreenshotsReporting` - optional parameter(`false` by default), in order to not attach screenshots to the reporter. +- `useCucumber` - optional parameter (`false` by default), if you use Cucumber, set it to true + +## Requirements + +We maintain the reporter on [LTS versions of Node](https://nodejs.org/en/about/releases/). + +`wdio >= 8.40.0` + + + +[auth]: https://developers.qase.io/#authentication diff --git a/qase-wdio/changelog.md b/qase-wdio/changelog.md new file mode 100644 index 00000000..85953e0a --- /dev/null +++ b/qase-wdio/changelog.md @@ -0,0 +1,5 @@ +# qase-wdio@1.0.0-beta.1 + +## What's new + +First major beta release for the version 1 series of the Qase WebDriverIO reporter. diff --git a/qase-wdio/package.json b/qase-wdio/package.json new file mode 100644 index 00000000..6b347eaf --- /dev/null +++ b/qase-wdio/package.json @@ -0,0 +1,43 @@ +{ + "name": "wdio-qase-reporter", + "version": "1.0.0-beta.1", + "description": "Qase WebDriverIO Reporter", + "homepage": "https://github.com/qase-tms/qase-javascript", + "sideEffects": false, + "main": "./dist/index.js", + "bugs": { + "url": "https://github.com/qase-tms/qase-javascript/issues" + }, + "keywords": [ + "reporter", + "webdriverio", + "wdio", + "wdio-plugin", + "wdio-reporter", + "qase" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/qase-tms/qase-javascript.git" + }, + "engines": { + "node": ">=14" + }, + "scripts": { + "build": "npm run clean && tsc --project tsconfig.build.json", + "lint": "eslint .", + "test": "jest --passWithNoTests", + "clean": "rm -rf dist" + }, + "author": "Qase Team ", + "license": "Apache-2.0", + "dependencies": { + "qase-javascript-commons": "~2.1.2", + "uuid": "^9.0.1", + "@types/node": "^20.1.0", + "@wdio/reporter": "^8.39.0", + "@wdio/types": "^8.39.0", + "csv-stringify": "^6.0.4", + "strip-ansi": "^7.1.0" + } +} diff --git a/qase-wdio/src/events.ts b/qase-wdio/src/events.ts new file mode 100644 index 00000000..0b877e03 --- /dev/null +++ b/qase-wdio/src/events.ts @@ -0,0 +1,12 @@ +export const events = { + addQaseID: 'qase:id', + addTitle: 'qase:title', + addFields: 'qase:fields', + addSuite: 'qase:suite', + addIgnore: 'qase:ignore', + addParameters: 'qase:parameters', + addAttachment: 'qase:attachment', + startStep: 'qase:startStep', + endStep: 'qase:endStep', + addStep: 'qase:addStep', +} as const diff --git a/qase-wdio/src/index.ts b/qase-wdio/src/index.ts new file mode 100644 index 00000000..a6ecef51 --- /dev/null +++ b/qase-wdio/src/index.ts @@ -0,0 +1,4 @@ +import WDIOQaseReporter from './reporter.js'; + +export * from './wdio'; +export default WDIOQaseReporter; diff --git a/qase-wdio/src/models.ts b/qase-wdio/src/models.ts new file mode 100644 index 00000000..5ec98a03 --- /dev/null +++ b/qase-wdio/src/models.ts @@ -0,0 +1,22 @@ +export interface AddQaseIdEventArgs { + ids: number[]; +} + +export interface AddRecordsEventArgs { + records: Record; +} + +export interface AddTitleEventArgs { + title: string; +} + +export interface AddSuiteEventArgs { + suite: string; +} + +export interface AddAttachmentEventArgs { + name?: string; + type?: string; + content?: string; + paths?: string[]; +} diff --git a/qase-wdio/src/options.ts b/qase-wdio/src/options.ts new file mode 100644 index 00000000..4a45a565 --- /dev/null +++ b/qase-wdio/src/options.ts @@ -0,0 +1,11 @@ +export class QaseReporterOptions { + useCucumber?: boolean; + disableWebdriverStepsReporting?: boolean; + disableWebdriverScreenshotsReporting?: boolean; + + constructor() { + this.useCucumber = false; + this.disableWebdriverStepsReporting = false; + this.disableWebdriverScreenshotsReporting = false; + } +} diff --git a/qase-wdio/src/reporter.ts b/qase-wdio/src/reporter.ts new file mode 100644 index 00000000..fcc17227 --- /dev/null +++ b/qase-wdio/src/reporter.ts @@ -0,0 +1,548 @@ +import WDIOReporter, { + AfterCommandArgs, + BeforeCommandArgs, + RunnerStats, + SuiteStats, + TestStats, + Tag, +} from '@wdio/reporter'; +import { + Attachment, + composeOptions, + CompoundError, + ConfigLoader, + getMimeTypes, + QaseReporter, + Relation, + ReporterInterface, + StepStatusEnum, + StepType, + TestResultType, + TestStatusEnum, + TestStepType, +} from 'qase-javascript-commons'; + +import { v4 as uuidv4 } from 'uuid'; +import { Storage } from './storage'; +import { QaseReporterOptions } from './options'; +import { isEmpty, isScreenshotCommand } from './utils'; +import { + AddAttachmentEventArgs, + AddQaseIdEventArgs, + AddRecordsEventArgs, + AddSuiteEventArgs, + AddTitleEventArgs, +} from './models'; +import path from 'path'; +import { events } from './events'; + +export default class WDIOQaseReporter extends WDIOReporter { + + /** + * @type {Record} + */ + static statusMap: Record = { + 'passed': TestStatusEnum.passed, + 'failed': TestStatusEnum.failed, + 'skipped': TestStatusEnum.skipped, + 'pending': TestStatusEnum.skipped, + }; + + /** + * @type {ReporterInterface} + * @private + */ + private reporter: ReporterInterface; + + private storage: Storage; + + /** + * @type {boolean} + * @private + */ + private isSync: boolean; + + private _options: QaseReporterOptions; + private _isMultiremote?: boolean; + + constructor(options: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + super(options); + const configLoader = new ConfigLoader(); + const config = configLoader.load(); + + this.reporter = QaseReporter.getInstance({ + ...composeOptions(options, config), + frameworkPackage: 'wdio', + frameworkName: 'wdio', + reporterName: 'wdio-qase-reporter', + }); + + this.isSync = true; + this.storage = new Storage(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this._options = Object.assign(new QaseReporterOptions(), options); + + this.registerListeners(); + } + + override get isSynchronised() { + return this.isSync; + } + + override set isSynchronised(value: boolean) { + this.isSync = value; + } + + override onRunnerStart(runner: RunnerStats) { + this._isMultiremote = runner.isMultiremote; + this.reporter.startTestRun(); + this.isSync = false; + } + + override onSuiteStart(suite: SuiteStats) { + this.storage.currentFile = suite.file; + + if (this._options.useCucumber && suite.type === 'scenario') { + this._startTest(suite.title, suite.cid ?? ''); + + if (suite.tags === undefined) { + return; + } + + const tags = (suite.tags as Tag[]).map((tag) => { + return tag.name; + }); + + for (const tag of tags) { + if (!tag.includes('=')) { + continue; + } + + const tagData = this.parseTag(tag); + if (tagData === null) { + continue; + } + + switch (tagData.key.toLowerCase()) { + case '@qaseid': + this.addQaseId({ ids: tagData.value.split(',').map((id) => parseInt(id)) }); + break; + case '@title': + this.addTitle({ title: tagData.value }); + break; + case '@suite': + this.addSuite({ suite: tagData.value }); + break; + // case 'parameters': + // const params = tagData.value.split(',').map((param) => { + // const [key, value] = param.split(':'); + // return { key, value }; + // }); + // process.emit(events.addParameters, { records: params }); + // break; + // case 'fields': + // const fields = tagData.value.split(',').map((field) => { + // const [key, value] = field.split(':'); + // return { key, value }; + // }); + // process.emit(events.addFields, { records: fields }); + // break; + } + } + + return; + } + + this.storage.suites.push(suite.title); + } + + override async onSuiteEnd(suite: SuiteStats) { + this.storage.currentFile = undefined; + + if (this._options.useCucumber && suite.type === 'scenario') { + suite.hooks = suite.hooks.map((hook) => { + hook.state = hook.state ?? 'passed'; + return hook; + }); + + const suiteChildren = [...suite.tests, ...suite.hooks]; + + const isSkipped = suite.tests.every(item => ['skipped'].includes(item.state)) + && suite.hooks.every(item => ['passed', 'skipped'].includes(item.state ?? 'passed')); + + if (isSkipped) { + await this._endTest(TestStatusEnum.skipped, null); + return; + } + + const isFailed = suiteChildren.find(item => item.state === 'failed'); + + if (isFailed) { + const err = WDIOQaseReporter.transformError(isFailed.errors ?? []); + await this._endTest(TestStatusEnum.failed, err); + return; + } + + const isPassed = suiteChildren.every(item => item.state === 'passed'); + const isPartiallySkipped = suiteChildren.every(item => ['passed', 'skipped'].includes(item.state ?? 'passed')); + + if (isPassed || isPartiallySkipped) { + await this._endTest(TestStatusEnum.passed, null); + return; + } + + return; + } + + this.storage.clear(); + } + + override async onRunnerEnd(_: RunnerStats) { + await this.reporter.publish(); + this.isSync = true; + } + + override onTestStart(test: TestStats) { + if (this._options.useCucumber) { + this._startStep(test.title); + return; + } + + this._startTest(test.title, test.cid, test.start.valueOf() / 1000); + } + + + /** + * @param {Error[]} testErrors + * @returns {CompoundError} + * @private + */ + private static transformError(testErrors: Error[]): CompoundError { + const err = new CompoundError(); + for (const error of testErrors) { + if (error.message == undefined) { + continue; + } + err.addMessage(error.message); + } + + for (const error of testErrors) { + if (error.stack == undefined) { + continue; + } + err.addStacktrace(error.stack); + } + + return err; + } + + override async onTestEnd(test: TestStats) { + const error = test.errors ? WDIOQaseReporter.transformError(test.errors) : null; + + await this._endTest(WDIOQaseReporter.statusMap[test.state] ?? TestStatusEnum.skipped, + error, + test.end ? test.end.valueOf() / 1000 : Date.now().valueOf() / 1000); + } + + private async _endTest(status: TestStatusEnum, err: CompoundError | null, end_time: number = Date.now().valueOf() / 1000) { + const testResult = this.storage.getCurrentTest(); + if (testResult === undefined || this.storage.ignore) { + return; + } + + const relations: Relation = {}; + if (this.storage.suites.length > 0) { + relations.suite = { + data: this.storage.suites.map((suite) => { + return { + title: suite, + public_id: null, + }; + }), + }; + } + + testResult.relations = relations; + testResult.execution.duration = testResult.execution.start_time ? Math.round(end_time - testResult.execution.start_time) : 0; + testResult.execution.status = status; + testResult.execution.stacktrace = err === null ? + null : err.stacktrace === undefined ? + null : err.stacktrace; + + testResult.message = err === null ? + null : err.message === undefined ? + null : err.message; + + console.log(testResult); + await this.reporter.addTestResult(testResult); + } + + override onHookStart() { + console.log('Hook started'); + } + + override onHookEnd() { + console.log('Hook ended'); + } + + override onBeforeCommand(command: BeforeCommandArgs) { + if (!this.storage.getLastItem()) { + return; + } + + const { disableWebdriverStepsReporting } = this._options; + + if (disableWebdriverStepsReporting || this._isMultiremote) { + return; + } + + const { method, endpoint } = command; + + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const stepName = command.command ? command.command : `${method} ${endpoint}`; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const payload = command.body || command.params; + + this._startStep(stepName); + + if (!isEmpty(payload)) { + this.attachJSON('Request', payload); + } + } + + override onAfterCommand(command: AfterCommandArgs) { + const { disableWebdriverStepsReporting, disableWebdriverScreenshotsReporting } = this._options; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const commandResult: string | undefined = command.result || undefined; + const isScreenshot = isScreenshotCommand(command); + if (!disableWebdriverScreenshotsReporting && isScreenshot && commandResult) { + this.attachFile('Screenshot', Buffer.from(commandResult, 'base64'), 'image/png'); + } + + if (disableWebdriverStepsReporting || this._isMultiremote || !this.storage.getCurrentStep()) { + return; + } + + this.attachJSON('Response', commandResult); + this._endStep(); + } + + registerListeners() { + process.on(events.addQaseID, this.addQaseId.bind(this)); + process.on(events.addTitle, this.addTitle.bind(this)); + process.on(events.addFields, this.addFields.bind(this)); + process.on(events.addSuite, this.addSuite.bind(this)); + process.on(events.addParameters, this.addParameters.bind(this)); + process.on(events.addAttachment, this.addAttachment.bind(this)); + process.on(events.addIgnore, this.ignore.bind(this)); + process.on(events.addStep, this.addStep.bind(this)); + } + + addQaseId({ ids }: AddQaseIdEventArgs) { + const curTest = this.storage.getCurrentTest(); + if (!curTest) { + return; + } + + curTest.testops_id = ids; + } + + addTitle({ title }: AddTitleEventArgs) { + const curTest = this.storage.getCurrentTest(); + if (!curTest) { + return; + } + + curTest.title = title; + } + + addSuite({ suite }: AddSuiteEventArgs) { + const curTest = this.storage.getCurrentTest(); + if (!curTest) { + return; + } + + curTest.relations = { + suite: { + data: [ + { + title: suite, + public_id: null, + }, + ], + }, + }; + } + + addParameters({ records }: AddRecordsEventArgs) { + const curTest = this.storage.getCurrentTest(); + if (!curTest) { + return; + } + + const stringRecord: Record = {}; + for (const [key, value] of Object.entries(records)) { + stringRecord[String(key)] = String(value); + } + + curTest.params = stringRecord; + } + + addFields({ records }: AddRecordsEventArgs) { + const curTest = this.storage.getCurrentTest(); + if (!curTest) { + return; + } + + const stringRecord: Record = {}; + for (const [key, value] of Object.entries(records)) { + stringRecord[String(key)] = String(value); + } + + curTest.fields = records; + } + + addAttachment({ name, type, content, paths }: AddAttachmentEventArgs) { + console.log('addAttachment'); + console.log(name, type, content, paths); + const curTest = this.storage.getCurrentTest(); + if (!curTest) { + return; + } + + if (paths) { + for (const file of paths) { + const attachmentName = path.basename(file); + const contentType: string = getMimeTypes(file); + + const attach: Attachment = { + file_path: file, + size: 0, + id: uuidv4(), + file_name: attachmentName, + mime_type: contentType, + content: '', + }; + + curTest.attachments.push(attach); + } + return; + } + + if (content) { + const attachmentName = name ?? 'attachment'; + const contentType = type ?? 'application/octet-stream'; + + const attach: Attachment = { + file_path: null, + size: content.length, + id: uuidv4(), + file_name: attachmentName, + mime_type: contentType, + content: content, + }; + + curTest.attachments.push(attach); + } + } + + ignore() { + const curTest = this.storage.getCurrentTest(); + if (!curTest) { + return; + } + + this.storage.ignore = true; + } + + addStep(step: TestStepType) { + const curItem = this.storage.getLastItem(); + if (!curItem) { + return; + } + + curItem.steps.push(step); + } + + private _startTest(title: string, cid: string, start_time: number = Date.now().valueOf() / 1000) { + const result = new TestResultType(title); + result.execution.thread = cid; + result.execution.start_time = start_time; + result.id = uuidv4(); + + this.storage.push(result); + } + + private _startStep(title: string) { + const step: TestStepType = { + id: uuidv4(), + step_type: StepType.TEXT, + data: { + action: title, + expected_result: null, + }, + parent_id: this.storage.getLastItem()?.id ?? null, + execution: { + start_time: Date.now().valueOf() / 1000, + end_time: null, + status: StepStatusEnum.passed, + duration: null, + }, + steps: [], + attachments: [], + }; + + this.storage.getLastItem()?.steps.push(step); + this.storage.push(step); + } + + private attachJSON(name: string, json: any) { + const isStr = typeof json === 'string'; + const content = isStr ? json : JSON.stringify(json, null, 2); + + this.attachFile(name, String(content), isStr ? 'application/json' : 'text/plain'); + } + + private attachFile(name: string, content: string | Buffer, contentType: string) { + if (!this.storage.getLastItem()) { + throw new Error('There isn\'t any active test!'); + } + + const attach: Attachment = { + file_path: null, + size: content.length, + id: uuidv4(), + file_name: name, + mime_type: contentType, + content: content, + }; + + console.log('attachFile:', attach); + this.storage.getLastItem()?.attachments.push(attach); + } + + private _endStep(status: TestStatusEnum = TestStatusEnum.passed) { + if (!this.storage.getLastItem()) { + return; + } + + const step = this.storage.pop(); + if (!step) { + return; + } + + step.execution.end_time = Date.now().valueOf() / 1000; + step.execution.status = status; + } + + private parseTag(tag: string): { key: string, value: string } | null { + const [key, value] = tag.split('='); + if (!key || !value) { + return null; + } + + return { key, value }; + } +} diff --git a/qase-wdio/src/step.ts b/qase-wdio/src/step.ts new file mode 100644 index 00000000..50cef502 --- /dev/null +++ b/qase-wdio/src/step.ts @@ -0,0 +1,105 @@ +import { Attachment, StepStatusEnum, TestStepType } from 'qase-javascript-commons'; +import { v4 as uuidv4 } from 'uuid'; +import path from 'path'; + +export type StepFunction = ( + this: QaseStep, + step: QaseStep, +) => T | Promise; + +export class QaseStep { + name = ''; + attachments: Attachment[] = []; + steps: TestStepType[] = []; + + constructor(name: string) { + this.name = name; + } + + attach(attach: { + name?: string; + type?: string; + content?: string; + paths?: string[] | string; + }): void { + if (attach.paths) { + const files = Array.isArray(attach.paths) ? attach.paths : [attach.paths]; + + for (const file of files) { + const attachmentName = path.basename(file); + const contentType = 'application/octet-stream'; + this.attachments.push({ + id: uuidv4(), + file_name: attachmentName, + mime_type: contentType, + content: '', + file_path: file, + size: 0, + }); + } + + return; + } + + if (attach.content) { + const attachmentName = attach.name ?? 'attachment'; + const contentType = attach.type ?? 'application/octet-stream'; + + this.attachments.push({ + id: uuidv4(), + file_name: attachmentName, + mime_type: contentType, + content: attach.content, + file_path: null, + size: attach.content.length, + }); + } + } + + async step(name: string, body: StepFunction): Promise { + const childStep = new QaseStep(name); + // eslint-disable-next-line @typescript-eslint/require-await + await childStep.run(body, async (step: TestStepType) => { + this.steps.push(step); + }); + } + + async run(body: StepFunction, messageEmitter: (step: TestStepType) => Promise): Promise { + const startDate = new Date().getTime(); + const step = new TestStepType(); + step.data = { + action: this.name, + expected_result: null, + }; + + try { + await body.call(this, this); + + step.execution = { + start_time: startDate, + end_time: new Date().getTime(), + status: StepStatusEnum.passed, + duration: null, + }; + + step.attachments = this.attachments; + step.steps = this.steps; + + await messageEmitter(step); + } catch (e: any) { + step.execution = { + start_time: startDate, + end_time: new Date().getTime(), + status: StepStatusEnum.failed, + duration: null, + }; + + step.attachments = this.attachments; + step.steps = this.steps; + + await messageEmitter(step); + + throw e; + } + } +} diff --git a/qase-wdio/src/storage.ts b/qase-wdio/src/storage.ts new file mode 100644 index 00000000..8a86a06c --- /dev/null +++ b/qase-wdio/src/storage.ts @@ -0,0 +1,54 @@ +import { TestResultType, TestStepType } from 'qase-javascript-commons'; + +export class Storage { + currentFile?: string | undefined; + suites: string[] = []; + ignore = false; + items: (TestResultType | TestStepType)[] = []; + + + clear() { + this.currentFile = undefined; + this.suites = []; + this.items = []; + this.ignore = false; + } + + push(item: TestResultType | TestStepType) { + this.items.push(item); + } + + pop(): TestResultType | TestStepType | undefined { + return this.items.pop(); + } + + getCurrentTest(): TestResultType | undefined { + return findLast(this.items, (item) => item instanceof TestResultType) as TestResultType | undefined; + } + + getCurrentStep(): TestStepType | undefined { + return findLast(this.items, (item) => item instanceof TestStepType) as TestStepType | undefined; + } + + getLastItem(): TestResultType | TestStepType | undefined { + return this.items[this.items.length - 1]; + } +} + +export const findLast = ( + arr: T[], + predicate: (el: T) => boolean, +): T | undefined => { + let result: T | undefined; + + for (let i = arr.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (predicate(arr[i])) { + result = arr[i]; + break; + } + } + + return result; +}; diff --git a/qase-wdio/src/utils.ts b/qase-wdio/src/utils.ts new file mode 100644 index 00000000..650e66cb --- /dev/null +++ b/qase-wdio/src/utils.ts @@ -0,0 +1,22 @@ +import { CommandArgs } from '@wdio/reporter'; + +/** + * Check is object is empty + * @param object {Object} + * @private + */ +export const isEmpty = (object: any) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + !object || Object.keys(object).length === 0; + + +export const isScreenshotCommand = (command: CommandArgs): boolean => { + const isScreenshotEndpoint = /\/session\/[^/]*(\/element\/[^/]*)?\/screenshot/ + + return ( + // WebDriver protocol + (command.endpoint && isScreenshotEndpoint.test(command.endpoint)) || + // DevTools protocol + command.command === 'takeScreenshot' + ) +} diff --git a/qase-wdio/src/wdio.ts b/qase-wdio/src/wdio.ts new file mode 100644 index 00000000..96fe065e --- /dev/null +++ b/qase-wdio/src/wdio.ts @@ -0,0 +1,107 @@ +import { events } from './events'; +import { QaseStep, StepFunction } from './step'; + +/** + * Send event to reporter + * @param {string} event - event name + * @param {object} msg - event payload + * @private + */ +const sendEvent = (event: string, msg: any = {}) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + process.emit(event as any, msg); +}; + +export class qase { + /** + * Assign QaseID to test + * @name id + * @param {number | number[]} value + */ + public static id(value: number | number[]) { + sendEvent(events.addQaseID, { ids: Array.isArray(value) ? value : [value] }); + return this; + } + + /** + * Assign title to test + * @name title + * @param {string} value + */ + public static title(value: string) { + sendEvent(events.addTitle, { title: value }); + return this; + } + + /** + * Assign parameters to test + * @name parameters + * @param {Record} values + */ + public static parameters(values: Record) { + sendEvent(events.addParameters, { records: values }); + + return this; + } + + /** + * Assign fields to test + * @name fields + * @param {Record} values + */ + public static fields(values: Record) { + sendEvent(events.addFields, { records: values }); + + return this; + } + + /** + * Assign suite to test + * @name suite + * @param {string} value + */ + public static suite(value: string) { + sendEvent(events.addSuite, { suite: value }); + + return this; + } + + /** + * Assign ignore mark to test + * @name ignore + */ + public static ignore() { + sendEvent(events.addIgnore, {}); + + return this; + } + + + /** + * Assign attachment to test + * @name attach + * @param attach + */ + public static attach(attach: { + name?: string; + type?: string; + content?: string; + paths?: string[]; + }) { + sendEvent(events.addAttachment, attach); + + return this; + } + + + /** + * Starts step + * @param {string} name - the step name + * @param {StepFunction} body - the step content function + */ + public static async step(name: string, body: StepFunction) { + const runningStep = new QaseStep(name); + // eslint-disable-next-line @typescript-eslint/require-await + await runningStep.run(body, async (message) => sendEvent(events.addStep, message)); + } +} diff --git a/qase-wdio/tsconfig.build.json b/qase-wdio/tsconfig.build.json new file mode 100644 index 00000000..85826046 --- /dev/null +++ b/qase-wdio/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "noEmit": false + }, + + "include": ["./src/**/*.ts"] +} diff --git a/qase-wdio/tsconfig.json b/qase-wdio/tsconfig.json new file mode 100644 index 00000000..e2f21b29 --- /dev/null +++ b/qase-wdio/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + + "compilerOptions": { + "outDir": "dist" + } +}