@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.
- package/README.md +76 -20
- package/bin/cli.js +4 -1
- package/commands/createExecution.js +1 -0
- package/commands/importResults.js +2 -0
- package/commands/init.js +2 -0
- package/commands/pushTests.js +6 -7
- package/lib/config.js +7 -0
- package/lib/jsonFile.js +12 -0
- package/lib/playwrightConverter.js +68 -45
- package/lib/testCaseBuilder.js +305 -99
- package/lib/xrayClient.js +119 -18
- package/package.json +2 -1
- package/schema/tests.schema.json +21 -2
- package/templates/README.template.md +132 -23
- package/templates/tests.json +5341 -103
- 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
|
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
99
|
+
const obj = {
|
|
100
|
+
fields: {
|
|
101
|
+
project: { key: cfg.jiraProjectKey },
|
|
92
102
|
summary: test.xray.summary,
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
281
|
+
mapping[test.test_id] = { key: issue.key, id: issue.id };
|
|
282
|
+
successList.push(issue.key);
|
|
283
|
+
}
|
|
163
284
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
*
|
|
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
|
|
266
|
-
* @returns {Promise<{added: number,
|
|
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,
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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 {
|
|
549
|
+
return { created: createdFolders.length, assigned: synced };
|
|
344
550
|
}
|
|
345
551
|
|
|
346
552
|
|