From 0d964c2b8acef56cfd444ab89b78591d8fb2b4f7 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Tue, 19 May 2026 18:36:50 +0400 Subject: [PATCH 1/4] Add create run logs --- README.md | 8 +- skills/qas-cli/SKILL.md | 8 +- src/commands/api/manifests/runs.ts | 59 ++++++++++++- src/tests/api/runs/logs-create.spec.ts | 112 +++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 src/tests/api/runs/logs-create.spec.ts diff --git a/README.md b/README.md index 165ff82..264b6df 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills/qas-cli/SKILL.md b/skills/qas-cli/SKILL.md index 584113f..1fbe601 100644 --- a/skills/qas-cli/SKILL.md +++ b/skills/qas-cli/SKILL.md @@ -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 # Create run log ├── settings │ ├── list-statuses # List result statuses │ └── update-statuses --statuses # Update custom statuses diff --git a/src/commands/api/manifests/runs.ts b/src/commands/api/manifests/runs.ts index 6e59625..0147291 100644 --- a/src/commands/api/manifests/runs.ts +++ b/src/commands/api/manifests/runs.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { CreateRunRequestSchema, + CreateRunLogRequestSchema, QueryPlansSchema, CloneRunRequestSchema, type ListRunTCasesRequest, @@ -31,6 +32,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'), @@ -102,6 +104,21 @@ const help = { }, ], }, + logsCreate: { + describe: 'Create a log entry on 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 = { @@ -332,4 +349,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 Parameters[2] + ) + ) + }, +} + +export const runSpecs: ApiEndpointSpec[] = [ + create, + list, + clone, + close, + tcasesList, + tcasesGet, + logsCreate, +] diff --git a/src/tests/api/runs/logs-create.spec.ts b/src/tests/api/runs/logs-create.spec.ts new file mode 100644 index 0000000..f6b693c --- /dev/null +++ b/src/tests/api/runs/logs-create.spec.ts @@ -0,0 +1,112 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { CreateRunLogRequest } from '../../../api/runs' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + expectValidationError, + testRejectsInvalidIdentifier, + testBodyInput, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'logs', 'create', ...args) + +describe('mocked', () => { + let lastBody: CreateRunLogRequest | null = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/log`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`ApiKey ${token}`) + lastParams = params + lastBody = (await request.json()) as CreateRunLogRequest + return HttpResponse.json({ id: 'log-1' }) + } + ) + ) + + beforeEach(() => { + lastBody = null + lastParams = {} + }) + + test('creates a run log with --comment', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--comment', + 'Deploy finished' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(lastBody).toEqual({ comment: 'Deploy finished' }) + expect(result).toEqual({ id: 'log-1' }) + }) + + const validBody = { comment: 'Body comment' } + + testBodyInput( + runCommand, + () => lastBody, + (h) => { + const requiredArgs = ['--project-code', 'PRJ', '--run-id', '1'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--comment', 'Overridden'], + expectedRequest: { comment: 'Overridden' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + } + ) +}) + +describe('validation errors', () => { + test('requires --comment, --body, or --body-file', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1'), + /Either --body, --body-file, or --comment is required/ + ) + }) + + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--comment', + 'msg', + ]) +}) + +test('creates a run log on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const tcase = await createTCase(project.code, folderId) + const run = await createRun(project.code, [tcase.id]) + const result = await runCli<{ id: string }>( + 'api', + 'runs', + 'logs', + 'create', + '--project-code', + project.code, + '--run-id', + String(run.id), + '--comment', + 'CLI live test log' + ) + expect(result).toHaveProperty('id') + expect(typeof result.id).toBe('string') +}) From b05a1dd6b97d6587acb5c75e2b21489f68b6bcb9 Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Tue, 26 May 2026 10:57:52 +0400 Subject: [PATCH 2/4] Expand run-log description and docs Clarify what run-level logs are used for in --help, README, and SKILL.md command tree. --- README.md | 4 ++++ skills/qas-cli/SKILL.md | 2 +- src/commands/api/manifests/runs.ts | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 264b6df..460105b 100644 --- a/README.md +++ b/README.md @@ -446,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 `` 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. +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: diff --git a/skills/qas-cli/SKILL.md b/skills/qas-cli/SKILL.md index 1fbe601..af33da4 100644 --- a/skills/qas-cli/SKILL.md +++ b/skills/qas-cli/SKILL.md @@ -155,7 +155,7 @@ qasphere api │ │ ├── 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 +│ └── 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 diff --git a/src/commands/api/manifests/runs.ts b/src/commands/api/manifests/runs.ts index 0147291..de4a622 100644 --- a/src/commands/api/manifests/runs.ts +++ b/src/commands/api/manifests/runs.ts @@ -105,7 +105,8 @@ const help = { ], }, logsCreate: { - describe: 'Create a log entry on a test run.', + 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: [ { From 70f20ede455baf7c87fabe8770e6f8f5d57110fd Mon Sep 17 00:00:00 2001 From: Ramil Amparo Date: Tue, 26 May 2026 11:34:38 +0400 Subject: [PATCH 3/4] Clean up runs API and validate run log comment - Inline runs API methods into the returned object literal and drop unused long-form aliases (createRun, listRuns, etc.) to match the pattern used by folders/tcases. - Require non-empty comment in CreateRunLogRequestSchema and add tests covering both --comment and --body paths. --- src/api/runs.ts | 140 +++++++++++-------------- src/commands/api/manifests/runs.ts | 3 +- src/tests/api/runs/logs-create.spec.ts | 20 +++- 3 files changed, 80 insertions(+), 83 deletions(-) diff --git a/src/api/runs.ts b/src/api/runs.ts index 97758d5..d75ff14 100644 --- a/src/api/runs.ts +++ b/src/api/runs.ts @@ -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 @@ -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(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(r)) - } - - const close = (projectCode: ResourceId, runId: ResourceId) => - fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/close`, { - method: 'POST', - }).then((r) => jsonResponse(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(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(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(r)) + }, + + close: (projectCode: ResourceId, runId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/close`, { + method: 'POST', + }).then((r) => jsonResponse(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(r) + ), } } diff --git a/src/commands/api/manifests/runs.ts b/src/commands/api/manifests/runs.ts index de4a622..5eba423 100644 --- a/src/commands/api/manifests/runs.ts +++ b/src/commands/api/manifests/runs.ts @@ -5,6 +5,7 @@ import { QueryPlansSchema, CloneRunRequestSchema, type ListRunTCasesRequest, + CreateRunLogRequest, } from '../../../api/runs' import { limitParam, sortFieldParam, sortOrderParam, resourceIdSchema } from '../../../api/schemas' import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' @@ -376,7 +377,7 @@ const logsCreate: ApiEndpointSpec = { await api.runs.createLog( pathParams['project-code'], pathParams['run-id'], - body as Parameters[2] + body as CreateRunLogRequest ) ) }, diff --git a/src/tests/api/runs/logs-create.spec.ts b/src/tests/api/runs/logs-create.spec.ts index f6b693c..8323b03 100644 --- a/src/tests/api/runs/logs-create.spec.ts +++ b/src/tests/api/runs/logs-create.spec.ts @@ -1,5 +1,5 @@ import { HttpResponse, http, type PathParams } from 'msw' -import { beforeEach, describe, expect } from 'vitest' +import { beforeEach, describe, expect, vi } from 'vitest' import type { CreateRunLogRequest } from '../../../api/runs' import { test, @@ -70,6 +70,7 @@ describe('mocked', () => { requiredArgs, }) h.testInvalidJson(requiredArgs) + h.testInvalidBody({ comment: '' }, /must not be empty/, requiredArgs) } ) }) @@ -88,6 +89,23 @@ describe('validation errors', () => { '--comment', 'msg', ]) + + test('rejects empty --comment', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect( + runCommand('--project-code', 'PRJ', '--run-id', '1', '--comment', '') + ).rejects.toThrow('process.exit') + const errorOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n') + expect(errorOutput).toMatch(/--comment.*must not be empty/) + } finally { + exitSpy.mockRestore() + errorSpy.mockRestore() + } + }) }) test('creates a run log on live server', { tags: ['live'] }, async ({ project }) => { From 2418e1a612f1be939778c6215e37923de774f43c Mon Sep 17 00:00:00 2001 From: Satvik Choudhary Date: Fri, 29 May 2026 16:39:35 +0530 Subject: [PATCH 4/4] Bump version to 0.8.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5937aed..8f88528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qas-cli", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qas-cli", - "version": "0.8.0", + "version": "0.8.1", "license": "ISC", "dependencies": { "@napi-rs/keyring": "^1.2.0", diff --git a/package.json b/package.json index 5c3a0cd..0a5d19a 100644 --- a/package.json +++ b/package.json @@ -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",