@actionlogementservices/wdio-test-context 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.editorconfig +20 -0
  2. package/.jsdoc.json +35 -0
  3. package/.prettierrc +11 -0
  4. package/README.md +116 -0
  5. package/doc/images/badges/modifier-abstract.png +0 -0
  6. package/doc/images/badges/modifier-abstract.svg +10 -0
  7. package/doc/images/badges/modifier-default.png +0 -0
  8. package/doc/images/badges/modifier-default.svg +10 -0
  9. package/doc/images/badges/modifier-private.png +0 -0
  10. package/doc/images/badges/modifier-private.svg +10 -0
  11. package/doc/images/badges/modifier-protected.png +0 -0
  12. package/doc/images/badges/modifier-protected.svg +10 -0
  13. package/doc/images/badges/modifier-public.png +0 -0
  14. package/doc/images/badges/modifier-public.svg +10 -0
  15. package/doc/images/badges/modifier-readonly.png +0 -0
  16. package/doc/images/badges/modifier-readonly.svg +10 -0
  17. package/doc/images/badges/modifier-static.png +0 -0
  18. package/doc/images/badges/modifier-static.svg +10 -0
  19. package/doc/src-page-objects_guerrillamail.page.md +69 -0
  20. package/doc/src-page-objects_maildrop.page.md +57 -0
  21. package/doc/src-page-objects_page.md +484 -0
  22. package/doc/src-page-objects_yopmail.page.md +57 -0
  23. package/doc/src-wdio_ado-reporter.md +93 -0
  24. package/doc/src-wdio_test-context-worker-service.md +48 -0
  25. package/doc/src_ado-client.md +55 -0
  26. package/doc/src_logger.md +183 -0
  27. package/doc/src_mail-service.md +54 -0
  28. package/doc/src_pdf-generator.md +35 -0
  29. package/doc/src_test-context.md +173 -0
  30. package/doc/toc.md +22 -0
  31. package/eslint.config.js +100 -0
  32. package/jsconfig.json +14 -0
  33. package/package.json +58 -0
  34. package/src/ado-client.js +96 -0
  35. package/src/index.js +8 -0
  36. package/src/logger.js +125 -0
  37. package/src/mail-service.js +81 -0
  38. package/src/page-objects/guerrillamail.page.js +144 -0
  39. package/src/page-objects/index.js +3 -0
  40. package/src/page-objects/maildrop.page.js +97 -0
  41. package/src/page-objects/page.js +463 -0
  42. package/src/page-objects/yopmail.page.js +102 -0
  43. package/src/pdf-generator.js +61 -0
  44. package/src/test-context.js +248 -0
  45. package/src/types.d.ts +60 -0
  46. package/src/wdio/ado-reporter.js +111 -0
  47. package/src/wdio/test-context-worker-service.js +88 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * **Test Context** to be populated and passed to WDIO specs.
