@@ -66,6 +66,15 @@ interface FolderCache {
6666interface ZephyrTestCase {
6767 key : string ;
6868 name : string ;
69+ objective ?: string | null ;
70+ labels ?: unknown [ ] ;
71+ createdOn ?: string ;
72+ executionCount ?: number ;
73+ }
74+ interface ZephyrTestCaseListResponse {
75+ values : ZephyrTestCase [ ] ;
76+ total ?: number ;
77+ isLast ?: boolean ;
6978}
7079
7180const ZEPHYR_BASE_URL = "https://api.zephyrscale.smartbear.com/v2" ;
@@ -82,6 +91,7 @@ function getZephyrToken(): string {
8291const ZEPHYR_LABELS = process . env . ZEPHYR_LABELS ?. split ( "," )
8392 . map ( ( l ) => l . trim ( ) )
8493 . filter ( Boolean ) || [ "storybook" , "vitest" , "automated" ] ;
94+ const TEST_CASE_PAGE_SIZE = 1000 ;
8595
8696// Folder prefix for Zephyr - can be customized via env var
8797const FOLDER_PREFIX = process . env . ZEPHYR_FOLDER_PREFIX || "React UI Kit" ;
@@ -110,6 +120,16 @@ function generateFolderMapping(): { [key: string]: string } {
110120
111121const FOLDER_MAPPING = generateFolderMapping ( ) ;
112122const folderIdCache : FolderCache = { } ;
123+ let generatedTestCaseCache : ZephyrTestCase [ ] | null = null ;
124+ const testCaseDetailCache = new Map < string , ZephyrTestCase > ( ) ;
125+ const executionCountCache = new Map < string , number > ( ) ;
126+ const OBJECTIVE_ENTITY_REPLACEMENTS : Record < string , string > = {
127+ "#39" : "'" ,
128+ amp : "&" ,
129+ mdash : "—" ,
130+ ndash : "–" ,
131+ quot : "\"" ,
132+ } ;
113133
114134function findStoryFiles ( dir : string ) : string [ ] {
115135 const storyFiles : string [ ] = [ ] ;
@@ -447,6 +467,151 @@ export function findDuplicateZephyrIds(stories: StoryCase[]): DuplicateZephyrId[
447467 } ) ) ;
448468}
449469
470+ export function normalizeZephyrObjective ( objective : string | null | undefined ) : string {
471+ return ( objective ?? "" )
472+ . replace ( / & ( # 3 9 | a m p | m d a s h | n d a s h | q u o t ) ; / g, ( _ , entity : string ) => OBJECTIVE_ENTITY_REPLACEMENTS [ entity ] )
473+ . replace ( / < b r \s * \/ ? \s * > / gi, "<br>" )
474+ . replace ( / \s + / g, " " )
475+ . trim ( ) ;
476+ }
477+
478+ export function selectReusableTestCase ( candidates : ZephyrTestCase [ ] ) : ZephyrTestCase | null {
479+ if ( candidates . length === 0 ) return null ;
480+
481+ return [ ...candidates ] . sort ( ( a , b ) => {
482+ const executionDelta = ( Number ( b . executionCount ) || 0 ) - ( Number ( a . executionCount ) || 0 ) ;
483+ if ( executionDelta !== 0 ) return executionDelta ;
484+
485+ const aCreated = a . createdOn ? Date . parse ( a . createdOn ) : Number . MAX_SAFE_INTEGER ;
486+ const bCreated = b . createdOn ? Date . parse ( b . createdOn ) : Number . MAX_SAFE_INTEGER ;
487+ if ( aCreated !== bCreated ) return aCreated - bCreated ;
488+
489+ return a . key . localeCompare ( b . key , undefined , { numeric : true } ) ;
490+ } ) [ 0 ] ;
491+ }
492+
493+ function getLabelNames ( testCase : ZephyrTestCase ) : string [ ] {
494+ const labels = testCase . labels ?? [ ] ;
495+ return labels . map ( ( label ) => {
496+ if ( typeof label === "string" ) return label ;
497+ if ( label && typeof label === "object" && "name" in label ) {
498+ return String ( ( label as { name : unknown } ) . name ) ;
499+ }
500+ return String ( label ) ;
501+ } ) ;
502+ }
503+
504+ function hasConfiguredLabels ( testCase : ZephyrTestCase ) : boolean {
505+ const labels = new Set ( getLabelNames ( testCase ) ) ;
506+ return ZEPHYR_LABELS . every ( ( label ) => labels . has ( label ) ) ;
507+ }
508+
509+ async function getTestCase ( testCaseKey : string ) : Promise < ZephyrTestCase > {
510+ const cached = testCaseDetailCache . get ( testCaseKey ) ;
511+ if ( cached ) return cached ;
512+
513+ const url = `${ ZEPHYR_BASE_URL } /testcases/${ testCaseKey } ` ;
514+ const response = await fetch ( url , {
515+ method : "GET" ,
516+ headers : { Authorization : `Bearer ${ getZephyrToken ( ) } ` , "Content-Type" : "application/json" } ,
517+ } ) ;
518+
519+ if ( ! response . ok ) {
520+ const errorText = await response . text ( ) ;
521+ throw new Error ( `Failed to fetch test case ${ testCaseKey } : ${ response . status } ${ errorText } ` ) ;
522+ }
523+
524+ const testCase = ( await response . json ( ) ) as ZephyrTestCase ;
525+ testCaseDetailCache . set ( testCaseKey , testCase ) ;
526+ return testCase ;
527+ }
528+
529+ async function getGeneratedTestCases ( ) : Promise < ZephyrTestCase [ ] > {
530+ if ( generatedTestCaseCache ) return generatedTestCaseCache ;
531+
532+ const testCases : ZephyrTestCase [ ] = [ ] ;
533+ let startAt = 0 ;
534+ let fetchedCount = 0 ;
535+
536+ while ( true ) {
537+ const url = `${ ZEPHYR_BASE_URL } /testcases?projectKey=${ PROJECT_KEY } &startAt=${ startAt } &maxResults=${ TEST_CASE_PAGE_SIZE } ` ;
538+ const response = await fetch ( url , {
539+ method : "GET" ,
540+ headers : { Authorization : `Bearer ${ getZephyrToken ( ) } ` , "Content-Type" : "application/json" } ,
541+ } ) ;
542+
543+ if ( ! response . ok ) {
544+ const errorText = await response . text ( ) ;
545+ throw new Error ( `Failed to list test cases: ${ response . status } ${ errorText } ` ) ;
546+ }
547+
548+ const page = ( await response . json ( ) ) as ZephyrTestCaseListResponse ;
549+ const values = page . values ?? [ ] ;
550+ fetchedCount += values . length ;
551+ testCases . push ( ...values . filter ( hasConfiguredLabels ) ) ;
552+
553+ if ( page . isLast || values . length === 0 || fetchedCount >= ( page . total ?? Number . MAX_SAFE_INTEGER ) ) break ;
554+ startAt += values . length ;
555+ }
556+
557+ generatedTestCaseCache = testCases ;
558+ return generatedTestCaseCache ;
559+ }
560+
561+ async function getExecutionCount ( testCaseKey : string ) : Promise < number > {
562+ const cached = executionCountCache . get ( testCaseKey ) ;
563+ if ( cached !== undefined ) return cached ;
564+
565+ const url = `${ ZEPHYR_BASE_URL } /testexecutions?projectKey=${ PROJECT_KEY } &testCase=${ encodeURIComponent (
566+ testCaseKey ,
567+ ) } &maxResults=1`;
568+ const response = await fetch ( url , {
569+ method : "GET" ,
570+ headers : { Authorization : `Bearer ${ getZephyrToken ( ) } ` , "Content-Type" : "application/json" } ,
571+ } ) ;
572+
573+ if ( ! response . ok ) {
574+ const errorText = await response . text ( ) ;
575+ throw new Error ( `Failed to fetch executions for ${ testCaseKey } : ${ response . status } ${ errorText } ` ) ;
576+ }
577+
578+ const page = ( await response . json ( ) ) as { total ?: number ; values ?: unknown [ ] } ;
579+ const count = page . total ?? page . values ?. length ?? 0 ;
580+ executionCountCache . set ( testCaseKey , count ) ;
581+ return count ;
582+ }
583+
584+ async function findReusableTestCase ( testName : string , objective : string ) : Promise < ZephyrTestCase | null > {
585+ const targetObjective = normalizeZephyrObjective ( objective ) ;
586+ const generatedTestCases = await getGeneratedTestCases ( ) ;
587+ const sameNameCandidates = generatedTestCases . filter ( ( testCase ) => testCase . name === testName ) ;
588+ const matchingCandidates : ZephyrTestCase [ ] = [ ] ;
589+
590+ for ( const candidate of sameNameCandidates ) {
591+ const detailedCandidate = await getTestCase ( candidate . key ) ;
592+ if ( normalizeZephyrObjective ( detailedCandidate . objective ) !== targetObjective ) continue ;
593+ matchingCandidates . push ( { ...candidate , ...detailedCandidate } ) ;
594+ }
595+
596+ if ( matchingCandidates . length === 0 ) return null ;
597+
598+ const candidatesWithExecutions = await Promise . all (
599+ matchingCandidates . map ( async ( candidate ) => ( {
600+ ...candidate ,
601+ executionCount : await getExecutionCount ( candidate . key ) ,
602+ } ) ) ,
603+ ) ;
604+
605+ const reusableTestCase = selectReusableTestCase ( candidatesWithExecutions ) ;
606+ if ( matchingCandidates . length > 1 && reusableTestCase ) {
607+ const duplicateKeys = matchingCandidates . map ( ( candidate ) => candidate . key ) . join ( ", " ) ;
608+ console . warn ( ` [WARN] Found duplicate Zephyr cases for this story: ${ duplicateKeys } ` ) ;
609+ console . warn ( ` Reusing ${ reusableTestCase . key } ; cleanup should remove the unreferenced duplicates.` ) ;
610+ }
611+
612+ return reusableTestCase ;
613+ }
614+
450615async function getFolders ( ) : Promise < FolderCache > {
451616 if ( Object . keys ( folderIdCache ) . length > 0 ) return folderIdCache ;
452617 const url = `${ ZEPHYR_BASE_URL } /folders?projectKey=${ PROJECT_KEY } &folderType=TEST_CASE&maxResults=100` ;
@@ -875,11 +1040,13 @@ async function main(): Promise<void> {
8751040 let updateFailCount = 0 ;
8761041 let stepSyncCount = 0 ;
8771042 let stepSyncFail = 0 ;
1043+ let createdCount = 0 ;
1044+ let reusedCount = 0 ;
8781045
8791046 if ( storiesNeedingIds . length === 0 ) {
8801047 console . log ( "[INFO] All stories already have Zephyr IDs!\n" ) ;
8811048 } else {
882- console . log ( `[INFO] Creating ${ storiesNeedingIds . length } test case(s) in Zephyr...\n` ) ;
1049+ console . log ( `[INFO] Creating or reusing ${ storiesNeedingIds . length } test case(s) in Zephyr...\n` ) ;
8831050
8841051 for ( const story of storiesNeedingIds ) {
8851052 const relativePath = path . relative ( process . cwd ( ) , story . filePath ) ;
@@ -892,9 +1059,17 @@ async function main(): Promise<void> {
8921059 const humanName = story . storyName . replace ( / ( [ A - Z ] ) / g, " $1" ) . trim ( ) ;
8931060 const testName = `${ story . componentName } - ${ humanName } ` ;
8941061
895- console . log ( ` [API] Creating test case in Zephyr...` ) ;
896- const result = await createTestCase ( testName , objective , folderId ) ;
897- console . log ( ` [SUCCESS] Created ${ result . key } ` ) ;
1062+ console . log ( ` [API] Looking for an existing Zephyr test case...` ) ;
1063+ const reusableTestCase = await findReusableTestCase ( testName , objective ) ;
1064+ const result = reusableTestCase ?? ( await createTestCase ( testName , objective , folderId ) ) ;
1065+
1066+ if ( reusableTestCase ) {
1067+ reusedCount ++ ;
1068+ console . log ( ` [SUCCESS] Reused ${ result . key } ` ) ;
1069+ } else {
1070+ createdCount ++ ;
1071+ console . log ( ` [SUCCESS] Created ${ result . key } ` ) ;
1072+ }
8981073
8991074 if ( story . steps . length > 0 ) {
9001075 try {
@@ -964,6 +1139,8 @@ async function main(): Promise<void> {
9641139 console . log ( "\n=====================================" ) ;
9651140 console . log ( `[INFO] Sync complete!` ) ;
9661141 console . log ( ` Fully synced: ${ successCount } ` ) ;
1142+ console . log ( ` Created in Zephyr: ${ createdCount } ` ) ;
1143+ console . log ( ` Reused from Zephyr: ${ reusedCount } ` ) ;
9671144 console . log ( ` Created but needs manual update: ${ updateFailCount } ` ) ;
9681145 console . log ( ` Test steps synced: ${ stepSyncCount } ` ) ;
9691146 console . log ( ` Failed: ${ failCount } ` ) ;
0 commit comments