@msalaam/xray-qe-toolkit 1.4.1 → 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.
@@ -12,13 +12,22 @@ import {
12
12
  authenticate,
13
13
  createIssue,
14
14
  updateIssue,
15
+ getIssue,
15
16
  setTestType,
16
17
  addTestStep,
17
18
  removeAllTestSteps,
18
19
  updateTestDefinition,
19
20
  addTestsToTestPlan,
21
+ removeTestsFromTestPlan,
22
+ getTestPlan,
23
+ createTestSet,
24
+ addTestsToTestSet,
25
+ addPreconditionsToTest,
20
26
  createFolder,
21
27
  addTestsToFolder,
28
+ importTestsBulk,
29
+ waitForImportJob,
30
+ searchIssues,
22
31
  withRetry,
23
32
  } from "./xrayClient.js";
24
33
 
@@ -57,8 +66,10 @@ export function saveMapping(mappingPath, mapping) {
57
66
  * Flow per test:
58
67
  * 1. If test_id exists in mapping → update JIRA issue + replace steps
59
68
  * 2. If test_id is new → create JIRA Test issue, set type (Generic/Manual), add steps
60
- * 3. Save mapping after each test (crash-safe)
61
- * 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
62
73
  *
63
74
  * @param {object} cfg Config from loadConfig()
64
75
  * @param {object[]} tests Array of test definitions from tests.json
@@ -71,136 +82,409 @@ export async function buildAndPush(cfg, tests, mapping, xrayToken) {
71
82
  const updated = [];
72
83
  const failed = [];
73
84
 
74
- for (const test of tests) {
75
- try {
76
- const existing = mapping[test.test_id];
77
- const testType = test.xray?.testType || "Generic";
78
- // Merge tags into labels (deduped)
79
- const labels = [...new Set([
80
- ...(test.xray?.labels || []),
81
- ...(test.tags || []),
82
- ])];
85
+ const toCreate = tests.filter((t) => !mapping[t.test_id]);
86
+ const toUpdate = tests.filter((t) => mapping[t.test_id]);
83
87
 
84
- if (existing) {
85
- // ── Update existing test ──────────────────────────────
86
- 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...`);
87
91
 
88
- await updateIssue(cfg, existing.key, {
92
+ const bulkPayload = toCreate.map((test) => {
93
+ const testType = test.xray?.testType || "Generic";
94
+ const labels = [...new Set([...(test.xray?.labels || []), ...(test.tags || [])])];
95
+
96
+ const obj = {
97
+ fields: {
98
+ project: { key: cfg.jiraProjectKey },
89
99
  summary: test.xray.summary,
90
- description: test.xray.description,
91
- priority: test.xray.priority,
92
- 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}`);
93
136
  });
94
- logger.step("Fields updated");
95
-
96
- // Replace steps for Manual tests
97
- if (testType === "Manual" && test.xray.steps && test.xray.steps.length > 0) {
98
- await withRetry(
99
- async () => {
100
- const removed = await removeAllTestSteps(cfg, xrayToken, existing.id);
101
- logger.debug(`Removed ${removed} existing step(s)`);
102
-
103
- for (const step of test.xray.steps) {
104
- await addTestStep(cfg, xrayToken, existing.id, step);
105
- }
106
- },
107
- { retryOn: "issueId provided is not valid" }
108
- );
109
- logger.step(`${test.xray.steps.length} step(s) replaced`);
110
- }
111
137
 
112
- // Update Generic definition
113
- if (testType === "Generic" && test.xray?.definition) {
114
- await withRetry(
115
- async () => updateTestDefinition(cfg, xrayToken, existing.id, test.xray.definition),
116
- { retryOn: "issueId provided is not valid" }
117
- );
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
+ }
118
154
  }
119
-
120
- updated.push(existing.key);
121
155
  } else {
122
- // ── Create new test ───────────────────────────────────
123
- 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
+ }
124
176
 
125
- const issue = await createIssue(cfg, "Test", {
126
- summary: test.xray.summary,
127
- description: test.xray.description,
128
- priority: test.xray.priority,
129
- labels,
130
- });
131
- 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
+ }
132
201
 
133
- // 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) {
134
224
  await withRetry(
135
225
  async () => {
136
- await setTestType(cfg, xrayToken, issue.id, testType);
137
-
138
- if (testType === "Manual" && test.xray.steps && test.xray.steps.length > 0) {
139
- for (const step of test.xray.steps) {
140
- await addTestStep(cfg, xrayToken, issue.id, step);
141
- }
142
- } else if (testType === "Generic" && test.xray?.definition) {
143
- 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);
144
230
  }
145
231
  },
146
232
  { retryOn: "issueId provided is not valid" }
147
233
  );
234
+ logger.step(`${test.xray.steps.length} step(s) replaced`);
235
+ }
236
+
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
+ }
243
+
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`);
274
+ }
148
275
 
149
- if (testType === "Manual" && test.xray.steps) {
150
- logger.step(`${test.xray.steps.length} step(s) added`);
276
+ mapping[test.test_id] = { key: issue.key, id: issue.id };
277
+ successList.push(issue.key);
278
+ }
279
+
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}`);
151
292
  }
