diff --git a/package.json b/package.json index 15fe6bc..7bb7386 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qas-cli", - "version": "0.4.3", + "version": "0.4.4", "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", diff --git a/src/api/run.ts b/src/api/run.ts index ad2ee66..3d4d872 100644 --- a/src/api/run.ts +++ b/src/api/run.ts @@ -1,4 +1,4 @@ -import { CreateResultRequest, ResourceId, RunTCase } from './schemas' +import { CreateResultsRequest, ResourceId, RunTCase } from './schemas' import { jsonResponse, withJson } from './utils' export interface CreateRunRequest { @@ -24,16 +24,16 @@ export const createRunApi = (fetcher: typeof fetch) => { fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`) .then((r) => jsonResponse<{ tcases: RunTCase[] }>(r)) .then((r) => r.tcases), - createResultStatus: ( + + createResults: ( projectCode: ResourceId, runId: ResourceId, - tcaseId: ResourceId, - req: CreateResultRequest + req: CreateResultsRequest ) => - fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase/${tcaseId}/result`, { + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/result/batch`, { body: JSON.stringify(req), method: 'POST', - }).then((r) => jsonResponse<{ id: number }>(r)), + }).then((r) => jsonResponse<{ ids: number[] }>(r)), createRun: (projectCode: ResourceId, req: CreateRunRequest) => fetcher(`/api/public/v0/project/${projectCode}/run`, { diff --git a/src/api/schemas.ts b/src/api/schemas.ts index da1f961..903ff48 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -19,7 +19,12 @@ export interface RunTCase { folder: Folder } -export interface CreateResultRequest { +export interface CreateResultsRequestItem { + tcaseId: string status: ResultStatus comment?: string } + +export interface CreateResultsRequest { + items: CreateResultsRequestItem[] +} diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index fcc8b60..fa2e1fb 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -4,6 +4,7 @@ import { setupServer } from 'msw/node' import { HttpResponse, http } from 'msw' import { runTestCases } from './fixtures/testcases' import { countMockedApiCalls } from './utils' +import { setMaxResultsInRequest } from '../utils/result-upload/ResultUploader' const projectCode = 'TEST' const runId = '1' @@ -56,11 +57,11 @@ const server = setupServer( }) }), http.post( - new RegExp(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/tcase/.+/result`), + new RegExp(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/result/batch`), ({ request }) => { expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') return HttpResponse.json({ - id: 0, + ids: [0], }) } ), @@ -83,12 +84,13 @@ afterAll(() => { afterEach(() => { server.resetHandlers() server.events.removeAllListeners() + setMaxResultsInRequest(50) }) const countFileUploadApiCalls = () => countMockedApiCalls(server, (req) => req.url.endsWith('/file')) const countResultUploadApiCalls = () => - countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith('/result')) + countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith('/result/batch')) const fileTypes = [ { @@ -116,22 +118,23 @@ fileTypes.forEach((fileType) => { ] for (const pattern of patterns) { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() await run(pattern) - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(5) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(1) // 5 results total } }) test('Passing correct Run URL pattern without https, should result in success', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + setMaxResultsInRequest(1) await run( `${fileType.command} -r ${qasHost}/project/${projectCode}/run/${runId} ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` ) - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(5) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(5) // 5 results total }) test('Passing incorrect Run URL pattern should result in failure', async () => { @@ -141,8 +144,8 @@ fileTypes.forEach((fileType) => { ] for (const pattern of patterns) { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() let isError = false try { @@ -151,105 +154,110 @@ fileTypes.forEach((fileType) => { isError = true } expect(isError).toBeTruthy() - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(0) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(0) } }) }) describe('Uploading test results', () => { test('Test cases on reports with all matching test cases on QAS should be successful', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + setMaxResultsInRequest(2) await run( `${fileType.command} -r ${runURL} ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` ) - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(5) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(3) // 5 results total }) test('Test cases on reports with a missing test case on QAS should throw an error', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() await expect( run( `${fileType.command} -r ${runURL} ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension}` ) ).rejects.toThrowError() - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(0) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(0) }) test('Test cases on reports with a missing test case on QAS should be successful when forced', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + setMaxResultsInRequest(3) await run( `${fileType.command} -r ${runURL} --force ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension}` ) - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(4) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(2) // 4 results total }) test('Test cases on reports with missing test cases should be successful with --ignore-unmatched', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() await run( `${fileType.command} -r ${runURL} --ignore-unmatched ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension}` ) - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(4) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(1) // 4 results total }) test('Test cases from multiple reports should be processed successfully', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + setMaxResultsInRequest(2) await run( `${fileType.command} -r ${runURL} --force ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension} ${fileType.dataBasePath}/missing-tcases.${fileType.fileExtension}` ) - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(8) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(4) // 8 results total }) test('Test suite with empty tcases should not result in error and be skipped', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() await run( `${fileType.command} -r ${runURL} --force ${fileType.dataBasePath}/empty-tsuite.${fileType.fileExtension}` ) - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(1) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(1) // 1 result total }) }) describe('Uploading with attachments', () => { test('Attachments should be uploaded', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + setMaxResultsInRequest(3) await run( `${fileType.command} -r ${runURL} --attachments ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` ) - expect(fileUploadCount()).toBe(5) - expect(tcaseUploadCount()).toBe(5) + expect(numFileUploadCalls()).toBe(5) + expect(numResultUploadCalls()).toBe(2) // 5 results total }) test('Missing attachments should throw an error', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() await expect( run( `${fileType.command} -r ${runURL} --attachments ${fileType.dataBasePath}/missing-attachments.${fileType.fileExtension}` ) ).rejects.toThrow() - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(0) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(0) }) test('Missing attachments should be successful when forced', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + setMaxResultsInRequest(1) await run( `${fileType.command} -r ${runURL} --attachments --force ${fileType.dataBasePath}/missing-attachments.${fileType.fileExtension}` ) - expect(fileUploadCount()).toBe(4) - expect(tcaseUploadCount()).toBe(5) + expect(numFileUploadCalls()).toBe(4) + expect(numResultUploadCalls()).toBe(5) // 5 results total }) }) @@ -318,8 +326,8 @@ fileTypes.forEach((fileType) => { }) test('Should reuse existing run when run title is already used', async () => { - const fileUploadCount = countFileUploadApiCalls() - const tcaseUploadCount = countResultUploadApiCalls() + const numFileUploadCalls = countFileUploadApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() createRunTitleConflict = true await run( @@ -327,8 +335,8 @@ fileTypes.forEach((fileType) => { ) expect(lastCreatedRunTitle).toBe('duplicate run title') - expect(fileUploadCount()).toBe(0) - expect(tcaseUploadCount()).toBe(5) + expect(numFileUploadCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(1) // 5 results total }) test('Should use default name template when --run-name is not specified', async () => { diff --git a/src/utils/misc.ts b/src/utils/misc.ts index e485b0e..dd70186 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -40,6 +40,8 @@ export const twirlLoader = () => { if (timer) { clearInterval(timer) } + x = chars.length - 1 + update() process.stdout.write('\n') }, setText: (newText: string) => { diff --git a/src/utils/result-upload/ResultUploader.ts b/src/utils/result-upload/ResultUploader.ts index 7f11906..f6703f0 100644 --- a/src/utils/result-upload/ResultUploader.ts +++ b/src/utils/result-upload/ResultUploader.ts @@ -3,9 +3,12 @@ import chalk from 'chalk' import { RunTCase } from '../../api/schemas' import { parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' import { Api, createApi } from '../../api' -import { TestCaseResult } from './types' +import { Attachment, TestCaseResult } from './types' import { ResultUploadCommandArgs, UploadCommandType } from './ResultUploadCommandHandler' +const MAX_CONCURRENT_FILE_UPLOADS = 10 +let MAX_RESULTS_IN_REQUEST = 50 // Only updated from tests, otherwise it's a constant + export class ResultUploader { private api: Api private project: string @@ -137,38 +140,140 @@ ${chalk.yellow('To fix this issue, choose one of the following options:')} private uploadTestCases = async (results: TCaseWithResult[]) => { const loader = twirlLoader() - loader.start() try { - for (let i = 0; i < results.length; i++) { - const { tcase, result } = results[i] - let comment = result.message - loader.setText(`Uploading test case ${i + 1} of ${results.length}`) - if (this.args.attachments) { - const attachmentUrls: Array<{ name: string; url: string }> = [] - for (const attachment of result.attachments) { - if (attachment.buffer) { - const { url } = await this.api.file.uploadFile( - new Blob([attachment.buffer]), - attachment.filename - ) - attachmentUrls.push({ url, name: attachment.filename }) - } - } - if (attachmentUrls.length > 0) { - comment += `\n

