@msalaam/xray-qe-toolkit 1.5.0 → 1.6.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.
@@ -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
@@ -69,109 +77,237 @@ export function saveMapping(mappingPath, mapping) {
69
77
  * @param {string} xrayToken JWT token (pass to avoid re-authenticating)
70
78
  * @returns {Promise<{created: string[], updated: string[], failed: string[]}>}
71
79
  */
72
- export async function buildAndPush(cfg, tests, mapping, xrayToken) {
80
+ export async function buildAndPush(cfg, tests, mapping, xrayToken, opts = {}) {
73
81
  const created = [];
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]);
87
+
88
+ const threshold = cfg.bulkImportThreshold ?? 50;
89
+ const useBulk = opts.forceBulk ? toCreate.length > 0 : toCreate.length >= threshold;
90
+
91
+ // ── Bulk create path ───────────────────────────────────────────────────────
92
+ if (useBulk) {
93
+ logger.send(`Bulk creating ${toCreate.length} new test(s) via Xray REST${opts.forceBulk ? " (--bulk)" : ""}...`);
86
94
 
87
- if (existing) {
88
- // ── Update existing test ──────────────────────────────
89
- logger.send(`${test.test_id} (update ${existing.key})`);
95
+ const bulkPayload = toCreate.map((test) => {
96
+ const testType = test.xray?.testType || "Generic";
97
+ const labels = [...new Set([...(test.xray?.labels || []), ...(test.tags || [])])];
90
98
 
91
- await updateIssue(cfg, existing.key, {
99
+ const obj = {
100
+ fields: {
101
+ project: { key: cfg.jiraProjectKey },
92
102
  summary: test.xray.summary,
93
- description: test.xray.description,
94
- priority: test.xray.priority,
95
- labels,
103
+ issuetype: { name: "Test" },
104
+ ...(test.xray.description ? { description: test.xray.description } : {}),
105
+ ...(test.xray.priority ? { priority: { name: test.xray.priority } } : {}),
106
+ ...(labels.length > 0 ? { labels } : {}),
107
+ },
108
+ testtype: testType,
109
+ };
110
+
111
+ if (testType === "Generic" && test.xray?.definition) {
112
+ obj.unstructured = test.xray.definition;
113
+ } else if (testType === "Manual" && test.xray?.steps?.length > 0) {
114
+ obj.steps = test.xray.steps.map((s) => ({
115
+ action: s.action,
116
+ data: s.data || "",
117
+ result: s.expected_result || "",
118
+ }));
119
+ }
120
+
121
+ return obj;
122
+ });
123
+
124
+ try {
125
+ const { jobId } = await importTestsBulk(cfg, xrayToken, bulkPayload);
126
+ logger.step(`Bulk job started: ${jobId} — polling...`);
127
+
128
+ const jobResult = await waitForImportJob(cfg, xrayToken, jobId, { timeout: 120000 });
129
+
130
+ if (jobResult.status === "successful") {
131
+ const createdIssues = jobResult.result?.updatedOrCreatedTests || [];
132
+
133
+ createdIssues.forEach((issue, idx) => {
134
+ const test = toCreate[idx];
135
+ if (!test) return;
136
+ mapping[test.test_id] = { key: issue.key, id: String(issue.id) };
137
+ created.push(issue.key);
138
+ logger.step(`${test.test_id} → ${issue.key}`);
96
139
  });
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
140
 
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
- );
141
+ saveMapping(cfg.mappingPath, mapping);
142
+
143
+ // Link preconditions for bulk-created tests
144
+ for (const test of toCreate) {
145
+ if (!test.preconditions?.length) continue;
146
+ const entry = mapping[test.test_id];
147
+ if (!entry?.id) continue;
148
+ try {
149
+ const precondIds = await resolvePreconditionIds(cfg, test.preconditions);
150
+ if (precondIds.length > 0) {
151
+ await addPreconditionsToTest(cfg, xrayToken, entry.id, precondIds);
152
+ logger.step(`Preconditions linked: ${test.preconditions.join(", ")}`);
153
+ }
154
+ } catch (err) {
155
+ logger.warn(`Precondition linking failed for ${test.test_id}: ${err.message}`);
156
+ }
121
157
  }
122
-
123
- updated.push(existing.key);
124
158
  } else {
125
- // ── Create new test ───────────────────────────────────
126
- logger.send(`${test.test_id}`);
159
+ logger.warn(`Bulk import job failed falling back to serial create`);
160
+ // Fallback: push failing tests serially
161
+ for (const test of toCreate) {
162
+ await pushSingleTest(cfg, xrayToken, test, mapping, created, failed);
163
+ }
164
+ saveMapping(cfg.mappingPath, mapping);
165
+ }
166
+ } catch (err) {
167
+ logger.warn(`Bulk import failed (${err.message}) — falling back to serial create`);
168
+ for (const test of toCreate) {
169
+ await pushSingleTest(cfg, xrayToken, test, mapping, created, failed);
170
+ }
171
+ saveMapping(cfg.mappingPath, mapping);
172
+ }
173
+ } else {
174
+ // ── Serial create path ─────────────────────────────────────────────────
175
+ for (const test of toCreate) {
176
+ await pushSingleTest(cfg, xrayToken, test, mapping, created, failed);
177
+ }
178
+ }
127
179
 
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})`);
180
+ // ── Serial update path (always serial — needs step diff) ──────────────────
181
+ for (const test of toUpdate) {
182
+ await pushSingleTest(cfg, xrayToken, test, mapping, updated, failed);
183
+ }
184
+
185
+ return { created, updated, failed };
186
+ }
187
+
188
+ // ─── Internal helpers ──────────────────────────────────────────────────────────
189
+
190
+ /**
191
+ * Resolve JIRA keys/IDs for precondition issue keys.
192
+ * Preconditions are referenced by JIRA key in tests.json.
193
+ */
194
+ async function resolvePreconditionIds(cfg, preconditionKeys) {
195
+ const ids = [];
196
+ for (const key of preconditionKeys) {
197
+ try {
198
+ const issues = await searchIssues(cfg, `issue = "${key}"`, ["id"], 1);
199
+ if (issues[0]?.id) ids.push(String(issues[0].id));
200
+ } catch { /* skip unresolvable */ }
201
+ }
202
+ return ids;
203
+ }
135
204
 
136
- // Set test type + add steps/definition with retry (Xray indexing delay)
205
+ /**
206
+ * Create or update a single test and link preconditions.
207
+ */
208
+ async function pushSingleTest(cfg, xrayToken, test, mapping, successList, failedList) {
209
+ try {
210
+ const existing = mapping[test.test_id];
211
+ const testType = test.xray?.testType || "Generic";
212
+ const labels = [...new Set([...(test.xray?.labels || []), ...(test.tags || [])])];
213
+
214
+ if (existing) {
215
+ // ── Update ──────────────────────────────────────────────────────────
216
+ logger.send(`${test.test_id} (update → ${existing.key})`);
217
+
218
+ await updateIssue(cfg, existing.key, {
219
+ summary: test.xray.summary,
220
+ description: test.xray.description,
221
+ priority: test.xray.priority,
222
+ labels,
223
+ });
224
+ logger.step("Fields updated");
225
+
226
+ if (testType === "Manual" && test.xray.steps && test.xray.steps.length > 0) {
137
227
  await withRetry(
138
228
  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);
229
+ const removed = await removeAllTestSteps(cfg, xrayToken, existing.id);
230
+ logger.debug(`Removed ${removed} existing step(s)`);
231
+ for (const step of test.xray.steps) {
232
+ await addTestStep(cfg, xrayToken, existing.id, step);
147
233
  }
148
234
  },
149
235
  { retryOn: "issueId provided is not valid" }
150
236
  );
237
+ logger.step(`${test.xray.steps.length} step(s) replaced`);
238
+ }
151
239
 
152
- if (testType === "Manual" && test.xray.steps) {
153
- logger.step(`${test.xray.steps.length} step(s) added`);
154
- }
240
+ if (testType === "Generic" && test.xray?.definition) {
241
+ await withRetry(
242
+ async () => updateTestDefinition(cfg, xrayToken, existing.id, test.xray.definition),
243
+ { retryOn: "issueId provided is not valid" }
244
+ );
245
+ }
155
246
 
156
- mapping[test.test_id] = { key: issue.key, id: issue.id };
157
- created.push(issue.key);
247
+ successList.push(existing.key);
248
+ } else {
249
+ // ── Create ──────────────────────────────────────────────────────────
250
+ logger.send(`${test.test_id}`);
251
+
252
+ const issue = await createIssue(cfg, "Test", {
253
+ summary: test.xray.summary,
254
+ description: test.xray.description,
255
+ priority: test.xray.priority,
256
+ labels,
257
+ });
258
+ logger.step(`${issue.key} (ID: ${issue.id})`);
259
+
260
+ await withRetry(
261
+ async () => {
262
+ await setTestType(cfg, xrayToken, issue.id, testType);
263
+
264
+ if (testType === "Manual" && test.xray.steps && test.xray.steps.length > 0) {
265
+ for (const step of test.xray.steps) {
266
+ await addTestStep(cfg, xrayToken, issue.id, step);
267
+ }
268
+ } else if (testType === "Generic" && test.xray?.definition) {
269
+ await updateTestDefinition(cfg, xrayToken, issue.id, test.xray.definition);
270
+ }
271
+ },
272
+ // Xray is eventually consistent: new issues take a moment to be indexed.
273
+ // Retry on both the "not found" timing error and the legacy "issueId" error.
274
+ { retryOn: ["not found", "issueId provided is not valid"], baseDelay: 1500 }
275
+ );
276
+
277
+ if (testType === "Manual" && test.xray.steps) {
278
+ logger.step(`${test.xray.steps.length} step(s) added`);
158
279
  }
159
280
 
160
- // Save mapping after each test (crash-safe)
161
- saveMapping(cfg.mappingPath, mapping);
162
- logger.blank();
281
+ mapping[test.test_id] = { key: issue.key, id: issue.id };
282
+ successList.push(issue.key);
283
+ }
163
284
 
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);
285
+ // ── Precondition linking ───────────────────────────────────────────────
286
+ if (test.preconditions?.length > 0) {
287
+ const issueId = existing?.id || mapping[test.test_id]?.id;
288
+ if (issueId) {
289
+ try {
290
+ const precondIds = await resolvePreconditionIds(cfg, test.preconditions);
291
+ if (precondIds.length > 0) {
292
+ await addPreconditionsToTest(cfg, xrayToken, issueId, precondIds);
293
+ logger.step(`Preconditions: ${test.preconditions.join(", ")}`);
294
+ }
295
+ } catch (err) {
296
+ logger.warn(`Precondition linking failed for ${test.test_id}: ${err.message}`);
297
+ }
298
+ }
171
299
  }
172
- }
173
300
 
174
- return { created, updated, failed };
301
+ saveMapping(cfg.mappingPath, mapping);
302
+ logger.blank();
303
+
304
+ await new Promise((r) => setTimeout(r, 300));
305
+ } catch (err) {
306
+ const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
307
+ logger.stepFail(`Failed: ${detail}`);
308
+ logger.blank();
309
+ failedList.push({ testId: test.test_id, reason: detail });
310
+ }
175
311
  }
176
312
 
177
313
  // ─── Test Set sync ───────────────────────────────────────────────────────────
@@ -194,14 +330,18 @@ export async function buildAndPush(cfg, tests, mapping, xrayToken) {
194
330
  * @returns {Promise<{created: number, synced: number, failed: string[]}>}
195
331
  */
196
332
  export async function syncTestSets(cfg, xrayToken, tests, mapping, mappingPath) {
197
- // Group test issue IDs by testSet name
333
+ // Group test issue IDs by testSet name — supports both string and array values
198
334
  const setGroups = new Map();
199
335
  for (const test of tests) {
200
336
  if (!test.testSet) continue;
201
337
  const mapped = mapping[test.test_id];
202
338
  if (!mapped?.id) continue;
203
- if (!setGroups.has(test.testSet)) setGroups.set(test.testSet, []);
204
- setGroups.get(test.testSet).push(String(mapped.id));
339
+ const setNames = Array.isArray(test.testSet) ? test.testSet : [test.testSet];
340
+ for (const setName of setNames) {
341
+ if (!setName) continue;
342
+ if (!setGroups.has(setName)) setGroups.set(setName, []);
343
+ setGroups.get(setName).push(String(mapped.id));
344
+ }
205
345
  }
206
346
 
207
347
  if (setGroups.size === 0) {
@@ -220,6 +360,28 @@ export async function syncTestSets(cfg, xrayToken, tests, mapping, mappingPath)
220
360
  try {
221
361
  let setEntry = mapping._testSets[setName];
222
362
 
363
+ if (!setEntry) {
364
+ // Search JIRA for an existing Test Set with this name before creating
365
+ logger.send(`Looking up Test Set "${setName}"...`);
366
+ try {
367
+ const safeQ = setName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
368
+ const existing = await searchIssues(
369
+ cfg,
370
+ `project = "${cfg.jiraProjectKey}" AND issuetype = "Test Set" AND summary = "${safeQ}"`,
371
+ ["id", "key", "summary"],
372
+ 1
373
+ );
374
+ if (existing.length > 0) {
375
+ setEntry = { key: existing[0].key, id: String(existing[0].id) };
376
+ mapping._testSets[setName] = setEntry;
377
+ saveMapping(mappingPath, mapping);
378
+ logger.step(`Found existing Test Set: ${setEntry.key}`);
379
+ }
380
+ } catch (err) {
381
+ logger.debug(`JIRA search for Test Set "${setName}" failed: ${err.message}`);
382
+ }
383
+ }
384
+
223
385
  if (!setEntry) {
224
386
  // Create new Test Set issue
225
387
  logger.send(`Creating Test Set "${setName}"...`);
@@ -257,33 +419,77 @@ export async function syncTestSets(cfg, xrayToken, tests, mapping, mappingPath)
257
419
  // ─── Test Plan sync ────────────────────────────────────────────────────────────
258
420
 
259
421
  /**
260
- * Ensure all test issues are members of the configured Test Plan.
422
+ * Bi-directionally sync Test Plan membership.
423
+ *
424
+ * Queries the plan's current members, then:
425
+ * - Adds tests that are mapped locally but not in the plan
426
+ * - Removes tests that are in the plan but no longer in the local mapping
261
427
  *
262
428
  * @param {object} cfg
263
429
  * @param {string} xrayToken
264
430
  * @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[]}>}
431
+ * @param {string[]} testIssueIds Numeric JIRA issue IDs that should be in the plan
432
+ * @returns {Promise<{added: number, removed: number, unchanged: number}>}
267
433
  */
268
434
  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})...`);
