playwright-slack-report 1.0.4
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.
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/dist/src/LayoutGenerator.d.ts +4 -0
- package/dist/src/LayoutGenerator.js +66 -0
- package/dist/src/ResultsParser.d.ts +37 -0
- package/dist/src/ResultsParser.js +104 -0
- package/dist/src/SlackClient.d.ts +22 -0
- package/dist/src/SlackClient.js +56 -0
- package/dist/src/SlackReporter.d.ts +19 -0
- package/dist/src/SlackReporter.js +96 -0
- package/dist/src/custom_block/my_block.d.ts +4 -0
- package/dist/src/custom_block/my_block.js +89 -0
- package/dist/src/custom_block/simple.d.ts +3 -0
- package/dist/src/custom_block/simple.js +16 -0
- package/dist/src/custom_block/simple_with_meta.d.ts +3 -0
- package/dist/src/custom_block/simple_with_meta.js +30 -0
- package/dist/src/index.d.ts +32 -0
- package/dist/src/index.js +2 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 ryanrosello-og
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# playwright-slack-report  [](https://github.com/ryanrosello-og/playwright-slack-report/blob/master/LICENSE)
|
|
2
|
+
|
|
3
|
+
[](https://gitpod.io/#https://github.com/ryanrosello-og/playwright-slack-report)
|
|
4
|
+
|
|
5
|
+
Publish your Playwright test results to your favorite Slack channel(s).
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## đ Features
|
|
10
|
+
|
|
11
|
+
- đ Send results your Playwright test results to one or more Slack channels
|
|
12
|
+
- đ Conditionally send results to Slack channels based on test results
|
|
13
|
+
- đ Include additional meta information into your test summary e.g. Branch, BuildId etc
|
|
14
|
+
- đ§âđ¨ Define your own custom Slack message layout!
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# đĻ Installation
|
|
18
|
+
|
|
19
|
+
Run following commands:
|
|
20
|
+
|
|
21
|
+
**yarn**
|
|
22
|
+
|
|
23
|
+
`yarn add playwright-slack-report -D`
|
|
24
|
+
|
|
25
|
+
**npm**
|
|
26
|
+
|
|
27
|
+
`npm install playwright-slack-report -D`
|
|
28
|
+
|
|
29
|
+
Modify your `playwright.config.ts` file to include the following:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
reporter: [
|
|
33
|
+
[
|
|
34
|
+
"./node_modules/playwright-slack-report/dist/src/SlackReporter.js",
|
|
35
|
+
{
|
|
36
|
+
channels: ["pw-tests", "ci"], // provide one or more Slack channels
|
|
37
|
+
sendResults: "always", // "always" , "on-failure", "off"
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
["dot"], // other reporters
|
|
41
|
+
],
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run your tests by providing your` SLACK_BOT_USER_OAUTH_TOKEN` as an environment variable:
|
|
45
|
+
|
|
46
|
+
`SLACK_BOT_USER_OAUTH_TOKEN=[your Slack bot user OAUTH token] npx playwright test`
|
|
47
|
+
|
|
48
|
+
# âšī¸ How do I find my Slack bot oauth token?
|
|
49
|
+
|
|
50
|
+
You will need to have Slack administrator rights to perform the steps below.
|
|
51
|
+
|
|
52
|
+
1. Navigate to https://api.slack.com/apps
|
|
53
|
+
2. Click the Create New App button and select "From scratch"
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
3. Input a name for your app and select the target workspace, then click on the **Create App** button
|
|
58
|
+
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
4. Under the Features menu, select **OAuth & Permissions** and scroll down to **Scopes** section
|
|
62
|
+
|
|
63
|
+

|
|
64
|
+
|
|
65
|
+
5. Click the **Add an OAuth Scope** button and select the following scopes:
|
|
66
|
+
|
|
67
|
+

|
|
68
|
+
|
|
69
|
+
* chat:write
|
|
70
|
+
* chat:write.public
|
|
71
|
+
* chat:write.customize
|
|
72
|
+
|
|
73
|
+
6. Scroll up to the OAuth Tokens for Your Workspace and click the **Install to Workspace** button
|
|
74
|
+
|
|
75
|
+

|
|
76
|
+
|
|
77
|
+
> You will be prompted with the message below, click the Allow button
|
|
78
|
+
|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
The final step will be to copy the generated Bot User OAuth Token aka `SLACK_BOT_USER_OAUTH_TOKEN`.
|
|
82
|
+
|
|
83
|
+
>**Treat this token as a secret.**
|
|
84
|
+
|
|
85
|
+

|
|
86
|
+
|
|
87
|
+
# âī¸ Configuration
|
|
88
|
+
|
|
89
|
+
An example advanced configuration is shown below:
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { generateCustomLayout } from "./my_custom_layout";
|
|
94
|
+
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
reporter: [
|
|
98
|
+
[
|
|
99
|
+
"./node_modules/playwright-slack-report/dist/src/SlackReporter.js",
|
|
100
|
+
{
|
|
101
|
+
channels: ["pw-tests", "ci"], // provide one or more Slack channels
|
|
102
|
+
sendResults: "always", // "always" , "on-failure", "off"
|
|
103
|
+
},
|
|
104
|
+
layout: generateCustomLayout,
|
|
105
|
+
meta: [
|
|
106
|
+
{
|
|
107
|
+
key: 'BUILD_NUMBER',
|
|
108
|
+
value: '323332-2341',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
key: 'WHATEVER_ENV_VARIABLE',
|
|
112
|
+
value: process.env.SOME_ENV_VARIABLE, // depending on your CI environment, this can be the branch name, build id, etc
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
],
|
|
116
|
+
],
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### **channels**
|
|
120
|
+
An array of Slack channels to post to, atleast one channel is required
|
|
121
|
+
### **sendResults**
|
|
122
|
+
Can either be *"always"*, *"on-failure"* or *"off"*, this configuration is required:
|
|
123
|
+
* **always** - will send the results to Slack at completion of the test run
|
|
124
|
+
* **on-failure** - will send the results to Slack only if a test failures are encountered
|
|
125
|
+
* **off** - turns off the reporter, it will not send the results to Slack
|
|
126
|
+
### **layout**
|
|
127
|
+
A function that returns a layout object, this configuration is optional. See section below for more details.
|
|
128
|
+
* meta - an array of meta data to be sent to Slack, this configuration is optional.
|
|
129
|
+
|
|
130
|
+
**Examples:**
|
|
131
|
+
```typescript
|
|
132
|
+
...
|
|
133
|
+
meta: [
|
|
134
|
+
{
|
|
135
|
+
key: 'Suite',
|
|
136
|
+
value: 'Nightly full regression',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: 'GITHUB_REPOSITORY',
|
|
140
|
+
value: 'octocat/telsa-ui',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
key: 'GITHUB_REF',
|
|
144
|
+
value: process.env.GITHUB_REF,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
...
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
# đ¨ Define your own Slack message custom layout
|
|
151
|
+
|
|
152
|
+
You can define your own Slack message layout to suit your needs.
|
|
153
|
+
|
|
154
|
+
Firstly, install the necessary type definitions:
|
|
155
|
+
|
|
156
|
+
`yarn add @slack/types -D`
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
Next, define your layout function. The signature of this function should adhere to example below:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { Block, KnownBlock } from "@slack/types";
|
|
163
|
+
import { SummaryResults } from "playwright-slack-report/dist/src";
|
|
164
|
+
|
|
165
|
+
const generateCustomLayout = (summaryResults: SummaryResults):Array<KnownBlock | Block> => {
|
|
166
|
+
// your implementation goes here
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default generateCustomLayout;
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
In your, `playwright.confing.ts` file, add your function into the config.
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
import { generateCustomLayout } from "./my_custom_layout";
|
|
176
|
+
|
|
177
|
+
...
|
|
178
|
+
|
|
179
|
+
reporter: [
|
|
180
|
+
[
|
|
181
|
+
"./node_modules/playwright-slack-report/dist/src/SlackReporter.js",
|
|
182
|
+
{
|
|
183
|
+
channels: ["pw-tests", "ci"], // provide one or more Slack channels
|
|
184
|
+
sendResults: "always", // "always" , "on-failure", "off"
|
|
185
|
+
},
|
|
186
|
+
layout: generateCustomLayout,
|
|
187
|
+
...
|
|
188
|
+
],
|
|
189
|
+
],
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
>Pro Tip: You can use the [block-kit provided by Slack when creating your layout.](https://app.slack.com/block-kit-builder/)
|
|
193
|
+
|
|
194
|
+
### Examples:
|
|
195
|
+
|
|
196
|
+
**Example 1: - very simple summary**
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
import { Block, KnownBlock } from '@slack/types';
|
|
200
|
+
import { SummaryResults } from '..';
|
|
201
|
+
|
|
202
|
+
export default function generateCustomLayoutSimpleExample(
|
|
203
|
+
summaryResults: SummaryResults,
|
|
204
|
+
): Array<Block | KnownBlock> {
|
|
205
|
+
return [
|
|
206
|
+
{
|
|
207
|
+
type: 'section',
|
|
208
|
+
text: {
|
|
209
|
+
type: 'mrkdwn',
|
|
210
|
+
text:
|
|
211
|
+
summaryResults.failed === 0
|
|
212
|
+
? ':tada: All tests passed!'
|
|
213
|
+
: `đ${summaryResults.failed} failure(s) out of ${summaryResults.tests.length} tests`,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Generates the following message in Slack:
|
|
221
|
+
|
|
222
|
+

|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
**Example 2: - very simple summary (with Meta information)**
|
|
226
|
+
|
|
227
|
+
Add the meta block in your config:
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
reporter: [
|
|
231
|
+
[
|
|
232
|
+
"./node_modules/playwright-slack-report/dist/src/SlackReporter.js",
|
|
233
|
+
{
|
|
234
|
+
channels: ["demo"],
|
|
235
|
+
sendResults: "always", // "always" , "on-failure", "off",
|
|
236
|
+
layout: generateCustomLayout,
|
|
237
|
+
meta: [
|
|
238
|
+
{
|
|
239
|
+
key: 'EXAMPLE_META_node_env',
|
|
240
|
+
value: process.env.HOME ,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
],
|
|
246
|
+
],
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Create the function to generate the layout:
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
import { Block, KnownBlock } from '@slack/types';
|
|
253
|
+
import { SummaryResults } from '..';
|
|
254
|
+
|
|
255
|
+
export default function generateCustomLayoutSimpleMeta(
|
|
256
|
+
summaryResults: SummaryResults,
|
|
257
|
+
): Array<Block | KnownBlock> {
|
|
258
|
+
const meta: { type: string; text: { type: string; text: string; }; }[] = [];
|
|
259
|
+
if (summaryResults.meta) {
|
|
260
|
+
for (let i = 0; i < summaryResults.meta.length; i += 1) {
|
|
261
|
+
const { key, value } = summaryResults.meta[i];
|
|
262
|
+
meta.push({
|
|
263
|
+
type: 'section',
|
|
264
|
+
text: {
|
|
265
|
+
type: 'mrkdwn',
|
|
266
|
+
text: `\n*${key}* :\t${value}`,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return [
|
|
272
|
+
{
|
|
273
|
+
type: 'section',
|
|
274
|
+
text: {
|
|
275
|
+
type: 'mrkdwn',
|
|
276
|
+
text:
|
|
277
|
+
summaryResults.failed === 0
|
|
278
|
+
? ':tada: All tests passed!'
|
|
279
|
+
: `đ${summaryResults.failed} failure(s) out of ${summaryResults.tests.length} tests`,
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
...meta,
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Generates the following message in Slack:
|
|
289
|
+
|
|
290
|
+

|
|
291
|
+
|
|
292
|
+
# đ License
|
|
293
|
+
|
|
294
|
+
[MIT](https://github.com/ryanrosello-og/playwright-slack-report/blob/main/LICENSE)
|
|
295
|
+
|
|
296
|
+
# ⨠Contributing
|
|
297
|
+
|
|
298
|
+
Clone the project and run `npm install`
|
|
299
|
+
|
|
300
|
+
Make your changes
|
|
301
|
+
Run the tests using `npm run pw`
|
|
302
|
+
|
|
303
|
+
**To execute and test the entire package:**
|
|
304
|
+
|
|
305
|
+
Run `npm pack`
|
|
306
|
+
|
|
307
|
+
Create a new playwright project using `yarn create playwright`
|
|
308
|
+
Modify the `package.json` and a local dependancy to the generated `tgz` file
|
|
309
|
+
|
|
310
|
+
e.g.
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
"dependencies": {
|
|
314
|
+
"playwright-slack-report": "/home/ry/_repo/playwright-slack-report/playwright-slack-report-1.0.3.tgz"
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
* Execute `npm install`
|
|
319
|
+
* Set your `SLACK_BOT_USER_OAUTH_TOKEN` environment variable
|
|
320
|
+
* Modify the `playwright.config.ts` as above
|
|
321
|
+
* Run the tests using `npx playwright text`
|
|
322
|
+
|
|
323
|
+
# đ Something not working for you?
|
|
324
|
+
|
|
325
|
+
Feel free to [raise a github issue](https://github.com/ryanrosello-og/playwright-slack-report/issues) for any bugs or feature requests.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const generateBlocks = async (summaryResults) => {
|
|
4
|
+
const maxNumberOfFailures = 10;
|
|
5
|
+
const maxNumberOfFailureLength = 650;
|
|
6
|
+
const fails = [];
|
|
7
|
+
const meta = [];
|
|
8
|
+
const summary = {
|
|
9
|
+
type: 'section',
|
|
10
|
+
text: {
|
|
11
|
+
type: 'mrkdwn',
|
|
12
|
+
text: `:white_check_mark: *${summaryResults.passed}* Tests ran successfully \n\n :red_circle: *${summaryResults.failed}* Tests failed \n\n ${summaryResults.skipped > 0
|
|
13
|
+
? `:fast_forward: *${summaryResults.skipped}* skipped`
|
|
14
|
+
: ''} \n\n ${summaryResults.aborted > 0
|
|
15
|
+
? `:exclamation: *${summaryResults.aborted}* aborted`
|
|
16
|
+
: ''}`,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
for (let i = 0; i < summaryResults.failures.length; i += 1) {
|
|
20
|
+
const { failureReason, test } = summaryResults.failures[i];
|
|
21
|
+
const formattedFailure = failureReason
|
|
22
|
+
.substring(0, maxNumberOfFailureLength)
|
|
23
|
+
.split('\n')
|
|
24
|
+
.map((l) => `>${l}`)
|
|
25
|
+
.join('\n');
|
|
26
|
+
fails.push({
|
|
27
|
+
type: 'section',
|
|
28
|
+
text: {
|
|
29
|
+
type: 'mrkdwn',
|
|
30
|
+
text: `*${test}*
|
|
31
|
+
\n\n${formattedFailure}`,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
if (i > maxNumberOfFailures) {
|
|
35
|
+
fails.push({
|
|
36
|
+
type: 'section',
|
|
37
|
+
text: {
|
|
38
|
+
type: 'mrkdwn',
|
|
39
|
+
text: '*There are too many failures to display*',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (summaryResults.meta) {
|
|
46
|
+
for (let i = 0; i < summaryResults.meta.length; i += 1) {
|
|
47
|
+
const { key, value } = summaryResults.meta[i];
|
|
48
|
+
meta.push({
|
|
49
|
+
type: 'section',
|
|
50
|
+
text: {
|
|
51
|
+
type: 'mrkdwn',
|
|
52
|
+
text: `\n*${key}* :\t${value}`,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [
|
|
58
|
+
summary,
|
|
59
|
+
...meta,
|
|
60
|
+
{
|
|
61
|
+
type: 'divider',
|
|
62
|
+
},
|
|
63
|
+
...fails,
|
|
64
|
+
];
|
|
65
|
+
};
|
|
66
|
+
exports.default = generateBlocks;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { failure, SummaryResults } from '.';
|
|
3
|
+
export declare type testResult = {
|
|
4
|
+
suiteName: string;
|
|
5
|
+
name: string;
|
|
6
|
+
browser?: string;
|
|
7
|
+
endedAt: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
retry: number;
|
|
10
|
+
startedAt: string;
|
|
11
|
+
status: 'failed' | 'passed' | 'skipped' | 'aborted';
|
|
12
|
+
attachments?: {
|
|
13
|
+
body: string | undefined | Buffer;
|
|
14
|
+
contentType: string;
|
|
15
|
+
name: string;
|
|
16
|
+
path: string;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
19
|
+
export declare type testSuite = {
|
|
20
|
+
testSuite: {
|
|
21
|
+
title: string;
|
|
22
|
+
tests: testResult[];
|
|
23
|
+
testRunId?: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export default class ResultsParser {
|
|
27
|
+
private result;
|
|
28
|
+
constructor();
|
|
29
|
+
getParsedResults(): Promise<SummaryResults>;
|
|
30
|
+
getFailures(): Promise<Array<failure>>;
|
|
31
|
+
updateResults(data: {
|
|
32
|
+
testSuite: any;
|
|
33
|
+
}): void;
|
|
34
|
+
addTestResult(suiteName: any, test: any): void;
|
|
35
|
+
parseTests(suiteName: any, tests: any): Promise<testResult[]>;
|
|
36
|
+
cleanseReason(rawReaseon: string): string;
|
|
37
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* eslint-disable import/extensions */
|
|
3
|
+
/* eslint-disable no-control-regex */
|
|
4
|
+
/* eslint-disable class-methods-use-this */
|
|
5
|
+
/* eslint-disable no-param-reassign */
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
class ResultsParser {
|
|
8
|
+
result;
|
|
9
|
+
constructor() {
|
|
10
|
+
this.result = [];
|
|
11
|
+
}
|
|
12
|
+
async getParsedResults() {
|
|
13
|
+
const summary = {
|
|
14
|
+
passed: 0,
|
|
15
|
+
failed: 0,
|
|
16
|
+
skipped: 0,
|
|
17
|
+
aborted: 0,
|
|
18
|
+
failures: await this.getFailures(),
|
|
19
|
+
tests: [],
|
|
20
|
+
};
|
|
21
|
+
for (const suite of this.result) {
|
|
22
|
+
summary.tests = summary.tests.concat(suite.testSuite.tests);
|
|
23
|
+
for (const test of suite.testSuite.tests) {
|
|
24
|
+
if (test.status === 'passed') {
|
|
25
|
+
summary.passed += 1;
|
|
26
|
+
}
|
|
27
|
+
else if (test.status === 'failed') {
|
|
28
|
+
summary.failed += 1;
|
|
29
|
+
}
|
|
30
|
+
else if (test.status === 'skipped') {
|
|
31
|
+
summary.skipped += 1;
|
|
32
|
+
}
|
|
33
|
+
else if (test.status === 'aborted') {
|
|
34
|
+
summary.aborted += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return summary;
|
|
39
|
+
}
|
|
40
|
+
async getFailures() {
|
|
41
|
+
const failures = [];
|
|
42
|
+
for (const suite of this.result) {
|
|
43
|
+
for (const test of suite.testSuite.tests) {
|
|
44
|
+
if (test.status === 'failed') {
|
|
45
|
+
failures.push({
|
|
46
|
+
test: test.name,
|
|
47
|
+
failureReason: test.reason,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return failures;
|
|
53
|
+
}
|
|
54
|
+
updateResults(data) {
|
|
55
|
+
if (data.testSuite.tests.length > 0) {
|
|
56
|
+
this.result.push(data);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
addTestResult(suiteName, test) {
|
|
60
|
+
const testResults = [];
|
|
61
|
+
for (const result of test.results) {
|
|
62
|
+
testResults.push({
|
|
63
|
+
suiteName,
|
|
64
|
+
name: test.title,
|
|
65
|
+
status: result.status,
|
|
66
|
+
retry: result.retry,
|
|
67
|
+
startedAt: new Date(result.startTime).toISOString(),
|
|
68
|
+
endedAt: new Date(new Date(result.startTime).getTime() + result.duration).toISOString(),
|
|
69
|
+
reason: `${this.cleanseReason(result.error?.message)} \n ${this.cleanseReason(result.error?.stack)}`,
|
|
70
|
+
attachments: result.attachments,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
this.updateResults({
|
|
74
|
+
testSuite: {
|
|
75
|
+
title: suiteName,
|
|
76
|
+
tests: testResults,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async parseTests(suiteName, tests) {
|
|
81
|
+
const testResults = [];
|
|
82
|
+
for (const test of tests) {
|
|
83
|
+
for (const result of test.results) {
|
|
84
|
+
testResults.push({
|
|
85
|
+
suiteName,
|
|
86
|
+
name: test.title,
|
|
87
|
+
status: result.status,
|
|
88
|
+
retry: result.retry,
|
|
89
|
+
startedAt: new Date(result.startTime).toISOString(),
|
|
90
|
+
endedAt: new Date(new Date(result.startTime).getTime() + result.duration).toISOString(),
|
|
91
|
+
reason: `${this.cleanseReason(result.error?.message)} \n ${this.cleanseReason(result.error?.stack)}`,
|
|
92
|
+
attachments: result.attachments,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return testResults;
|
|
97
|
+
}
|
|
98
|
+
cleanseReason(rawReaseon) {
|
|
99
|
+
// eslint-disable-next-line prefer-regex-literals
|
|
100
|
+
const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g');
|
|
101
|
+
return rawReaseon ? rawReaseon.replace(ansiRegex, '') : '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
exports.default = ResultsParser;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { WebClient, KnownBlock, Block, ChatPostMessageResponse } from '@slack/web-api';
|
|
2
|
+
import { SummaryResults } from '.';
|
|
3
|
+
export declare type additionalInfo = Array<{
|
|
4
|
+
key: string;
|
|
5
|
+
value: string;
|
|
6
|
+
}>;
|
|
7
|
+
export default class SlackClient {
|
|
8
|
+
private slackWebClient;
|
|
9
|
+
constructor(slackClient: WebClient);
|
|
10
|
+
sendMessage({ options, }: {
|
|
11
|
+
options: {
|
|
12
|
+
channelIds: Array<string>;
|
|
13
|
+
summaryResults: SummaryResults;
|
|
14
|
+
customLayout: Function | undefined;
|
|
15
|
+
fakeRequest?: Function;
|
|
16
|
+
};
|
|
17
|
+
}): Promise<Array<{
|
|
18
|
+
channel: string;
|
|
19
|
+
outcome: string;
|
|
20
|
+
}>>;
|
|
21
|
+
doPostRequest(channel: string, blocks: Array<KnownBlock | Block>): Promise<ChatPostMessageResponse>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const LayoutGenerator_1 = require("./LayoutGenerator");
|
|
4
|
+
class SlackClient {
|
|
5
|
+
slackWebClient;
|
|
6
|
+
constructor(slackClient) {
|
|
7
|
+
this.slackWebClient = slackClient;
|
|
8
|
+
}
|
|
9
|
+
async sendMessage({ options, }) {
|
|
10
|
+
let blocks;
|
|
11
|
+
if (options.customLayout) {
|
|
12
|
+
blocks = options.customLayout(options.summaryResults);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
blocks = await (0, LayoutGenerator_1.default)(options.summaryResults);
|
|
16
|
+
}
|
|
17
|
+
if (!options.channelIds) {
|
|
18
|
+
throw new Error(`Channel ids [${options.channelIds}] is not valid`);
|
|
19
|
+
}
|
|
20
|
+
const result = [];
|
|
21
|
+
for (const channel of options.channelIds) {
|
|
22
|
+
let chatResponse;
|
|
23
|
+
try {
|
|
24
|
+
// under test
|
|
25
|
+
if (options.fakeRequest) {
|
|
26
|
+
chatResponse = await options.fakeRequest();
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
// send request for reals
|
|
30
|
+
chatResponse = await this.doPostRequest(channel, blocks);
|
|
31
|
+
}
|
|
32
|
+
if (chatResponse.ok) {
|
|
33
|
+
result.push({ channel, outcome: `â
Message sent to ${channel}` });
|
|
34
|
+
// eslint-disable-next-line no-console
|
|
35
|
+
console.log(`â
Message sent to ${channel}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
result.push({
|
|
40
|
+
channel,
|
|
41
|
+
outcome: `â Message not sent to ${channel} \r\n ${error.message}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
async doPostRequest(channel, blocks) {
|
|
48
|
+
const chatResponse = await this.slackWebClient.chat.postMessage({
|
|
49
|
+
channel,
|
|
50
|
+
text: ' ',
|
|
51
|
+
blocks,
|
|
52
|
+
});
|
|
53
|
+
return chatResponse;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.default = SlackClient;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { FullConfig, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
|
|
2
|
+
declare class SlackReporter implements Reporter {
|
|
3
|
+
private suite;
|
|
4
|
+
private sendResults;
|
|
5
|
+
private slackChannels;
|
|
6
|
+
private meta;
|
|
7
|
+
private customLayout;
|
|
8
|
+
private resultsParser;
|
|
9
|
+
logs: string[];
|
|
10
|
+
onBegin(fullConfig: FullConfig, suite: Suite): void;
|
|
11
|
+
onTestEnd(test: TestCase, result: TestResult): void;
|
|
12
|
+
onEnd(): Promise<void>;
|
|
13
|
+
preChecks(): {
|
|
14
|
+
okToProceed: boolean;
|
|
15
|
+
message?: string;
|
|
16
|
+
};
|
|
17
|
+
log(message: string | undefined): void;
|
|
18
|
+
}
|
|
19
|
+
export default SlackReporter;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const web_api_1 = require("@slack/web-api");
|
|
4
|
+
const ResultsParser_1 = require("./ResultsParser");
|
|
5
|
+
const SlackClient_1 = require("./SlackClient");
|
|
6
|
+
class SlackReporter {
|
|
7
|
+
suite;
|
|
8
|
+
sendResults = 'on-failure';
|
|
9
|
+
slackChannels = [];
|
|
10
|
+
meta = [];
|
|
11
|
+
customLayout;
|
|
12
|
+
resultsParser;
|
|
13
|
+
logs = [];
|
|
14
|
+
onBegin(fullConfig, suite) {
|
|
15
|
+
this.suite = suite;
|
|
16
|
+
this.logs = [];
|
|
17
|
+
const slackReporterConfig = fullConfig.reporter.filter((f) => f[0].toLowerCase().includes('slackreporter'))[0][1];
|
|
18
|
+
if (slackReporterConfig) {
|
|
19
|
+
this.meta = slackReporterConfig.meta || [];
|
|
20
|
+
this.sendResults = slackReporterConfig.sendResults || 'always';
|
|
21
|
+
this.customLayout = slackReporterConfig.layout;
|
|
22
|
+
this.slackChannels = slackReporterConfig.channels;
|
|
23
|
+
}
|
|
24
|
+
this.resultsParser = new ResultsParser_1.default();
|
|
25
|
+
}
|
|
26
|
+
// eslint-disable-next-line class-methods-use-this, no-unused-vars
|
|
27
|
+
onTestEnd(test, result) {
|
|
28
|
+
this.resultsParser.addTestResult(test.parent.title, test);
|
|
29
|
+
}
|
|
30
|
+
async onEnd() {
|
|
31
|
+
const { okToProceed, message } = this.preChecks();
|
|
32
|
+
if (!okToProceed) {
|
|
33
|
+
this.log(message);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const resultSummary = await this.resultsParser.getParsedResults();
|
|
37
|
+
resultSummary.meta = this.meta;
|
|
38
|
+
if (this.sendResults === 'on-failure'
|
|
39
|
+
&& resultSummary.failures.length === 0) {
|
|
40
|
+
this.log('⊠Slack reporter - no failures found');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const slackClient = new SlackClient_1.default(new web_api_1.WebClient(process.env.SLACK_BOT_USER_OAUTH_TOKEN, {
|
|
44
|
+
logLevel: web_api_1.LogLevel.DEBUG,
|
|
45
|
+
}));
|
|
46
|
+
await slackClient.sendMessage({
|
|
47
|
+
options: {
|
|
48
|
+
channelIds: this.slackChannels,
|
|
49
|
+
summaryResults: resultSummary,
|
|
50
|
+
customLayout: this.customLayout,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
preChecks() {
|
|
55
|
+
if (this.sendResults === 'off') {
|
|
56
|
+
return { okToProceed: false, message: 'â Slack reporter is disabled' };
|
|
57
|
+
}
|
|
58
|
+
if (!process.env.SLACK_BOT_USER_OAUTH_TOKEN) {
|
|
59
|
+
return {
|
|
60
|
+
okToProceed: false,
|
|
61
|
+
message: 'â SLACK_BOT_USER_OAUTH_TOKEN was not found',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (!this.sendResults
|
|
65
|
+
|| !['always', 'on-failure', 'off'].includes(this.sendResults)) {
|
|
66
|
+
return {
|
|
67
|
+
okToProceed: false,
|
|
68
|
+
message: "â \"sendResults\" is not valid. Expecting one of ['always', 'on-failure', 'off'].",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (!this.sendResults || this.slackChannels?.length === 0) {
|
|
72
|
+
return {
|
|
73
|
+
okToProceed: false,
|
|
74
|
+
message: 'â Slack channel(s) was not provided in the config',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (this.customLayout && typeof this.customLayout !== 'function') {
|
|
78
|
+
return {
|
|
79
|
+
okToProceed: false,
|
|
80
|
+
message: 'â Custom layout is not a function',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (this.meta && !Array.isArray(this.meta)) {
|
|
84
|
+
return { okToProceed: false, message: 'â Meta is not an array' };
|
|
85
|
+
}
|
|
86
|
+
return { okToProceed: true };
|
|
87
|
+
}
|
|
88
|
+
log(message) {
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.log(message);
|
|
91
|
+
if (message) {
|
|
92
|
+
this.logs.push(message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.default = SlackReporter;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const generateCustomLayout = (summaryResults) => {
|
|
4
|
+
const maxNumberOfFailures = 10;
|
|
5
|
+
const maxNumberOfFailureLength = 650;
|
|
6
|
+
const fails = [];
|
|
7
|
+
const meta = [];
|
|
8
|
+
for (let i = 0; i < summaryResults.failures.length; i += 1) {
|
|
9
|
+
const { failureReason, test } = summaryResults.failures[i];
|
|
10
|
+
const formattedFailure = failureReason
|
|
11
|
+
.substring(0, maxNumberOfFailureLength)
|
|
12
|
+
.split('\n')
|
|
13
|
+
.map((l) => `>${l}`)
|
|
14
|
+
.join('\n');
|
|
15
|
+
fails.push({
|
|
16
|
+
type: 'section',
|
|
17
|
+
text: {
|
|
18
|
+
type: 'mrkdwn',
|
|
19
|
+
text: `*${test}*
|
|
20
|
+
\n\n${formattedFailure}`,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (i > maxNumberOfFailures) {
|
|
24
|
+
fails.push({
|
|
25
|
+
type: 'section',
|
|
26
|
+
text: {
|
|
27
|
+
type: 'mrkdwn',
|
|
28
|
+
text: '*There are too many failures to display, view the full results in BuildKite*',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (summaryResults.meta) {
|
|
35
|
+
for (let i = 0; i < summaryResults.meta.length; i += 1) {
|
|
36
|
+
const { key, value } = summaryResults.meta[i];
|
|
37
|
+
meta.push({
|
|
38
|
+
type: 'section',
|
|
39
|
+
text: {
|
|
40
|
+
type: 'mrkdwn',
|
|
41
|
+
text: `\n*${key}* :\t${value}`,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const testWithStringAttachment = summaryResults.tests.filter((t) => t.attachments)[0];
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
type: 'section',
|
|
50
|
+
text: {
|
|
51
|
+
type: 'mrkdwn',
|
|
52
|
+
text: '**Thisi is cusomter block**',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 'section',
|
|
57
|
+
text: {
|
|
58
|
+
type: 'mrkdwn',
|
|
59
|
+
text: `**The first test is:**\n${summaryResults.tests[0].name}`,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'section',
|
|
64
|
+
text: {
|
|
65
|
+
type: 'mrkdwn',
|
|
66
|
+
text: `**Test with attachment:**\n${testWithStringAttachment?.attachments
|
|
67
|
+
?.filter((a) => a.contentType === 'text/plain')
|
|
68
|
+
.map((a) => a.body?.toString())}`,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
...meta,
|
|
72
|
+
{
|
|
73
|
+
type: 'section',
|
|
74
|
+
text: {
|
|
75
|
+
type: 'mrkdwn',
|
|
76
|
+
text: `:white_check_mark: *${summaryResults.passed}* Tests ran successfully \n\n :red_circle: *${summaryResults.failed}* Tests failed \n\n ${summaryResults.skipped > 0
|
|
77
|
+
? `:fast_forward: *${summaryResults.skipped}* skipped`
|
|
78
|
+
: ''} \n\n ${summaryResults.aborted > 0
|
|
79
|
+
? `:exclamation: *${summaryResults.aborted}* aborted`
|
|
80
|
+
: ''}`,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
type: 'divider',
|
|
85
|
+
},
|
|
86
|
+
...fails,
|
|
87
|
+
];
|
|
88
|
+
};
|
|
89
|
+
exports.default = generateCustomLayout;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
function generateCustomLayoutSimpleExample(summaryResults) {
|
|
4
|
+
return [
|
|
5
|
+
{
|
|
6
|
+
type: 'section',
|
|
7
|
+
text: {
|
|
8
|
+
type: 'mrkdwn',
|
|
9
|
+
text: summaryResults.failed === 0
|
|
10
|
+
? ':tada: All tests passed!'
|
|
11
|
+
: `đ${summaryResults.failed} failure(s) out of ${summaryResults.tests.length} tests`,
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
exports.default = generateCustomLayoutSimpleExample;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
function generateCustomLayoutSimpleMeta(summaryResults) {
|
|
4
|
+
const meta = [];
|
|
5
|
+
if (summaryResults.meta) {
|
|
6
|
+
for (let i = 0; i < summaryResults.meta.length; i += 1) {
|
|
7
|
+
const { key, value } = summaryResults.meta[i];
|
|
8
|
+
meta.push({
|
|
9
|
+
type: 'section',
|
|
10
|
+
text: {
|
|
11
|
+
type: 'mrkdwn',
|
|
12
|
+
text: `\n*${key}* :\t${value}`,
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
type: 'section',
|
|
20
|
+
text: {
|
|
21
|
+
type: 'mrkdwn',
|
|
22
|
+
text: summaryResults.failed === 0
|
|
23
|
+
? ':tada: All tests passed!'
|
|
24
|
+
: `đ${summaryResults.failed} failure(s) out of ${summaryResults.tests.length} tests`,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
...meta,
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
exports.default = generateCustomLayoutSimpleMeta;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
export declare type SummaryResults = {
|
|
3
|
+
passed: number;
|
|
4
|
+
failed: number;
|
|
5
|
+
skipped: number;
|
|
6
|
+
aborted: number;
|
|
7
|
+
failures: Array<failure>;
|
|
8
|
+
meta?: Array<{
|
|
9
|
+
key: string;
|
|
10
|
+
value: string;
|
|
11
|
+
}>;
|
|
12
|
+
tests: Array<{
|
|
13
|
+
suiteName: string;
|
|
14
|
+
name: string;
|
|
15
|
+
browser?: string;
|
|
16
|
+
endedAt: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
retry: number;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
status: 'failed' | 'passed' | 'skipped' | 'aborted';
|
|
21
|
+
attachments?: {
|
|
22
|
+
body: string | undefined | Buffer;
|
|
23
|
+
contentType: string;
|
|
24
|
+
name: string;
|
|
25
|
+
path: string;
|
|
26
|
+
}[];
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
export declare type failure = {
|
|
30
|
+
test: string;
|
|
31
|
+
failureReason: string;
|
|
32
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@playwright/test": "^1.23.3",
|
|
4
|
+
"@slack/web-api": "^6.7.2",
|
|
5
|
+
"dotenv": "^16.0.1",
|
|
6
|
+
"playwright": "^1.23.3",
|
|
7
|
+
"typescript": "^4.7.4",
|
|
8
|
+
"@slack/types": "^2.7.0"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
|
12
|
+
"@typescript-eslint/parser": "^5.30.6",
|
|
13
|
+
"eslint": "^7.32.0 || ^8.2.0",
|
|
14
|
+
"eslint-config-airbnb-base": "^15.0.0",
|
|
15
|
+
"eslint-config-node": "^4.1.0",
|
|
16
|
+
"eslint-config-prettier": "^8.5.0",
|
|
17
|
+
"eslint-plugin-import": "^2.25.2",
|
|
18
|
+
"eslint-plugin-node": "^11.1.0",
|
|
19
|
+
"eslint-plugin-prettier": "^4.2.1",
|
|
20
|
+
"nyc": "^15.1.0",
|
|
21
|
+
"prettier": "^2.7.1",
|
|
22
|
+
"ts-mockito": "^2.6.1"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"prettier": "prettier --write --loglevel warn \"**/**/*.ts\"",
|
|
26
|
+
"pw": "nyc playwright test",
|
|
27
|
+
"build": "tsc -p ./tsconfig.json",
|
|
28
|
+
"lint":"npx eslint . --ext .ts"
|
|
29
|
+
},
|
|
30
|
+
"name": "playwright-slack-report",
|
|
31
|
+
"version": "1.0.4",
|
|
32
|
+
"main": "index.js",
|
|
33
|
+
"types": "dist/index.d.ts",
|
|
34
|
+
"repository": "git@github.com:ryanrosello-og/playwright-slack-report.git",
|
|
35
|
+
"author": "Ryan Rosello <ryanrosello@hotmail.com>",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"files": ["/dist/src"]
|
|
38
|
+
}
|