@msalaam/xray-qe-toolkit 1.3.4 → 1.4.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/.env.example +23 -9
- package/README.md +721 -1322
- package/bin/cli.js +97 -51
- package/commands/createExecution.js +112 -23
- package/commands/createPlan.js +121 -0
- package/commands/editJson.js +1 -1
- package/commands/genPipeline.js +1 -1
- package/commands/genTests.js +18 -18
- package/commands/importResults.js +107 -74
- package/commands/init.js +258 -70
- package/commands/pullTests.js +128 -0
- package/commands/pushTests.js +87 -43
- package/commands/status.js +108 -0
- package/commands/syncFolders.js +62 -0
- package/commands/validate.js +136 -0
- package/lib/config.js +50 -13
- package/lib/index.js +43 -4
- package/lib/playwrightConverter.js +91 -173
- package/lib/testCaseBuilder.js +116 -55
- package/lib/xrayClient.js +779 -202
- package/package.json +5 -4
- package/schema/business-rules.schema.json +110 -0
- package/schema/tests.schema.json +42 -16
- package/templates/README.template.md +570 -158
- package/templates/SPEC-DRIVEN-APPROACH.md +372 -0
- package/templates/azure-pipelines.yml +129 -77
- package/templates/business-rules.yaml +83 -0
- package/templates/resources-README.md +112 -0
- package/templates/tests.json +69 -51
- package/commands/genPostman.js +0 -70
- package/lib/postmanGenerator.js +0 -304
- package/templates/knowledge-README.md +0 -121
package/lib/xrayClient.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @msalaam/xray-qe-toolkit — Xray + JIRA API Client
|
|
3
3
|
*
|
|
4
|
-
* Consolidated API layer
|
|
5
|
-
* index.js and scripts/linkTests.js. Handles:
|
|
4
|
+
* Consolidated API layer handling:
|
|
6
5
|
* • Xray Cloud authentication (JWT)
|
|
7
6
|
* • JIRA REST v3 issue CRUD
|
|
8
|
-
* • Xray GraphQL
|
|
9
|
-
* •
|
|
7
|
+
* • Xray GraphQL — test management (tests, test plans, folders, test runs)
|
|
8
|
+
* • Xray REST v2 — execution import (JSON, multipart), bulk test import, attachments
|
|
10
9
|
* • Exponential-backoff retry for Xray indexing race conditions
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
import axios from "axios";
|
|
14
13
|
import https from "node:https";
|
|
14
|
+
import FormData from "form-data";
|
|
15
15
|
import logger from "./logger.js";
|
|
16
16
|
|
|
17
17
|
// Shared HTTPS agent — rejectUnauthorized: false for corporate proxy compat
|
|
@@ -19,20 +19,10 @@ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
|
19
19
|
|
|
20
20
|
// ─── Internal helpers ──────────────────────────────────────────────────────────
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
* Build the Basic Auth header value for JIRA REST calls.
|
|
24
|
-
* @param {object} cfg Config from loadConfig()
|
|
25
|
-
* @returns {string}
|
|
26
|
-
*/
|
|
27
22
|
function jiraAuth(cfg) {
|
|
28
23
|
return Buffer.from(`${cfg.jiraEmail}:${cfg.jiraApiToken}`).toString("base64");
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
/**
|
|
32
|
-
* Standard JIRA REST headers.
|
|
33
|
-
* @param {object} cfg
|
|
34
|
-
* @returns {object}
|
|
35
|
-
*/
|
|
36
26
|
function jiraHeaders(cfg) {
|
|
37
27
|
return {
|
|
38
28
|
Authorization: `Basic ${jiraAuth(cfg)}`,
|
|
@@ -40,11 +30,13 @@ function jiraHeaders(cfg) {
|
|
|
40
30
|
};
|
|
41
31
|
}
|
|
42
32
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
33
|
+
function xrayHeaders(xrayToken) {
|
|
34
|
+
return {
|
|
35
|
+
Authorization: `Bearer ${xrayToken}`,
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
48
40
|
export function toAdf(text) {
|
|
49
41
|
return {
|
|
50
42
|
type: "doc",
|
|
@@ -58,11 +50,6 @@ export function toAdf(text) {
|
|
|
58
50
|
};
|
|
59
51
|
}
|
|
60
52
|
|
|
61
|
-
/**
|
|
62
|
-
* Sleep for `ms` milliseconds.
|
|
63
|
-
* @param {number} ms
|
|
64
|
-
* @returns {Promise<void>}
|
|
65
|
-
*/
|
|
66
53
|
function sleep(ms) {
|
|
67
54
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
55
|
}
|
|
@@ -71,7 +58,6 @@ function sleep(ms) {
|
|
|
71
58
|
|
|
72
59
|
/**
|
|
73
60
|
* Authenticate with Xray Cloud and return a JWT bearer token.
|
|
74
|
-
*
|
|
75
61
|
* @param {object} cfg Config from loadConfig()
|
|
76
62
|
* @returns {Promise<string>} JWT token string
|
|
77
63
|
*/
|
|
@@ -90,10 +76,9 @@ export async function authenticate(cfg) {
|
|
|
90
76
|
|
|
91
77
|
/**
|
|
92
78
|
* Create a JIRA issue (generic).
|
|
93
|
-
*
|
|
94
|
-
* @param {
|
|
95
|
-
* @param {
|
|
96
|
-
* @param {object} fields { summary, description, priority, labels, ... }
|
|
79
|
+
* @param {object} cfg Config
|
|
80
|
+
* @param {string} issueType e.g. "Test", "Test Execution", "Test Plan"
|
|
81
|
+
* @param {object} fields { summary, description, priority, labels, ... }
|
|
97
82
|
* @returns {Promise<{key: string, id: string}>}
|
|
98
83
|
*/
|
|
99
84
|
export async function createIssue(cfg, issueType, fields) {
|
|
@@ -119,11 +104,9 @@ export async function createIssue(cfg, issueType, fields) {
|
|
|
119
104
|
|
|
120
105
|
/**
|
|
121
106
|
* Update an existing JIRA issue's fields.
|
|
122
|
-
*
|
|
123
|
-
* @param {
|
|
124
|
-
* @param {
|
|
125
|
-
* @param {object} fields Fields to update { summary, description, priority, labels }
|
|
126
|
-
* @returns {Promise<void>}
|
|
107
|
+
* @param {object} cfg
|
|
108
|
+
* @param {string} issueKey
|
|
109
|
+
* @param {object} fields
|
|
127
110
|
*/
|
|
128
111
|
export async function updateIssue(cfg, issueKey, fields) {
|
|
129
112
|
const update = {};
|
|
@@ -141,10 +124,9 @@ export async function updateIssue(cfg, issueKey, fields) {
|
|
|
141
124
|
|
|
142
125
|
/**
|
|
143
126
|
* Fetch a JIRA issue by key.
|
|
144
|
-
*
|
|
145
127
|
* @param {object} cfg
|
|
146
|
-
* @param {string} issueKey
|
|
147
|
-
* @returns {Promise<object>}
|
|
128
|
+
* @param {string} issueKey
|
|
129
|
+
* @returns {Promise<object>}
|
|
148
130
|
*/
|
|
149
131
|
export async function getIssue(cfg, issueKey) {
|
|
150
132
|
const response = await axios.get(
|
|
@@ -154,162 +136,537 @@ export async function getIssue(cfg, issueKey) {
|
|
|
154
136
|
return response.data;
|
|
155
137
|
}
|
|
156
138
|
|
|
157
|
-
// ─── Xray GraphQL —
|
|
139
|
+
// ─── Xray GraphQL — queries ────────────────────────────────────────────────────
|
|
158
140
|
|
|
159
141
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
142
|
+
* Execute a GraphQL operation against Xray Cloud.
|
|
162
143
|
* @param {object} cfg
|
|
163
|
-
* @param {string} xrayToken
|
|
164
|
-
* @param {string}
|
|
144
|
+
* @param {string} xrayToken
|
|
145
|
+
* @param {string} query GraphQL query or mutation string
|
|
146
|
+
* @param {object} variables
|
|
147
|
+
* @returns {Promise<object>} data object
|
|
165
148
|
*/
|
|
166
|
-
|
|
167
|
-
const headers = {
|
|
168
|
-
Authorization: `Bearer ${xrayToken}`,
|
|
169
|
-
"Content-Type": "application/json",
|
|
170
|
-
};
|
|
171
|
-
|
|
149
|
+
async function graphql(cfg, xrayToken, query, variables = {}) {
|
|
172
150
|
const response = await axios.post(
|
|
173
151
|
cfg.xrayGraphqlUrl,
|
|
174
|
-
{
|
|
175
|
-
|
|
176
|
-
updateTestType(issueId: $issueId, testType: $testType) {
|
|
177
|
-
issueId
|
|
178
|
-
testType { name kind }
|
|
179
|
-
}
|
|
180
|
-
}`,
|
|
181
|
-
variables: { issueId: String(issueId), testType: { name: "Automated" } },
|
|
182
|
-
},
|
|
183
|
-
{ httpsAgent, headers }
|
|
152
|
+
{ query, variables },
|
|
153
|
+
{ httpsAgent, headers: xrayHeaders(xrayToken) }
|
|
184
154
|
);
|
|
185
155
|
|
|
186
156
|
if (response.data.errors) {
|
|
187
157
|
throw new Error(`GraphQL errors: ${JSON.stringify(response.data.errors)}`);
|
|
188
158
|
}
|
|
159
|
+
|
|
160
|
+
return response.data.data;
|
|
189
161
|
}
|
|
190
162
|
|
|
191
163
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
164
|
+
* Get a single test by issue ID.
|
|
194
165
|
* @param {object} cfg
|
|
195
|
-
* @param {string} xrayToken
|
|
196
|
-
* @param {string} issueId
|
|
197
|
-
* @
|
|
166
|
+
* @param {string} xrayToken
|
|
167
|
+
* @param {string} issueId Numeric JIRA issue ID
|
|
168
|
+
* @returns {Promise<object>}
|
|
198
169
|
*/
|
|
199
|
-
export async function
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
170
|
+
export async function getTest(cfg, xrayToken, issueId) {
|
|
171
|
+
const data = await graphql(cfg, xrayToken, `
|
|
172
|
+
query ($issueId: String!) {
|
|
173
|
+
getTest(issueId: $issueId) {
|
|
174
|
+
issueId
|
|
175
|
+
projectId
|
|
176
|
+
testType { name kind }
|
|
177
|
+
steps { id action data result }
|
|
178
|
+
status { name description }
|
|
179
|
+
testSets(limit: 10) { results { issueId } }
|
|
180
|
+
testPlans(limit: 10) { results { issueId } }
|
|
181
|
+
folder { path }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
`, { issueId: String(issueId) });
|
|
185
|
+
return data.getTest;
|
|
186
|
+
}
|
|
204
187
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Get tests in a project (paginated).
|
|
190
|
+
* @param {object} cfg
|
|
191
|
+
* @param {string} xrayToken
|
|
192
|
+
* @param {object} opts { projectId, jql, limit (1-100), start }
|
|
193
|
+
* @returns {Promise<{total, results}>}
|
|
194
|
+
*/
|
|
195
|
+
export async function getTests(cfg, xrayToken, opts = {}) {
|
|
196
|
+
const data = await graphql(cfg, xrayToken, `
|
|
197
|
+
query ($projectId: String, $jql: String, $limit: Int!, $start: Int) {
|
|
198
|
+
getTests(projectId: $projectId, jql: $jql, limit: $limit, start: $start) {
|
|
199
|
+
total
|
|
200
|
+
start
|
|
201
|
+
limit
|
|
202
|
+
results {
|
|
203
|
+
issueId
|
|
204
|
+
projectId
|
|
205
|
+
testType { name kind }
|
|
206
|
+
steps { id action data result }
|
|
207
|
+
folder { path }
|
|
208
|
+
status { name }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
`, {
|
|
213
|
+
projectId: opts.projectId || null,
|
|
214
|
+
jql: opts.jql || null,
|
|
215
|
+
limit: Math.min(opts.limit || 100, 100),
|
|
216
|
+
start: opts.start || 0,
|
|
217
|
+
});
|
|
218
|
+
return data.getTests;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Get a Test Plan by issue ID (includes its tests).
|
|
223
|
+
* @param {object} cfg
|
|
224
|
+
* @param {string} xrayToken
|
|
225
|
+
* @param {string} issueId
|
|
226
|
+
* @returns {Promise<object>}
|
|
227
|
+
*/
|
|
228
|
+
export async function getTestPlan(cfg, xrayToken, issueId) {
|
|
229
|
+
const data = await graphql(cfg, xrayToken, `
|
|
230
|
+
query ($issueId: String!) {
|
|
231
|
+
getTestPlan(issueId: $issueId) {
|
|
232
|
+
issueId
|
|
233
|
+
projectId
|
|
234
|
+
tests(limit: 100) {
|
|
235
|
+
total
|
|
236
|
+
results {
|
|
237
|
+
issueId
|
|
238
|
+
testType { name }
|
|
239
|
+
status { name }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
testExecutions(limit: 10) {
|
|
243
|
+
results {
|
|
244
|
+
issueId
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
`, { issueId: String(issueId) });
|
|
250
|
+
return data.getTestPlan;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get test plans in a project.
|
|
255
|
+
* @param {object} cfg
|
|
256
|
+
* @param {string} xrayToken
|
|
257
|
+
* @param {string} projectId JIRA project ID (numeric)
|
|
258
|
+
* @param {number} limit
|
|
259
|
+
* @returns {Promise<{total, results}>}
|
|
260
|
+
*/
|
|
261
|
+
export async function getTestPlans(cfg, xrayToken, projectId, limit = 50) {
|
|
262
|
+
const data = await graphql(cfg, xrayToken, `
|
|
263
|
+
query ($projectId: String!, $limit: Int!) {
|
|
264
|
+
getTestPlans(projectId: $projectId, limit: $limit) {
|
|
265
|
+
total
|
|
266
|
+
results {
|
|
267
|
+
issueId
|
|
268
|
+
projectId
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
`, { projectId: String(projectId), limit });
|
|
273
|
+
return data.getTestPlans;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get a test execution by issue ID (includes test runs).
|
|
278
|
+
* @param {object} cfg
|
|
279
|
+
* @param {string} xrayToken
|
|
280
|
+
* @param {string} issueId
|
|
281
|
+
* @returns {Promise<object>}
|
|
282
|
+
*/
|
|
283
|
+
export async function getTestExecution(cfg, xrayToken, issueId) {
|
|
284
|
+
const data = await graphql(cfg, xrayToken, `
|
|
285
|
+
query ($issueId: String!) {
|
|
286
|
+
getTestExecution(issueId: $issueId) {
|
|
287
|
+
issueId
|
|
288
|
+
projectId
|
|
289
|
+
testRuns(limit: 100) {
|
|
290
|
+
total
|
|
291
|
+
results {
|
|
292
|
+
id
|
|
293
|
+
status { name }
|
|
294
|
+
test { issueId }
|
|
295
|
+
startedOn
|
|
296
|
+
finishedOn
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
testPlans(limit: 10) {
|
|
300
|
+
results { issueId }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
`, { issueId: String(issueId) });
|
|
305
|
+
return data.getTestExecution;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get test runs for a given test.
|
|
310
|
+
* @param {object} cfg
|
|
311
|
+
* @param {string} xrayToken
|
|
312
|
+
* @param {string} testIssueId
|
|
313
|
+
* @param {number} limit
|
|
314
|
+
* @returns {Promise<{total, results}>}
|
|
315
|
+
*/
|
|
316
|
+
export async function getTestRuns(cfg, xrayToken, testIssueId, limit = 10) {
|
|
317
|
+
const data = await graphql(cfg, xrayToken, `
|
|
318
|
+
query ($testIssueId: String!, $limit: Int!) {
|
|
319
|
+
getTestRuns(testIssueId: $testIssueId, limit: $limit) {
|
|
320
|
+
total
|
|
321
|
+
results {
|
|
210
322
|
id
|
|
211
|
-
|
|
323
|
+
status { name color }
|
|
324
|
+
startedOn
|
|
325
|
+
finishedOn
|
|
326
|
+
testExecution { issueId }
|
|
212
327
|
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
data: step.data || "",
|
|
219
|
-
result: step.expected_result || "",
|
|
220
|
-
},
|
|
221
|
-
},
|
|
222
|
-
},
|
|
223
|
-
{ httpsAgent, headers }
|
|
224
|
-
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
`, { testIssueId: String(testIssueId), limit });
|
|
331
|
+
return data.getTestRuns;
|
|
332
|
+
}
|
|
225
333
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Get folder by path within a project.
|
|
336
|
+
* @param {object} cfg
|
|
337
|
+
* @param {string} xrayToken
|
|
338
|
+
* @param {string} projectId
|
|
339
|
+
* @param {string} folderPath e.g. "/API/Auth"
|
|
340
|
+
* @returns {Promise<object|null>}
|
|
341
|
+
*/
|
|
342
|
+
export async function getFolder(cfg, xrayToken, projectId, folderPath) {
|
|
343
|
+
const data = await graphql(cfg, xrayToken, `
|
|
344
|
+
query ($projectId: String!, $path: String!) {
|
|
345
|
+
getFolder(projectId: $projectId, path: $path) {
|
|
346
|
+
name
|
|
347
|
+
path
|
|
348
|
+
testsCount
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
`, { projectId: String(projectId), path: folderPath });
|
|
352
|
+
return data.getFolder;
|
|
353
|
+
}
|
|
229
354
|
|
|
230
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Get project settings (statuses, test types, custom fields).
|
|
357
|
+
* @param {object} cfg
|
|
358
|
+
* @param {string} xrayToken
|
|
359
|
+
* @param {string} projectId
|
|
360
|
+
* @returns {Promise<object>}
|
|
361
|
+
*/
|
|
362
|
+
export async function getProjectSettings(cfg, xrayToken, projectId) {
|
|
363
|
+
const data = await graphql(cfg, xrayToken, `
|
|
364
|
+
query ($projectId: String!) {
|
|
365
|
+
getProjectSettings(projectId: $projectId) {
|
|
366
|
+
testTypes { name kind }
|
|
367
|
+
testStepSettings {
|
|
368
|
+
fields { id name }
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
`, { projectId: String(projectId) });
|
|
373
|
+
return data.getProjectSettings;
|
|
231
374
|
}
|
|
232
375
|
|
|
376
|
+
// ─── Xray GraphQL — Test mutations ────────────────────────────────────────────
|
|
377
|
+
|
|
233
378
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
379
|
+
* Set a test issue's type via Xray GraphQL.
|
|
236
380
|
* @param {object} cfg
|
|
237
381
|
* @param {string} xrayToken
|
|
238
382
|
* @param {string} issueId
|
|
383
|
+
* @param {string} typeName "Generic" | "Manual" | "Cucumber"
|
|
239
384
|
*/
|
|
240
|
-
export async function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
385
|
+
export async function setTestType(cfg, xrayToken, issueId, typeName = "Generic") {
|
|
386
|
+
await graphql(cfg, xrayToken, `
|
|
387
|
+
mutation ($issueId: String!, $testType: UpdateTestTypeInput!) {
|
|
388
|
+
updateTestType(issueId: $issueId, testType: $testType) {
|
|
389
|
+
issueId
|
|
390
|
+
testType { name kind }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
`, { issueId: String(issueId), testType: { name: typeName } });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** @deprecated Use setTestType("Generic") */
|
|
397
|
+
export async function setTestTypeAutomated(cfg, xrayToken, issueId) {
|
|
398
|
+
return setTestType(cfg, xrayToken, issueId, "Generic");
|
|
399
|
+
}
|
|
245
400
|
|
|
246
|
-
|
|
247
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Add a single test step.
|
|
403
|
+
* @param {object} cfg
|
|
404
|
+
* @param {string} xrayToken
|
|
405
|
+
* @param {string} issueId
|
|
406
|
+
* @param {object} step { action, data, expected_result }
|
|
407
|
+
* @returns {Promise<object>}
|
|
408
|
+
*/
|
|
409
|
+
export async function addTestStep(cfg, xrayToken, issueId, step) {
|
|
410
|
+
const data = await graphql(cfg, xrayToken, `
|
|
411
|
+
mutation ($issueId: String!, $step: CreateStepInput!) {
|
|
412
|
+
addTestStep(issueId: $issueId, step: $step) {
|
|
413
|
+
id
|
|
414
|
+
action
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
`, {
|
|
418
|
+
issueId: String(issueId),
|
|
419
|
+
step: {
|
|
420
|
+
action: step.action,
|
|
421
|
+
data: step.data || "",
|
|
422
|
+
result: step.expected_result || "",
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
return data.addTestStep;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Remove all existing test steps (for update/replace flow).
|
|
430
|
+
* Uses the removeAllTestSteps mutation directly — faster than looping.
|
|
431
|
+
* @param {object} cfg
|
|
432
|
+
* @param {string} xrayToken
|
|
433
|
+
* @param {string} issueId
|
|
434
|
+
* @returns {Promise<number>} count of removed steps
|
|
435
|
+
*/
|
|
436
|
+
export async function removeAllTestSteps(cfg, xrayToken, issueId) {
|
|
437
|
+
// Get existing steps count first
|
|
438
|
+
const response = await axios.post(
|
|
248
439
|
cfg.xrayGraphqlUrl,
|
|
249
440
|
{
|
|
250
441
|
query: `query ($issueId: String!) {
|
|
251
442
|
getTest(issueId: $issueId) {
|
|
252
|
-
steps {
|
|
253
|
-
id
|
|
254
|
-
}
|
|
443
|
+
steps { id }
|
|
255
444
|
}
|
|
256
445
|
}`,
|
|
257
446
|
variables: { issueId: String(issueId) },
|
|
258
447
|
},
|
|
259
|
-
{ httpsAgent, headers }
|
|
448
|
+
{ httpsAgent, headers: xrayHeaders(xrayToken) }
|
|
260
449
|
);
|
|
261
450
|
|
|
262
|
-
const steps =
|
|
451
|
+
const steps = response.data?.data?.getTest?.steps || [];
|
|
452
|
+
if (steps.length === 0) return 0;
|
|
263
453
|
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
removeTestStep(issueId: $issueId, stepId: $stepId)
|
|
271
|
-
}`,
|
|
272
|
-
variables: { issueId: String(issueId), stepId: String(step.id) },
|
|
273
|
-
},
|
|
274
|
-
{ httpsAgent, headers }
|
|
275
|
-
);
|
|
276
|
-
}
|
|
454
|
+
// Use removeAllTestSteps mutation (single call instead of N calls)
|
|
455
|
+
await graphql(cfg, xrayToken, `
|
|
456
|
+
mutation ($issueId: String!) {
|
|
457
|
+
removeAllTestSteps(issueId: $issueId)
|
|
458
|
+
}
|
|
459
|
+
`, { issueId: String(issueId) });
|
|
277
460
|
|
|
278
461
|
return steps.length;
|
|
279
462
|
}
|
|
280
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Update the unstructured (Generic) test definition.
|
|
466
|
+
* @param {object} cfg
|
|
467
|
+
* @param {string} xrayToken
|
|
468
|
+
* @param {string} issueId
|
|
469
|
+
* @param {string} definition Free-form test definition string
|
|
470
|
+
*/
|
|
471
|
+
export async function updateTestDefinition(cfg, xrayToken, issueId, definition) {
|
|
472
|
+
await graphql(cfg, xrayToken, `
|
|
473
|
+
mutation ($issueId: String!, $unstructured: String!) {
|
|
474
|
+
updateUnstructuredTestDefinition(issueId: $issueId, unstructured: $unstructured) {
|
|
475
|
+
issueId
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
`, { issueId: String(issueId), unstructured: definition });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Update test's folder in the Xray Test Repository.
|
|
483
|
+
* @param {object} cfg
|
|
484
|
+
* @param {string} xrayToken
|
|
485
|
+
* @param {string} issueId
|
|
486
|
+
* @param {string} folderPath e.g. "/API/Auth"
|
|
487
|
+
*/
|
|
488
|
+
export async function updateTestFolder(cfg, xrayToken, issueId, folderPath) {
|
|
489
|
+
await graphql(cfg, xrayToken, `
|
|
490
|
+
mutation ($issueId: String!, $folderPath: String!) {
|
|
491
|
+
updateTestFolder(issueId: $issueId, folderPath: $folderPath) {
|
|
492
|
+
warnings
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
`, { issueId: String(issueId), folderPath });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─── Xray GraphQL — Test Plan mutations ───────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Create a new Test Plan issue via GraphQL.
|
|
502
|
+
* @param {object} cfg
|
|
503
|
+
* @param {string} xrayToken
|
|
504
|
+
* @param {object} opts { projectId, summary, description }
|
|
505
|
+
* @returns {Promise<{issueId: string, warnings: string[]}>}
|
|
506
|
+
*/
|
|
507
|
+
export async function createTestPlan(cfg, xrayToken, opts) {
|
|
508
|
+
const data = await graphql(cfg, xrayToken, `
|
|
509
|
+
mutation ($projectId: String!, $summary: String!, $description: String) {
|
|
510
|
+
createTestPlan(projectId: $projectId, summary: $summary, description: $description) {
|
|
511
|
+
testPlan {
|
|
512
|
+
issueId
|
|
513
|
+
projectId
|
|
514
|
+
}
|
|
515
|
+
warnings
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
`, {
|
|
519
|
+
projectId: opts.projectId || cfg.jiraProjectKey,
|
|
520
|
+
summary: opts.summary,
|
|
521
|
+
description: opts.description || null,
|
|
522
|
+
});
|
|
523
|
+
return data.createTestPlan;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Add tests to a Test Plan.
|
|
528
|
+
* @param {object} cfg
|
|
529
|
+
* @param {string} xrayToken
|
|
530
|
+
* @param {string} planIssueId
|
|
531
|
+
* @param {string[]} testIssueIds Numeric JIRA issue IDs of test issues
|
|
532
|
+
* @returns {Promise<object>}
|
|
533
|
+
*/
|
|
534
|
+
export async function addTestsToTestPlan(cfg, xrayToken, planIssueId, testIssueIds) {
|
|
535
|
+
const data = await graphql(cfg, xrayToken, `
|
|
536
|
+
mutation ($issueId: String!, $testIssueIds: [String]!) {
|
|
537
|
+
addTestsToTestPlan(issueId: $issueId, testIssueIds: $testIssueIds) {
|
|
538
|
+
addedTests
|
|
539
|
+
warnings
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
`, {
|
|
543
|
+
issueId: String(planIssueId),
|
|
544
|
+
testIssueIds: testIssueIds.map(String),
|
|
545
|
+
});
|
|
546
|
+
return data.addTestsToTestPlan;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Remove tests from a Test Plan.
|
|
551
|
+
* @param {object} cfg
|
|
552
|
+
* @param {string} xrayToken
|
|
553
|
+
* @param {string} planIssueId
|
|
554
|
+
* @param {string[]} testIssueIds
|
|
555
|
+
*/
|
|
556
|
+
export async function removeTestsFromTestPlan(cfg, xrayToken, planIssueId, testIssueIds) {
|
|
557
|
+
await graphql(cfg, xrayToken, `
|
|
558
|
+
mutation ($issueId: String!, $testIssueIds: [String]!) {
|
|
559
|
+
removeTestsFromTestPlan(issueId: $issueId, testIssueIds: $testIssueIds) {
|
|
560
|
+
warnings
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
`, {
|
|
564
|
+
issueId: String(planIssueId),
|
|
565
|
+
testIssueIds: testIssueIds.map(String),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ─── Xray GraphQL — Folder mutations ──────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Create a folder in the Xray Test Repository.
|
|
573
|
+
* @param {object} cfg
|
|
574
|
+
* @param {string} xrayToken
|
|
575
|
+
* @param {string} projectId JIRA project ID (numeric)
|
|
576
|
+
* @param {string} folderPath Full path e.g. "/API/Auth"
|
|
577
|
+
* @returns {Promise<object>}
|
|
578
|
+
*/
|
|
579
|
+
export async function createFolder(cfg, xrayToken, projectId, folderPath) {
|
|
580
|
+
// Derive name and parent path
|
|
581
|
+
const parts = folderPath.split("/").filter(Boolean);
|
|
582
|
+
const name = parts[parts.length - 1];
|
|
583
|
+
const parentPath = "/" + parts.slice(0, -1).join("/");
|
|
584
|
+
|
|
585
|
+
const data = await graphql(cfg, xrayToken, `
|
|
586
|
+
mutation ($projectId: String!, $path: String!) {
|
|
587
|
+
createFolder(projectId: $projectId, path: $path) {
|
|
588
|
+
folder { name path testsCount }
|
|
589
|
+
warnings
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
`, { projectId: String(projectId), path: folderPath });
|
|
593
|
+
return data.createFolder;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Move tests into a repository folder.
|
|
598
|
+
* @param {object} cfg
|
|
599
|
+
* @param {string} xrayToken
|
|
600
|
+
* @param {string} projectId
|
|
601
|
+
* @param {string} folderPath e.g. "/API/Auth"
|
|
602
|
+
* @param {string[]} testIssueIds
|
|
603
|
+
* @returns {Promise<object>}
|
|
604
|
+
*/
|
|
605
|
+
export async function addTestsToFolder(cfg, xrayToken, projectId, folderPath, testIssueIds) {
|
|
606
|
+
const data = await graphql(cfg, xrayToken, `
|
|
607
|
+
mutation ($projectId: String!, $path: String!, $testIssueIds: [String]!) {
|
|
608
|
+
addTestsToFolder(projectId: $projectId, path: $path, testIssueIds: $testIssueIds) {
|
|
609
|
+
folder { path testsCount }
|
|
610
|
+
warnings
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
`, {
|
|
614
|
+
projectId: String(projectId),
|
|
615
|
+
path: folderPath,
|
|
616
|
+
testIssueIds: testIssueIds.map(String),
|
|
617
|
+
});
|
|
618
|
+
return data.addTestsToFolder;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ─── Xray GraphQL — Test Execution mutations ───────────────────────────────────
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Add test environments to an existing test execution.
|
|
625
|
+
* @param {object} cfg
|
|
626
|
+
* @param {string} xrayToken
|
|
627
|
+
* @param {string} execIssueId JIRA issue ID of execution (numeric)
|
|
628
|
+
* @param {string[]} environments e.g. ["IOP-QA"]
|
|
629
|
+
*/
|
|
630
|
+
export async function addTestEnvironmentsToTestExecution(cfg, xrayToken, execIssueId, environments) {
|
|
631
|
+
await graphql(cfg, xrayToken, `
|
|
632
|
+
mutation ($issueId: String!, $testEnvironments: [String]!) {
|
|
633
|
+
addTestEnvironmentsToTestExecution(issueId: $issueId, testEnvironments: $testEnvironments) {
|
|
634
|
+
addedTestEnvironments
|
|
635
|
+
warnings
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
`, {
|
|
639
|
+
issueId: String(execIssueId),
|
|
640
|
+
testEnvironments: environments,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
281
644
|
// ─── Issue linking ─────────────────────────────────────────────────────────────
|
|
282
645
|
|
|
283
646
|
/**
|
|
284
|
-
* Link a test issue to a container
|
|
285
|
-
*
|
|
647
|
+
* Link a test issue to a container via JIRA issue links.
|
|
286
648
|
* @param {object} cfg
|
|
287
|
-
* @param {string} testKey
|
|
288
|
-
* @param {string} containerKey
|
|
289
|
-
* @returns {Promise<boolean>} true if linked successfully
|
|
649
|
+
* @param {string} testKey Inward issue key (Test)
|
|
650
|
+
* @param {string} containerKey Outward issue key (Execution / Plan)
|
|
290
651
|
*/
|
|
291
652
|
export async function linkIssues(cfg, testKey, containerKey) {
|
|
292
|
-
const payload = {
|
|
293
|
-
type: { name: "Test" },
|
|
294
|
-
inwardIssue: { key: testKey },
|
|
295
|
-
outwardIssue: { key: containerKey },
|
|
296
|
-
};
|
|
297
|
-
|
|
298
653
|
await axios.post(
|
|
299
654
|
`${cfg.jiraUrl}/rest/api/3/issueLink`,
|
|
300
|
-
|
|
655
|
+
{
|
|
656
|
+
type: { name: "Test" },
|
|
657
|
+
inwardIssue: { key: testKey },
|
|
658
|
+
outwardIssue: { key: containerKey },
|
|
659
|
+
},
|
|
301
660
|
{ httpsAgent, headers: jiraHeaders(cfg) }
|
|
302
661
|
);
|
|
303
|
-
|
|
304
662
|
return true;
|
|
305
663
|
}
|
|
306
664
|
|
|
307
665
|
/**
|
|
308
666
|
* Link multiple test keys to a container key.
|
|
309
|
-
*
|
|
310
667
|
* @param {object} cfg
|
|
311
668
|
* @param {string[]} testKeys
|
|
312
|
-
* @param {string} containerKey
|
|
669
|
+
* @param {string} containerKey
|
|
313
670
|
* @returns {Promise<{linked: string[], failed: string[]}>}
|
|
314
671
|
*/
|
|
315
672
|
export async function linkMultiple(cfg, testKeys, containerKey) {
|
|
@@ -329,20 +686,17 @@ export async function linkMultiple(cfg, testKeys, containerKey) {
|
|
|
329
686
|
return { linked, failed };
|
|
330
687
|
}
|
|
331
688
|
|
|
332
|
-
// ─── Xray
|
|
689
|
+
// ─── Xray REST v2 — Import execution results ───────────────────────────────────
|
|
333
690
|
|
|
334
691
|
/**
|
|
335
|
-
* Import JUnit/XUnit XML results
|
|
336
|
-
*
|
|
692
|
+
* Import JUnit/XUnit XML results.
|
|
337
693
|
* @param {object} cfg
|
|
338
|
-
* @param {string} xrayToken
|
|
339
|
-
* @param {Buffer} xmlBuffer
|
|
340
|
-
* @param {string} testExecKey
|
|
341
|
-
* @returns {Promise<object>}
|
|
694
|
+
* @param {string} xrayToken
|
|
695
|
+
* @param {Buffer} xmlBuffer
|
|
696
|
+
* @param {string} [testExecKey]
|
|
342
697
|
*/
|
|
343
698
|
export async function importResults(cfg, xrayToken, xmlBuffer, testExecKey) {
|
|
344
|
-
const url =
|
|
345
|
-
|
|
699
|
+
const url = `${cfg.xrayRestUrl}/import/execution/junit`;
|
|
346
700
|
const params = {};
|
|
347
701
|
if (testExecKey) params.testExecKey = testExecKey;
|
|
348
702
|
if (cfg.jiraProjectKey) params.projectKey = cfg.jiraProjectKey;
|
|
@@ -360,17 +714,15 @@ export async function importResults(cfg, xrayToken, xmlBuffer, testExecKey) {
|
|
|
360
714
|
}
|
|
361
715
|
|
|
362
716
|
/**
|
|
363
|
-
* Import test results using Xray
|
|
364
|
-
*
|
|
365
|
-
*
|
|
717
|
+
* Import test results using Xray JSON format (single JSON body).
|
|
718
|
+
* Creates a new Test Execution automatically.
|
|
366
719
|
* @param {object} cfg
|
|
367
|
-
* @param {string} xrayToken
|
|
368
|
-
* @param {object} xrayJson Xray JSON
|
|
720
|
+
* @param {string} xrayToken
|
|
721
|
+
* @param {object} xrayJson Full Xray JSON payload (info + tests)
|
|
369
722
|
* @returns {Promise<object>}
|
|
370
723
|
*/
|
|
371
724
|
export async function importResultsXrayJson(cfg, xrayToken, xrayJson) {
|
|
372
|
-
const url =
|
|
373
|
-
|
|
725
|
+
const url = `${cfg.xrayRestUrl}/import/execution`;
|
|
374
726
|
try {
|
|
375
727
|
const response = await axios.post(url, xrayJson, {
|
|
376
728
|
httpsAgent,
|
|
@@ -379,59 +731,175 @@ export async function importResultsXrayJson(cfg, xrayToken, xrayJson) {
|
|
|
379
731
|
"Content-Type": "application/json",
|
|
380
732
|
},
|
|
381
733
|
});
|
|
734
|
+
return response.data;
|
|
735
|
+
} catch (error) {
|
|
736
|
+
_handleImportError(error);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Import test results using Xray JSON multipart format.
|
|
742
|
+
* Provides full control over Jira issue fields on the created Test Execution.
|
|
743
|
+
*
|
|
744
|
+
* @param {object} cfg
|
|
745
|
+
* @param {string} xrayToken
|
|
746
|
+
* @param {object} xrayJson Xray results JSON (info.testPlanKey etc. — note: 'info' in this part IS used by multipart)
|
|
747
|
+
* @param {object} [jiraFieldsJson] Jira issue fields for the new Test Execution (optional)
|
|
748
|
+
* @returns {Promise<object>}
|
|
749
|
+
*/
|
|
750
|
+
export async function importResultsMultipart(cfg, xrayToken, xrayJson, jiraFieldsJson = null) {
|
|
751
|
+
const url = `${cfg.xrayRestUrl}/import/execution/multipart`;
|
|
752
|
+
|
|
753
|
+
const form = new FormData();
|
|
754
|
+
form.append("results", JSON.stringify(xrayJson), {
|
|
755
|
+
contentType: "application/json",
|
|
756
|
+
filename: "results.json",
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
if (jiraFieldsJson) {
|
|
760
|
+
form.append("info", JSON.stringify(jiraFieldsJson), {
|
|
761
|
+
contentType: "application/json",
|
|
762
|
+
filename: "info.json",
|
|
763
|
+
});
|
|
764
|
+
}
|
|
382
765
|
|
|
766
|
+
try {
|
|
767
|
+
const response = await axios.post(url, form, {
|
|
768
|
+
httpsAgent,
|
|
769
|
+
headers: {
|
|
770
|
+
Authorization: `Bearer ${xrayToken}`,
|
|
771
|
+
...form.getHeaders(),
|
|
772
|
+
},
|
|
773
|
+
});
|
|
383
774
|
return response.data;
|
|
384
775
|
} catch (error) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
776
|
+
_handleImportError(error);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ─── Xray REST v2 — Bulk test import ──────────────────────────────────────────
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Import tests in bulk (async — returns a jobId).
|
|
784
|
+
* Max 1000 tests per call. Use checkImportJobStatus() to poll.
|
|
785
|
+
* @param {object} cfg
|
|
786
|
+
* @param {string} xrayToken
|
|
787
|
+
* @param {object[]} tests Array of Xray test import objects
|
|
788
|
+
* @returns {Promise<{jobId: string}>}
|
|
789
|
+
*/
|
|
790
|
+
export async function importTestsBulk(cfg, xrayToken, tests) {
|
|
791
|
+
const url = `${cfg.xrayRestUrl}/import/test/bulk`;
|
|
792
|
+
|
|
793
|
+
const response = await axios.post(url, tests, {
|
|
794
|
+
httpsAgent,
|
|
795
|
+
headers: {
|
|
796
|
+
Authorization: `Bearer ${xrayToken}`,
|
|
797
|
+
"Content-Type": "application/json",
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
return response.data; // { jobId: "..." }
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Check the status of a bulk test import job.
|
|
806
|
+
* @param {object} cfg
|
|
807
|
+
* @param {string} xrayToken
|
|
808
|
+
* @param {string} jobId
|
|
809
|
+
* @returns {Promise<{status: string, result?: object}>}
|
|
810
|
+
*/
|
|
811
|
+
export async function checkImportJobStatus(cfg, xrayToken, jobId) {
|
|
812
|
+
const url = `${cfg.xrayRestUrl}/import/test/bulk/${jobId}/status`;
|
|
813
|
+
|
|
814
|
+
const response = await axios.get(url, {
|
|
815
|
+
httpsAgent,
|
|
816
|
+
headers: { Authorization: `Bearer ${xrayToken}` },
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
return response.data;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Poll a bulk import job until complete or timeout.
|
|
824
|
+
* @param {object} cfg
|
|
825
|
+
* @param {string} xrayToken
|
|
826
|
+
* @param {string} jobId
|
|
827
|
+
* @param {object} [opts] { pollInterval: ms, timeout: ms }
|
|
828
|
+
* @returns {Promise<object>} Final job status result
|
|
829
|
+
*/
|
|
830
|
+
export async function waitForImportJob(cfg, xrayToken, jobId, opts = {}) {
|
|
831
|
+
const pollInterval = opts.pollInterval || 2000;
|
|
832
|
+
const timeout = opts.timeout || 60000;
|
|
833
|
+
const start = Date.now();
|
|
834
|
+
|
|
835
|
+
while (Date.now() - start < timeout) {
|
|
836
|
+
const status = await checkImportJobStatus(cfg, xrayToken, jobId);
|
|
837
|
+
if (status.status === "successful" || status.status === "failed") {
|
|
838
|
+
return status;
|
|
415
839
|
}
|
|
416
|
-
|
|
417
|
-
throw error;
|
|
840
|
+
await sleep(pollInterval);
|
|
418
841
|
}
|
|
842
|
+
|
|
843
|
+
throw new Error(`Bulk import job ${jobId} timed out after ${timeout}ms`);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ─── Xray REST v2 — Attachments ───────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Upload an attachment to Xray.
|
|
850
|
+
* @param {object} cfg
|
|
851
|
+
* @param {string} xrayToken
|
|
852
|
+
* @param {Buffer} fileBuffer
|
|
853
|
+
* @param {string} filename
|
|
854
|
+
* @param {string} [contentType]
|
|
855
|
+
* @returns {Promise<{id: string, filename: string}>}
|
|
856
|
+
*/
|
|
857
|
+
export async function addAttachment(cfg, xrayToken, fileBuffer, filename, contentType = "application/octet-stream") {
|
|
858
|
+
const url = `${cfg.xrayRestUrl}/attachments`;
|
|
859
|
+
|
|
860
|
+
const form = new FormData();
|
|
861
|
+
form.append("file", fileBuffer, { filename, contentType });
|
|
862
|
+
|
|
863
|
+
const response = await axios.post(url, form, {
|
|
864
|
+
httpsAgent,
|
|
865
|
+
headers: {
|
|
866
|
+
Authorization: `Bearer ${xrayToken}`,
|
|
867
|
+
...form.getHeaders(),
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
return response.data;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Get an attachment by its ID.
|
|
876
|
+
* @param {object} cfg
|
|
877
|
+
* @param {string} xrayToken
|
|
878
|
+
* @param {string} attachmentId
|
|
879
|
+
* @returns {Promise<Buffer>}
|
|
880
|
+
*/
|
|
881
|
+
export async function getAttachment(cfg, xrayToken, attachmentId) {
|
|
882
|
+
const url = `${cfg.xrayRestUrl}/attachments/${attachmentId}`;
|
|
883
|
+
|
|
884
|
+
const response = await axios.get(url, {
|
|
885
|
+
httpsAgent,
|
|
886
|
+
responseType: "arraybuffer",
|
|
887
|
+
headers: { Authorization: `Bearer ${xrayToken}` },
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
return response.data;
|
|
419
891
|
}
|
|
420
892
|
|
|
421
893
|
// ─── Retry wrapper ─────────────────────────────────────────────────────────────
|
|
422
894
|
|
|
423
895
|
/**
|
|
424
|
-
* Retry
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
* @param {
|
|
430
|
-
* @param {
|
|
431
|
-
* @param {number} [opts.maxRetries=5] Max attempts
|
|
432
|
-
* @param {number} [opts.baseDelay=2000] Base delay in ms
|
|
433
|
-
* @param {string} [opts.retryOn] Error substring to match for retry
|
|
434
|
-
* @returns {Promise<*>}
|
|
896
|
+
* Retry with exponential backoff — handles Xray indexing delays.
|
|
897
|
+
* Backoff: 2s → 4s → 8s → 16s → 32s
|
|
898
|
+
* @param {Function} fn
|
|
899
|
+
* @param {object} [opts]
|
|
900
|
+
* @param {number} [opts.maxRetries=5]
|
|
901
|
+
* @param {number} [opts.baseDelay=2000]
|
|
902
|
+
* @param {string} [opts.retryOn] Error substring to match for retry
|
|
435
903
|
*/
|
|
436
904
|
export async function withRetry(fn, opts = {}) {
|
|
437
905
|
const maxRetries = opts.maxRetries ?? 5;
|
|
@@ -451,19 +919,10 @@ export async function withRetry(fn, opts = {}) {
|
|
|
451
919
|
lastError = err;
|
|
452
920
|
const msg = err.message || "";
|
|
453
921
|
if (!msg.includes(retryOn)) {
|
|
454
|
-
// Non-retryable error — detect impersonation issues
|
|
455
922
|
if (msg.includes("disallowed to impersonate") || msg.includes("no valid active user exists")) {
|
|
456
923
|
throw new Error(
|
|
457
|
-
`Xray user authentication mismatch
|
|
458
|
-
`
|
|
459
|
-
`1. Your JIRA_EMAIL doesn't match the Xray API Key owner\n` +
|
|
460
|
-
`2. The user doesn't have an active Xray license\n` +
|
|
461
|
-
`3. The Xray API Key was created by a different user\n\n` +
|
|
462
|
-
`Solutions:\n` +
|
|
463
|
-
`- Ensure JIRA_EMAIL matches the Xray API Key owner's email\n` +
|
|
464
|
-
`- Verify you have an active Xray license assigned\n` +
|
|
465
|
-
`- Regenerate Xray API Key with the same user as JIRA_API_TOKEN\n` +
|
|
466
|
-
`- Contact your Xray administrator\n\n` +
|
|
924
|
+
`Xray user authentication mismatch.\n\n` +
|
|
925
|
+
`Ensure JIRA_EMAIL matches the Xray API Key owner.\n` +
|
|
467
926
|
`Original error: ${msg}`
|
|
468
927
|
);
|
|
469
928
|
}
|
|
@@ -473,3 +932,121 @@ export async function withRetry(fn, opts = {}) {
|
|
|
473
932
|
}
|
|
474
933
|
throw lastError;
|
|
475
934
|
}
|
|
935
|
+
|
|
936
|
+
// ─── Internal error handler ────────────────────────────────────────────────────
|
|
937
|
+
|
|
938
|
+
function _handleImportError(error) {
|
|
939
|
+
if (error.response) {
|
|
940
|
+
const status = error.response.status;
|
|
941
|
+
const data = error.response.data;
|
|
942
|
+
|
|
943
|
+
let errorMsg = `Xray API returned ${status} error`;
|
|
944
|
+
if (typeof data === "string") errorMsg += `: ${data}`;
|
|
945
|
+
else if (data?.error) errorMsg += `: ${data.error}`;
|
|
946
|
+
else if (data?.message) errorMsg += `: ${data.message}`;
|
|
947
|
+
else if (data) errorMsg += `: ${JSON.stringify(data)}`;
|
|
948
|
+
|
|
949
|
+
logger.error(errorMsg);
|
|
950
|
+
|
|
951
|
+
if (status === 400) {
|
|
952
|
+
logger.warn("Common causes: missing test annotations, invalid test key, malformed JSON structure.");
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
throw new Error(errorMsg);
|
|
956
|
+
}
|
|
957
|
+
throw error;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// ─── Xray GraphQL — Test Execution mutations (cont.) ──────────────────────────
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Add tests to an existing Test Execution by their Xray issue IDs.
|
|
964
|
+
* @param {object} cfg
|
|
965
|
+
* @param {string} xrayToken
|
|
966
|
+
* @param {string} execIssueId Numeric Xray issue ID of the Test Execution
|
|
967
|
+
* @param {string[]} testIssueIds Numeric Xray issue IDs of the tests to add
|
|
968
|
+
*/
|
|
969
|
+
export async function addTestsToTestExecution(cfg, xrayToken, execIssueId, testIssueIds) {
|
|
970
|
+
const { data } = await graphql(cfg, xrayToken, `
|
|
971
|
+
mutation ($issueId: String!, $testIssueIds: [String]!) {
|
|
972
|
+
addTestsToTestExecution(issueId: $issueId, testIssueIds: $testIssueIds) {
|
|
973
|
+
addedTests
|
|
974
|
+
warnings
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
`, {
|
|
978
|
+
issueId: String(execIssueId),
|
|
979
|
+
testIssueIds: testIssueIds.map(String),
|
|
980
|
+
});
|
|
981
|
+
return data.addTestsToTestExecution;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ─── High-level — Test Execution creation ─────────────────────────────────────
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Create a new Test Execution in JIRA and configure it for Xray.
|
|
988
|
+
*
|
|
989
|
+
* Steps:
|
|
990
|
+
* 1. Create the JIRA issue of type "Test Execution"
|
|
991
|
+
* 2. Apply environment labels via GraphQL mutation
|
|
992
|
+
* 3. If testIssueIds supplied, add those tests to the execution
|
|
993
|
+
*
|
|
994
|
+
* @param {object} cfg
|
|
995
|
+
* @param {string} xrayToken
|
|
996
|
+
* @param {object} opts
|
|
997
|
+
* @param {string} opts.summary Execution title
|
|
998
|
+
* @param {string} [opts.description] Optional description
|
|
999
|
+
* @param {string[]} [opts.environments] e.g. ["IOP-QA"]
|
|
1000
|
+
* @param {string[]} [opts.testIssueIds] Numeric Xray issue IDs to add
|
|
1001
|
+
* @param {string} [opts.testPlanKey] If set, link execution to this plan
|
|
1002
|
+
* @returns {Promise<{key: string, id: string}>}
|
|
1003
|
+
*/
|
|
1004
|
+
export async function createTestExecution(cfg, xrayToken, opts) {
|
|
1005
|
+
const {
|
|
1006
|
+
summary,
|
|
1007
|
+
description,
|
|
1008
|
+
environments = [],
|
|
1009
|
+
testIssueIds = [],
|
|
1010
|
+
testPlanKey,
|
|
1011
|
+
} = opts;
|
|
1012
|
+
|
|
1013
|
+
// 1. Create JIRA issue
|
|
1014
|
+
const issue = await createIssue(cfg, "Test Execution", {
|
|
1015
|
+
summary: summary || `Test Execution — ${new Date().toLocaleString()}`,
|
|
1016
|
+
description: description || summary || "",
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
logger.step(`Test Execution created: ${issue.key}`);
|
|
1020
|
+
|
|
1021
|
+
// 2. Add environment labels
|
|
1022
|
+
if (environments.length > 0) {
|
|
1023
|
+
try {
|
|
1024
|
+
await addTestEnvironmentsToTestExecution(cfg, xrayToken, issue.id, environments);
|
|
1025
|
+
logger.step(`Environments set: ${environments.join(", ")}`);
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
logger.warn(`Could not set environments: ${err.message}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// 3. Link to Test Plan if provided
|
|
1032
|
+
if (testPlanKey) {
|
|
1033
|
+
try {
|
|
1034
|
+
await linkIssues(cfg, issue.key, testPlanKey);
|
|
1035
|
+
logger.step(`Linked to Test Plan: ${testPlanKey}`);
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
logger.warn(`Could not link to Test Plan ${testPlanKey}: ${err.message}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// 4. Add specific tests
|
|
1042
|
+
if (testIssueIds.length > 0) {
|
|
1043
|
+
try {
|
|
1044
|
+
await addTestsToTestExecution(cfg, xrayToken, issue.id, testIssueIds);
|
|
1045
|
+
logger.step(`${testIssueIds.length} test(s) added to execution`);
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
logger.warn(`Could not add tests to execution: ${err.message}`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return { key: issue.key, id: issue.id };
|
|
1052
|
+
}
|