Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
334 changes: 334 additions & 0 deletions tests/integration/adaptation-editor/custom-reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
import { writeFile, mkdir } from 'fs/promises';
import { join, basename } from 'path';
import { existsSync } from 'fs';
import type {
FullConfig,
FullResult,
Reporter,
Suite,
TestCase,
TestResult,
TestStep
} from '@playwright/test/reporter';

interface ManualTestCaseStep {
name: string;
}
interface ManualTestCase {
name: string;
filePath: string;
steps: ManualTestCaseStep[];
}

export default class ManualTestCaseReporter implements Reporter {
private manualTestCases: Record<string, ManualTestCase> = {};
private config: FullConfig;
private fileTotalTests: Record<string, number> = {};
private fileCompletedTests: Record<string, number> = {};
// only for latest ui5 reporter is enabled
private isReporterDisabled = false;
private createEmptyManualTestCase(test: TestCase): ManualTestCase {
return {
name: test.title,
filePath: '',
steps: []
};
}

onBegin(config: FullConfig, suite: Suite) {
// Only run this reporter for the latest version
const latestVersion = this.getLatestVersion();
const currentProject = suite.suites && suite.suites.length > 0 ? suite.suites[0].title : null;
if (latestVersion && currentProject && currentProject !== latestVersion) {
this.isReporterDisabled = true;
return;
}

this.config = config;

const allTests = suite.allTests();
allTests.forEach((test) => {
if (test.location.file) {
const filename = basename(test.location.file);
const fileNameWithoutExt = filename.replace(/\.spec\.ts$/, '');

// Initialize counters if not already set
this.fileTotalTests[fileNameWithoutExt] = (this.fileTotalTests[fileNameWithoutExt] || 0) + 1;
this.fileCompletedTests[fileNameWithoutExt] = this.fileCompletedTests[fileNameWithoutExt] || 0;
}
});
}

onStepBegin(test: TestCase, result: TestResult, step: TestStep): void {
if (this.isReporterDisabled) return;
const skipPatterns = [
/^Before Hooks$/,
/^After Hooks$/,
/^fixture: /,
/^attach "/,
/^browserType\./,
/^browser\./,
/^browserContext\./,
/^expect\.toBeVisible$/,
/^expect\.toBe$/,
/^expect\.toEqual$/,
/^expect\.poll\.toEqual$/,
/^expect\.toBeEnabled$/,
/^expect\.toBeDisabled$/,
/^expect\.poll$/,
/^locator\.textContent/,
/^locator\.click\(iframe.*internal:control=enter-frame.*\)$/,
/^locator\.hover\(iframe.*internal:control=enter-frame.*\)$/,
/^locator\.fill\(iframe.*internal:control=enter-frame.*\)$/,
/^Click on in Application Preview$/,
/^locator\.getByTestId\(\'saved-changes-stack\'\)/,
/^Verifying Changes.../
];

const shouldSkip = skipPatterns.some((pattern) => pattern.test(step.title));

if (!shouldSkip) {
this.manualTestCases[test.title].steps ??= [];
const parsedStep = parseActionStep(step.title);
const lastStep = this.manualTestCases[test.title].steps[this.manualTestCases[test.title].steps.length - 1];
const isDuplicate = lastStep && parsedStep === lastStep.name;
if (!isDuplicate) {
this.manualTestCases[test.title].steps.push({ name: parsedStep });
}
}
}
onTestBegin(test: TestCase, result: TestResult) {
if (this.isReporterDisabled) return;
const testCase = this.createEmptyManualTestCase(test);
this.manualTestCases[test.title] = testCase;
}

/**
* Called when a test ends.
*
* @param test - The test case that just ended
* @param result - Result of the test run
*/
async onTestEnd(test: TestCase, result: TestResult) {
if (this.isReporterDisabled) return;
if (test.location.file) {
const filename = basename(test.location.file);
this.manualTestCases[test.title].filePath = filename;
const fileNameWithoutExt = filename.replace(/\.spec\.ts$/, '');
this.fileCompletedTests[fileNameWithoutExt] = (this.fileCompletedTests[fileNameWithoutExt] || 0) + 1;
await this.checkAndGenerateFileDocumentation(fileNameWithoutExt);
}
}

/**
* Called when all tests have finished.
*
* @param result - Result of the entire test run
*/
async onEnd(result: FullResult) {
if (this.isReporterDisabled) return;
}

/**
* Generates documentation for tests from a specific file.
*
* @param fileBaseName Base name of the file to generate documentation for
*/
private async generateFileDocumentation(fileBaseName: string): Promise<void> {
try {
// Find all test cases belonging to the specified file (match with or without .spec.ts extension)
const testsFromFile = Object.entries(this.manualTestCases)
.filter(
([_, testCase]) =>
testCase.filePath === fileBaseName || testCase.filePath === `${fileBaseName}.spec.ts`
)
.map(([_, testCase]) => testCase);

if (testsFromFile.length === 0) {
console.log(`No tests found for ${fileBaseName}, skipping documentation generation`);
return;
}

console.log(`Generating documentation for ${testsFromFile.length} tests from ${fileBaseName}`);

const outputDir = join(process.cwd(), 'manual_test_description_generated');
if (!existsSync(outputDir)) {
await mkdir(outputDir, { recursive: true });
}

const titleCased = fileBaseName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');

let content = `# ${titleCased} Test Documentation\n\n`;
content += '## Table of Contents\n\n';

testsFromFile.forEach((test) => {
const anchorName = test.name
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-_]/g, '')
.toLowerCase();
content += `- [${test.name}](#${anchorName})\n`;
});

content += '\n';

testsFromFile.forEach((test) => {
const anchorName = test.name
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-_]/g, '')
.toLowerCase();

content += `<a id="${anchorName}"></a>\n`;
content += `## ${test.name}\n\n`;

if (test.steps && test.steps.length > 0) {
content += '### Steps\n\n';
test.steps.forEach((step, index) => {
content += `${index + 1}. ${step.name.replace(/^1\.\s*/, '')}\n`;
});
} else {
content += '*No steps recorded for this test.*\n';
}

content += '\n---\n\n';
});