3
+ * @module
4
+ * @category public
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ import { faker } from '@faker-js/faker/locale/fr';
11
+ import merge from 'lodash.merge';
12
+ import { SevereServiceError } from 'webdriverio';
13
+
14
+ import { MailService } from './mail-service.js';
15
+ import { logger } from './logger.js';
16
+
17
+ /** @typedef {import('./types.js').TestEnvironmentConfiguration} TestEnvironmentConfiguration */
18
+ /** @typedef {import('./types.js').TestUser} TestUser */
19
+ /** @typedef {import('./types.js').MailProviderName} MailProviderName */
20
+ /** @typedef {import('./types.js').DataGenerator} DataGenerator */
21
+ /** @typedef {import('./types.js').LogLevel} LogLevel */
22
+
23
+ export const defaultDatasetName = 'default';
24
+ const defaultRecordedUsersFilename = 'users.json';
25
+ /** @type {MailProviderName} */ const defaultMailProviderName = 'maildrop';
26
+
27
+ /**
28
+ * Implements **Test Context** with data to pass to WDIO specs.
29
+ */
30
+ export class TestContext {
31
+ /** Test user @type {TestUser} */ user;
32
+ /** Mail service @type {MailService} */ mailService;
33
+
34
+ /** Test environments @private @type {Map<string, TestEnvironmentConfiguration>} */
35
+ _environments = new Map();
36
+ /** Mail provider name @private @type {MailProviderName} */ _mailProviderName;
37
+ /** data folder @private @type {string} */ _dataFolder;
38
+ /** log folder @private @type {string} */ _logFolder;
39
+ /** data folder @private @type {string} */ _recordedUsersFilepath;
40
+ /** custom data generator @private @type {DataGenerator} */ _dataGenerator;
41
+ /** default dataset name @private @type {string} */ _defaultDatasetName;
42
+
43
+ /**
44
+ * Implements a test context with environments and dataset in webdriverio.
45
+ * @param {string} name name of the environment (defined by TARGET_ENV environment variable)
46
+ * @param {Record<string, any>} parameters custom parameters (like 'url')
47
+ * @param {import('./types.js').EnvironmentOptions} [environmentOptions] custom options
48
+ * @returns {TestContext}
49
+ */
50
+ setEnvironment(name, parameters, environmentOptions) {
51
+ const options = environmentOptions ?? { perEnvironmentData: false };
52
+ this._environments.set(name, { parameters: new Map(Object.entries(parameters)), options });
53
+ return this;
54
+ }
55
+
56
+ /**
57
+ * Defines a custom function that adds data to the current dataset
58
+ * @param {DataGenerator} dataGenerator
59
+ * @returns {TestContext}
60
+ */
61
+ setDataGenerator(dataGenerator) {
62
+ this._dataGenerator = dataGenerator;
63
+ return this;
64
+ }
65
+
66
+ /**
67
+ * Defines the **default dataset** when no DATASET environment variable is specified.
68
+ * @param {string} name name ot the default dataset
69
+ * @returns {TestContext}
70
+ */
71
+ setDefaultDataset(name) {
72
+ this._defaultDatasetName = name ?? defaultDatasetName;
73
+ return this;
74
+ }
75
+
76
+ /**
77
+ * Defines the **disposable email provider**. Overriden by MAILPROVIDER environment variable. **maildrop** is default value.
78
+ * @param {MailProviderName} name name of the disposable mail service provider
79
+ * @returns {TestContext}
80
+ */
81
+ setMailProvider(name) {
82
+ this._mailProviderName = /** @type {import('./types.js').MailProviderName} */ (
83
+ process.env.MAILPROVIDER ?? name
84
+ );
85
+ return this;
86
+ }
87
+
88
+ /**
89
+ * Sets the logging level.
90
+ * @param {LogLevel} level logging level
91
+ */
92
+ setLogLevel(level) {
93
+ logger.setLogLevel(level);
94
+ }
95
+
96
+ /**
97
+ * Initializes the data of the test context.
98
+ * @returns {TestContext}
99
+ */
100
+ initialize() {
101
+ // email provider
102
+ this.mailService = new MailService(this._mailProviderName ?? defaultMailProviderName);
103
+ // data folder
104
+ this._dataFolder =
105
+ (this.environment.options.perEnvironmentData ?? false)
106
+ ? path.join(process.cwd(), 'data', this.environmentName)
107
+ : path.join(process.cwd(), 'data');
108
+ this._logFolder = path.join(process.cwd(), 'data', 'log');
109
+ this._recordedUsersFilepath = path.join(this._logFolder, defaultRecordedUsersFilename);
110
+ const datasetFilepath = path.join(this._dataFolder, `${this.dataset}.json`);
111
+ if (!fs.existsSync(datasetFilepath))
112
+ throw new SevereServiceError(`Unable to find dataset file '${datasetFilepath}'!`);
113
+ // generate random user
114
+ const user = this._generateRandomUser(this.mailService);
115
+ // generate custom data
116
+ const customData = this._dataGenerator(this);
117
+ // load dataset
118
+ const data = JSON.parse(fs.readFileSync(datasetFilepath, 'utf8'));
119
+ // add dataset to test context
120
+ merge(this, data);
121
+ // add generated user to test context
122
+ merge(this, { user });
123
+ // add custom data to test context
124
+ merge(this, customData);
125
+ // EMAIL environment variable to force a previous recorder user
126
+ const email = process.env.EMAIL;
127
+ if (email) {
128
+ merge(this, { user: this._getPreviouslyRecordedUser(email) });
129
+ }
130
+ return this;
131
+ }
132
+
133
+ /**
134
+ * Retrieves the specified parameter of the current environment.
135
+ * @param {string} name
136
+ * @param {boolean} [mandatory]
137
+ * @returns {any | undefined}
138
+ */
139
+ getParameter(name, mandatory) {
140
+ if (mandatory && !this.environment.parameters.has(name))
141
+ throw new Error(`Parameter '${name}' was not defined for '${this.environmentName}' environment!`);
142
+ return this.environment.parameters.get(name);
143
+ }
144
+
145
+ /**
146
+ * Retrieves the name of the current environment.
147
+ * @returns {string}
148
+ */
149
+ get environmentName() {
150
+ if (this._environments.size === 0)
151
+ throw new SevereServiceError('At least one environment must be set! Check your environment.js file.');
152
+ const firstEnvironmentName = this._environments.keys().next().value;
153
+ return (process.env.TARGET_ENV || firstEnvironmentName).toLowerCase();
154
+ }
155
+
156
+ /**
157
+ * Retrieves the configuration of the current environment.
158
+ * @returns {TestEnvironmentConfiguration}
159
+ */
160
+ get environment() {
161
+ return this._environments.get(this.environmentName);
162
+ }
163
+
164
+ /**
165
+ * The current dataset.
166
+ * @returns {string}
167
+ */
168
+ get dataset() {
169
+ return process.env.DATASET || this._defaultDatasetName;
170
+ }
171
+
172
+ /**
173
+ * Is the current dataset the default one.
174
+ * @returns {boolean}
175
+ */
176
+ get isDefaultDataset() {
177
+ return this._defaultDatasetName === this.dataset;
178
+ }
179
+
180
+ /**
181
+ * Generates a random user.
182
+ * @private
183
+ * @param {MailService} mailService
184
+ * @returns {TestUser}
185
+ */
186
+ _generateRandomUser(mailService) {
187
+ if (!process.env.USER_PASSWORD)
188
+ throw new SevereServiceError(
189
+ `The default password for generated user was not provided with the 'USER_PASSWORD' environment variable!`
190
+ );
191
+ // random user
192
+ const sex = /** @type {'male' | 'female'} */ (faker.person.sexType());
193
+ const gender = sex === 'female' ? 'Madame' : 'Monsieur';
194
+ const lastname = faker.person.lastName().toUpperCase();
195
+ const firstname = faker.person.firstName(sex);
196
+ // random email
197
+ const domain = faker.helpers.arrayElement(mailService.domains);
198
+ const suffix = new Date().getMilliseconds();
199
+ const email = `${firstname[0]}${lastname}-${suffix}@${domain}`
200
+ .normalize('NFD')
201
+ .replaceAll(/[\u0300-\u036F]/g, '')
202
+ .replaceAll(new RegExp(' ', 'g'), '')
203
+ .toLowerCase();
204
+ return { gender, lastname, firstname, email, password: process.env.USER_PASSWORD };
205
+ }
206
+
207
+ /**
208
+ * Retrieves the previously recorded user.
209
+ * @private
210
+ * @param {string} email
211
+ * @returns {TestUser}
212
+ */
213
+ _getPreviouslyRecordedUser(email) {
214
+ /** @type {import('./types.js').RecordedTestUser[]} */
215
+ const users = JSON.parse(fs.readFileSync(this._recordedUsersFilepath, 'utf8'));
216
+ const lastUser = email?.toLowerCase() === 'last';
217
+ const previouslyRecordedUser = lastUser
218
+ ? users.findLast(a => a.environment === this.environmentName)
219
+ : users.findLast(a => a.email === email && a.environment === this.environmentName);
220
+ if (!previouslyRecordedUser) {
221
+ const userInfo = lastUser ? 'last recorded user' : `previously recorded user with email '${email}'`;
222
+ throw new SevereServiceError(
223
+ `Unable to find ${userInfo} for '${this.environmentName}' in the ${defaultRecordedUsersFilename} file!`
224
+ );
225
+ }
226
+ delete previouslyRecordedUser.dataset;
227
+ delete previouslyRecordedUser.date;
228
+ delete previouslyRecordedUser.environment;
229
+ return previouslyRecordedUser;
230
+ }
231
+
232
+ /**
233
+ * Records the current created user.
234
+ */
235
+ recordTestUser() {
236
+ if (!fs.existsSync(this._recordedUsersFilepath)) {
237
+ fs.mkdirSync(this._logFolder, { recursive: true });
238
+ fs.writeFileSync(this._recordedUsersFilepath, JSON.stringify([]));
239
+ }
240
+ const { firstname, lastname, gender, email, password } = this.user;
241
+ const environment = this.environmentName;
242
+ const dataset = this.dataset;
243
+ const date = new Date().toLocaleString('fr-FR');
244
+ const users = JSON.parse(fs.readFileSync(this._recordedUsersFilepath, 'utf8'));
245
+ users.push({ environment, dataset, date, lastname, firstname, gender, email, password });
246
+ fs.writeFileSync(this._recordedUsersFilepath, JSON.stringify(users));
247
+ }
248
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { TestContext } from './test-context';
2
+
3
+ export { TestContext };
4
+
5
+ /** TestContextWorkerService options */
6
+ export type TestContextWorkerServiceOptions = {
7
+ testContext: TestContext;
8
+ logLevel?: LogLevel;
9
+ };
10
+
11
+ /** User gender. */
12
+ export type Gender = 'Madame' | 'Monsieur';
13
+
14
+ /** Implemented disposable email provider */
15
+ export type MailProviderName = 'yopmail' | 'maildrop' | 'guerrillamail';
16
+
17
+ /** Disposable email provider */
18
+ export type MailProvider = {
19
+ /** name of the provider */ name: string;
20
+ /** domains exposed of the provider */ domains: string[];
21
+ /** wdio logic to open the inbox */ openInbox: (email?: string) => Promise<void>;
22
+ /** wdio logic to get emails */ getMessages: () => Promise<{ from: string; subject: string }[]>;
23
+ /** wdio logic to retieve email content */ getMessageContent: (index: number) => Promise<string>;
24
+ };
25
+
26
+ /** Randomly auto generated user */
27
+ export type TestUser = {
28
+ /** last name */ lastname: string;
29
+ /** first name */ firstname: string;
30
+ /** gender */ gender: Gender;
31
+ /** e-mail */ email: string;
32
+ /** password */ password: string;
33
+ };
34
+
35
+ /** Recorded generated user */
36
+ export type RecordedTestUser = TestUser & {
37
+ /** target environment used for recording */ environment: string;
38
+ /** dataset used for recording */ dataset: string;
39
+ /** recording date */ date: string;
40
+ };
41
+
42
+ /** Log level */
43
+ export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
44
+
45
+ /** Test environment configuration*/
46
+ export type TestEnvironmentConfiguration = {
47
+ /** parameters of the environment */ parameters: Map<string, any>;
48
+ /** options of the environment */ options: EnvironmentOptions;
49
+ };
50
+
51
+ /** Custom options for the environement */
52
+ export type EnvironmentOptions = {
53
+ /** Retrieve dataset **per environment** rather than globally. Dataset are global if not specified. */
54
+ perEnvironmentData?: boolean;
55
+ };
56
+
57
+ /** Generate dynamic data to append to TestContext */
58
+ export type DataGenerator = {
59
+ (context: TestContext): any;
60
+ };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * **WDIO Reporter** to handle Azure DevOps Test Run generation.
3
+ * @module
4
+ * @category public
5
+ * @public
6
+ */
7
+
8
+ import { inspect } from 'node:util';
9
+
10
+ import WDIOReporter from '@wdio/reporter';
11
+
12
+ import { createAdoTestRun } from '../ado-client.js';
13
+ import { logger } from '../logger.js';
14
+ import { defaultDatasetName } from '../test-context.js';
15
+
16
+ /** @typedef { import('azure-devops-node-api/interfaces/TestInterfaces.d.ts').TestCaseResult} TestResult */
17
+
18
+ /** Default Azure Devops Test Run model @type {import('../ado-client.js').RunModel} */
19
+ const testRun = {
20
+ name: 'Tests e2e',
21
+ results: [],
22
+ state: 'Completed'
23
+ };
24
+
25
+ export class AdoReporter extends WDIOReporter {
26
+ /**
27
+ * Implements the **WDIO Reporter** that produces an Azure DevOps Test Run.
28
+ */
29
+ constructor(options) {
30
+ super(options);
31
+ this.isSynchronising = false;
32
+ }
33
+
34
+ get isSynchronised() {
35
+ return this.isSynchronising;
36
+ }
37
+
38
+ /**
39
+ * Gets executed when the runner starts.
40
+ * @param {import('@wdio/reporter').RunnerStats} run run information
41
+ */
42
+ onRunnerStart(run) {
43
+ const projet = process.env.SYSTEM_TEAMPROJECT;
44
+ const dataset = process.env.DATASET || defaultDatasetName;
45
+ const environnement = process.env.TARGET_ENV.toUpperCase();
46
+ const buildId = process.env.BUILD_BUILDID;
47
+ testRun.startDate = new Date().toISOString();
48
+ testRun.name = `Tests e2e ${projet} | Scénario '${dataset}' sur ${environnement}`;
49
+ testRun.comment = `Executé avec ${run.capabilities.browserName}(${run.capabilities.browserVersion}) | ${run.capabilities.platformName}`;
50
+ testRun.build = { id: buildId };
51
+ }
52
+
53
+ /**
54
+ * Gets executed when the test passes.
55
+ * @param {import('@wdio/reporter').TestStats} test test information
56
+ */
57
+ onTestPass(test) {
58
+ testRun.results.push({
59
+ automatedTestName: test.fullTitle,
60
+ testCaseTitle: test.fullTitle,
61
+ startedDate: test.start,
62
+ completedDate: test.end,
63
+ outcome: 'Passed',
64
+ state: 'Completed'
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Gets executed when the test fails.
70
+ * @param {import('@wdio/reporter').TestStats} test test information
71
+ */
72
+ onTestFail(test) {
73
+ testRun.state = 'Aborted';
74
+ testRun.results.push({
75
+ automatedTestName: test.fullTitle,
76
+ testCaseTitle: test.fullTitle,
77
+ startedDate: test.start,
78
+ completedDate: test.end,
79
+ outcome: test.error.stack === undefined ? 'Failed' : 'Error',
80
+ errorMessage: test.error.message,
81
+ stackTrace: test.error.stack,
82
+ state: 'Completed'
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Gets executed when the test is skipped.
88
+ * @param {import('@wdio/reporter').TestStats} test test information
89
+ */
90
+ onTestSkip(test) {
91
+ testRun.results.push({
92
+ automatedTestName: test.fullTitle,
93
+ testCaseTitle: test.fullTitle,
94
+ startedDate: test.start,
95
+ completedDate: test.end,
96
+ outcome: 'NotExecuted',
97
+ state: 'Completed'
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Gets executed when the runner ends.
103
+ * @param {import('@wdio/reporter').RunnerStats} _run run information
104
+ */
105
+ async onRunnerEnd(_run) {
106
+ testRun.completedDate = new Date().toISOString();
107
+ logger.debug(inspect(testRun, { colors: true, depth: 5 }));
108
+ await createAdoTestRun(testRun);
109
+ this.isSynchronising = true;
110
+ }
111
+ }
@@ -0,0 +1,88 @@
1
+ /* global browser */
2
+
3
+ import path from 'node:path';
4
+ import fs from 'node:fs/promises';
5
+ import { inspect } from 'node:util';
6
+
7
+ import moment from 'moment';
8
+ import { SevereServiceError } from 'webdriverio';
9
+
10
+ import { logger } from '../logger.js';
11
+
12
+ /** @typedef {import('@wdio/types/build/Options.d.ts').Testrunner } TestrunnerOptions */
13
+ /** @typedef {import('@wdio/types/build/Capabilities.d.ts').TestrunnerCapabilities} TestrunnerCapabilities */
14
+ /** @typedef {import('@wdio/types/build/Frameworks.d.ts').Test} Test */
15
+ /** @typedef {import('@wdio/types/build/Frameworks.d.ts').TestResult} TestResult */
16
+ /** @typedef {import('../types.js').TestContextWorkerServiceOptions} TestContextWorkerServiceOptions */
17
+ /** @typedef {import('../types.js').LogLevel} LogLevel */
18
+
19
+ /** @type {(object: any) => any } */ const filterPrivateProperty = object =>
20
+ Object.fromEntries(Object.entries(object).filter(([key]) => !key.startsWith('_') && key !== 'mailService'));
21
+
22
+ export default class TestContextWorkerService {
23
+ /**
24
+ * Imlements the **WDIO Worker Service** that handles the Test Context life cycle.
25
+ * @param {TestContextWorkerServiceOptions} serviceOptions
26
+ * @param {TestrunnerCapabilities} _capabilities
27
+ * @param {TestrunnerOptions} _config
28
+ */
29
+ constructor(serviceOptions, _capabilities, _config) {
30
+ const { testContext, logLevel } = serviceOptions;
31
+ if (!testContext)
32
+ throw new SevereServiceError(
33
+ 'No context specified in TestContextWorkerService options! This is a required parameter.'
34
+ );
35
+ this._logLevel = logLevel ?? 'error';
36
+ logger.setLogLevel(this._logLevel);
37
+ this._context = testContext;
38
+ this._context.setLogLevel(this._logLevel);
39
+ }
40
+
41
+ /**
42
+ * Gets executed just before initialising the webdriver session and test framework.
43
+ * **Displays** the Test Context.
44
+ * @param {TestrunnerOptions} _config
45
+ * @param {TestrunnerCapabilities} _capabilities
46
+ * @param {string[]} _specs
47
+ * @param {string} _cid
48
+ */
49
+ beforeSession(_config, _capabilities, _specs, _cid) {
50
+ logger.debug(
51
+ inspect(filterPrivateProperty(this._context), {
52
+ colors: true,
53
+ depth: 5
54
+ })
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Gets executed after a test.
60
+ * Generates **error screenshot** if applicable.
61
+ * @param {Test} test
62
+ * @param {*} _context
63
+ * @param {TestResult} result
64
+ */
65
+ async afterTest(test, _context, result) {
66
+ const { passed, error: testError } = result;
67
+ if (passed || !testError || !this._context) return;
68
+ const { dataset, environmentName } = this._context;
69
+ try {
70
+ const folder = path.join(process.cwd(), 'screenshot', environmentName, dataset);
71
+ await fs.mkdir(folder, { recursive: true });
72
+ const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss');
73
+ const filename = path.join(folder, `error-${timestamp}`);
74
+ const { message, stack } = testError;
75
+ const errorLines = stack?.split('at ') ?? [];
76
+ const errorFile = errorLines
77
+ .find((/** @type {string} */ l) => l.toLowerCase().includes('e2e.js'))
78
+ .trim();
79
+ const fileContent = `Test : ${test.fullName}\nErreur : ${message}${
80
+ errorFile ? '\nEmplacement : ' + errorFile : ''
81
+ }`;
82
+ await fs.writeFile(`${filename}.txt`, fileContent, { encoding: 'utf8', flag: 'w', flush: true });
83
+ await browser.saveScreenshot(`${filename}.png`);
84
+ } catch (error) {
85
+ logger.detailError(error);
86
+ }
87
+ }
88
+ }