@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.
- package/README.md +157 -71
- package/bin/cli.js +38 -25
- package/commands/createExecution.js +1 -0
- package/commands/createPlan.js +23 -34
- package/commands/importResults.js +2 -0
- package/commands/init.js +2 -0
- package/commands/pushTests.js +65 -7
- package/lib/config.js +7 -0
- package/lib/jsonFile.js +12 -0
- package/lib/playwrightConverter.js +68 -45
- package/lib/testCaseBuilder.js +374 -90
- package/lib/xrayClient.js +168 -11
- package/package.json +2 -1
- package/schema/tests.schema.json +126 -7
- package/templates/README.template.md +80 -23
- package/templates/tests.json +5295 -47
- package/commands/genTests.js +0 -138
- package/templates/SPEC-DRIVEN-APPROACH.md +0 -372
package/lib/testCaseBuilder.js
CHANGED
|
@@ -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.
|
|
61
|
-
* 4.
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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,
|
|
411
|
+
return { created, synced, failed };
|
|
172
412
|
}
|
|
173
413
|
|
|
174
414
|
// ─── Test Plan sync ────────────────────────────────────────────────────────────
|
|
175
415
|
|
|
176
416
|
/**
|
|
177
|
-
*
|
|
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
|
|
183
|
-
* @returns {Promise<{added: number,
|
|
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,
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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 ──────────────────────────────────────────────────────────────
|