diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..5eec986
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+.claude
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..ef332f7
--- /dev/null
+++ b/src/utils/result-upload/MarkerParser.ts
@@ -0,0 +1,172 @@
+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
+ // 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) {
+ return hyphenatedMatch[1]
+ }
+
+ if (this.type !== 'junit-upload' || !LOOKS_LIKE_TEST_FN.test(name)) {
+ 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')
+ 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)
}
}
}