diff --git a/CLAUDE.md b/CLAUDE.md index bb2a78b..a05131b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ The upload flow has two stages handled by two classes, with a shared `MarkerPars - `junitXmlParser.ts` — Parses JUnit XML via `xml2js` + Zod validation. Extracts attachments from `[[ATTACHMENT|path]]` markers in system-out/failure/error/skipped elements. Extracts suite-level `` and empty-name `` errors as run level error logs. - `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. Extracts top-level `errors[]` as run level error logs. -- `allureParser.ts` — Parses Allure 2 JSON results directories (`*-result.json` files only; containers/XML/images ignored). Supports test case linking via TMS links (`type: "tms"`) or marker in test name, maps Allure statuses to QA Sphere result statuses (`unknown→open`, `broken→blocked`), strips ANSI codes and HTML-escapes messages, and resolves attachments via `attachments[].source`. Uses `formatMarker()` from `MarkerParser`. +- `allureParser.ts` — Parses Allure JSON results directories (`*-result.json` and `*-container.json` files; XML/images ignored). Supports test case linking via TMS links (`type: "tms"`) or marker in test name, maps Allure statuses to QA Sphere result statuses (`unknown→open`, `broken→blocked`), strips ANSI codes and HTML-escapes messages, and resolves attachments via `attachments[].source`. Uses `formatMarker()` from `MarkerParser`. Extracts run-level failure logs from container files by checking `befores`/`afters` fixtures with `failed`/`broken` status — primarily useful for pytest (allure-junit5 and allure-playwright leave container fixtures empty). - `types.ts` — Shared `TestCaseResult`, `ParseResult`, and `Attachment` interfaces used by both parsers. ### API Layer (src/api/) diff --git a/README.md b/README.md index 2e24eb8..c8dbde7 100644 --- a/README.md +++ b/README.md @@ -262,14 +262,15 @@ Allure results use one `*-result.json` file per test in a results directory. `al 2. **TMS link name fallback** - If `url` is not a QA Sphere URL, a marker in `links[].name` is used (for example `PRJ-123`) 3. **Test case marker in name** - Marker in `name` field (same `PROJECT-SEQUENCE` format as JUnit XML) -Only Allure 2 JSON (`*-result.json`) is supported. Legacy Allure 1 XML files are ignored. +Only Allure JSON result files (`*-result.json`) are supported. Legacy Allure 1 XML files are ignored. ## Run-Level Logs The CLI automatically detects global or suite-level failures and uploads them as run-level logs to QA Sphere. These failures are typically caused by setup/teardown issues that aren't tied to specific test cases. -- **JUnit XML**: Suite-level `` elements and empty-name `` entries with `` or `` (synthetic entries from setup/teardown failures, e.g., Maven Surefire) are extracted as run-level logs. Empty-name testcases are excluded from individual test case results. +- **JUnit XML**: Suite-level `` elements and empty-name `` entries with `` or `` (synthetic entries from setup/teardown failures, e.g., Maven Surefire) are extracted as run-level logs. - **Playwright JSON**: Top-level `errors` array entries (global setup/teardown failures) are extracted as run-level logs. +- **Allure**: Failed or broken `befores`/`afters` fixtures in `*-container.json` files (e.g., session/module-level setup/teardown failures from pytest) are extracted as run-level logs. ## Development (for those who want to contribute to the tool) diff --git a/package.json b/package.json index e3d1bb2..4ca64d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qas-cli", - "version": "0.5.0", + "version": "0.6.0", "description": "QAS CLI is a command line tool for submitting your automation test results to QA Sphere at https://qasphere.com/", "type": "module", "main": "./build/bin/qasphere.js", diff --git a/src/api/index.ts b/src/api/index.ts index 7cd7261..105d1ef 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,7 +3,8 @@ import { createFolderApi } from './folders' import { createProjectApi } from './projects' import { createRunApi } from './run' import { createTCaseApi } from './tcases' -import { withApiKey, withBaseUrl, withHttpRetry } from './utils' +import { withBaseUrl, withHeaders, withHttpRetry } from './utils' +import { CLI_VERSION } from '../utils/version' const getApi = (fetcher: typeof fetch) => { return { @@ -18,4 +19,11 @@ const getApi = (fetcher: typeof fetch) => { export type Api = ReturnType export const createApi = (baseUrl: string, apiKey: string) => - getApi(withHttpRetry(withApiKey(withBaseUrl(fetch, baseUrl), apiKey))) + getApi( + withHttpRetry( + withHeaders(withBaseUrl(fetch, baseUrl), { + Authorization: `ApiKey ${apiKey}`, + 'User-Agent': `qas-cli/${CLI_VERSION}`, + }) + ) + ) diff --git a/src/api/utils.ts b/src/api/utils.ts index 1948f3f..c114137 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -26,12 +26,15 @@ export const withJson = (fetcher: typeof fetch): typeof fetch => { } } -export const withApiKey = (fetcher: typeof fetch, apiKey: string): typeof fetch => { +export const withHeaders = ( + fetcher: typeof fetch, + headers: Record +): typeof fetch => { return (input: URL | RequestInfo, init?: RequestInit | undefined) => { return fetcher(input, { ...init, headers: { - Authorization: `ApiKey ${apiKey}`, + ...headers, ...init?.headers, }, }) diff --git a/src/commands/main.ts b/src/commands/main.ts index 05af261..81c3de0 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -1,7 +1,7 @@ import yargs from 'yargs' import { ResultUploadCommandModule } from './resultUpload' import { qasEnvs, qasEnvFile } from '../utils/env' -import { getVersion } from '../utils/version' +import { CLI_VERSION } from '../utils/version' export const run = (args: string | string[]) => yargs(args) @@ -17,7 +17,7 @@ Required variables: ${qasEnvs.join(', ')} .demandCommand(1, '') .help('h') .alias('h', 'help') - .version(getVersion()) + .version(CLI_VERSION) .options({ verbose: { type: 'boolean', diff --git a/src/tests/allure-parsing.spec.ts b/src/tests/allure-parsing.spec.ts index 3b11a2d..bee37af 100644 --- a/src/tests/allure-parsing.spec.ts +++ b/src/tests/allure-parsing.spec.ts @@ -10,7 +10,7 @@ import { afterEach, describe, expect, test } from 'vitest' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { parseAllureResults } from '../utils/result-upload/allureParser' +import { parseAllureResults } from '../utils/result-upload/parsers/allureParser' const allureBasePath = './src/tests/fixtures/allure' const tempDirsToCleanup: string[] = [] @@ -342,19 +342,20 @@ describe('Allure parsing', () => { expect(testcases[0].message).toContain('<script>') }) - test('Should ignore container files, non-result JSON, XML, and images', async () => { + test('Should only parse result and container files, ignoring non-result JSON, XML, and images', async () => { const dir = await createTempAllureDir({ '001-result.json': JSON.stringify(makeResult({ name: 'valid result only' })), '002-container.json': JSON.stringify({ uuid: 'c1', befores: [], afters: [] }), 'report.xml': '', 'screenshot.png': 'fake-png-data', }) - const { testCaseResults: testcases } = await parseAllureResults(dir, dir, { + const { testCaseResults: testcases, runFailureLogs } = await parseAllureResults(dir, dir, { skipStdout: 'never', skipStderr: 'never', }) expect(testcases).toHaveLength(1) expect(testcases[0].name).toBe('valid result only') + expect(runFailureLogs).toBe('') }) test('Should return empty array for empty directory', async () => { @@ -540,6 +541,189 @@ describe('Allure parsing', () => { expect(filenames).toEqual(['level1.txt', 'level2.txt', 'top-level.txt']) }) + test('Should return empty runFailureLogs when no container failures exist', async () => { + const dir = await createTempAllureDir({ + '001-result.json': JSON.stringify(makeResult({ name: 'TEST-100 simple test' })), + '001-container.json': JSON.stringify({ + uuid: 'c1', + befores: [{ name: 'setup', status: 'passed' }], + afters: [], + }), + }) + + const { runFailureLogs } = await parseAllureResults(dir, dir, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(runFailureLogs).toBe('') + }) + + test('Should extract failed/broken before fixture errors into runFailureLogs', async () => { + const dir = await createTempAllureDir({ + '001-result.json': JSON.stringify(makeResult({ name: 'TEST-100 some test' })), + '001-container.json': JSON.stringify({ + uuid: 'c1', + befores: [ + { + name: 'setup_database', + status: 'broken', + statusDetails: { + message: 'Connection refused', + trace: 'at setup_database (setup.py:10)', + }, + }, + ], + afters: [], + }), + }) + + const { runFailureLogs } = await parseAllureResults(dir, dir, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(runFailureLogs).toBe( + '