Attachments:

\n${makeListHtml(attachmentUrls)}` - } + const resultsWithAttachments = await this.uploadAllAttachments(results, loader) + await this.createResultsInBatches(resultsWithAttachments, loader) + } catch (e) { + loader.stop() + printErrorThenExit(e) + } + } + + private uploadAllAttachments = async ( + results: TCaseWithResult[], + loader: ReturnType + ): Promise => { + if (!this.args.attachments) { + return results + } + + // Collect all attachments from all test cases + const allAttachments: Array<{ + attachment: Attachment + tcaseIndex: number + }> = [] + let uploadedCount = 0 + + results.forEach((item, index) => { + item.result.attachments.forEach((attachment) => { + if (attachment.buffer !== null) { + allAttachments.push({ attachment, tcaseIndex: index }) } + }) + }) + + if (allAttachments.length === 0) { + return results + } + + // Upload all attachments concurrently with progress tracking + loader.start(`Uploading attachments: 0/${allAttachments.length} files uploaded`) + const uploadedAttachments = await this.processConcurrently( + allAttachments, + async ({ attachment, tcaseIndex }) => { + const { url } = await this.api.file.uploadFile( + new Blob([attachment.buffer! as BlobPart]), + attachment.filename + ) + uploadedCount++ + loader.setText( + `Uploading attachments: ${uploadedCount}/${allAttachments.length} files uploaded` + ) + return { + tcaseIndex, + url, + name: attachment.filename, + } + }, + MAX_CONCURRENT_FILE_UPLOADS + ) + loader.stop() + + // Group uploaded attachments by test case index + const attachmentsByTCase = new Map>() + uploadedAttachments.forEach(({ tcaseIndex, url, name }) => { + if (!attachmentsByTCase.has(tcaseIndex)) { + attachmentsByTCase.set(tcaseIndex, []) + } + attachmentsByTCase.get(tcaseIndex)!.push({ url, name }) + }) + + // Map results with their uploaded attachment URLs + return results.map(({ tcase, result }, index) => { + const attachmentUrls = attachmentsByTCase.get(index) || [] + if (attachmentUrls.length > 0) { + result.message += `\n

