@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.
- package/README.md +58 -19
- package/bin/cli.js +3 -1
- package/commands/createExecution.js +1 -0
- package/commands/importResults.js +2 -0
- package/commands/init.js +2 -0
- package/commands/pushTests.js +5 -6
- package/lib/config.js +7 -0
- package/lib/jsonFile.js +12 -0
- package/lib/playwrightConverter.js +68 -45
- package/lib/testCaseBuilder.js +297 -96
- package/lib/xrayClient.js +94 -11
- package/package.json +2 -1
- package/schema/tests.schema.json +21 -2
- package/templates/README.template.md +80 -23
- package/templates/tests.json +5193 -98
- package/templates/SPEC-DRIVEN-APPROACH.md +0 -372
package/lib/testCaseBuilder.js
CHANGED
|
@@ -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.
|
|
64
|
-
* 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
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
96
|
+
const obj = {
|
|
97
|
+
fields: {
|
|
98
|
+
project: { key: cfg.jiraProjectKey },
|
|
92
99
|
summary: test.xray.summary,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
276
|
+
mapping[test.test_id] = { key: issue.key, id: issue.id };
|
|
277
|
+
successList.push(issue.key);
|
|
278
|
+
}
|
|
163
279
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
*
|
|
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
|
|
266
|
-
* @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}>}
|
|
267
428
|
*/
|
|
268
429
|
export async function syncTestPlan(cfg, xrayToken, planIssueId, testIssueIds) {
|
|
269
|
-
if (!testIssueIds.length) return { added: 0,
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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:
|
|
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
|
-
// ───
|
|
382
|
+
// ─── JIRA REST — Issue 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.
|
|
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",
|