@isummation/kasauti-playwright-reporter 0.2.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.
- package/README.md +17 -0
- package/index.mjs +115 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @isummation/kasauti-playwright-reporter
|
|
2
|
+
|
|
3
|
+
Sends Playwright results to [Kasauti](https://gitlab.isummation.com/isummation-product/kasauti), with the screenshots, videos and traces Playwright keeps and anything tests attach. No dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -D @isummation/kasauti-playwright-reporter # needs access to the @isummation npm org
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// playwright.config.ts
|
|
11
|
+
reporter: [['list'], ['@isummation/kasauti-playwright-reporter', { project: 'SHOP' }]],
|
|
12
|
+
use: { screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'retain-on-failure' },
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Tag tests with case keys (`test('pays by card @SHOP-12', …)`); untagged tests become draft cases on first run. Set `KASAUTI_URL` and `KASAUTI_TOKEN`; optional `KASAUTI_RUN_TITLE`, `KASAUTI_ENV`, `KASAUTI_RUN`. Without a token it does nothing. Files over 20 MB are skipped.
|
|
16
|
+
|
|
17
|
+
Full guide: `docs/using-kasauti-in-your-project.md` in the Kasauti repository.
|
package/index.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Playwright → Kasauti reporter. Zero dependencies.
|
|
2
|
+
//
|
|
3
|
+
// // playwright.config.ts
|
|
4
|
+
// reporter: [['list'], ['@isummation/kasauti-playwright-reporter', { project: 'DEMO' }]]
|
|
5
|
+
//
|
|
6
|
+
// Link a test to an existing case with a tag in the title (`test('login works @DEMO-12', ...)`)
|
|
7
|
+
// or an annotation ({ type: 'kasauti', description: 'DEMO-12' }). Untagged tests are matched by
|
|
8
|
+
// suite path + title and auto-created as draft automated cases on first run.
|
|
9
|
+
// Env: KASAUTI_URL, KASAUTI_TOKEN, KASAUTI_PROJECT, KASAUTI_RUN (report into an existing run), KASAUTI_RUN_TITLE.
|
|
10
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { basename } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const STATUS = { passed: 'passed', failed: 'failed', timedOut: 'failed', interrupted: 'blocked', skipped: 'skipped' };
|
|
14
|
+
const MAX_ATTACHMENT = 20 * 1024 * 1024; // the API's per-file limit
|
|
15
|
+
const MAX_BATCH = 20 * 1024 * 1024; // base64 per request, under the API's 30 MB body limit
|
|
16
|
+
|
|
17
|
+
export default class KasautiReporter {
|
|
18
|
+
constructor(opts = {}) {
|
|
19
|
+
this.url = (opts.url ?? process.env.KASAUTI_URL ?? 'http://localhost:3001').replace(/\/$/, '');
|
|
20
|
+
this.token = opts.token ?? process.env.KASAUTI_TOKEN;
|
|
21
|
+
this.project = opts.project ?? process.env.KASAUTI_PROJECT;
|
|
22
|
+
this.run = opts.run ?? process.env.KASAUTI_RUN;
|
|
23
|
+
this.title = opts.title ?? process.env.KASAUTI_RUN_TITLE ?? `Playwright ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
|
|
24
|
+
this.results = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async api(method, path, body) {
|
|
28
|
+
const res = await fetch(this.url + path, { method, headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' }, body: body && JSON.stringify(body) });
|
|
29
|
+
if (!res.ok) throw new Error(`Kasauti ${method} ${path} → ${res.status}: ${await res.text()}`);
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
onBegin() {
|
|
34
|
+
if (!this.token || !this.project) {
|
|
35
|
+
console.warn('[kasauti] KASAUTI_TOKEN and project are required; reporting disabled');
|
|
36
|
+
this.disabled = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
onTestEnd(test, result) {
|
|
41
|
+
if (this.disabled) return;
|
|
42
|
+
// only the final attempt counts
|
|
43
|
+
if (result.status !== 'passed' && result.retry < test.retries && result.status !== 'skipped') return;
|
|
44
|
+
const tag = test.annotations.find((a) => a.type === 'kasauti')?.description ?? / @([A-Z][A-Z0-9]{1,9}-\d+)\b/.exec(test.title)?.[1];
|
|
45
|
+
const [, , ...path] = test.titlePath(); // drop root + project; keep file > describe... > title
|
|
46
|
+
const title = path.pop().replace(/ @[A-Z][A-Z0-9]{1,9}-\d+\b/g, '').trim();
|
|
47
|
+
this.results.push({
|
|
48
|
+
...(tag ? { case: tag } : { title, suite: path.join('/') }),
|
|
49
|
+
status: STATUS[result.status] ?? 'invalid',
|
|
50
|
+
durationMs: result.duration,
|
|
51
|
+
comment: result.errors?.map((e) => stripAnsi(e.message ?? '')).join('\n\n') || undefined,
|
|
52
|
+
stacktrace: result.errors?.map((e) => frames(stripAnsi(e.stack ?? ''))).filter(Boolean).join('\n\n') || undefined,
|
|
53
|
+
attachments: fit(result.attachments.flatMap(file).slice(0, 10)),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async onEnd() {
|
|
58
|
+
if (this.disabled || !this.results.length) return;
|
|
59
|
+
try {
|
|
60
|
+
const run = this.run ?? (await this.api('POST', '/runs', { project: this.project, title: this.title, source: 'automation', environment: process.env.KASAUTI_ENV })).key;
|
|
61
|
+
for (const results of batches(this.results)) await this.api('POST', `/k/${run}/results/bulk`, { results });
|
|
62
|
+
if (!this.run) await this.api('POST', `/k/${run}/complete`);
|
|
63
|
+
console.log(`[kasauti] ${this.results.length} results → ${this.url}/k/${run}`);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error('[kasauti]', e.message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
printsToStdio() {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Screenshots, videos and traces Playwright recorded (use: { screenshot, video, trace }), plus anything a test
|
|
75
|
+
// attached itself: testInfo.attach('cart', { body: await page.screenshot(), contentType: 'image/png' }).
|
|
76
|
+
function file(a) {
|
|
77
|
+
const body = a.body ?? (a.path && statSync(a.path, { throwIfNoEntry: false })?.isFile() ? readFileSync(a.path) : null);
|
|
78
|
+
if (!body) return [];
|
|
79
|
+
if (body.length > MAX_ATTACHMENT) {
|
|
80
|
+
console.warn(`[kasauti] skipped ${a.name} (${(body.length / 1048576).toFixed(1)} MB; the limit is 20 MB)`);
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const kind = a.contentType.startsWith('image/') ? 'screenshot' : a.contentType.startsWith('video/') ? 'video' : a.name === 'trace' ? 'trace' : 'file';
|
|
84
|
+
const ext = { 'image/png': '.png', 'image/jpeg': '.jpg', 'video/webm': '.webm', 'text/plain': '.txt', 'application/json': '.json' }[a.contentType] ?? '';
|
|
85
|
+
return [{ kind, filename: a.path ? basename(a.path) : a.name + (a.name.includes('.') ? '' : ext), mime: a.contentType, base64: body.toString('base64') }];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One result's files go in one request, so they must fit one: keep the earliest ones (failure screenshot first).
|
|
89
|
+
function fit(files) {
|
|
90
|
+
let size = 0;
|
|
91
|
+
return files.filter((f) => {
|
|
92
|
+
if (size + f.base64.length > MAX_BATCH) return void console.warn(`[kasauti] skipped ${f.filename}: this result's files are over 20 MB`);
|
|
93
|
+
size += f.base64.length;
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// At most 100 results and MAX_BATCH of files per request: one failing suite with videos can be hundreds of MB.
|
|
99
|
+
function* batches(results) {
|
|
100
|
+
let batch = [], size = 0;
|
|
101
|
+
for (const r of results) {
|
|
102
|
+
const n = r.attachments.reduce((t, a) => t + a.base64.length, 0);
|
|
103
|
+
if (batch.length && (batch.length === 100 || size + n > MAX_BATCH)) {
|
|
104
|
+
yield batch;
|
|
105
|
+
(batch = []), (size = 0);
|
|
106
|
+
}
|
|
107
|
+
batch.push(r);
|
|
108
|
+
size += n;
|
|
109
|
+
}
|
|
110
|
+
if (batch.length) yield batch;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, '');
|
|
114
|
+
// A stack starts with the error message, which already goes in the comment: keep only the frames.
|
|
115
|
+
const frames = (stack) => stack.split('\n').filter((l) => /^\s+at /.test(l)).join('\n');
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@isummation/kasauti-playwright-reporter",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Send Playwright results, with screenshots, videos and traces, to Kasauti",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
7
|
+
"exports": "./index.mjs",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.mjs",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@playwright/test": ">=1.40"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git@gitlab.isummation.com:isummation-product/kasauti.git",
|
|
21
|
+
"directory": "packages/playwright-reporter"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "restricted",
|
|
25
|
+
"registry": "https://registry.npmjs.org/"
|
|
26
|
+
}
|
|
27
|
+
}
|