Skip to content

Commit 1e5417c

Browse files
Merge branch 'main' into feat/layout-patterns
2 parents 3f60480 + 8fbefea commit 1e5417c

6 files changed

Lines changed: 539 additions & 4 deletions

File tree

scripts/zephyr/__tests__/sync-storybook-zephyr.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
extractZephyrIdsFromParameters,
1414
findDuplicateZephyrIds,
1515
findStoryNameAssignment,
16+
normalizeZephyrObjective,
17+
selectReusableTestCase,
1618
updateStoryFile,
1719
} from "../sync-storybook-zephyr";
1820

@@ -351,6 +353,44 @@ export const Warning: Story = {
351353
});
352354
});
353355

356+
describe("normalizeZephyrObjective", () => {
357+
it("normalizes Zephyr objective HTML variants for stable identity matching", () => {
358+
const objective = "Component: Elevation &amp; Shape<br/>Story: Brand &mdash; Active<br>File: `src/foo.stories.tsx`";
359+
360+
expect(normalizeZephyrObjective(objective)).toBe(
361+
"Component: Elevation & Shape<br>Story: Brand — Active<br>File: `src/foo.stories.tsx`",
362+
);
363+
});
364+
365+
it("does not recursively decode entities produced by earlier replacements", () => {
366+
const objective = "Component: Copy<br>Story: Literal &amp;mdash; Text<br>File: `src/foo.stories.tsx`";
367+
368+
expect(normalizeZephyrObjective(objective)).toBe(
369+
"Component: Copy<br>Story: Literal &mdash; Text<br>File: `src/foo.stories.tsx`",
370+
);
371+
});
372+
});
373+
374+
describe("selectReusableTestCase", () => {
375+
it("prefers candidates with execution history", () => {
376+
const selected = selectReusableTestCase([
377+
{ key: "SW-T200", name: "Button - Default", executionCount: 0, createdOn: "2026-05-12T00:00:00Z" },
378+
{ key: "SW-T100", name: "Button - Default", executionCount: 3, createdOn: "2026-05-11T00:00:00Z" },
379+
]);
380+
381+
expect(selected?.key).toBe("SW-T100");
382+
});
383+
384+
it("uses the oldest candidate when execution counts tie", () => {
385+
const selected = selectReusableTestCase([
386+
{ key: "SW-T200", name: "Button - Default", executionCount: 0, createdOn: "2026-05-12T00:00:00Z" },
387+
{ key: "SW-T100", name: "Button - Default", executionCount: 0, createdOn: "2026-05-11T00:00:00Z" },
388+
]);
389+
390+
expect(selected?.key).toBe("SW-T100");
391+
});
392+
});
393+
354394
describe("updateStoryFile", () => {
355395
it("should add zephyr inside an existing one-line parameters object", () => {
356396
const filePath = createTempStoryFile(`import type { StoryObj } from "@storybook/react";

scripts/zephyr/sync-storybook-zephyr.ts

Lines changed: 181 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ interface FolderCache {
6666
interface 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

7180
const ZEPHYR_BASE_URL = "https://api.zephyrscale.smartbear.com/v2";
@@ -82,6 +91,7 @@ function getZephyrToken(): string {
8291
const 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
8797
const FOLDER_PREFIX = process.env.ZEPHYR_FOLDER_PREFIX || "React UI Kit";
@@ -110,6 +120,16 @@ function generateFolderMapping(): { [key: string]: string } {
110120

111121
const FOLDER_MAPPING = generateFolderMapping();
112122
const 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

114134
function 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(/&(#39|amp|mdash|ndash|quot);/g, (_, entity: string) => OBJECTIVE_ENTITY_REPLACEMENTS[entity])
473+
.replace(/<br\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+
450615
async 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

Comments
 (0)