From 993ce44e156eded67e1e47e74ee5b3018001070a Mon Sep 17 00:00:00 2001 From: Himanshu Rai Date: Fri, 19 Dec 2025 19:06:01 +0530 Subject: [PATCH 1/4] Add support for creating test cases --- README.md | 38 ++-- src/api/schemas.ts | 31 +++ src/api/tcases.ts | 53 ++--- src/api/utils.ts | 36 ++++ src/commands/resultUpload.ts | 77 +++++-- .../fixtures/junit-xml/without-markers.xml | 16 ++ .../playwright-json/without-markers.json | 77 +++++++ src/tests/result-upload.spec.ts | 71 ++++++- .../ResultUploadCommandHandler.ts | 193 +++++++++++++----- 9 files changed, 476 insertions(+), 116 deletions(-) create mode 100644 src/tests/fixtures/junit-xml/without-markers.xml create mode 100644 src/tests/fixtures/playwright-json/without-markers.json diff --git a/README.md b/README.md index a223cc6..685cc9e 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,19 @@ 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 @@ -96,32 +103,33 @@ 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: ```bash @@ -139,13 +147,13 @@ 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: +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 ``` @@ -153,7 +161,7 @@ Ensure the required environment variables are defined before running these comma ## 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 diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 903ff48..c6d7f46 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -2,6 +2,37 @@ export type ResourceId = string | number export type ResultStatus = 'open' | 'passed' | 'blocked' | 'failed' | 'skipped' +export interface PaginatedResponse { + 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 Folder { id: number title: string diff --git a/src/api/tcases.ts b/src/api/tcases.ts index a3443a8..0a9575f 100644 --- a/src/api/tcases.ts +++ b/src/api/tcases.ts @@ -1,34 +1,25 @@ -import { ResourceId } from './schemas' -import { jsonResponse, withJson } from './utils' -export interface PaginatedResponse { - 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, + PaginatedRequest, + PaginatedResponse, + ResourceId, + TCase, +} from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' export const createTCaseApi = (fetcher: typeof fetch) => { - fetcher = withJson(fetcher) - return { - getTCasesBySeq: (projectCode: ResourceId, request: GetTCasesBySeqRequest) => - fetcher(`/api/public/v0/project/${projectCode}/tcase/seq`, { - method: 'POST', - body: JSON.stringify(request), - }).then((r) => jsonResponse>(r)), - } + fetcher = withJson(fetcher) + return { + getTCasesPaginated: (projectCode: ResourceId, request: PaginatedRequest) => + fetcher(appendSearchParams(`/api/public/v0/project/${projectCode}/tcase`, request)).then( + (r) => jsonResponse>(r) + ), + + createTCases: (projectCode: ResourceId, request: CreateTCasesRequest) => + fetcher(`/api/public/v0/project/${projectCode}/tcase/bulk`, { + method: 'POST', + body: JSON.stringify(request), + }).then((r) => jsonResponse(r)), + } } diff --git a/src/api/utils.ts b/src/api/utils.ts index 4f459db..7461557 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -48,3 +48,39 @@ export const jsonResponse = async (response: Response): Promise => { } throw new Error(response.statusText) } + +const updateSearchParams = (searchParams: URLSearchParams, obj?: T) => { + const isValidValue = (value: unknown) => { + return value || value === false || value === '' + } + + 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 = (pathname: string, obj: T): string => { + const searchParams = new URLSearchParams() + updateSearchParams(searchParams, obj) + + if (searchParams.size > 0) { + return `${pathname}?${searchParams.toString()}` + } + return pathname +} diff --git a/src/commands/resultUpload.ts b/src/commands/resultUpload.ts index df5cac0..37e3a90 100644 --- a/src/commands/resultUpload.ts +++ b/src/commands/resultUpload.ts @@ -1,10 +1,10 @@ import { Arguments, Argv, CommandModule } from 'yargs' import chalk from 'chalk' -import { loadEnvs } from '../utils/env' +import { loadEnvs, qasEnvFile } from '../utils/env' import { - ResultUploadCommandArgs, - ResultUploadCommandHandler, - UploadCommandType + ResultUploadCommandArgs, + ResultUploadCommandHandler, + UploadCommandType, } from '../utils/result-upload/ResultUploadCommandHandler' const commandTypeDisplayStrings: Record = { @@ -36,11 +36,22 @@ export class ResultUploadCommandModule implements CommandModule + + + + + + + + + + + + diff --git a/src/tests/fixtures/playwright-json/without-markers.json b/src/tests/fixtures/playwright-json/without-markers.json new file mode 100644 index 0000000..7ca1b39 --- /dev/null +++ b/src/tests/fixtures/playwright-json/without-markers.json @@ -0,0 +1,77 @@ +{ + "suites": [ + { + "title": "ui.cart.spec.ts", + "specs": [ + { + "title": "Test cart TEST-002", + "tags": [], + "tests": [ + { + "annotations": [], + "expectedStatus": "passed", + "projectName": "chromium", + "results": [ + { + "status": "passed", + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "attachments": [] + } + ], + "status": "expected" + } + ] + }, + { + "title": "The cart is still filled after refreshing the page", + "tags": [], + "tests": [ + { + "annotations": [], + "expectedStatus": "passed", + "projectName": "chromium", + "results": [ + { + "status": "passed", + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "attachments": [] + } + ], + "status": "expected" + } + ] + }, + { + "title": "TEST-010: Cart should be cleared after making the checkout", + "tags": [], + "tests": [ + { + "annotations": [], + "expectedStatus": "passed", + "projectName": "chromium", + "results": [ + { + "status": "passed", + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "attachments": [] + } + ], + "status": "expected" + } + ] + } + ], + "suites": [] + } + ] +} + diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index fa2e1fb..c382b4d 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -1,10 +1,12 @@ -import { afterAll, beforeAll, expect, test, describe, afterEach } from 'vitest' +import { afterAll, beforeAll, beforeEach, expect, test, describe, afterEach } from 'vitest' import { run } from '../commands/main' 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' +import { CreateTCasesResponse } from '../api/schemas' +import { unlinkSync, readdirSync } from 'node:fs' const projectCode = 'TEST' const runId = '1' @@ -16,6 +18,7 @@ process.env['QAS_TOKEN'] = 'QAS_TOKEN' process.env['QAS_URL'] = baseURL let lastCreatedRunTitle = '' +let createTCasesResponse: CreateTCasesResponse | null = null let createRunTitleConflict = false const server = setupServer( @@ -23,7 +26,7 @@ const server = setupServer( expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') return HttpResponse.json({ exists: true }) }), - http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/seq`, ({ request }) => { + http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') return HttpResponse.json({ data: runTestCases, @@ -72,6 +75,10 @@ const server = setupServer( id: 'TEST', url: 'http://example.com', }) + }), + http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/bulk`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') + return HttpResponse.json(createTCasesResponse) }) ) @@ -91,6 +98,22 @@ const countFileUploadApiCalls = () => countMockedApiCalls(server, (req) => req.url.endsWith('/file')) const countResultUploadApiCalls = () => countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith('/result/batch')) +const countCreateTCasesApiCalls = () => + countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith('/tcase/bulk')) + +const getMappingFiles = () => + new Set( + readdirSync('.').filter((f) => f.startsWith('qasphere-automapping-') && f.endsWith('.txt')) + ) + +const cleanupMappingFiles = (existingMappingFiles?: Set) => { + const currentFiles = getMappingFiles() + currentFiles.forEach((f) => { + if (!existingMappingFiles?.has(f)) { + unlinkSync(f) + } + }) +} const fileTypes = [ { @@ -160,7 +183,7 @@ fileTypes.forEach((fileType) => { }) }) - describe('Uploading test results', () => { + describe('Uploading test results with run URL', () => { test('Test cases on reports with all matching test cases on QAS should be successful', async () => { const numFileUploadCalls = countFileUploadApiCalls() const numResultUploadCalls = countResultUploadApiCalls() @@ -351,5 +374,47 @@ fileTypes.forEach((fileType) => { ) }) }) + + describe('Uploading test results with new run', () => { + let existingMappingFiles: Set | undefined = undefined + + beforeEach(() => { + existingMappingFiles = getMappingFiles() + }) + + afterEach(() => { + cleanupMappingFiles(existingMappingFiles) + createTCasesResponse = null + existingMappingFiles = undefined + }) + + test('Should create new test cases for results without valid markers', async () => { + const numCreateTCasesCalls = countCreateTCasesApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + + createTCasesResponse = { + tcases: [ + { id: '6', seq: 6 }, + { id: '7', seq: 7 }, + ], + } + + await run( + `${fileType.command} --project-code ${projectCode} --create-tcases ${fileType.dataBasePath}/without-markers.${fileType.fileExtension}` + ) + expect(numCreateTCasesCalls()).toBe(1) + expect(numResultUploadCalls()).toBe(1) // 3 results total + }) + + test('Should not create new test cases if all results have valid markers', async () => { + const numCreateTCasesCalls = countCreateTCasesApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + await run( + `${fileType.command} --project-code ${projectCode} --create-tcases ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` + ) + expect(numCreateTCasesCalls()).toBe(0) + expect(numResultUploadCalls()).toBe(1) // 5 results total + }) + }) }) }) diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 43aa3ef..54d4907 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -4,11 +4,12 @@ import { readFileSync } from 'node:fs' import { dirname } from 'node:path' import { parseRunUrl, printErrorThenExit, processTemplate } from '../misc' import { Api, createApi } from '../../api' -import { PaginatedResponse, TCaseBySeq } from '../../api/tcases' +import { TCase } from '../../api/schemas' import { TestCaseResult } from './types' import { ResultUploader } from './ResultUploader' import { parseJUnitXml } from './junitXmlParser' import { parsePlaywrightJson } from './playwrightJsonParser' +import { writeFileSync } from 'node:fs' export type UploadCommandType = 'junit-upload' | 'playwright-json-upload' @@ -25,23 +26,34 @@ export type Parser = ( options: ParserOptions ) => Promise -export interface ResultUploadCommandArgs { +export type ResultUploadCommandArgs = { type: UploadCommandType - runUrl?: string - runName?: string files: string[] force: boolean attachments: boolean ignoreUnmatched: boolean skipReportStdout: SkipOutputOption skipReportStderr: SkipOutputOption -} +} & ( + | { + runUrl: string + } + | { + projectCode?: string + runName: string + createTcases: boolean + } +) interface FileResults { file: string results: TestCaseResult[] } +const GET_TCASES_PAGE_SIZE = 5000 +const DEFAULT_FOLDER_TITLE = 'cli-import' +const DEFAULT_TCASE_TAGS = ['cli-import'] +const DEFAULT_MAPPING_FILENAME_TEMPLATE = 'qasphere-automapping-{YYYY}{MM}{DD}-{HH}{mm}{ss}.txt' const commandTypeParsers: Record = { 'junit-upload': parseJUnitXml, 'playwright-json-upload': parsePlaywrightJson, @@ -63,12 +75,11 @@ export class ResultUploadCommandHandler { return printErrorThenExit('No files specified') } - const fileResults = await this.parseFiles() - const results = fileResults.flatMap((fileResult) => fileResult.results) - + let fileResults = await this.parseFiles() let projectCode = '' let runId = 0 - if (this.args.runUrl) { + + if ('runUrl' in this.args) { // Handle existing run URL console.log(chalk.blue(`Using existing test run: ${this.args.runUrl}`)) @@ -82,22 +93,25 @@ export class ResultUploadCommandHandler { runId = urlParsed.run projectCode = urlParsed.project } else { - // Auto-detect project from results - projectCode = this.detectProjectCode(results) - console.log(chalk.blue(`Detected project code: ${projectCode}`)) + if (this.args.projectCode) { + projectCode = this.args.projectCode + } else { + // Try to auto-detect project code from results. This is not fully reliable, but + // is kept for backward compatibility. Better to specify project code explicitly + projectCode = this.detectProjectCode(fileResults) + console.log(chalk.blue(`Detected project code: ${projectCode}`)) + } - // Create a new test run if (!(await this.api.projects.checkProjectExists(projectCode))) { return printErrorThenExit(`Project ${projectCode} does not exist`) } - console.log(chalk.blue(`Creating a new test run for project: ${projectCode}`)) - const tcaseRefs = this.extractTestCaseRefs(projectCode, fileResults) - const tcases = await this.getTestCases(projectCode, tcaseRefs) - runId = await this.createNewRun(projectCode, tcases) - console.log(chalk.blue(`Test run URL: ${this.baseUrl}/project/${projectCode}/run/${runId}`)) + const resp = await this.getTCaseIds(projectCode, fileResults) + fileResults = resp.fileResults + runId = await this.createNewRun(projectCode, resp.tcaseIds) } + const results = fileResults.flatMap((fileResult) => fileResult.results) await this.uploadResults(projectCode, runId, results) } @@ -111,33 +125,58 @@ export class ResultUploadCommandHandler { for (const file of this.args.files) { const fileData = readFileSync(file).toString() - const fileResults = await commandTypeParsers[this.type](fileData, dirname(file), parserOptions) + const fileResults = await commandTypeParsers[this.type]( + fileData, + dirname(file), + parserOptions + ) results.push({ file, results: fileResults }) } return results } - protected detectProjectCode(results: TestCaseResult[]) { - for (const result of results) { - if (result.name) { - // Look for pattern like PRJ-123 or TEST-456 - const match = result.name.match(/([A-Za-z0-9]{1,5})-\d{3,}/) - if (match) { - return match[1] + protected detectProjectCode(fileResults: FileResults[]) { + for (const { results } of fileResults) { + for (const result of results) { + if (result.name) { + // Look for pattern like PRJ-123 or TEST-456 + const match = result.name.match(/([A-Za-z0-9]{1,5})-\d{3,}/) + if (match) { + return match[1] + } } } } return printErrorThenExit( - 'Could not detect project code from test case names. Please make sure they contain a valid project code (e.g., PRJ-123)' + 'Could not detect project code from test case names. Please specify project code using --project-code flag' ) } - protected extractTestCaseRefs(projectCode: string, fileResults: FileResults[]): Set { - const tcaseRefs = new Set() + protected async getTCaseIds(projectCode: string, fileResults: FileResults[]) { const shouldFailOnInvalid = !this.args.force && !this.args.ignoreUnmatched + const tcaseMapBySeq: Record = {} + const tcaseMapByTitle: Record = {} + for (let page = 1; ; page++) { + const response = await this.api.testcases.getTCasesPaginated(projectCode, { + page, + limit: GET_TCASES_PAGE_SIZE, + }) + + for (const tcase of response.data) { + tcaseMapBySeq[tcase.seq] = tcase + tcaseMapByTitle[tcase.title] = tcase // If there are multiple tcases with the same title, it will be overwritten + } + + if (response.data.length < GET_TCASES_PAGE_SIZE) { + break + } + } + + const tcaseIds: string[] = [] + const tcasesToCreate: Map = new Map() for (const { file, results } of fileResults) { for (const result of results) { if (!result.name) { @@ -149,7 +188,25 @@ export class ResultUploadCommandHandler { const match = new RegExp(`${projectCode}-(\\d{3,})`).exec(result.name) if (match) { - tcaseRefs.add(`${projectCode}-${match[1]}`) + const tcase = tcaseMapBySeq[Number(match[1])] + if (tcase) { + tcaseIds.push(tcase.id) + continue + } + } + + const tcase = tcaseMapByTitle[result.name] + if (tcase) { + // Prefix the test case markers for use in ResultUploader + result.name = `${projectCode}-${tcase.seq.toString().padStart(3, '0')}: ${result.name}` + tcaseIds.push(tcase.id) + continue + } + + if (this.args.createTcases) { + const tcaseResults = tcasesToCreate.get(result.name) || [] + tcaseResults.push(result) + tcasesToCreate.set(result.name, tcaseResults) continue } @@ -161,45 +218,85 @@ export class ResultUploadCommandHandler { } } - if (tcaseRefs.size === 0) { - return printErrorThenExit('No valid test case references found in any of the files') + if (tcasesToCreate.size > 0) { + const keys = Array.from(tcasesToCreate.keys()) + const newTcases = await this.createNewTCases(projectCode, keys) + + for (let i = 0; i < keys.length; i++) { + const marker = `${projectCode}-${newTcases[i].seq.toString().padStart(3, '0')}` + for (const result of tcasesToCreate.get(keys[i]) || []) { + // Prefix the test case markers for use in ResultUploader + result.name = `${marker}: ${result.name}` + } + tcaseIds.push(newTcases[i].id) + } } - return tcaseRefs + if (tcaseIds.length === 0) { + return printErrorThenExit('No valid test cases found in any of the files') + } + + return { tcaseIds, fileResults } } - private async getTestCases(projectCode: string, tcaseRefs: Set) { - const response = await this.api.testcases.getTCasesBySeq(projectCode, { - seqIds: Array.from(tcaseRefs), - page: 1, - limit: tcaseRefs.size, + private async createNewTCases(projectCode: string, tcasesToCreate: string[]) { + console.log(chalk.blue(`Creating new test cases for results with no test case markers`)) + + const { tcases } = await this.api.testcases.createTCases(projectCode, { + folderPath: [DEFAULT_FOLDER_TITLE], + tcases: tcasesToCreate.map((title) => ({ title, tags: DEFAULT_TCASE_TAGS })), }) - if (response.total === 0 || response.data.length === 0) { - return printErrorThenExit('No matching test cases found in the project') + console.log( + chalk.green(`Created ${tcases.length} new test cases in folder "${DEFAULT_FOLDER_TITLE}"`) + ) + + try { + const mappingFilename = processTemplate(DEFAULT_MAPPING_FILENAME_TEMPLATE) + const mappingLines = tcases.map((t, i) => `${t.seq}: ${tcasesToCreate[i]}`).join('\n') + writeFileSync(mappingFilename, mappingLines) + console.log( + chalk.green(`Created mapping file for newly created test cases: ${mappingFilename}`) + ) + console.log( + chalk.yellow( + `Update your test cases to include the test case markers in the name, for future uploads` + ) + ) + } catch (err) { + console.log( + chalk.yellow( + `Warning: Failed to write test case mapping file: ${ + err instanceof Error ? err.message : String(err) + }` + ) + ) } - return response + return tcases } - private async createNewRun(projectCode: string, tcases: PaginatedResponse) { + private async createNewRun(projectCode: string, tcaseIds: string[]) { const title = processTemplate( - this.args.runName ?? 'Automated test run - {MMM} {DD}, {YYYY}, {hh}:{mm}:{ss} {AMPM}' + 'runName' in this.args + ? (this.args.runName as string) + : 'Automated test run - {MMM} {DD}, {YYYY}, {hh}:{mm}:{ss} {AMPM}' ) + console.log(chalk.blue(`Creating a new test run for project: ${projectCode}`)) + try { const response = await this.api.runs.createRun(projectCode, { title, description: 'Test run created through automation pipeline', type: 'static_struct', - queryPlans: [ - { - tcaseIds: tcases.data.map((t: TCaseBySeq) => t.id), - }, - ], + queryPlans: [{ tcaseIds }], }) console.log(chalk.green(`Created new test run "${title}" with ID: ${response.id}`)) + console.log( + chalk.blue(`Test run URL: ${this.baseUrl}/project/${projectCode}/run/${response.id}`) + ) return response.id } catch (error) { // Check if the error is about conflicting run ID From cd525f7441023d81300425708353f49cb467d3fe Mon Sep 17 00:00:00 2001 From: Himanshu Rai Date: Fri, 19 Dec 2025 20:31:45 +0530 Subject: [PATCH 2/4] Fix comments --- src/utils/result-upload/ResultUploadCommandHandler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 54d4907..5a203c2 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -40,7 +40,7 @@ export type ResultUploadCommandArgs = { } | { projectCode?: string - runName: string + runName?: string createTcases: boolean } ) @@ -79,7 +79,7 @@ export class ResultUploadCommandHandler { let projectCode = '' let runId = 0 - if ('runUrl' in this.args) { + if ('runUrl' in this.args && this.args.runUrl) { // Handle existing run URL console.log(chalk.blue(`Using existing test run: ${this.args.runUrl}`)) @@ -94,7 +94,7 @@ export class ResultUploadCommandHandler { projectCode = urlParsed.project } else { if (this.args.projectCode) { - projectCode = this.args.projectCode + projectCode = this.args.projectCode as string } else { // Try to auto-detect project code from results. This is not fully reliable, but // is kept for backward compatibility. Better to specify project code explicitly @@ -278,7 +278,7 @@ export class ResultUploadCommandHandler { private async createNewRun(projectCode: string, tcaseIds: string[]) { const title = processTemplate( - 'runName' in this.args + 'runName' in this.args && this.args.runName ? (this.args.runName as string) : 'Automated test run - {MMM} {DD}, {YYYY}, {hh}:{mm}:{ss} {AMPM}' ) From bd66a06b1e1fe1a1d3f59b5619a363135a94ee20 Mon Sep 17 00:00:00 2001 From: Himanshu Rai Date: Tue, 23 Dec 2025 17:16:27 +0530 Subject: [PATCH 3/4] Use get tcase seq APIs, fetch tcases only from default folder during creation --- src/api/folders.ts | 14 ++ src/api/index.ts | 18 +- src/api/schemas.ts | 12 + src/api/tcases.ts | 21 +- src/tests/result-upload.spec.ts | 122 ++++++++-- src/utils/misc.ts | 4 + .../ResultUploadCommandHandler.ts | 229 +++++++++++++----- src/utils/result-upload/ResultUploader.ts | 14 +- .../result-upload/playwrightJsonParser.ts | 4 +- 9 files changed, 332 insertions(+), 106 deletions(-) create mode 100644 src/api/folders.ts diff --git a/src/api/folders.ts b/src/api/folders.ts new file mode 100644 index 0000000..866275f --- /dev/null +++ b/src/api/folders.ts @@ -0,0 +1,14 @@ +import { Folder, PaginatedRequest, PaginatedResponse, ResourceId } from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export const createFolderApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + getFoldersPaginated: (projectCode: ResourceId, request: PaginatedRequest) => + fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/folders`, request) + ).then((r) => jsonResponse>(r)), + } +} + +export type FolderApi = ReturnType diff --git a/src/api/index.ts b/src/api/index.ts index 370326a..f9cd166 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,19 +1,21 @@ +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 { - projects: createProjectApi(fetcher), - runs: createRunApi(fetcher), - testcases: createTCaseApi(fetcher), - file: createFileApi(fetcher), - } + return { + files: createFileApi(fetcher), + folders: createFolderApi(fetcher), + projects: createProjectApi(fetcher), + runs: createRunApi(fetcher), + testcases: createTCaseApi(fetcher), + } } export type Api = ReturnType export const createApi = (baseUrl: string, apiKey: string) => - getApi(withApiKey(withBaseUrl(fetch, baseUrl), apiKey)) \ No newline at end of file + getApi(withApiKey(withBaseUrl(fetch, baseUrl), apiKey)) diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c6d7f46..ca9ad1d 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -33,8 +33,20 @@ 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 Folder { id: number + parentId: number + pos: number title: string } diff --git a/src/api/tcases.ts b/src/api/tcases.ts index 0a9575f..8c758c3 100644 --- a/src/api/tcases.ts +++ b/src/api/tcases.ts @@ -1,21 +1,28 @@ import { - CreateTCasesRequest, - CreateTCasesResponse, - PaginatedRequest, - PaginatedResponse, - ResourceId, - TCase, + 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: PaginatedRequest) => + getTCasesPaginated: (projectCode: ResourceId, request: GetTCasesRequest) => fetcher(appendSearchParams(`/api/public/v0/project/${projectCode}/tcase`, request)).then( (r) => jsonResponse>(r) ), + getTCasesBySeq: (projectCode: ResourceId, request: GetTCasesBySeqRequest) => + fetcher(`/api/public/v0/project/${projectCode}/tcase/seq`, { + method: 'POST', + body: JSON.stringify(request), + }).then((r) => jsonResponse>(r)), + createTCases: (projectCode: ResourceId, request: CreateTCasesRequest) => fetcher(`/api/public/v0/project/${projectCode}/tcase/bulk`, { method: 'POST', diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index c382b4d..3aee549 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -1,12 +1,19 @@ +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' +import { unlinkSync, readdirSync } from 'node:fs' import { afterAll, beforeAll, beforeEach, expect, test, describe, afterEach } from 'vitest' import { run } from '../commands/main' -import { setupServer } from 'msw/node' -import { HttpResponse, http } from 'msw' +import { + CreateTCasesRequest, + CreateTCasesResponse, + Folder, + PaginatedResponse, + TCase, +} from '../api/schemas' +import { DEFAULT_FOLDER_TITLE } from '../utils/result-upload/ResultUploadCommandHandler' +import { setMaxResultsInRequest } from '../utils/result-upload/ResultUploader' import { runTestCases } from './fixtures/testcases' import { countMockedApiCalls } from './utils' -import { setMaxResultsInRequest } from '../utils/result-upload/ResultUploader' -import { CreateTCasesResponse } from '../api/schemas' -import { unlinkSync, readdirSync } from 'node:fs' const projectCode = 'TEST' const runId = '1' @@ -17,22 +24,63 @@ const runURL = `${baseURL}/project/${projectCode}/run/${runId}` process.env['QAS_TOKEN'] = 'QAS_TOKEN' process.env['QAS_URL'] = baseURL -let lastCreatedRunTitle = '' -let createTCasesResponse: CreateTCasesResponse | null = null -let createRunTitleConflict = false +let lastCreatedRunTitle = '' // Stores title in the request, for the last create run API call +let createRunTitleConflict = false // If true, the create run API returns a title conflict error +let createTCasesResponse: CreateTCasesResponse | null = null // Stores mock response for the create tcases API call +let overriddenGetPaginatedTCasesResponse: PaginatedResponse | null = null // Stores overridden (non-default) response for the get tcases API call +let overriddenGetFoldersResponse: PaginatedResponse | null = null // Stores overridden (non-default) response for the get folders API call const server = setupServer( http.get(`${baseURL}/api/public/v0/project/${projectCode}`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') return HttpResponse.json({ exists: true }) }), + http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase/folders`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') + return HttpResponse.json( + overriddenGetFoldersResponse || { data: [], total: 0, page: 1, limit: 50 } + ) + }), http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') + return HttpResponse.json( + overriddenGetPaginatedTCasesResponse || { data: [], total: 0, page: 1, limit: 50 } + ) + }), + http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/seq`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') return HttpResponse.json({ data: runTestCases, total: runTestCases.length, }) }), + http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/bulk`, async ({ request }) => { + expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') + + if (!createTCasesResponse) { + return HttpResponse.json( + { + message: 'No mock response set for create tcases API call', + }, + { + status: 500, + } + ) + } + + const body = (await request.json()) as CreateTCasesRequest + if (body.tcases.length !== createTCasesResponse.tcases.length) { + return HttpResponse.json( + { + message: `${body.tcases.length} test cases in request does not match ${createTCasesResponse.tcases.length} in the mock response`, + }, + { + status: 400, + } + ) + } + return HttpResponse.json(createTCasesResponse) + }), http.post(`${baseURL}/api/public/v0/project/${projectCode}/run`, async ({ request }) => { expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') const body = (await request.json()) as { title: string } @@ -75,10 +123,6 @@ const server = setupServer( id: 'TEST', url: 'http://example.com', }) - }), - http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/bulk`, ({ request }) => { - expect(request.headers.get('Authorization')).toEqual('ApiKey QAS_TOKEN') - return HttpResponse.json(createTCasesResponse) }) ) @@ -106,7 +150,7 @@ const getMappingFiles = () => readdirSync('.').filter((f) => f.startsWith('qasphere-automapping-') && f.endsWith('.txt')) ) -const cleanupMappingFiles = (existingMappingFiles?: Set) => { +const cleanupGeneratedMappingFiles = (existingMappingFiles?: Set) => { const currentFiles = getMappingFiles() currentFiles.forEach((f) => { if (!existingMappingFiles?.has(f)) { @@ -375,7 +419,7 @@ fileTypes.forEach((fileType) => { }) }) - describe('Uploading test results with new run', () => { + describe('Uploading test results with test case creation', () => { let existingMappingFiles: Set | undefined = undefined beforeEach(() => { @@ -383,15 +427,18 @@ fileTypes.forEach((fileType) => { }) afterEach(() => { - cleanupMappingFiles(existingMappingFiles) - createTCasesResponse = null + cleanupGeneratedMappingFiles(existingMappingFiles) existingMappingFiles = undefined + createTCasesResponse = null + overriddenGetPaginatedTCasesResponse = null + overriddenGetFoldersResponse = null }) test('Should create new test cases for results without valid markers', async () => { const numCreateTCasesCalls = countCreateTCasesApiCalls() const numResultUploadCalls = countResultUploadApiCalls() + setMaxResultsInRequest(1) createTCasesResponse = { tcases: [ { id: '6', seq: 6 }, @@ -403,17 +450,56 @@ fileTypes.forEach((fileType) => { `${fileType.command} --project-code ${projectCode} --create-tcases ${fileType.dataBasePath}/without-markers.${fileType.fileExtension}` ) expect(numCreateTCasesCalls()).toBe(1) - expect(numResultUploadCalls()).toBe(1) // 3 results total + expect(numResultUploadCalls()).toBe(3) // 3 results total + }) + + test('Should not create new test case if one with same title already exists', async () => { + const numCreateTCasesCalls = countCreateTCasesApiCalls() + const numResultUploadCalls = countResultUploadApiCalls() + + setMaxResultsInRequest(1) + overriddenGetFoldersResponse = { + data: [{ id: 1, title: DEFAULT_FOLDER_TITLE, parentId: 0, pos: 0 }], + total: 1, + page: 1, + limit: 50, + } + overriddenGetPaginatedTCasesResponse = { + data: [ + { + id: '6', + seq: 6, + title: 'The cart is still filled after refreshing the page', + version: 1, + projectId: 'projectid', + folderId: 1, + }, + ], + total: 1, + page: 1, + limit: 50, + } + createTCasesResponse = { + tcases: [{ id: '7', seq: 7 }], + } + + await run( + `${fileType.command} --project-code ${projectCode} --create-tcases ${fileType.dataBasePath}/without-markers.${fileType.fileExtension}` + ) + expect(numCreateTCasesCalls()).toBe(1) + expect(numResultUploadCalls()).toBe(3) // 3 results total }) test('Should not create new test cases if all results have valid markers', async () => { const numCreateTCasesCalls = countCreateTCasesApiCalls() const numResultUploadCalls = countResultUploadApiCalls() + + setMaxResultsInRequest(1) await run( `${fileType.command} --project-code ${projectCode} --create-tcases ${fileType.dataBasePath}/matching-tcases.${fileType.fileExtension}` ) expect(numCreateTCasesCalls()).toBe(0) - expect(numResultUploadCalls()).toBe(1) // 5 results total + expect(numResultUploadCalls()).toBe(5) // 5 results total }) }) }) diff --git a/src/utils/misc.ts b/src/utils/misc.ts index dd70186..2f411ad 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -96,6 +96,10 @@ export const parseTCaseUrl = (url: string) => { } } +export const getTCaseMarker = (projectCode: string, seq: number) => { + return `${projectCode}-${seq.toString().padStart(3, '0')}` +} + export const printErrorThenExit = (e: unknown): never => { printError(e) process.exit(1) diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 5a203c2..368e85a 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -1,15 +1,14 @@ import { Arguments } from 'yargs' import chalk from 'chalk' -import { readFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' -import { parseRunUrl, printErrorThenExit, processTemplate } from '../misc' +import { getTCaseMarker, parseRunUrl, printErrorThenExit, processTemplate } from '../misc' import { Api, createApi } from '../../api' import { TCase } from '../../api/schemas' import { TestCaseResult } from './types' import { ResultUploader } from './ResultUploader' import { parseJUnitXml } from './junitXmlParser' import { parsePlaywrightJson } from './playwrightJsonParser' -import { writeFileSync } from 'node:fs' export type UploadCommandType = 'junit-upload' | 'playwright-json-upload' @@ -50,8 +49,14 @@ interface FileResults { results: TestCaseResult[] } -const GET_TCASES_PAGE_SIZE = 5000 -const DEFAULT_FOLDER_TITLE = 'cli-import' +interface TestCaseResultWithSeqAndFile { + seq: number | null + file: string + result: TestCaseResult +} + +const DEFAULT_PAGE_SIZE = 5000 +export const DEFAULT_FOLDER_TITLE = 'cli-import' const DEFAULT_TCASE_TAGS = ['cli-import'] const DEFAULT_MAPPING_FILENAME_TEMPLATE = 'qasphere-automapping-{YYYY}{MM}{DD}-{HH}{mm}{ss}.txt' const commandTypeParsers: Record = { @@ -98,7 +103,7 @@ export class ResultUploadCommandHandler { } else { // Try to auto-detect project code from results. This is not fully reliable, but // is kept for backward compatibility. Better to specify project code explicitly - projectCode = this.detectProjectCode(fileResults) + projectCode = this.detectProjectCodeFromTCaseNames(fileResults) console.log(chalk.blue(`Detected project code: ${projectCode}`)) } @@ -136,12 +141,13 @@ export class ResultUploadCommandHandler { return results } - protected detectProjectCode(fileResults: FileResults[]) { + protected detectProjectCodeFromTCaseNames(fileResults: FileResults[]) { + // Look for pattern like PRJ-123 or TEST-456 + const tcaseSeqRegex = new RegExp(/([A-Za-z0-9]{1,5})-\d{3,}/) for (const { results } of fileResults) { for (const result of results) { if (result.name) { - // Look for pattern like PRJ-123 or TEST-456 - const match = result.name.match(/([A-Za-z0-9]{1,5})-\d{3,}/) + const match = tcaseSeqRegex.exec(result.name) if (match) { return match[1] } @@ -156,27 +162,12 @@ export class ResultUploadCommandHandler { protected async getTCaseIds(projectCode: string, fileResults: FileResults[]) { const shouldFailOnInvalid = !this.args.force && !this.args.ignoreUnmatched - const tcaseMapBySeq: Record = {} - const tcaseMapByTitle: Record = {} - - for (let page = 1; ; page++) { - const response = await this.api.testcases.getTCasesPaginated(projectCode, { - page, - limit: GET_TCASES_PAGE_SIZE, - }) - - for (const tcase of response.data) { - tcaseMapBySeq[tcase.seq] = tcase - tcaseMapByTitle[tcase.title] = tcase // If there are multiple tcases with the same title, it will be overwritten - } + const tcaseSeqRegex = new RegExp(`${projectCode}-(\\d{3,})`) - if (response.data.length < GET_TCASES_PAGE_SIZE) { - break - } - } + const seqIdsSet: Set = new Set() + const resultsWithSeqAndFile: TestCaseResultWithSeqAndFile[] = [] - const tcaseIds: string[] = [] - const tcasesToCreate: Map = new Map() + // First extract the sequence numbers from the test case names for (const { file, results } of fileResults) { for (const result of results) { if (!result.name) { @@ -186,49 +177,77 @@ export class ResultUploadCommandHandler { continue } - const match = new RegExp(`${projectCode}-(\\d{3,})`).exec(result.name) + const match = tcaseSeqRegex.exec(result.name) + resultsWithSeqAndFile.push({ + seq: match ? Number(match[1]) : null, + file, + result, + }) + if (match) { - const tcase = tcaseMapBySeq[Number(match[1])] - if (tcase) { - tcaseIds.push(tcase.id) - continue - } + seqIdsSet.add(Number(match[1])) } + } + } - const tcase = tcaseMapByTitle[result.name] - if (tcase) { - // Prefix the test case markers for use in ResultUploader - result.name = `${projectCode}-${tcase.seq.toString().padStart(3, '0')}: ${result.name}` - tcaseIds.push(tcase.id) - continue - } + // Now fetch the test cases by their sequence numbers + const apiTCasesMap: Record = {} + if (seqIdsSet.size > 0) { + const tcaseMarkers = Array.from(seqIdsSet).map((v) => getTCaseMarker(projectCode, v)) - if (this.args.createTcases) { - const tcaseResults = tcasesToCreate.get(result.name) || [] - tcaseResults.push(result) - tcasesToCreate.set(result.name, tcaseResults) - continue + for (let page = 1; ; page++) { + const response = await this.api.testcases.getTCasesBySeq(projectCode, { + seqIds: tcaseMarkers, + page, + limit: DEFAULT_PAGE_SIZE, + }) + + for (const tcase of response.data) { + apiTCasesMap[tcase.seq] = tcase } - if (shouldFailOnInvalid) { - return printErrorThenExit( - `Test case name "${result.name}" in ${file} does not contain valid sequence number with project code (e.g., ${projectCode}-123)` - ) + if (response.data.length < DEFAULT_PAGE_SIZE) { + break } } } - if (tcasesToCreate.size > 0) { - const keys = Array.from(tcasesToCreate.keys()) - const newTcases = await this.createNewTCases(projectCode, keys) + // Now validate that the test cases with found sequence numbers actually exist + const tcaseIds: string[] = [] + const tcasesToCreateMap: Record = {} + for (const { seq, file, result } of resultsWithSeqAndFile) { + if (seq && apiTCasesMap[seq]) { + tcaseIds.push(apiTCasesMap[seq].id) + continue + } + + if (this.args.createTcases) { + const tcaseResults = tcasesToCreateMap[result.name] || [] + tcaseResults.push(result) + tcasesToCreateMap[result.name] = tcaseResults + continue + } + + if (shouldFailOnInvalid) { + return printErrorThenExit( + `Test case name "${result.name}" in ${file} does not contain valid sequence number with project code (e.g., ${projectCode}-123)` + ) + } + } + + // Create new test cases, if same is requested + if (Object.keys(tcasesToCreateMap).length > 0) { + const keys = Object.keys(tcasesToCreateMap) + const newTCases = await this.createNewTCases(projectCode, keys) for (let i = 0; i < keys.length; i++) { - const marker = `${projectCode}-${newTcases[i].seq.toString().padStart(3, '0')}` - for (const result of tcasesToCreate.get(keys[i]) || []) { - // Prefix the test case markers for use in ResultUploader + const marker = getTCaseMarker(projectCode, newTCases[i].seq) + for (const result of tcasesToCreateMap[keys[i]] || []) { + // Prefix the test case markers for use in ResultUploader. The fileResults array + // containing the updated name is returned to the caller result.name = `${marker}: ${result.name}` } - tcaseIds.push(newTcases[i].id) + tcaseIds.push(newTCases[i].id) } } @@ -240,27 +259,105 @@ export class ResultUploadCommandHandler { } private async createNewTCases(projectCode: string, tcasesToCreate: string[]) { - console.log(chalk.blue(`Creating new test cases for results with no test case markers`)) + console.log(chalk.blue(`Creating test cases for results with no test case markers`)) + + // First fetch the default folder ID where we are creating new test cases + let defaultFolderId = null + for (let page = 1; ; page++) { + const response = await this.api.folders.getFoldersPaginated(projectCode, { + page, + limit: DEFAULT_PAGE_SIZE, + }) + + for (const folder of response.data) { + if (folder.title === DEFAULT_FOLDER_TITLE && !folder.parentId) { + defaultFolderId = folder.id + break + } + } + + if (defaultFolderId || response.data.length < DEFAULT_PAGE_SIZE) { + break + } + } + + // If the default folder exists, fetch the test cases in it + const apiTCasesMap: Record = {} + if (defaultFolderId) { + for (let page = 1; ; page++) { + const response = await this.api.testcases.getTCasesPaginated(projectCode, { + folders: [defaultFolderId], + page, + limit: DEFAULT_PAGE_SIZE, + }) + + for (const tcase of response.data) { + apiTCasesMap[tcase.title] = tcase + } + + if (response.data.length < DEFAULT_PAGE_SIZE) { + break + } + } + } + // Reuse existing test cases with the same title from the default folder + const ret: { id: string; seq: number }[] = [] + const idxToFill: number[] = [] + const finalTCasesToCreate: string[] = [] + for (let i = 0; i < tcasesToCreate.length; i++) { + const existingTcase = apiTCasesMap[tcasesToCreate[i]] + if (existingTcase) { + // TCase with this title already exists, reuse it + ret.push({ id: existingTcase.id, seq: existingTcase.seq }) + continue + } + + // Add a placeholder for the new test case. Will be updated later + ret.push({ id: '', seq: 0 }) + finalTCasesToCreate.push(tcasesToCreate[i]) + idxToFill.push(i) + } + + if (!finalTCasesToCreate.length) { + console.log( + chalk.blue( + `Reusing ${ret.length} test cases with same title from "${DEFAULT_FOLDER_TITLE}" folder, no new test cases created` + ) + ) + return ret + } + + // Create new test cases and update the placeholders with the actual test case IDs const { tcases } = await this.api.testcases.createTCases(projectCode, { folderPath: [DEFAULT_FOLDER_TITLE], - tcases: tcasesToCreate.map((title) => ({ title, tags: DEFAULT_TCASE_TAGS })), + tcases: finalTCasesToCreate.map((title) => ({ title, tags: DEFAULT_TCASE_TAGS })), }) console.log( - chalk.green(`Created ${tcases.length} new test cases in folder "${DEFAULT_FOLDER_TITLE}"`) + chalk.green( + `Created ${tcases.length} new test cases in folder "${DEFAULT_FOLDER_TITLE}"${ + ret.length > tcases.length + ? ` and reused ${ret.length - tcases.length} test cases with same title` + : '' + }` + ) ) + for (let i = 0; i < idxToFill.length; i++) { + ret[idxToFill[i]] = tcases[i] + } + try { const mappingFilename = processTemplate(DEFAULT_MAPPING_FILENAME_TEMPLATE) - const mappingLines = tcases.map((t, i) => `${t.seq}: ${tcasesToCreate[i]}`).join('\n') + const mappingLines = tcases + .map((t, i) => `${getTCaseMarker(projectCode, t.seq)}: ${tcasesToCreate[i]}`) + .join('\n') + writeFileSync(mappingFilename, mappingLines) - console.log( - chalk.green(`Created mapping file for newly created test cases: ${mappingFilename}`) - ) console.log( chalk.yellow( - `Update your test cases to include the test case markers in the name, for future uploads` + `Created mapping file for newly created test cases: ${mappingFilename}\nUpdate your test cases to include the test case markers in the name, for future uploads` ) ) } catch (err) { @@ -273,7 +370,7 @@ export class ResultUploadCommandHandler { ) } - return tcases + return ret } private async createNewRun(projectCode: string, tcaseIds: string[]) { diff --git a/src/utils/result-upload/ResultUploader.ts b/src/utils/result-upload/ResultUploader.ts index f6703f0..6c6f2d8 100644 --- a/src/utils/result-upload/ResultUploader.ts +++ b/src/utils/result-upload/ResultUploader.ts @@ -1,7 +1,7 @@ import { Arguments } from 'yargs' import chalk from 'chalk' import { RunTCase } from '../../api/schemas' -import { parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' +import { getTCaseMarker, parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' import { Api, createApi } from '../../api' import { Attachment, TestCaseResult } from './types' import { ResultUploadCommandArgs, UploadCommandType } from './ResultUploadCommandHandler' @@ -79,7 +79,11 @@ export class ResultUploader { } else if (this.type === 'playwright-json-upload') { this.printPlaywrightGuidance(missing[0]?.name || 'your test name') } - console.error(chalk.yellow('Also ensure that the test cases exist in the QA Sphere project and the test run (if run URL is provided).')) + console.error( + chalk.yellow( + 'Also ensure that the test cases exist in the QA Sphere project and the test run (if run URL is provided).' + ) + ) } private printJUnitGuidance() { @@ -181,7 +185,7 @@ ${chalk.yellow('To fix this issue, choose one of the following options:')} const uploadedAttachments = await this.processConcurrently( allAttachments, async ({ attachment, tcaseIndex }) => { - const { url } = await this.api.file.uploadFile( + const { url } = await this.api.files.uploadFile( new Blob([attachment.buffer! as BlobPart]), attachment.filename ) @@ -283,8 +287,8 @@ ${chalk.yellow('To fix this issue, choose one of the following options:')} testcaseResults.forEach((result) => { if (result.name) { const tcase = testcases.find((tcase) => { - const tcaseCode = `${this.project}-${tcase.seq.toString().padStart(3, '0')}` - return result.name.includes(tcaseCode) + const tcaseMarker = getTCaseMarker(this.project, tcase.seq) + return result.name.includes(tcaseMarker) }) if (tcase) { diff --git a/src/utils/result-upload/playwrightJsonParser.ts b/src/utils/result-upload/playwrightJsonParser.ts index c322dfc..9e6076f 100644 --- a/src/utils/result-upload/playwrightJsonParser.ts +++ b/src/utils/result-upload/playwrightJsonParser.ts @@ -4,7 +4,7 @@ import stripAnsi from 'strip-ansi' import { Attachment, TestCaseResult } from './types' import { Parser, ParserOptions } from './ResultUploadCommandHandler' import { ResultStatus } from '../../api/schemas' -import { parseTCaseUrl } from '../misc' +import { getTCaseMarker, parseTCaseUrl } from '../misc' import { getAttachments } from './utils' // Schema definition as per https://github.com/microsoft/playwright/blob/main/packages/playwright/types/testReporter.d.ts @@ -158,7 +158,7 @@ const getTCaseMarkerFromAnnotations = (annotations: Annotation[]) => { if (annotation.type.toLowerCase().includes('test case') && annotation.description) { const res = parseTCaseUrl(annotation.description) if (res) { - return `${res.project}-${res.tcaseSeq.toString().padStart(3, '0')}` + return getTCaseMarker(res.project, res.tcaseSeq) } } } From 74db9f5c3e5a33bc053c22ca0401be5ec11a10fd Mon Sep 17 00:00:00 2001 From: Himanshu Rai Date: Fri, 2 Jan 2026 16:58:16 +0530 Subject: [PATCH 4/4] Fix comments --- src/api/folders.ts | 4 +-- src/api/schemas.ts | 4 +++ src/api/utils.ts | 2 +- .../ResultUploadCommandHandler.ts | 28 +++++++++++++++---- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/api/folders.ts b/src/api/folders.ts index 866275f..964dc54 100644 --- a/src/api/folders.ts +++ b/src/api/folders.ts @@ -1,10 +1,10 @@ -import { Folder, PaginatedRequest, PaginatedResponse, ResourceId } from './schemas' +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: PaginatedRequest) => + getFoldersPaginated: (projectCode: ResourceId, request: GetFoldersRequest) => fetcher( appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/folders`, request) ).then((r) => jsonResponse>(r)), diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 8ee5598..61b0425 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -43,6 +43,10 @@ export interface GetTCasesBySeqRequest { limit?: number } +export interface GetFoldersRequest extends PaginatedRequest { + search?: string +} + export interface Folder { id: number parentId: number diff --git a/src/api/utils.ts b/src/api/utils.ts index 7461557..9a58649 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -51,7 +51,7 @@ export const jsonResponse = async (response: Response): Promise => { const updateSearchParams = (searchParams: URLSearchParams, obj?: T) => { const isValidValue = (value: unknown) => { - return value || value === false || value === '' + return value !== undefined && value !== null } if (!obj) return diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 71fce8c..f4b90be 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -146,11 +146,11 @@ export class ResultUploadCommandHandler { protected detectProjectCodeFromTCaseNames(fileResults: FileResults[]) { // Look for pattern like PRJ-123 or TEST-456 - const tcaseSeqRegex = new RegExp(/([A-Za-z0-9]{1,5})-\d{3,}/) + const tcaseSeqPattern = String.raw`([A-Za-z0-9]{1,5})-\d{3,}` for (const { results } of fileResults) { for (const result of results) { if (result.name) { - const match = tcaseSeqRegex.exec(result.name) + const match = this.execRegexWithPriority(tcaseSeqPattern, result.name) if (match) { return match[1] } @@ -163,9 +163,25 @@ export class ResultUploadCommandHandler { ) } + private execRegexWithPriority(pattern: string, str: string): RegExpExecArray | null { + // Try matching at start first + const startRegex = new RegExp(`^${pattern}`) + let match = startRegex.exec(str) + if (match) return match + + // Try matching at end + const endRegex = new RegExp(`${pattern}$`) + match = endRegex.exec(str) + if (match) return match + + // Fall back to matching anywhere + const anywhereRegex = new RegExp(pattern) + return anywhereRegex.exec(str) + } + protected async getTCaseIds(projectCode: string, fileResults: FileResults[]) { const shouldFailOnInvalid = !this.args.force && !this.args.ignoreUnmatched - const tcaseSeqRegex = new RegExp(`${projectCode}-(\\d{3,})`) + const tcaseSeqPattern = String.raw`${projectCode}-(\d{3,})` const seqIdsSet: Set = new Set() const resultsWithSeqAndFile: TestCaseResultWithSeqAndFile[] = [] @@ -180,7 +196,7 @@ export class ResultUploadCommandHandler { continue } - const match = tcaseSeqRegex.exec(result.name) + const match = this.execRegexWithPriority(tcaseSeqPattern, result.name) resultsWithSeqAndFile.push({ seq: match ? Number(match[1]) : null, file, @@ -264,10 +280,12 @@ export class ResultUploadCommandHandler { private async createNewTCases(projectCode: string, tcasesToCreate: string[]) { console.log(chalk.blue(`Creating test cases for results with no test case markers`)) - // First fetch the default folder ID where we are creating new test cases + // First fetch the default folder ID where we are creating new test cases. + // Ideally, there shouldn't be the need to fetch more than one page. let defaultFolderId = null for (let page = 1; ; page++) { const response = await this.api.folders.getFoldersPaginated(projectCode, { + search: DEFAULT_FOLDER_TITLE, page, limit: DEFAULT_PAGE_SIZE, })