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
50 changes: 29 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,20 @@ QAS_URL=https://qas.eu1.qasphere.com

## Commands: `junit-upload`, `playwright-json-upload`

The `junit-upload` and `playwright-json-upload` commands upload test results from JUnit XML and Playwright JSON reports to QA Sphere respectively. Both commands can either create a new test run within a QA Sphere project or upload results to an existing run, and they share the same set of options.
The `junit-upload` and `playwright-json-upload` commands upload test results from JUnit XML and Playwright JSON reports to QA Sphere respectively.

There are two modes for uploading results using the commands:

1. Upload to an existing test run by specifying its URL via `--run-url` flag
2. Create a new test run and upload results to it (when `--run-url` flag is not specified)

### Options

- `-r, --run-url` - Optional URL of an existing run for uploading results (a new run is created if not specified)
- `--run-name` - Optional name template for creating new test run when run url is not specified (supports `{env:VAR}`, `{YYYY}`, `{YY}`, `{MM}`, `{MMM}`, `{DD}`, `{HH}`, `{hh}`, `{mm}`, `{ss}`, `{AMPM}` placeholders). If not specified, `Automated test run - {MMM} {DD}, {YYYY}, {hh}:{mm}:{ss} {AMPM}` is used as default
- `-r`/`--run-url` - Upload results to an existing test run
- `--project-code`, `--run-name`, `--create-tcases` - Create a new test run and upload results to it
- `--project-code` - Project code for creating new test run. It can also be auto detected from test case markers in the results, but this is not fully reliable, so it is recommended to specify the project code explicitly
- `--run-name` - Optional name template for creating new test run. It supports `{env:VAR}`, `{YYYY}`, `{YY}`, `{MM}`, `{MMM}`, `{DD}`, `{HH}`, `{hh}`, `{mm}`, `{ss}`, `{AMPM}` placeholders (default: `Automated test run - {MMM} {DD}, {YYYY}, {hh}:{mm}:{ss} {AMPM}`)
- `--create-tcases` - Automatically create test cases in QA Sphere for results that don't have valid test case markers. A mapping file (`qasphere-automapping-YYYYMMDD-HHmmss.txt`) is generated showing the sequence numbers assigned to each new test case (default: `false`)
- `--attachments` - Try to detect and upload any attachments with the test result
- `--force` - Ignore API request errors, invalid test cases, or attachments
- `--ignore-unmatched` - Suppress individual unmatched test messages, show summary only
Expand Down Expand Up @@ -97,39 +105,41 @@ Ensure the required environment variables are defined before running these comma

**Note:** The following examples use `junit-upload`, but you can replace it with `playwright-json-upload` and adjust the file extension from `.xml` to `.json` to upload Playwright JSON reports instead.

1. Create a new test run with default name template (`Automated test run - {MMM} {DD}, {YYYY}, {hh}:{mm}:{ss} {AMPM}`) and upload results:
1. Upload to an existing test run:

```bash
qasphere junit-upload ./test-results.xml
qasphere junit-upload -r https://qas.eu1.qasphere.com/project/P1/run/23 ./test-results.xml
```

2. Upload to an existing test run:
2. Create a new test run with default name template and upload results:

```bash
qasphere junit-upload -r https://qas.eu1.qasphere.com/project/P1/run/23 ./test-results.xml
qasphere junit-upload ./test-results.xml
```

Project code is detected from test case markers in the results.

3. Create a new test run with name template without any placeholders and upload results:

```bash
qasphere junit-upload --run-name "v1.4.4-rc5" ./test-results.xml
qasphere junit-upload --project-code P1 --run-name "v1.4.4-rc5" ./test-results.xml
```

4. Create a new test run with name template using environment variables and date placeholders and upload results:

```bash
qasphere junit-upload --run-name "CI Build {env:BUILD_NUMBER} - {YYYY}-{MM}-{DD}" ./test-results.xml
qasphere junit-upload --project-code P1 --run-name "CI Build {env:BUILD_NUMBER} - {YYYY}-{MM}-{DD}" ./test-results.xml
```

If `BUILD_NUMBER` environment variable is set to `v1.4.4-rc5` and today's date is January 1, 2025, the run would be named "CI Build v1.4.4-rc5 - 2025-01-01".

5. Create a new test run with name template using date/time placeholders and upload results:
5. Create a new test run with name template using date/time placeholders and create test cases for results without valid markers and upload results:

```bash
qasphere junit-upload --run-name "Nightly Tests {YYYY}/{MM}/{DD} {HH}:{mm}" ./test-results.xml
qasphere junit-upload --project-code P1 --run-name "Nightly Tests {YYYY}/{MM}/{DD} {HH}:{mm}" --create-tcases ./test-results.xml
```

If the current time is 10:34 PM on January 1, 2025, the run would be named "Nightly Tests 2025/01/01 22:34".
If the current time is 10:34 PM on January 1, 2025, the run would be named "Nightly Tests 2025/01/01 22:34". This also creates new test cases in QA Sphere for any results that doesn't have a valid test case marker. A mapping file (`qasphere-automapping-YYYYMMDD-HHmmss.txt`) is generated showing the sequence numbers assigned to each newly created test case. Update your test cases to include the markers in the name, for future uploads.

6. Upload results with attachments:

Expand All @@ -151,25 +161,23 @@ Ensure the required environment variables are defined before running these comma

This will show only a summary like "Skipped 5 unmatched tests" instead of individual error messages for each unmatched test.

9. Skip stdout/stderr for passed tests to reduce result payload size:
9. Skip stdout for passed tests to reduce result payload size:

```bash
qasphere junit-upload --skip-report-stdout on-success ./test-results.xml
```

This will exclude stdout from passed tests while still including it for failed, blocked, or skipped tests.

Skip both stdout and stderr for passed tests:

```bash
qasphere junit-upload --skip-report-stdout on-success --skip-report-stderr on-success ./test-results.xml
```

This is useful when you have verbose logging in tests but only want to see output for failures.
10. Skip both stdout and stderr for passed tests:
```bash
qasphere junit-upload --skip-report-stdout on-success --skip-report-stderr on-success ./test-results.xml
```
This is useful when you have verbose logging in tests but only want to see output for failures.

## Test Report Requirements

The QAS CLI requires test cases in your reports (JUnit XML or Playwright JSON) to reference corresponding test cases in QA Sphere. These references are used to map test results from your automation to the appropriate test cases in QA Sphere. If a report lacks these references or the referenced test case doesn't exist in QA Sphere, the tool will display an error message.
The QAS CLI maps test results from your reports (JUnit XML or Playwright JSON) to corresponding test cases in QA Sphere using test case markers. If a test result lacks a valid marker, the CLI will display an error unless you use `--create-tcases` to automatically create test cases, or `--ignore-unmatched`/`--force` to skip unmatched results.

### JUnit XML

Expand Down
14 changes: 14 additions & 0 deletions src/api/folders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Folder, GetFoldersRequest, PaginatedResponse, ResourceId } from './schemas'
import { appendSearchParams, jsonResponse, withJson } from './utils'

export const createFolderApi = (fetcher: typeof fetch) => {
fetcher = withJson(fetcher)
return {
getFoldersPaginated: (projectCode: ResourceId, request: GetFoldersRequest) =>
fetcher(
appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/folders`, request)
).then((r) => jsonResponse<PaginatedResponse<Folder>>(r)),
}
}

export type FolderApi = ReturnType<typeof createFolderApi>
6 changes: 4 additions & 2 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { createFileApi } from './file'
import { createFolderApi } from './folders'
import { createProjectApi } from './projects'
import { createRunApi } from './run'
import { createTCaseApi } from './tcases'
import { createFileApi } from './file'
import { withApiKey, withBaseUrl } from './utils'

const getApi = (fetcher: typeof fetch) => {
return {
files: createFileApi(fetcher),
folders: createFolderApi(fetcher),
projects: createProjectApi(fetcher),
runs: createRunApi(fetcher),
testcases: createTCaseApi(fetcher),
file: createFileApi(fetcher),
}
}

Expand Down
47 changes: 47 additions & 0 deletions src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,55 @@ export type ResourceId = string | number

export type ResultStatus = 'open' | 'passed' | 'blocked' | 'failed' | 'skipped'

export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
}

export interface PaginatedRequest {
page?: number
limit?: number
}

export interface TCase {
id: string
legacyId?: string
seq: number
title: string
version: number
projectId: string
folderId: number
}

export interface CreateTCasesRequest {
folderPath: string[]
tcases: { title: string; tags: string[] }[]
}

export interface CreateTCasesResponse {
tcases: { id: string; seq: number }[]
}

export interface GetTCasesRequest extends PaginatedRequest {
folders?: number[]
}

export interface GetTCasesBySeqRequest {
seqIds: string[]
page?: number
limit?: number
}

export interface GetFoldersRequest extends PaginatedRequest {
search?: string
}

export interface Folder {
id: number
parentId: number
pos: number
title: string
}

Expand Down
46 changes: 22 additions & 24 deletions src/api/tcases.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
import { ResourceId } from './schemas'
import { jsonResponse, withJson } from './utils'
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
}

export interface TCaseBySeq {
id: string
legacyId?: string
seq: number
version: number
projectId: string
folderId: number
}

export interface GetTCasesBySeqRequest {
seqIds: string[]
page?: number
limit?: number
}
import {
CreateTCasesRequest,
CreateTCasesResponse,
GetTCasesBySeqRequest,
GetTCasesRequest,
PaginatedResponse,
ResourceId,
TCase,
} from './schemas'
import { appendSearchParams, jsonResponse, withJson } from './utils'

export const createTCaseApi = (fetcher: typeof fetch) => {
fetcher = withJson(fetcher)
return {
getTCasesPaginated: (projectCode: ResourceId, request: GetTCasesRequest) =>
fetcher(appendSearchParams(`/api/public/v0/project/${projectCode}/tcase`, request)).then(
(r) => jsonResponse<PaginatedResponse<TCase>>(r)
),

getTCasesBySeq: (projectCode: ResourceId, request: GetTCasesBySeqRequest) =>
fetcher(`/api/public/v0/project/${projectCode}/tcase/seq`, {
method: 'POST',
body: JSON.stringify(request),
}).then((r) => jsonResponse<PaginatedResponse<TCaseBySeq>>(r)),
}).then((r) => jsonResponse<PaginatedResponse<TCase>>(r)),
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should add zod for validation in future.


createTCases: (projectCode: ResourceId, request: CreateTCasesRequest) =>
fetcher(`/api/public/v0/project/${projectCode}/tcase/bulk`, {
method: 'POST',
body: JSON.stringify(request),
}).then((r) => jsonResponse<CreateTCasesResponse>(r)),
}
}
36 changes: 36 additions & 0 deletions src/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,39 @@ export const jsonResponse = async <T>(response: Response): Promise<T> => {
}
throw new Error(response.statusText)
}

const updateSearchParams = <T extends object>(searchParams: URLSearchParams, obj?: T) => {
const isValidValue = (value: unknown) => {
return value !== undefined && value !== null
}

if (!obj) return

Object.entries(obj).forEach(([key, value]) => {
if (isValidValue(value)) {
if (Array.isArray(value)) {
value.forEach((param) => {
if (isValidValue(param)) {
searchParams.append(key, String(param))
}
})
} else if (value instanceof Date) {
searchParams.set(key, value.toISOString())
} else if (typeof value === 'object') {
updateSearchParams(searchParams, value)
} else {
searchParams.set(key, String(value))
}
}
})
}

export const appendSearchParams = <T extends object>(pathname: string, obj: T): string => {
const searchParams = new URLSearchParams()
updateSearchParams(searchParams, obj)

if (searchParams.size > 0) {
return `${pathname}?${searchParams.toString()}`
}
return pathname
}
Loading