gha-workflow-testing 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,387 @@
1
+ import axios from 'axios';
2
+ import { Jobs, Steps, Artifacts } from './github-api';
3
+ import { WorkflowTestReporter } from './reporter';
4
+
5
+ export function assertJobSuccess(
6
+ jobs: Jobs,
7
+ jobName: string,
8
+ reporter?: WorkflowTestReporter,
9
+ expectedConclusion: string = 'success'
10
+ ): void {
11
+ try {
12
+ if (!jobs[jobName]) {
13
+ const availableJobs = Object.keys(jobs).join('\n - ');
14
+ throw new Error(
15
+ `${jobName} not found. Available jobs:\n - ${availableJobs}`
16
+ );
17
+ }
18
+
19
+ const jobInfo = jobs[jobName];
20
+ const { status, conclusion } = jobInfo;
21
+
22
+ if (conclusion !== expectedConclusion) {
23
+ if (conclusion === null) {
24
+ throw new Error(
25
+ `${jobName} did not complete with expected conclusion '${expectedConclusion}'.\n` +
26
+ ` Status: ${status}\n` +
27
+ ` Conclusion: ${conclusion} (job was skipped, cancelled, or still running)`
28
+ );
29
+ } else {
30
+ throw new Error(
31
+ `${jobName} did not have expected conclusion.\n` +
32
+ ` Expected: ${expectedConclusion}\n` +
33
+ ` Actual: ${conclusion}\n` +
34
+ ` Status: ${status}`
35
+ );
36
+ }
37
+ }
38
+
39
+ console.log(`✅ ${jobName} completed with conclusion '${expectedConclusion}'`);
40
+
41
+ // Record success in reporter
42
+ if (reporter) {
43
+ reporter.recordJobTest(jobName, true, expectedConclusion, conclusion);
44
+ }
45
+ } catch (error) {
46
+ // Record failure in reporter
47
+ if (reporter) {
48
+ const conclusion = jobs[jobName]?.conclusion || 'unknown';
49
+ reporter.recordJobTest(jobName, false, expectedConclusion, conclusion);
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ export function assertStepSuccess(
56
+ steps: Steps,
57
+ stepName: string,
58
+ jobName?: string,
59
+ reporter?: WorkflowTestReporter,
60
+ expectedConclusion: string = 'success'
61
+ ): void {
62
+ try {
63
+ if (!steps[stepName]) {
64
+ const availableSteps = Object.keys(steps).join('\n - ');
65
+ throw new Error(
66
+ `Step '${stepName}' not found. Available steps:\n - ${availableSteps}`
67
+ );
68
+ }
69
+
70
+ const stepInfo = steps[stepName];
71
+ const { conclusion } = stepInfo;
72
+
73
+ if (conclusion !== expectedConclusion) {
74
+ throw new Error(
75
+ `Step '${stepName}' did not have expected conclusion.\n` +
76
+ ` Expected: ${expectedConclusion}\n` +
77
+ ` Actual: ${conclusion}`
78
+ );
79
+ }
80
+
81
+ console.log(`✅ Step '${stepName}' completed with conclusion '${expectedConclusion}'`);
82
+
83
+ // Record success in reporter
84
+ if (reporter && jobName) {
85
+ reporter.recordStepTest(jobName, stepName, true, expectedConclusion, conclusion || undefined);
86
+ }
87
+ } catch (error) {
88
+ // Record failure in reporter
89
+ if (reporter && jobName) {
90
+ const conclusion = steps[stepName]?.conclusion || 'unknown';
91
+ reporter.recordStepTest(jobName, stepName, false, expectedConclusion, conclusion || undefined);
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ export function assertArtifactExists(
98
+ artifacts: Artifacts,
99
+ artifactName: string,
100
+ reporter?: WorkflowTestReporter
101
+ ): void {
102
+ try {
103
+ if (!artifacts[artifactName]) {
104
+ const availableArtifacts = Object.keys(artifacts).length > 0
105
+ ? Object.keys(artifacts).join('\n - ')
106
+ : '(no artifacts)';
107
+ throw new Error(
108
+ `Artifact '${artifactName}' not found. Available artifacts:\n - ${availableArtifacts}`
109
+ );
110
+ }
111
+
112
+ const artifactInfo = artifacts[artifactName];
113
+ if (artifactInfo.expired) {
114
+ throw new Error(`Artifact '${artifactName}' has expired`);
115
+ }
116
+
117
+ console.log(`✅ Artifact '${artifactName}' exists (size: ${artifactInfo.size} bytes)`);
118
+
119
+ // Record success in reporter
120
+ if (reporter) {
121
+ reporter.recordArtifactTest(artifactName, 'exists', true, 'exists');
122
+ }
123
+ } catch (error) {
124
+ // Record failure in reporter
125
+ if (reporter) {
126
+ reporter.recordArtifactTest(
127
+ artifactName,
128
+ 'exists',
129
+ false,
130
+ 'exists',
131
+ 'not found or expired'
132
+ );
133
+ }
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ export function assertFileInArtifact(
139
+ artifactFiles: { [filename: string]: Buffer },
140
+ filename: string,
141
+ artifactName?: string,
142
+ reporter?: WorkflowTestReporter
143
+ ): void {
144
+ try {
145
+ if (!artifactFiles[filename]) {
146
+ const availableFiles = Object.keys(artifactFiles).length > 0
147
+ ? Object.keys(artifactFiles).join('\n - ')
148
+ : '(no files)';
149
+ throw new Error(
150
+ `File '${filename}' not found in artifact. Available files:\n - ${availableFiles}`
151
+ );
152
+ }
153
+
154
+ console.log(`✅ File '${filename}' found in artifact`);
155
+
156
+ // Record success in reporter
157
+ if (reporter && artifactName) {
158
+ reporter.recordArtifactTest(
159
+ artifactName,
160
+ `file '${filename}' exists`,
161
+ true,
162
+ filename
163
+ );
164
+ }
165
+ } catch (error) {
166
+ // Record failure in reporter
167
+ if (reporter && artifactName) {
168
+ reporter.recordArtifactTest(
169
+ artifactName,
170
+ `file '${filename}' exists`,
171
+ false,
172
+ filename,
173
+ 'not found'
174
+ );
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ export function assertFileContains(
181
+ artifactFiles: { [filename: string]: Buffer },
182
+ filename: string,
183
+ expectedContent: string | Buffer,
184
+ artifactName?: string,
185
+ reporter?: WorkflowTestReporter
186
+ ): void {
187
+ try {
188
+ assertFileInArtifact(artifactFiles, filename, artifactName, reporter);
189
+
190
+ const fileContent = artifactFiles[filename];
191
+ const expectedBuffer =
192
+ typeof expectedContent === 'string'
193
+ ? Buffer.from(expectedContent)
194
+ : expectedContent;
195
+
196
+ if (!fileContent.includes(expectedBuffer)) {
197
+ const contentStr =
198
+ typeof expectedContent === 'string'
199
+ ? expectedContent
200
+ : expectedContent.toString();
201
+ throw new Error(
202
+ `File '${filename}' does not contain expected content: ${contentStr}`
203
+ );
204
+ }
205
+
206
+ console.log(`✅ File '${filename}' contains expected content`);
207
+
208
+ // Record success in reporter
209
+ if (reporter && artifactName) {
210
+ const contentPreview = (
211
+ typeof expectedContent === 'string'
212
+ ? expectedContent
213
+ : expectedContent.toString()
214
+ ).substring(0, 50);
215
+ reporter.recordArtifactTest(
216
+ artifactName,
217
+ `file '${filename}' contains content`,
218
+ true,
219
+ contentPreview
220
+ );
221
+ }
222
+ } catch (error) {
223
+ // Record failure in reporter
224
+ if (reporter && artifactName) {
225
+ const contentPreview = (
226
+ typeof expectedContent === 'string'
227
+ ? expectedContent
228
+ : expectedContent.toString()
229
+ ).substring(0, 50);
230
+ reporter.recordArtifactTest(
231
+ artifactName,
232
+ `file '${filename}' contains content`,
233
+ false,
234
+ contentPreview,
235
+ 'not found'
236
+ );
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+
242
+ // ============================================================================
243
+ // JFrog Artifactory Assertions
244
+ // ============================================================================
245
+
246
+ export interface JFrogArtifactOptions {
247
+ jfrogUrl: string;
248
+ repository: string;
249
+ artifactPath: string;
250
+ artifactName: string;
251
+ jfrogApiKey?: string;
252
+ jfrogToken?: string;
253
+ reporter?: WorkflowTestReporter;
254
+ jobName?: string;
255
+ }
256
+
257
+ export async function assertJFrogArtifactExists(
258
+ options: JFrogArtifactOptions
259
+ ): Promise<void> {
260
+ const {
261
+ jfrogUrl,
262
+ repository,
263
+ artifactPath,
264
+ artifactName,
265
+ jfrogApiKey,
266
+ jfrogToken,
267
+ reporter,
268
+ } = options;
269
+
270
+ // Create artifact display name for reporter
271
+ const jfrogArtifactDisplayName = `JFrog: ${repository}/${artifactPath}/${artifactName}`;
272
+
273
+ try {
274
+ // Get credentials from parameters or environment
275
+ const apiKey = jfrogApiKey || process.env.MCD_ACTIONS_DD_API_KEY;
276
+ const token = jfrogToken || process.env.GDAP_ARTIFACTORY_ACCESS_TOKEN;
277
+
278
+ if (!apiKey && !token) {
279
+ throw new Error(
280
+ 'JFrog credentials not provided. Set one of:\n' +
281
+ ' - JFROG_API_KEY or GDAP_ARTIFACTORY_ACCESS_TOKEN environment variable\n' +
282
+ ' - JFROG_TOKEN environment variable\n' +
283
+ ' - Pass jfrogApiKey or jfrogToken parameter'
284
+ );
285
+ }
286
+
287
+ // Build artifact URL
288
+ const cleanJfrogUrl = jfrogUrl.replace(/\/$/, '');
289
+ const cleanArtifactPath = artifactPath.replace(/^\/|\/$/g, '');
290
+ const artifactUrl = `${cleanJfrogUrl}/artifactory/${repository}/${cleanArtifactPath}/${artifactName}`;
291
+
292
+ // Set up authentication headers
293
+ const headers: { [key: string]: string } = {};
294
+ let authMethod: string;
295
+
296
+ if (apiKey) {
297
+ headers['X-JFrog-Art-Api'] = apiKey;
298
+ authMethod = 'API Key';
299
+ } else {
300
+ headers['Authorization'] = `Bearer ${token}`;
301
+ authMethod = 'Bearer Token';
302
+ }
303
+
304
+ console.log('🔍 Checking JFrog artifact existence...');
305
+ console.log(` URL: ${artifactUrl}`);
306
+ console.log(` Auth: ${authMethod}`);
307
+
308
+ // Make HEAD request to check if artifact exists
309
+ const response = await axios.head(artifactUrl, {
310
+ headers,
311
+ timeout: 30000,
312
+ maxRedirects: 5,
313
+ });
314
+
315
+ if (response.status === 200) {
316
+ // Artifact exists! Get metadata from response headers
317
+ const fileSize = response.headers['content-length'] || 'unknown';
318
+ const checksumSha1 = response.headers['x-checksum-sha1'] || 'N/A';
319
+ const lastModified = response.headers['last-modified'] || 'N/A';
320
+
321
+ console.log(`✅ Artifact '${artifactName}' exists in JFrog Artifactory`);
322
+ console.log(` Repository: ${repository}`);
323
+ console.log(` Path: ${artifactPath}`);
324
+ console.log(` Size: ${fileSize} bytes`);
325
+ console.log(` SHA1: ${checksumSha1}`);
326
+ console.log(` Last Modified: ${lastModified}`);
327
+
328
+ // Record success in reporter as artifact
329
+ if (reporter) {
330
+ reporter.recordArtifactTest(
331
+ jfrogArtifactDisplayName,
332
+ 'exists in JFrog',
333
+ true,
334
+ `Size: ${fileSize} bytes, SHA1: ${checksumSha1.substring(0, 8)}...`
335
+ );
336
+ }
337
+ }
338
+ } catch (error: any) {
339
+ let errorMsg: string;
340
+
341
+ if (axios.isAxiosError(error)) {
342
+ if (error.response?.status === 404) {
343
+ errorMsg =
344
+ `❌ Artifact '${artifactName}' not found in JFrog Artifactory.\n` +
345
+ ` Repository: ${repository}\n` +
346
+ ` Path: ${artifactPath}\n` +
347
+ ` Status: 404 Not Found`;
348
+ } else if (error.response?.status === 401) {
349
+ errorMsg =
350
+ `❌ Authentication failed when checking JFrog artifact.\n` +
351
+ ` Status: 401 Unauthorized`;
352
+ } else if (error.response?.status === 403) {
353
+ errorMsg =
354
+ `❌ Permission denied when checking JFrog artifact.\n` +
355
+ ` Status: 403 Forbidden`;
356
+ } else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
357
+ errorMsg =
358
+ `❌ Failed to connect to JFrog Artifactory.\n` +
359
+ ` Error: Connection error`;
360
+ } else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
361
+ errorMsg =
362
+ `❌ Request to JFrog Artifactory timed out.\n` +
363
+ ` Timeout: 30 seconds`;
364
+ } else {
365
+ errorMsg =
366
+ `❌ Unexpected response when checking JFrog artifact.\n` +
367
+ ` Status: ${error.response?.status}\n` +
368
+ ` Error: ${error.message}`;
369
+ }
370
+ } else {
371
+ errorMsg = `❌ Error checking JFrog artifact: ${error.message}`;
372
+ }
373
+
374
+ // Record failure in reporter
375
+ if (reporter) {
376
+ reporter.recordArtifactTest(
377
+ jfrogArtifactDisplayName,
378
+ 'exists in JFrog',
379
+ false,
380
+ 'artifact exists',
381
+ 'not found or error'
382
+ );
383
+ }
384
+
385
+ throw new Error(errorMsg);
386
+ }
387
+ }
@@ -0,0 +1,184 @@
1
+ import axios, { AxiosInstance } from 'axios';
2
+
3
+ export interface JobInfo {
4
+ status: string;
5
+ conclusion: string | null;
6
+ }
7
+
8
+ export interface Jobs {
9
+ [jobName: string]: JobInfo;
10
+ }
11
+
12
+ export interface StepInfo {
13
+ conclusion: string | null;
14
+ number: number;
15
+ }
16
+
17
+ export interface Steps {
18
+ [stepName: string]: StepInfo;
19
+ }
20
+
21
+ export interface ArtifactInfo {
22
+ id: number;
23
+ size: number;
24
+ expired: boolean;
25
+ url: string;
26
+ }
27
+
28
+ export interface Artifacts {
29
+ [artifactName: string]: ArtifactInfo;
30
+ }
31
+
32
+ export class GitHubAPI {
33
+ private repo: string;
34
+ private client: AxiosInstance;
35
+
36
+ constructor() {
37
+ this.repo = process.env.GITHUB_REPOSITORY || '';
38
+ const token = process.env.GITHUB_TOKEN || '';
39
+
40
+ if (!this.repo || !token) {
41
+ throw new Error('GITHUB_REPOSITORY and GITHUB_TOKEN environment variables are required');
42
+ }
43
+
44
+ console.log(`🔍 GitHub API initialized for repo: ${this.repo}`);
45
+
46
+ this.client = axios.create({
47
+ baseURL: 'https://api.github.com',
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ Accept: 'application/vnd.github+json',
51
+ },
52
+ });
53
+ }
54
+
55
+ async getLatestRun(workflowName: string): Promise<number> {
56
+ const url = `/repos/${this.repo}/actions/runs`;
57
+ console.log(`📡 Fetching workflow runs from: ${url}`);
58
+
59
+ const response = await this.client.get(url);
60
+ const runs = response.data.workflow_runs;
61
+ console.log(`Found ${runs.length} total workflow runs`);
62
+
63
+ for (const run of runs) {
64
+ if (run.name === workflowName) {
65
+ console.log(`✅ Found matching workflow: ${workflowName} (ID: ${run.id})`);
66
+ return run.id;
67
+ }
68
+ }
69
+
70
+ console.log(`❌ Workflow '${workflowName}' not found in recent runs`);
71
+ throw new Error('Workflow run not found');
72
+ }
73
+
74
+ async getJobs(runId: number): Promise<Jobs> {
75
+ const url = `/repos/${this.repo}/actions/runs/${runId}/jobs`;
76
+ console.log(`📡 Fetching jobs for run ID ${runId}`);
77
+
78
+ const response = await this.client.get(url);
79
+ const jobs = response.data.jobs;
80
+ console.log(`Found ${jobs.length} jobs in this run`);
81
+
82
+ const result: Jobs = {};
83
+ for (const job of jobs) {
84
+ result[job.name] = {
85
+ status: job.status,
86
+ conclusion: job.conclusion,
87
+ };
88
+ }
89
+
90
+ return result;
91
+ }
92
+
93
+ async waitForJobsCompletion(
94
+ runId: number,
95
+ timeout: number = 300,
96
+ pollInterval: number = 10
97
+ ): Promise<Jobs> {
98
+ console.log(`⏳ Waiting for all jobs to complete (timeout: ${timeout}s)...`);
99
+ const startTime = Date.now();
100
+
101
+ while (Date.now() - startTime < timeout * 1000) {
102
+ const jobs = await this.getJobs(runId);
103
+
104
+ const incompleteJobs = Object.entries(jobs)
105
+ .filter(([_, info]) => info.conclusion === null)
106
+ .map(([name]) => name);
107
+
108
+ if (incompleteJobs.length === 0) {
109
+ console.log('✅ All jobs completed!');
110
+ return jobs;
111
+ }
112
+
113
+ console.log(
114
+ `⏳ Waiting for ${incompleteJobs.length} job(s) to complete: ${incompleteJobs.join(', ')}`
115
+ );
116
+ await this.sleep(pollInterval * 1000);
117
+ }
118
+
119
+ throw new Error(`Jobs did not complete within ${timeout} seconds`);
120
+ }
121
+
122
+ async getSteps(runId: number, jobName: string): Promise<Steps> {
123
+ const url = `/repos/${this.repo}/actions/runs/${runId}/jobs`;
124
+ console.log(`📡 Fetching steps for job: ${jobName}`);
125
+
126
+ const response = await this.client.get(url);
127
+ const jobs = response.data.jobs;
128
+
129
+ const job = jobs.find((j: any) => j.name === jobName);
130
+ if (!job) {
131
+ throw new Error(`Job '${jobName}' not found in run ${runId}`);
132
+ }
133
+
134
+ const result: Steps = {};
135
+ for (const step of job.steps) {
136
+ result[step.name] = {
137
+ conclusion: step.conclusion,
138
+ number: step.number,
139
+ };
140
+ }
141
+
142
+ console.log(`Found ${Object.keys(result).length} steps in job '${jobName}'`);
143
+ return result;
144
+ }
145
+
146
+ async getArtifacts(runId: number): Promise<Artifacts> {
147
+ const url = `/repos/${this.repo}/actions/runs/${runId}/artifacts`;
148
+ console.log(`📡 Fetching artifacts for run ID ${runId}`);
149
+
150
+ const response = await this.client.get(url);
151
+ const artifacts = response.data.artifacts;
152
+ console.log(`Found ${artifacts.length} artifacts`);
153
+
154
+ const result: Artifacts = {};
155
+ for (const artifact of artifacts) {
156
+ result[artifact.name] = {
157
+ id: artifact.id,
158
+ size: artifact.size_in_bytes,
159
+ expired: artifact.expired,
160
+ url: artifact.archive_download_url,
161
+ };
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ async downloadArtifact(artifactId: number): Promise<{ [filename: string]: Buffer }> {
168
+ const url = `/repos/${this.repo}/actions/artifacts/${artifactId}/zip`;
169
+ console.log(`📥 Downloading artifact ID ${artifactId}`);
170
+
171
+ const response = await this.client.get(url, { responseType: 'arraybuffer' });
172
+
173
+ // In a real implementation, you would unzip the response
174
+ // For simplicity, returning a placeholder
175
+ console.log('⚠️ Artifact download implemented - unzipping not yet implemented in this example');
176
+ return {
177
+ 'artifact-file.zip': Buffer.from(response.data),
178
+ };
179
+ }
180
+
181
+ private sleep(ms: number): Promise<void> {
182
+ return new Promise((resolve) => setTimeout(resolve, ms));
183
+ }
184
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './github-api';
2
+ export * from './assertions';
3
+ export * from './reporter';