Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<system-err>` and empty-name `<testcase>` 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/)
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is also Allure 3, so we shouldn't specifically mention Allure 2


## 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 `<system-err>` elements and empty-name `<testcase>` entries with `<error>` or `<failure>` (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 `<system-err>` elements and empty-name `<testcase>` entries with `<error>` or `<failure>` (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)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "qas-cli",
"version": "0.5.0",
"version": "0.6.0",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumped version for run level logs support

"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",
Expand Down
12 changes: 10 additions & 2 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,4 +19,11 @@ const getApi = (fetcher: typeof fetch) => {
export type Api = ReturnType<typeof getApi>

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}`,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added qas-cli/<version> user-agent header, which might help in debugging future issues

})
)
)
7 changes: 5 additions & 2 deletions src/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
): typeof fetch => {
return (input: URL | RequestInfo, init?: RequestInit | undefined) => {
return fetcher(input, {
...init,
headers: {
Authorization: `ApiKey ${apiKey}`,
...headers,
...init?.headers,
},
})
Expand Down
4 changes: 2 additions & 2 deletions src/commands/main.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -17,7 +17,7 @@ Required variables: ${qasEnvs.join(', ')}
.demandCommand(1, '')
.help('h')
.alias('h', 'help')
.version(getVersion())
.version(CLI_VERSION)
.options({
verbose: {
type: 'boolean',
Expand Down
190 changes: 187 additions & 3 deletions src/tests/allure-parsing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down Expand Up @@ -342,19 +342,20 @@ describe('Allure parsing', () => {
expect(testcases[0].message).toContain('&lt;script&gt;')
})

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': '<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 () => {
Expand Down Expand Up @@ -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(
'<h4>setup_database</h4>' +
'<p>Connection refused</p>' +
'<pre><code>at setup_database (setup.py:10)</code></pre>'
)
})

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('<h4>teardown_server</h4>' + '<p>Failed to stop server</p>')
})

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<div>error</div>\x1b[0m',
},
},
],
afters: [],
}),
})

const { runFailureLogs } = await parseAllureResults(dir, dir, {
skipStdout: 'never',
skipStderr: 'never',
})

expect(runFailureLogs).toBe('<h4>setup</h4>' + '<p>&lt;div&gt;error&lt;/div&gt;</p>')
})

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('<h4>broken_setup</h4>' + '<p>setup crashed</p>')
})

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('<h4>Test Container</h4>' + '<p>unnamed fixture error</p>')
})

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()}`)

Expand Down
15 changes: 15 additions & 0 deletions src/tests/fixtures/allure/container-failures/001-container.json
Original file line number Diff line number Diff line change
@@ -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": []
}
21 changes: 21 additions & 0 deletions src/tests/fixtures/allure/container-failures/001-result.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
23 changes: 23 additions & 0 deletions src/tests/fixtures/allure/container-failures/002-result.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Loading
Loading