@testomatio/reporter
Version:
Testomatio Reporter Client
319 lines (271 loc) • 9.72 kB
JavaScript
import crypto from 'crypto';
import os from 'os';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
import { APP_PREFIX, STATUS as Status, TESTOMAT_TMP_STORAGE_DIR } from '../constants.js';
import TestomatioClient from '../client.js';
import { getTestomatIdFromTestTitle, fileSystem } from '../utils/utils.js';
import { services } from '../services/index.js';
import { dataStorage } from '../data-storage.js';
import { extensionMap } from '../utils/constants.js';
import pc from 'picocolors';
import { fetchLinksFromLogs } from './utils/playwright.js';
const reportTestPromises = [];
class PlaywrightReporter {
constructor(config = {}) {
this.client = new TestomatioClient({ apiKey: config?.apiKey });
this.uploads = [];
}
onBegin(config, suite) {
// clean data storage
fileSystem.clearDir(TESTOMAT_TMP_STORAGE_DIR);
if (!this.client) return;
this.suite = suite;
this.config = config;
this.client.createRun();
}
onTestBegin(testInfo) {
const fullTestTitle = getTestContextName(testInfo);
dataStorage.setContext(fullTestTitle);
}
onTestEnd(test, result) {
// test.parent.project().__projectId
if (!this.client) return;
const { title } = test;
const { error, duration } = result;
const pwAttachments = (result.attachments || []).filter(a => a.body || a.path);
const files = pwAttachments
.map(att => ({
path: this.#getArtifactPath(att),
title: att.name || title,
type: att.contentType,
}))
.filter(f => f.path);
const suite_title = test.parent ? test.parent?.title : path.basename(test?.location?.file);
const steps = [];
for (const step of result.steps) {
const appendedStep = appendStep(step);
if (appendedStep) {
steps.push(appendedStep);
}
}
// Extract and normalize tags
const tags = extractTags(test);
const fullTestTitle = getTestContextName(test);
let logs = '';
// get links along with filtered logs (liks related logs removed)
const { stdout: filteredStdout, links, meta } = fetchLinksFromLogs(result.stdout);
if (filteredStdout?.length || result.stderr?.length) {
logs = `\n\n${pc.bold('Logs:')}\n${pc.red(result.stderr.join(''))}\n${filteredStdout.join('')}`;
}
/*
All services fucntions work different for Playwright.
We don't have access to test title (as result, to test id) when calling this functions inside a test.
Thus, when user calls services functions inside a test, we just log this data to console.
Playwright intercepts the console.log on it's end and we just get this data from it.
Thus, we have a tiny drawback: all data from services functions inside a test will be logged to console.
And this requires a condition to be added for each service function – if its Playwright, then log to console.
"get" method of services will not return data for Playwright, we should parse stdout.
*/
const manuallyAttachedArtifacts = services.artifacts.get(fullTestTitle);
const testMeta = services.keyValues.get(fullTestTitle);
const rid = test.id || test.testId || uuidv4();
/**
* @type {{
* browser?: string,
* dependencies: string[],
* isMobile?: boolean
* metadata: Record<string, any>,
* name: string,
* }}
*/
const project = {
browser: test.parent.project().use.defaultBrowserType,
dependencies: test.parent.project().dependencies,
isMobile: test.parent.project().use.isMobile,
metadata: test.parent.project().metadata,
name: test.parent.project().name,
};
let status = result.status;
// process test.fail() annotation
if (test.expectedStatus === 'failed') {
// actual status = expected
if (result.status === 'failed') status = 'passed';
// actual status != expected
if (result.status === 'passed') status = 'failed';
}
const reportTestPromise = this.client.addTestRun(checkStatus(status), {
rid: `${rid}-${project.name}`,
error,
test_id: getTestomatIdFromTestTitle(`${title} ${tags.join(' ')}`),
suite_title,
title,
tags: tags.map(tag => tag.replace('@', '')),
steps: steps.length ? steps : undefined,
time: duration,
logs,
links,
manuallyAttachedArtifacts,
files: files.length ? files : undefined,
meta: {
browser: project.browser,
isMobile: project.isMobile,
project: project.name,
projectDependencies: project.dependencies?.length ? project.dependencies : null,
...testMeta,
...meta,
...project.metadata, // metadata has any type (in playwright), but we will stringify it in client.js
...test.annotations?.reduce((acc, annotation) => {
acc[annotation.type] = annotation.description;
return acc;
}, {}),
},
file: test.location?.file,
});
this.uploads.push({
rid: `${rid}-${project.name}`,
title: test.title,
files: pwAttachments,
file: test.location?.file,
});
// remove empty uploads
this.uploads = this.uploads.filter(anUpload => anUpload.files.length);
reportTestPromises.push(reportTestPromise);
}
#getArtifactPath(artifact) {
if (artifact.path) {
if (path.isAbsolute(artifact.path)) return artifact.path;
return path.join(this.config.outputDir || this.config.projects[0].outputDir, artifact.path);
}
if (artifact.body) {
let filePath = generateTmpFilepath(artifact.name);
const hasExtension = artifact.name && path.extname(artifact.name);
if (!hasExtension && artifact.contentType) {
const extension = extensionMap[artifact.contentType] || artifact.contentType.split('/')[1];
if (extension) filePath += `.${extension}`;
}
fs.writeFileSync(filePath, artifact.body);
return filePath;
}
return null;
}
async onEnd(result) {
if (!this.client) return;
await Promise.all(reportTestPromises);
if (this.uploads.length) {
if (this.client.uploader.isEnabled) console.log(APP_PREFIX, `🎞️ Uploading ${this.uploads.length} files...`);
const promises = [];
// ? possible move to addTestRun (needs investigation if files are ready)
for (const upload of this.uploads) {
const { rid, file, title } = upload;
const files = upload.files.map(attachment => ({
path: this.#getArtifactPath(attachment),
title,
type: attachment.contentType,
}));
if (!this.client.uploader.isEnabled) {
files.forEach(f => this.client.uploader.storeUploadedFile(f, this.client.runId, rid, false));
continue;
}
promises.push(
this.client.addTestRun(undefined, {
rid,
title,
files,
file,
}),
);
}
await Promise.all(promises);
}
await this.client.updateRunStatus(checkStatus(result.status));
}
}
function checkStatus(status) {
return (
{
skipped: Status.SKIPPED,
timedOut: Status.FAILED,
passed: Status.PASSED,
}[status] || Status.FAILED
);
}
function appendStep(step, shift = 0) {
// nesting too deep, ignore those steps
if (shift >= 10) return;
let newCategory = step.category;
switch (newCategory) {
case 'test.step':
newCategory = 'user';
break;
case 'hook':
newCategory = 'hook';
break;
case 'attach':
return null; // Skip steps with category 'attach'
default:
newCategory = 'framework';
}
const formattedSteps = [];
for (const child of step.steps || []) {
const appendedChild = appendStep(child, shift + 2);
if (appendedChild) {
formattedSteps.push(appendedChild);
}
}
const resultStep = {
category: newCategory,
title: step.title,
duration: step.duration,
};
if (formattedSteps.length) {
resultStep.steps = formattedSteps.filter(s => !!s);
}
if (step.error !== undefined) {
resultStep.error = step.error;
}
return resultStep;
}
function generateTmpFilepath(filename = '') {
filename = filename || `tmp.${crypto.randomBytes(16).toString('hex')}`;
const tmpdir = os.tmpdir();
return path.join(tmpdir, filename);
}
/**
* Extracts tags from test title, test options, and suite level
* Identifies duplicate tags (case-insensitive)
* @param {*} test - testInfo object from Playwright
* @returns {string[]} - array of normalized tags with @ prefix
*/
function extractTags(test) {
const tagsMap = new Map(); // key: lowercase tag, value: original case tag
function addTag(tag) {
if (typeof tag !== 'string') return;
const trimmed = tag.trim();
if (!trimmed) return;
const normalizedTag = trimmed.startsWith('@') ? trimmed : `@${trimmed}`;
const lowercaseTag = normalizedTag.toLowerCase();
if (!tagsMap.has(lowercaseTag)) {
tagsMap.set(lowercaseTag, normalizedTag);
}
}
// Extract tags from test title (@tag format); only test title is considered
const titleTagsMatch = test.title.match(/@[A-Za-z0-9_-]+/g) || [];
titleTagsMatch.forEach(addTag);
// Extract tags from test.tags (Playwright built-in tags); ignore parents
if (Array.isArray(test.tags)) {
test.tags.forEach(addTag);
}
return Array.from(tagsMap.values());
}
/**
* Returns filename + test title
* @param {*} test - testInfo object from Playwright
* @returns
*/
function getTestContextName(test) {
return `${test._requireFile || ''}_${test.title}`;
}
export default PlaywrightReporter;
export { extractTags, fetchLinksFromLogs };