293
+ }
294
+ }
295
+
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
+ }
306
+ }
307
+
308
+ // ─── Test Set sync ───────────────────────────────────────────────────────────
309
+
310
+ /**
311
+ * Create or update Xray Test Sets based on the `testSet` field in tests.json.
312
+ *
313
+ * Groups all tests by their `testSet` value and:
314
+ * 1. Creates a new Test Set JIRA issue for any set name not yet in mapping._testSets
315
+ * 2. Adds the tests to the Test Set (idempotent — Xray ignores already-added members)
316
+ *
317
+ * Test Set → JIRA key mappings are stored in `mapping._testSets` alongside the
318
+ * per-test mappings, so the same file persists both.
319
+ *
320
+ * @param {object} cfg
321
+ * @param {string} xrayToken
322
+ * @param {object[]} tests Test definitions from tests.json (already filtered — no skip)
323
+ * @param {object} mapping xray-mapping.json contents (mutated in place)
324
+ * @param {string} mappingPath Path to persist the updated mapping
325
+ * @returns {Promise<{created: number, synced: number, failed: string[]}>}
326
+ */
327
+ export async function syncTestSets(cfg, xrayToken, tests, mapping, mappingPath) {
328
+ // Group test issue IDs by testSet name — supports both string and array values
329
+ const setGroups = new Map();
330
+ for (const test of tests) {
331
+ if (!test.testSet) continue;
332
+ const mapped = mapping[test.test_id];
333
+ if (!mapped?.id) continue;
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
+ }
340
+ }
341
+
342
+ if (setGroups.size === 0) {
343
+ logger.debug("No testSet fields found — skipping Test Set sync");
344
+ return { created: 0, synced: 0, failed: [] };
345
+ }
346
+
347
+ // Ensure _testSets section exists in the mapping
348
+ if (!mapping._testSets) mapping._testSets = {};
152
349
 
153
- mapping[test.test_id] = { key: issue.key, id: issue.id };
154
- created.push(issue.key);
350
+ let created = 0;
351
+ let synced = 0;
352
+ const failed = [];
353
+
354
+ for (const [setName, testIssueIds] of setGroups) {
355
+ try {
356
+ let setEntry = mapping._testSets[setName];
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
+ }
155
378
  }
156
379
 
157
- // Save mapping after each test (crash-safe)
158
- saveMapping(cfg.mappingPath, mapping);
159
- logger.blank();
380
+ if (!setEntry) {
381
+ // Create new Test Set issue
382
+ logger.send(`Creating Test Set "${setName}"...`);
383
+ const result = await createTestSet(cfg, xrayToken, { summary: setName });
384
+ const testSetIssueId = result?.testSet?.issueId;
385
+ if (!testSetIssueId) throw new Error("createTestSet returned no issueId");
386
+
387
+ // JIRA REST accepts numeric issue ID in place of key — resolve JIRA key
388
+ const jiraIssue = await getIssue(cfg, testSetIssueId);
389
+ setEntry = { key: jiraIssue.key, id: testSetIssueId };
390
+ mapping._testSets[setName] = setEntry;
391
+ saveMapping(mappingPath, mapping);
392
+ created++;
393
+ logger.step(`Test Set created: ${jiraIssue.key}`);
394
+ } else {
395
+ logger.send(`Syncing Test Set "${setName}" (${setEntry.key || setEntry.id})...`);
396
+ }
160
397
 
161
- // Rate limiting
162
- await new Promise((r) => setTimeout(r, 300));
398
+ // Add tests in batches of 50 (Xray silently ignores duplicates)
399
+ const BATCH = 50;
400
+ for (let i = 0; i < testIssueIds.length; i += BATCH) {
401
+ await addTestsToTestSet(cfg, xrayToken, setEntry.id, testIssueIds.slice(i, i + BATCH));
402
+ }
403
+ synced += testIssueIds.length;
404
+ logger.step(`${testIssueIds.length} test(s) added to "${setName}"`);
163
405
  } catch (err) {
164
- const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
165
- logger.stepFail(`Failed: ${detail}`);
166
- logger.blank();
167
- failed.push(test.test_id);
406
+ logger.warn(`Test Set "${setName}" sync failed: ${err.message}`);
407
+ failed.push(setName);
168
408
  }
169
409
  }
170
410
 
171
- return { created, updated, failed };
411
+ return { created, synced, failed };
172
412
  }
173
413
 
174
414
  // ─── Test Plan sync ────────────────────────────────────────────────────────────
175
415
 
176
416
  /**
177
- * 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
178
422
  *
179
423
  * @param {object} cfg
180
424
  * @param {string} xrayToken
181
425
  * @param {string} planIssueId Numeric JIRA issue ID of the Test Plan
182
- * @param {string[]} testIssueIds Numeric JIRA issue IDs to add
183
- * @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}>}
184
428
  */
185
429
  export async function syncTestPlan(cfg, xrayToken, planIssueId, testIssueIds) {
186
- if (!testIssueIds.length) return { added: 0, warnings: [] };
430
+ if (!testIssueIds.length) return { added: 0, removed: 0, unchanged: 0 };
187
431
 
188
- logger.pin(`Syncing ${testIssueIds.length} test(s) to Test Plan (ID: ${planIssueId})...`);
189
-
190
- // Batch into groups of 50 (API limit suggestion)
191
432
  const BATCH = 50;
192
- let totalAdded = 0;
193
- 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
+ }
451
+
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;
194
471
 
195
- for (let i = 0; i < testIssueIds.length; i += BATCH) {
196
- const batch = testIssueIds.slice(i, i + BATCH);
197
- const result = await addTestsToTestPlan(cfg, xrayToken, planIssueId, batch);
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));
198
476
  totalAdded += result.addedTests?.length || 0;
199
- if (result.warnings?.length) allWarnings.push(...result.warnings);
200
477
  }
201
478
 
202
- logger.step(`${totalAdded} test(s) added to Test Plan`);
203
- 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 };
204
488
  }
205
489
 
206
490
  // ─── Folder sync ──────────────────────────────────────────────────────────────