@msalaam/xray-qe-toolkit 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@
12
12
  import fs from "node:fs";
13
13
  import logger, { setVerbose } from "../lib/logger.js";
14
14
  import { loadConfig, validateConfig } from "../lib/config.js";
15
- import { authenticate, createTestPlan } from "../lib/xrayClient.js";
15
+ import { authenticate, createIssue, createTestPlan, getIssue } from "../lib/xrayClient.js";
16
16
 
17
17
  export default async function createPlan(opts = {}) {
18
18
  if (opts.verbose) setVerbose(true);
@@ -42,47 +42,36 @@ export default async function createPlan(opts = {}) {
42
42
 
43
43
  let planKey;
44
44
  try {
45
- const jiraPayload = {
46
- fields: {
47
- project: { key: cfg.jiraProjectKey },
48
- summary,
49
- issuetype: { name: "Test Plan" },
50
- ...(opts.version && {
51
- fixVersions: [{ name: opts.version }],
52
- }),
53
- ...(opts.label && {
54
- labels: opts.label.split(",").map((l) => l.trim()),
55
- }),
56
- },
45
+ const fields = {
46
+ summary,
47
+ description: summary,
48
+ ...(opts.label && {
49
+ labels: opts.label.split(",").map((l) => l.trim()),
50
+ }),
57
51
  };
58
52
 
59
- const response = await fetch(`${cfg.jiraUrl}/rest/api/3/issue`, {
60
- method: "POST",
61
- headers: {
62
- "Content-Type": "application/json",
63
- Authorization: `Basic ${Buffer.from(`${cfg.jiraEmail}:${cfg.jiraApiToken}`).toString("base64")}`,
64
- },
65
- body: JSON.stringify(jiraPayload),
66
- });
67
-
68
- if (!response.ok) {
69
- const body = await response.text();
70
- throw new Error(`JIRA returned ${response.status}: ${body}`);
71
- }
72
-
73
- const data = await response.json();
74
- planKey = data.key;
53
+ const issue = await createIssue(cfg, "Test Plan", fields);
54
+ planKey = issue.key;
75
55
  } catch (err) {
76
56
  // Fallback: try via Xray GraphQL createTestPlan mutation
77
57
  logger.debug(`JIRA REST failed, trying Xray GraphQL: ${err.message}`);
78
58
  try {
79
59
  const result = await createTestPlan(cfg, xrayToken, {
80
- jira: {
81
- summary,
82
- project: { key: cfg.jiraProjectKey },
83
- },
60
+ projectId: cfg.jiraProjectKey,
61
+ summary,
62
+ description: summary,
84
63
  });
85
- planKey = result?.createTestPlan?.testPlan?.jira?.key;
64
+
65
+ // GraphQL returns { testPlan: { issueId, projectId } } — resolve key via JIRA
66
+ const issueId = result?.testPlan?.issueId;
67
+ if (issueId) {
68
+ try {
69
+ const issue = await getIssue(cfg, issueId);
70
+ planKey = issue?.key;
71
+ } catch {
72
+ logger.debug(`Could not resolve JIRA key for issueId ${issueId}`);
73
+ }
74
+ }
86
75
  } catch (gqlErr) {
87
76
  logger.error(`Could not create Test Plan: ${gqlErr.message}`);
88
77
  process.exit(1);
@@ -88,6 +88,7 @@ export default async function importResults(opts = {}) {
88
88
  ...(!execKey && planKey && { testPlanKey: planKey }),
89
89
  ...(opts.version && { version: opts.version }),
90
90
  ...(opts.revision && { revision: opts.revision }),
91
+ ...(opts.fixVersion && { fixVersion: opts.fixVersion }),
91
92
  };
92
93
 
93
94
  let result;
@@ -101,6 +102,7 @@ export default async function importResults(opts = {}) {
101
102
 
102
103
  xrayJson = convertPlaywrightToXray(playwrightJson, {
103
104
  projectKey: cfg.jiraProjectKey,
105
+ statusMapping: cfg.statusMapping || undefined,
104
106
  infoOverrides: infoBase,
105
107
  });
106
108
 
package/commands/init.js CHANGED
@@ -200,6 +200,8 @@ export default async function init(opts = {}) {
200
200
  environments: ["IOP-DEV", "IOP-QA", "IOP-PROD"],
201
201
  folderRoot: `/${serviceName}`,
202
202
  xrayRegion: "us",
203
+ bulkImportThreshold: 50,
204
+ // statusMapping: { interrupted: "ABORTED", skipped: "TODO" }
203
205
  };
204
206
  fs.writeFileSync(xrayrcPath, JSON.stringify(xrayrc, null, 2));
205
207
  logger.success(".xrayrc created — Project config");
@@ -8,8 +8,10 @@
8
8
  * 1. Load & validate config + tests.json
9
9
  * 2. Authenticate with Xray (once)
10
10
  * 3. Build & push tests (create/update via GraphQL)
11
- * 4. Sync Test Plan membership (add new keys, remove old)
12
- * 5. Sync Xray folder structure from test `folder` fields
11
+ * 4. Sync Test Sets create sets by testSet name, add tests (mapping._testSets)
12
+ * 5. Sync Test Plan membership (add new keys)
13
+ * 6. Sync Xray folder structure from test `folder` fields
14
+ * 7. Optionally create a Test Execution linked to the plan
13
15
  * 6. Save mapping
14
16
  */
15
17
 
@@ -17,11 +19,13 @@ import fs from "node:fs";
17
19
  import { createRequire } from "node:module";
18
20
  import logger, { setVerbose } from "../lib/logger.js";
19
21
  import { loadConfig, validateConfig } from "../lib/config.js";
20
- import { authenticate, getIssue } from "../lib/xrayClient.js";
22
+ import { readJsonFile } from "../lib/jsonFile.js";
23
+ import { authenticate, getIssue, createTestExecution } from "../lib/xrayClient.js";
21
24
  import {
22
25
  buildAndPush,
23
26
  loadMapping,
24
27
  saveMapping,
28
+ syncTestSets,
25
29
  syncTestPlan,
26
30
  syncFolders,
27
31
  } from "../lib/testCaseBuilder.js";
@@ -41,7 +45,7 @@ export default async function pushTests(opts = {}) {
41
45
  process.exit(1);
42
46
  }
43
47
 
44
- const testsConfig = JSON.parse(fs.readFileSync(cfg.testsPath, "utf8"));
48
+ const testsConfig = readJsonFile(cfg.testsPath);
45
49
  const allTests = testsConfig.tests || [];
46
50
  const tests = allTests.filter((t) => !t.skip);
47
51
  const skippedCount = allTests.length - tests.length;
@@ -58,9 +62,7 @@ export default async function pushTests(opts = {}) {
58
62
  try {
59
63
  const require = createRequire(import.meta.url);
60
64
  const Ajv = require("ajv");
61
- const schema = JSON.parse(
62
- fs.readFileSync(new URL("../schema/tests.schema.json", import.meta.url), "utf8")
63
- );
65
+ const schema = readJsonFile(new URL("../schema/tests.schema.json", import.meta.url));
64
66
  const ajv = new Ajv({ allErrors: true });
65
67
  const validate = ajv.compile(schema);
66
68
  if (!validate(testsConfig)) {
@@ -99,6 +101,31 @@ export default async function pushTests(opts = {}) {
99
101
  saveMapping(cfg.mappingPath, mapping);
100
102
  logger.save(`Mapping saved to ${cfg.mappingPath}`);
101
103
 
104
+ // ── Sync Test Sets ──────────────────────────────────────────────────
105
+ const testsWithSets = tests.filter((t) => t.testSet && (Array.isArray(t.testSet) ? t.testSet.length > 0 : true));
106
+
107
+ if (testsWithSets.length > 0) {
108
+ logger.blank();
109
+ const uniqueSets = [...new Set(testsWithSets.flatMap((t) => Array.isArray(t.testSet) ? t.testSet : [t.testSet]))];
110
+ logger.send(`Syncing ${uniqueSets.length} Test Set(s) (${testsWithSets.length} test(s))...`);
111
+ try {
112
+ const setResult = await syncTestSets(cfg, xrayToken, tests, mapping, cfg.mappingPath);
113
+ logger.success(
114
+ `Test Sets: ${setResult.created} created, ${setResult.synced} test(s) assigned` +
115
+ (setResult.failed.length > 0 ? `, ${setResult.failed.length} failed` : "")
116
+ );
117
+ if (setResult.failed.length > 0) {
118
+ for (const name of setResult.failed) {
119
+ logger.warn(` FAILED: Test Set "${name}"`);
120
+ }
121
+ }
122
+ } catch (err) {
123
+ logger.warn(`Test Set sync failed: ${err.message}`);
124
+ }
125
+ } else {
126
+ logger.debug("No testSet fields — skipping Test Set sync");
127
+ }
128
+
102
129
  // ── Sync Test Plan ─────────────────────────────────────────────────────────
103
130
  const planKey = opts.plan || cfg.testPlanKey || testsConfig.testPlan?.key;
104
131
 
@@ -151,6 +178,37 @@ export default async function pushTests(opts = {}) {
151
178
  logger.debug("No folder fields — skipping folder sync");
152
179
  }
153
180
 
181
+ // ── Create Test Execution (optional) ───────────────────────────────────────
182
+ if (opts.createExecution && planKey) {
183
+ logger.blank();
184
+ logger.send("Creating Test Execution linked to Test Plan...");
185
+ try {
186
+ const environment = opts.executionEnv || cfg.defaultEnvironment || "IOP-DEV";
187
+ const testIssueIds = Object.values(mapping)
188
+ .filter((v) => v.id && !String(v.id).startsWith("_"))
189
+ .map((v) => String(v.id));
190
+
191
+ const execSummary =
192
+ opts.executionSummary ||
193
+ `${environment} Test Run — ${new Date().toLocaleString()}`;
194
+
195
+ const { key: execKey } = await createTestExecution(cfg, xrayToken, {
196
+ summary: execSummary,
197
+ environments: [environment],
198
+ testIssueIds,
199
+ testPlanKey: planKey,
200
+ });
201
+
202
+ logger.success(`Test Execution created: ${execKey}`);
203
+ logger.link(`${cfg.jiraUrl}/browse/${execKey}`);
204
+ } catch (err) {
205
+ logger.warn(`Test Execution creation failed: ${err.message}`);
206
+ }
207
+ } else if (opts.createExecution && !planKey) {
208
+ logger.blank();
209
+ logger.warn("--create-execution requires a Test Plan key (--plan or .xrayrc testPlanKey)");
210
+ }
211
+
154
212
  logger.blank();
155
213
  logger.success("push-tests complete");
156
214
  logger.blank();
package/lib/config.js CHANGED
@@ -97,6 +97,13 @@ export function loadConfig(opts = {}) {
97
97
  environments: rcConfig.environments || defaultEnvironments,
98
98
  folderRoot: rcConfig.folderRoot || null,
99
99
 
100
+ // Playwright status mapping (overrides defaults per-project)
101
+ // e.g. { "interrupted": "ABORTED", "skipped": "TODO" }
102
+ statusMapping: rcConfig.statusMapping || null,
103
+
104
+ // Bulk import threshold — use Xray REST bulk API when creating this many new tests
105
+ bulkImportThreshold: Number(process.env.XQT_BULK_THRESHOLD ?? rcConfig.bulkImportThreshold ?? 50),
106
+
100
107
  // Paths (resolved relative to consuming project)
101
108
  testsPath: path.resolve(cwd, rcConfig.testsPath || "tests.json"),
102
109
  mappingPath: path.resolve(cwd, rcConfig.mappingPath || "xray-mapping.json"),
@@ -0,0 +1,12 @@
1
+ import fs from "node:fs";
2
+
3
+ /**
4
+ * Parse a JSON file and tolerate UTF-8 BOM at file start.
5
+ * @param {string | URL} filePath
6
+ * @returns {any}
7
+ */
8
+ export function readJsonFile(filePath) {
9
+ const raw = fs.readFileSync(filePath, "utf8");
10
+ const content = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
11
+ return JSON.parse(content);
12
+ }
@@ -20,8 +20,10 @@ import path from "node:path";
20
20
  * @param {object} playwrightJson - Playwright JSON reporter output
21
21
  * @param {object} options - Conversion options
22
22
  * @param {string} [options.projectKey] - JIRA project key (for new test creation)
23
+ * @param {object} [options.statusMapping] - Override default Playwright→Xray status map
24
+ * e.g. { interrupted: "ABORTED", skipped: "TODO" }
23
25
  * @param {object} [options.infoOverrides] - Merged into the Xray `info` object:
24
- * testPlanKey, testEnvironments, version, revision, summary, description
26
+ * testPlanKey, testEnvironments, version, revision, fixVersion, summary, description
25
27
  * @returns {object} Xray JSON format ready for import
26
28
  */
27
29
  export function convertPlaywrightToXray(playwrightJson, options = {}) {
@@ -110,7 +112,7 @@ function convertTest(test, spec, suite, options) {
110
112
  const result = test.results[test.results.length - 1];
111
113
 
112
114
  const testKey = extractTestKey(spec.title, test.annotations);
113
- const status = mapStatus(result.status);
115
+ const status = mapStatus(result.status, options.statusMapping);
114
116
 
115
117
  const xrayTest = {
116
118
  status,
@@ -145,14 +147,16 @@ function convertTest(test, spec, suite, options) {
145
147
  }
146
148
 
147
149
  // ── Map Playwright steps to Xray step results ─────────────────────────────
148
- if (result.steps && result.steps.length > 0) {
149
- xrayTest.steps = result.steps
150
- .filter((s) => s.category === "test.step" || s.category === "hook")
151
- .map((s) => ({
152
- status: s.error ? "FAILED" : "PASSED",
153
- comment: s.title || s.category,
154
- ...(s.duration && { duration: Math.round(s.duration) }),
155
- }));
150
+ const relevantSteps = result.steps
151
+ ? result.steps.filter((s) => s.category === "test.step" || s.category === "hook")
152
+ : [];
153
+
154
+ if (relevantSteps.length > 0) {
155
+ xrayTest.steps = relevantSteps.map((s) => ({
156
+ status: s.error ? "FAILED" : "PASSED",
157
+ comment: s.title || s.category,
158
+ ...(s.duration && { duration: Math.round(s.duration) }),
159
+ }));
156
160
  }
157
161
 
158
162
  // ── Error details ──────────────────────────────────────────────────────────
@@ -162,35 +166,34 @@ function convertTest(test, spec, suite, options) {
162
166
  xrayTest.comment += `\n\nError: ${msg}${stack}`;
163
167
  }
164
168
 
165
- // ── Attachments / Evidence ────────────────────────────────────────────────
169
+ // ── Attachments / Evidence (with per-step attachment for screenshots) ──────
166
170
  if (result.attachments && result.attachments.length > 0) {
167
- xrayTest.evidence = [];
168
- for (const attachment of result.attachments) {
169
- let data = null;
170
-
171
- if (attachment.body) {
172
- // In-memory body (Buffer from Playwright)
173
- data = Buffer.isBuffer(attachment.body)
174
- ? attachment.body.toString("base64")
175
- : Buffer.from(attachment.body).toString("base64");
176
- } else if (attachment.path && fs.existsSync(attachment.path)) {
177
- // File on disk
178
- data = fs.readFileSync(attachment.path).toString("base64");
179
- }
171
+ const screenshots = result.attachments.filter(
172
+ (a) => a.contentType?.startsWith("image/") || a.name?.match(/screenshot/i)
173
+ );
174
+ const traces = result.attachments.filter((a) => a.name?.match(/trace/i));
175
+ const other = result.attachments.filter(
176
+ (a) => !screenshots.includes(a) && !traces.includes(a)
177
+ );
180
178
 
181
- if (data) {
182
- xrayTest.evidence.push({
183
- filename: attachment.name || path.basename(attachment.path || "attachment"),
184
- contentType: attachment.contentType || "application/octet-stream",
185
- data,
186
- });
187
- }
179
+ // Find the last failed step index for per-step evidence attachment
180
+ const lastFailedStepIdx =
181
+ relevantSteps.reduce((acc, s, i) => (s.error ? i : acc), -1);
182
+
183
+ // Attach screenshots to the last failed step (per-step evidence)
184
+ if (xrayTest.steps && lastFailedStepIdx >= 0 && screenshots.length > 0) {
185
+ xrayTest.steps[lastFailedStepIdx].evidences = screenshots
186
+ .map((a) => buildEvidenceItem(a))
187
+ .filter(Boolean);
188
188
  }
189
189
 
190
- if (xrayTest.evidence.length > 0) {
191
- xrayTest.comment += `\n\nAttachments: ${result.attachments.map((a) => a.name).join(", ")}`;
192
- } else {
193
- delete xrayTest.evidence;
190
+ // Everything else (traces, other) stays at test level
191
+ const testLevelAttachments = [...(lastFailedStepIdx >= 0 ? [] : screenshots), ...traces, ...other];
192
+ const testLevelEvidence = testLevelAttachments.map((a) => buildEvidenceItem(a)).filter(Boolean);
193
+
194
+ if (testLevelEvidence.length > 0) {
195
+ xrayTest.evidence = testLevelEvidence;
196
+ xrayTest.comment += `\n\nAttachments: ${testLevelAttachments.map((a) => a.name).join(", ")}`;
194
197
  }
195
198
  }
196
199
 
@@ -209,16 +212,36 @@ function extractTestKey(title, annotations) {
209
212
  return match ? match[1] : null;
210
213
  }
211
214
 
212
- function mapStatus(playwrightStatus) {
213
- return (
214
- {
215
- passed: "PASSED",
216
- failed: "FAILED",
217
- timedOut: "FAILED",
218
- interrupted: "ABORTED",
219
- skipped: "TODO",
220
- }[playwrightStatus] || "TODO"
221
- );
215
+ function mapStatus(playwrightStatus, customMapping) {
216
+ const defaults = {
217
+ passed: "PASSED",
218
+ failed: "FAILED",
219
+ timedOut: "FAILED",
220
+ interrupted: "ABORTED",
221
+ skipped: "TODO",
222
+ };
223
+ const map = customMapping ? { ...defaults, ...customMapping } : defaults;
224
+ return map[playwrightStatus] || "TODO";
225
+ }
226
+
227
+ function buildEvidenceItem(attachment) {
228
+ let data = null;
229
+
230
+ if (attachment.body) {
231
+ data = Buffer.isBuffer(attachment.body)
232
+ ? attachment.body.toString("base64")
233
+ : Buffer.from(attachment.body).toString("base64");
234
+ } else if (attachment.path && fs.existsSync(attachment.path)) {
235
+ data = fs.readFileSync(attachment.path).toString("base64");
236
+ }
237
+
238
+ if (!data) return null;
239
+
240
+ return {
241
+ filename: attachment.name || path.basename(attachment.path || "attachment"),
242
+ contentType: attachment.contentType || "application/octet-stream",
243
+ data,
244
+ };
222
245
  }
223
246
 
224
247
  function buildComment(result, spec, suite) {