Skip to content
Open
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
11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,22 @@ The upload flow has two stages handled by two classes, with a shared `MarkerPars
- Also exports a standalone `formatMarker()` function used by parsers

2. **`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 or Playwright JSON), which return `ParseResult` objects containing both `testCaseResults` and `runFailureLogs`
- Detects project code from test case names via `MarkerParser` (or from `--run-url`)
- Creates a new test run (or reuses an existing one if title conflicts)
- Delegates actual result uploading to `ResultUploader`
- Collects run-level logs from all parsed files and passes them to `ResultUploader`

3. **`ResultUploader`** — Handles the upload-to-run mechanics:
- Fetches test cases from the run, maps parsed results to them via `MarkerParser` matching
- Validates unmatched/missing test cases (respects `--force`, `--ignore-unmatched`)
- If run-level log is present, uploads it via `createRunLog` API before uploading test case results
- Uploads file attachments concurrently (max 10 parallel), then creates results in batches (max 50 per request)

### Report Parsers

- `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.
- `types.ts` — Shared `TestCaseResult` and `Attachment` interfaces used by both parsers.
- `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.
- `types.ts` — Shared `TestCaseResult`, `ParseResult`, and `Attachment` interfaces used by both parsers.

### API Layer (src/api/)

Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

The QAS CLI is a command-line tool for submitting your test automation results to [QA Sphere](https://qasphere.com/). It provides the most efficient way to collect and report test results from your test automation workflow, CI/CD pipeline, and build servers.

The tool can upload test case results from JUnit XML and Playwright JSON files to QA Sphere test runs by matching test case names (mentions of special markers) to QA Sphere's test cases.
The tool can upload test case results from JUnit XML and Playwright JSON files to QA Sphere test runs by matching test case names (mentions of special markers) to QA Sphere's test cases. It also automatically detects global or suite-level failures (e.g., setup/teardown errors) and uploads them as run-level logs.

## Installation

Expand Down Expand Up @@ -237,6 +237,13 @@ Playwright JSON reports support two methods for referencing test cases (checked

2. **Hyphenated Marker in Name** - Include the `PROJECT-SEQUENCE` marker in the test name (same format as JUnit XML format 1). Hyphenless markers are **not** supported for Playwright JSON

## 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.
- **Playwright JSON**: Top-level `errors` array entries (global setup/teardown failures) are extracted as run-level logs.

## Development (for those who want to contribute to the tool)

1. Install and build: `npm install && npm run build && npm link`
Expand Down
10 changes: 10 additions & 0 deletions src/api/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export interface CreateRunResponse {
id: number
}

export interface CreateRunLogRequest {
comment: string
}

export const createRunApi = (fetcher: typeof fetch) => {
fetcher = withJson(fetcher)
return {
Expand All @@ -36,6 +40,12 @@ export const createRunApi = (fetcher: typeof fetch) => {
method: 'POST',
body: JSON.stringify(req),
}).then((r) => jsonResponse<CreateRunResponse>(r)),

createRunLog: (projectCode: ResourceId, runId: ResourceId, req: CreateRunLogRequest) =>
fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/log`, {
method: 'POST',
body: JSON.stringify(req),
}).then((r) => jsonResponse<{ id: string }>(r)),
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/tests/fixtures/junit-xml/suite-level-errors.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="Test Suite">
<testsuite name="com.example.SetupFailureTest">
<system-err>Failed to initialize database connection
java.sql.SQLException: Connection refused</system-err>
<testcase name="" time="0.0">
<error message="Setup failed" type="java.lang.RuntimeException">java.lang.RuntimeException: BeforeAll setup failed
at com.example.SetupFailureTest.setup(SetupFailureTest.java:15)</error>
</testcase>
<testcase name="TEST-001: Normal passing test" classname="com.example.SetupFailureTest" time="1.5">
</testcase>
<testcase name="TEST-002: Another test" classname="com.example.SetupFailureTest" time="2.0">
<failure message="Expected true">Expected true but got false</failure>
</testcase>
</testsuite>
</testsuites>
80 changes: 64 additions & 16 deletions src/tests/junit-xml-parsing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('Junit XML parsing', () => {
const xmlContent = await readFile(xmlPath, 'utf8')

// This should not throw any exceptions
const testcases = await parseJUnitXml(xmlContent, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand Down Expand Up @@ -55,7 +55,7 @@ describe('Junit XML parsing', () => {
const xmlPath = `${xmlBasePath}/comprehensive-test.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const testcases = await parseJUnitXml(xmlContent, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand Down Expand Up @@ -86,7 +86,7 @@ describe('Junit XML parsing', () => {
const xmlPath = `${xmlBasePath}/empty-system-err.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const testcases = await parseJUnitXml(xmlContent, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand All @@ -103,7 +103,7 @@ describe('Junit XML parsing', () => {
const xmlPath = `${xmlBasePath}/jest-failure-type-missing.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const testcases = await parseJUnitXml(xmlContent, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand Down Expand Up @@ -132,7 +132,7 @@ describe('Junit XML parsing', () => {
const xmlPath = `${xmlBasePath}/pytest-style.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const testcases = await parseJUnitXml(xmlContent, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand Down Expand Up @@ -168,7 +168,7 @@ describe('Junit XML parsing', () => {
</testsuite>
</testsuites>`

const testcases = await parseJUnitXml(xml, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand All @@ -181,7 +181,7 @@ describe('Junit XML parsing', () => {
const xmlPath = `${xmlBasePath}/webdriverio-real.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const testcases = await parseJUnitXml(xmlContent, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand All @@ -203,7 +203,7 @@ describe('Junit XML parsing', () => {
const xmlPath = `${xmlBasePath}/empty-system-err.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const testcases = await parseJUnitXml(xmlContent, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand All @@ -218,7 +218,7 @@ describe('Junit XML parsing', () => {
const xmlPath = `${xmlBasePath}/empty-system-err.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const testcases = await parseJUnitXml(xmlContent, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'on-success',
skipStderr: 'never',
})
Expand All @@ -241,7 +241,7 @@ describe('Junit XML parsing', () => {
</testsuite>
</testsuites>`

const testcases = await parseJUnitXml(xml, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'on-success',
})
Expand All @@ -266,7 +266,7 @@ describe('Junit XML parsing', () => {
</testsuite>
</testsuites>`

const testcases = await parseJUnitXml(xml, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'on-success',
skipStderr: 'on-success',
})
Expand All @@ -291,7 +291,7 @@ describe('Junit XML parsing', () => {
</testsuite>
</testsuites>`

const testcases = await parseJUnitXml(xml, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'on-success',
skipStderr: 'on-success',
})
Expand All @@ -311,7 +311,7 @@ describe('Junit XML parsing', () => {
</testsuite>
</testsuites>`

const testcases = await parseJUnitXml(xml, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand All @@ -330,7 +330,7 @@ describe('Junit XML parsing', () => {
</testsuite>
</testsuites>`

const testcases = await parseJUnitXml(xml, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand All @@ -350,7 +350,7 @@ describe('Junit XML parsing', () => {
</testcase>
</testsuite>`

const testcases = await parseJUnitXml(xml, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand All @@ -372,7 +372,7 @@ describe('Junit XML parsing', () => {
<testcase name="test1" time="0.1"/>
</testsuite>`

const testcases = await parseJUnitXml(xml, xmlBasePath, {
const { testCaseResults: testcases } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})
Expand All @@ -381,4 +381,52 @@ describe('Junit XML parsing', () => {
expect(testcases[0].name).toBe('test1')
expect(testcases[0].folder).toBe('')
})

test('Should return empty runFailureLogs when no suite-level errors', async () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="Test Suite">
<testsuite name="my-suite">
<testcase name="test one" time="1.0">
</testcase>
</testsuite>
</testsuites>`

const { runFailureLogs } = await parseJUnitXml(xml, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})

expect(runFailureLogs).toBe('')
})

test('Should extract suite-level system-err into runFailureLogs', async () => {
const xmlPath = `${xmlBasePath}/suite-level-errors.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const { runFailureLogs } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})

expect(runFailureLogs).toContain('com.example.SetupFailureTest')
expect(runFailureLogs).toContain('Failed to initialize database connection')
expect(runFailureLogs).toContain('Connection refused')
})

test('Should extract empty-name testcase errors into runFailureLogs and exclude from testCaseResults', async () => {
const xmlPath = `${xmlBasePath}/suite-level-errors.xml`
const xmlContent = await readFile(xmlPath, 'utf8')

const { testCaseResults, runFailureLogs } = await parseJUnitXml(xmlContent, xmlBasePath, {
skipStdout: 'never',
skipStderr: 'never',
})

// Empty-name testcase should be excluded from testCaseResults
expect(testCaseResults).toHaveLength(2)
expect(testCaseResults.every((tc) => tc.name !== '')).toBe(true)

// Its error content should be in runFailureLogs
expect(runFailureLogs).toContain('BeforeAll setup failed')
})
})
Loading