const outputFile = join(outputDir, `${fileBaseName}.md`);
await writeFile(outputFile, content);
console.log(`Documentation written to ${outputFile}`);
} catch (error) {
console.error(`Error generating documentation for ${fileBaseName}:`, error);
}
}

/**
* Checks if all tests from a specific file have completed
*
* @param filename The filename to check (e.g., 'list-report-v2.spec.ts')
* @returns True if all tests from the file have completed
*/
private isFileComplete(filename: string): boolean {
if (!this.fileTotalTests[filename]) {
return false;
}

const completed = this.fileCompletedTests[filename] || 0;
const total = this.fileTotalTests[filename];

const isComplete = completed >= total;
console.log(`File ${filename}: ${completed}/${total} tests completed, complete status: ${isComplete}`);

return isComplete;
}

/**
* Generate documentation immediately if a file is complete
*
* @param testFile The name of the test file
*/
private async checkAndGenerateFileDocumentation(testFile: string): Promise<void> {
// Check if all tests for this file are complete and generate documentation if they are
if (this.isFileComplete(testFile)) {
await this.generateFileDocumentation(testFile);
}
}

private getLatestVersion(): string | null {
if (process.env.HIGHEST_UI5_VERSION) {
return process.env.HIGHEST_UI5_VERSION;
}

try {
const versionsPath = join(process.cwd(), 'versions.json');

const versionsContent = existsSync(versionsPath) ? require('fs').readFileSync(versionsPath, 'utf8') : null;

if (!versionsContent) return null;

const versions = JSON.parse(versionsContent) as string[];
return versions[0]; // First version is the latest
} catch (error) {
return null;
}
}
}

function parseActionStep(stepTitle: string): string {
// Action mapping - common Playwright methods to human verbs
const actionMap: Record<string, string> = {
'click': 'Click on',
'hover': 'Hover over'
};

// Element type mapping - detect element types from selectors/roles
const elementMap: Record<string, string> = {
'button': 'button'
};

// Try to find action verb from the main step title
let action = '';
for (const [actionKey, actionVerb] of Object.entries(actionMap)) {
if (stepTitle.includes(`.${actionKey}`) || stepTitle.startsWith(actionKey)) {
action = actionVerb;
break;
}
}

let element = '';
const getByMethodMap: Record<string, string> = {
'getByRole': 'role-based'
};

for (const [method, elementType] of Object.entries(getByMethodMap)) {
const methodMatch = stepTitle.match(new RegExp(`${method}\\(`));
if (methodMatch) {
if (method === 'getByRole') {
// Special handling for getByRole - extract the role type
const roleMatch = stepTitle.match(/getByRole\('(\w+)'/);
if (roleMatch) {
const roleType = roleMatch[1];
element = elementMap[roleType] || roleType;
}
} else {
element = elementType;
}
break;
}
}

// Try to extract element name using a map of patterns
let name = '';
const nameExtractionPatterns: Record<string, RegExp> = {
'getByRole': /getByRole\('\w+',\s*{\s*name:\s*'([^']+)'|getByRole\('\w+',\s*{\s*name:\s*"([^"]+)"/
};

// Try each pattern until we find a match
for (const [_method, pattern] of Object.entries(nameExtractionPatterns)) {
const nameMatch = stepTitle.match(pattern);
if (nameMatch) {
name = nameMatch[1] || nameMatch[2];
break;
}
}

// Build human-readable step using priority order
const resultBuilders = [
() => (action && element && name ? `${action} ${element} \`${name}\`` : null),
() => (action && element ? `${action} ${element}` : null),
() => (action && name ? `${action}\`${name}\`` : null),
() => (action ? action : null),
() => stepTitle
];

let result = ``;
for (const builder of resultBuilders) {
const buildResult = builder();
if (buildResult) {
result = buildResult;
break;
}
}
return result;
}
Loading
Loading