@msalaam/xray-qe-toolkit 1.3.4 → 1.4.1

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.
package/lib/xrayClient.js CHANGED
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * @msalaam/xray-qe-toolkit — Xray + JIRA API Client
3
3
  *
4
- * Consolidated API layer that replaces the duplicated code from the original
5
- * index.js and scripts/linkTests.js. Handles:
4
+ * Consolidated API layer handling:
6
5
  * • Xray Cloud authentication (JWT)
7
6
  * • JIRA REST v3 issue CRUD
8
- * • Xray GraphQL mutations (test type, steps)
9
- * • Issue linking (Test Execution / Plan)
7
+ * • Xray GraphQL test management (tests, test plans, folders, test runs)
8
+ * • Xray REST v2 — execution import (JSON, multipart), bulk test import, attachments
10
9
  * • Exponential-backoff retry for Xray indexing race conditions
11
10
  */
12
11
 
13
12
  import axios from "axios";
14
13
  import https from "node:https";
14
+ import FormData from "form-data";
15
15
  import logger from "./logger.js";
16
16
 
17
17
  // Shared HTTPS agent — rejectUnauthorized: false for corporate proxy compat
@@ -19,20 +19,10 @@ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
19
19
 
20
20
  // ─── Internal helpers ──────────────────────────────────────────────────────────
21
21
 
22
- /**
23
- * Build the Basic Auth header value for JIRA REST calls.
24
- * @param {object} cfg Config from loadConfig()
25
- * @returns {string}
26
- */
27
22
  function jiraAuth(cfg) {
28
23
  return Buffer.from(`${cfg.jiraEmail}:${cfg.jiraApiToken}`).toString("base64");
29
24
  }
30
25
 
31
- /**
32
- * Standard JIRA REST headers.
33
- * @param {object} cfg
34
- * @returns {object}
35
- */
36
26
  function jiraHeaders(cfg) {
37
27
  return {
38
28
  Authorization: `Basic ${jiraAuth(cfg)}`,
@@ -40,11 +30,13 @@ function jiraHeaders(cfg) {
40
30
  };
41
31
  }
42
32
 
43
- /**
44
- * Convert a plain-text description to Atlassian Document Format (ADF).
45
- * @param {string} text
46
- * @returns {object} ADF document
47
- */
33
+ function xrayHeaders(xrayToken) {
34
+ return {
35
+ Authorization: `Bearer ${xrayToken}`,
36
+ "Content-Type": "application/json",
37
+ };
38
+ }
39
+
48
40
  export function toAdf(text) {
49
41
  return {
50
42
  type: "doc",
@@ -58,11 +50,6 @@ export function toAdf(text) {
58
50
  };
59
51
  }
60
52
 
61
- /**
62
- * Sleep for `ms` milliseconds.
63
- * @param {number} ms
64
- * @returns {Promise<void>}
65
- */
66
53
  function sleep(ms) {
67
54
  return new Promise((resolve) => setTimeout(resolve, ms));
68
55
  }
@@ -71,7 +58,6 @@ function sleep(ms) {
71
58
 
72
59
  /**
73
60
  * Authenticate with Xray Cloud and return a JWT bearer token.
74
- *
75
61
  * @param {object} cfg Config from loadConfig()
76
62
  * @returns {Promise<string>} JWT token string
77
63
  */
@@ -90,10 +76,9 @@ export async function authenticate(cfg) {
90
76
 
91
77
  /**
92
78
  * Create a JIRA issue (generic).
93
- *
94
- * @param {object} cfg Config
95
- * @param {string} issueType e.g. "Test", "Test Execution", "Test Plan"
96
- * @param {object} fields { summary, description, priority, labels, ... }
79
+ * @param {object} cfg Config
80
+ * @param {string} issueType e.g. "Test", "Test Execution", "Test Plan"
81
+ * @param {object} fields { summary, description, priority, labels, ... }
97
82
  * @returns {Promise<{key: string, id: string}>}
98
83
  */
99
84
  export async function createIssue(cfg, issueType, fields) {
@@ -119,11 +104,9 @@ export async function createIssue(cfg, issueType, fields) {
119
104
 
120
105
  /**
121
106
  * Update an existing JIRA issue's fields.
122
- *
123
- * @param {object} cfg Config
124
- * @param {string} issueKey e.g. "APIEE-6933"
125
- * @param {object} fields Fields to update { summary, description, priority, labels }
126
- * @returns {Promise<void>}
107
+ * @param {object} cfg
108
+ * @param {string} issueKey
109
+ * @param {object} fields
127
110
  */
128
111
  export async function updateIssue(cfg, issueKey, fields) {
129
112
  const update = {};
@@ -141,10 +124,9 @@ export async function updateIssue(cfg, issueKey, fields) {
141
124
 
142
125
  /**
143
126
  * Fetch a JIRA issue by key.
144
- *
145
127
  * @param {object} cfg
146
- * @param {string} issueKey e.g. "APIEE-6933"
147
- * @returns {Promise<object>} Full issue payload
128
+ * @param {string} issueKey
129
+ * @returns {Promise<object>}
148
130
  */
149
131
  export async function getIssue(cfg, issueKey) {
150
132
  const response = await axios.get(
@@ -154,162 +136,537 @@ export async function getIssue(cfg, issueKey) {
154
136
  return response.data;
155
137
  }
156
138
 
157
- // ─── Xray GraphQL — Test type & steps ──────────────────────────────────────────
139
+ // ─── Xray GraphQL — queries ────────────────────────────────────────────────────
158
140
 
159
141
  /**
160
- * Set a test issue's type to "Automated" via Xray GraphQL.
161
- *
142
+ * Execute a GraphQL operation against Xray Cloud.
162
143
  * @param {object} cfg
163
- * @param {string} xrayToken JWT from authenticate()
164
- * @param {string} issueId Numeric JIRA issue ID (NOT the key)
144
+ * @param {string} xrayToken
145
+ * @param {string} query GraphQL query or mutation string
146
+ * @param {object} variables
147
+ * @returns {Promise<object>} data object
165
148
  */
166
- export async function setTestTypeAutomated(cfg, xrayToken, issueId) {
167
- const headers = {
168
- Authorization: `Bearer ${xrayToken}`,
169
- "Content-Type": "application/json",
170
- };
171
-
149
+ async function graphql(cfg, xrayToken, query, variables = {}) {
172
150
  const response = await axios.post(
173
151
  cfg.xrayGraphqlUrl,
174
- {
175
- query: `mutation ($issueId: String!, $testType: UpdateTestTypeInput!) {
176
- updateTestType(issueId: $issueId, testType: $testType) {
177
- issueId
178
- testType { name kind }
179
- }
180
- }`,
181
- variables: { issueId: String(issueId), testType: { name: "Automated" } },
182
- },
183
- { httpsAgent, headers }
152
+ { query, variables },
153
+ { httpsAgent, headers: xrayHeaders(xrayToken) }
184
154
  );
185
155
 
186
156
  if (response.data.errors) {
187
157
  throw new Error(`GraphQL errors: ${JSON.stringify(response.data.errors)}`);
188
158
  }
159
+
160
+ return response.data.data;
189
161
  }
190
162
 
191
163
  /**
192
- * Add a single test step via Xray GraphQL.
193
- *
164
+ * Get a single test by issue ID.
194
165
  * @param {object} cfg
195
- * @param {string} xrayToken JWT
196
- * @param {string} issueId Numeric JIRA issue ID
197
- * @param {object} step { action, data, expected_result }
166
+ * @param {string} xrayToken
167
+ * @param {string} issueId Numeric JIRA issue ID
168
+ * @returns {Promise<object>}
198
169
  */
199
- export async function addTestStep(cfg, xrayToken, issueId, step) {
200
- const headers = {
201
- Authorization: `Bearer ${xrayToken}`,
202
- "Content-Type": "application/json",
203
- };
170
+ export async function getTest(cfg, xrayToken, issueId) {
171
+ const data = await graphql(cfg, xrayToken, `
172
+ query ($issueId: String!) {
173
+ getTest(issueId: $issueId) {
174
+ issueId
175
+ projectId
176
+ testType { name kind }
177
+ steps { id action data result }
178
+ status { name description }
179
+ testSets(limit: 10) { results { issueId } }
180
+ testPlans(limit: 10) { results { issueId } }
181
+ folder { path }
182
+ }
183
+ }
184
+ `, { issueId: String(issueId) });
185
+ return data.getTest;
186
+ }
204
187
 
205
- const response = await axios.post(
206
- cfg.xrayGraphqlUrl,
207
- {
208
- query: `mutation ($issueId: String!, $step: CreateStepInput!) {
209
- addTestStep(issueId: $issueId, step: $step) {
188
+ /**
189
+ * Get tests in a project (paginated).
190
+ * @param {object} cfg
191
+ * @param {string} xrayToken
192
+ * @param {object} opts { projectId, jql, limit (1-100), start }
193
+ * @returns {Promise<{total, results}>}
194
+ */
195
+ export async function getTests(cfg, xrayToken, opts = {}) {
196
+ const data = await graphql(cfg, xrayToken, `
197
+ query ($projectId: String, $jql: String, $limit: Int!, $start: Int) {
198
+ getTests(projectId: $projectId, jql: $jql, limit: $limit, start: $start) {
199
+ total
200
+ start
201
+ limit
202
+ results {
203
+ issueId
204
+ projectId
205
+ testType { name kind }
206
+ steps { id action data result }
207
+ folder { path }
208
+ status { name }
209
+ }
210
+ }
211
+ }
212
+ `, {
213
+ projectId: opts.projectId || null,
214
+ jql: opts.jql || null,
215
+ limit: Math.min(opts.limit || 100, 100),
216
+ start: opts.start || 0,
217
+ });
218
+ return data.getTests;
219
+ }
220
+
221
+ /**
222
+ * Get a Test Plan by issue ID (includes its tests).
223
+ * @param {object} cfg
224
+ * @param {string} xrayToken
225
+ * @param {string} issueId
226
+ * @returns {Promise<object>}
227
+ */
228
+ export async function getTestPlan(cfg, xrayToken, issueId) {
229
+ const data = await graphql(cfg, xrayToken, `
230
+ query ($issueId: String!) {
231
+ getTestPlan(issueId: $issueId) {
232
+ issueId
233
+ projectId
234
+ tests(limit: 100) {
235
+ total
236
+ results {
237
+ issueId
238
+ testType { name }
239
+ status { name }
240
+ }
241
+ }
242
+ testExecutions(limit: 10) {
243
+ results {
244
+ issueId
245
+ }
246
+ }
247
+ }
248
+ }
249
+ `, { issueId: String(issueId) });
250
+ return data.getTestPlan;
251
+ }
252
+
253
+ /**
254
+ * Get test plans in a project.
255
+ * @param {object} cfg
256
+ * @param {string} xrayToken
257
+ * @param {string} projectId JIRA project ID (numeric)
258
+ * @param {number} limit
259
+ * @returns {Promise<{total, results}>}
260
+ */
261
+ export async function getTestPlans(cfg, xrayToken, projectId, limit = 50) {
262
+ const data = await graphql(cfg, xrayToken, `
263
+ query ($projectId: String!, $limit: Int!) {
264
+ getTestPlans(projectId: $projectId, limit: $limit) {
265
+ total
266
+ results {
267
+ issueId
268
+ projectId
269
+ }
270
+ }
271
+ }
272
+ `, { projectId: String(projectId), limit });
273
+ return data.getTestPlans;
274
+ }
275
+
276
+ /**
277
+ * Get a test execution by issue ID (includes test runs).
278
+ * @param {object} cfg
279
+ * @param {string} xrayToken
280
+ * @param {string} issueId
281
+ * @returns {Promise<object>}
282
+ */
283
+ export async function getTestExecution(cfg, xrayToken, issueId) {
284
+ const data = await graphql(cfg, xrayToken, `
285
+ query ($issueId: String!) {
286
+ getTestExecution(issueId: $issueId) {
287
+ issueId
288
+ projectId
289
+ testRuns(limit: 100) {
290
+ total
291
+ results {
292
+ id
293
+ status { name }
294
+ test { issueId }
295
+ startedOn
296
+ finishedOn
297
+ }
298
+ }
299
+ testPlans(limit: 10) {
300
+ results { issueId }
301
+ }
302
+ }
303
+ }
304
+ `, { issueId: String(issueId) });
305
+ return data.getTestExecution;
306
+ }
307
+
308
+ /**
309
+ * Get test runs for a given test.
310
+ * @param {object} cfg
311
+ * @param {string} xrayToken
312
+ * @param {string} testIssueId
313
+ * @param {number} limit
314
+ * @returns {Promise<{total, results}>}
315
+ */
316
+ export async function getTestRuns(cfg, xrayToken, testIssueId, limit = 10) {
317
+ const data = await graphql(cfg, xrayToken, `
318
+ query ($testIssueId: String!, $limit: Int!) {
319
+ getTestRuns(testIssueId: $testIssueId, limit: $limit) {
320
+ total
321
+ results {
210
322
  id
211
- action
323
+ status { name color }
324
+ startedOn
325
+ finishedOn
326
+ testExecution { issueId }
212
327
  }
213
- }`,
214
- variables: {
215
- issueId: String(issueId),
216
- step: {
217
- action: step.action,
218
- data: step.data || "",
219
- result: step.expected_result || "",
220
- },
221
- },
222
- },
223
- { httpsAgent, headers }
224
- );
328
+ }
329
+ }
330
+ `, { testIssueId: String(testIssueId), limit });
331
+ return data.getTestRuns;
332
+ }
225
333
 
226
- if (response.data.errors) {
227
- throw new Error(`GraphQL errors: ${JSON.stringify(response.data.errors)}`);
228
- }
334
+ /**
335
+ * Get folder by path within a project.
336
+ * @param {object} cfg
337
+ * @param {string} xrayToken
338
+ * @param {string} projectId
339
+ * @param {string} folderPath e.g. "/API/Auth"
340
+ * @returns {Promise<object|null>}
341
+ */
342
+ export async function getFolder(cfg, xrayToken, projectId, folderPath) {
343
+ const data = await graphql(cfg, xrayToken, `
344
+ query ($projectId: String!, $path: String!) {
345
+ getFolder(projectId: $projectId, path: $path) {
346
+ name
347
+ path
348
+ testsCount
349
+ }
350
+ }
351
+ `, { projectId: String(projectId), path: folderPath });
352
+ return data.getFolder;
353
+ }
229
354
 
230
- return response.data.data.addTestStep;
355
+ /**
356
+ * Get project settings (statuses, test types, custom fields).
357
+ * @param {object} cfg
358
+ * @param {string} xrayToken
359
+ * @param {string} projectId
360
+ * @returns {Promise<object>}
361
+ */
362
+ export async function getProjectSettings(cfg, xrayToken, projectId) {
363
+ const data = await graphql(cfg, xrayToken, `
364
+ query ($projectId: String!) {
365
+ getProjectSettings(projectId: $projectId) {
366
+ testTypes { name kind }
367
+ testStepSettings {
368
+ fields { id name }
369
+ }
370
+ }
371
+ }
372
+ `, { projectId: String(projectId) });
373
+ return data.getProjectSettings;
231
374
  }
232
375
 
376
+ // ─── Xray GraphQL — Test mutations ────────────────────────────────────────────
377
+
233
378
  /**
234
- * Remove all existing test steps via Xray GraphQL (for update/replace flow).
235
- *
379
+ * Set a test issue's type via Xray GraphQL.
236
380
  * @param {object} cfg
237
381
  * @param {string} xrayToken
238
382
  * @param {string} issueId
383
+ * @param {string} typeName "Generic" | "Manual" | "Cucumber"
239
384
  */
240
- export async function removeAllTestSteps(cfg, xrayToken, issueId) {
241
- const headers = {
242
- Authorization: `Bearer ${xrayToken}`,
243
- "Content-Type": "application/json",
244
- };
385
+ export async function setTestType(cfg, xrayToken, issueId, typeName = "Generic") {
386
+ await graphql(cfg, xrayToken, `
387
+ mutation ($issueId: String!, $testType: UpdateTestTypeInput!) {
388
+ updateTestType(issueId: $issueId, testType: $testType) {
389
+ issueId
390
+ testType { name kind }
391
+ }
392
+ }
393
+ `, { issueId: String(issueId), testType: { name: typeName } });
394
+ }
395
+
396
+ /** @deprecated Use setTestType("Generic") */
397
+ export async function setTestTypeAutomated(cfg, xrayToken, issueId) {
398
+ return setTestType(cfg, xrayToken, issueId, "Generic");
399
+ }
245
400
 
246
- // First, get existing steps
247
- const getResponse = await axios.post(
401
+ /**
402
+ * Add a single test step.
403
+ * @param {object} cfg
404
+ * @param {string} xrayToken
405
+ * @param {string} issueId
406
+ * @param {object} step { action, data, expected_result }
407
+ * @returns {Promise<object>}
408
+ */
409
+ export async function addTestStep(cfg, xrayToken, issueId, step) {
410
+ const data = await graphql(cfg, xrayToken, `
411
+ mutation ($issueId: String!, $step: CreateStepInput!) {
412
+ addTestStep(issueId: $issueId, step: $step) {
413
+ id
414
+ action
415
+ }
416
+ }
417
+ `, {
418
+ issueId: String(issueId),
419
+ step: {
420
+ action: step.action,
421
+ data: step.data || "",
422
+ result: step.expected_result || "",
423
+ },
424
+ });
425
+ return data.addTestStep;
426
+ }
427
+
428
+ /**
429
+ * Remove all existing test steps (for update/replace flow).
430
+ * Uses the removeAllTestSteps mutation directly — faster than looping.
431
+ * @param {object} cfg
432
+ * @param {string} xrayToken
433
+ * @param {string} issueId
434
+ * @returns {Promise<number>} count of removed steps
435
+ */
436
+ export async function removeAllTestSteps(cfg, xrayToken, issueId) {
437
+ // Get existing steps count first
438
+ const response = await axios.post(
248
439
  cfg.xrayGraphqlUrl,
249
440
  {
250
441
  query: `query ($issueId: String!) {
251
442
  getTest(issueId: $issueId) {
252
- steps {
253
- id
254
- }
443
+ steps { id }
255
444
  }
256
445
  }`,
257
446
  variables: { issueId: String(issueId) },
258
447
  },
259
- { httpsAgent, headers }
448
+ { httpsAgent, headers: xrayHeaders(xrayToken) }
260
449
  );
261
450
 
262
- const steps = getResponse.data?.data?.getTest?.steps || [];
451
+ const steps = response.data?.data?.getTest?.steps || [];
452
+ if (steps.length === 0) return 0;
263
453
 
264
- // Remove each step
265
- for (const step of steps) {
266
- await axios.post(
267
- cfg.xrayGraphqlUrl,
268
- {
269
- query: `mutation ($issueId: String!, $stepId: String!) {
270
- removeTestStep(issueId: $issueId, stepId: $stepId)
271
- }`,
272
- variables: { issueId: String(issueId), stepId: String(step.id) },
273
- },
274
- { httpsAgent, headers }
275
- );
276
- }
454
+ // Use removeAllTestSteps mutation (single call instead of N calls)
455
+ await graphql(cfg, xrayToken, `
456
+ mutation ($issueId: String!) {
457
+ removeAllTestSteps(issueId: $issueId)
458
+ }
459
+ `, { issueId: String(issueId) });
277
460
 
278
461
  return steps.length;
279
462
  }
280
463
 
464
+ /**
465
+ * Update the unstructured (Generic) test definition.
466
+ * @param {object} cfg
467
+ * @param {string} xrayToken
468
+ * @param {string} issueId
469
+ * @param {string} definition Free-form test definition string
470
+ */
471
+ export async function updateTestDefinition(cfg, xrayToken, issueId, definition) {
472
+ await graphql(cfg, xrayToken, `
473
+ mutation ($issueId: String!, $unstructured: String!) {
474
+ updateUnstructuredTestDefinition(issueId: $issueId, unstructured: $unstructured) {
475
+ issueId
476
+ }
477
+ }
478
+ `, { issueId: String(issueId), unstructured: definition });
479
+ }
480
+
481
+ /**
482
+ * Update test's folder in the Xray Test Repository.
483
+ * @param {object} cfg
484
+ * @param {string} xrayToken
485
+ * @param {string} issueId
486
+ * @param {string} folderPath e.g. "/API/Auth"
487
+ */
488
+ export async function updateTestFolder(cfg, xrayToken, issueId, folderPath) {
489
+ await graphql(cfg, xrayToken, `
490
+ mutation ($issueId: String!, $folderPath: String!) {
491
+ updateTestFolder(issueId: $issueId, folderPath: $folderPath) {
492
+ warnings
493
+ }
494
+ }
495
+ `, { issueId: String(issueId), folderPath });
496
+ }
497
+
498
+ // ─── Xray GraphQL — Test Plan mutations ───────────────────────────────────────
499
+
500
+ /**
501
+ * Create a new Test Plan issue via GraphQL.
502
+ * @param {object} cfg
503
+ * @param {string} xrayToken
504
+ * @param {object} opts { projectId, summary, description }
505
+ * @returns {Promise<{issueId: string, warnings: string[]}>}
506
+ */
507
+ export async function createTestPlan(cfg, xrayToken, opts) {
508
+ const data = await graphql(cfg, xrayToken, `
509
+ mutation ($projectId: String!, $summary: String!, $description: String) {
510
+ createTestPlan(projectId: $projectId, summary: $summary, description: $description) {
511
+ testPlan {
512
+ issueId
513
+ projectId
514
+ }
515
+ warnings
516
+ }
517
+ }
518
+ `, {
519
+ projectId: opts.projectId || cfg.jiraProjectKey,
520
+ summary: opts.summary,
521
+ description: opts.description || null,
522
+ });
523
+ return data.createTestPlan;
524
+ }
525
+
526
+ /**
527
+ * Add tests to a Test Plan.
528
+ * @param {object} cfg
529
+ * @param {string} xrayToken
530
+ * @param {string} planIssueId
531
+ * @param {string[]} testIssueIds Numeric JIRA issue IDs of test issues
532
+ * @returns {Promise<object>}
533
+ */
534
+ export async function addTestsToTestPlan(cfg, xrayToken, planIssueId, testIssueIds) {
535
+ const data = await graphql(cfg, xrayToken, `
536
+ mutation ($issueId: String!, $testIssueIds: [String]!) {
537
+ addTestsToTestPlan(issueId: $issueId, testIssueIds: $testIssueIds) {
538
+ addedTests
539
+ warnings
540
+ }
541
+ }
542
+ `, {
543
+ issueId: String(planIssueId),
544
+ testIssueIds: testIssueIds.map(String),
545
+ });
546
+ return data.addTestsToTestPlan;
547
+ }
548
+
549
+ /**
550
+ * Remove tests from a Test Plan.
551
+ * @param {object} cfg
552
+ * @param {string} xrayToken
553
+ * @param {string} planIssueId
554
+ * @param {string[]} testIssueIds
555
+ */
556
+ export async function removeTestsFromTestPlan(cfg, xrayToken, planIssueId, testIssueIds) {
557
+ await graphql(cfg, xrayToken, `
558
+ mutation ($issueId: String!, $testIssueIds: [String]!) {
559
+ removeTestsFromTestPlan(issueId: $issueId, testIssueIds: $testIssueIds) {
560
+ warnings
561
+ }
562
+ }
563
+ `, {
564
+ issueId: String(planIssueId),
565
+ testIssueIds: testIssueIds.map(String),
566
+ });
567
+ }
568
+
569
+ // ─── Xray GraphQL — Folder mutations ──────────────────────────────────────────
570
+
571
+ /**
572
+ * Create a folder in the Xray Test Repository.
573
+ * @param {object} cfg
574
+ * @param {string} xrayToken
575
+ * @param {string} projectId JIRA project ID (numeric)
576
+ * @param {string} folderPath Full path e.g. "/API/Auth"
577
+ * @returns {Promise<object>}
578
+ */
579
+ export async function createFolder(cfg, xrayToken, projectId, folderPath) {
580
+ // Derive name and parent path
581
+ const parts = folderPath.split("/").filter(Boolean);
582
+ const name = parts[parts.length - 1];
583
+ const parentPath = "/" + parts.slice(0, -1).join("/");
584
+
585
+ const data = await graphql(cfg, xrayToken, `
586
+ mutation ($projectId: String!, $path: String!) {
587
+ createFolder(projectId: $projectId, path: $path) {
588
+ folder { name path testsCount }
589
+ warnings
590
+ }
591
+ }
592
+ `, { projectId: String(projectId), path: folderPath });
593
+ return data.createFolder;
594
+ }
595
+
596
+ /**
597
+ * Move tests into a repository folder.
598
+ * @param {object} cfg
599
+ * @param {string} xrayToken
600
+ * @param {string} projectId
601
+ * @param {string} folderPath e.g. "/API/Auth"
602
+ * @param {string[]} testIssueIds
603
+ * @returns {Promise<object>}
604
+ */
605
+ export async function addTestsToFolder(cfg, xrayToken, projectId, folderPath, testIssueIds) {
606
+ const data = await graphql(cfg, xrayToken, `
607
+ mutation ($projectId: String!, $path: String!, $testIssueIds: [String]!) {
608
+ addTestsToFolder(projectId: $projectId, path: $path, testIssueIds: $testIssueIds) {
609
+ folder { path testsCount }
610
+ warnings
611
+ }
612
+ }
613
+ `, {
614
+ projectId: String(projectId),
615
+ path: folderPath,
616
+ testIssueIds: testIssueIds.map(String),
617
+ });
618
+ return data.addTestsToFolder;
619
+ }
620
+
621
+ // ─── Xray GraphQL — Test Execution mutations ───────────────────────────────────
622
+
623
+ /**
624
+ * Add test environments to an existing test execution.
625
+ * @param {object} cfg
626
+ * @param {string} xrayToken
627
+ * @param {string} execIssueId JIRA issue ID of execution (numeric)
628
+ * @param {string[]} environments e.g. ["IOP-QA"]
629
+ */
630
+ export async function addTestEnvironmentsToTestExecution(cfg, xrayToken, execIssueId, environments) {
631
+ await graphql(cfg, xrayToken, `
632
+ mutation ($issueId: String!, $testEnvironments: [String]!) {
633
+ addTestEnvironmentsToTestExecution(issueId: $issueId, testEnvironments: $testEnvironments) {
634
+ addedTestEnvironments
635
+ warnings
636
+ }
637
+ }
638
+ `, {
639
+ issueId: String(execIssueId),
640
+ testEnvironments: environments,
641
+ });
642
+ }
643
+
281
644
  // ─── Issue linking ─────────────────────────────────────────────────────────────
282
645
 
283
646
  /**
284
- * Link a test issue to a container (Test Execution or Test Plan) via JIRA issue links.
285
- *
647
+ * Link a test issue to a container via JIRA issue links.
286
648
  * @param {object} cfg
287
- * @param {string} testKey Inward issue key (Test)
288
- * @param {string} containerKey Outward issue key (Execution / Plan)
289
- * @returns {Promise<boolean>} true if linked successfully
649
+ * @param {string} testKey Inward issue key (Test)
650
+ * @param {string} containerKey Outward issue key (Execution / Plan)
290
651
  */
291
652
  export async function linkIssues(cfg, testKey, containerKey) {
292
- const payload = {
293
- type: { name: "Test" },
294
- inwardIssue: { key: testKey },
295
- outwardIssue: { key: containerKey },
296
- };
297
-
298
653
  await axios.post(
299
654
  `${cfg.jiraUrl}/rest/api/3/issueLink`,
300
- payload,
655
+ {
656
+ type: { name: "Test" },
657
+ inwardIssue: { key: testKey },
658
+ outwardIssue: { key: containerKey },
659
+ },
301
660
  { httpsAgent, headers: jiraHeaders(cfg) }
302
661
  );
303
-
304
662
  return true;
305
663
  }
306
664
 
307
665
  /**
308
666
  * Link multiple test keys to a container key.
309
- *
310
667
  * @param {object} cfg
311
668
  * @param {string[]} testKeys
312
- * @param {string} containerKey Test Execution or Test Plan key
669
+ * @param {string} containerKey
313
670
  * @returns {Promise<{linked: string[], failed: string[]}>}
314
671
  */
315
672
  export async function linkMultiple(cfg, testKeys, containerKey) {
@@ -329,20 +686,17 @@ export async function linkMultiple(cfg, testKeys, containerKey) {
329
686
  return { linked, failed };
330
687
  }
331
688
 
332
- // ─── Xray import endpoints ─────────────────────────────────────────────────────
689
+ // ─── Xray REST v2 — Import execution results ───────────────────────────────────
333
690
 
334
691
  /**
335
- * Import JUnit/XUnit XML results into Xray Cloud.
336
- *
692
+ * Import JUnit/XUnit XML results.
337
693
  * @param {object} cfg
338
- * @param {string} xrayToken JWT
339
- * @param {Buffer} xmlBuffer Raw XML file content
340
- * @param {string} testExecKey Test Execution key to associate results with
341
- * @returns {Promise<object>}
694
+ * @param {string} xrayToken
695
+ * @param {Buffer} xmlBuffer
696
+ * @param {string} [testExecKey]
342
697
  */
343
698
  export async function importResults(cfg, xrayToken, xmlBuffer, testExecKey) {
344
- const url = "https://xray.cloud.getxray.app/api/v2/import/execution/junit";
345
-
699
+ const url = `${cfg.xrayRestUrl}/import/execution/junit`;
346
700
  const params = {};
347
701
  if (testExecKey) params.testExecKey = testExecKey;
348
702
  if (cfg.jiraProjectKey) params.projectKey = cfg.jiraProjectKey;
@@ -360,17 +714,15 @@ export async function importResults(cfg, xrayToken, xmlBuffer, testExecKey) {
360
714
  }
361
715
 
362
716
  /**
363
- * Import test results using Xray's native JSON format.
364
- * This format supports detailed test information including custom fields and evidence.
365
- *
717
+ * Import test results using Xray JSON format (single JSON body).
718
+ * Creates a new Test Execution automatically.
366
719
  * @param {object} cfg
367
- * @param {string} xrayToken JWT
368
- * @param {object} xrayJson Xray JSON format object
720
+ * @param {string} xrayToken
721
+ * @param {object} xrayJson Full Xray JSON payload (info + tests)
369
722
  * @returns {Promise<object>}
370
723
  */
371
724
  export async function importResultsXrayJson(cfg, xrayToken, xrayJson) {
372
- const url = "https://xray.cloud.getxray.app/api/v2/import/execution";
373
-
725
+ const url = `${cfg.xrayRestUrl}/import/execution`;
374
726
  try {
375
727
  const response = await axios.post(url, xrayJson, {
376
728
  httpsAgent,
@@ -379,59 +731,175 @@ export async function importResultsXrayJson(cfg, xrayToken, xrayJson) {
379
731
  "Content-Type": "application/json",
380
732
  },
381
733
  });
734
+ return response.data;
735
+ } catch (error) {
736
+ _handleImportError(error);
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Import test results using Xray JSON multipart format.
742
+ * Provides full control over Jira issue fields on the created Test Execution.
743
+ *
744
+ * @param {object} cfg
745
+ * @param {string} xrayToken
746
+ * @param {object} xrayJson Xray results JSON (info.testPlanKey etc. — note: 'info' in this part IS used by multipart)
747
+ * @param {object} [jiraFieldsJson] Jira issue fields for the new Test Execution (optional)
748
+ * @returns {Promise<object>}
749
+ */
750
+ export async function importResultsMultipart(cfg, xrayToken, xrayJson, jiraFieldsJson = null) {
751
+ const url = `${cfg.xrayRestUrl}/import/execution/multipart`;
752
+
753
+ const form = new FormData();
754
+ form.append("results", JSON.stringify(xrayJson), {
755
+ contentType: "application/json",
756
+ filename: "results.json",
757
+ });
758
+
759
+ if (jiraFieldsJson) {
760
+ form.append("info", JSON.stringify(jiraFieldsJson), {
761
+ contentType: "application/json",
762
+ filename: "info.json",
763
+ });
764
+ }
382
765
 
766
+ try {
767
+ const response = await axios.post(url, form, {
768
+ httpsAgent,
769
+ headers: {
770
+ Authorization: `Bearer ${xrayToken}`,
771
+ ...form.getHeaders(),
772
+ },
773
+ });
383
774
  return response.data;
384
775
  } catch (error) {
385
- // Enhanced error reporting for Xray API issues
386
- if (error.response) {
387
- const status = error.response.status;
388
- const data = error.response.data;
389
-
390
- let errorMsg = `Xray API returned ${status} error`;
391
-
392
- if (typeof data === 'string') {
393
- errorMsg += `: ${data}`;
394
- } else if (data?.error) {
395
- errorMsg += `: ${data.error}`;
396
- } else if (data?.message) {
397
- errorMsg += `: ${data.message}`;
398
- } else if (data) {
399
- errorMsg += `: ${JSON.stringify(data)}`;
400
- }
401
-
402
- logger.error(errorMsg);
403
-
404
- // Common issues help
405
- if (status === 400) {
406
- logger.warn('\n💡 Common causes of 400 errors:');
407
- logger.warn(' • Missing test annotations in Playwright tests');
408
- logger.warn(' • Invalid test key format (should be PROJ-123)');
409
- logger.warn(' • Test execution key not found');
410
- logger.warn(' • Malformed JSON structure');
411
- logger.warn(' • Test keys reference non-existent tests\n');
412
- }
413
-
414
- throw new Error(errorMsg);
776
+ _handleImportError(error);
777
+ }
778
+ }
779
+
780
+ // ─── Xray REST v2 — Bulk test import ──────────────────────────────────────────
781
+
782
+ /**
783
+ * Import tests in bulk (async returns a jobId).
784
+ * Max 1000 tests per call. Use checkImportJobStatus() to poll.
785
+ * @param {object} cfg
786
+ * @param {string} xrayToken
787
+ * @param {object[]} tests Array of Xray test import objects
788
+ * @returns {Promise<{jobId: string}>}
789
+ */
790
+ export async function importTestsBulk(cfg, xrayToken, tests) {
791
+ const url = `${cfg.xrayRestUrl}/import/test/bulk`;
792
+
793
+ const response = await axios.post(url, tests, {
794
+ httpsAgent,
795
+ headers: {
796
+ Authorization: `Bearer ${xrayToken}`,
797
+ "Content-Type": "application/json",
798
+ },
799
+ });
800
+
801
+ return response.data; // { jobId: "..." }
802
+ }
803
+
804
+ /**
805
+ * Check the status of a bulk test import job.
806
+ * @param {object} cfg
807
+ * @param {string} xrayToken
808
+ * @param {string} jobId
809
+ * @returns {Promise<{status: string, result?: object}>}
810
+ */
811
+ export async function checkImportJobStatus(cfg, xrayToken, jobId) {
812
+ const url = `${cfg.xrayRestUrl}/import/test/bulk/${jobId}/status`;
813
+
814
+ const response = await axios.get(url, {
815
+ httpsAgent,
816
+ headers: { Authorization: `Bearer ${xrayToken}` },
817
+ });
818
+
819
+ return response.data;
820
+ }
821
+
822
+ /**
823
+ * Poll a bulk import job until complete or timeout.
824
+ * @param {object} cfg
825
+ * @param {string} xrayToken
826
+ * @param {string} jobId
827
+ * @param {object} [opts] { pollInterval: ms, timeout: ms }
828
+ * @returns {Promise<object>} Final job status result
829
+ */
830
+ export async function waitForImportJob(cfg, xrayToken, jobId, opts = {}) {
831
+ const pollInterval = opts.pollInterval || 2000;
832
+ const timeout = opts.timeout || 60000;
833
+ const start = Date.now();
834
+
835
+ while (Date.now() - start < timeout) {
836
+ const status = await checkImportJobStatus(cfg, xrayToken, jobId);
837
+ if (status.status === "successful" || status.status === "failed") {
838
+ return status;
415
839
  }
416
-
417
- throw error;
840
+ await sleep(pollInterval);
418
841
  }
842
+
843
+ throw new Error(`Bulk import job ${jobId} timed out after ${timeout}ms`);
844
+ }
845
+
846
+ // ─── Xray REST v2 — Attachments ───────────────────────────────────────────────
847
+
848
+ /**
849
+ * Upload an attachment to Xray.
850
+ * @param {object} cfg
851
+ * @param {string} xrayToken
852
+ * @param {Buffer} fileBuffer
853
+ * @param {string} filename
854
+ * @param {string} [contentType]
855
+ * @returns {Promise<{id: string, filename: string}>}
856
+ */
857
+ export async function addAttachment(cfg, xrayToken, fileBuffer, filename, contentType = "application/octet-stream") {
858
+ const url = `${cfg.xrayRestUrl}/attachments`;
859
+
860
+ const form = new FormData();
861
+ form.append("file", fileBuffer, { filename, contentType });
862
+
863
+ const response = await axios.post(url, form, {
864
+ httpsAgent,
865
+ headers: {
866
+ Authorization: `Bearer ${xrayToken}`,
867
+ ...form.getHeaders(),
868
+ },
869
+ });
870
+
871
+ return response.data;
872
+ }
873
+
874
+ /**
875
+ * Get an attachment by its ID.
876
+ * @param {object} cfg
877
+ * @param {string} xrayToken
878
+ * @param {string} attachmentId
879
+ * @returns {Promise<Buffer>}
880
+ */
881
+ export async function getAttachment(cfg, xrayToken, attachmentId) {
882
+ const url = `${cfg.xrayRestUrl}/attachments/${attachmentId}`;
883
+
884
+ const response = await axios.get(url, {
885
+ httpsAgent,
886
+ responseType: "arraybuffer",
887
+ headers: { Authorization: `Bearer ${xrayToken}` },
888
+ });
889
+
890
+ return response.data;
419
891
  }
420
892
 
421
893
  // ─── Retry wrapper ─────────────────────────────────────────────────────────────
422
894
 
423
895
  /**
424
- * Retry an async function with exponential backoff.
425
- * Used to handle the Xray GraphQL indexing delay after new issue creation.
426
- *
427
- * Backoff sequence: 2s → 4s → 8s → 16s → 32s (baseDelay × 2^attempt)
428
- *
429
- * @param {Function} fn Async function to retry
430
- * @param {object} [opts] Options
431
- * @param {number} [opts.maxRetries=5] Max attempts
432
- * @param {number} [opts.baseDelay=2000] Base delay in ms
433
- * @param {string} [opts.retryOn] Error substring to match for retry
434
- * @returns {Promise<*>}
896
+ * Retry with exponential backoff handles Xray indexing delays.
897
+ * Backoff: 2s 4s 8s 16s 32s
898
+ * @param {Function} fn
899
+ * @param {object} [opts]
900
+ * @param {number} [opts.maxRetries=5]
901
+ * @param {number} [opts.baseDelay=2000]
902
+ * @param {string} [opts.retryOn] Error substring to match for retry
435
903
  */
436
904
  export async function withRetry(fn, opts = {}) {
437
905
  const maxRetries = opts.maxRetries ?? 5;
@@ -451,19 +919,10 @@ export async function withRetry(fn, opts = {}) {
451
919
  lastError = err;
452
920
  const msg = err.message || "";
453
921
  if (!msg.includes(retryOn)) {
454
- // Non-retryable error — detect impersonation issues
455
922
  if (msg.includes("disallowed to impersonate") || msg.includes("no valid active user exists")) {
456
923
  throw new Error(
457
- `Xray user authentication mismatch detected.\n\n` +
458
- `This error occurs when:\n` +
459
- `1. Your JIRA_EMAIL doesn't match the Xray API Key owner\n` +
460
- `2. The user doesn't have an active Xray license\n` +
461
- `3. The Xray API Key was created by a different user\n\n` +
462
- `Solutions:\n` +
463
- `- Ensure JIRA_EMAIL matches the Xray API Key owner's email\n` +
464
- `- Verify you have an active Xray license assigned\n` +
465
- `- Regenerate Xray API Key with the same user as JIRA_API_TOKEN\n` +
466
- `- Contact your Xray administrator\n\n` +
924
+ `Xray user authentication mismatch.\n\n` +
925
+ `Ensure JIRA_EMAIL matches the Xray API Key owner.\n` +
467
926
  `Original error: ${msg}`
468
927
  );
469
928
  }
@@ -473,3 +932,121 @@ export async function withRetry(fn, opts = {}) {
473
932
  }
474
933
  throw lastError;
475
934
  }
935
+
936
+ // ─── Internal error handler ────────────────────────────────────────────────────
937
+
938
+ function _handleImportError(error) {
939
+ if (error.response) {
940
+ const status = error.response.status;
941
+ const data = error.response.data;
942
+
943
+ let errorMsg = `Xray API returned ${status} error`;
944
+ if (typeof data === "string") errorMsg += `: ${data}`;
945
+ else if (data?.error) errorMsg += `: ${data.error}`;
946
+ else if (data?.message) errorMsg += `: ${data.message}`;
947
+ else if (data) errorMsg += `: ${JSON.stringify(data)}`;
948
+
949
+ logger.error(errorMsg);
950
+
951
+ if (status === 400) {
952
+ logger.warn("Common causes: missing test annotations, invalid test key, malformed JSON structure.");
953
+ }
954
+
955
+ throw new Error(errorMsg);
956
+ }
957
+ throw error;
958
+ }
959
+
960
+ // ─── Xray GraphQL — Test Execution mutations (cont.) ──────────────────────────
961
+
962
+ /**
963
+ * Add tests to an existing Test Execution by their Xray issue IDs.
964
+ * @param {object} cfg
965
+ * @param {string} xrayToken
966
+ * @param {string} execIssueId Numeric Xray issue ID of the Test Execution
967
+ * @param {string[]} testIssueIds Numeric Xray issue IDs of the tests to add
968
+ */
969
+ export async function addTestsToTestExecution(cfg, xrayToken, execIssueId, testIssueIds) {
970
+ const { data } = await graphql(cfg, xrayToken, `
971
+ mutation ($issueId: String!, $testIssueIds: [String]!) {
972
+ addTestsToTestExecution(issueId: $issueId, testIssueIds: $testIssueIds) {
973
+ addedTests
974
+ warnings
975
+ }
976
+ }
977
+ `, {
978
+ issueId: String(execIssueId),
979
+ testIssueIds: testIssueIds.map(String),
980
+ });
981
+ return data.addTestsToTestExecution;
982
+ }
983
+
984
+ // ─── High-level — Test Execution creation ─────────────────────────────────────
985
+
986
+ /**
987
+ * Create a new Test Execution in JIRA and configure it for Xray.
988
+ *
989
+ * Steps:
990
+ * 1. Create the JIRA issue of type "Test Execution"
991
+ * 2. Apply environment labels via GraphQL mutation
992
+ * 3. If testIssueIds supplied, add those tests to the execution
993
+ *
994
+ * @param {object} cfg
995
+ * @param {string} xrayToken
996
+ * @param {object} opts
997
+ * @param {string} opts.summary Execution title
998
+ * @param {string} [opts.description] Optional description
999
+ * @param {string[]} [opts.environments] e.g. ["IOP-QA"]
1000
+ * @param {string[]} [opts.testIssueIds] Numeric Xray issue IDs to add
1001
+ * @param {string} [opts.testPlanKey] If set, link execution to this plan
1002
+ * @returns {Promise<{key: string, id: string}>}
1003
+ */
1004
+ export async function createTestExecution(cfg, xrayToken, opts) {
1005
+ const {
1006
+ summary,
1007
+ description,
1008
+ environments = [],
1009
+ testIssueIds = [],
1010
+ testPlanKey,
1011
+ } = opts;
1012
+
1013
+ // 1. Create JIRA issue
1014
+ const issue = await createIssue(cfg, "Test Execution", {
1015
+ summary: summary || `Test Execution — ${new Date().toLocaleString()}`,
1016
+ description: description || summary || "",
1017
+ });
1018
+
1019
+ logger.step(`Test Execution created: ${issue.key}`);
1020
+
1021
+ // 2. Add environment labels
1022
+ if (environments.length > 0) {
1023
+ try {
1024
+ await addTestEnvironmentsToTestExecution(cfg, xrayToken, issue.id, environments);
1025
+ logger.step(`Environments set: ${environments.join(", ")}`);
1026
+ } catch (err) {
1027
+ logger.warn(`Could not set environments: ${err.message}`);
1028
+ }
1029
+ }
1030
+
1031
+ // 3. Link to Test Plan if provided
1032
+ if (testPlanKey) {
1033
+ try {
1034
+ await linkIssues(cfg, issue.key, testPlanKey);
1035
+ logger.step(`Linked to Test Plan: ${testPlanKey}`);
1036
+ } catch (err) {
1037
+ logger.warn(`Could not link to Test Plan ${testPlanKey}: ${err.message}`);
1038
+ }
1039
+ }
1040
+
1041
+ // 4. Add specific tests
1042
+ if (testIssueIds.length > 0) {
1043
+ try {
1044
+ await addTestsToTestExecution(cfg, xrayToken, issue.id, testIssueIds);
1045
+ logger.step(`${testIssueIds.length} test(s) added to execution`);
1046
+ } catch (err) {
1047
+ logger.warn(`Could not add tests to execution: ${err.message}`);
1048
+ }
1049
+ }
1050
+
1051
+ return { key: issue.key, id: issue.id };
1052
+ }