From eec12a74857e732d9587c98f8653b80116e2a416 Mon Sep 17 00:00:00 2001 From: Andrian Budantsov Date: Tue, 10 Feb 2026 14:27:15 +0400 Subject: [PATCH 1/3] Add MarkerParser class with camelCase support, bump to 0.4.6 Centralize marker detection/extraction/matching into a new MarkerParser class, replacing scattered regex logic in misc.ts, ResultUploadCommandHandler, and ResultUploader. Add camelCase marker support (Go/Java-style test names like TestPrj002Foo) alongside existing hyphenated and underscore-separated formats. Hyphenless matching is enforced as JUnit-only. --- CLAUDE.md | 22 +- README.md | 29 ++- package-lock.json | 4 +- package.json | 2 +- .../junit-xml/camelcase-matching-tcases.xml | 16 ++ .../junit-xml/hyphenless-matching-tcases.xml | 16 ++ src/tests/marker-parser.spec.ts | 235 ++++++++++++++++++ src/tests/result-upload.spec.ts | 60 +++++ src/utils/misc.ts | 4 - src/utils/result-upload/MarkerParser.ts | 164 ++++++++++++ .../ResultUploadCommandHandler.ts | 50 ++-- src/utils/result-upload/ResultUploader.ts | 11 +- .../result-upload/playwrightJsonParser.ts | 5 +- 13 files changed, 560 insertions(+), 58 deletions(-) create mode 100644 src/tests/fixtures/junit-xml/camelcase-matching-tcases.xml create mode 100644 src/tests/fixtures/junit-xml/hyphenless-matching-tcases.xml create mode 100644 src/tests/marker-parser.spec.ts create mode 100644 src/utils/result-upload/MarkerParser.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7236a64..251a9d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,16 +34,22 @@ Node.js compatibility tests: `cd mnode-test && ./docker-test.sh` (requires Docke ### Core Upload Pipeline (src/utils/result-upload/) -The upload flow has two stages handled by two classes: +The upload flow has two stages handled by two classes, with a shared `MarkerParser` instance: -1. **`ResultUploadCommandHandler`** — Orchestrates the overall flow: +1. **`MarkerParser`** — Centralizes all test case marker detection/extraction/matching logic: + - Supports three marker formats: hyphenated (`PRJ-123`), underscore-separated hyphenless (`test_prj123_foo`), and CamelCase hyphenless (`TestPrj123Foo` or `TestFooPrj123`) + - Hyphenless matching (underscore-separated and CamelCase) is gated on `type === 'junit-upload'` and requires the test name to start with `test` (case-insensitive) + - Created by `ResultUploadCommandHandler` and passed to `ResultUploader` — both share one instance + - Also exports a standalone `formatMarker()` function used by parsers + +2. **`ResultUploadCommandHandler`** — Orchestrates the overall flow: - Parses report files using the appropriate parser (JUnit XML or Playwright JSON) - - Detects project code from test case names (or from `--run-url`) + - Detects project code from test case names via `MarkerParser` (or from `--run-url`) - Creates a new test run (or reuses an existing one if title conflicts) - Delegates actual result uploading to `ResultUploader` -2. **`ResultUploader`** — Handles the upload-to-run mechanics: - - Fetches test cases from the run, maps parsed results to them via marker matching +3. **`ResultUploader`** — Handles the upload-to-run mechanics: + - Fetches test cases from the run, maps parsed results to them via `MarkerParser` matching - Validates unmatched/missing test cases (respects `--force`, `--ignore-unmatched`) - Uploads file attachments concurrently (max 10 parallel), then creates results in batches (max 50 per request) @@ -65,14 +71,15 @@ Composable fetch wrappers using higher-order functions: - `env.ts` — Loads `QAS_TOKEN` and `QAS_URL` from environment variables, `.env`, or `.qaspherecli` (searched up the directory tree) - `config.ts` — Constants (required Node version) -- `misc.ts` — URL parsing, template string processing (`{env:VAR}`, date placeholders), error handling utilities +- `misc.ts` — URL parsing, template string processing (`{env:VAR}`, date placeholders), error handling utilities. Note: marker-related functions have been moved to `MarkerParser.ts` - `version.ts` — Reads version from `package.json` by traversing parent directories ## Testing Tests use **Vitest** with **MSW** (Mock Service Worker) for API mocking. Test files are in `src/tests/`: -- `result-upload.spec.ts` — Integration tests for the full upload flow (both JUnit and Playwright), with MSW intercepting all API calls +- `result-upload.spec.ts` — Integration tests for the full upload flow (both JUnit and Playwright), with MSW intercepting all API calls. Includes hyphenless and CamelCase marker tests (JUnit only) +- `marker-parser.spec.ts` — Unit tests for `MarkerParser` (detection, extraction, matching across all marker formats and command types) - `junit-xml-parsing.spec.ts` — Unit tests for JUnit XML parser - `playwright-json-parsing.spec.ts` — Unit tests for Playwright JSON parser - `template-string-processing.spec.ts` — Unit tests for run name template processing @@ -90,3 +97,4 @@ ESM project (`"type": "module"`). TypeScript compiles to `build/`, then `ts-add- - **Linter**: ESLint with typescript-eslint (config: `eslint.config.mjs`) - **Formatter**: Prettier (config: `.prettierrc`) - **Pre-commit hook** (Husky): runs lint-staged (Prettier + ESLint on staged files) +- **Commits**: Do NOT add `Co-Authored-By` lines to commit messages diff --git a/README.md b/README.md index efa2dc0..acca4d4 100644 --- a/README.md +++ b/README.md @@ -181,17 +181,36 @@ The QAS CLI maps test results from your reports (JUnit XML or Playwright JSON) t ### JUnit XML -Test case names in JUnit XML reports must include a QA Sphere test case marker in the format `PROJECT-SEQUENCE`: +Test case names in JUnit XML reports must include a QA Sphere test case marker. The following marker formats are supported (checked in order): -- **PROJECT** - Your QA Sphere project code -- **SEQUENCE** - Test case sequence number (minimum 3 digits, zero-padded if needed) +#### 1. Hyphenated Marker (all languages) + +Format: `PROJECT-SEQUENCE` where **PROJECT** is your QA Sphere project code and **SEQUENCE** is the test case sequence number (minimum 3 digits, zero-padded if needed). The marker can appear anywhere in the test name and is matched case-insensitively. **Examples:** - `PRJ-002: Login with valid credentials` - `Login with invalid credentials: PRJ-1312` -**Note:** The project code in test names must exactly match your QA Sphere project code. +#### 2. Underscore-Separated Hyphenless Marker (pytest, Go, Rust, etc.) + +For languages where test names are function identifiers and hyphens are not allowed, the CLI supports hyphenless markers separated by underscores. The test name must start with `test` (case-insensitive). + +**Examples (pytest):** + +- `test_prj002_login_with_valid_credentials` +- `test_login_with_invalid_credentials_prj1312` + +#### 3. CamelCase Hyphenless Marker (Go, Java) + +For CamelCase test function names, the CLI detects markers at the start (immediately after the `Test` prefix) or at the end of the name. The test name must start with `Test` (case-insensitive). + +**Examples (Go):** + +- `TestPrj002LoginWithValidCredentials` (marker at start) +- `TestLoginWithValidCredentialsPrj1312` (marker at end) + +**Note:** Hyphenless matching (formats 2 and 3) is only available for `junit-upload`. For `playwright-json-upload`, only the hyphenated format is supported (or test annotations, see below). ### Playwright JSON @@ -216,7 +235,7 @@ Playwright JSON reports support two methods for referencing test cases (checked ) ``` -2. **Test Case Marker in Name** - Include the `PROJECT-SEQUENCE` marker in the test name (same format as JUnit XML) +2. **Hyphenated Marker in Name** - Include the `PROJECT-SEQUENCE` marker in the test name (same format as JUnit XML format 1). Hyphenless markers are **not** supported for Playwright JSON ## Development (for those who want to contribute to the tool) diff --git a/package-lock.json b/package-lock.json index 7674e76..b98862f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qas-cli", - "version": "0.4.4", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qas-cli", - "version": "0.4.4", + "version": "0.4.6", "license": "ISC", "dependencies": { "chalk": "^5.4.1", diff --git a/package.json b/package.json index e2bcf31..bf8e2c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qas-cli", - "version": "0.4.5", + "version": "0.4.6", "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/tests/fixtures/junit-xml/camelcase-matching-tcases.xml b/src/tests/fixtures/junit-xml/camelcase-matching-tcases.xml new file mode 100644 index 0000000..a69d0bc --- /dev/null +++ b/src/tests/fixtures/junit-xml/camelcase-matching-tcases.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + AssertionError: expected True but got False + + + + + diff --git a/src/tests/fixtures/junit-xml/hyphenless-matching-tcases.xml b/src/tests/fixtures/junit-xml/hyphenless-matching-tcases.xml new file mode 100644 index 0000000..4cf5106 --- /dev/null +++ b/src/tests/fixtures/junit-xml/hyphenless-matching-tcases.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + AssertionError: expected True but got False + + + + + diff --git a/src/tests/marker-parser.spec.ts b/src/tests/marker-parser.spec.ts new file mode 100644 index 0000000..4f53ef1 --- /dev/null +++ b/src/tests/marker-parser.spec.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from 'vitest' +import { MarkerParser, formatMarker } from '../utils/result-upload/MarkerParser' + +const junit = new MarkerParser('junit-upload') +const playwright = new MarkerParser('playwright-json-upload') + +describe('formatMarker', () => { + test('pads sequence to 3 digits', () => { + expect(formatMarker('TEST', 2)).toBe('TEST-002') + expect(formatMarker('PRJ', 42)).toBe('PRJ-042') + }) + + test('does not pad sequences with 3+ digits', () => { + expect(formatMarker('TEST', 123)).toBe('TEST-123') + expect(formatMarker('TEST', 1234)).toBe('TEST-1234') + }) + + test('instance method matches standalone function', () => { + expect(junit.formatMarker('ABC', 7)).toBe(formatMarker('ABC', 7)) + }) +}) + +describe('detectProjectCode', () => { + describe('hyphenated markers (all formats)', () => { + test('at start of name', () => { + expect(junit.detectProjectCode('TEST-002 Cart items')).toBe('TEST') + expect(playwright.detectProjectCode('TEST-002 Cart items')).toBe('TEST') + }) + + test('at end of name', () => { + expect(junit.detectProjectCode('Cart items TEST-002')).toBe('TEST') + expect(playwright.detectProjectCode('Cart items TEST-002')).toBe('TEST') + }) + + test('in middle of name', () => { + expect(junit.detectProjectCode('Some TEST-002 thing')).toBe('TEST') + expect(playwright.detectProjectCode('Some TEST-002 thing')).toBe('TEST') + }) + + test('with alphanumeric project code', () => { + expect(junit.detectProjectCode('BD026-123 something')).toBe('BD026') + }) + }) + + describe('separator-bounded hyphenless (JUnit only)', () => { + test('underscore-separated', () => { + expect(junit.detectProjectCode('test_test002_cart')).toBe('TEST') + }) + + test('returns null for Playwright', () => { + expect(playwright.detectProjectCode('test_test002_cart')).toBeNull() + }) + + test('requires name starting with "test"', () => { + expect(junit.detectProjectCode('check_test002_cart')).toBeNull() + }) + + test('case-insensitive project code', () => { + expect(junit.detectProjectCode('test_bd026_cart')).toBe('BD') + }) + }) + + describe('camelCase start (JUnit only)', () => { + test('marker after Test prefix', () => { + expect(junit.detectProjectCode('TestTest002CartItems')).toBe('TEST') + }) + + test('returns null for Playwright', () => { + expect(playwright.detectProjectCode('TestTest002CartItems')).toBeNull() + }) + + test('marker at end of string', () => { + expect(junit.detectProjectCode('TestBd026')).toBe('BD') + }) + + test('requires name starting with "test"', () => { + expect(junit.detectProjectCode('CheckTest002CartItems')).toBeNull() + }) + }) + + describe('camelCase end (JUnit only)', () => { + test('marker at end of name', () => { + expect(junit.detectProjectCode('TestCartItemsTest002')).toBe('TEST') + }) + + test('returns null for Playwright', () => { + expect(playwright.detectProjectCode('TestCartItemsTest002')).toBeNull() + }) + + test('all-uppercase without separator matches via separator pattern', () => { + expect(junit.detectProjectCode('TEST002')).toBe('TEST') + }) + }) + + describe('no match', () => { + test('returns null for unrecognized names', () => { + expect(junit.detectProjectCode('some random test')).toBeNull() + expect(junit.detectProjectCode('test_cart_items')).toBeNull() + }) + }) +}) + +describe('extractSeq', () => { + describe('hyphenated markers (all formats)', () => { + test('at start of name', () => { + expect(junit.extractSeq('TEST-002 Cart items', 'TEST')).toBe(2) + expect(playwright.extractSeq('TEST-002 Cart items', 'TEST')).toBe(2) + }) + + test('at end of name', () => { + expect(junit.extractSeq('Cart items TEST-1234', 'TEST')).toBe(1234) + }) + + test('in middle of name', () => { + expect(junit.extractSeq('Some TEST-042 thing', 'TEST')).toBe(42) + }) + }) + + describe('separator-bounded hyphenless (JUnit only)', () => { + test('underscore-separated', () => { + expect(junit.extractSeq('test_test002_cart', 'TEST')).toBe(2) + }) + + test('returns null for Playwright', () => { + expect(playwright.extractSeq('test_test002_cart', 'TEST')).toBeNull() + }) + + test('requires name starting with "test"', () => { + expect(junit.extractSeq('check_test002_cart', 'TEST')).toBeNull() + }) + + test('case-insensitive project code match', () => { + expect(junit.extractSeq('test_bd026_cart', 'BD')).toBe(26) + }) + }) + + describe('camelCase start (JUnit only)', () => { + test('marker after Test prefix', () => { + expect(junit.extractSeq('TestTest002CartItems', 'TEST')).toBe(2) + }) + + test('returns null for Playwright', () => { + expect(playwright.extractSeq('TestTest002CartItems', 'TEST')).toBeNull() + }) + + test('case-insensitive project code', () => { + expect(junit.extractSeq('TestBd026Something', 'BD')).toBe(26) + }) + }) + + describe('camelCase end (JUnit only)', () => { + test('marker at end of name', () => { + expect(junit.extractSeq('TestCartItemsTest002', 'TEST')).toBe(2) + }) + + test('returns null for Playwright', () => { + expect(playwright.extractSeq('TestCartItemsTest002', 'TEST')).toBeNull() + }) + }) + + describe('no match', () => { + test('returns null for wrong project code', () => { + expect(junit.extractSeq('TEST-002 Cart items', 'OTHER')).toBeNull() + }) + + test('returns null for no marker', () => { + expect(junit.extractSeq('test_cart_items', 'TEST')).toBeNull() + }) + }) +}) + +describe('nameMatchesTCase', () => { + describe('hyphenated markers (all formats)', () => { + test('case-insensitive match', () => { + expect(junit.nameMatchesTCase('test-002 Cart', 'TEST', 2)).toBe(true) + expect(junit.nameMatchesTCase('TEST-002 Cart', 'TEST', 2)).toBe(true) + expect(playwright.nameMatchesTCase('TEST-002 Cart', 'TEST', 2)).toBe(true) + }) + + test('marker anywhere in name', () => { + expect(junit.nameMatchesTCase('Cart TEST-002 items', 'TEST', 2)).toBe(true) + expect(junit.nameMatchesTCase('Cart items TEST-002', 'TEST', 2)).toBe(true) + }) + + test('no match for wrong seq', () => { + expect(junit.nameMatchesTCase('TEST-002 Cart', 'TEST', 3)).toBe(false) + }) + }) + + describe('separator-bounded hyphenless (JUnit only)', () => { + test('underscore-separated', () => { + expect(junit.nameMatchesTCase('test_test002_cart', 'TEST', 2)).toBe(true) + }) + + test('returns false for Playwright', () => { + expect(playwright.nameMatchesTCase('test_test002_cart', 'TEST', 2)).toBe(false) + }) + + test('requires name starting with "test"', () => { + expect(junit.nameMatchesTCase('check_test002_cart', 'TEST', 2)).toBe(false) + }) + + test('no match for wrong seq', () => { + expect(junit.nameMatchesTCase('test_test002_cart', 'TEST', 3)).toBe(false) + }) + }) + + describe('camelCase start (JUnit only)', () => { + test('matches marker after Test prefix', () => { + expect(junit.nameMatchesTCase('TestTest002CartItems', 'TEST', 2)).toBe(true) + }) + + test('returns false for Playwright', () => { + expect(playwright.nameMatchesTCase('TestTest002CartItems', 'TEST', 2)).toBe(false) + }) + + test('no match for wrong seq', () => { + expect(junit.nameMatchesTCase('TestTest002CartItems', 'TEST', 3)).toBe(false) + }) + }) + + describe('camelCase end (JUnit only)', () => { + test('matches marker at end', () => { + expect(junit.nameMatchesTCase('TestCartItemsTest002', 'TEST', 2)).toBe(true) + }) + + test('returns false for Playwright', () => { + expect(playwright.nameMatchesTCase('TestCartItemsTest002', 'TEST', 2)).toBe(false) + }) + + test('no match for wrong seq', () => { + expect(junit.nameMatchesTCase('TestCartItemsTest002', 'TEST', 3)).toBe(false) + }) + }) +}) diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index 3aee549..c9e4478 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -174,6 +174,66 @@ const fileTypes = [ }, ] +describe('Hyphenless test case markers (pytest style)', () => { + const junitBasePath = './src/tests/fixtures/junit-xml' + + describe('Uploading with --run-url', () => { + test('Hyphenless markers in JUnit XML should be mapped to run test cases', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await run(`junit-upload -r ${runURL} ${junitBasePath}/hyphenless-matching-tcases.xml`) + expect(numResultUploadCalls()).toBe(1) // 5 results total + }) + }) + + describe('Uploading with auto-detected project code', () => { + test('Project code should be auto-detected from hyphenless markers', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await run(`junit-upload ${junitBasePath}/hyphenless-matching-tcases.xml`) + expect(numResultUploadCalls()).toBe(1) // 5 results total + }) + }) + + describe('Uploading with --project-code', () => { + test('Explicit project code with hyphenless markers should work', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await run( + `junit-upload --project-code ${projectCode} ${junitBasePath}/hyphenless-matching-tcases.xml` + ) + expect(numResultUploadCalls()).toBe(1) // 5 results total + }) + }) +}) + +describe('CamelCase test case markers (Go/Java style)', () => { + const junitBasePath = './src/tests/fixtures/junit-xml' + + describe('Uploading with --run-url', () => { + test('CamelCase markers in JUnit XML should be mapped to run test cases', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await run(`junit-upload -r ${runURL} ${junitBasePath}/camelcase-matching-tcases.xml`) + expect(numResultUploadCalls()).toBe(1) // 5 results total + }) + }) + + describe('Uploading with auto-detected project code', () => { + test('Project code should be auto-detected from CamelCase markers', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await run(`junit-upload ${junitBasePath}/camelcase-matching-tcases.xml`) + expect(numResultUploadCalls()).toBe(1) // 5 results total + }) + }) + + describe('Uploading with --project-code', () => { + test('Explicit project code with CamelCase markers should work', async () => { + const numResultUploadCalls = countResultUploadApiCalls() + await run( + `junit-upload --project-code ${projectCode} ${junitBasePath}/camelcase-matching-tcases.xml` + ) + expect(numResultUploadCalls()).toBe(1) // 5 results total + }) + }) +}) + fileTypes.forEach((fileType) => { describe(`Uploading ${fileType.name} files`, () => { describe('Argument parsing', () => { diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 2f411ad..dd70186 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -96,10 +96,6 @@ 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/MarkerParser.ts b/src/utils/result-upload/MarkerParser.ts new file mode 100644 index 0000000..f229af7 --- /dev/null +++ b/src/utils/result-upload/MarkerParser.ts @@ -0,0 +1,164 @@ +import { UploadCommandType } from './ResultUploadCommandHandler' + +const MARKER_SEP = `_` +const LOOKS_LIKE_TEST_FN = /^test/i + +/** Convert a string like "BD026" to a case-insensitive regex fragment "[bB][dD]026" */ +const toCaseInsensitive = (str: string): string => + str.replace(/[a-zA-Z]/g, (ch) => { + const lower = ch.toLowerCase() + const upper = ch.toUpperCase() + return `[${lower}${upper}]` + }) + +/** Try matching at start, then end, then anywhere — return first match. */ +const execRegexWithPriority = ( + pattern: string, + str: string, + flags: string = '' +): RegExpExecArray | null => { + const startRegex = new RegExp(`^${pattern}`, flags) + let match = startRegex.exec(str) + if (match) return match + + const endRegex = new RegExp(`${pattern}$`, flags) + match = endRegex.exec(str) + if (match) return match + + const anywhereRegex = new RegExp(pattern, flags) + return anywhereRegex.exec(str) +} + +/** Also exported as a standalone function for parsers that just need formatting */ +export const formatMarker = (projectCode: string, seq: number) => + `${projectCode}-${seq.toString().padStart(3, '0')}` + +export class MarkerParser { + constructor(private type: UploadCommandType) {} + + /** Canonical hyphenated marker for API communication: "TEST-002" */ + formatMarker(projectCode: string, seq: number): string { + return formatMarker(projectCode, seq) + } + + /** + * Try to detect a project code from a test name. + * Returns uppercase project code or null. + * Tries hyphenated first (PRJ-123), then hyphenless for JUnit only. + */ + detectProjectCode(name: string): string | null { + // 1. Hyphenated: PRJ-123 + const hyphenatedPattern = String.raw`([A-Za-z0-9]{1,5})-\d{3,}` + const hyphenatedMatch = execRegexWithPriority(hyphenatedPattern, name) + if (hyphenatedMatch) { + return hyphenatedMatch[1] + } + + if (this.type !== 'junit-upload' || !LOOKS_LIKE_TEST_FN.test(name)) { + return null + } + + // 2. Separator-bounded hyphenless: test_prj123_foo + const sepPattern = String.raw`(?:^|${MARKER_SEP})([A-Za-z]{1,5})(\d{3,})(?:$|${MARKER_SEP})` + const sepMatch = execRegexWithPriority(sepPattern, name, 'i') + if (sepMatch) { + return sepMatch[1].toUpperCase() + } + + // 3. CamelCase start: TestPrj123Foo + const camelStartMatch = /^[tT][eE][sS][tT]([A-Za-z]{1,5})(\d{3,})(?=[A-Z]|$)/.exec(name) + if (camelStartMatch) { + return camelStartMatch[1].toUpperCase() + } + + // 4. CamelCase end: TestFooPrj123 + const camelEndMatch = /(?<=[a-z])([A-Z][A-Za-z]{0,4})(\d{3,})$/.exec(name) + if (camelEndMatch) { + return camelEndMatch[1].toUpperCase() + } + + return null + } + + /** + * Try to extract a sequence number for a known project code. + * Returns the seq number or null. + * Tries hyphenated first, then hyphenless for JUnit only. + */ + extractSeq(name: string, projectCode: string): number | null { + // 1. Hyphenated: PRJ-123 + const hyphenatedPattern = String.raw`${projectCode}-(\d{3,})` + const hyphenatedMatch = execRegexWithPriority(hyphenatedPattern, name) + if (hyphenatedMatch) { + return Number(hyphenatedMatch[1]) + } + + if (this.type !== 'junit-upload' || !LOOKS_LIKE_TEST_FN.test(name)) { + return null + } + + const ciCode = toCaseInsensitive(projectCode) + + // 2. Separator-bounded hyphenless: test_prj123_foo + const sepPattern = String.raw`(?:^|${MARKER_SEP})${ciCode}(\d{3,})(?:$|${MARKER_SEP})` + const sepMatch = execRegexWithPriority(sepPattern, name, 'i') + if (sepMatch) { + return Number(sepMatch[1]) + } + + // 3. CamelCase start: TestPrj123Foo + const camelStartPattern = `^[tT][eE][sS][tT]${ciCode}(\\d{3,})(?=[A-Z]|$)` + const camelStartMatch = new RegExp(camelStartPattern).exec(name) + if (camelStartMatch) { + return Number(camelStartMatch[1]) + } + + // 4. CamelCase end: TestFooPrj123 + const camelEndPattern = `(?<=[a-z])${ciCode}(\\d{3,})$` + const camelEndMatch = new RegExp(camelEndPattern).exec(name) + if (camelEndMatch) { + return Number(camelEndMatch[1]) + } + + return null + } + + /** + * Check if a test result name matches a specific test case. + * Used by ResultUploader to map results → run test cases. + */ + nameMatchesTCase(name: string, projectCode: string, seq: number): boolean { + // 1. Hyphenated: case-insensitive check with hyphenated marker (e.g., TEST-002) + const hyphenated = formatMarker(projectCode, seq) + if (name.toLowerCase().includes(hyphenated.toLowerCase())) { + return true + } + + if (this.type !== 'junit-upload' || !LOOKS_LIKE_TEST_FN.test(name)) { + return false + } + + const ciCode = toCaseInsensitive(projectCode) + const seqStr = seq.toString().padStart(3, '0') + + // 2. Separator-bounded hyphenless: test_prj002_foo + const sepPattern = new RegExp(`(?:^|${MARKER_SEP})${ciCode}${seqStr}(?:$|${MARKER_SEP})`, 'i') + if (sepPattern.test(name)) { + return true + } + + // 3. CamelCase start: TestPrj002Foo + const camelStartPattern = new RegExp(`^[tT][eE][sS][tT]${ciCode}${seqStr}(?=[A-Z]|$)`) + if (camelStartPattern.test(name)) { + return true + } + + // 4. CamelCase end: TestFooPrj002 + const camelEndPattern = new RegExp(`(?<=[a-z])${ciCode}${seqStr}$`) + if (camelEndPattern.test(name)) { + return true + } + + return false + } +} diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index f4b90be..cb3e4de 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -2,7 +2,8 @@ import { Arguments } from 'yargs' import chalk from 'chalk' import { readFileSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' -import { getTCaseMarker, parseRunUrl, printErrorThenExit, processTemplate } from '../misc' +import { parseRunUrl, printErrorThenExit, processTemplate } from '../misc' +import { MarkerParser } from './MarkerParser' import { Api, createApi } from '../../api' import { TCase } from '../../api/schemas' import { TestCaseResult } from './types' @@ -67,6 +68,7 @@ const commandTypeParsers: Record = { export class ResultUploadCommandHandler { private api: Api private baseUrl: string + private markerParser: MarkerParser constructor( private type: UploadCommandType, @@ -76,6 +78,7 @@ export class ResultUploadCommandHandler { this.baseUrl = process.env.QAS_URL!.replace(/\/+$/, '') this.api = createApi(this.baseUrl, apiToken) + this.markerParser = new MarkerParser(this.type) } async handle() { @@ -145,15 +148,11 @@ export class ResultUploadCommandHandler { } protected detectProjectCodeFromTCaseNames(fileResults: FileResults[]) { - // Look for pattern like PRJ-123 or TEST-456 - 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 = this.execRegexWithPriority(tcaseSeqPattern, result.name) - if (match) { - return match[1] - } + const code = this.markerParser.detectProjectCode(result.name) + if (code) return code } } } @@ -163,25 +162,8 @@ 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 tcaseSeqPattern = String.raw`${projectCode}-(\d{3,})` const seqIdsSet: Set = new Set() const resultsWithSeqAndFile: TestCaseResultWithSeqAndFile[] = [] @@ -196,15 +178,15 @@ export class ResultUploadCommandHandler { continue } - const match = this.execRegexWithPriority(tcaseSeqPattern, result.name) + const seq = this.markerParser.extractSeq(result.name, projectCode) resultsWithSeqAndFile.push({ - seq: match ? Number(match[1]) : null, + seq, file, result, }) - if (match) { - seqIdsSet.add(Number(match[1])) + if (seq !== null) { + seqIdsSet.add(seq) } } } @@ -212,7 +194,9 @@ export class ResultUploadCommandHandler { // 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)) + const tcaseMarkers = Array.from(seqIdsSet).map((v) => + this.markerParser.formatMarker(projectCode, v) + ) for (let page = 1; ; page++) { const response = await this.api.testcases.getTCasesBySeq(projectCode, { @@ -260,7 +244,7 @@ export class ResultUploadCommandHandler { const newTCases = await this.createNewTCases(projectCode, keys) for (let i = 0; i < keys.length; i++) { - const marker = getTCaseMarker(projectCode, newTCases[i].seq) + const marker = this.markerParser.formatMarker(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 @@ -372,7 +356,9 @@ export class ResultUploadCommandHandler { try { const mappingFilename = processTemplate(DEFAULT_MAPPING_FILENAME_TEMPLATE) const mappingLines = tcases - .map((t, i) => `${getTCaseMarker(projectCode, t.seq)}: ${tcasesToCreate[i]}`) + .map( + (t, i) => `${this.markerParser.formatMarker(projectCode, t.seq)}: ${tcasesToCreate[i]}` + ) .join('\n') writeFileSync(mappingFilename, mappingLines) @@ -434,7 +420,7 @@ export class ResultUploadCommandHandler { private async uploadResults(projectCode: string, runId: number, results: TestCaseResult[]) { const runUrl = `${this.baseUrl}/project/${projectCode}/run/${runId}` - const uploader = new ResultUploader(this.type, { ...this.args, runUrl }) + const uploader = new ResultUploader(this.markerParser, this.type, { ...this.args, runUrl }) await uploader.handle(results) } } diff --git a/src/utils/result-upload/ResultUploader.ts b/src/utils/result-upload/ResultUploader.ts index bb96596..eb6ecb8 100644 --- a/src/utils/result-upload/ResultUploader.ts +++ b/src/utils/result-upload/ResultUploader.ts @@ -1,10 +1,11 @@ import { Arguments } from 'yargs' import chalk from 'chalk' import { RunTCase } from '../../api/schemas' -import { getTCaseMarker, parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' +import { parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' import { Api, createApi } from '../../api' import { Attachment, TestCaseResult } from './types' import { ResultUploadCommandArgs, UploadCommandType } from './ResultUploadCommandHandler' +import type { MarkerParser } from './MarkerParser' const MAX_CONCURRENT_FILE_UPLOADS = 10 let MAX_RESULTS_IN_REQUEST = 50 // Only updated from tests, otherwise it's a constant @@ -15,6 +16,7 @@ export class ResultUploader { private run: number constructor( + private markerParser: MarkerParser, private type: UploadCommandType, private args: Arguments ) { @@ -290,10 +292,9 @@ ${chalk.yellow('To fix this issue, choose one of the following options:')} testcaseResults.forEach((result) => { if (result.name) { - const tcase = testcases.find((tcase) => { - const tcaseMarker = getTCaseMarker(this.project, tcase.seq) - return result.name.includes(tcaseMarker) - }) + const tcase = testcases.find((tcase) => + this.markerParser.nameMatchesTCase(result.name, this.project, tcase.seq) + ) if (tcase) { results.push({ result, tcase }) diff --git a/src/utils/result-upload/playwrightJsonParser.ts b/src/utils/result-upload/playwrightJsonParser.ts index 55ec5a7..c4c50a2 100644 --- a/src/utils/result-upload/playwrightJsonParser.ts +++ b/src/utils/result-upload/playwrightJsonParser.ts @@ -4,7 +4,8 @@ import stripAnsi from 'strip-ansi' import { Attachment, TestCaseResult } from './types' import { Parser, ParserOptions } from './ResultUploadCommandHandler' import { ResultStatus } from '../../api/schemas' -import { getTCaseMarker, parseTCaseUrl } from '../misc' +import { parseTCaseUrl } from '../misc' +import { formatMarker } from './MarkerParser' import { getAttachments } from './utils' // Schema definition as per https://github.com/microsoft/playwright/blob/main/packages/playwright/types/testReporter.d.ts @@ -160,7 +161,7 @@ const getTCaseMarkerFromAnnotations = (annotations: Annotation[]) => { if (annotation.type.toLowerCase().includes('test case') && annotation.description) { const res = parseTCaseUrl(annotation.description) if (res) { - return getTCaseMarker(res.project, res.tcaseSeq) + return formatMarker(res.project, res.tcaseSeq) } } } From 8baada5ec06a3b456d8382db0e39faba81e81903 Mon Sep 17 00:00:00 2001 From: Andrian Budantsov Date: Tue, 10 Feb 2026 14:56:31 +0400 Subject: [PATCH 2/3] Add comments explaining hyphenless letters-only and hyphenated case-sensitivity design choices --- src/utils/result-upload/MarkerParser.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/utils/result-upload/MarkerParser.ts b/src/utils/result-upload/MarkerParser.ts index f229af7..ef332f7 100644 --- a/src/utils/result-upload/MarkerParser.ts +++ b/src/utils/result-upload/MarkerParser.ts @@ -48,6 +48,8 @@ export class MarkerParser { */ detectProjectCode(name: string): string | null { // 1. Hyphenated: PRJ-123 + // Case-sensitive, returns code as-is (no uppercasing). Hyphenated markers appear + // in annotations or string literals where there's no reason to use wrong case. const hyphenatedPattern = String.raw`([A-Za-z0-9]{1,5})-\d{3,}` const hyphenatedMatch = execRegexWithPriority(hyphenatedPattern, name) if (hyphenatedMatch) { @@ -58,6 +60,12 @@ export class MarkerParser { return null } + // Hyphenless patterns use letters-only for project codes ([A-Za-z]{1,5}). + // Alphanumeric codes (e.g., "BD026") won't work here because without a hyphen + // delimiter there's no way to tell where the code ends and the sequence starts + // (e.g., "BD026123" is ambiguous). This is a known limitation — projects using + // numeric characters in their code must use hyphenated markers. + // 2. Separator-bounded hyphenless: test_prj123_foo const sepPattern = String.raw`(?:^|${MARKER_SEP})([A-Za-z]{1,5})(\d{3,})(?:$|${MARKER_SEP})` const sepMatch = execRegexWithPriority(sepPattern, name, 'i') From 9a6dbd1101c4e5ee6834e72f8f1162234293b06b Mon Sep 17 00:00:00 2001 From: Andrian Budantsov Date: Tue, 10 Feb 2026 15:17:06 +0400 Subject: [PATCH 3/3] Add .prettierignore --- .prettierignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5eec986 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +.claude