diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7090d12 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Keep invalid JSON fixtures unformatted for parser tests +src/tests/fixtures/allure/parser/malformed-result.json +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 7236a64..33e0815 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,12 @@ # CLAUDE.md +Note: `AGENTS.md` is a symlink to this file for tooling that looks for that filename. + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview -QAS CLI (`qas-cli`) is a Node.js CLI tool for uploading test automation results (JUnit XML / Playwright JSON) to [QA Sphere](https://qasphere.com/). It matches test case markers (e.g., `PRJ-123`) in report files to QA Sphere test cases, creates or reuses test runs, and uploads results with optional attachments. +QAS CLI (`qas-cli`) is a Node.js CLI tool for uploading test automation results (JUnit XML / Playwright JSON / Allure) to [QA Sphere](https://qasphere.com/). It matches test case markers (e.g., `PRJ-123`) in report files to QA Sphere test cases, creates or reuses test runs, and uploads results with optional attachments. ## Commands @@ -29,7 +31,7 @@ Node.js compatibility tests: `cd mnode-test && ./docker-test.sh` (requires Docke ### Entry Point & CLI Framework - `src/bin/qasphere.ts` — Entry point (`#!/usr/bin/env node`). Validates Node version, delegates to `run()`. -- `src/commands/main.ts` — Yargs setup. Registers two commands (`junit-upload`, `playwright-json-upload`) as instances of the same `ResultUploadCommandModule` class. +- `src/commands/main.ts` — Yargs setup. Registers three commands (`junit-upload`, `playwright-json-upload`, `allure-upload`) as instances of the same `ResultUploadCommandModule` class. - `src/commands/resultUpload.ts` — `ResultUploadCommandModule` defines CLI options shared by both commands. Loads env vars, then delegates to `ResultUploadCommandHandler`. ### Core Upload Pipeline (src/utils/result-upload/) @@ -37,7 +39,7 @@ Node.js compatibility tests: `cd mnode-test && ./docker-test.sh` (requires Docke The upload flow has two stages handled by two classes: 1. **`ResultUploadCommandHandler`** — Orchestrates the overall flow: - - Parses report files using the appropriate parser (JUnit XML or Playwright JSON) + - Parses report files using the appropriate parser (JUnit XML, Playwright JSON, or Allure) - Detects project code from test case names (or from `--run-url`) - Creates a new test run (or reuses an existing one if title conflicts) - Delegates actual result uploading to `ResultUploader` @@ -51,6 +53,7 @@ The upload flow has two stages handled by two classes: - `junitXmlParser.ts` — Parses JUnit XML via `xml2js` + Zod validation. Extracts attachments from `[[ATTACHMENT|path]]` markers in system-out/failure/error/skipped elements. - `playwrightJsonParser.ts` — Parses Playwright JSON report. Supports two test case linking methods: (1) test annotations with `type: "test case"` and URL description, (2) marker in test name. Handles nested suites recursively. +- `allureParser.ts` — Parses Allure results directories (Allure 2 JSON). Supports status mapping, attachments, and TMS link-based test case markers. - `types.ts` — Shared `TestCaseResult` and `Attachment` interfaces used by both parsers. ### API Layer (src/api/) @@ -72,9 +75,10 @@ Composable fetch wrappers using higher-order functions: Tests use **Vitest** with **MSW** (Mock Service Worker) for API mocking. Test files are in `src/tests/`: -- `result-upload.spec.ts` — Integration tests for the full upload flow (both JUnit and Playwright), with MSW intercepting all API calls +- `result-upload.spec.ts` — Integration tests for the full upload flow (JUnit, Playwright, Allure), with MSW intercepting all API calls - `junit-xml-parsing.spec.ts` — Unit tests for JUnit XML parser - `playwright-json-parsing.spec.ts` — Unit tests for Playwright JSON parser +- `allure-parsing.spec.ts` — Unit tests for Allure results parser - `template-string-processing.spec.ts` — Unit tests for run name template processing Test fixtures live in `src/tests/fixtures/` (XML files, JSON files, and mock test case data). diff --git a/package-lock.json b/package-lock.json index 7674e76..36c9776 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qas-cli", - "version": "0.4.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qas-cli", - "version": "0.4.4", + "version": "0.4.5", "license": "ISC", "dependencies": { "chalk": "^5.4.1", diff --git a/src/commands/main.ts b/src/commands/main.ts index 98ed8a3..05af261 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -13,6 +13,7 @@ Required variables: ${qasEnvs.join(', ')} ) .command(new ResultUploadCommandModule('junit-upload')) .command(new ResultUploadCommandModule('playwright-json-upload')) + .command(new ResultUploadCommandModule('allure-upload')) .demandCommand(1, '') .help('h') .alias('h', 'help') diff --git a/src/commands/resultUpload.ts b/src/commands/resultUpload.ts index 78b6687..1d788b5 100644 --- a/src/commands/resultUpload.ts +++ b/src/commands/resultUpload.ts @@ -10,11 +10,13 @@ import { const commandTypeDisplayStrings: Record = { 'junit-upload': 'JUnit XML', 'playwright-json-upload': 'Playwright JSON', + 'allure-upload': 'Allure', } const commandTypeFileExtensions: Record = { 'junit-upload': 'xml', 'playwright-json-upload': 'json', + 'allure-upload': '', } export class ResultUploadCommandModule implements CommandModule { @@ -25,6 +27,9 @@ export class ResultUploadCommandModule implements CommandModule { + const extension = commandTypeFileExtensions[type] + if (!extension) { + return './allure-results' + } + return `./test-results.${extension}` +} diff --git a/src/tests/allure-parsing.spec.ts b/src/tests/allure-parsing.spec.ts new file mode 100644 index 0000000..3aeffaa --- /dev/null +++ b/src/tests/allure-parsing.spec.ts @@ -0,0 +1,129 @@ +import { beforeAll, describe, expect, test, vi } from 'vitest' +import { parseAllureResults } from '../utils/result-upload/allureParser' + +const allureParserBasePath = './src/tests/fixtures/allure/parser' + +let testcases: Awaited> + +describe('Allure results parsing', () => { + beforeAll(async () => { + testcases = await parseAllureResults(allureParserBasePath, allureParserBasePath, { + skipStdout: 'never', + skipStderr: 'never', + }) + }) + + test('Should parse results directory and ignore non-result files', () => { + expect(testcases).toHaveLength(9) + + const statuses = testcases.map((tc) => tc.status) + expect(statuses).toContain('passed') + expect(statuses).toContain('failed') + expect(statuses).toContain('blocked') + expect(statuses).toContain('skipped') + }) + + test('Should derive folders from labels with correct priority', () => { + const suiteCase = testcases.find((tc) => tc.name.includes('Login happy path')) + const parentCase = testcases.find((tc) => tc.name.includes('Payment error')) + const featureCase = testcases.find((tc) => tc.name.includes('Broken flow')) + const packageCase = testcases.find((tc) => tc.name.includes('Skipped TEST-003')) + const noLabelCase = testcases.find((tc) => tc.name.includes('Unknown status test')) + + expect(suiteCase?.folder).toBe('SuiteA') + expect(parentCase?.folder).toBe('ParentOnly') + expect(featureCase?.folder).toBe('FeatureOnly') + expect(packageCase?.folder).toBe('pkg.module') + expect(noLabelCase?.folder).toBe('') + }) + + test('Should calculate durations from start/stop timestamps', () => { + const suiteCase = testcases.find((tc) => tc.name.includes('Login happy path')) + const parentCase = testcases.find((tc) => tc.name.includes('Payment error')) + + expect(suiteCase?.timeTaken).toBe(1500) + expect(parentCase?.timeTaken).toBe(600) + }) + + test('Should map Allure statuses to QA Sphere statuses', () => { + const brokenCase = testcases.find((tc) => tc.name.includes('Broken flow')) + const unknownCase = testcases.find((tc) => tc.name.includes('Unknown status test')) + + expect(brokenCase?.status).toBe('blocked') + expect(unknownCase?.status).toBe('passed') + }) + + test('Should build messages from status details', () => { + const parentCase = testcases.find((tc) => tc.name.includes('Payment error')) + const brokenCase = testcases.find((tc) => tc.name.includes('Broken flow')) + const skippedCase = testcases.find((tc) => tc.name.includes('Skipped TEST-003')) + + expect(parentCase?.message).toContain('AssertionError') + expect(parentCase?.message).toContain('Trace line 1') + expect(brokenCase?.message).toContain('NullPointerException') + expect(skippedCase?.message).toBe('') + }) + + test('Should resolve attachments from result directory', () => { + const parentCase = testcases.find((tc) => tc.name.includes('Payment error')) + expect(parentCase?.attachments).toHaveLength(1) + expect(parentCase?.attachments[0].filename).toBe('failure-log.txt') + expect(parentCase?.attachments[0].buffer).not.toBeNull() + }) + + test('Should handle missing or empty attachments arrays', () => { + const suiteCase = testcases.find((tc) => tc.name.includes('Login happy path')) + const brokenCase = testcases.find((tc) => tc.name.includes('Broken flow')) + + expect(suiteCase?.attachments).toHaveLength(0) + expect(brokenCase?.attachments).toHaveLength(0) + }) + + test('Should keep marker when present in test name', () => { + const suiteCase = testcases.find((tc) => tc.name.includes('Login happy path')) + expect(suiteCase?.name).toContain('TEST-002') + }) + + test('Should extract markers from TMS links', () => { + const tmsUrlCase = testcases.find((tc) => tc.name.includes('TMS URL linked test')) + const tmsNameCase = testcases.find((tc) => tc.name.includes('TMS name linked test')) + + expect(tmsUrlCase?.name.startsWith('ALR-123:')).toBe(true) + expect(tmsNameCase?.name.startsWith('TESTCASE-002:')).toBe(true) + }) + + test('Should keep parameterized results as separate entries', () => { + const paramCases = testcases.filter((tc) => tc.name.startsWith('Param test')) + expect(paramCases).toHaveLength(2) + }) + + test('Should handle empty results directory', async () => { + const emptyResults = await parseAllureResults( + './src/tests/fixtures/allure/empty', + './src/tests/fixtures/allure/empty', + { + skipStdout: 'never', + skipStderr: 'never', + } + ) + expect(emptyResults).toHaveLength(0) + }) + + test('Should skip malformed or invalid result files with warnings', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const results = await parseAllureResults(allureParserBasePath, allureParserBasePath, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(results).toHaveLength(9) + expect(warnSpy.mock.calls.length).toBeGreaterThanOrEqual(2) + + const warnings = warnSpy.mock.calls.map((call) => String(call[0])) + expect(warnings.some((w) => w.includes('malformed-result.json'))).toBe(true) + expect(warnings.some((w) => w.includes('invalid-schema-result.json'))).toBe(true) + + warnSpy.mockRestore() + }) +}) diff --git a/src/tests/fixtures/allure/empty-tsuite/tc-2-result.json b/src/tests/fixtures/allure/empty-tsuite/tc-2-result.json new file mode 100644 index 0000000..487058d --- /dev/null +++ b/src/tests/fixtures/allure/empty-tsuite/tc-2-result.json @@ -0,0 +1,8 @@ +{ + "name": "Test cart TEST-002", + "status": "passed", + "uuid": "et-2", + "start": 1700000200000, + "stop": 1700000200300, + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/empty/.keep b/src/tests/fixtures/allure/empty/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/fixtures/allure/matching-tcases/attachment.txt b/src/tests/fixtures/allure/matching-tcases/attachment.txt new file mode 100644 index 0000000..6fc888e --- /dev/null +++ b/src/tests/fixtures/allure/matching-tcases/attachment.txt @@ -0,0 +1 @@ +matching attachment diff --git a/src/tests/fixtures/allure/matching-tcases/tc-2-result.json b/src/tests/fixtures/allure/matching-tcases/tc-2-result.json new file mode 100644 index 0000000..317114d --- /dev/null +++ b/src/tests/fixtures/allure/matching-tcases/tc-2-result.json @@ -0,0 +1,15 @@ +{ + "name": "Test cart TEST-002", + "status": "passed", + "uuid": "tc-2", + "start": 1700000000000, + "stop": 1700000000500, + "attachments": [ + { + "name": "Attachment", + "source": "attachment.txt", + "type": "text/plain" + } + ], + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/matching-tcases/tc-3-result.json b/src/tests/fixtures/allure/matching-tcases/tc-3-result.json new file mode 100644 index 0000000..497d3ed --- /dev/null +++ b/src/tests/fixtures/allure/matching-tcases/tc-3-result.json @@ -0,0 +1,15 @@ +{ + "name": "Test checkout TEST-003", + "status": "passed", + "uuid": "tc-3", + "start": 1700000001000, + "stop": 1700000001400, + "attachments": [ + { + "name": "Attachment", + "source": "attachment.txt", + "type": "text/plain" + } + ], + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/matching-tcases/tc-4-result.json b/src/tests/fixtures/allure/matching-tcases/tc-4-result.json new file mode 100644 index 0000000..f0ec8e4 --- /dev/null +++ b/src/tests/fixtures/allure/matching-tcases/tc-4-result.json @@ -0,0 +1,15 @@ +{ + "name": "About page content TEST-004", + "status": "passed", + "uuid": "tc-4", + "start": 1700000002000, + "stop": 1700000002700, + "attachments": [ + { + "name": "Attachment", + "source": "attachment.txt", + "type": "text/plain" + } + ], + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/matching-tcases/tc-5-result.json b/src/tests/fixtures/allure/matching-tcases/tc-5-result.json new file mode 100644 index 0000000..c8e2705 --- /dev/null +++ b/src/tests/fixtures/allure/matching-tcases/tc-5-result.json @@ -0,0 +1,15 @@ +{ + "name": "Menu page content TEST-005", + "status": "passed", + "uuid": "tc-5", + "start": 1700000003000, + "stop": 1700000003600, + "attachments": [ + { + "name": "Attachment", + "source": "attachment.txt", + "type": "text/plain" + } + ], + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/matching-tcases/tc-6-result.json b/src/tests/fixtures/allure/matching-tcases/tc-6-result.json new file mode 100644 index 0000000..b2b892e --- /dev/null +++ b/src/tests/fixtures/allure/matching-tcases/tc-6-result.json @@ -0,0 +1,15 @@ +{ + "name": "Navigation bar items TEST-006", + "status": "failed", + "uuid": "tc-6", + "start": 1700000004000, + "stop": 1700000004800, + "attachments": [ + { + "name": "Attachment", + "source": "attachment.txt", + "type": "text/plain" + } + ], + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-attachments/attachment.txt b/src/tests/fixtures/allure/missing-attachments/attachment.txt new file mode 100644 index 0000000..4ca5c4d --- /dev/null +++ b/src/tests/fixtures/allure/missing-attachments/attachment.txt @@ -0,0 +1 @@ +attachment content diff --git a/src/tests/fixtures/allure/missing-attachments/tc-2-result.json b/src/tests/fixtures/allure/missing-attachments/tc-2-result.json new file mode 100644 index 0000000..129f2af --- /dev/null +++ b/src/tests/fixtures/allure/missing-attachments/tc-2-result.json @@ -0,0 +1,9 @@ +{ + "name": "Test cart TEST-002", + "status": "passed", + "uuid": "ma-2", + "start": 1700000300000, + "stop": 1700000300500, + "attachments": [{ "name": "Attachment", "source": "attachment.txt", "type": "text/plain" }], + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-attachments/tc-3-result.json b/src/tests/fixtures/allure/missing-attachments/tc-3-result.json new file mode 100644 index 0000000..7b18c4d --- /dev/null +++ b/src/tests/fixtures/allure/missing-attachments/tc-3-result.json @@ -0,0 +1,9 @@ +{ + "name": "Test checkout TEST-003", + "status": "passed", + "uuid": "ma-3", + "start": 1700000301000, + "stop": 1700000301400, + "attachments": [{ "name": "Attachment", "source": "attachment.txt", "type": "text/plain" }], + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-attachments/tc-4-result.json b/src/tests/fixtures/allure/missing-attachments/tc-4-result.json new file mode 100644 index 0000000..1ed2fb1 --- /dev/null +++ b/src/tests/fixtures/allure/missing-attachments/tc-4-result.json @@ -0,0 +1,9 @@ +{ + "name": "About page content TEST-004", + "status": "passed", + "uuid": "ma-4", + "start": 1700000302000, + "stop": 1700000302700, + "attachments": [{ "name": "Attachment", "source": "attachment.txt", "type": "text/plain" }], + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-attachments/tc-5-result.json b/src/tests/fixtures/allure/missing-attachments/tc-5-result.json new file mode 100644 index 0000000..ffa4c86 --- /dev/null +++ b/src/tests/fixtures/allure/missing-attachments/tc-5-result.json @@ -0,0 +1,9 @@ +{ + "name": "Menu page content TEST-005", + "status": "passed", + "uuid": "ma-5", + "start": 1700000303000, + "stop": 1700000303600, + "attachments": [{ "name": "Attachment", "source": "attachment.txt", "type": "text/plain" }], + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-attachments/tc-6-result.json b/src/tests/fixtures/allure/missing-attachments/tc-6-result.json new file mode 100644 index 0000000..8e30c44 --- /dev/null +++ b/src/tests/fixtures/allure/missing-attachments/tc-6-result.json @@ -0,0 +1,9 @@ +{ + "name": "Navigation bar items TEST-006", + "status": "failed", + "uuid": "ma-6", + "start": 1700000304000, + "stop": 1700000304800, + "attachments": [{ "name": "Attachment", "source": "missing.txt", "type": "text/plain" }], + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-tcases/tc-2-result.json b/src/tests/fixtures/allure/missing-tcases/tc-2-result.json new file mode 100644 index 0000000..1ab2bc1 --- /dev/null +++ b/src/tests/fixtures/allure/missing-tcases/tc-2-result.json @@ -0,0 +1,8 @@ +{ + "name": "Test cart TEST-002", + "status": "passed", + "uuid": "mt-2", + "start": 1700000100000, + "stop": 1700000100300, + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-tcases/tc-3-result.json b/src/tests/fixtures/allure/missing-tcases/tc-3-result.json new file mode 100644 index 0000000..de8d979 --- /dev/null +++ b/src/tests/fixtures/allure/missing-tcases/tc-3-result.json @@ -0,0 +1,8 @@ +{ + "name": "Test checkout TEST-003", + "status": "passed", + "uuid": "mt-3", + "start": 1700000101000, + "stop": 1700000101400, + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-tcases/tc-4-result.json b/src/tests/fixtures/allure/missing-tcases/tc-4-result.json new file mode 100644 index 0000000..d27931c --- /dev/null +++ b/src/tests/fixtures/allure/missing-tcases/tc-4-result.json @@ -0,0 +1,8 @@ +{ + "name": "About page content TEST-004", + "status": "passed", + "uuid": "mt-4", + "start": 1700000102000, + "stop": 1700000102700, + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-tcases/tc-5-result.json b/src/tests/fixtures/allure/missing-tcases/tc-5-result.json new file mode 100644 index 0000000..75c17c6 --- /dev/null +++ b/src/tests/fixtures/allure/missing-tcases/tc-5-result.json @@ -0,0 +1,8 @@ +{ + "name": "Menu page content TEST-005", + "status": "passed", + "uuid": "mt-5", + "start": 1700000103000, + "stop": 1700000103600, + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/missing-tcases/tc-999-result.json b/src/tests/fixtures/allure/missing-tcases/tc-999-result.json new file mode 100644 index 0000000..1a60bb5 --- /dev/null +++ b/src/tests/fixtures/allure/missing-tcases/tc-999-result.json @@ -0,0 +1,8 @@ +{ + "name": "TEST-999: Missing test", + "status": "failed", + "uuid": "mt-999", + "start": 1700000104000, + "stop": 1700000104600, + "labels": [{ "name": "suite", "value": "ui.contents.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/parser/broken-feature-result.json b/src/tests/fixtures/allure/parser/broken-feature-result.json new file mode 100644 index 0000000..34976d6 --- /dev/null +++ b/src/tests/fixtures/allure/parser/broken-feature-result.json @@ -0,0 +1,12 @@ +{ + "name": "Broken flow", + "status": "broken", + "uuid": "bf-1", + "start": 3000, + "stop": 3100, + "statusDetails": { + "message": "NullPointerException" + }, + "attachments": [], + "labels": [{ "name": "feature", "value": "FeatureOnly" }] +} diff --git a/src/tests/fixtures/allure/parser/failed-parent-result.json b/src/tests/fixtures/allure/parser/failed-parent-result.json new file mode 100644 index 0000000..1bc1b94 --- /dev/null +++ b/src/tests/fixtures/allure/parser/failed-parent-result.json @@ -0,0 +1,13 @@ +{ + "name": "Payment error", + "status": "failed", + "uuid": "fp-1", + "start": 2000, + "stop": 2600, + "statusDetails": { + "message": "AssertionError: expected true to be false", + "trace": "Trace line 1\nTrace line 2" + }, + "attachments": [{ "name": "Failure log", "source": "failure-log.txt", "type": "text/plain" }], + "labels": [{ "name": "parentSuite", "value": "ParentOnly" }] +} diff --git a/src/tests/fixtures/allure/parser/failure-log.txt b/src/tests/fixtures/allure/parser/failure-log.txt new file mode 100644 index 0000000..e1888c8 --- /dev/null +++ b/src/tests/fixtures/allure/parser/failure-log.txt @@ -0,0 +1 @@ +failure log content diff --git a/src/tests/fixtures/allure/parser/ignored-attachment.png b/src/tests/fixtures/allure/parser/ignored-attachment.png new file mode 100644 index 0000000..283e5e9 --- /dev/null +++ b/src/tests/fixtures/allure/parser/ignored-attachment.png @@ -0,0 +1 @@ +not an image diff --git a/src/tests/fixtures/allure/parser/invalid-schema-result.json b/src/tests/fixtures/allure/parser/invalid-schema-result.json new file mode 100644 index 0000000..cc7cab4 --- /dev/null +++ b/src/tests/fixtures/allure/parser/invalid-schema-result.json @@ -0,0 +1,6 @@ +{ + "name": "Invalid schema", + "uuid": "inv-1", + "start": 8000, + "stop": 8100 +} diff --git a/src/tests/fixtures/allure/parser/legacy-testsuite.xml b/src/tests/fixtures/allure/parser/legacy-testsuite.xml new file mode 100644 index 0000000..5d3f9b0 --- /dev/null +++ b/src/tests/fixtures/allure/parser/legacy-testsuite.xml @@ -0,0 +1 @@ + diff --git a/src/tests/fixtures/allure/parser/malformed-result.json b/src/tests/fixtures/allure/parser/malformed-result.json new file mode 100644 index 0000000..c50485f --- /dev/null +++ b/src/tests/fixtures/allure/parser/malformed-result.json @@ -0,0 +1,6 @@ +{ + "name": "Malformed", + "status": "passed", + "uuid": "bad-1", + "start": 9000, + "stop": 9100, diff --git a/src/tests/fixtures/allure/parser/param-1-result.json b/src/tests/fixtures/allure/parser/param-1-result.json new file mode 100644 index 0000000..10bcf53 --- /dev/null +++ b/src/tests/fixtures/allure/parser/param-1-result.json @@ -0,0 +1,10 @@ +{ + "name": "Param test [chrome]", + "fullName": "tests.param.test", + "status": "passed", + "uuid": "pr-1", + "start": 7000, + "stop": 7200, + "parameters": [{ "name": "browser", "value": "chrome" }], + "labels": [{ "name": "suite", "value": "ParamSuite" }] +} diff --git a/src/tests/fixtures/allure/parser/param-2-result.json b/src/tests/fixtures/allure/parser/param-2-result.json new file mode 100644 index 0000000..7559df0 --- /dev/null +++ b/src/tests/fixtures/allure/parser/param-2-result.json @@ -0,0 +1,10 @@ +{ + "name": "Param test [firefox]", + "fullName": "tests.param.test", + "status": "passed", + "uuid": "pr-2", + "start": 7300, + "stop": 7600, + "parameters": [{ "name": "browser", "value": "firefox" }], + "labels": [{ "name": "suite", "value": "ParamSuite" }] +} diff --git a/src/tests/fixtures/allure/parser/passed-suite-result.json b/src/tests/fixtures/allure/parser/passed-suite-result.json new file mode 100644 index 0000000..80a4f5b --- /dev/null +++ b/src/tests/fixtures/allure/parser/passed-suite-result.json @@ -0,0 +1,13 @@ +{ + "name": "Login happy path TEST-002", + "status": "passed", + "uuid": "ps-1", + "start": 1000, + "stop": 2500, + "statusDetails": null, + "attachments": null, + "labels": [ + { "name": "suite", "value": "SuiteA" }, + { "name": "parentSuite", "value": "ParentA" } + ] +} diff --git a/src/tests/fixtures/allure/parser/sample-container.json b/src/tests/fixtures/allure/parser/sample-container.json new file mode 100644 index 0000000..4ca2df7 --- /dev/null +++ b/src/tests/fixtures/allure/parser/sample-container.json @@ -0,0 +1,8 @@ +{ + "uuid": "container-1", + "children": ["ps-1"], + "befores": [], + "afters": [], + "start": 1000, + "stop": 2000 +} diff --git a/src/tests/fixtures/allure/parser/skipped-package-result.json b/src/tests/fixtures/allure/parser/skipped-package-result.json new file mode 100644 index 0000000..b3af332 --- /dev/null +++ b/src/tests/fixtures/allure/parser/skipped-package-result.json @@ -0,0 +1,10 @@ +{ + "name": "Skipped TEST-003", + "status": "skipped", + "uuid": "sp-1", + "start": 4000, + "stop": 4200, + "statusDetails": {}, + "attachments": null, + "labels": [{ "name": "package", "value": "pkg.module" }] +} diff --git a/src/tests/fixtures/allure/parser/tms-name-result.json b/src/tests/fixtures/allure/parser/tms-name-result.json new file mode 100644 index 0000000..023df80 --- /dev/null +++ b/src/tests/fixtures/allure/parser/tms-name-result.json @@ -0,0 +1,15 @@ +{ + "name": "TMS name linked test", + "status": "passed", + "uuid": "tn-1", + "start": 6500, + "stop": 6900, + "labels": [{ "name": "suite", "value": "SuiteB" }], + "links": [ + { + "type": "tms", + "url": "https://example.com/tms/123", + "name": "TESTCASE-2" + } + ] +} diff --git a/src/tests/fixtures/allure/parser/tms-url-result.json b/src/tests/fixtures/allure/parser/tms-url-result.json new file mode 100644 index 0000000..df28a1d --- /dev/null +++ b/src/tests/fixtures/allure/parser/tms-url-result.json @@ -0,0 +1,15 @@ +{ + "name": "TMS URL linked test", + "status": "passed", + "uuid": "tu-1", + "start": 6000, + "stop": 6400, + "labels": [{ "name": "suite", "value": "SuiteB" }], + "links": [ + { + "type": "tms", + "url": "https://qas.eu1.qasphere.com/project/ALR/tcase/123", + "name": "ALR-123" + } + ] +} diff --git a/src/tests/fixtures/allure/parser/unknown-empty-labels-result.json b/src/tests/fixtures/allure/parser/unknown-empty-labels-result.json new file mode 100644 index 0000000..4328168 --- /dev/null +++ b/src/tests/fixtures/allure/parser/unknown-empty-labels-result.json @@ -0,0 +1,10 @@ +{ + "name": "Unknown status test", + "status": "unknown", + "uuid": "ul-1", + "start": 5000, + "stop": 5100, + "statusDetails": null, + "attachments": [], + "labels": [] +} diff --git a/src/tests/fixtures/allure/without-markers/no-marker-result.json b/src/tests/fixtures/allure/without-markers/no-marker-result.json new file mode 100644 index 0000000..94066e6 --- /dev/null +++ b/src/tests/fixtures/allure/without-markers/no-marker-result.json @@ -0,0 +1,8 @@ +{ + "name": "The cart is still filled after refreshing the page", + "status": "passed", + "uuid": "wm-3", + "start": 1700000401000, + "stop": 1700000401500, + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/without-markers/tc-10-result.json b/src/tests/fixtures/allure/without-markers/tc-10-result.json new file mode 100644 index 0000000..c0da5b0 --- /dev/null +++ b/src/tests/fixtures/allure/without-markers/tc-10-result.json @@ -0,0 +1,8 @@ +{ + "name": "TEST-010: Cart should be cleared after making the checkout", + "status": "passed", + "uuid": "wm-10", + "start": 1700000402000, + "stop": 1700000402600, + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/fixtures/allure/without-markers/tc-2-result.json b/src/tests/fixtures/allure/without-markers/tc-2-result.json new file mode 100644 index 0000000..ee06496 --- /dev/null +++ b/src/tests/fixtures/allure/without-markers/tc-2-result.json @@ -0,0 +1,8 @@ +{ + "name": "Test cart TEST-002", + "status": "passed", + "uuid": "wm-2", + "start": 1700000400000, + "stop": 1700000400300, + "labels": [{ "name": "suite", "value": "ui.cart.spec.ts" }] +} diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index 3aee549..e7bf428 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -165,23 +165,38 @@ const fileTypes = [ command: 'junit-upload', dataBasePath: './src/tests/fixtures/junit-xml', fileExtension: 'xml', + inputType: 'file', }, { name: 'Playwright JSON', command: 'playwright-json-upload', dataBasePath: './src/tests/fixtures/playwright-json', fileExtension: 'json', + inputType: 'file', + }, + { + name: 'Allure', + command: 'allure-upload', + dataBasePath: './src/tests/fixtures/allure', + inputType: 'directory', }, ] +const getFixturePath = (fileType: (typeof fileTypes)[number], fixtureName: string): string => { + if (fileType.inputType === 'directory') { + return `${fileType.dataBasePath}/${fixtureName}` + } + return `${fileType.dataBasePath}/${fixtureName}.${fileType.fileExtension}` +} + fileTypes.forEach((fileType) => { describe(`Uploading ${fileType.name} files`, () => { describe('Argument parsing', () => { test('Passing correct Run URL pattern should result in success', async () => { const patterns = [ - `${fileType.command} --run-url ${runURL} ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}`, - `${fileType.command} -r ${runURL}/ ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}`, - `${fileType.command} -r ${runURL}/tcase/1 ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}`, + `${fileType.command} --run-url ${runURL} ${getFixturePath(fileType, 'matching-tcases')}`, + `${fileType.command} -r ${runURL}/ ${getFixturePath(fileType, 'matching-tcases')}`, + `${fileType.command} -r ${runURL}/tcase/1 ${getFixturePath(fileType, 'matching-tcases')}`, ] for (const pattern of patterns) { @@ -198,7 +213,7 @@ fileTypes.forEach((fileType) => { const numResultUploadCalls = countResultUploadApiCalls() setMaxResultsInRequest(1) await run( - `${fileType.command} -r ${qasHost}/project/${projectCode}/run/${runId} ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` + `${fileType.command} -r ${qasHost}/project/${projectCode}/run/${runId} ${getFixturePath(fileType, 'matching-tcases')}` ) expect(numFileUploadCalls()).toBe(0) expect(numResultUploadCalls()).toBe(5) // 5 results total @@ -206,8 +221,8 @@ fileTypes.forEach((fileType) => { test('Passing incorrect Run URL pattern should result in failure', async () => { const patterns = [ - `${fileType.command} -r ${qasHost}/projects/${projectCode}/runs/${runId} ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}`, - `${fileType.command} -r ${runURL}abc/tcase/1 ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}`, + `${fileType.command} -r ${qasHost}/projects/${projectCode}/runs/${runId} ${getFixturePath(fileType, 'matching-tcases')}`, + `${fileType.command} -r ${runURL}abc/tcase/1 ${getFixturePath(fileType, 'matching-tcases')}`, ] for (const pattern of patterns) { @@ -232,9 +247,7 @@ fileTypes.forEach((fileType) => { const numFileUploadCalls = countFileUploadApiCalls() const numResultUploadCalls = countResultUploadApiCalls() setMaxResultsInRequest(2) - await run( - `${fileType.command} -r ${runURL} ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` - ) + await run(`${fileType.command} -r ${runURL} ${getFixturePath(fileType, 'matching-tcases')}`) expect(numFileUploadCalls()).toBe(0) expect(numResultUploadCalls()).toBe(3) // 5 results total }) @@ -243,9 +256,7 @@ fileTypes.forEach((fileType) => { const numFileUploadCalls = countFileUploadApiCalls() const numResultUploadCalls = countResultUploadApiCalls() await expect( - run( - `${fileType.command} -r ${runURL} ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension}` - ) + run(`${fileType.command} -r ${runURL} ${getFixturePath(fileType, 'missing-tcases')}`) ).rejects.toThrowError() expect(numFileUploadCalls()).toBe(0) expect(numResultUploadCalls()).toBe(0) @@ -256,7 +267,7 @@ fileTypes.forEach((fileType) => { const numResultUploadCalls = countResultUploadApiCalls() setMaxResultsInRequest(3) await run( - `${fileType.command} -r ${runURL} --force ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension}` + `${fileType.command} -r ${runURL} --force ${getFixturePath(fileType, 'missing-tcases')}` ) expect(numFileUploadCalls()).toBe(0) expect(numResultUploadCalls()).toBe(2) // 4 results total @@ -266,7 +277,7 @@ fileTypes.forEach((fileType) => { const numFileUploadCalls = countFileUploadApiCalls() const numResultUploadCalls = countResultUploadApiCalls() await run( - `${fileType.command} -r ${runURL} --ignore-unmatched ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension}` + `${fileType.command} -r ${runURL} --ignore-unmatched ${getFixturePath(fileType, 'missing-tcases')}` ) expect(numFileUploadCalls()).toBe(0) expect(numResultUploadCalls()).toBe(1) // 4 results total @@ -277,7 +288,7 @@ fileTypes.forEach((fileType) => { const numResultUploadCalls = countResultUploadApiCalls() setMaxResultsInRequest(2) await run( - `${fileType.command} -r ${runURL} --force ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension} ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension}` + `${fileType.command} -r ${runURL} --force ${getFixturePath(fileType, 'missing-tcases')} ${getFixturePath(fileType, 'missing-tcases')}` ) expect(numFileUploadCalls()).toBe(0) expect(numResultUploadCalls()).toBe(4) // 8 results total @@ -287,7 +298,7 @@ fileTypes.forEach((fileType) => { const numFileUploadCalls = countFileUploadApiCalls() const numResultUploadCalls = countResultUploadApiCalls() await run( - `${fileType.command} -r ${runURL} --force ${fileType.dataBasePath}/empty-tsuite.${fileType.fileExtension}` + `${fileType.command} -r ${runURL} --force ${getFixturePath(fileType, 'empty-tsuite')}` ) expect(numFileUploadCalls()).toBe(0) expect(numResultUploadCalls()).toBe(1) // 1 result total @@ -300,7 +311,7 @@ fileTypes.forEach((fileType) => { const numResultUploadCalls = countResultUploadApiCalls() setMaxResultsInRequest(3) await run( - `${fileType.command} -r ${runURL} --attachments ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` + `${fileType.command} -r ${runURL} --attachments ${getFixturePath(fileType, 'matching-tcases')}` ) expect(numFileUploadCalls()).toBe(5) expect(numResultUploadCalls()).toBe(2) // 5 results total @@ -310,7 +321,7 @@ fileTypes.forEach((fileType) => { const numResultUploadCalls = countResultUploadApiCalls() await expect( run( - `${fileType.command} -r ${runURL} --attachments ${fileType.dataBasePath}/missing-attachments.${fileType.fileExtension}` + `${fileType.command} -r ${runURL} --attachments ${getFixturePath(fileType, 'missing-attachments')}` ) ).rejects.toThrow() expect(numFileUploadCalls()).toBe(0) @@ -321,7 +332,7 @@ fileTypes.forEach((fileType) => { const numResultUploadCalls = countResultUploadApiCalls() setMaxResultsInRequest(1) await run( - `${fileType.command} -r ${runURL} --attachments --force ${fileType.dataBasePath}/missing-attachments.${fileType.fileExtension}` + `${fileType.command} -r ${runURL} --attachments --force ${getFixturePath(fileType, 'missing-attachments')}` ) expect(numFileUploadCalls()).toBe(4) expect(numResultUploadCalls()).toBe(5) // 5 results total @@ -342,7 +353,7 @@ fileTypes.forEach((fileType) => { try { // This should create a new run since no --run-url is specified await run( - `${fileType.command} --run-name "CI Build {env:TEST_BUILD_NUMBER}" ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` + `${fileType.command} --run-name "CI Build {env:TEST_BUILD_NUMBER}" ${getFixturePath(fileType, 'matching-tcases')}` ) expect(lastCreatedRunTitle).toBe('CI Build 456') @@ -363,7 +374,7 @@ fileTypes.forEach((fileType) => { const expectedDay = String(now.getDate()).padStart(2, '0') await run( - `${fileType.command} --run-name "Test Run {YYYY}-{MM}-{DD}" ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` + `${fileType.command} --run-name "Test Run {YYYY}-{MM}-{DD}" ${getFixturePath(fileType, 'matching-tcases')}` ) expect(lastCreatedRunTitle).toBe(`Test Run ${expectedYear}-${expectedMonth}-${expectedDay}`) @@ -375,7 +386,7 @@ fileTypes.forEach((fileType) => { try { await run( - `${fileType.command} --run-name "{env:TEST_PROJECT} - {YYYY}/{MM}" ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` + `${fileType.command} --run-name "{env:TEST_PROJECT} - {YYYY}/{MM}" ${getFixturePath(fileType, 'matching-tcases')}` ) const now = new Date() @@ -398,7 +409,7 @@ fileTypes.forEach((fileType) => { createRunTitleConflict = true await run( - `${fileType.command} --run-name "duplicate run title" ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` + `${fileType.command} --run-name "duplicate run title" ${getFixturePath(fileType, 'matching-tcases')}` ) expect(lastCreatedRunTitle).toBe('duplicate run title') @@ -407,9 +418,7 @@ fileTypes.forEach((fileType) => { }) test('Should use default name template when --run-name is not specified', async () => { - await run( - `${fileType.command} ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` - ) + await run(`${fileType.command} ${getFixturePath(fileType, 'matching-tcases')}`) // Should use default format: "Automated test run - {MMM} {DD}, {YYYY}, {hh}:{mm}:{ss} {AMPM}" expect(lastCreatedRunTitle).toContain('Automated test run - ') @@ -447,7 +456,7 @@ fileTypes.forEach((fileType) => { } await run( - `${fileType.command} --project-code ${projectCode} --create-tcases ${fileType.dataBasePath}/without-markers.${fileType.fileExtension}` + `${fileType.command} --project-code ${projectCode} --create-tcases ${getFixturePath(fileType, 'without-markers')}` ) expect(numCreateTCasesCalls()).toBe(1) expect(numResultUploadCalls()).toBe(3) // 3 results total @@ -484,7 +493,7 @@ fileTypes.forEach((fileType) => { } await run( - `${fileType.command} --project-code ${projectCode} --create-tcases ${fileType.dataBasePath}/without-markers.${fileType.fileExtension}` + `${fileType.command} --project-code ${projectCode} --create-tcases ${getFixturePath(fileType, 'without-markers')}` ) expect(numCreateTCasesCalls()).toBe(1) expect(numResultUploadCalls()).toBe(3) // 3 results total @@ -496,7 +505,7 @@ fileTypes.forEach((fileType) => { setMaxResultsInRequest(1) await run( - `${fileType.command} --project-code ${projectCode} --create-tcases ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` + `${fileType.command} --project-code ${projectCode} --create-tcases ${getFixturePath(fileType, 'matching-tcases')}` ) expect(numCreateTCasesCalls()).toBe(0) expect(numResultUploadCalls()).toBe(5) // 5 results total diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index f4b90be..dd5be72 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -9,8 +9,9 @@ import { TestCaseResult } from './types' import { ResultUploader } from './ResultUploader' import { parseJUnitXml } from './junitXmlParser' import { parsePlaywrightJson } from './playwrightJsonParser' +import { parseAllureResults } from './allureParser' -export type UploadCommandType = 'junit-upload' | 'playwright-json-upload' +export type UploadCommandType = 'junit-upload' | 'playwright-json-upload' | 'allure-upload' export type SkipOutputOption = 'on-success' | 'never' @@ -20,6 +21,8 @@ export interface ParserOptions { } export type Parser = ( + // For file-based parsers, data is file content. For directory-based parsers (e.g., Allure), data is + // the directory path and the parser is responsible for its own I/O. data: string, attachmentBaseDirectory: string, options: ParserOptions @@ -62,6 +65,7 @@ const DEFAULT_MAPPING_FILENAME_TEMPLATE = 'qasphere-automapping-{YYYY}{MM}{DD}-{ const commandTypeParsers: Record = { 'junit-upload': parseJUnitXml, 'playwright-json-upload': parsePlaywrightJson, + 'allure-upload': parseAllureResults, } export class ResultUploadCommandHandler { @@ -132,10 +136,20 @@ export class ResultUploadCommandHandler { } for (const file of this.args.files) { - const fileData = readFileSync(file).toString() + let fileData: string + let attachmentBaseDir: string + + if (this.type === 'allure-upload') { + fileData = file + attachmentBaseDir = file + } else { + fileData = readFileSync(file).toString() + attachmentBaseDir = dirname(file) + } + const fileResults = await commandTypeParsers[this.type]( fileData, - dirname(file), + attachmentBaseDir, parserOptions ) results.push({ file, results: fileResults }) diff --git a/src/utils/result-upload/allureParser.ts b/src/utils/result-upload/allureParser.ts new file mode 100644 index 0000000..d973487 --- /dev/null +++ b/src/utils/result-upload/allureParser.ts @@ -0,0 +1,226 @@ +import escapeHtml from 'escape-html' +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import z from 'zod' +import { ResultStatus } from '../../api/schemas' +import { getTCaseMarker, parseTCaseUrl } from '../misc' +import { Parser, ParserOptions } from './ResultUploadCommandHandler' +import { Attachment, TestCaseResult } from './types' +import { getAttachments } from './utils' + +const allureStatusSchema = z.enum(['passed', 'failed', 'broken', 'skipped', 'unknown']) + +const allureStatusDetailsSchema = z + .object({ + message: z.string().optional(), + trace: z.string().optional(), + known: z.boolean().optional(), + muted: z.boolean().optional(), + flaky: z.boolean().optional(), + }) + .optional() + .nullable() + +const allureAttachmentSchema = z.object({ + name: z.string(), + source: z.string(), + type: z.string(), +}) + +const allureLabelSchema = z.object({ + name: z.string(), + value: z.string(), +}) + +const allureParameterSchema = z.object({ + name: z.string(), + value: z.string(), + excluded: z.boolean().optional(), + mode: z.enum(['default', 'masked', 'hidden']).optional(), +}) + +const allureLinkSchema = z.object({ + name: z.string().optional(), + url: z.string(), + type: z.string().optional(), +}) + +const allureResultSchema = z.object({ + name: z.string(), + status: allureStatusSchema, + uuid: z.string(), + start: z.number(), + stop: z.number(), + fullName: z.string().optional(), + historyId: z.string().optional(), + testCaseId: z.string().optional(), + description: z.string().optional(), + descriptionHtml: z.string().optional(), + stage: z.string().optional(), + statusDetails: allureStatusDetailsSchema.optional().nullable(), + attachments: allureAttachmentSchema.array().optional().nullable(), + labels: allureLabelSchema.array().optional().nullable(), + links: allureLinkSchema.array().optional().nullable(), + parameters: allureParameterSchema.array().optional().nullable(), + steps: z.unknown().optional().nullable(), +}) + +type AllureResult = z.infer + +export const parseAllureResults: Parser = async ( + resultsDirectory: string, + attachmentBaseDirectory: string, + options: ParserOptions +): Promise => { + const entries = readdirSync(resultsDirectory, { withFileTypes: true }) + const resultFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('-result.json')) + .map((entry) => entry.name) + + const testcases: TestCaseResult[] = [] + const attachmentsPromises: Array<{ + index: number + promise: Promise + }> = [] + + for (const filename of resultFiles) { + const filePath = path.join(resultsDirectory, filename) + let parsed: AllureResult + + try { + const content = readFileSync(filePath, 'utf8') + const json = JSON.parse(content) + parsed = allureResultSchema.parse(json) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.warn(`Warning: Skipping Allure result "${filename}": ${message}`) + continue + } + + const status = mapAllureStatus(parsed.status) + const folder = deriveFolder(parsed.labels ?? []) + const name = applyMarkerFromLinks(parsed.name, parsed.links ?? []) + const timeTaken = calculateDuration(parsed.start, parsed.stop) + const message = buildMessage(parsed.statusDetails, status, options) + + const index = + testcases.push({ + name, + folder, + status, + message, + timeTaken, + attachments: [], + }) - 1 + + const attachmentSources = (parsed.attachments ?? []).map((attachment) => attachment.source) + if (attachmentSources.length > 0) { + attachmentsPromises.push({ + index, + promise: getAttachments(attachmentSources, attachmentBaseDirectory), + }) + } + } + + const attachments = await Promise.all(attachmentsPromises.map((p) => p.promise)) + attachments.forEach((tcaseAttachments, i) => { + const tcaseIndex = attachmentsPromises[i].index + testcases[tcaseIndex].attachments = tcaseAttachments + }) + + return testcases +} + +const mapAllureStatus = (status: z.infer): ResultStatus => { + switch (status) { + case 'passed': + return 'passed' + case 'failed': + return 'failed' + case 'broken': + return 'blocked' + case 'skipped': + return 'skipped' + case 'unknown': + return 'passed' + default: + return 'passed' + } +} + +const deriveFolder = (labels: z.infer[]): string => { + return ( + getLabelValue(labels, 'suite') ?? + getLabelValue(labels, 'parentSuite') ?? + getLabelValue(labels, 'feature') ?? + getLabelValue(labels, 'package') ?? + '' + ) +} + +const getLabelValue = (labels: z.infer[], name: string) => { + return labels.find((label) => label.name === name)?.value +} + +const calculateDuration = (start: number, stop: number) => { + const duration = stop - start + return Number.isFinite(duration) && duration >= 0 ? duration : null +} + +const buildMessage = ( + statusDetails: z.infer, + status: ResultStatus, + options: ParserOptions +) => { + let message = '' + + const includeStdout = !(status === 'passed' && options.skipStdout === 'on-success') + const includeStderr = !(status === 'passed' && options.skipStderr === 'on-success') + + if (includeStdout && statusDetails?.message) { + message += `
${escapeHtml(statusDetails.message)}
` + } + if (includeStderr && statusDetails?.trace) { + message += `
${escapeHtml(statusDetails.trace)}
` + } + + return message +} + +const applyMarkerFromLinks = (name: string, links: z.infer[]) => { + const markerFromLinks = getMarkerFromLinks(links) + if (!markerFromLinks) { + return name + } + + if (name.includes(markerFromLinks)) { + return name + } + + return `${markerFromLinks}: ${name}` +} + +const getMarkerFromLinks = (links: z.infer[]) => { + for (const link of links) { + if (link.type !== 'tms') { + continue + } + + if (link.url) { + const parsed = parseTCaseUrl(link.url) + if (parsed) { + return getTCaseMarker(parsed.project, parsed.tcaseSeq) + } + } + + if (link.name) { + const match = link.name.match(/\b([A-Z]+)-(\d+)\b/) + if (match) { + const seq = Number(match[2]) + if (Number.isFinite(seq)) { + return getTCaseMarker(match[1], seq) + } + } + } + } +}