Attachments:

\n${makeListHtml(attachmentUrls)}` + } + + return { + tcase, + result, + } + }) + } + + private createResultsInBatches = async ( + results: TCaseWithResult[], + loader: ReturnType + ) => { + const totalBatches = Math.ceil(results.length / MAX_RESULTS_IN_REQUEST) - await this.api.runs.createResultStatus(this.project, this.run, tcase.id, { + loader.start(`Creating results: 0/${results.length} results created`) + for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) { + const startIdx = batchIndex * MAX_RESULTS_IN_REQUEST + const endIdx = Math.min(startIdx + MAX_RESULTS_IN_REQUEST, results.length) + const batch = results.slice(startIdx, endIdx) + + await this.api.runs.createResults(this.project, this.run, { + items: batch.map(({ tcase, result }) => ({ + tcaseId: tcase.id, status: result.status, - comment, + comment: result.message, + })), + }) + + loader.setText(`Creating results: ${endIdx}/${results.length} results created`) + } + loader.stop() + } + + private async processConcurrently( + items: T[], + handler: (item: T) => Promise, + concurrency: number + ): Promise { + const results: R[] = [] + const executing: Set> = new Set() + + for (const item of items) { + const promise = handler(item) + .then((result) => { + results.push(result) + }) + .finally(() => { + executing.delete(wrappedPromise) }) + + const wrappedPromise = promise + executing.add(wrappedPromise) + + if (executing.size >= concurrency) { + await Promise.race(executing) } - loader.stop() - } catch (e) { - loader.stop() - printErrorThenExit(e) } + + await Promise.all(Array.from(executing)) + return results } private mapTestCaseResults = (testcaseResults: TestCaseResult[], testcases: RunTCase[]) => { @@ -194,6 +299,10 @@ ${chalk.yellow('To fix this issue, choose one of the following options:')} } } +export const setMaxResultsInRequest = (max: number) => { + MAX_RESULTS_IN_REQUEST = max +} + interface TCaseWithResult { tcase: RunTCase result: TestCaseResult