diff --git a/.gitignore b/.gitignore index 36eea75c..5e8b67ec 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ yarn-error.log* lerna-debug.log* # package-lock.json - +sample_allure/ tests/adapter/cucumber/node_modules # Diagnostic reports (https://nodejs.org/api/report.html) diff --git a/README.md b/README.md index 67507983..790366f6 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ npx testomatio-reporter [options] | [TestCafe](./docs/frameworks.md#testcafe) | [Detox](./docs/frameworks.md#detox) | [Codeception](https://github.com/testomatio/php-reporter) | | [Newman (Postman)](./docs/frameworks.md#newman) | [JUnit](./docs/junit.md#junit) | [NUnit](./docs/junit.md#nunit) | | [PyTest](./docs/junit.md#pytest) | [PHPUnit](./docs/junit.md#phpunit) | [Protractor](./docs/frameworks.md#protractor) | +| [Allure](./docs/allure.md) | | | or **any [other via JUnit](./docs/junit.md)** report.... @@ -137,9 +138,10 @@ Bring this reporter on CI and never lose test results again! - [HTML report](./docs/pipes/html.md) - [Markdown report](./docs/pipes/markdown.md) - [Bitbucket](./docs/pipes/bitbucket.md) -- 🔗 [Linking Tests](./docs/linking-tests.md) -- 📓 [JUnit](./docs/junit.md) +- 📓 [JUnit Reports](./docs/junit.md) - 🗄️ [Artifacts](./docs/artifacts.md) +- 🔬 [Allure Reports](./docs/allure.md) +- 🔗 [Linking Tests](./docs/linking-tests.md) - 🔂 [Workflows](./docs/workflows.md) - 🖊️ [Logger](./docs/logger.md) - 🪲 [Debug File Format](./docs/debug-file-format.md) diff --git a/docs/allure.md b/docs/allure.md new file mode 100644 index 00000000..41c743e9 --- /dev/null +++ b/docs/allure.md @@ -0,0 +1,225 @@ +# Allure Reports Import + +Import test results from [Allure](https://docs.qameta.io/allure-report/) format into Testomat.io. The reporter automatically parses Allure results, extracts test information, and creates tests in your project. + +## Quick Start + +Install the reporter: + +```bash +npm install @testomatio/reporter --save-dev +``` + +Run your tests to generate Allure results, then import: + +```bash +TESTOMATIO={API_KEY} npx @testomatio/reporter allure allure-results +``` + +You can pass: +- A folder path (e.g., `allure-results`) +- A glob pattern (e.g., `"allure-results/*-result.json"`) + +The reporter automatically finds Allure result files (`*-result.json`) and container files (`*-container.json`) in the specified location. + +## What Gets Imported + +| Field | Source | Notes | +|-------|--------|-------| +| **Test Title** | `name` field | Test method name | +| **Status** | `status` field | passed, failed, broken → failed, skipped/pending | +| **Suite** | `suite` label | Suite title | +| **Epic** | `epic` label | Sent as link `{ label: "epic:Value" }` | +| **Feature** | `feature` label | Sent as link `{ label: "feature:Value" }` | +| **File** | `testClass` label + `language` | Source file name (e.g., `LoginTest.kt`) | +| **Code** | Source file | Fetched if file exists (see [Source Code](#source-code)) | +| **Steps** | `steps` array | Converted with category="user"; per-step `status` mapped (passed, broken/failed → failed, skipped → none) | +| **Attachments** | `attachments` array | Uploaded as artifacts | +| **Parameters** | `parameters` array | Converted to `example` object | +| **Description** | `description` field | Mapped directly | +| **Message/Stack** | `statusDetails` | Error information from failed tests | +| **Test ID** | `@TmsLink` (`links[type=tms]`) | Used to match existing Testomat.io test cases | + +## Matching Existing Tests with @TmsLink + +To make reported results **update existing test cases instead of creating duplicates**, annotate +tests with `@TmsLink` pointing at the Testomat.io test ID: + +```java +@TmsLink("T1a2b3c4d") // or the bare id: @TmsLink("1a2b3c4d") +@Test +void testLogin() { ... } +``` + +Allure writes this into the result JSON as a link with `type: "tms"`: + +```json +"links": [{ "name": "T1a2b3c4d", "type": "tms", "url": "https://app.testomat.io/.../test/1a2b3c4d" }] +``` + +The reader reads that link's name and sends it as the test's `test_id`, so Testomat.io matches the +existing case. Links whose URL points at a Testomat.io test page are also recognized even when `type` +is missing (e.g. `.../test/1a2b3c4d`). When no `@TmsLink` is present, the reader falls back to an ID +parsed from the source code (if available). + +**ID format.** Testomat.io test IDs are exactly **8 characters**. The reader accepts the bare id +(`1a2b3c4d`) or the `T` / `@T` markers Testomat uses (`T1a2b3c4d`, `@T1a2b3c4d`) and normalizes them +to the bare 8-char id. Values that are not valid 8-char Testomat IDs — a numeric Allure TestOps ID +(`@AllureId(12345)`), a JIRA key, or a 6-digit TMS number — are **ignored** rather than sent, so they +never produce unmatchable IDs. Those tests are still imported; they just match by title/`historyId` or +are created fresh. + +## Retry Deduplication + +If a test was run multiple times (retries), results are combined into **1 test**. Tests are deduplicated by `historyId` - so 3 retry attempts of the same test count as **1 test**, not 3. + +**Failure information is preserved**: If any retry attempt fails, all failure messages and stack traces from failed attempts are combined. The final status reflects the last attempt. + +- If all attempts failed → combines all failure messages and stack traces +- If final attempt passed but earlier attempts failed → marked as failed with combined failure info + +Console output shows the deduplication: +``` +[TESTOMATIO] Found 4 result files and 0 container files +[TESTOMATIO] Processed 2 unique tests (from 4 result files) +``` + +## Command Options + +```bash +npx @testomatio/reporter allure [options] +``` + +| Option | Description | +|--------|-------------| +| `` | Folder path or glob pattern (e.g., `allure-results` or `allure-results/*-result.json`) | +| `--with-package` | Keep full package path in file name (default: strip package) | +| `--java-tests ` | Path to test source files for code fetching | +| `--lang ` | Language for source code parsing (java, kotlin, python, ruby, etc.) | +| `--timelimit ` | Kill process after N seconds if stuck | + +## File Path Behavior + +By default, only the class name is used as the file (e.g., `LoginTest.kt`). + +With `--with-package`, the full package path is included: +``` +com/example/app/tests/ui/LoginTest.kt +``` + +## Artifacts + +Attachments (screenshots, videos, logs) from Allure results are uploaded as artifacts. + +**Note**: Artifacts require S3-compatible storage to be configured. If S3 is not set up, attachments will be omitted from the report. + +See [Artifacts documentation](./artifacts.md) for S3 configuration details. + +## Source Code + +Test code snippets are fetched from source files when available. + +**Note**: Source code extraction requires access to test source files. Use `--java-tests ` to specify the source directory. If source files are not accessible, code field will be empty. + +## Examples + +### Java / JUnit + +```bash +mvn clean test +TESTOMATIO={API_KEY} npx @testomatio/reporter allure "target/allure-results/*-result.json" --lang java --java-tests src/test/java +``` + +### Kotlin / Android + +```bash +./gradlew connectedAndroidTest +TESTOMATIO={API_KEY} npx @testomatio/reporter allure "build/allure-results/*-result.json" --lang kotlin +``` + +## Migration from Allure Report + + +### Migration Steps + +1. **Keep Allure for test execution** - Continue using Allure in your test framework + +2. **Add Testomat.io import** - Add one command to your CI pipeline to import Allure results: + +```bash +npx @testomatio/reporter allure allure-results +``` + +3. **Configure S3 (optional)** - Set up S3 storage to enable artifact uploads + +4. **Remove Allure report generation** - Once satisfied with Testomat.io reports, you can remove Allure HTML report generation step + +5. **Enable PR comments** - Add `GH_PAT: ${{ github.token }}` to enable automatic PR comments with test results + +### Comparison + +**With Allure only:** +```bash +mvn test +allure generate allure-results +# Upload HTML report manually or share locally +``` + +**With Testomat.io:** +```bash +mvn test +npx @testomatio/reporter allure allure-results +# Automatic cloud report with history, PR comments, analytics +``` + +## Debugging + +Enable debug mode to see detailed information: + +```bash +DEBUG=@testomatio/reporter:* npx @testomatio/reporter allure "allure-results/*-result.json" +``` + +A debug file is created at `/tmp/testomatio.debug.latest.json` with the exact data sent to Testomat.io. + + +## GitHub Actions Workflow + +Example workflow for Java/JUnit projects with Allure: + +```yaml +name: CI with Allure + +on: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Run tests + run: mvn clean test + + - name: Import to Testomat.io + run: npx @testomatio/reporter allure target/allure-results --lang java --java-tests src/test/java + if: always() + env: + TESTOMATIO: ${{ secrets.TESTOMATIO }} + TESTOMATIO_TITLE: 'PR ${{ github.event.number }}' + # Optional: S3 for artifacts + # S3_BUCKET: ${{ secrets.S3_BUCKET }} + # S3_REGION: ${{ secrets.S3_REGION }} + # S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} + # S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} +``` diff --git a/src/allureReader.js b/src/allureReader.js new file mode 100644 index 00000000..9fd6cbfa --- /dev/null +++ b/src/allureReader.js @@ -0,0 +1,659 @@ +import createDebugMessages from 'debug'; +import path from 'path'; +import pc from 'picocolors'; +import fs from 'fs'; +import { glob } from 'glob'; +import { APP_PREFIX, STATUS, BATCH_MODE } from './constants.js'; +import { randomUUID } from 'crypto'; +import { fileURLToPath } from 'url'; +import { config } from './config.js'; +import { S3Uploader } from './uploader.js'; +import { pipesFactory } from './pipe/index.js'; +import { splitTestsIntoChunks } from './utils/pipe_utils.js'; +import { + fetchSourceCode, + fetchIdFromCode, +} from './utils/utils.js'; +import adapterFactory from './junit-adapter/index.js'; + +// @ts-ignore +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const debug = createDebugMessages('@testomatio/reporter:allure'); + +const TESTOMATIO_URL = process.env.TESTOMATIO_URL || 'https://app.testomat.io'; +const { TESTOMATIO_RUNGROUP_TITLE, TESTOMATIO_SUITE, TESTOMATIO_TITLE, TESTOMATIO_ENV, TESTOMATIO_RUN } = process.env; + +class AllureReader { + constructor(opts = {}) { + this.requestParams = { + apiKey: opts.apiKey || config.TESTOMATIO, + url: opts.url || TESTOMATIO_URL, + title: TESTOMATIO_TITLE, + env: TESTOMATIO_ENV, + group_title: TESTOMATIO_RUNGROUP_TITLE, + // Buffer tests and flush them manually in size-limited chunks, exactly like XmlReader. + // No setInterval auto-upload means each test is sent exactly once (no double-send). + batchMode: BATCH_MODE.MANUAL, + }; + this.runId = opts.runId || TESTOMATIO_RUN; + this.opts = opts || {}; + this.withPackage = opts.withPackage || false; + this.store = {}; + this.pipesPromise = pipesFactory(opts, this.store); + this._tests = []; + this.stats = {}; + this.suites = {}; + this.uploader = new S3Uploader(); + + // Allure results already contain steps and stack traces for all tests, + // so enable passing them for passed tests by default + if (!process.env.TESTOMATIO_STACK_PASSED) { + process.env.TESTOMATIO_STACK_PASSED = '1'; + } + if (!process.env.TESTOMATIO_STEPS_PASSED) { + process.env.TESTOMATIO_STEPS_PASSED = '1'; + } + + const packageJsonPath = path.resolve(__dirname, '..', 'package.json'); + this.version = JSON.parse(fs.readFileSync(packageJsonPath).toString()).version; + console.log(APP_PREFIX, `Testomatio Reporter v${this.version}`); + } + + get tests() { + return this._tests; + } + + set tests(value) { + this._tests = value; + } + + async createRun() { + const runParams = { + api_key: this.requestParams.apiKey, + title: this.requestParams.title, + env: this.requestParams.env, + group_title: this.requestParams.group_title, + batchMode: this.requestParams.batchMode, + }; + + debug('Run', runParams); + this.pipes = this.pipes || (await this.pipesPromise); + + const run = await Promise.all(this.pipes.map(p => p.createRun(runParams))); + this.uploader.checkEnabled(); + return run; + } + + parse(resultsPattern) { + this._tests = []; + let pattern = resultsPattern; + + // Auto-append wildcard if pattern refers to a directory (like XML command does) + if (!pattern.endsWith('.json') && !pattern.includes('*')) { + if (pattern.endsWith('/') || (fs.existsSync(pattern) && fs.statSync(pattern).isDirectory())) { + pattern = pattern.replace(/\/+$/, '') + '/*-result.json'; + } else { + pattern += '*-result.json'; + } + } + + const resultsDir = path.dirname(pattern); + + console.log(APP_PREFIX, `Scanning for Allure results in: ${resultsDir}`); + console.log(APP_PREFIX, `Using pattern: ${pattern}`); + + const resultFiles = glob.sync(pattern); + const containerFiles = glob.sync(pattern.replace('*-result.json', '*-container.json')); + + if (resultFiles.length === 0 && containerFiles.length === 0) { + throw new Error(`No Allure result files found matching pattern: ${pattern}`); + } + + console.log(APP_PREFIX, `Found ${resultFiles.length} result files and ${containerFiles.length} container files`); + + this.parseContainerFiles(containerFiles); + + // Store all tests temporarily for deduplication by historyId + const allTests = []; + for (const file of resultFiles) { + const fullPath = file; + const fileDir = path.dirname(file); + try { + const resultData = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + const test = this.processAllureResult(resultData, fileDir); + if (test) { + test._historyId = resultData.historyId; + test._stop = resultData.stop || 0; + allTests.push(test); + } + } catch (err) { + console.warn(APP_PREFIX, `Failed to parse ${file}:`, err.message); + debug('Parse error:', err); + } + } + + const attemptsMap = new Map(); + for (const test of allTests) { + const historyId = test._historyId || test.rid; + if (!attemptsMap.has(historyId)) { + attemptsMap.set(historyId, []); + } + attemptsMap.get(historyId).push(test); + } + + const uniqueTestsMap = new Map(); + for (const [historyId, attempts] of attemptsMap) { + uniqueTestsMap.set(historyId, this.combineRetryAttempts(attempts)); + } + + // Convert map to array and clean up internal fields + this._tests = Array.from(uniqueTestsMap.values()).map(t => { + delete t._historyId; + delete t._stop; + return t; + }); + + console.log(APP_PREFIX, `Processed ${this._tests.length} unique tests (from ${allTests.length} result files)`); + + return this.calculateStats(); + } + + parseContainerFiles(containerFiles) { + for (const file of containerFiles) { + try { + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + if (data.name && data.children) { + data.children.forEach(uuid => { + this.suites[uuid] = data.name; + }); + } + } catch (err) { + debug('Failed to parse container file:', file, err.message); + } + } + debug('Parsed suites:', this.suites); + } + + processAllureResult(result, resultsDir) { + const test = { + rid: result.uuid || randomUUID(), + title: result.name || 'Unknown test', + status: this.mapStatus(result.status), + suite_title: this.extractSuiteTitle(result), + file: this.extractFile(result), + run_time: this.calculateRunTime(result), + steps: this.convertSteps(result.steps || []), + message: result.statusDetails?.message || '', + stack: result.statusDetails?.trace || '', + meta: this.extractMeta(result), + links: this.extractLinks(result), + artifacts: [], + create: true, + overwrite: true, + }; + + // Use the @TmsLink / Testomat.io link as the test id so reported tests MATCH + // existing cases instead of creating duplicates on every run. + const testId = this.extractTestId(result); + if (testId) { + test.test_id = testId; + } + + // Add description if present + if (result.description) { + test.description = result.description; + } + + if (result.parameters && result.parameters.length > 0) { + test.example = this.convertParameters(result.parameters); + } + + if (result.attachments && result.attachments.length > 0) { + const attachments = result.attachments + .map(att => { + const fullPath = path.join(resultsDir, att.source); + if (fs.existsSync(fullPath)) { + return fullPath; + } + debug('Attachment file not found:', fullPath); + return null; + }) + .filter(Boolean); + + if (attachments.length > 0) { + test.files = attachments; + } + } + + return test; + } + + mapStatus(status) { + const statusMap = { + passed: 'passed', + failed: 'failed', + broken: 'failed', + skipped: 'skipped', + pending: 'skipped', + }; + return statusMap[status] || 'failed'; + } + + /** + * Map an Allure step status to the Testomat.io Step status enum + * (`passed | failed | none | custom`, see testomat-api-definition.yml). + * + * Allure marks a step `broken` when it threw an unexpected error — that is a + * failure for reporting purposes, matching how `mapStatus` treats tests. + * `skipped` and anything unknown/absent become `none` (the neutral value), + * since the step enum has no `skipped`. + * + * @param {string} status - Allure step status + * @returns {'passed'|'failed'|'none'} Testomat.io step status + */ + mapStepStatus(status) { + const statusMap = { + passed: 'passed', + failed: 'failed', + broken: 'failed', + skipped: 'none', + pending: 'none', + }; + return statusMap[status] || 'none'; + } + + extractSuiteTitle(result) { + const labels = result.labels || []; + + // Only use suite label for suite_title + // Epic and Feature are sent as separate labels in meta + const suiteLabel = labels.find(l => l.name === 'suite')?.value; + + if (suiteLabel) { + return this.stripNamespace(suiteLabel); + } + + // Fallback to parentSuite or subSuite if no suite label + const parentSuite = labels.find(l => l.name === 'parentSuite')?.value; + const subSuite = labels.find(l => l.name === 'subSuite')?.value; + + if (parentSuite && subSuite) { + return `${parentSuite} / ${subSuite}`; + } + if (parentSuite) return parentSuite; + if (subSuite) return subSuite; + + return 'Default Suite'; + } + + stripNamespace(suiteName) { + if (suiteName && suiteName.includes('.')) { + return suiteName.split('.').pop(); + } + return suiteName; + } + + extractFile(result) { + const labels = result.labels || []; + const packageLabel = labels.find(l => l.name === 'package')?.value; + const testClassLabel = labels.find(l => l.name === 'testClass')?.value; + + if (!packageLabel) { + return null; + } + + const ext = this.getFileExtension(result); + let className; + + if (testClassLabel) { + className = testClassLabel.split('.').pop(); + } else if (result.fullName) { + const fullNameParts = result.fullName.split('.'); + className = fullNameParts[fullNameParts.length - 2] || fullNameParts[fullNameParts.length - 1]; + } else { + return null; + } + + if (this.withPackage) { + const parts = packageLabel.split('.'); + return `${parts.join('/')}/${className}.${ext}`; + } + + return `${className}.${ext}`; + } + + getFileExtension(result) { + const labels = result.labels || []; + const languageLabel = labels.find(l => l.name === 'language')?.value; + + const extMap = { + java: 'java', + kotlin: 'kt', + javascript: 'js', + typescript: 'ts', + python: 'py', + ruby: 'rb', + 'c#': 'cs', + php: 'php', + }; + + return extMap[languageLabel?.toLowerCase()] || 'java'; + } + + extractMeta(result) { + const labels = result.labels || []; + const excludedLabels = [ + 'suite', 'package', 'parentSuite', 'subSuite', + 'testClass', 'testMethod', 'epic', 'feature', + ]; + + const meta = {}; + labels.forEach(label => { + if (!excludedLabels.includes(label.name)) { + meta[label.name] = label.value; + } + }); + + return meta; + } + + extractLinks(result) { + const labels = result.labels || []; + const links = []; + + const epicLabel = labels.find(l => l.name === 'epic'); + const featureLabel = labels.find(l => l.name === 'feature'); + + if (epicLabel?.value) { + links.push({ label: `epic:${epicLabel.value}` }); + } + + if (featureLabel?.value) { + links.push({ label: `feature:${featureLabel.value}` }); + } + + return links.length > 0 ? links : undefined; + } + + /** + * Extract a Testomat.io test id from Allure links so reported tests match + * existing cases instead of creating duplicates. + * + * Allure's `@TmsLink("T1a2b3c4d")` produces a link with `type: "tms"`. Some exporters + * omit the type but still point the link URL at a Testomat.io test page; both are + * accepted. The link `name` is used as the id (falling back to the last URL segment). + * + * @param {object} result - Parsed Allure result JSON + * @returns {string|null} Normalized test id, or null when no usable link exists + */ + extractTestId(result) { + const links = result.links || []; + if (!links.length) return null; + + const isTmsLink = l => typeof l?.type === 'string' && l.type.toLowerCase() === 'tms'; + const isTestomatioLink = l => typeof l?.url === 'string' && /testomat\.io\/[^\s]*\/test\//i.test(l.url); + + const link = links.find(isTmsLink) || links.find(isTestomatioLink); + if (!link) return null; + + // Prefer the explicit link name; fall back to the id segment of a Testomat.io URL. + let id = this.normalizeTestId(link.name); + if (!id && typeof link.url === 'string') { + const fromUrl = link.url.match(/\/test\/([\w\d]{8})(?=$|[/?#])/i); + if (fromUrl) id = fromUrl[1]; + } + + return id; + } + + /** + * Normalize a value into a Testomat.io test id. + * + * Testomat.io test ids are exactly **8 word characters**. The value may arrive bare + * (`1a2b3c4d`), or carrying the `T` / `@T` markers Testomat uses in code and titles + * (`T1a2b3c4d`, `@T1a2b3c4d`). The markers are removed only when doing so still leaves + * a valid 8-char id, so a real id that happens to start with `T` is preserved. + * + * Anything that does not resolve to a valid 8-char id — a numeric Allure TestOps id + * like `12345`, a JIRA key, a 6-digit TMS number — is rejected (returns null) so we + * never send an unmatchable id that would create duplicates. + * + * @param {string|number|null|undefined} value + * @returns {string|null} The bare 8-char id, or null when the value is not a valid id + */ + normalizeTestId(value) { + if (value === null || value === undefined) return null; + const match = value.toString().trim().match(/^@?T?([\w\d]{8})$/); + return match ? match[1] : null; + } + + convertSteps(steps, depth = 0) { + if (depth >= 10) return null; + + return steps + .map(step => { + const convertedStep = { + category: 'user', + title: step.name || step.title || 'Unknown step', + status: this.mapStepStatus(step.status), + duration: this.calculateRunTime(step), + steps: this.convertSteps(step.steps || [], depth + 1), + }; + + if (convertedStep.steps && convertedStep.steps.length === 0) { + delete convertedStep.steps; + } + + if (convertedStep.duration === 0) { + delete convertedStep.duration; + } + + return convertedStep; + }) + .filter(Boolean); + } + + calculateRunTime(item) { + if (item.start && item.stop) { + const durationMs = item.stop - item.start; + return durationMs / 1000; + } + return null; + } + + convertParameters(parameters) { + const example = {}; + parameters.forEach(param => { + if (param.name) { + example[param.name] = param.value; + } + }); + return example; + } + + combineRetryAttempts(attempts) { + attempts.sort((a, b) => (a._stop || 0) - (b._stop || 0)); + + const finalTest = attempts[attempts.length - 1]; + const retryCount = attempts.length - 1; + + if (retryCount > 0) { + finalTest.retries = retryCount; + } + + const failedAttempts = attempts.filter(t => t.status === 'failed'); + + if (failedAttempts.length === 0) { + return finalTest; + } + + const failureMessages = []; + const failureStacks = []; + + for (const failed of failedAttempts) { + const attemptNum = attempts.indexOf(failed) + 1; + if (failed.message) { + failureMessages.push(`[Attempt ${attemptNum}] ${failed.message}`); + } + if (failed.stack) { + failureStacks.push(`\n--- Attempt ${attemptNum} ---\n${failed.stack}`); + } + } + + if (failureMessages.length > 0) { + finalTest.message = failureMessages.join('\n'); + } + if (failureStacks.length > 0) { + finalTest.stack = failureStacks.join('\n'); + } + + if (finalTest.status === 'passed') { + finalTest.status = 'failed'; + const retryMsg = `Test passed after ${failedAttempts.length} retries. Previous failures:\n`; + finalTest.message = retryMsg + finalTest.message; + } + + return finalTest; + } + + calculateStats() { + this.stats = { + create_tests: true, + tests_count: this._tests.length, + passed_count: 0, + failed_count: 0, + skipped_count: 0, + duration: 0, + status: 'passed', + tests: this._tests, + }; + this._tests.forEach(t => { + if (t.status === 'passed') this.stats.passed_count++; + if (t.status === 'failed') this.stats.failed_count++; + if (t.status === 'skipped') this.stats.skipped_count++; + }); + this.stats.duration = this._tests.reduce((acc, t) => acc + (t.run_time || 0), 0); + if (this.stats.failed_count) this.stats.status = 'failed'; + + debug('Stats:', this.stats); + return this.stats; + } + + fetchSourceCode() { + const adapter = this.adapter || adapterFactory(this.getLanguage(), this.opts); + + this._tests.forEach(t => { + try { + let filePath = t.file; + + if (adapter && adapter.getFilePath) { + filePath = adapter.getFilePath(t); + } + + if (!filePath) return; + + if (!fs.existsSync(filePath)) { + debug('Source file not found:', filePath); + return; + } + + const contents = fs.readFileSync(filePath).toString(); + const code = fetchSourceCode(contents, { ...t, lang: this.getLanguage() }); + if (code) { + t.code = code; + debug('Fetched code for test %s', t.title); + } + + // Don't override an id already taken from a @TmsLink — the link is the + // explicit, source-independent match key the client maintains. + const testId = fetchIdFromCode(contents, { lang: this.getLanguage() }); + if (testId && !t.test_id) { + t.test_id = testId; + debug('Fetched test id %s for test %s', testId, t.title); + } + } catch (err) { + debug('Failed to fetch source code:', err.message); + } + }); + } + + getLanguage() { + if (this._tests.length === 0) return null; + return this._tests[0].meta?.language || this.opts.lang; + } + + async uploadArtifacts() { + for (const test of this._tests.filter(t => t.files && t.files.length > 0)) { + const runId = this.runId || this.store.runId || Date.now().toString(); + const artifacts = await Promise.all( + test.files.map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path.basename(f)])), + ); + test.artifacts = artifacts.filter(a => a && a.link).map(a => a.link); + delete test.files; + if (test.artifacts.length > 0) { + console.log(APP_PREFIX, `🗄️ Uploaded ${pc.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`); + } + } + } + + async uploadData() { + await this.uploadArtifacts(); + this.calculateStats(); + this.fetchSourceCode(); + + this.pipes = this.pipes || (await this.pipesPromise); + + const finishData = { + api_key: this.requestParams.apiKey, + status: 'finished', + duration: this.stats.duration, + }; + + if (!this._tests || !Array.isArray(this._tests) || this._tests.length === 0) { + debug('No tests to upload, finishing run'); + return Promise.all(this.pipes.map(p => p.finishRun(finishData))); + } + + // Upload tests in size-limited chunks (max 1MB each), exactly like XmlReader. + // The testomatio pipe runs in MANUAL batch mode, so addTest only buffers tests and + // sync() flushes one batch request per chunk. There is no setInterval auto-upload, + // so each test is sent exactly once (no double-send) and requests stay under the limit. + const testChunks = splitTestsIntoChunks(this._tests); + + const totalChunks = testChunks.length; + const totalTests = this._tests.length; + + debug(`Split ${totalTests} tests into ${totalChunks} chunks (max 1MB per chunk)`); + + let uploadedTests = 0; + for (let i = 0; i < testChunks.length; i++) { + const chunk = testChunks[i]; + + if (totalChunks > 1) { + debug(`Uploading chunk ${i + 1}/${totalChunks} (${chunk.length} tests)`); + } + + // Buffer each test in the chunk, then flush the whole chunk as a single batch + for (const test of chunk) { + await Promise.all(this.pipes.map(p => p.addTest(test))); + } + await Promise.all(this.pipes.map(p => p.sync())); + + uploadedTests += chunk.length; + debug(`Uploaded ${uploadedTests}/${totalTests} tests`); + } + + if (totalChunks > 1) { + console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests in ${totalChunks} chunks`); + } else { + console.log(APP_PREFIX, `✅ Successfully uploaded ${uploadedTests} tests`); + } + + debug('Uploaded %d tests, finishing run', this._tests.length); + + return Promise.all(this.pipes.map(p => p.finishRun(finishData))); + } +} + +export default AllureReader; diff --git a/src/bin/cli.js b/src/bin/cli.js index d7bf861c..c34ae60c 100755 --- a/src/bin/cli.js +++ b/src/bin/cli.js @@ -6,6 +6,7 @@ import { glob } from 'glob'; import createDebugMessages from 'debug'; import TestomatClient from '../client.js'; import XmlReader from '../xmlReader.js'; +import AllureReader from '../allureReader.js'; import { APP_PREFIX, STATUS, DEBUG_FILE, BATCH_MODE } from '../constants.js'; import { cleanLatestRunId, getPackageVersion, applyFilter } from '../utils/utils.js'; import { config } from '../config.js'; @@ -351,6 +352,40 @@ program if (timeoutTimer) clearTimeout(timeoutTimer); }); +program + .command('allure') + .description('Parse Allure result files and upload to Testomat.io') + .argument('', 'Allure result directory pattern') + .option('-d, --dir ', 'Project directory') + .option('--timelimit