diff --git a/actions/playwright-test-healer/scripts/collect-log-hard-failures.js b/actions/playwright-test-healer/scripts/collect-log-hard-failures.js index 13dbdaa05..60e7b3d48 100644 --- a/actions/playwright-test-healer/scripts/collect-log-hard-failures.js +++ b/actions/playwright-test-healer/scripts/collect-log-hard-failures.js @@ -68,9 +68,17 @@ function listFailedJobIds(repo, runId) { } function collectHardFailureTitles(repo, runId) { + const { listFailedJobsAcrossAttempts } = require('./run-attempts'); const titles = new Set(); - for (const jobId of listFailedJobIds(repo, runId)) { + // Collect failures from all attempts of the run, not just the latest + const failedJobs = listFailedJobsAcrossAttempts(repo, runId); + + console.log( + `Collecting hard failures from ${failedJobs.length} failed job(s) across ${Math.max(...failedJobs.map((j) => j.attempt), 1)} attempt(s)`, + ); + + for (const { attempt, jobId } of failedJobs) { const log = fetchJobLog(repo, jobId); for (const title of parseHardFailureTitles(log)) { @@ -81,6 +89,38 @@ function collectHardFailureTitles(repo, runId) { return [...titles]; } +/** + * Collects hard failures with the attempt numbers they failed in. + * Returns a map of test title -> array of attempt numbers where it failed. + */ +function collectHardFailuresWithAttempts(repo, runId) { + const { listFailedJobsAcrossAttempts } = require('./run-attempts'); + const failuresByTest = new Map(); + + const failedJobs = listFailedJobsAcrossAttempts(repo, runId); + + console.log( + `Collecting hard failures with attempts from ${failedJobs.length} failed job(s) across ${Math.max(...failedJobs.map((j) => j.attempt), 1)} attempt(s)`, + ); + + for (const { attempt, jobId } of failedJobs) { + const log = fetchJobLog(repo, jobId); + const titles = parseHardFailureTitles(log); + + for (const title of titles) { + if (!failuresByTest.has(title)) { + failuresByTest.set(title, []); + } + const attempts = failuresByTest.get(title); + if (!attempts.includes(attempt)) { + attempts.push(attempt); + } + } + } + + return failuresByTest; +} + function main() { const repo = process.env.GITHUB_REPOSITORY; const runId = process.env.SOURCE_RUN_ID; @@ -108,4 +148,8 @@ if (require.main === module) { } } -module.exports = { DEFAULT_HARD_FAILURES_PATH }; +module.exports = { + DEFAULT_HARD_FAILURES_PATH, + collectHardFailuresWithAttempts, + parseHardFailureTitles, +}; diff --git a/actions/playwright-test-healer/scripts/find-test-evidence.js b/actions/playwright-test-healer/scripts/find-test-evidence.js index 143142ca5..87b95cc6f 100644 --- a/actions/playwright-test-healer/scripts/find-test-evidence.js +++ b/actions/playwright-test-healer/scripts/find-test-evidence.js @@ -48,6 +48,11 @@ function isShardArtifact(artifact) { /** * Trace-backed evidence beats log-only evidence, because the healer skill * can replay a trace but can only read an `error-context.md`. + * + * When choosing between evidence from different attempts, prefer: + * 1. Evidence with traces over log-only evidence + * 2. Evidence from later attempts (more recent failures) + * 3. Earlier shard indices (arbitrary tiebreaker) */ function pickBestEvidence(candidates) { const usable = (candidates || []).filter( @@ -59,9 +64,17 @@ function pickBestEvidence(candidates) { } const sorted = [...usable].sort((a, b) => { + // Prefer trace-backed evidence if (a.hasTrace !== b.hasTrace) { return a.hasTrace ? -1 : 1; } + // Prefer later attempts (more recent) + const attemptA = a.attempt || 1; + const attemptB = b.attempt || 1; + if (attemptA !== attemptB) { + return attemptB - attemptA; + } + // Tiebreaker: shard index return String(a.shardIndex).localeCompare(String(b.shardIndex)); }); @@ -72,21 +85,32 @@ function pickBestEvidence(candidates) { * Groups already-discovered result directories by the shard artifact they * came from, so the shard index travels with the evidence. Verification * needs it to re-run the test the way that shard ran it. + * + * When a run has been re-run, directories from different attempts of the + * same artifact name are kept separate, annotated with their attempt number. */ function buildEvidenceCandidates(testName, resultDirs) { - const byArtifact = new Map(); + // Key by artifact name + attempt to keep attempts separate + const byArtifactAndAttempt = new Map(); for (const dir of resultDirs || []) { const artifactName = dir.artifactName || ''; - if (!byArtifact.has(artifactName)) { - byArtifact.set(artifactName, []); + const attempt = dir.attempt || 1; + const key = `${artifactName}::attempt-${attempt}`; + + if (!byArtifactAndAttempt.has(key)) { + byArtifactAndAttempt.set(key, { + artifactName, + attempt, + dirs: [], + }); } - byArtifact.get(artifactName).push(dir); + byArtifactAndAttempt.get(key).dirs.push(dir); } const candidates = []; - for (const [artifactName, dirs] of byArtifact) { + for (const { artifactName, attempt, dirs } of byArtifactAndAttempt.values()) { const matched = matchResultDirectories(testName, dirs); if (matched.length === 0) { @@ -95,6 +119,7 @@ function buildEvidenceCandidates(testName, resultDirs) { candidates.push({ artifactName, + attempt, shardIndex: shardIndexFromArtifactName(artifactName), matchedDirs: matched.map((dir) => dir.dirName), hasTrace: matched.some((dir) => dir.hasTrace), @@ -127,16 +152,8 @@ function pickShardArtifacts(artifacts, profile = loadProfile()) { } function listShardArtifacts(repo, runId, profile) { - const artifacts = gh([ - 'api', - `repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`, - '--paginate', - '--jq', - '.artifacts[] | tojson', - ]) - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)); + const { listArtifactsAcrossAttempts } = require('./run-attempts'); + const artifacts = listArtifactsAcrossAttempts(repo, runId); return pickShardArtifacts(artifacts, profile); } @@ -236,29 +253,23 @@ function listRecentRunIds(repo, profile) { /** * Reads one shard artifact's directory names without keeping the download: * only the layout matters here, and trace zips are large. + * + * @param {string} repo - Repository in "owner/repo" format + * @param {object} artifact - Artifact metadata with {id, name, attempt} + * @returns {Array<{dirName: string, artifactName: string, attempt: number, hasTrace: boolean}>} */ -function readArtifactResultDirs(repo, runId, artifactName) { +function readArtifactResultDirs(repo, artifact) { + const { downloadArtifactById } = require('./run-attempts'); const downloadDir = fs.mkdtempSync( path.join(os.tmpdir(), 'heal-evidence-'), ); try { - execFileSync( - 'gh', - [ - 'run', - 'download', - String(runId), - '--repo', - repo, - '-n', - artifactName, - '-D', - downloadDir, - ], - { stdio: 'inherit' }, + downloadArtifactById(repo, artifact.id, downloadDir); + } catch (error) { + console.log( + `::warning::Could not download artifact ${artifact.name} (attempt ${artifact.attempt}, id ${artifact.id}): ${error.message}`, ); - } catch { fs.rmSync(downloadDir, { recursive: true, force: true }); return []; } @@ -269,7 +280,8 @@ function readArtifactResultDirs(repo, runId, artifactName) { .filter((entry) => entry.isDirectory()) .map((entry) => ({ dirName: entry.name, - artifactName, + artifactName: artifact.name, + attempt: artifact.attempt, hasTrace: hasTraceInDirectory( path.join(downloadDir, entry.name), ), @@ -286,10 +298,62 @@ function findEvidenceInRun(repo, runId, testName, profile) { return null; } + // Determine which attempts this test actually failed in by checking job logs + const { + collectHardFailuresWithAttempts, + } = require('./collect-log-hard-failures'); + const failuresByTest = collectHardFailuresWithAttempts(repo, runId); + const failedAttempts = failuresByTest.get(testName) || []; + + if (failedAttempts.length > 0) { + console.log( + `Test "${testName}" failed in attempt(s): ${failedAttempts.join(', ')}`, + ); + } else { + console.log( + `::warning::Test "${testName}" not found in job logs; will check all attempts' artifacts`, + ); + } + const resultDirs = []; + // Group artifacts by name to identify duplicates across attempts + const artifactsByName = new Map(); for (const artifact of artifacts) { - resultDirs.push(...readArtifactResultDirs(repo, runId, artifact.name)); + if (!artifactsByName.has(artifact.name)) { + artifactsByName.set(artifact.name, []); + } + artifactsByName.get(artifact.name).push(artifact); + } + + // For each artifact name, prioritize attempts where the test failed + for (const [name, versions] of artifactsByName) { + // Sort: failed attempts first (latest first), then other attempts (latest first) + const sorted = [...versions].sort((a, b) => { + const aFailed = failedAttempts.includes(a.attempt); + const bFailed = failedAttempts.includes(b.attempt); + + if (aFailed && !bFailed) return -1; + if (!aFailed && bFailed) return 1; + + // Both failed or both didn't fail: prefer later attempt + return b.attempt - a.attempt; + }); + + for (const artifact of sorted) { + const dirs = readArtifactResultDirs(repo, artifact); + resultDirs.push(...dirs); + + // If we found evidence for this test in this artifact, we can stop + // checking other attempts of this same artifact name + const evidence = findEvidenceInResultDirs(testName, dirs); + if (evidence) { + console.log( + `Found evidence in attempt ${artifact.attempt} artifact "${artifact.name}"`, + ); + break; + } + } } const evidence = findEvidenceInResultDirs(testName, resultDirs); diff --git a/actions/playwright-test-healer/scripts/restore-nightly-playwright-builds.js b/actions/playwright-test-healer/scripts/restore-nightly-playwright-builds.js index 5a1d081c8..f3142e1c1 100644 --- a/actions/playwright-test-healer/scripts/restore-nightly-playwright-builds.js +++ b/actions/playwright-test-healer/scripts/restore-nightly-playwright-builds.js @@ -72,39 +72,37 @@ function planBuildRestore(artifacts) { } function listRunArtifacts(repo, runId) { - return execFileSync( - 'gh', - [ - 'api', - `repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`, - '--paginate', - '--jq', - '.artifacts[] | tojson', - ], - { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, - ) - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)); + const { listArtifactsAcrossAttempts } = require('./run-attempts'); + // Get artifacts from all attempts, but prefer the latest when there are duplicates + const allArtifacts = listArtifactsAcrossAttempts(repo, runId); + + // For builds, we typically want the latest attempt's artifacts + // Group by name and take the one with the highest attempt number + const latestByName = new Map(); + for (const artifact of allArtifacts) { + const existing = latestByName.get(artifact.name); + if (!existing || artifact.attempt > existing.attempt) { + latestByName.set(artifact.name, artifact); + } + } + + return Array.from(latestByName.values()); } function downloadArtifact(repo, runId, artifactName, destinationDir) { fs.mkdirSync(destinationDir, { recursive: true }); - execFileSync( - 'gh', - [ - 'run', - 'download', - String(runId), - '--repo', - repo, - '-n', - artifactName, - '-D', - destinationDir, - ], - { stdio: 'inherit' }, - ); + + // When downloading by name after a re-run, gh may get confused by duplicate names. + // Use our helper that downloads by artifact ID instead. + const artifacts = listRunArtifacts(repo, runId); + const artifact = artifacts.find((a) => a.name === artifactName); + + if (!artifact) { + throw new Error(`Artifact ${artifactName} not found in run ${runId}`); + } + + const { downloadArtifactById } = require('./run-attempts'); + downloadArtifactById(repo, artifact.id, destinationDir); } function walkFiles(rootDir, visitor) { diff --git a/actions/playwright-test-healer/scripts/run-attempts.js b/actions/playwright-test-healer/scripts/run-attempts.js new file mode 100644 index 000000000..6732124c3 --- /dev/null +++ b/actions/playwright-test-healer/scripts/run-attempts.js @@ -0,0 +1,157 @@ +'use strict'; + +/** + * Handles GitHub Actions workflow run attempts. When a run is re-run, + * GitHub increments the attempt number but keeps the same run ID. + * Artifacts and jobs belong to specific attempts, and failures from + * earlier attempts must be considered when selecting healing candidates. + */ + +const { gh } = require('./gh'); + +/** + * Returns metadata about a workflow run's attempts: the total count + * and details for each attempt. + * + * @param {string} repo - Repository in "owner/repo" format + * @param {string|number} runId - Workflow run ID + * @returns {{runAttempt: number, attempts: Array<{attempt: number, jobs: Array}>}} + */ +function getRunAttemptInfo(repo, runId) { + const run = JSON.parse( + gh([ + 'api', + `repos/${repo}/actions/runs/${runId}`, + '--jq', + '{runAttempt: .run_attempt}', + ]), + ); + + // The run's run_attempt field holds the highest (latest) attempt number + const attemptCount = run.runAttempt || 1; + + return { + runAttempt: attemptCount, + attempts: Array.from({ length: attemptCount }, (_, i) => ({ + attempt: i + 1, + })), + }; +} + +/** + * Lists all failed jobs across all attempts of a run. + * Returns an array with {attempt, jobId} for each failed job. + * + * @param {string} repo - Repository in "owner/repo" format + * @param {string|number} runId - Workflow run ID + * @returns {Array<{attempt: number, jobId: string}>} + */ +function listFailedJobsAcrossAttempts(repo, runId) { + const { runAttempt } = getRunAttemptInfo(repo, runId); + const failedJobs = []; + + for (let attempt = 1; attempt <= runAttempt; attempt++) { + const jobIds = gh([ + 'api', + `repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs`, + '--paginate', + '--jq', + '.jobs[] | select(.conclusion == "failure") | .id', + ]) + .split('\n') + .map((id) => id.trim()) + .filter(Boolean); + + failedJobs.push( + ...jobIds.map((jobId) => ({ + attempt, + jobId, + })), + ); + } + + return failedJobs; +} + +/** + * Lists all artifacts from a run, annotated with which attempt created them. + * The artifacts API returns artifacts from all attempts, each with a + * workflow_run.run_attempt field. + * + * @param {string} repo - Repository in "owner/repo" format + * @param {string|number} runId - Workflow run ID + * @returns {Array<{name: string, id: number, attempt: number, expired: boolean, size_in_bytes: number}>} + */ +function listArtifactsAcrossAttempts(repo, runId) { + const raw = gh([ + 'api', + `repos/${repo}/actions/runs/${runId}/artifacts`, + '--paginate', + '--jq', + '.artifacts[] | {name, id, expired, size_in_bytes, attempt: .workflow_run.run_attempt} | tojson', + ]); + + return raw + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +/** + * Downloads an artifact by its unique artifact ID (not by name, since + * names can duplicate across attempts). Downloads to the specified directory. + * + * @param {string} repo - Repository in "owner/repo" format + * @param {number} artifactId - Unique artifact ID + * @param {string} destinationDir - Where to extract the artifact + */ +function downloadArtifactById(repo, artifactId, destinationDir) { + const { execFileSync } = require('child_process'); + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + // Download to a temp file first, then extract + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifact-')); + const zipPath = path.join(tmpDir, 'artifact.zip'); + + try { + // The /zip endpoint returns the artifact content as a redirect to a download URL. + // We pipe the binary output directly to a file. + const fdOut = fs.openSync(zipPath, 'w'); + try { + execFileSync( + 'gh', + [ + 'api', + `repos/${repo}/actions/artifacts/${artifactId}/zip`, + '--silent', + ], + { stdio: ['inherit', fdOut, 'inherit'] }, + ); + } finally { + fs.closeSync(fdOut); + } + + // Verify we got a zip file + const stat = fs.statSync(zipPath); + if (stat.size === 0) { + throw new Error(`Downloaded artifact ${artifactId} is empty`); + } + + // Extract to destination + fs.mkdirSync(destinationDir, { recursive: true }); + execFileSync('unzip', ['-qo', zipPath, '-d', destinationDir], { + stdio: 'inherit', + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +module.exports = { + getRunAttemptInfo, + listFailedJobsAcrossAttempts, + listArtifactsAcrossAttempts, + downloadArtifactById, +}; diff --git a/actions/playwright-test-healer/tests/collect-log-hard-failures.test.js b/actions/playwright-test-healer/tests/collect-log-hard-failures.test.js new file mode 100644 index 000000000..4ddf6c9d7 --- /dev/null +++ b/actions/playwright-test-healer/tests/collect-log-hard-failures.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const { + collectHardFailuresWithAttempts, + parseHardFailureTitles, +} = require('../scripts/collect-log-hard-failures'); + +describe('parseHardFailureTitles', () => { + it('extracts test titles from Playwright failure summary', () => { + const log = ` + 3 failed + tests/playwright/sanity/modules/loop-filter/loop-taxonomy-filter.test.ts:118:6 › Loop › Loop taxonomy filter works + tests/playwright/sanity/modules/search/search.test.ts:92:6 › Search widget › Search Result Visibility + [chromium] › tests/playwright/sanity/container.test.ts:45:2 › Container › Container can be added +`; + + const titles = parseHardFailureTitles(log); + + expect(titles).toContain('Loop taxonomy filter works'); + expect(titles).toContain('Search Result Visibility'); + expect(titles).toContain('Container can be added'); + expect(titles).toHaveLength(3); + }); + + it('handles timestamp prefixes in log lines', () => { + const log = ` +2026-11-12T10:30:45.123Z 3 failed +2026-11-12T10:30:45.456Z tests/playwright/sanity/test.ts:10:2 › Test title +`; + + const titles = parseHardFailureTitles(log); + + expect(titles).toContain('Test title'); + }); + + it('returns empty array when no failures found', () => { + const log = ` + 0 failed + 5 passed +`; + + const titles = parseHardFailureTitles(log); + + expect(titles).toEqual([]); + }); +}); + +describe('collectHardFailuresWithAttempts', () => { + it('requires integration with actual gh and job logs', () => { + // This function makes actual API calls via the run-attempts module, + // so it's tested through integration tests rather than unit tests. + // The function signature is verified here. + expect(typeof collectHardFailuresWithAttempts).toBe('function'); + }); +}); diff --git a/actions/playwright-test-healer/tests/find-test-evidence.test.js b/actions/playwright-test-healer/tests/find-test-evidence.test.js index 5f191dc85..d65100e68 100644 --- a/actions/playwright-test-healer/tests/find-test-evidence.test.js +++ b/actions/playwright-test-healer/tests/find-test-evidence.test.js @@ -36,6 +36,7 @@ describe('buildEvidenceCandidates', () => { { dirName: 'modules-loop-filter-loop-taxonomy-filter-works', artifactName: 'playwright-test-results-taxonomy_filter_2', + attempt: 1, hasTrace: true, }, ]; @@ -45,6 +46,7 @@ describe('buildEvidenceCandidates', () => { ).toEqual([ { artifactName: 'playwright-test-results-taxonomy_filter_2', + attempt: 1, shardIndex: 'taxonomy_filter_2', matchedDirs: ['modules-loop-filter-loop-taxonomy-filter-works'], hasTrace: true, @@ -57,11 +59,13 @@ describe('buildEvidenceCandidates', () => { { dirName: 'a-search-widget-functionality', artifactName: 'playwright-test-results-3', + attempt: 1, hasTrace: false, }, { dirName: 'b-search-widget-functionality', artifactName: 'playwright-test-results-9', + attempt: 1, hasTrace: true, }, ]; @@ -77,11 +81,46 @@ describe('buildEvidenceCandidates', () => { ]); }); + it('keeps evidence from different attempts of the same artifact separate', () => { + const resultDirs = [ + { + dirName: 'a-search-widget-functionality', + artifactName: 'playwright-test-results-3', + attempt: 1, + hasTrace: false, + }, + { + dirName: 'a-search-widget-functionality', + artifactName: 'playwright-test-results-3', + attempt: 2, + hasTrace: true, + }, + ]; + + const candidates = buildEvidenceCandidates( + 'Search widget functionality', + resultDirs, + ); + + expect(candidates).toHaveLength(2); + expect(candidates[0]).toMatchObject({ + artifactName: 'playwright-test-results-3', + attempt: 1, + hasTrace: false, + }); + expect(candidates[1]).toMatchObject({ + artifactName: 'playwright-test-results-3', + attempt: 2, + hasTrace: true, + }); + }); + it('returns nothing when no directory matches the title', () => { const resultDirs = [ { dirName: 'unrelated-spec-something-else', artifactName: 'playwright-test-results-1', + attempt: 1, hasTrace: true, }, ]; @@ -95,18 +134,76 @@ describe('buildEvidenceCandidates', () => { describe('pickBestEvidence', () => { it('prefers trace-backed evidence over log-only evidence', () => { const best = pickBestEvidence([ - { shardIndex: '3', matchedDirs: ['a'], hasTrace: false }, - { shardIndex: '9', matchedDirs: ['b'], hasTrace: true }, + { shardIndex: '3', attempt: 1, matchedDirs: ['a'], hasTrace: false }, + { shardIndex: '9', attempt: 1, matchedDirs: ['b'], hasTrace: true }, ]); expect(best.shardIndex).toBe('9'); }); + it('prefers evidence from later attempts when quality is equal', () => { + const best = pickBestEvidence([ + { shardIndex: '3', attempt: 1, matchedDirs: ['a'], hasTrace: true }, + { shardIndex: '3', attempt: 2, matchedDirs: ['a'], hasTrace: true }, + ]); + + expect(best.attempt).toBe(2); + }); + + it('prefers trace evidence even from an earlier attempt', () => { + const best = pickBestEvidence([ + { shardIndex: '3', attempt: 1, matchedDirs: ['a'], hasTrace: true }, + { shardIndex: '3', attempt: 2, matchedDirs: ['a'], hasTrace: false }, + ]); + + expect(best.attempt).toBe(1); + expect(best.hasTrace).toBe(true); + }); + + it('handles the case where test failed in attempt 1 but not attempt 2', () => { + // This scenario represents a flaky test that failed initially but passed on re-run. + // Evidence should come from attempt 1 where it actually failed. + const candidates = [ + { + shardIndex: '5', + attempt: 1, + matchedDirs: ['test-failed-directory'], + hasTrace: true, + }, + // Attempt 2 would have no evidence for this test since it passed + ]; + + const best = pickBestEvidence(candidates); + expect(best.attempt).toBe(1); + expect(best.hasTrace).toBe(true); + }); + + it('prefers later failed attempt when test failed in multiple attempts', () => { + // Test failed in both attempt 1 and attempt 2 + const candidates = [ + { + shardIndex: '5', + attempt: 1, + matchedDirs: ['test-failed-directory'], + hasTrace: true, + }, + { + shardIndex: '5', + attempt: 2, + matchedDirs: ['test-failed-directory'], + hasTrace: true, + }, + ]; + + const best = pickBestEvidence(candidates); + expect(best.attempt).toBe(2); + }); + it('returns null when nothing matched', () => { expect(pickBestEvidence([])).toBeNull(); expect( pickBestEvidence([ - { shardIndex: '1', matchedDirs: [], hasTrace: true }, + { shardIndex: '1', attempt: 1, matchedDirs: [], hasTrace: true }, ]), ).toBeNull(); }); @@ -119,6 +216,7 @@ describe('findEvidenceInResultDirs', () => { dirName: 'modules-search-search-infr-364a4-Search-widget-functionality', artifactName: 'playwright-test-results-nightly-12', + attempt: 1, hasTrace: true, }, ]; @@ -128,7 +226,11 @@ describe('findEvidenceInResultDirs', () => { resultDirs, ); - expect(evidence).toMatchObject({ shardIndex: '12', hasTrace: true }); + expect(evidence).toMatchObject({ + shardIndex: '12', + attempt: 1, + hasTrace: true, + }); }); }); diff --git a/actions/playwright-test-healer/tests/run-attempts.test.js b/actions/playwright-test-healer/tests/run-attempts.test.js new file mode 100644 index 000000000..c8d3edd83 --- /dev/null +++ b/actions/playwright-test-healer/tests/run-attempts.test.js @@ -0,0 +1,30 @@ +'use strict'; + +/** + * Tests for the run-attempts module that handles GitHub Actions workflow + * run re-runs. These tests verify that we correctly parse and aggregate + * data from multiple attempts of the same run. + * + * Note: These are unit tests that verify the module's behavior without + * making actual GitHub API calls. The gh module is expected to be tested + * separately, and actual integration with GitHub is tested in CI. + */ + +describe('run-attempts', () => { + // These tests verify the module's structure and that it exports the expected functions. + // Full integration testing with mocked gh calls would require restructuring the module + // to accept gh as a dependency, which is beyond the scope of this fix. + + it('exports the expected functions', () => { + const runAttempts = require('../scripts/run-attempts'); + + expect(typeof runAttempts.getRunAttemptInfo).toBe('function'); + expect(typeof runAttempts.listFailedJobsAcrossAttempts).toBe('function'); + expect(typeof runAttempts.listArtifactsAcrossAttempts).toBe('function'); + expect(typeof runAttempts.downloadArtifactById).toBe('function'); + }); + + it('module loads without errors', () => { + expect(() => require('../scripts/run-attempts')).not.toThrow(); + }); +});