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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,11 @@ qasphere api
│ ├── list --project-code # List runs
│ ├── clone --project-code --run-id --title # Clone run
│ ├── close --project-code --run-id # Close run
│ └── test-cases
│ ├── list --project-code --run-id # List test cases in run
│ └── get --project-code --run-id --tcase-id # Get test case in run
│ ├── test-cases
│ │ ├── list --project-code --run-id # List test cases in run
│ │ └── get --project-code --run-id --tcase-id # Get test case in run
│ └── logs
│ └── create --project-code --run-id --comment # Create run log
├── settings
│ ├── list-statuses # List result statuses
│ └── update-statuses --statuses # Update custom statuses
Expand Down Expand Up @@ -444,12 +446,16 @@ Only Allure JSON result files (`*-result.json`) are supported. Legacy Allure 1 X

### Run-Level Logs

Run-level logs are HTML messages attached to a test run rather than to a specific test case. They are useful for capturing CI/CD output, automation framework output, or any external system message produced during test execution.

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.
- **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.

Run-level logs can also be created manually via [`qasphere api runs logs create`](#api-command-tree).

## AI Agent Skill

qas-cli includes a [SKILL.md](./skills/qas-cli/SKILL.md) file that enables AI coding agents (e.g., Claude Code, Cursor) to use the CLI effectively. To add this skill to your agent:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.8.0",
"version": "0.8.1",
"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
8 changes: 5 additions & 3 deletions skills/qas-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,11 @@ qasphere api
│ ├── list --project-code # List runs
│ ├── clone --project-code --run-id --title # Clone run
│ ├── close --project-code --run-id # Close run
│ └── test-cases
│ ├── list --project-code --run-id # List test cases in run
│ └── get --project-code --run-id --tcase-id # Get test case in run
│ ├── test-cases
│ │ ├── list --project-code --run-id # List test cases in run
│ │ └── get --project-code --run-id --tcase-id # Get test case in run
│ └── logs
│ └── create --project-code --run-id --comment # Append a run-level log message (e.g. CI/CD or automation framework output) to a test run.
├── settings
│ ├── list-statuses # List result statuses
│ └── update-statuses --statuses # Update custom statuses
Expand Down
140 changes: 59 additions & 81 deletions src/api/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export interface Run {
}

export const CreateRunLogRequestSchema = z.object({
comment: z.string(),
comment: z.string().min(1, 'comment must not be empty'),
})

export type CreateRunLogRequest = z.infer<typeof CreateRunLogRequestSchema>
Expand Down Expand Up @@ -151,86 +151,64 @@ export const RunSchema = z

export const createRunApi = (fetcher: typeof fetch) => {
fetcher = withJson(fetcher)

const getTCases = (projectCode: ResourceId, runId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`)
.then((r) => jsonResponse<{ tcases: RunTCase[] }>(r))
.then((r) => r.tcases)

const create = async (projectCode: ResourceId, req: CreateRunRequest) => {
const validated = validateRequest(req, CreateRunRequestSchema)
return fetcher(`/api/public/v0/project/${projectCode}/run`, {
method: 'POST',
body: JSON.stringify(validated),
}).then((r) => jsonResponse<CreateRunResponse>(r))
}

const list = async (projectCode: ResourceId, params?: ListRunsRequest) => {
const validated = params ? validateRequest(params, ListRunsRequestSchema) : {}
return fetcher(appendSearchParams(`/api/public/v0/project/${projectCode}/run`, validated))
.then((r) => jsonResponse<{ runs: Run[] }>(r))
.then((r) => r.runs)
}

const clone = async (projectCode: ResourceId, req: CloneRunRequest) => {
const validated = validateRequest(req, CloneRunRequestSchema)
return fetcher(`/api/public/v0/project/${projectCode}/run/clone`, {
method: 'POST',
body: JSON.stringify(validated),
}).then((r) => jsonResponse<CloneRunResponse>(r))
}

const close = (projectCode: ResourceId, runId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/close`, {
method: 'POST',
}).then((r) => jsonResponse<MessageResponse>(r))

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

const listTCases = async (
projectCode: ResourceId,
runId: ResourceId,
params?: ListRunTCasesRequest
) => {
const validated = params ? validateRequest(params, ListRunTCasesRequestSchema) : {}
return fetcher(
appendSearchParams(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`, validated)
)
.then((r) => jsonResponse<{ tcases: RunTCase[] }>(r))
.then((r) => r.tcases)
}

const getTCase = (projectCode: ResourceId, runId: ResourceId, tcaseId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase/${tcaseId}`).then((r) =>
jsonResponse<RunTCase>(r)
)

return {
getTCases,
create,
list,
clone,
close,
createLog,
listTCases,
getTCase,
getRunTCases: getTCases,
createRun: create,
listRuns: list,
cloneRun: clone,
closeRun: close,
createRunLog: createLog,
listRunTCases: listTCases,
getRunTCase: getTCase,
getTCases: (projectCode: ResourceId, runId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`)
.then((r) => jsonResponse<{ tcases: RunTCase[] }>(r))
.then((r) => r.tcases),

create: async (projectCode: ResourceId, req: CreateRunRequest) => {
const validated = validateRequest(req, CreateRunRequestSchema)
return fetcher(`/api/public/v0/project/${projectCode}/run`, {
method: 'POST',
body: JSON.stringify(validated),
}).then((r) => jsonResponse<CreateRunResponse>(r))
},

list: async (projectCode: ResourceId, params?: ListRunsRequest) => {
const validated = params ? validateRequest(params, ListRunsRequestSchema) : {}
return fetcher(appendSearchParams(`/api/public/v0/project/${projectCode}/run`, validated))
.then((r) => jsonResponse<{ runs: Run[] }>(r))
.then((r) => r.runs)
},

clone: async (projectCode: ResourceId, req: CloneRunRequest) => {
const validated = validateRequest(req, CloneRunRequestSchema)
return fetcher(`/api/public/v0/project/${projectCode}/run/clone`, {
method: 'POST',
body: JSON.stringify(validated),
}).then((r) => jsonResponse<CloneRunResponse>(r))
},

close: (projectCode: ResourceId, runId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/close`, {
method: 'POST',
}).then((r) => jsonResponse<MessageResponse>(r)),

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

listTCases: async (
projectCode: ResourceId,
runId: ResourceId,
params?: ListRunTCasesRequest
) => {
const validated = params ? validateRequest(params, ListRunTCasesRequestSchema) : {}
return fetcher(
appendSearchParams(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`, validated)
)
.then((r) => jsonResponse<{ tcases: RunTCase[] }>(r))
.then((r) => r.tcases)
},

getTCase: (projectCode: ResourceId, runId: ResourceId, tcaseId: ResourceId) =>
fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase/${tcaseId}`).then((r) =>
jsonResponse<RunTCase>(r)
),
}
}
61 changes: 60 additions & 1 deletion src/commands/api/manifests/runs.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { z } from 'zod'
import {
CreateRunRequestSchema,
CreateRunLogRequestSchema,
QueryPlansSchema,
CloneRunRequestSchema,
type ListRunTCasesRequest,
CreateRunLogRequest,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: CreateRunLogRequest is only used as a type annotation (body as CreateRunLogRequest), so it should be type CreateRunLogRequest to match the existing pattern on line 7 (type ListRunTCasesRequest).

Alternatively, you could use body as Parameters<typeof api.runs.createLog>[2] like the create and clone endpoints do, which would make this import unnecessary entirely.

} from '../../../api/runs'
import { limitParam, sortFieldParam, sortOrderParam, resourceIdSchema } from '../../../api/schemas'
import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils'
Expand Down Expand Up @@ -31,6 +33,7 @@ const help = {
tags: 'Comma-separated tag IDs to filter by.',
priorities: 'Comma-separated priorities to filter by (e.g., "low,high").',
include: 'Include additional fields. Use "folder" to include folder details.',
comment: 'Log message body (supports HTML).',
create: {
describe: 'Create a new test run.',
epilog: apiDocsEpilog('run', 'create-new-run'),
Expand Down Expand Up @@ -102,6 +105,22 @@ const help = {
},
],
},
logsCreate: {
describe:
'Append a run-level log message (e.g. CI/CD or automation framework output) to a test run.',
epilog: apiDocsEpilog('run', 'create-run-log'),
examples: [
{
usage: '$0 api runs logs create --project-code PRJ --run-id 1 --comment "Deploy finished"',
description: 'Create a run log with --comment',
},
{
usage:
'$0 api runs logs create --project-code PRJ --run-id 1 --body \'{"comment": "Deploy finished"}\'',
description: 'Create a run log using --body',
},
],
},
} as const

const runIdParam = {
Expand Down Expand Up @@ -332,4 +351,44 @@ const tcasesGet: ApiEndpointSpec = {
},
}

export const runSpecs: ApiEndpointSpec[] = [create, list, clone, close, tcasesList, tcasesGet]
const logsCreate: ApiEndpointSpec = {
id: 'runs.logs.create',
commandPath: ['runs', 'logs', 'create'],
describe: help.logsCreate.describe,
bodyMode: 'json',
pathParams: [projectCodeParam, runIdParam],
fieldOptions: [
{
name: 'comment',
type: 'string',
describe: help.comment,
schema: CreateRunLogRequestSchema.shape.comment,
},
],
check: (argv) => {
return argv.body !== undefined || argv['body-file'] !== undefined || argv.comment !== undefined
? true
: 'Either --body, --body-file, or --comment is required'
},
epilog: help.logsCreate.epilog,
examples: help.logsCreate.examples,
execute: async (api, { pathParams, body }) => {
printJson(
await api.runs.createLog(
pathParams['project-code'],
pathParams['run-id'],
body as CreateRunLogRequest
)
)
},
}

export const runSpecs: ApiEndpointSpec[] = [
create,
list,
clone,
close,
tcasesList,
tcasesGet,
logsCreate,
]
Loading
Loading