setup_database

' + + '

Connection refused

' + + '
at setup_database (setup.py:10)
' + ) + }) + + test('Should extract failed after fixture errors into runFailureLogs', async () => { + const dir = await createTempAllureDir({ + '001-result.json': JSON.stringify(makeResult({ name: 'TEST-100 some test' })), + '001-container.json': JSON.stringify({ + uuid: 'c1', + befores: [], + afters: [ + { + name: 'teardown_server', + status: 'failed', + statusDetails: { + message: 'Failed to stop server', + }, + }, + ], + }), + }) + + const { runFailureLogs } = await parseAllureResults(dir, dir, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(runFailureLogs).toBe('

teardown_server

' + '

Failed to stop server

') + }) + + test('Should strip ANSI codes and HTML-escape container failure messages in runFailureLogs', async () => { + const dir = await createTempAllureDir({ + '001-result.json': JSON.stringify(makeResult({ name: 'TEST-100 test' })), + '001-container.json': JSON.stringify({ + uuid: 'c1', + befores: [ + { + name: 'setup', + status: 'failed', + statusDetails: { + message: '\x1b[31m
error
\x1b[0m', + }, + }, + ], + afters: [], + }), + }) + + const { runFailureLogs } = await parseAllureResults(dir, dir, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(runFailureLogs).toBe('

setup

' + '

<div>error</div>

') + }) + + test('Should skip passed/skipped fixtures and only include failed/broken in runFailureLogs', async () => { + const dir = await createTempAllureDir({ + '001-result.json': JSON.stringify(makeResult({ name: 'TEST-100 test' })), + '001-container.json': JSON.stringify({ + uuid: 'c1', + befores: [ + { name: 'passed_setup', status: 'passed', statusDetails: { message: 'all good' } }, + { + name: 'skipped_setup', + status: 'skipped', + statusDetails: { message: 'skipped reason' }, + }, + { + name: 'broken_setup', + status: 'broken', + statusDetails: { message: 'setup crashed' }, + }, + ], + afters: [], + }), + }) + + const { runFailureLogs } = await parseAllureResults(dir, dir, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(runFailureLogs).toBe('

broken_setup

' + '

setup crashed

') + }) + + test('Should use container name as header when fixture has no name', async () => { + const dir = await createTempAllureDir({ + '001-result.json': JSON.stringify(makeResult({ name: 'TEST-100 test' })), + '001-container.json': JSON.stringify({ + uuid: 'c1', + name: 'Test Container', + befores: [ + { + status: 'failed', + statusDetails: { message: 'unnamed fixture error' }, + }, + ], + afters: [], + }), + }) + + const { runFailureLogs } = await parseAllureResults(dir, dir, { + skipStdout: 'never', + skipStderr: 'never', + }) + + expect(runFailureLogs).toBe('

Test Container

' + '

unnamed fixture error

') + }) + + test('Should fail by default for malformed container files', async () => { + const dir = await createTempAllureDir({ + '001-result.json': JSON.stringify(makeResult({ name: 'TEST-100 valid' })), + '001-container.json': '{ invalid json', + }) + await expect( + parseAllureResults(dir, dir, { + skipStdout: 'never', + skipStderr: 'never', + allowPartialParse: false, + }) + ).rejects.toThrow('Failed to parse Allure container file') + }) + + test('Should skip malformed container files when partial parsing is allowed', async () => { + const dir = await createTempAllureDir({ + '001-result.json': JSON.stringify(makeResult({ name: 'TEST-100 valid' })), + '001-container.json': '{ invalid json', + }) + const { testCaseResults, runFailureLogs } = await parseAllureResults(dir, dir, { + skipStdout: 'never', + skipStderr: 'never', + allowPartialParse: true, + }) + expect(testCaseResults).toHaveLength(1) + expect(runFailureLogs).toBe('') + }) + test('Should throw a friendly error when the results directory does not exist', async () => { const missingDir = join(tmpdir(), `qas-missing-allure-results-${Date.now()}`) diff --git a/src/tests/fixtures/allure/container-failures/001-container.json b/src/tests/fixtures/allure/container-failures/001-container.json new file mode 100644 index 0000000..2bbb158 --- /dev/null +++ b/src/tests/fixtures/allure/container-failures/001-container.json @@ -0,0 +1,15 @@ +{ + "uuid": "container-001", + "children": ["matching-001", "matching-002"], + "befores": [ + { + "name": "setup_database", + "status": "broken", + "statusDetails": { + "message": "Failed to initialize database connection", + "trace": "java.sql.SQLException: Connection refused\n\tat com.example.Setup.init(Setup.java:15)" + } + } + ], + "afters": [] +} diff --git a/src/tests/fixtures/allure/container-failures/001-result.json b/src/tests/fixtures/allure/container-failures/001-result.json new file mode 100644 index 0000000..858f3eb --- /dev/null +++ b/src/tests/fixtures/allure/container-failures/001-result.json @@ -0,0 +1,21 @@ +{ + "name": "Test cart", + "status": "passed", + "uuid": "matching-001", + "start": 1700000000000, + "stop": 1700000000500, + "statusDetails": {}, + "attachments": [], + "labels": [ + { + "name": "suite", + "value": "ui.cart.spec.ts" + } + ], + "links": [ + { + "type": "tms", + "url": "https://qas.eu1.qasphere.com/project/TEST/tcase/2" + } + ] +} diff --git a/src/tests/fixtures/allure/container-failures/002-result.json b/src/tests/fixtures/allure/container-failures/002-result.json new file mode 100644 index 0000000..bfe8a4d --- /dev/null +++ b/src/tests/fixtures/allure/container-failures/002-result.json @@ -0,0 +1,23 @@ +{ + "name": "Test checkout", + "status": "failed", + "uuid": "matching-002", + "start": 1700000001000, + "stop": 1700000002000, + "statusDetails": { + "message": "AssertionError: checkout failed" + }, + "attachments": [], + "labels": [ + { + "name": "suite", + "value": "ui.cart.spec.ts" + } + ], + "links": [ + { + "type": "tms", + "url": "https://qas.eu1.qasphere.com/project/TEST/tcase/3" + } + ] +} diff --git a/src/tests/fixtures/playwright-json/global-errors.json b/src/tests/fixtures/playwright-json/global-errors.json new file mode 100644 index 0000000..0b35463 --- /dev/null +++ b/src/tests/fixtures/playwright-json/global-errors.json @@ -0,0 +1,59 @@ +{ + "suites": [ + { + "title": "ui.cart.spec.ts", + "specs": [ + { + "title": "Test cart TEST-002", + "tags": [], + "tests": [ + { + "annotations": [], + "expectedStatus": "passed", + "projectName": "chromium", + "results": [ + { + "status": "passed", + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "duration": 1000 + } + ], + "status": "expected" + } + ] + }, + { + "title": "Test checkout TEST-003", + "tags": [], + "tests": [ + { + "annotations": [], + "expectedStatus": "passed", + "projectName": "chromium", + "results": [ + { + "status": "passed", + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "duration": 1000 + } + ], + "status": "expected" + } + ] + } + ], + "suites": [] + } + ], + "errors": [ + { + "message": "Error: Global teardown failed: database connection timeout" + } + ] +} diff --git a/src/tests/junit-xml-parsing.spec.ts b/src/tests/junit-xml-parsing.spec.ts index b340247..eadbd88 100644 --- a/src/tests/junit-xml-parsing.spec.ts +++ b/src/tests/junit-xml-parsing.spec.ts @@ -1,6 +1,6 @@ import { expect, test, describe } from 'vitest' import { readFile } from 'node:fs/promises' -import { parseJUnitXml } from '../utils/result-upload/junitXmlParser' +import { parseJUnitXml } from '../utils/result-upload/parsers/junitXmlParser' const xmlBasePath = './src/tests/fixtures/junit-xml' diff --git a/src/tests/playwright-json-parsing.spec.ts b/src/tests/playwright-json-parsing.spec.ts index 02f7d2d..ced352f 100644 --- a/src/tests/playwright-json-parsing.spec.ts +++ b/src/tests/playwright-json-parsing.spec.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from 'vitest' -import { parsePlaywrightJson } from '../utils/result-upload/playwrightJsonParser' +import { parsePlaywrightJson } from '../utils/result-upload/parsers/playwrightJsonParser' import { readFile } from 'fs/promises' const playwrightJsonBasePath = './src/tests/fixtures/playwright-json' diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index 1d8bdbd..548ef27 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -617,8 +617,9 @@ describe('Allure invalid result file handling', () => { describe('Run-level log upload', () => { const junitBasePath = './src/tests/fixtures/junit-xml' + const allureBasePath = './src/tests/fixtures/allure' - test('Should upload run-level log when suite-level errors exist', async () => { + test('Should upload run-level log when suite-level errors exist (JUnit)', async () => { const numRunLogCalls = countRunLogApiCalls() const numResultUploadCalls = countResultUploadApiCalls() await run(`junit-upload -r ${runURL} --force ${junitBasePath}/suite-level-errors.xml`) @@ -626,11 +627,47 @@ describe('Run-level log upload', () => { expect(numResultUploadCalls()).toBe(1) }) - test('Should not upload run-level log when no suite-level errors exist', async () => { + test('Should not upload run-level log when no suite-level errors exist (JUnit)', async () => { const numRunLogCalls = countRunLogApiCalls() const numResultUploadCalls = countResultUploadApiCalls() await run(`junit-upload -r ${runURL} ${junitBasePath}/matching-tcases.xml`) expect(numRunLogCalls()).toBe(0) expect(numResultUploadCalls()).toBe(1) }) + + test('Should upload run-level log when global errors exist (Playwright)', async () => { + const numRunLogCalls = countRunLogApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + await run( + `playwright-json-upload -r ${runURL} --force ./src/tests/fixtures/playwright-json/global-errors.json` + ) + expect(numRunLogCalls()).toBe(1) + expect(numResultUploadCalls()).toBe(1) + }) + + test('Should not upload run-level log when no global errors exist (Playwright)', async () => { + const numRunLogCalls = countRunLogApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + await run( + `playwright-json-upload -r ${runURL} ./src/tests/fixtures/playwright-json/matching-tcases.json` + ) + expect(numRunLogCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(1) + }) + + test('Should upload run-level log when container failures exist (Allure)', async () => { + const numRunLogCalls = countRunLogApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + await run(`allure-upload -r ${runURL} --force ${allureBasePath}/container-failures`) + expect(numRunLogCalls()).toBe(1) + expect(numResultUploadCalls()).toBe(1) + }) + + test('Should not upload run-level log when no container failures exist (Allure)', async () => { + const numRunLogCalls = countRunLogApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + await run(`allure-upload -r ${runURL} ${allureBasePath}/matching-tcases`) + expect(numRunLogCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(1) + }) }) diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 77e4a99..77cb415 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -8,9 +8,9 @@ import { Api, createApi } from '../../api' import { TCase } from '../../api/schemas' import { ParseResult, TestCaseResult } from './types' import { ResultUploader } from './ResultUploader' -import { parseJUnitXml } from './junitXmlParser' -import { parsePlaywrightJson } from './playwrightJsonParser' -import { parseAllureResults } from './allureParser' +import { parseJUnitXml } from './parsers/junitXmlParser' +import { parsePlaywrightJson } from './parsers/playwrightJsonParser' +import { parseAllureResults } from './parsers/allureParser' export type UploadCommandType = 'junit-upload' | 'playwright-json-upload' | 'allure-upload' diff --git a/src/utils/result-upload/allureParser.ts b/src/utils/result-upload/parsers/allureParser.ts similarity index 68% rename from src/utils/result-upload/allureParser.ts rename to src/utils/result-upload/parsers/allureParser.ts index 26cda36..a53aa61 100644 --- a/src/utils/result-upload/allureParser.ts +++ b/src/utils/result-upload/parsers/allureParser.ts @@ -3,12 +3,16 @@ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import stripAnsi from 'strip-ansi' import z from 'zod' -import { ResultStatus } from '../../api/schemas' -import { parseTCaseUrl } from '../misc' -import { formatMarker, getMarkerFromText } from './MarkerParser' -import { Parser, ParserOptions } from './ResultUploadCommandHandler' -import { Attachment, ParseResult, TestCaseResult } from './types' -import { getAttachments } from './utils' +import { ResultStatus } from '../../../api/schemas' +import { parseTCaseUrl } from '../../misc' +import { formatMarker, getMarkerFromText } from '../MarkerParser' +import { Parser, ParserOptions } from '../ResultUploadCommandHandler' +import { Attachment, ParseResult, TestCaseResult } from '../types' +import { getAttachments } from '../utils' + +// Allure result file schema reference: +// https://allurereport.org/docs/how-it-works-test-result-file/ +// https://allurereport.org/docs/how-it-works-categories-file/ const allureStatusSchema = z.enum(['passed', 'failed', 'broken', 'skipped', 'unknown']) type AllureStatus = z.infer @@ -87,16 +91,32 @@ const allureResultSchema = z.object({ type AllureResult = z.infer +const allureContainerFixtureSchema = z.object({ + name: z.string().optional(), + status: z.string().optional(), + statusDetails: allureStatusDetailsSchema, +}) + +const allureContainerSchema = z.object({ + uuid: z.string().optional(), + name: z.string().optional(), + befores: z.array(allureContainerFixtureSchema).nullish(), + afters: z.array(allureContainerFixtureSchema).nullish(), +}) + +type AllureContainer = z.infer + export const parseAllureResults: Parser = async ( resultsDirectory: string, attachmentBaseDirectory: string, options: ParserOptions ): Promise => { let resultFiles: string[] + let containerFiles: string[] try { - resultFiles = readdirSync(resultsDirectory) - .filter((f) => f.endsWith('-result.json')) - .sort() + const allFiles = readdirSync(resultsDirectory).sort() + resultFiles = allFiles.filter((f) => f.endsWith('-result.json')) + containerFiles = allFiles.filter((f) => f.endsWith('-container.json')) } catch (error) { throw new Error( `Failed to read Allure results directory "${resultsDirectory}": ${getErrorMessage(error)}` @@ -162,7 +182,9 @@ export const parseAllureResults: Parser = async ( testcases[tcaseIndex].attachments = tcaseAttachment }) - return { testCaseResults: testcases, runFailureLogs: '' } + const runFailureLogs = extractRunFailureLogs(containerFiles, resultsDirectory, allowPartialParse) + + return { testCaseResults: testcases, runFailureLogs } } const collectStepAttachmentPaths = (steps: AllureStep[] | null | undefined): string[] => { @@ -220,7 +242,7 @@ const buildMessage = ( let message = '' if (includeStdout && statusDetails.message) { - message += `
${escapeHtml(stripAnsi(statusDetails.message))}
` + message += `

${escapeHtml(stripAnsi(statusDetails.message))}

` } if (includeStderr && statusDetails.trace) { message += `
${escapeHtml(stripAnsi(statusDetails.trace))}
` @@ -254,6 +276,67 @@ const getMarkerFromTmsLinks = (links: AllureResult['links']): string | undefined return undefined } +const extractRunFailureLogs = ( + containerFiles: string[], + resultsDirectory: string, + allowPartialParse: boolean +): string => { + const parts: string[] = [] + + for (const file of containerFiles) { + const filePath = join(resultsDirectory, file) + + let container: AllureContainer + try { + const content = readFileSync(filePath, 'utf8') + container = allureContainerSchema.parse(JSON.parse(content)) + } catch (error) { + if (allowPartialParse) { + console.warn( + `Warning: Skipping invalid Allure container file "${filePath}": ${getErrorMessage(error)}` + ) + continue + } + throw new Error( + `Failed to parse Allure container file "${filePath}": ${getErrorMessage(error)}` + ) + } + + const fixtures = [...(container.befores || []), ...(container.afters || [])] + for (const fixture of fixtures) { + const status = fixture.status?.toLowerCase() + if (status !== 'failed' && status !== 'broken') continue + + const details = fixture.statusDetails + if (!details) continue + + const fixtureName = fixture.name || container.name + let headerEmitted = false + + const entries: Array<{ text: string | undefined; tag: 'p' | 'code' }> = [ + { text: details.message, tag: 'p' }, + { text: details.trace, tag: 'code' }, + ] + for (const { text, tag } of entries) { + if (!text) continue + const clean = stripAnsi(text).trim() + if (!clean) continue + if (fixtureName && !headerEmitted) { + parts.push(`

${escapeHtml(fixtureName)}

`) + headerEmitted = true + } + if (tag === 'p') { + parts.push(`

${escapeHtml(clean)}

`) + } else { + parts.push(`
${escapeHtml(clean)}
`) + } + } + } + } + + return parts.join('') +} + const getErrorMessage = (error: unknown) => { return error instanceof Error ? error.message : String(error) } diff --git a/src/utils/result-upload/junitXmlParser.ts b/src/utils/result-upload/parsers/junitXmlParser.ts similarity index 97% rename from src/utils/result-upload/junitXmlParser.ts rename to src/utils/result-upload/parsers/junitXmlParser.ts index 08845c2..30b634c 100644 --- a/src/utils/result-upload/junitXmlParser.ts +++ b/src/utils/result-upload/parsers/junitXmlParser.ts @@ -1,10 +1,10 @@ import escapeHtml from 'escape-html' import xml from 'xml2js' import z from 'zod' -import { Attachment, ParseResult, TestCaseResult } from './types' -import { Parser, ParserOptions } from './ResultUploadCommandHandler' -import { ResultStatus } from '../../api/schemas' -import { getAttachments } from './utils' +import { Attachment, ParseResult, TestCaseResult } from '../types' +import { Parser, ParserOptions } from '../ResultUploadCommandHandler' +import { ResultStatus } from '../../../api/schemas' +import { getAttachments } from '../utils' // There is no official JUnit XML schema — multiple popular variants exist with varying strictness: // - Jenkins/Jest: https://github.com/jest-community/jest-junit/blob/master/__tests__/lib/junit.xsd diff --git a/src/utils/result-upload/playwrightJsonParser.ts b/src/utils/result-upload/parsers/playwrightJsonParser.ts similarity index 95% rename from src/utils/result-upload/playwrightJsonParser.ts rename to src/utils/result-upload/parsers/playwrightJsonParser.ts index a6d0587..e05f651 100644 --- a/src/utils/result-upload/playwrightJsonParser.ts +++ b/src/utils/result-upload/parsers/playwrightJsonParser.ts @@ -1,12 +1,12 @@ import z from 'zod' import escapeHtml from 'escape-html' import stripAnsi from 'strip-ansi' -import { Attachment, ParseResult, TestCaseResult } from './types' -import { Parser, ParserOptions } from './ResultUploadCommandHandler' -import { ResultStatus } from '../../api/schemas' -import { parseTCaseUrl } from '../misc' -import { formatMarker } from './MarkerParser' -import { getAttachments } from './utils' +import { Attachment, ParseResult, TestCaseResult } from '../types' +import { Parser, ParserOptions } from '../ResultUploadCommandHandler' +import { ResultStatus } from '../../../api/schemas' +import { parseTCaseUrl } from '../../misc' +import { formatMarker } from '../MarkerParser' +import { getAttachments } from '../utils' // Schema definition as per https://github.com/microsoft/playwright/blob/main/packages/playwright/types/testReporter.d.ts diff --git a/src/utils/version.ts b/src/utils/version.ts index 6b6a8d4..d5b3ac3 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -19,7 +19,7 @@ function tryReadPackageJson(path: string): string | null { return null } -export function getVersion(): string { +function getVersion(): string { try { const __filename = fileURLToPath(import.meta.url) let currentDir = dirname(__filename) @@ -39,3 +39,5 @@ export function getVersion(): string { return FALLBACK_VERSION } + +export const CLI_VERSION = getVersion()