From 6e665a92e24fe98d41847dff6c360af17bc3be31 Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:47:42 -0400 Subject: [PATCH 1/6] chore: migrate zephyr scripts to internal zephyr library Replace the repo's bespoke Zephyr Scale integration (hand-rolled fetch, auth, timeouts, pagination, error handling) with the shared internal ts-lib-zephyr-nodejs library, per SW-1779 (tech debt). - report-zephyr-results.ts / sync-storybook-zephyr.ts now route all Zephyr HTTP through the library's ZephyrClient. Repo-specific logic (JUnit parsing, story parsing/write-back, testCaseId schema, cycle find-or-create, folder mapping, reuse selection) stays local. - Reporter now embeds each story's screenshot as a base64 into every step of the execution (buildStepsPayload + putExecutionSteps), replacing the previous S3-upload + URL-in-comment path. - Screenshot transport: delete upload-test-screenshots.ts; ship test-results/screenshots/** in the storybook-junit-results artifact; drop the S3 upload + AWS OIDC steps from storybook-tests.yml. Remove the now-unused @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner devDependencies. - The library is published only to the internal registry, so it is kept out of package.json/yarn.lock (preserving the public-registry invariant) and installed as a leaf tarball inside the two Zephyr workflows. Requires JFROG_ARTIFACTORY_NPM_VIRTUAL_URL and JFROG_ARTIFACTORY_READ_NPM_AUTH secrets. - Docs updated in AGENTS.md and CONTRIBUTING.md. Refs: SW-1779 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/storybook-tests.yml | 26 +-- .github/workflows/zephyr-report-results.yml | 24 +++ .github/workflows/zephyr-sync-storybook.yml | 24 +++ AGENTS.md | 4 +- CONTRIBUTING.md | 2 + package.json | 2 - scripts/upload-test-screenshots.ts | 214 ------------------- scripts/zephyr/report-zephyr-results.ts | 219 ++++++++------------ scripts/zephyr/sync-storybook-zephyr.ts | 159 +++++--------- yarn.lock | 32 +-- 10 files changed, 198 insertions(+), 508 deletions(-) delete mode 100644 scripts/upload-test-screenshots.ts diff --git a/.github/workflows/storybook-tests.yml b/.github/workflows/storybook-tests.yml index 6e744176..aafb44da 100644 --- a/.github/workflows/storybook-tests.yml +++ b/.github/workflows/storybook-tests.yml @@ -9,9 +9,7 @@ on: required: false type: string -# Required for AWS OIDC authentication permissions: - id-token: write contents: read jobs: @@ -63,31 +61,17 @@ jobs: continue-on-error: true id: test_filtered - - name: 🔐 Configure AWS Credentials - if: always() - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 - with: - aws-region: ${{ secrets.TEST_ARTIFACTS_AWS_REGION }} - role-to-assume: arn:aws:iam::${{ secrets.TEST_ARTIFACTS_AWS_ACCOUNT_ID }}:role/${{ secrets.TEST_ARTIFACTS_POLICY_NAME}} - role-session-name: StorybookTestsSession - - - name: 📤 Upload Test Screenshots to S3 - if: always() - run: npx tsx scripts/upload-test-screenshots.ts - env: - GITHUB_RUN_NUMBER: ${{ github.run_number }} - GITHUB_REPOSITORY: ${{ github.repository }} - S3_BUCKET: ${{ secrets.TEST_ARTIFACTS_BUCKET_NAME }} - AWS_REGION: ${{ secrets.TEST_ARTIFACTS_AWS_REGION }} - - - name: 📊 Upload JUnit test results + - name: 📊 Upload JUnit test results and screenshots uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: storybook-junit-results + # Ship the raw per-story screenshots alongside the JUnit report so the + # Zephyr report workflow can embed them as base64 in each step's + # result (replaces the previous S3-upload + URL-in-comment path). path: | test-results/storybook-junit.xml - test-results/screenshot-urls.json + test-results/screenshots/ retention-days: 7 - name: 📸 Upload failure artifacts diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml index 8497ae05..f6ec87d6 100644 --- a/.github/workflows/zephyr-report-results.yml +++ b/.github/workflows/zephyr-report-results.yml @@ -51,6 +51,30 @@ jobs: - name: Install dependencies run: yarn install --immutable + # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory. + # It is deliberately kept OUT of package.json / yarn.lock so public and + # external-contributor `yarn install` stays on the public npm registry. + # Fetch it here as a leaf tarball and extract into node_modules — this avoids + # `npm install`'s full-tree reconciliation, which corrupts the Yarn-managed + # node_modules. Its only transitive need (ts-morph) is already installed. + - name: Install ts-lib-zephyr-nodejs (JFrog) + env: + JFROG_NPM_REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }} + JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} + run: | + set -euo pipefail + host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}" + npmrc="$RUNNER_TEMP/.npmrc-jfrog" + # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for + # the read-only Artifactory user (same value used across the org's repos). + printf 'registry=%s\n//%s:_auth=%s\nalways-auth=true\n' \ + "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc" + tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY") + rm -rf node_modules/ts-lib-zephyr-nodejs + mkdir -p node_modules/ts-lib-zephyr-nodejs + tar -xzf "$tgz" -C node_modules/ts-lib-zephyr-nodejs --strip-components=1 + rm -f "$npmrc" "$tgz" + - name: Download test results artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: diff --git a/.github/workflows/zephyr-sync-storybook.yml b/.github/workflows/zephyr-sync-storybook.yml index 3d68f38f..c1577c3f 100644 --- a/.github/workflows/zephyr-sync-storybook.yml +++ b/.github/workflows/zephyr-sync-storybook.yml @@ -41,6 +41,30 @@ jobs: - name: Install dependencies run: yarn install --immutable + # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory. + # It is deliberately kept OUT of package.json / yarn.lock so public and + # external-contributor `yarn install` stays on the public npm registry. + # Fetch it here as a leaf tarball and extract into node_modules — this avoids + # `npm install`'s full-tree reconciliation, which corrupts the Yarn-managed + # node_modules. Its only transitive need (ts-morph) is already installed. + - name: Install ts-lib-zephyr-nodejs (JFrog) + env: + JFROG_NPM_REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }} + JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} + run: | + set -euo pipefail + host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}" + npmrc="$RUNNER_TEMP/.npmrc-jfrog" + # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for + # the read-only Artifactory user (same value used across the org's repos). + printf 'registry=%s\n//%s:_auth=%s\nalways-auth=true\n' \ + "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc" + tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY") + rm -rf node_modules/ts-lib-zephyr-nodejs + mkdir -p node_modules/ts-lib-zephyr-nodejs + tar -xzf "$tgz" -C node_modules/ts-lib-zephyr-nodejs --strip-components=1 + rm -f "$npmrc" "$tgz" + - name: Run Zephyr sync env: ZEPHYR_TOKEN: ${{ secrets.ZEPHYR_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index a8b113f0..c70fb2e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,9 @@ Convention: uses [Conventional Commits](https://www.conventionalcommits.org/) fo ## Zephyr Integration -- Test results reported to Zephyr Scale via `scripts/zephyr/report-zephyr-results.ts` +- Zephyr HTTP is handled by a shared internal `ts-lib-zephyr-nodejs` library (`ZephyrClient` + helpers). The repo's scripts are thin wrappers around it — JUnit parsing, story parsing/write-back, cycle resolution, and folder mapping stay local. +- **Dependency source:** the library lives only on TetraScience's JFrog Artifactory and is deliberately kept **out of `package.json` / `yarn.lock`** so public/external-contributor `yarn install` stays on the public npm registry. CI installs it as a leaf tarball inside the two Zephyr workflows (see `Install ts-lib-zephyr-nodejs (JFrog)` steps; needs `JFROG_ARTIFACTORY_NPM_VIRTUAL_URL` + `JFROG_ARTIFACTORY_READ_NPM_AUTH` secrets). For local script work, install it as a leaf: `npm pack ts-lib-zephyr-nodejs@0.4.0 --registry ` then extract the tgz into `node_modules/ts-lib-zephyr-nodejs` (do **not** `npm install --no-save` — it corrupts the Yarn `node_modules`). +- Test results reported to Zephyr Scale via `scripts/zephyr/report-zephyr-results.ts`. Each story's screenshot is embedded (base64 ``) into every step of the execution via the library's `buildStepsPayload` + `putExecutionSteps` — there is no S3 upload; CI ships `test-results/screenshots/**` in the `storybook-junit-results` artifact for the report job to read. - Story-to-testcase sync handled by `scripts/zephyr/sync-storybook-zephyr.ts` - Test case IDs live in story parameters: `parameters.zephyr.testCaseId` - Do not manually invent, copy, reuse, or paste Zephyr test case IDs between stories. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dee5291..6bb4368a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -282,6 +282,8 @@ export const MyStory: Story = { #### Zephyr Integration +The Zephyr scripts are thin wrappers around a shared internal `ts-lib-zephyr-nodejs` library (installed from the internal registry only inside the Zephyr CI workflows — see `AGENTS.md`). Test results report per-story screenshots embedded directly into each Zephyr step. + Test cases are linked to Zephyr Scale via story parameters. These IDs are auto-generated by `sync-storybook-zephyr`: ```typescript diff --git a/package.json b/package.json index e0cf31be..b619a413 100644 --- a/package.json +++ b/package.json @@ -168,8 +168,6 @@ }, "devDependencies": { "@aws-sdk/client-athena": "^3.975.0", - "@aws-sdk/client-s3": "^3.985.0", - "@aws-sdk/s3-request-presigner": "^3.985.0", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@databricks/sql": "^1.12.0", diff --git a/scripts/upload-test-screenshots.ts b/scripts/upload-test-screenshots.ts deleted file mode 100644 index 62f7959b..00000000 --- a/scripts/upload-test-screenshots.ts +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env tsx -/** - * Upload test screenshots to S3 organized by Zephyr test case ID - */ - -import fs from "fs"; -import path from "path"; - -import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; - -// ============================================================================ -// Configuration -// ============================================================================ - -// Required environment variables -const AWS_REGION = process.env.AWS_REGION; -const S3_BUCKET = process.env.S3_BUCKET; - -if (!AWS_REGION) { - console.error("[ERROR] AWS_REGION environment variable is required"); - process.exit(1); -} - -if (!S3_BUCKET) { - console.error("[ERROR] S3_BUCKET environment variable is required"); - process.exit(1); -} -const SCREENSHOT_DIR = path.resolve(process.cwd(), "test-results/screenshots"); -const OUTPUT_FILE = path.resolve(process.cwd(), "test-results/screenshot-urls.json"); - -// GitHub context for organizing uploads -const GITHUB_RUN_NUMBER = process.env.GITHUB_RUN_NUMBER || "local"; -const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || "tetrascience/ts-lib-ui-kit"; - -// Pattern to extract Zephyr ID from filename: SW-T123.png -const ZEPHYR_ID_FROM_FILENAME = /^([A-Z]+-T\d+)\.png$/; - -// ============================================================================ -// Types -// ============================================================================ - -interface ScreenshotUrlMapping { - zephyrId: string; - s3Key: string; - url: string; -} - -interface OutputFile { - uploadedAt: string; - bucket: string; - region: string; - runNumber: string; - repository: string; - screenshots: ScreenshotUrlMapping[]; -} - -// ============================================================================ -// S3 Client -// ============================================================================ - -const s3Client = new S3Client({ region: AWS_REGION }); - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function getDateString(): string { - const now = new Date(); - return now.toISOString().split("T")[0]; // YYYY-MM-DD -} - -function getRepoName(): string { - return GITHUB_REPOSITORY.split("/").pop() || "unknown"; -} - -const CLOUDFRONT_DOMAIN = "d3kr1q5iwe1zqc.cloudfront.net"; - -/** - * Build artifact URL via CloudFront distribution in front of the S3 bucket. - */ -function buildArtifactUrl(s3Key: string): string { - return `https://${CLOUDFRONT_DOMAIN}/${s3Key}`; -} - -/** - * Upload a screenshot file to S3 for a specific Zephyr ID - */ -async function uploadScreenshotForZephyrId(screenshotPath: string, zephyrId: string): Promise { - const repoName = getRepoName(); - const dateStr = getDateString(); - - // Path structure: {repo}/{date}/{run-number}/{zephyr-id}/screenshot.png - const s3Key = `${repoName}/${dateStr}/${GITHUB_RUN_NUMBER}/${zephyrId}/screenshot.png`; - - console.log(` Uploading: ${zephyrId} -> s3://${S3_BUCKET}/${s3Key}`); - - const fileContent = fs.readFileSync(screenshotPath); - - const putCommand = new PutObjectCommand({ - Bucket: S3_BUCKET, - Key: s3Key, - Body: fileContent, - ContentType: "image/png", - // Using bucket policy for public access (no ACL) - Metadata: { - "zephyr-id": zephyrId, - "github-run-number": GITHUB_RUN_NUMBER, - "github-repository": GITHUB_REPOSITORY, - "uploaded-at": new Date().toISOString(), - }, - }); - - await s3Client.send(putCommand); - - return { - zephyrId, - s3Key, - url: buildArtifactUrl(s3Key), - }; -} - -/** - * Get screenshot files and extract Zephyr IDs from filenames - */ -function getScreenshotFiles(): { filename: string; zephyrId: string }[] { - if (!fs.existsSync(SCREENSHOT_DIR)) { - console.log(`[INFO] Screenshot directory not found: ${SCREENSHOT_DIR}`); - return []; - } - - return fs - .readdirSync(SCREENSHOT_DIR) - .filter((f) => f.endsWith(".png")) - .map((filename) => { - const match = filename.match(ZEPHYR_ID_FROM_FILENAME); - if (match) { - return { filename, zephyrId: match[1] }; - } - return null; - }) - .filter((f): f is { filename: string; zephyrId: string } => f !== null); -} - -// ============================================================================ -// Main -// ============================================================================ - -async function main(): Promise { - console.log("[INFO] Starting screenshot upload to S3"); - console.log(`[INFO] S3 bucket: ${S3_BUCKET}`); - console.log(`[INFO] AWS region: ${AWS_REGION}`); - console.log(`[INFO] Run number: ${GITHUB_RUN_NUMBER}`); - console.log(`[INFO] Screenshot directory: ${SCREENSHOT_DIR}\n`); - - // Get screenshot files with Zephyr IDs - const screenshotFiles = getScreenshotFiles(); - console.log(`[INFO] Found ${screenshotFiles.length} screenshots with Zephyr IDs\n`); - - if (screenshotFiles.length === 0) { - console.log("[INFO] No screenshots to upload. Creating empty mapping file."); - const emptyOutput: OutputFile = { - uploadedAt: new Date().toISOString(), - bucket: S3_BUCKET, - region: AWS_REGION, - runNumber: GITHUB_RUN_NUMBER, - repository: GITHUB_REPOSITORY, - screenshots: [], - }; - fs.writeFileSync(OUTPUT_FILE, JSON.stringify(emptyOutput, null, 2)); - return; - } - - const uploadedScreenshots: ScreenshotUrlMapping[] = []; - let successCount = 0; - let errorCount = 0; - - // Upload each screenshot - for (const { filename, zephyrId } of screenshotFiles) { - const screenshotPath = path.join(SCREENSHOT_DIR, filename); - - try { - const result = await uploadScreenshotForZephyrId(screenshotPath, zephyrId); - uploadedScreenshots.push(result); - successCount++; - console.log(` ✓ ${zephyrId}`); - } catch (error) { - console.error(` ✗ Failed to upload ${zephyrId}:`, error); - errorCount++; - } - } - - // Write output mapping file - const output: OutputFile = { - uploadedAt: new Date().toISOString(), - bucket: S3_BUCKET, - region: AWS_REGION, - runNumber: GITHUB_RUN_NUMBER, - repository: GITHUB_REPOSITORY, - screenshots: uploadedScreenshots, - }; - - fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2)); - console.log(`\n[INFO] Screenshot URL mapping written to: ${OUTPUT_FILE}`); - console.log(`[INFO] Uploaded: ${successCount}, Errors: ${errorCount}`); - - if (errorCount > 0) { - process.exit(1); - } -} - -main().catch((error) => { - console.error("[ERROR] Fatal error:", error); - process.exit(1); -}); diff --git a/scripts/zephyr/report-zephyr-results.ts b/scripts/zephyr/report-zephyr-results.ts index cce02473..959eb34c 100644 --- a/scripts/zephyr/report-zephyr-results.ts +++ b/scripts/zephyr/report-zephyr-results.ts @@ -24,27 +24,15 @@ import fs from "fs"; import path from "path"; import { XMLParser } from "fast-xml-parser"; +import { + ZephyrClient, + buildStepsPayload, + stripAnsi, + type ZephyrScreenshot, + type ZephyrStep, +} from "ts-lib-zephyr-nodejs"; import { Node, Project, type VariableDeclaration } from "ts-morph"; -// ============================================================================ -// Screenshot URL Mapping Types -// ============================================================================ - -interface ScreenshotUrlMapping { - zephyrId: string; - s3Key: string; - url: string; -} - -interface ScreenshotUrlsFile { - uploadedAt: string; - bucket: string; - region: string; - runNumber: string; - repository: string; - screenshots: ScreenshotUrlMapping[]; -} - // ============================================================================ // Story to Zephyr ID Mapping // ============================================================================ @@ -329,6 +317,9 @@ const ZEPHYR_BASE_URL = "https://api.zephyrscale.smartbear.com/v2"; const PROJECT_KEY = process.env.ZEPHYR_PROJECT_KEY || "SW"; const JUNIT_PATH = process.env.JUNIT_PATH || "test-results/storybook-junit.xml"; +/** Directory where the Storybook vitest setup writes per-story screenshots (`.png`). */ +const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR || "test-results/screenshots"; + function getZephyrToken(): string { const token = process.env.ZEPHYR_TOKEN; if (!token) { @@ -338,6 +329,16 @@ function getZephyrToken(): string { return token; } +/** + * Builds the shared Zephyr API client. All HTTP (auth headers, timeouts, error + * surfacing, execution/step endpoints) is delegated to `ts-lib-zephyr-nodejs`. + * `cycleKey` is resolved per-run and passed on each `postExecution` call, so a + * placeholder is fine here. + */ +function createZephyrClient(): ZephyrClient { + return new ZephyrClient({ baseUrl: ZEPHYR_BASE_URL, apiToken: getZephyrToken(), projectKey: PROJECT_KEY, cycleKey: "" }); +} + /** Gets the GitHub Actions run URL from environment variable */ function getGitHubActionsUrl(): string | null { // GITHUB_ACTIONS_URL is set by the workflow to point to the correct run @@ -345,50 +346,14 @@ function getGitHubActionsUrl(): string | null { return process.env.GITHUB_ACTIONS_URL || null; } -const SCREENSHOT_URLS_PATH = process.env.SCREENSHOT_URLS_PATH || "test-results/screenshot-urls.json"; - -/** - * Loads screenshot URLs from the mapping file created by upload-test-screenshots.ts - * Returns null if the file doesn't exist or is invalid - */ -function loadScreenshotUrls(): ScreenshotUrlsFile | null { - const screenshotUrlsPath = path.resolve(process.cwd(), SCREENSHOT_URLS_PATH); - - if (!fs.existsSync(screenshotUrlsPath)) { - console.log("[INFO] No screenshot URLs file found (screenshots may not have been uploaded)"); - return null; - } - - try { - const content = fs.readFileSync(screenshotUrlsPath, "utf-8"); - const data = JSON.parse(content) as ScreenshotUrlsFile; - - if (!data.screenshots || data.screenshots.length === 0) { - console.log("[INFO] Screenshot URLs file exists but contains no screenshots"); - return null; - } - - console.log(`[INFO] Loaded ${data.screenshots.length} screenshot URLs from ${screenshotUrlsPath}`); - return data; - } catch (error) { - console.error("[WARN] Failed to parse screenshot URLs file:", error); - return null; - } -} - /** - * Formats a screenshot URL for inclusion in test execution comment + * Resolves the on-disk path to a test case's screenshot, or null when absent. + * The Storybook vitest setup (`.storybook/vitest.setup.ts`) captures one PNG per + * story named `.png`; CI ships this directory alongside the JUnit report. */ -function formatScreenshotLinkComment(screenshotUrl: string): string { - return `Test Screenshot: ${screenshotUrl}`; -} - -/** - * Finds the screenshot URL for a specific Zephyr test case - */ -function findScreenshotForTestCase(screenshotUrls: ScreenshotUrlsFile, testCaseKey: string): string | null { - const screenshot = screenshotUrls.screenshots.find((s) => s.zephyrId === testCaseKey); - return screenshot?.url || null; +function findScreenshotPath(testCaseKey: string): string | null { + const p = path.resolve(process.cwd(), SCREENSHOT_DIR, `${testCaseKey}.png`); + return fs.existsSync(p) ? p : null; } /** Validates that a cache file path is safe (no path traversal) */ @@ -419,18 +384,13 @@ export function sanitizeBranchName(branch: string): string { return branch.replace(/[^a-zA-Z0-9\-_/]/g, "-"); } -/** Creates a new test cycle and returns its key */ -async function createTestCycle(name: string): Promise { - const url = `${ZEPHYR_BASE_URL}/testcycles`; - const response = await fetch(url, { - method: "POST", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - body: JSON.stringify({ projectKey: PROJECT_KEY, name }), - }); - if (!response.ok) { - throw new Error(`Failed to create test cycle: HTTP ${response.status}`); - } - const data = await response.json(); +/** + * Creates a new test cycle and returns its key. + * The library's `ZephyrClient` has no cycle-creation method, so we use its generic + * `request()` — this still routes through the shared auth/timeout/error handling. + */ +async function createTestCycle(client: ZephyrClient, name: string): Promise { + const data = await client.request<{ key: string }>("POST", "/testcycles", { projectKey: PROJECT_KEY, name }); return data.key; } @@ -438,7 +398,7 @@ async function createTestCycle(name: string): Promise { * Determines which test cycle to use based on environment variables. * Returns the cycle key (existing or newly created) and the source. */ -async function resolveCycleKey(): Promise<{ cycleKey: string; source: string }> { +async function resolveCycleKey(client: ZephyrClient): Promise<{ cycleKey: string; source: string }> { const cycleKey = process.env.ZEPHYR_CYCLE_KEY?.trim(); const branch = process.env.ZEPHYR_BRANCH?.trim(); const cacheFile = process.env.ZEPHYR_CYCLE_CACHE_FILE || ".zephyr-cycle-key"; @@ -459,7 +419,7 @@ async function resolveCycleKey(): Promise<{ cycleKey: string; source: string }> const safeBranch = sanitizeBranchName(branch); const cycleName = `React UI Lib Storybook Tests - ${safeBranch}`; console.log(`[INFO] Creating test cycle: ${cycleName}`); - const newKey = await createTestCycle(cycleName); + const newKey = await createTestCycle(client, cycleName); console.log(`[SUCCESS] Created test cycle: ${newKey}`); // Cache the key for subsequent runs @@ -595,61 +555,63 @@ export function parseJUnitXML( return results; } -async function reportTestExecution( - cycleKey: string, - result: TestResult, - screenshotUrls: ScreenshotUrlsFile | null, -): Promise { - const url = `${ZEPHYR_BASE_URL}/testexecutions`; - const body: { - projectKey: string; - testCycleKey: string; - testCaseKey: string; - statusName: string; - executionTime?: number; - comment?: string; - } = { - projectKey: PROJECT_KEY, - testCycleKey: cycleKey, - testCaseKey: result.testCaseKey, - statusName: result.status, - }; - if (result.executionTime) body.executionTime = result.executionTime; - - // Build comment with GitHub Actions link and screenshot links if available +/** Builds the execution comment: result message + CI run link. */ +function buildExecutionComment(result: TestResult): string { const commentParts: string[] = []; + if (result.comment) commentParts.push(stripAnsi(result.comment)); - // Add test result comment (failure message, skip reason, etc.) - if (result.comment) { - commentParts.push(result.comment); - } - - // Add GitHub Actions link const githubUrl = getGitHubActionsUrl(); - if (githubUrl) { - commentParts.push(`View CI run: ${githubUrl}`); - } - - // Add screenshot link for this specific test case if available - if (screenshotUrls) { - const screenshotUrl = findScreenshotForTestCase(screenshotUrls, result.testCaseKey); - if (screenshotUrl) { - commentParts.push(formatScreenshotLinkComment(screenshotUrl)); - } - } + if (githubUrl) commentParts.push(`View CI run: ${githubUrl}`); - if (commentParts.length > 0) { - body.comment = commentParts.join("\n\n"); - } + return commentParts.join("\n\n"); +} - const response = await fetch(url, { - method: "POST", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - throw new Error(`Failed to report execution for ${result.testCaseKey}: HTTP ${response.status}`); - } +/** + * Reports one test execution to Zephyr and embeds the captured story screenshot + * into every step of that execution. + * + * Flow (mirrors the library's own StorybookZephyrReporter): + * 1. POST the execution (creates it + baseline testScriptResults). + * 2. Look up the test case's step count and attach the same PNG to each step via + * `buildStepsPayload` (base64 `` embedded in each step's actualResult). + * 3. PUT the step payload onto the execution. + * Skipped ("Not Executed") tests get no step-image PUT. + */ +async function reportTestExecution(client: ZephyrClient, cycleKey: string, result: TestResult): Promise { + const comment = buildExecutionComment(result); + const stepCount = Math.max(1, await client.getStepCount(result.testCaseKey)); + + const { key: executionKey } = await client.postExecution( + PROJECT_KEY, + result.testCaseKey, + cycleKey, + result.status, + comment, + // actualResult: the last step's baseline result before screenshots are merged in + result.status === "Pass" ? "Story rendered successfully" : (result.comment ?? result.status), + result.executionTime, + stepCount, + ); + + // Embed the captured screenshot into every step (per-step evidence). + if (result.status === "Not Executed" || !executionKey) return; + + const screenshotPath = findScreenshotPath(result.testCaseKey); + if (!screenshotPath) return; + + const failed = result.status === "Fail"; + const localSteps: ZephyrStep[] = Array.from({ length: stepCount }, () => ({ + title: result.testCaseKey, + failed, + ...(failed && result.comment ? { errorMessage: stripAnsi(result.comment) } : {}), + })); + const screenshots: ZephyrScreenshot[] = Array.from({ length: stepCount }, (_, i) => ({ + stepIndex: i, + filePath: screenshotPath, + })); + + const steps = buildStepsPayload(stepCount, localSteps, screenshots); + await client.putExecutionSteps(executionKey, steps); } async function main(): Promise { @@ -671,11 +633,10 @@ async function main(): Promise { return; } - // Load screenshot URLs if available (from S3 upload script) - const screenshotUrls = loadScreenshotUrls(); + const client = createZephyrClient(); // Determine the test cycle to use - const { cycleKey, source } = await resolveCycleKey(); + const { cycleKey, source } = await resolveCycleKey(client); console.log(`[INFO] Using test cycle: ${cycleKey} (${source})\n`); let passCount = 0, @@ -684,7 +645,7 @@ async function main(): Promise { for (const result of results) { try { - await reportTestExecution(cycleKey, result, screenshotUrls); + await reportTestExecution(client, cycleKey, result); if (result.status === "Pass") passCount++; else failCount++; console.log(` [${result.status.toUpperCase()}] ${result.testCaseKey}`); diff --git a/scripts/zephyr/sync-storybook-zephyr.ts b/scripts/zephyr/sync-storybook-zephyr.ts index efa78f78..3d3f0d9a 100644 --- a/scripts/zephyr/sync-storybook-zephyr.ts +++ b/scripts/zephyr/sync-storybook-zephyr.ts @@ -16,6 +16,7 @@ import fs from "fs"; import path from "path"; +import { ZephyrClient } from "ts-lib-zephyr-nodejs"; import { Project, Node } from "ts-morph"; import type { VariableDeclaration } from "ts-morph"; @@ -55,11 +56,6 @@ export interface DuplicateZephyrId { stories: Pick[]; } -interface ZephyrFolder { - id: string; - name: string; - parentId?: string; -} interface FolderCache { [key: string]: string | null; } @@ -88,6 +84,24 @@ function getZephyrToken(): string { } return token; } + +/** + * Lazily-constructed shared Zephyr API client. All HTTP (auth headers, timeouts, + * error surfacing, pagination helpers) is delegated to `ts-lib-zephyr-nodejs`. + * Provisioning never posts executions, so `cycleKey` is left blank. + */ +let zephyrClient: ZephyrClient | null = null; +function getClient(): ZephyrClient { + if (!zephyrClient) { + zephyrClient = new ZephyrClient({ + baseUrl: ZEPHYR_BASE_URL, + apiToken: getZephyrToken(), + projectKey: PROJECT_KEY, + cycleKey: "", + }); + } + return zephyrClient; +} const ZEPHYR_LABELS = process.env.ZEPHYR_LABELS?.split(",") .map((l) => l.trim()) .filter(Boolean) || ["storybook", "vitest", "automated"]; @@ -510,18 +524,9 @@ async function getTestCase(testCaseKey: string): Promise { const cached = testCaseDetailCache.get(testCaseKey); if (cached) return cached; - const url = `${ZEPHYR_BASE_URL}/testcases/${testCaseKey}`; - const response = await fetch(url, { - method: "GET", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch test case ${testCaseKey}: ${response.status} ${errorText}`); - } - - const testCase = (await response.json()) as ZephyrTestCase; + // The library's typed `getTestCase` omits objective/labels (needed for reuse + // matching), so use the generic request to fetch the full test case body. + const testCase = await getClient().request("GET", `/testcases/${testCaseKey}`); testCaseDetailCache.set(testCaseKey, testCase); return testCase; } @@ -529,23 +534,16 @@ async function getTestCase(testCaseKey: string): Promise { async function getGeneratedTestCases(): Promise { if (generatedTestCaseCache) return generatedTestCaseCache; + const client = getClient(); const testCases: ZephyrTestCase[] = []; let startAt = 0; let fetchedCount = 0; while (true) { - const url = `${ZEPHYR_BASE_URL}/testcases?projectKey=${PROJECT_KEY}&startAt=${startAt}&maxResults=${TEST_CASE_PAGE_SIZE}`; - const response = await fetch(url, { - method: "GET", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to list test cases: ${response.status} ${errorText}`); - } - - const page = (await response.json()) as ZephyrTestCaseListResponse; + const page = await client.request( + "GET", + `/testcases?projectKey=${PROJECT_KEY}&startAt=${startAt}&maxResults=${TEST_CASE_PAGE_SIZE}`, + ); const values = page.values ?? []; fetchedCount += values.length; testCases.push(...values.filter(hasConfiguredLabels)); @@ -562,20 +560,10 @@ async function getExecutionCount(testCaseKey: string): Promise { const cached = executionCountCache.get(testCaseKey); if (cached !== undefined) return cached; - const url = `${ZEPHYR_BASE_URL}/testexecutions?projectKey=${PROJECT_KEY}&testCase=${encodeURIComponent( - testCaseKey, - )}&maxResults=1`; - const response = await fetch(url, { - method: "GET", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch executions for ${testCaseKey}: ${response.status} ${errorText}`); - } - - const page = (await response.json()) as { total?: number; values?: unknown[] }; + const page = await getClient().request<{ total?: number; values?: unknown[] }>( + "GET", + `/testexecutions?projectKey=${PROJECT_KEY}&testCase=${encodeURIComponent(testCaseKey)}&maxResults=1`, + ); const count = page.total ?? page.values?.length ?? 0; executionCountCache.set(testCaseKey, count); return count; @@ -614,39 +602,35 @@ async function findReusableTestCase(testName: string, objective: string): Promis async function getFolders(): Promise { if (Object.keys(folderIdCache).length > 0) return folderIdCache; - const url = `${ZEPHYR_BASE_URL}/folders?projectKey=${PROJECT_KEY}&folderType=TEST_CASE&maxResults=100`; - const response = await fetch(url, { - method: "GET", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - }); - if (!response.ok) { + + let folders: Array<{ id: number; name: string; parentId: number | null }>; + try { + folders = await getClient().listFolders(PROJECT_KEY, "TEST_CASE"); + } catch { console.warn("Could not fetch folders"); return {}; } - const data = await response.json(); - const folders: ZephyrFolder[] = data.values || []; // Find or create the root folder - let rootFolder = folders.find((f) => f.name === FOLDER_PREFIX && !f.parentId); - if (!rootFolder) { + let rootFolderId = folders.find((f) => f.name === FOLDER_PREFIX && !f.parentId)?.id; + if (rootFolderId === undefined) { try { - rootFolder = await createFolder(FOLDER_PREFIX); + rootFolderId = await createFolder(FOLDER_PREFIX); console.log(`[INFO] Created root folder: ${FOLDER_PREFIX}`); } catch (e: unknown) { console.warn(`[WARN] Could not create root folder ${FOLDER_PREFIX}:`, e instanceof Error ? e.message : String(e)); return {}; } } - const rootFolderId = rootFolder.id; for (const [compType, folderName] of Object.entries(FOLDER_MAPPING)) { const existing = folders.find((f) => f.name === folderName && f.parentId === rootFolderId); if (existing) { - folderIdCache[compType] = existing.id; + folderIdCache[compType] = String(existing.id); } else { try { - const newF = await createFolder(folderName, rootFolderId); - folderIdCache[compType] = newF.id; + const newFolderId = await createFolder(folderName, rootFolderId); + folderIdCache[compType] = String(newFolderId); console.log(`[INFO] Created subfolder: ${FOLDER_PREFIX}/${folderName}`); } catch (e: unknown) { console.warn(`[WARN] Could not create subfolder ${folderName}:`, e instanceof Error ? e.message : String(e)); @@ -657,24 +641,9 @@ async function getFolders(): Promise { return folderIdCache; } -async function createFolder(folderName: string, parentId?: string): Promise { - const url = `${ZEPHYR_BASE_URL}/folders`; - const body: { projectKey: string; name: string; folderType: string; parentId?: string } = { - projectKey: PROJECT_KEY, - name: folderName, - folderType: "TEST_CASE", - }; - if (parentId) body.parentId = parentId; - const response = await fetch(url, { - method: "POST", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to create folder: ${response.status} ${errorText}`); - } - return await response.json(); +/** Creates a TEST_CASE folder and returns its numeric id. */ +async function createFolder(folderName: string, parentId?: number): Promise { + return getClient().createFolder(PROJECT_KEY, folderName, "TEST_CASE", parentId ?? null); } async function getFolderId(componentType: string): Promise { @@ -683,7 +652,8 @@ async function getFolderId(componentType: string): Promise { } async function createTestCase(testName: string, objective: string, folderId: string | null): Promise { - const url = `${ZEPHYR_BASE_URL}/testcases`; + // Uses the generic request (not the library's typed createTestCase) to preserve + // this repo's custom objective format and label set. const body: { projectKey: string; name: string; objective: string; labels: string[]; folderId?: string } = { projectKey: PROJECT_KEY, name: testName, @@ -691,44 +661,13 @@ async function createTestCase(testName: string, objective: string, folderId: str labels: ZEPHYR_LABELS, }; if (folderId) body.folderId = folderId; - const response = await fetch(url, { - method: "POST", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to create test case: ${response.status} ${errorText}`); - } - return await response.json(); + return getClient().request("POST", "/testcases", body); } /** Uploads test steps to a Zephyr test case (overwrites existing steps) */ async function uploadTestSteps(testCaseKey: string, steps: TestStep[]): Promise { if (steps.length === 0) return; - - const url = `${ZEPHYR_BASE_URL}/testcases/${testCaseKey}/teststeps`; - const body = { - mode: "OVERWRITE", - items: steps.map((step) => ({ - inline: { - description: step.description, - testData: step.testData, - expectedResult: step.expectedResult, - }, - })), - }; - - const response = await fetch(url, { - method: "POST", - headers: { Authorization: `Bearer ${getZephyrToken()}`, "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to upload test steps: ${response.status} ${errorText}`); - } + await getClient().setTestSteps(testCaseKey, steps); } // ============================================================================ diff --git a/yarn.lock b/yarn.lock index 1cab197f..352f527b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -357,7 +357,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-s3@npm:^3.983.0, @aws-sdk/client-s3@npm:^3.985.0": +"@aws-sdk/client-s3@npm:^3.983.0": version: 3.1021.0 resolution: "@aws-sdk/client-s3@npm:3.1021.0" dependencies: @@ -980,22 +980,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/s3-request-presigner@npm:^3.985.0": - version: 3.1021.0 - resolution: "@aws-sdk/s3-request-presigner@npm:3.1021.0" - dependencies: - "@aws-sdk/signature-v4-multi-region": "npm:^3.996.15" - "@aws-sdk/types": "npm:^3.973.6" - "@aws-sdk/util-format-url": "npm:^3.972.8" - "@smithy/middleware-endpoint": "npm:^4.4.28" - "@smithy/protocol-http": "npm:^5.3.12" - "@smithy/smithy-client": "npm:^4.12.8" - "@smithy/types": "npm:^4.13.1" - tslib: "npm:^2.6.2" - checksum: 10c0/5475fbf393d43401ac600fd8e5f790554b2b3b5cb09264349c0795223922d32627f2348ba1af0de99674d085126e2c9ed852773b1fe65e5bb8114d49b57559e1 - languageName: node - linkType: hard - "@aws-sdk/signature-v4-multi-region@npm:^3.996.15": version: 3.996.15 resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.15" @@ -1057,18 +1041,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/util-format-url@npm:^3.972.8": - version: 3.972.8 - resolution: "@aws-sdk/util-format-url@npm:3.972.8" - dependencies: - "@aws-sdk/types": "npm:^3.973.6" - "@smithy/querystring-builder": "npm:^4.2.12" - "@smithy/types": "npm:^4.13.1" - tslib: "npm:^2.6.2" - checksum: 10c0/f0983faf602c21b960570057df141778200314b7f37cfffb8682a0ad8bbbb87a022761000379106d1f22c11c16f4f6ea76e4242aa7d7ba943ad9948ca5f95481 - languageName: node - linkType: hard - "@aws-sdk/util-locate-window@npm:^3.0.0": version: 3.965.5 resolution: "@aws-sdk/util-locate-window@npm:3.965.5" @@ -7888,11 +7860,9 @@ __metadata: dependencies: "@aws-sdk/client-athena": "npm:^3.975.0" "@aws-sdk/client-cloudwatch-logs": "npm:>=3.0.0 <4.0.0" - "@aws-sdk/client-s3": "npm:^3.985.0" "@aws-sdk/client-sqs": "npm:>=3.0.0 <4.0.0" "@aws-sdk/client-ssm": "npm:>=3.0.0 <4.0.0" "@aws-sdk/lib-storage": "npm:>=3.0.0 <4.0.0" - "@aws-sdk/s3-request-presigner": "npm:^3.985.0" "@base-ui/react": "npm:^1.2.0" "@commitlint/cli": "npm:^20.5.0" "@commitlint/config-conventional": "npm:^20.5.0" From 80f77167db8db2d7c99d025b722a4eada3b1a981 Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:52:09 -0400 Subject: [PATCH 2/6] fix: guard zephyr report job to same-repo triggering runs Resolve CodeQL actions/untrusted-checkout (critical): the workflow_run- triggered report job is privileged (ZEPHYR_TOKEN + JFrog secrets) and checks out workflow_run.head_sha. Require the triggering run to come from this repository so the checked-out ref is always trusted, never untrusted fork code. Mirrors the same-repo guard already used in zephyr-sync-storybook.yml. Refs: SW-1779 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/zephyr-report-results.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml index f6ec87d6..f50eb22f 100644 --- a/.github/workflows/zephyr-report-results.yml +++ b/.github/workflows/zephyr-report-results.yml @@ -25,11 +25,16 @@ permissions: jobs: report: - # Run on workflow_run (when Storybook Tests completes) or workflow_dispatch (manual) + # Run on workflow_run (when Storybook Tests completes) or workflow_dispatch (manual). + # For the workflow_run path, require the triggering run to originate from THIS + # repository (not a fork). This job is privileged (holds ZEPHYR_TOKEN + JFrog + # secrets) and checks out `workflow_run.head_sha`; gating on the head repository + # ensures that checked-out ref is always trusted, never untrusted fork code. if: > github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' || - github.event.workflow_run.conclusion == 'failure' + ((github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure') && + github.event.workflow_run.head_repository.full_name == github.repository) runs-on: ubuntu-latest steps: - name: Checkout repository From 6df9f497c772e28cf45236f341c6ebb63c256daf Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:53:59 -0400 Subject: [PATCH 3/6] fix: address review feedback on zephyr migration - JFrog npmrc auth: normalize the registry host (strip trailing slash) and always emit `///:_auth=...` so authentication works whether or not the secret URL ends with a slash. - Root-folder detection in sync-storybook-zephyr now matches `parentId === null` instead of a falsy check, so a folder id of 0 is never mistaken for a root folder. Refs: SW-1779 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/zephyr-report-results.yml | 7 +++++-- .github/workflows/zephyr-sync-storybook.yml | 7 +++++-- scripts/zephyr/sync-storybook-zephyr.ts | 5 +++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml index f50eb22f..812d27e0 100644 --- a/.github/workflows/zephyr-report-results.yml +++ b/.github/workflows/zephyr-report-results.yml @@ -68,11 +68,14 @@ jobs: JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} run: | set -euo pipefail - host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}" + # Normalize the host: strip scheme and any trailing slash so the auth + # line is always `///:_auth=...` regardless of whether the secret + # URL ends with a slash (npm requires the `/` before `:_auth`). + host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"; host="${host%/}" npmrc="$RUNNER_TEMP/.npmrc-jfrog" # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for # the read-only Artifactory user (same value used across the org's repos). - printf 'registry=%s\n//%s:_auth=%s\nalways-auth=true\n' \ + printf 'registry=%s\n//%s/:_auth=%s\nalways-auth=true\n' \ "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc" tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY") rm -rf node_modules/ts-lib-zephyr-nodejs diff --git a/.github/workflows/zephyr-sync-storybook.yml b/.github/workflows/zephyr-sync-storybook.yml index c1577c3f..858d13b9 100644 --- a/.github/workflows/zephyr-sync-storybook.yml +++ b/.github/workflows/zephyr-sync-storybook.yml @@ -53,11 +53,14 @@ jobs: JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} run: | set -euo pipefail - host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}" + # Normalize the host: strip scheme and any trailing slash so the auth + # line is always `///:_auth=...` regardless of whether the secret + # URL ends with a slash (npm requires the `/` before `:_auth`). + host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"; host="${host%/}" npmrc="$RUNNER_TEMP/.npmrc-jfrog" # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for # the read-only Artifactory user (same value used across the org's repos). - printf 'registry=%s\n//%s:_auth=%s\nalways-auth=true\n' \ + printf 'registry=%s\n//%s/:_auth=%s\nalways-auth=true\n' \ "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc" tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY") rm -rf node_modules/ts-lib-zephyr-nodejs diff --git a/scripts/zephyr/sync-storybook-zephyr.ts b/scripts/zephyr/sync-storybook-zephyr.ts index 3d3f0d9a..16acf3ab 100644 --- a/scripts/zephyr/sync-storybook-zephyr.ts +++ b/scripts/zephyr/sync-storybook-zephyr.ts @@ -611,8 +611,9 @@ async function getFolders(): Promise { return {}; } - // Find or create the root folder - let rootFolderId = folders.find((f) => f.name === FOLDER_PREFIX && !f.parentId)?.id; + // Find or create the root folder. Match on `parentId === null` (not a falsy + // check) so a legitimate folder id of 0 is never mistaken for a root folder. + let rootFolderId = folders.find((f) => f.name === FOLDER_PREFIX && f.parentId === null)?.id; if (rootFolderId === undefined) { try { rootFolderId = await createFolder(FOLDER_PREFIX); From 6a24063af7bfab3595b9527e59351fd0d250cf5b Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:04:07 -0400 Subject: [PATCH 4/6] fix: load ts-lib-zephyr-nodejs via runtime dynamic import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit vitest project includes scripts/**/*.test.ts, and those tests import the Zephyr scripts. With a static import of the JFrog-only ts-lib-zephyr-nodejs (which is not installed in the publish/CI jobs, only in the two Zephyr workflows), Vite's import-analysis failed to resolve it at test-collection time and broke the publish/build jobs. Load the library through a runtime dynamic import whose specifier is held in a variable (so bundlers/Vitest can't statically resolve it) and keep only type-only imports at module scope. The pure parsing/cache helpers the unit tests exercise never reach the client code path, so the suite now loads without the package installed; the library is loaded only when a real Zephyr run reaches the reporting/provisioning code. Also drop the `#!/usr/bin/env tsx` shebangs (the scripts run via `npx tsx`, not directly) — Vite prepended its client import onto the shebang line, causing a parse error once resolution succeeded. Refs: SW-1779 Co-Authored-By: Claude Opus 4.8 --- scripts/zephyr/report-zephyr-results.ts | 37 +++++++++++++++++-------- scripts/zephyr/sync-storybook-zephyr.ts | 26 ++++++++++------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/scripts/zephyr/report-zephyr-results.ts b/scripts/zephyr/report-zephyr-results.ts index 959eb34c..811ff7f7 100644 --- a/scripts/zephyr/report-zephyr-results.ts +++ b/scripts/zephyr/report-zephyr-results.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Report Storybook test execution results to Zephyr Scale * @@ -24,15 +23,27 @@ import fs from "fs"; import path from "path"; import { XMLParser } from "fast-xml-parser"; -import { - ZephyrClient, - buildStepsPayload, - stripAnsi, - type ZephyrScreenshot, - type ZephyrStep, -} from "ts-lib-zephyr-nodejs"; import { Node, Project, type VariableDeclaration } from "ts-morph"; +import type { ZephyrClient, ZephyrScreenshot, ZephyrStep } from "ts-lib-zephyr-nodejs"; + +/** + * Lazily loads the JFrog-only `ts-lib-zephyr-nodejs` package at runtime. + * + * The specifier is held in a variable so bundlers/Vitest can't statically + * resolve it at module-load time — this lets the unit test suite (which only + * exercises the pure parsing/cache helpers below) import this module even in + * environments where the package isn't installed. The library is only actually + * loaded when a Zephyr report run reaches the reporting code path. + */ +type ZephyrLib = typeof import("ts-lib-zephyr-nodejs"); +let zephyrLibPromise: Promise | null = null; +function getZephyrLib(): Promise { + const spec = "ts-lib-zephyr-nodejs"; + zephyrLibPromise ??= import(spec) as Promise; + return zephyrLibPromise; +} + // ============================================================================ // Story to Zephyr ID Mapping // ============================================================================ @@ -335,7 +346,8 @@ function getZephyrToken(): string { * `cycleKey` is resolved per-run and passed on each `postExecution` call, so a * placeholder is fine here. */ -function createZephyrClient(): ZephyrClient { +async function createZephyrClient(): Promise { + const { ZephyrClient } = await getZephyrLib(); return new ZephyrClient({ baseUrl: ZEPHYR_BASE_URL, apiToken: getZephyrToken(), projectKey: PROJECT_KEY, cycleKey: "" }); } @@ -556,7 +568,7 @@ export function parseJUnitXML( } /** Builds the execution comment: result message + CI run link. */ -function buildExecutionComment(result: TestResult): string { +function buildExecutionComment(result: TestResult, stripAnsi: (text: string) => string): string { const commentParts: string[] = []; if (result.comment) commentParts.push(stripAnsi(result.comment)); @@ -578,7 +590,8 @@ function buildExecutionComment(result: TestResult): string { * Skipped ("Not Executed") tests get no step-image PUT. */ async function reportTestExecution(client: ZephyrClient, cycleKey: string, result: TestResult): Promise { - const comment = buildExecutionComment(result); + const { buildStepsPayload, stripAnsi } = await getZephyrLib(); + const comment = buildExecutionComment(result, stripAnsi); const stepCount = Math.max(1, await client.getStepCount(result.testCaseKey)); const { key: executionKey } = await client.postExecution( @@ -633,7 +646,7 @@ async function main(): Promise { return; } - const client = createZephyrClient(); + const client = await createZephyrClient(); // Determine the test cycle to use const { cycleKey, source } = await resolveCycleKey(client); diff --git a/scripts/zephyr/sync-storybook-zephyr.ts b/scripts/zephyr/sync-storybook-zephyr.ts index 16acf3ab..c1ced325 100644 --- a/scripts/zephyr/sync-storybook-zephyr.ts +++ b/scripts/zephyr/sync-storybook-zephyr.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Sync Storybook stories to Zephyr Scale as test cases * @@ -16,9 +15,9 @@ import fs from "fs"; import path from "path"; -import { ZephyrClient } from "ts-lib-zephyr-nodejs"; import { Project, Node } from "ts-morph"; +import type { ZephyrClient } from "ts-lib-zephyr-nodejs"; import type { VariableDeclaration } from "ts-morph"; // ============================================================================ @@ -89,10 +88,17 @@ function getZephyrToken(): string { * Lazily-constructed shared Zephyr API client. All HTTP (auth headers, timeouts, * error surfacing, pagination helpers) is delegated to `ts-lib-zephyr-nodejs`. * Provisioning never posts executions, so `cycleKey` is left blank. + * + * The package specifier is held in a variable so bundlers/Vitest can't statically + * resolve this JFrog-only package at module-load time — the unit test suite only + * exercises the pure parsing helpers below and never constructs a client, so it + * can import this module even where the package isn't installed. */ let zephyrClient: ZephyrClient | null = null; -function getClient(): ZephyrClient { +async function getClient(): Promise { if (!zephyrClient) { + const spec = "ts-lib-zephyr-nodejs"; + const { ZephyrClient } = (await import(spec)) as typeof import("ts-lib-zephyr-nodejs"); zephyrClient = new ZephyrClient({ baseUrl: ZEPHYR_BASE_URL, apiToken: getZephyrToken(), @@ -526,7 +532,7 @@ async function getTestCase(testCaseKey: string): Promise { // The library's typed `getTestCase` omits objective/labels (needed for reuse // matching), so use the generic request to fetch the full test case body. - const testCase = await getClient().request("GET", `/testcases/${testCaseKey}`); + const testCase = await (await getClient()).request("GET", `/testcases/${testCaseKey}`); testCaseDetailCache.set(testCaseKey, testCase); return testCase; } @@ -534,7 +540,7 @@ async function getTestCase(testCaseKey: string): Promise { async function getGeneratedTestCases(): Promise { if (generatedTestCaseCache) return generatedTestCaseCache; - const client = getClient(); + const client = await getClient(); const testCases: ZephyrTestCase[] = []; let startAt = 0; let fetchedCount = 0; @@ -560,7 +566,7 @@ async function getExecutionCount(testCaseKey: string): Promise { const cached = executionCountCache.get(testCaseKey); if (cached !== undefined) return cached; - const page = await getClient().request<{ total?: number; values?: unknown[] }>( + const page = await (await getClient()).request<{ total?: number; values?: unknown[] }>( "GET", `/testexecutions?projectKey=${PROJECT_KEY}&testCase=${encodeURIComponent(testCaseKey)}&maxResults=1`, ); @@ -605,7 +611,7 @@ async function getFolders(): Promise { let folders: Array<{ id: number; name: string; parentId: number | null }>; try { - folders = await getClient().listFolders(PROJECT_KEY, "TEST_CASE"); + folders = await (await getClient()).listFolders(PROJECT_KEY, "TEST_CASE"); } catch { console.warn("Could not fetch folders"); return {}; @@ -644,7 +650,7 @@ async function getFolders(): Promise { /** Creates a TEST_CASE folder and returns its numeric id. */ async function createFolder(folderName: string, parentId?: number): Promise { - return getClient().createFolder(PROJECT_KEY, folderName, "TEST_CASE", parentId ?? null); + return (await getClient()).createFolder(PROJECT_KEY, folderName, "TEST_CASE", parentId ?? null); } async function getFolderId(componentType: string): Promise { @@ -662,13 +668,13 @@ async function createTestCase(testName: string, objective: string, folderId: str labels: ZEPHYR_LABELS, }; if (folderId) body.folderId = folderId; - return getClient().request("POST", "/testcases", body); + return (await getClient()).request("POST", "/testcases", body); } /** Uploads test steps to a Zephyr test case (overwrites existing steps) */ async function uploadTestSteps(testCaseKey: string, steps: TestStep[]): Promise { if (steps.length === 0) return; - await getClient().setTestSteps(testCaseKey, steps); + await (await getClient()).setTestSteps(testCaseKey, steps); } // ============================================================================ From 817a82c833fd4069c648fb0f88d681b01bf25a67 Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:47:56 -0400 Subject: [PATCH 5/6] fix: hardcode JFrog read registry URL in zephyr workflows JFROG_ARTIFACTORY_NPM_VIRTUAL_URL is an environment-scoped secret (artifactory-prod), so it does not resolve in the Zephyr jobs (which do not bind that environment). The virtual registry URL is infra info, not a credential, so hardcode it and keep only the org-level JFROG_ARTIFACTORY_READ_NPM_AUTH token as a secret. Refs: SW-1779 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/zephyr-report-results.yml | 7 ++++++- .github/workflows/zephyr-sync-storybook.yml | 7 ++++++- AGENTS.md | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml index 812d27e0..7c02aa19 100644 --- a/.github/workflows/zephyr-report-results.yml +++ b/.github/workflows/zephyr-report-results.yml @@ -64,7 +64,12 @@ jobs: # node_modules. Its only transitive need (ts-morph) is already installed. - name: Install ts-lib-zephyr-nodejs (JFrog) env: - JFROG_NPM_REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }} + # Read-only JFrog virtual registry URL. This is infra info, not a + # credential, so it is hardcoded rather than pulled from the + # environment-scoped JFROG_ARTIFACTORY_NPM_VIRTUAL_URL secret (which is + # only visible to jobs bound to the artifactory-prod environment). Only + # the read auth token comes from a secret (org-level, no environment). + JFROG_NPM_REGISTRY: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/ JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} run: | set -euo pipefail diff --git a/.github/workflows/zephyr-sync-storybook.yml b/.github/workflows/zephyr-sync-storybook.yml index 858d13b9..32758308 100644 --- a/.github/workflows/zephyr-sync-storybook.yml +++ b/.github/workflows/zephyr-sync-storybook.yml @@ -49,7 +49,12 @@ jobs: # node_modules. Its only transitive need (ts-morph) is already installed. - name: Install ts-lib-zephyr-nodejs (JFrog) env: - JFROG_NPM_REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }} + # Read-only JFrog virtual registry URL. This is infra info, not a + # credential, so it is hardcoded rather than pulled from the + # environment-scoped JFROG_ARTIFACTORY_NPM_VIRTUAL_URL secret (which is + # only visible to jobs bound to the artifactory-prod environment). Only + # the read auth token comes from a secret (org-level, no environment). + JFROG_NPM_REGISTRY: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/ JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} run: | set -euo pipefail diff --git a/AGENTS.md b/AGENTS.md index c70fb2e7..062dba34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ Convention: uses [Conventional Commits](https://www.conventionalcommits.org/) fo ## Zephyr Integration - Zephyr HTTP is handled by a shared internal `ts-lib-zephyr-nodejs` library (`ZephyrClient` + helpers). The repo's scripts are thin wrappers around it — JUnit parsing, story parsing/write-back, cycle resolution, and folder mapping stay local. -- **Dependency source:** the library lives only on TetraScience's JFrog Artifactory and is deliberately kept **out of `package.json` / `yarn.lock`** so public/external-contributor `yarn install` stays on the public npm registry. CI installs it as a leaf tarball inside the two Zephyr workflows (see `Install ts-lib-zephyr-nodejs (JFrog)` steps; needs `JFROG_ARTIFACTORY_NPM_VIRTUAL_URL` + `JFROG_ARTIFACTORY_READ_NPM_AUTH` secrets). For local script work, install it as a leaf: `npm pack ts-lib-zephyr-nodejs@0.4.0 --registry ` then extract the tgz into `node_modules/ts-lib-zephyr-nodejs` (do **not** `npm install --no-save` — it corrupts the Yarn `node_modules`). +- **Dependency source:** the library lives only on TetraScience's JFrog Artifactory and is deliberately kept **out of `package.json` / `yarn.lock`** so public/external-contributor `yarn install` stays on the public npm registry. CI installs it as a leaf tarball inside the two Zephyr workflows (see `Install ts-lib-zephyr-nodejs (JFrog)` steps; the read-only virtual registry URL is hardcoded and only the org-level `JFROG_ARTIFACTORY_READ_NPM_AUTH` token comes from a secret). For local script work, install it as a leaf: `npm pack ts-lib-zephyr-nodejs@0.4.0 --registry ` then extract the tgz into `node_modules/ts-lib-zephyr-nodejs` (do **not** `npm install --no-save` — it corrupts the Yarn `node_modules`). - Test results reported to Zephyr Scale via `scripts/zephyr/report-zephyr-results.ts`. Each story's screenshot is embedded (base64 ``) into every step of the execution via the library's `buildStepsPayload` + `putExecutionSteps` — there is no S3 upload; CI ships `test-results/screenshots/**` in the `storybook-junit-results` artifact for the report job to read. - Story-to-testcase sync handled by `scripts/zephyr/sync-storybook-zephyr.ts` - Test case IDs live in story parameters: `parameters.zephyr.testCaseId` From 0f230b684c76b3585f7ff32a772251e46de56955 Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:48:08 -0400 Subject: [PATCH 6/6] chore: use shared install-jfrog-npm-package action in zephyr workflows Replace the duplicated inline JFrog leaf-tarball install steps with the new tetrascience/ts-ci-cd-lib/install-jfrog-npm-package composite action. Pinned to the action's PR branch (@install-jfrog-npm-package-action) so it can be exercised before ts-ci-cd-lib#29 merges; switch to @main once that lands. Refs: SW-1779 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/zephyr-report-results.yml | 44 +++++++-------------- .github/workflows/zephyr-sync-storybook.yml | 44 +++++++-------------- 2 files changed, 28 insertions(+), 60 deletions(-) diff --git a/.github/workflows/zephyr-report-results.yml b/.github/workflows/zephyr-report-results.yml index 7c02aa19..03963b65 100644 --- a/.github/workflows/zephyr-report-results.yml +++ b/.github/workflows/zephyr-report-results.yml @@ -56,37 +56,21 @@ jobs: - name: Install dependencies run: yarn install --immutable - # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory. - # It is deliberately kept OUT of package.json / yarn.lock so public and - # external-contributor `yarn install` stays on the public npm registry. - # Fetch it here as a leaf tarball and extract into node_modules — this avoids - # `npm install`'s full-tree reconciliation, which corrupts the Yarn-managed - # node_modules. Its only transitive need (ts-morph) is already installed. + # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory + # and is deliberately kept OUT of package.json / yarn.lock so public and + # external-contributor `yarn install` stays on the public npm registry. The + # shared composite action fetches it as a leaf tarball into node_modules + # (its only transitive need, ts-morph, is already installed). + # NOTE: pinned to the action's PR branch for testing; switch to @main once + # tetrascience/ts-ci-cd-lib#29 merges. - name: Install ts-lib-zephyr-nodejs (JFrog) - env: - # Read-only JFrog virtual registry URL. This is infra info, not a - # credential, so it is hardcoded rather than pulled from the - # environment-scoped JFROG_ARTIFACTORY_NPM_VIRTUAL_URL secret (which is - # only visible to jobs bound to the artifactory-prod environment). Only - # the read auth token comes from a secret (org-level, no environment). - JFROG_NPM_REGISTRY: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/ - JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} - run: | - set -euo pipefail - # Normalize the host: strip scheme and any trailing slash so the auth - # line is always `///:_auth=...` regardless of whether the secret - # URL ends with a slash (npm requires the `/` before `:_auth`). - host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"; host="${host%/}" - npmrc="$RUNNER_TEMP/.npmrc-jfrog" - # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for - # the read-only Artifactory user (same value used across the org's repos). - printf 'registry=%s\n//%s/:_auth=%s\nalways-auth=true\n' \ - "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc" - tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY") - rm -rf node_modules/ts-lib-zephyr-nodejs - mkdir -p node_modules/ts-lib-zephyr-nodejs - tar -xzf "$tgz" -C node_modules/ts-lib-zephyr-nodejs --strip-components=1 - rm -f "$npmrc" "$tgz" + uses: tetrascience/ts-ci-cd-lib/install-jfrog-npm-package@install-jfrog-npm-package-action + with: + package: ts-lib-zephyr-nodejs + version: "0.4.0" + # Read-only virtual registry URL — infra info, not a credential. + registry-url: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/ + auth: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} - name: Download test results artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 diff --git a/.github/workflows/zephyr-sync-storybook.yml b/.github/workflows/zephyr-sync-storybook.yml index 32758308..2cb62a93 100644 --- a/.github/workflows/zephyr-sync-storybook.yml +++ b/.github/workflows/zephyr-sync-storybook.yml @@ -41,37 +41,21 @@ jobs: - name: Install dependencies run: yarn install --immutable - # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory. - # It is deliberately kept OUT of package.json / yarn.lock so public and - # external-contributor `yarn install` stays on the public npm registry. - # Fetch it here as a leaf tarball and extract into node_modules — this avoids - # `npm install`'s full-tree reconciliation, which corrupts the Yarn-managed - # node_modules. Its only transitive need (ts-morph) is already installed. + # ts-lib-zephyr-nodejs is published only to TetraScience's JFrog Artifactory + # and is deliberately kept OUT of package.json / yarn.lock so public and + # external-contributor `yarn install` stays on the public npm registry. The + # shared composite action fetches it as a leaf tarball into node_modules + # (its only transitive need, ts-morph, is already installed). + # NOTE: pinned to the action's PR branch for testing; switch to @main once + # tetrascience/ts-ci-cd-lib#29 merges. - name: Install ts-lib-zephyr-nodejs (JFrog) - env: - # Read-only JFrog virtual registry URL. This is infra info, not a - # credential, so it is hardcoded rather than pulled from the - # environment-scoped JFROG_ARTIFACTORY_NPM_VIRTUAL_URL secret (which is - # only visible to jobs bound to the artifactory-prod environment). Only - # the read auth token comes from a secret (org-level, no environment). - JFROG_NPM_REGISTRY: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/ - JFROG_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} - run: | - set -euo pipefail - # Normalize the host: strip scheme and any trailing slash so the auth - # line is always `///:_auth=...` regardless of whether the secret - # URL ends with a slash (npm requires the `/` before `:_auth`). - host="${JFROG_NPM_REGISTRY#https://}"; host="${host#http://}"; host="${host%/}" - npmrc="$RUNNER_TEMP/.npmrc-jfrog" - # JFROG_ARTIFACTORY_READ_NPM_AUTH is the base64 npm `_auth` identity for - # the read-only Artifactory user (same value used across the org's repos). - printf 'registry=%s\n//%s/:_auth=%s\nalways-auth=true\n' \ - "$JFROG_NPM_REGISTRY" "$host" "$JFROG_NPM_AUTH" > "$npmrc" - tgz=$(npm pack ts-lib-zephyr-nodejs@0.4.0 --userconfig "$npmrc" --registry "$JFROG_NPM_REGISTRY") - rm -rf node_modules/ts-lib-zephyr-nodejs - mkdir -p node_modules/ts-lib-zephyr-nodejs - tar -xzf "$tgz" -C node_modules/ts-lib-zephyr-nodejs --strip-components=1 - rm -f "$npmrc" "$tgz" + uses: tetrascience/ts-ci-cd-lib/install-jfrog-npm-package@install-jfrog-npm-package-action + with: + package: ts-lib-zephyr-nodejs + version: "0.4.0" + # Read-only virtual registry URL — infra info, not a credential. + registry-url: https://tetrascience.jfrog.io/artifactory/api/npm/ts-npm-virtual/ + auth: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }} - name: Run Zephyr sync env: