@msalaam/xray-qe-toolkit 1.5.0 → 1.6.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.
@@ -18,10 +18,16 @@ import {
18
18
  removeAllTestSteps,
19
19
  updateTestDefinition,
20
20
  addTestsToTestPlan,
21
+ removeTestsFromTestPlan,
22
+ getTestPlan,
21
23
  createTestSet,
22
24
  addTestsToTestSet,
25
+ addPreconditionsToTest,
23
26
  createFolder,
24
27
  addTestsToFolder,
28
+ importTestsBulk,
29
+ waitForImportJob,
30
+ searchIssues,
25
31
  withRetry,
26
32
  } from "./xrayClient.js";
27
33
 
@@ -60,8 +66,10 @@ export function saveMapping(mappingPath, mapping) {
60
66
  * Flow per test:
61
67
  * 1. If test_id exists in mapping → update JIRA issue + replace steps
62
68
  * 2. If test_id is new → create JIRA Test issue, set type (Generic/Manual), add steps
63
- * 3. Save mapping after each test (crash-safe)
64
- * 4. 300ms rate-limit delay between tests
69
+ * 3. When new-test count cfg.bulkImportThreshold, new tests are created via bulk REST API
70
+ * 4. After create/update, link any preconditions defined on the test
71
+ * 5. Save mapping after each test (crash-safe)
72
+ * 6. 300ms rate-limit delay between serial tests
65
73
  *
66
74
  * @param {object} cfg Config from loadConfig()
67
75
  * @param {object[]} tests Array of test definitions from tests.json
@@ -74,104 +82,227 @@ export async function buildAndPush(cfg, tests, mapping, xrayToken) {
74
82
  const updated = [];
75
83
  const failed = [];
76
84
 
77
- for (const test of tests) {
78
- try {
79
- const existing = mapping[test.test_id];
80
- const testType = test.xray?.testType || "Generic";
81
- // Merge tags into labels (deduped)
82
- const labels = [...new Set([
83
- ...(test.xray?.labels || []),
84
- ...(test.tags || []),
85
- ])];
85
+ const toCreate = tests.filter((t) => !mapping[t.test_id]);
86
+ const toUpdate = tests.filter((t) => mapping[t.test_id]);
86
87
 
87
- if (existing) {
88
- // ── Update existing test ──────────────────────────────
89
- logger.send(`${test.test_id} (update ${existing.key})`);
88
+ // ── Bulk create path ───────────────────────────────────────────────────────
89
+ if (toCreate.length > 0 && toCreate.length >= (cfg.bulkImportThreshold ?? 50)) {
90
+ logger.send(`Bulk creating ${toCreate.length} new test(s) via Xray REST...`);
91
+
92
+ const bulkPayload = toCreate.map((test) => {
93
+ const testType = test.xray?.testType || "Generic";
94
+ const labels = [...new Set([...(test.xray?.labels || []), ...(test.tags || [])])];
90
95
 
91
- await updateIssue(cfg, existing.key, {
96
+ const obj = {
97
+ fields: {
98
+ project: { key: cfg.jiraProjectKey },
92
99
  summary: test.xray.summary,
93
- description: test.xray.description,
94
- priority: test.xray.priority,
95
- labels,
100
+ issuetype: { name: "Test" },
101
+ ...(test.xray.description ? { description: test.xray.description } : {}),
102
+ ...(test.xray.priority ? { priority: { name: test.xray.priority } } : {}),
103
+ ...(labels.length > 0 ? { labels } : {}),
104
+ },
105
+ testtype: testType,
106
+ };
107
+
108
+ if (testType === "Generic" && test.xray?.definition) {
109
+ obj.unstructured = test.xray.definition;
110
+ } else if (testType === "Manual" && test.xray?.steps?.length > 0) {
111
+ obj.steps = test.xray.steps.map((s) => ({
112
+ action: s.action,
113
+ data: s.data || "",
114
+ result: s.expected_result || "",
115
+ }));
116
+ }
117
+
118
+ return obj;
119
+ });
120
+
121
+ try {
122
+ const { jobId } = await importTestsBulk(cfg, xrayToken, bulkPayload);
123
+ logger.step(`Bulk job started: ${jobId} — polling...`);
124
+
125
+ const jobResult = await waitForImportJob(cfg, xrayToken, jobId, { timeout: 120000 });
126
+
127
+ if (jobResult.status === "successful") {
128
+ const createdIssues = jobResult.result?.updatedOrCreatedTests || [];
129
+
130
+ createdIssues.forEach((issue, idx) => {
131
+ const test = toCreate[idx];
132
+ if (!test) return;
133
+ mapping[test.test_id] = { key: issue.key, id: String(issue.id) };
134
+ created.push(issue.key);
135
+ logger.step(`${test.test_id} → ${issue.key}`);
96
136
  });
97
- logger.step("Fields updated");
98
-
99
- // Replace steps for Manual tests
100
- if (testType === "Manual" && test.xray.steps && test.xray.steps.length > 0) {
101
- await withRetry(
102
- async () => {
103
- const removed = await removeAllTestSteps(cfg, xrayToken, existing.id);
104
- logger.debug(`Removed ${removed} existing step(s)`);
105
-
106
- for (const step of test.xray.steps) {
107
- await addTestStep(cfg, xrayToken, existing.id, step);
108
- }
109
- },
110
- { retryOn: "issueId provided is not valid" }
111
- );
112
- logger.step(`${test.xray.steps.length} step(s) replaced`);
113
- }
114
137
 
115
- // Update Generic definition
116
- if (testType === "Generic" && test.xray?.definition) {
117
- await withRetry(
118
- async () => updateTestDefinition(cfg, xrayToken, existing.id, test.xray.definition),
119
- { retryOn: "issueId provided is not valid" }
120
- );
138
+ saveMapping(cfg.mappingPath, mapping);
139
+
140
+ // Link preconditions for bulk-created tests
141
+ for (const test of toCreate) {
142
+ if (!test.preconditions?.length) continue;
143
+ const entry = mapping[test.test_id];
144
+ if (!entry?.id) continue;
145
+ try {
146
+ const precondIds = await resolvePreconditionIds(cfg, test.preconditions);
147
+ if (precondIds.length > 0) {
148
+ await addPreconditionsToTest(cfg, xrayToken, entry.id, precondIds);
149
+ logger.step(`Preconditions linked: ${test.preconditions.join(", ")}`);
150
+ }
151
+ } catch (err) {
152
+ logger.warn(`Precondition linking failed for ${test.test_id}: ${err.message}`);
153
+ }
121
154
  }
122
-
123
- updated.push(existing.key);
124
155
  } else {
125
- // ── Create new test ───────────────────────────────────
126
- logger.send(`${test.test_id}`);
156
+ logger.warn(`Bulk import job failed falling back to serial create`);
157
+ // Fallback: push failing tests serially
158
+ for (const test of toCreate) {
159
+ await pushSingleTest(cfg, xrayToken, test, mapping, created, failed);
160
+ }
161
+ saveMapping(cfg.mappingPath, mapping);
162
+ }
163
+ } catch (err) {
164
+ logger.warn(`Bulk import failed (${err.message}) — falling back to serial create`);
165
+ for (const test of toCreate) {
166
+ await pushSingleTest(cfg, xrayToken, test, mapping, created, failed);
167
+ }
168
+ saveMapping(cfg.mappingPath, mapping);
169
+ }
170
+ } else {
171
+ // ── Serial create path ─────────────────────────────────────────────────
172
+ for (const test of toCreate) {
173
+ await pushSingleTest(cfg, xrayToken, test, mapping, created, failed);
174
+ }
175
+ }
127
176
 
128
- const issue = await createIssue(cfg, "Test", {
129
- summary: test.xray.summary,
130
- description: test.xray.description,
131
- priority: test.xray.priority,
132
- labels,
133
- });
134
- logger.step(`${issue.key} (ID: ${issue.id})`);
177
+ // ── Serial update path (always serial — needs step diff) ──────────────────
178
+ for (const test of toUpdate) {
179
+ await pushSingleTest(cfg, xrayToken, test, mapping, updated, failed);
180
+ }
181
+
182
+ return { created, updated, failed };
183
+ }
184
+
185
+ // ─── Internal helpers ──────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Resolve JIRA keys/IDs for precondition issue keys.
189
+ * Preconditions are referenced by JIRA key in tests.json.
190
+ */
191
+ async function resolvePreconditionIds(cfg, preconditionKeys) {
192
+ const ids = [];
193
+ for (const key of preconditionKeys) {
194
+ try {
195
+ const issues = await searchIssues(cfg, `issue = "${key}"`, ["id"], 1);
196
+ if (issues[0]?.id) ids.push(String(issues[0].id));
197
+ } catch { /* skip unresolvable */ }
198
+ }
199
+ return ids;
200
+ }
135
201
 
136
- // Set test type + add steps/definition with retry (Xray indexing delay)
202
+ /**
203
+ * Create or update a single test and link preconditions.
204
+ */
205
+ async function pushSingleTest(cfg, xrayToken, test, mapping, successList, failedList) {
206
+ try {
207
+ const existing = mapping[test.test_id];
208
+ const testType = test.xray?.testType || "Generic";
209
+ const labels = [...new Set([...(test.xray?.labels || []), ...(test.tags || [])])];
210
+
211
+ if (existing) {
212
+ // ── Update ──────────────────────────────────────────────────────────
213
+ logger.send(`${test.test_id} (update → ${existing.key})`);
214
+
215
+ await updateIssue(cfg, existing.key, {
216
+ summary: test.xray.summary,
217
+ description: test.xray.description,
218
+ priority: test.xray.priority,
219
+ labels,
220
+ });
221
+ logger.step("Fields updated");
222
+
223
+ if (testType === "Manual" && test.xray.steps && test.xray.steps.length > 0) {
137
224
  await withRetry(
138
225
  async () => {
139
- await setTestType(cfg, xrayToken, issue.id, testType);
140
-
141
- if (testType === "Manual" && test.xray.steps && test.xray.steps.length > 0) {
142
- for (const step of test.xray.steps) {
143
- await addTestStep(cfg, xrayToken, issue.id, step);
144
- }
145
- } else if (testType === "Generic" && test.xray?.definition) {
146
- await updateTestDefinition(cfg, xrayToken, issue.id, test.xray.definition);
226
+ const removed = await removeAllTestSteps(cfg, xrayToken, existing.id);
227
+ logger.debug(`Removed ${removed} existing step(s)`);
228
+ for (const step of test.xray.steps) {
229
+ await addTestStep(cfg, xrayToken, existing.id, step);
147
230
  }
148
231
  },
149
232
  { retryOn: "issueId provided is not valid" }
150
233
  );
234
+ logger.step(`${test.xray.steps.length} step(s) replaced`);
235
+ }
151
236
 
152
- if (testType === "Manual" && test.xray.steps) {
153
- logger.step(`${test.xray.steps.length} step(s) added`);
154
- }
237
+ if (testType === "Generic" && test.xray?.definition) {
238
+ await withRetry(
239
+ async () => updateTestDefinition(cfg, xrayToken, existing.id, test.xray.definition),
240
+ { retryOn: "issueId provided is not valid" }
241
+ );
242
+ }
155
243
 
156
- mapping[test.test_id] = { key: issue.key, id: issue.id };
157
- created.push(issue.key);
244
+ successList.push(existing.key);
245
+ } else {
246
+ // ── Create ──────────────────────────────────────────────────────────
247
+ logger.send(`${test.test_id}`);
248
+
249
+ const issue = await createIssue(cfg, "Test", {
250
+ summary: test.xray.summary,
251
+ description: test.xray.description,
252
+ priority: test.xray.priority,
253
+ labels,
254
+ });
255
+ logger.step(`${issue.key} (ID: ${issue.id})`);
256
+
257
+ await withRetry(
258
+ async () => {
259
+ await setTestType(cfg, xrayToken, issue.id, testType);
260
+
261
+ if (testType === "Manual" && test.xray.steps && test.xray.steps.length > 0) {
262
+ for (const step of test.xray.steps) {
263
+ await addTestStep(cfg, xrayToken, issue.id, step);
264
+ }
265
+ } else if (testType === "Generic" && test.xray?.definition) {
266
+ await updateTestDefinition(cfg, xrayToken, issue.id, test.xray.definition);
267
+ }
268
+ },
269
+ { retryOn: "issueId provided is not valid" }
270
+ );
271
+
272
+ if (testType === "Manual" && test.xray.steps) {
273
+ logger.step(`${test.xray.steps.length} step(s) added`);
158
274
  }
159
275
 
160
- // Save mapping after each test (crash-safe)
161
- saveMapping(cfg.mappingPath, mapping);
162
- logger.blank();
276
+ mapping[test.test_id] = { key: issue.key, id: issue.id };
277
+ successList.push(issue.key);
278
+ }
163
279
 
164
- // Rate limiting
165
- await new Promise((r) => setTimeout(r, 300));
166
- } catch (err) {
167
- const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
168
- logger.stepFail(`Failed: ${detail}`);
169
- logger.blank();
170
- failed.push(test.test_id);
280
+ // ── Precondition linking ───────────────────────────────────────────────
281
+ if (test.preconditions?.length > 0) {
282
+ const issueId = existing?.id || mapping[test.test_id]?.id;
283
+ if (issueId) {
284
+ try {
285
+ const precondIds = await resolvePreconditionIds(cfg, test.preconditions);
286
+ if (precondIds.length > 0) {
287
+ await addPreconditionsToTest(cfg, xrayToken, issueId, precondIds);
288
+ logger.step(`Preconditions: ${test.preconditions.join(", ")}`);
289
+ }
290
+ } catch (err) {
291
+ logger.warn(`Precondition linking failed for ${test.test_id}: ${err.message}`);
292
+ }
293
+ }
171
294
  }
172
- }
173
295
 
174
- return { created, updated, failed };
296
+ saveMapping(cfg.mappingPath, mapping);
297
+ logger.blank();
298
+
299
+ await new Promise((r) => setTimeout(r, 300));
300
+ } catch (err) {
301
+ const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
302
+ logger.stepFail(`Failed: ${detail}`);
303
+ logger.blank();
304
+ failedList.push(test.test_id);
305
+ }
175
306
  }
176
307
 
177
308
  // ─── Test Set sync ───────────────────────────────────────────────────────────
@@ -194,14 +325,18 @@ export async function buildAndPush(cfg, tests, mapping, xrayToken) {
194
325
  * @returns {Promise<{created: number, synced: number, failed: string[]}>}
195
326
  */
196
327
  export async function syncTestSets(cfg, xrayToken, tests, mapping, mappingPath) {
197
- // Group test issue IDs by testSet name
328
+ // Group test issue IDs by testSet name — supports both string and array values
198
329
  const setGroups = new Map();
199
330
  for (const test of tests) {
200
331
  if (!test.testSet) continue;
201
332
  const mapped = mapping[test.test_id];
202
333
  if (!mapped?.id) continue;
203
- if (!setGroups.has(test.testSet)) setGroups.set(test.testSet, []);
204
- setGroups.get(test.testSet).push(String(mapped.id));
334
+ const setNames = Array.isArray(test.testSet) ? test.testSet : [test.testSet];
335
+ for (const setName of setNames) {
336
+ if (!setName) continue;
337
+ if (!setGroups.has(setName)) setGroups.set(setName, []);
338
+ setGroups.get(setName).push(String(mapped.id));
339
+ }
205
340
  }
206
341
 
207
342
  if (setGroups.size === 0) {
@@ -220,6 +355,28 @@ export async function syncTestSets(cfg, xrayToken, tests, mapping, mappingPath)
220
355
  try {
221
356
  let setEntry = mapping._testSets[setName];
222
357
 
358
+ if (!setEntry) {
359
+ // Search JIRA for an existing Test Set with this name before creating
360
+ logger.send(`Looking up Test Set "${setName}"...`);
361
+ try {
362
+ const safeQ = setName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
363
+ const existing = await searchIssues(
364
+ cfg,
365
+ `project = "${cfg.jiraProjectKey}" AND issuetype = "Test Set" AND summary = "${safeQ}"`,
366
+ ["id", "key", "summary"],
367
+ 1
368
+ );
369
+ if (existing.length > 0) {
370
+ setEntry = { key: existing[0].key, id: String(existing[0].id) };
371
+ mapping._testSets[setName] = setEntry;
372
+ saveMapping(mappingPath, mapping);
373
+ logger.step(`Found existing Test Set: ${setEntry.key}`);
374
+ }
375
+ } catch (err) {
376
+ logger.debug(`JIRA search for Test Set "${setName}" failed: ${err.message}`);
377
+ }
378
+ }
379
+
223
380
  if (!setEntry) {
224
381
  // Create new Test Set issue
225
382
  logger.send(`Creating Test Set "${setName}"...`);
@@ -257,33 +414,77 @@ export async function syncTestSets(cfg, xrayToken, tests, mapping, mappingPath)
257
414
  // ─── Test Plan sync ────────────────────────────────────────────────────────────
258
415
 
259
416
  /**
260
- * Ensure all test issues are members of the configured Test Plan.
417
+ * Bi-directionally sync Test Plan membership.
418
+ *
419
+ * Queries the plan's current members, then:
420
+ * - Adds tests that are mapped locally but not in the plan
421
+ * - Removes tests that are in the plan but no longer in the local mapping
261
422
  *
262
423
  * @param {object} cfg
263
424
  * @param {string} xrayToken
264
425
  * @param {string} planIssueId Numeric JIRA issue ID of the Test Plan
265
- * @param {string[]} testIssueIds Numeric JIRA issue IDs to add
266
- * @returns {Promise<{added: number, warnings: string[]}>}
426
+ * @param {string[]} testIssueIds Numeric JIRA issue IDs that should be in the plan
427
+ * @returns {Promise<{added: number, removed: number, unchanged: number}>}
267
428
  */
268
429
  export async function syncTestPlan(cfg, xrayToken, planIssueId, testIssueIds) {
269
- if (!testIssueIds.length) return { added: 0, warnings: [] };
270
-
271
- logger.pin(`Syncing ${testIssueIds.length} test(s) to Test Plan (ID: ${planIssueId})...`);
430
+ if (!testIssueIds.length) return { added: 0, removed: 0, unchanged: 0 };
272
431
 
273
- // Batch into groups of 50 (API limit suggestion)
274
432
  const BATCH = 50;
275
- let totalAdded = 0;
276
- const allWarnings = [];
433
+ const targetSet = new Set(testIssueIds.map(String));
434
+
435
+ // ── Query existing plan membership ────────────────────────────────────────
436
+ let existingIds = new Set();
437
+ try {
438
+ // Paginate through all plan members (100 per call)
439
+ let start = 0;
440
+ const limit = 100;
441
+ let total = null;
442
+
443
+ while (total === null || start < total) {
444
+ const plan = await getTestPlan(cfg, xrayToken, planIssueId, { limit, start });
445
+ const results = plan?.tests?.results || [];
446
+ if (total === null) total = plan?.tests?.total ?? results.length;
447
+
448
+ for (const t of results) {
449
+ if (t.issueId) existingIds.add(String(t.issueId));
450
+ }
277
451
 
278
- for (let i = 0; i < testIssueIds.length; i += BATCH) {
279
- const batch = testIssueIds.slice(i, i + BATCH);
280
- const result = await addTestsToTestPlan(cfg, xrayToken, planIssueId, batch);
452
+ if (results.length < limit) break;
453
+ start += results.length;
454
+ }
455
+
456
+ logger.pin(`Plan has ${existingIds.size} existing test(s)`);
457
+ } catch (err) {
458
+ logger.warn(`Could not query plan members: ${err.message} — falling back to add-only`);
459
+ // Fallback: add all (idempotent), skip removes
460
+ let totalAdded = 0;
461
+ for (let i = 0; i < testIssueIds.length; i += BATCH) {
462
+ const result = await addTestsToTestPlan(cfg, xrayToken, planIssueId, testIssueIds.slice(i, i + BATCH));
463
+ totalAdded += result.addedTests?.length || 0;
464
+ }
465
+ return { added: totalAdded, removed: 0, unchanged: 0 };
466
+ }
467
+
468
+ const toAdd = [...targetSet].filter((id) => !existingIds.has(id));
469
+ const toRemove = [...existingIds].filter((id) => !targetSet.has(id));
470
+ const unchanged = testIssueIds.filter((id) => existingIds.has(String(id))).length;
471
+
472
+ // ── Add new tests ─────────────────────────────────────────────────────────
473
+ let totalAdded = 0;
474
+ for (let i = 0; i < toAdd.length; i += BATCH) {
475
+ const result = await addTestsToTestPlan(cfg, xrayToken, planIssueId, toAdd.slice(i, i + BATCH));
281
476
  totalAdded += result.addedTests?.length || 0;
282
- if (result.warnings?.length) allWarnings.push(...result.warnings);
283
477
  }
284
478
 
285
- logger.step(`${totalAdded} test(s) added to Test Plan`);
286
- return { added: totalAdded, warnings: allWarnings };
479
+ // ── Remove stale tests ────────────────────────────────────────────────────
480
+ let totalRemoved = 0;
481
+ for (let i = 0; i < toRemove.length; i += BATCH) {
482
+ const batch = toRemove.slice(i, i + BATCH);
483
+ await removeTestsFromTestPlan(cfg, xrayToken, planIssueId, batch);
484
+ totalRemoved += batch.length;
485
+ }
486
+
487
+ return { added: totalAdded, removed: totalRemoved, unchanged };
287
488
  }
288
489
 
289
490
  // ─── Folder sync ──────────────────────────────────────────────────────────────
package/lib/xrayClient.js CHANGED
@@ -90,6 +90,9 @@ export async function createIssue(cfg, issueType, fields) {
90
90
  issuetype: { name: issueType },
91
91
  ...(fields.priority ? { priority: { name: fields.priority } } : {}),
92
92
  ...(fields.labels && fields.labels.length > 0 ? { labels: fields.labels } : {}),
93
+ ...(fields.fixVersions && fields.fixVersions.length > 0
94
+ ? { fixVersions: fields.fixVersions.map((v) => ({ name: v })) }
95
+ : {}),
93
96
  },
94
97
  };
95
98
 
@@ -219,19 +222,22 @@ export async function getTests(cfg, xrayToken, opts = {}) {
219
222
  }
220
223
 
221
224
  /**
222
- * Get a Test Plan by issue ID (includes its tests).
225
+ * Get a Test Plan by issue ID (includes its tests, paginated).
223
226
  * @param {object} cfg
224
227
  * @param {string} xrayToken
225
228
  * @param {string} issueId
229
+ * @param {object} [opts] { limit?: number, start?: number }
226
230
  * @returns {Promise<object>}
227
231
  */
228
- export async function getTestPlan(cfg, xrayToken, issueId) {
232
+ export async function getTestPlan(cfg, xrayToken, issueId, opts = {}) {
233
+ const limit = opts.limit ?? 100;
234
+ const start = opts.start ?? 0;
229
235
  const data = await graphql(cfg, xrayToken, `
230
- query ($issueId: String!) {
236
+ query ($issueId: String!, $limit: Int!, $start: Int) {
231
237
  getTestPlan(issueId: $issueId) {
232
238
  issueId
233
239
  projectId
234
- tests(limit: 100) {
240
+ tests(limit: $limit, start: $start) {
235
241
  total
236
242
  results {
237
243
  issueId
@@ -246,7 +252,7 @@ export async function getTestPlan(cfg, xrayToken, issueId) {
246
252
  }
247
253
  }
248
254
  }
249
- `, { issueId: String(issueId) });
255
+ `, { issueId: String(issueId), limit, start });
250
256
  return data.getTestPlan;
251
257
  }
252
258
 
@@ -373,8 +379,58 @@ export async function getProjectSettings(cfg, xrayToken, projectId) {
373
379
  return data.getProjectSettings;
374
380
  }
375
381
 
376
- // ─── Xray GraphQLTest mutations ────────────────────────────────────────────
382
+ // ─── JIRA RESTIssue search ──────────────────────────────────────────────────
383
+
384
+ /**
385
+ * Search JIRA issues via JQL.
386
+ * @param {object} cfg
387
+ * @param {string} jql
388
+ * @param {string[]} [fields] Fields to return (default: summary, id, key)
389
+ * @param {number} [maxResults]
390
+ * @returns {Promise<object[]>} Array of issue objects
391
+ */
392
+ export async function searchIssues(cfg, jql, fields = ["summary", "id", "key"], maxResults = 10) {
393
+ const response = await axios.get(
394
+ `${cfg.jiraUrl}/rest/api/3/search`,
395
+ {
396
+ httpsAgent,
397
+ headers: jiraHeaders(cfg),
398
+ params: { jql, fields: fields.join(","), maxResults },
399
+ }
400
+ );
401
+ return response.data.issues || [];
402
+ }
403
+
404
+ // ─── Xray GraphQL — Test Set queries ──────────────────────────────────────────
405
+
406
+ /**
407
+ * Get Test Sets in a project (paginated).
408
+ * @param {object} cfg
409
+ * @param {string} xrayToken
410
+ * @param {object} [opts] { projectId, jql, limit, start }
411
+ * @returns {Promise<{total, results}>}
412
+ */
413
+ export async function getTestSets(cfg, xrayToken, opts = {}) {
414
+ const data = await graphql(cfg, xrayToken, `
415
+ query ($projectId: String, $jql: String, $limit: Int!, $start: Int) {
416
+ getTestSets(projectId: $projectId, jql: $jql, limit: $limit, start: $start) {
417
+ total
418
+ results {
419
+ issueId
420
+ projectId
421
+ }
422
+ }
423
+ }
424
+ `, {
425
+ projectId: opts.projectId || null,
426
+ jql: opts.jql || null,
427
+ limit: Math.min(opts.limit || 100, 100),
428
+ start: opts.start || 0,
429
+ });
430
+ return data.getTestSets;
431
+ }
377
432
 
433
+ // ─── Xray GraphQL — Test mutations ────────────────────────────────────────────
378
434
  /**
379
435
  * Set a test issue's type via Xray GraphQL.
380
436
  * @param {object} cfg
@@ -495,6 +551,31 @@ export async function updateTestFolder(cfg, xrayToken, issueId, folderPath) {
495
551
  `, { issueId: String(issueId), folderPath });
496
552
  }
497
553
 
554
+ // ─── Xray GraphQL — Precondition mutations ────────────────────────────────────
555
+
556
+ /**
557
+ * Link precondition issues to a test.
558
+ * @param {object} cfg
559
+ * @param {string} xrayToken
560
+ * @param {string} testIssueId Numeric Xray issue ID of the test
561
+ * @param {string[]} preconditionIssueIds Numeric Xray issue IDs of preconditions
562
+ * @returns {Promise<{addedPreconditions: string[], warnings: string[]}>}
563
+ */
564
+ export async function addPreconditionsToTest(cfg, xrayToken, testIssueId, preconditionIssueIds) {
565
+ const data = await graphql(cfg, xrayToken, `
566
+ mutation ($issueId: String!, $preconditionIssueIds: [String]!) {
567
+ addPreconditionsToTest(issueId: $issueId, preconditionIssueIds: $preconditionIssueIds) {
568
+ addedPreconditions
569
+ warnings
570
+ }
571
+ }
572
+ `, {
573
+ issueId: String(testIssueId),
574
+ preconditionIssueIds: preconditionIssueIds.map(String),
575
+ });
576
+ return data.addPreconditionsToTest;
577
+ }
578
+
498
579
  // ─── Xray GraphQL — Test Plan mutations ───────────────────────────────────────
499
580
 
500
581
  /**
@@ -983,11 +1064,6 @@ export async function withRetry(fn, opts = {}) {
983
1064
  let lastError;
984
1065
  for (let attempt = 0; attempt < maxRetries; attempt++) {
985
1066
  try {
986
- const delay = baseDelay * Math.pow(2, attempt);
987
- if (attempt > 0) {
988
- logger.wait(`Retry ${attempt}/${maxRetries - 1} after ${delay}ms...`);
989
- }
990
- await sleep(delay);
991
1067
  return await fn();
992
1068
  } catch (err) {
993
1069
  lastError = err;
@@ -1002,6 +1078,11 @@ export async function withRetry(fn, opts = {}) {
1002
1078
  }
1003
1079
  throw err;
1004
1080
  }
1081
+ if (attempt < maxRetries - 1) {
1082
+ const delay = baseDelay * Math.pow(2, attempt);
1083
+ logger.wait(`Retry ${attempt + 1}/${maxRetries - 1} after ${delay}ms...`);
1084
+ await sleep(delay);
1085
+ }
1005
1086
  }
1006
1087
  }
1007
1088
  throw lastError;
@@ -1082,12 +1163,14 @@ export async function createTestExecution(cfg, xrayToken, opts) {
1082
1163
  environments = [],
1083
1164
  testIssueIds = [],
1084
1165
  testPlanKey,
1166
+ fixVersion,
1085
1167
  } = opts;
1086
1168
 
1087
1169
  // 1. Create JIRA issue
1088
1170
  const issue = await createIssue(cfg, "Test Execution", {
1089
1171
  summary: summary || `Test Execution — ${new Date().toLocaleString()}`,
1090
1172
  description: description || summary || "",
1173
+ ...(fixVersion ? { fixVersions: [fixVersion] } : {}),
1091
1174
  });
1092
1175
 
1093
1176
  logger.step(`Test Execution created: ${issue.key}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msalaam/xray-qe-toolkit",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "QE toolkit for Xray Cloud — test management, tests.json standardisation, Playwright result import, and CI pipeline integration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "license": "UNLICENSED",
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "^1.27.1",
33
+ "@msalaam/xray-qe-toolkit": "^1.5.0",
33
34
  "axios": "^1.13.4",
34
35
  "commander": "^13.1.0",
35
36
  "dotenv": "^17.2.3",