435
+ if (!testIssueIds.length) return { added: 0, removed: 0, unchanged: 0 };
272
436
 
273
- // Batch into groups of 50 (API limit suggestion)
274
437
  const BATCH = 50;
275
- let totalAdded = 0;
276
- const allWarnings = [];
438
+ const targetSet = new Set(testIssueIds.map(String));
439
+
440
+ // ── Query existing plan membership ────────────────────────────────────────
441
+ let existingIds = new Set();
442
+ try {
443
+ // Paginate through all plan members (100 per call)
444
+ let start = 0;
445
+ const limit = 100;
446
+ let total = null;
447
+
448
+ while (total === null || start < total) {
449
+ const plan = await getTestPlan(cfg, xrayToken, planIssueId, { limit, start });
450
+ const results = plan?.tests?.results || [];
451
+ if (total === null) total = plan?.tests?.total ?? results.length;
452
+
453
+ for (const t of results) {
454
+ if (t.issueId) existingIds.add(String(t.issueId));
455
+ }
277
456
 
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);
457
+ if (results.length < limit) break;
458
+ start += results.length;
459
+ }
460
+
461
+ logger.pin(`Plan has ${existingIds.size} existing test(s)`);
462
+ } catch (err) {
463
+ logger.warn(`Could not query plan members: ${err.message} — falling back to add-only`);
464
+ // Fallback: add all (idempotent), skip removes
465
+ let totalAdded = 0;
466
+ for (let i = 0; i < testIssueIds.length; i += BATCH) {
467
+ const result = await addTestsToTestPlan(cfg, xrayToken, planIssueId, testIssueIds.slice(i, i + BATCH));
468
+ totalAdded += result.addedTests?.length || 0;
469
+ }
470
+ return { added: totalAdded, removed: 0, unchanged: 0 };
471
+ }
472
+
473
+ const toAdd = [...targetSet].filter((id) => !existingIds.has(id));
474
+ const toRemove = [...existingIds].filter((id) => !targetSet.has(id));
475
+ const unchanged = testIssueIds.filter((id) => existingIds.has(String(id))).length;
476
+
477
+ // ── Add new tests ─────────────────────────────────────────────────────────
478
+ let totalAdded = 0;
479
+ for (let i = 0; i < toAdd.length; i += BATCH) {
480
+ const result = await addTestsToTestPlan(cfg, xrayToken, planIssueId, toAdd.slice(i, i + BATCH));
281
481
  totalAdded += result.addedTests?.length || 0;
282
- if (result.warnings?.length) allWarnings.push(...result.warnings);
283
482
  }
284
483
 
285
- logger.step(`${totalAdded} test(s) added to Test Plan`);
286
- return { added: totalAdded, warnings: allWarnings };
484
+ // ── Remove stale tests ────────────────────────────────────────────────────
485
+ let totalRemoved = 0;
486
+ for (let i = 0; i < toRemove.length; i += BATCH) {
487
+ const batch = toRemove.slice(i, i + BATCH);
488
+ await removeTestsFromTestPlan(cfg, xrayToken, planIssueId, batch);
489
+ totalRemoved += batch.length;
490
+ }
491
+
492
+ return { added: totalAdded, removed: totalRemoved, unchanged };
287
493
  }
288
494
 
289
495
  // ─── Folder sync ──────────────────────────────────────────────────────────────
@@ -334,13 +540,13 @@ export async function syncFolders(cfg, xrayToken, projectId, tests, mapping) {
334
540
  await addTestsToFolder(cfg, xrayToken, projectId, folderPath, testIds);
335
541
  synced += testIds.length;
336
542
 
337
- logger.step(`${folderPath} — ${testIds.length} test(s).__`);
543
+ logger.step(`${folderPath} — ${testIds.length} test(s)`);
338
544
  } catch (err) {
339
545
  logger.warn(`Failed to sync folder ${folderPath}: ${err.message}`);
340
546
  }
341
547
  }
342
548
 
343
- return { synced, created: createdFolders };
549
+ return { created: createdFolders.length, assigned: synced };
344
550
  }
345
551
 
346
552