@msalaam/xray-qe-toolkit 1.1.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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * @msalaam/xray-qe-toolkit — Xray + JIRA API Client
3
+ *
4
+ * Consolidated API layer that replaces the duplicated code from the original
5
+ * index.js and scripts/linkTests.js. Handles:
6
+ * • Xray Cloud authentication (JWT)
7
+ * • JIRA REST v3 issue CRUD
8
+ * • Xray GraphQL mutations (test type, steps)
9
+ * • Issue linking (Test ↔ Execution / Plan)
10
+ * • Exponential-backoff retry for Xray indexing race conditions
11
+ */
12
+
13
+ import axios from "axios";
14
+ import https from "node:https";
15
+ import logger from "./logger.js";
16
+
17
+ // Shared HTTPS agent — rejectUnauthorized: false for corporate proxy compat
18
+ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
19
+
20
+ // ─── Internal helpers ──────────────────────────────────────────────────────────
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
+ function jiraAuth(cfg) {
28
+ return Buffer.from(`${cfg.jiraEmail}:${cfg.jiraApiToken}`).toString("base64");
29
+ }
30
+
31
+ /**
32
+ * Standard JIRA REST headers.
33
+ * @param {object} cfg
34
+ * @returns {object}
35
+ */
36
+ function jiraHeaders(cfg) {
37
+ return {
38
+ Authorization: `Basic ${jiraAuth(cfg)}`,
39
+ "Content-Type": "application/json",
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Convert a plain-text description to Atlassian Document Format (ADF).
45
+ * @param {string} text
46
+ * @returns {object} ADF document
47
+ */
48
+ export function toAdf(text) {
49
+ return {
50
+ type: "doc",
51
+ version: 1,
52
+ content: [
53
+ {
54
+ type: "paragraph",
55
+ content: [{ type: "text", text: text || "" }],
56
+ },
57
+ ],
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Sleep for `ms` milliseconds.
63
+ * @param {number} ms
64
+ * @returns {Promise<void>}
65
+ */
66
+ function sleep(ms) {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+
70
+ // ─── Xray Cloud Authentication ─────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Authenticate with Xray Cloud and return a JWT bearer token.
74
+ *
75
+ * @param {object} cfg Config from loadConfig()
76
+ * @returns {Promise<string>} JWT token string
77
+ */
78
+ export async function authenticate(cfg) {
79
+ logger.auth("Authenticating with Xray Cloud...");
80
+ const response = await axios.post(
81
+ cfg.xrayAuthUrl,
82
+ { client_id: cfg.xrayId, client_secret: cfg.xraySecret },
83
+ { httpsAgent }
84
+ );
85
+ logger.success("Authenticated with Xray Cloud");
86
+ return response.data; // JWT string
87
+ }
88
+
89
+ // ─── JIRA REST v3 — Issue operations ───────────────────────────────────────────
90
+
91
+ /**
92
+ * Create a JIRA issue (generic).
93
+ *
94
+ * @param {object} cfg Config
95
+ * @param {string} issueType e.g. "Test", "Test Execution", "Test Plan"
96
+ * @param {object} fields { summary, description, priority, labels, ... }
97
+ * @returns {Promise<{key: string, id: string}>}
98
+ */
99
+ export async function createIssue(cfg, issueType, fields) {
100
+ const payload = {
101
+ fields: {
102
+ project: { key: cfg.jiraProjectKey },
103
+ summary: fields.summary,
104
+ description: toAdf(fields.description || fields.summary),
105
+ issuetype: { name: issueType },
106
+ ...(fields.priority ? { priority: { name: fields.priority } } : {}),
107
+ ...(fields.labels && fields.labels.length > 0 ? { labels: fields.labels } : {}),
108
+ },
109
+ };
110
+
111
+ const response = await axios.post(
112
+ `${cfg.jiraUrl}/rest/api/3/issue`,
113
+ payload,
114
+ { httpsAgent, headers: jiraHeaders(cfg) }
115
+ );
116
+
117
+ return { key: response.data.key, id: response.data.id };
118
+ }
119
+
120
+ /**
121
+ * Update an existing JIRA issue's fields.
122
+ *
123
+ * @param {object} cfg Config
124
+ * @param {string} issueKey e.g. "APIEE-6933"
125
+ * @param {object} fields Fields to update { summary, description, priority, labels }
126
+ * @returns {Promise<void>}
127
+ */
128
+ export async function updateIssue(cfg, issueKey, fields) {
129
+ const update = {};
130
+ if (fields.summary) update.summary = fields.summary;
131
+ if (fields.description) update.description = toAdf(fields.description);
132
+ if (fields.priority) update.priority = { name: fields.priority };
133
+ if (fields.labels) update.labels = fields.labels;
134
+
135
+ await axios.put(
136
+ `${cfg.jiraUrl}/rest/api/3/issue/${issueKey}`,
137
+ { fields: update },
138
+ { httpsAgent, headers: jiraHeaders(cfg) }
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Fetch a JIRA issue by key.
144
+ *
145
+ * @param {object} cfg
146
+ * @param {string} issueKey e.g. "APIEE-6933"
147
+ * @returns {Promise<object>} Full issue payload
148
+ */
149
+ export async function getIssue(cfg, issueKey) {
150
+ const response = await axios.get(
151
+ `${cfg.jiraUrl}/rest/api/3/issue/${issueKey}`,
152
+ { httpsAgent, headers: jiraHeaders(cfg) }
153
+ );
154
+ return response.data;
155
+ }
156
+
157
+ // ─── Xray GraphQL — Test type & steps ──────────────────────────────────────────
158
+
159
+ /**
160
+ * Set a test issue's type to "Automated" via Xray GraphQL.
161
+ *
162
+ * @param {object} cfg
163
+ * @param {string} xrayToken JWT from authenticate()
164
+ * @param {string} issueId Numeric JIRA issue ID (NOT the key)
165
+ */
166
+ export async function setTestTypeAutomated(cfg, xrayToken, issueId) {
167
+ const headers = {
168
+ Authorization: `Bearer ${xrayToken}`,
169
+ "Content-Type": "application/json",
170
+ };
171
+
172
+ const response = await axios.post(
173
+ cfg.xrayGraphqlUrl,
174
+ {
175
+ query: `mutation ($issueId: String!, $testType: UpdateTestTypeInput!) {
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 }
184
+ );
185
+
186
+ if (response.data.errors) {
187
+ throw new Error(`GraphQL errors: ${JSON.stringify(response.data.errors)}`);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Add a single test step via Xray GraphQL.
193
+ *
194
+ * @param {object} cfg
195
+ * @param {string} xrayToken JWT
196
+ * @param {string} issueId Numeric JIRA issue ID
197
+ * @param {object} step { action, data, expected_result }
198
+ */
199
+ export async function addTestStep(cfg, xrayToken, issueId, step) {
200
+ const headers = {
201
+ Authorization: `Bearer ${xrayToken}`,
202
+ "Content-Type": "application/json",
203
+ };
204
+
205
+ const response = await axios.post(
206
+ cfg.xrayGraphqlUrl,
207
+ {
208
+ query: `mutation ($issueId: String!, $step: CreateStepInput!) {
209
+ addTestStep(issueId: $issueId, step: $step) {
210
+ id
211
+ action
212
+ }
213
+ }`,
214
+ variables: {
215
+ issueId: String(issueId),
216
+ step: {
217
+ action: step.action,
218
+ data: step.data || "",
219
+ result: step.expected_result || "",
220
+ },
221
+ },
222
+ },
223
+ { httpsAgent, headers }
224
+ );
225
+
226
+ if (response.data.errors) {
227
+ throw new Error(`GraphQL errors: ${JSON.stringify(response.data.errors)}`);
228
+ }
229
+
230
+ return response.data.data.addTestStep;
231
+ }
232
+
233
+ /**
234
+ * Remove all existing test steps via Xray GraphQL (for update/replace flow).
235
+ *
236
+ * @param {object} cfg
237
+ * @param {string} xrayToken
238
+ * @param {string} issueId
239
+ */
240
+ export async function removeAllTestSteps(cfg, xrayToken, issueId) {
241
+ const headers = {
242
+ Authorization: `Bearer ${xrayToken}`,
243
+ "Content-Type": "application/json",
244
+ };
245
+
246
+ // First, get existing steps
247
+ const getResponse = await axios.post(
248
+ cfg.xrayGraphqlUrl,
249
+ {
250
+ query: `query ($issueId: String!) {
251
+ getTest(issueId: $issueId) {
252
+ steps {
253
+ id
254
+ }
255
+ }
256
+ }`,
257
+ variables: { issueId: String(issueId) },
258
+ },
259
+ { httpsAgent, headers }
260
+ );
261
+
262
+ const steps = getResponse.data?.data?.getTest?.steps || [];
263
+
264
+ // Remove each step
265
+ for (const step of steps) {
266
+ await axios.post(
267
+ cfg.xrayGraphqlUrl,
268
+ {
269
+ query: `mutation ($issueId: String!, $stepId: String!) {
270
+ removeTestStep(issueId: $issueId, stepId: $stepId)
271
+ }`,
272
+ variables: { issueId: String(issueId), stepId: String(step.id) },
273
+ },
274
+ { httpsAgent, headers }
275
+ );
276
+ }
277
+
278
+ return steps.length;
279
+ }
280
+
281
+ // ─── Issue linking ─────────────────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Link a test issue to a container (Test Execution or Test Plan) via JIRA issue links.
285
+ *
286
+ * @param {object} cfg
287
+ * @param {string} testKey Inward issue key (Test)
288
+ * @param {string} containerKey Outward issue key (Execution / Plan)
289
+ * @returns {Promise<boolean>} true if linked successfully
290
+ */
291
+ 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
+ await axios.post(
299
+ `${cfg.jiraUrl}/rest/api/3/issueLink`,
300
+ payload,
301
+ { httpsAgent, headers: jiraHeaders(cfg) }
302
+ );
303
+
304
+ return true;
305
+ }
306
+
307
+ /**
308
+ * Link multiple test keys to a container key.
309
+ *
310
+ * @param {object} cfg
311
+ * @param {string[]} testKeys
312
+ * @param {string} containerKey Test Execution or Test Plan key
313
+ * @returns {Promise<{linked: string[], failed: string[]}>}
314
+ */
315
+ export async function linkMultiple(cfg, testKeys, containerKey) {
316
+ const linked = [];
317
+ const failed = [];
318
+
319
+ for (const testKey of testKeys) {
320
+ try {
321
+ await linkIssues(cfg, testKey, containerKey);
322
+ linked.push(testKey);
323
+ } catch (err) {
324
+ logger.warn(`Failed to link ${testKey}: ${err.response?.data?.errorMessages?.[0] || err.message}`);
325
+ failed.push(testKey);
326
+ }
327
+ }
328
+
329
+ return { linked, failed };
330
+ }
331
+
332
+ // ─── Xray import endpoint ──────────────────────────────────────────────────────
333
+
334
+ /**
335
+ * Import JUnit/XUnit XML results into Xray Cloud.
336
+ *
337
+ * @param {object} cfg
338
+ * @param {string} xrayToken JWT
339
+ * @param {Buffer} xmlBuffer Raw XML file content
340
+ * @param {string} testExecKey Test Execution key to associate results with
341
+ * @returns {Promise<object>}
342
+ */
343
+ export async function importResults(cfg, xrayToken, xmlBuffer, testExecKey) {
344
+ const url = "https://xray.cloud.getxray.app/api/v2/import/execution/junit";
345
+
346
+ const params = {};
347
+ if (testExecKey) params.testExecKey = testExecKey;
348
+ if (cfg.jiraProjectKey) params.projectKey = cfg.jiraProjectKey;
349
+
350
+ const response = await axios.post(url, xmlBuffer, {
351
+ httpsAgent,
352
+ headers: {
353
+ Authorization: `Bearer ${xrayToken}`,
354
+ "Content-Type": "application/xml",
355
+ },
356
+ params,
357
+ });
358
+
359
+ return response.data;
360
+ }
361
+
362
+ // ─── Retry wrapper ─────────────────────────────────────────────────────────────
363
+
364
+ /**
365
+ * Retry an async function with exponential backoff.
366
+ * Used to handle the Xray GraphQL indexing delay after new issue creation.
367
+ *
368
+ * Backoff sequence: 2s → 4s → 8s → 16s → 32s (baseDelay × 2^attempt)
369
+ *
370
+ * @param {Function} fn Async function to retry
371
+ * @param {object} [opts] Options
372
+ * @param {number} [opts.maxRetries=5] Max attempts
373
+ * @param {number} [opts.baseDelay=2000] Base delay in ms
374
+ * @param {string} [opts.retryOn] Error substring to match for retry
375
+ * @returns {Promise<*>}
376
+ */
377
+ export async function withRetry(fn, opts = {}) {
378
+ const maxRetries = opts.maxRetries ?? 5;
379
+ const baseDelay = opts.baseDelay ?? 2000;
380
+ const retryOn = opts.retryOn ?? "issueId provided is not valid";
381
+
382
+ let lastError;
383
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
384
+ try {
385
+ const delay = baseDelay * Math.pow(2, attempt);
386
+ if (attempt > 0) {
387
+ logger.wait(`Retry ${attempt}/${maxRetries - 1} after ${delay}ms...`);
388
+ }
389
+ await sleep(delay);
390
+ return await fn();
391
+ } catch (err) {
392
+ lastError = err;
393
+ const msg = err.message || "";
394
+ if (!msg.includes(retryOn)) {
395
+ // Non-retryable error — detect impersonation issues
396
+ if (msg.includes("disallowed to impersonate") || msg.includes("no valid active user exists")) {
397
+ throw new Error(
398
+ `Xray user authentication mismatch detected.\n\n` +
399
+ `This error occurs when:\n` +
400
+ `1. Your JIRA_EMAIL doesn't match the Xray API Key owner\n` +
401
+ `2. The user doesn't have an active Xray license\n` +
402
+ `3. The Xray API Key was created by a different user\n\n` +
403
+ `Solutions:\n` +
404
+ `- Ensure JIRA_EMAIL matches the Xray API Key owner's email\n` +
405
+ `- Verify you have an active Xray license assigned\n` +
406
+ `- Regenerate Xray API Key with the same user as JIRA_API_TOKEN\n` +
407
+ `- Contact your Xray administrator\n\n` +
408
+ `Original error: ${msg}`
409
+ );
410
+ }
411
+ throw err;
412
+ }
413
+ }
414
+ }
415
+ throw lastError;
416
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@msalaam/xray-qe-toolkit",
3
+ "version": "1.1.0",
4
+ "description": "Full QE workflow toolkit for Xray Cloud integration — test management, Postman generation, CI pipeline scaffolding, and browser-based review gates for API regression projects.",
5
+ "type": "module",
6
+ "bin": {
7
+ "xqt": "./bin/cli.js"
8
+ },
9
+ "main": "lib/index.js",
10
+ "scripts": {
11
+ "start": "node bin/cli.js",
12
+ "test": "node --test tests/",
13
+ "lint": "echo \"No linter configured\" && exit 0"
14
+ },
15
+ "keywords": [
16
+ "xray",
17
+ "jira",
18
+ "testing",
19
+ "automation",
20
+ "test-management",
21
+ "ci-cd",
22
+ "quality-assurance",
23
+ "xray-cloud",
24
+ "test-automation",
25
+ "jira-integration",
26
+ "postman",
27
+ "newman",
28
+ "qe-toolkit",
29
+ "regression"
30
+ ],
31
+ "author": "@Muhaymien96 <muhaymien96@gmail.com>",
32
+ "license": "UNLICENSED",
33
+ "dependencies": {
34
+ "axios": "^1.13.4",
35
+ "commander": "^13.1.0",
36
+ "dotenv": "^17.2.3",
37
+ "express": "^5.1.0",
38
+ "open": "^10.1.2"
39
+ },
40
+ "devDependencies": {
41
+ "ajv": "^8.17.1"
42
+ },
43
+ "engines": {
44
+ "node": ">=18.0.0",
45
+ "npm": ">=8.0.0"
46
+ },
47
+ "files": [
48
+ "bin/",
49
+ "commands/",
50
+ "lib/",
51
+ "ui/",
52
+ "templates/",
53
+ "schema/",
54
+ "README.md",
55
+ ".env.example"
56
+ ],
57
+ "publishConfig": {
58
+ "registry": "https://registry.npmjs.org/",
59
+ "access": "public"
60
+ }
61
+ }
@@ -0,0 +1,112 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://oldmutual.com/schemas/xray-qe-toolkit/tests.json",
4
+ "title": "XQT Test Configuration",
5
+ "description": "Schema for @msalaam/xray-qe-toolkit tests.json files. Defines test cases to push to Xray Cloud.",
6
+ "type": "object",
7
+ "required": ["tests"],
8
+ "properties": {
9
+ "testExecution": {
10
+ "type": "object",
11
+ "description": "Configuration for the Test Execution issue created in JIRA.",
12
+ "required": ["summary"],
13
+ "properties": {
14
+ "summary": {
15
+ "type": "string",
16
+ "minLength": 1,
17
+ "description": "Summary/title for the Test Execution issue."
18
+ },
19
+ "description": {
20
+ "type": "string",
21
+ "description": "Description for the Test Execution issue."
22
+ }
23
+ }
24
+ },
25
+ "tests": {
26
+ "type": "array",
27
+ "minItems": 1,
28
+ "description": "Array of test case definitions.",
29
+ "items": {
30
+ "type": "object",
31
+ "required": ["test_id", "xray"],
32
+ "properties": {
33
+ "test_id": {
34
+ "type": "string",
35
+ "minLength": 1,
36
+ "pattern": "^[A-Za-z0-9_\\-]+$",
37
+ "description": "Unique internal test identifier. Used as the key in xray-mapping.json. Must be alphanumeric with hyphens/underscores."
38
+ },
39
+ "skip": {
40
+ "type": "boolean",
41
+ "default": false,
42
+ "description": "If true, this test will be excluded from push-tests."
43
+ },
44
+ "tags": {
45
+ "type": "array",
46
+ "items": {
47
+ "type": "string",
48
+ "enum": ["regression", "smoke", "edge", "critical", "integration", "e2e", "security", "performance"]
49
+ },
50
+ "description": "QE-assigned tags for categorisation. Set via edit-json UI."
51
+ },
52
+ "type": {
53
+ "type": "string",
54
+ "enum": ["api", "ui", "e2e"],
55
+ "default": "api",
56
+ "description": "Test type: 'api' for API tests (Postman), 'ui' for UI tests (Playwright), 'e2e' for end-to-end. Determines which generator applies."
57
+ },
58
+ "xray": {
59
+ "type": "object",
60
+ "required": ["summary"],
61
+ "description": "Xray/JIRA test case fields.",
62
+ "properties": {
63
+ "summary": {
64
+ "type": "string",
65
+ "minLength": 1,
66
+ "description": "JIRA issue summary (title) for the test."
67
+ },
68
+ "description": {
69
+ "type": "string",
70
+ "description": "JIRA issue description."
71
+ },
72
+ "priority": {
73
+ "type": "string",
74
+ "enum": ["Highest", "High", "Medium", "Low", "Lowest"],
75
+ "description": "JIRA priority level."
76
+ },
77
+ "labels": {
78
+ "type": "array",
79
+ "items": { "type": "string" },
80
+ "description": "JIRA labels for the test issue."
81
+ },
82
+ "steps": {
83
+ "type": "array",
84
+ "description": "Ordered test steps.",
85
+ "items": {
86
+ "type": "object",
87
+ "required": ["action", "expected_result"],
88
+ "properties": {
89
+ "action": {
90
+ "type": "string",
91
+ "minLength": 1,
92
+ "description": "What action to perform."
93
+ },
94
+ "data": {
95
+ "type": "string",
96
+ "description": "Input data or parameters for the step."
97
+ },
98
+ "expected_result": {
99
+ "type": "string",
100
+ "minLength": 1,
101
+ "description": "Expected outcome of the step."
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }