@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 CHANGED
@@ -194,12 +194,14 @@ Optionally create a Test Execution linked to the plan in a single command.
194
194
  ```bash
195
195
  npx xqt push
196
196
  npx xqt push --plan APIEE-1234
197
+ npx xqt push --bulk # force bulk import regardless of count
197
198
  npx xqt push --plan APIEE-1234 --create-execution --execution-env IOP-QA
198
199
  ```
199
200
 
200
201
  | Flag | Description |
201
202
  |---|---|
202
203
  | `--plan <key>` | Test Plan key (overrides `.xrayrc testPlanKey`) |
204
+ | `--bulk` | Force bulk Xray REST import regardless of new-test count (useful when count is below `bulkImportThreshold`) |
203
205
  | `--create-execution` | Create a Test Execution linked to the Test Plan after pushing |
204
206
  | `--execution-env <label>` | Environment label for the created execution (e.g. `IOP-QA`) |
205
207
  | `--execution-summary <text>` | Custom summary for the created execution |
@@ -255,6 +257,7 @@ npx xqt exec --env IOP-QA --plan APIEE-1234 --tests TC-001,TC-002
255
257
  | `--plan <key>` | Test Plan to link to (overrides `.xrayrc`) |
256
258
  | `--tests <ids>` | Comma-separated testIds or JIRA keys (default: all mapped) |
257
259
  | `--summary <text>` | Custom execution title |
260
+ | `--fix-version <ver>` | JIRA Fix Version to stamp on the execution (e.g. `2.5.0`) |
258
261
  | `--quiet` | Print only the execution key |
259
262
 
260
263
  > `xqt import` creates an execution automatically — this command is only needed when pre-selecting a specific subset of tests.
@@ -281,12 +284,12 @@ npx xqt import --file test-results/results.json --exec APIEE-9876
281
284
  # JUnit XML
282
285
  npx xqt import --file test-results/results.xml --env IOP-PROD
283
286
 
284
- # Full options
287
+ # Full options — with Fix Version and git SHA
285
288
  npx xqt import \
286
289
  --file test-results/results.json \
287
290
  --env IOP-QA \
288
291
  --plan APIEE-1234 \
289
- --version "2024.12" \
292
+ --fix-version "2.5.0" \
290
293
  --revision "a1b2c3d4"
291
294
  ```
292
295
 
@@ -296,7 +299,8 @@ npx xqt import \
296
299
  | `--env <label>` | Environment label (default: `defaultEnvironment` from `.xrayrc`) |
297
300
  | `--plan <key>` | Test Plan key (overrides `.xrayrc`; used when no `--exec`) |
298
301
  | `--exec <key>` | Import INTO an existing execution (from `xqt exec`) |
299
- | `--version <ver>` | Fix version / release label |
302
+ | `--fix-version <ver>` | JIRA Fix Version name to stamp on the execution (e.g. `2.5.0`) |
303
+ | `--version <ver>` | Xray version label (free-form, separate from Fix Version) |
300
304
  | `--revision <rev>` | Build number or git SHA |
301
305
  | `--summary <text>` | Custom execution summary |
302
306
 
@@ -394,23 +398,53 @@ JIRA_EMAIL=your.email@company.com
394
398
 
395
399
  ```json
396
400
  {
397
- "testsPath": "tests.json",
398
- "mappingPath": "xray-mapping.json",
399
- "testPlanKey": "APIEE-1234",
400
- "defaultEnvironment": "IOP-DEV",
401
- "environments": ["IOP-DEV", "IOP-QA", "IOP-PROD"],
402
- "folderRoot": "/MyService",
403
- "xrayRegion": "us"
401
+ "testsPath": "tests.json",
402
+ "mappingPath": "xray-mapping.json",
403
+ "testPlanKey": "APIEE-1234",
404
+ "defaultEnvironment": "IOP-DEV",
405
+ "environments": ["IOP-DEV", "IOP-QA", "IOP-PROD"],
406
+ "folderRoot": "/MyService",
407
+ "xrayRegion": "us",
408
+ "bulkImportThreshold": 50,
409
+ "statusMapping": {
410
+ "interrupted": "ABORTED",
411
+ "skipped": "TODO"
412
+ }
404
413
  }
405
414
  ```
406
415
 
407
416
  | Field | Description |
408
417
  |---|---|
409
- | `testPlanKey` | Default Test Plan key for push-tests and import-results |
418
+ | `testPlanKey` | Default Test Plan key for `push` and `import` |
410
419
  | `defaultEnvironment` | Fallback environment when `--env` is not provided |
411
420
  | `environments` | Allowed environment labels |
412
421
  | `folderRoot` | Base folder path in Xray Test Repository |
413
- | `xrayRegion` | `us` (default) or `eu` |
422
+ | `xrayRegion` | `us` (default), `eu`, or `au` |
423
+ | `bulkImportThreshold` | Min new-test count to use the Xray bulk REST API (default: `50`) — faster for large suites |
424
+ | `statusMapping` | Override default Playwright → Xray status mapping (see below) |
425
+
426
+ #### Playwright status mapping
427
+
428
+ By default the converter maps:
429
+
430
+ | Playwright status | Xray status |
431
+ |---|---|
432
+ | `passed` | `PASSED` |
433
+ | `failed` | `FAILED` |
434
+ | `timedOut` | `FAILED` |
435
+ | `interrupted` | `ABORTED` |
436
+ | `skipped` | `TODO` |
437
+
438
+ Override any value in `.xrayrc`:
439
+
440
+ ```json
441
+ {
442
+ "statusMapping": {
443
+ "skipped": "TODO",
444
+ "interrupted": "FAILED"
445
+ }
446
+ }
447
+ ```
414
448
 
415
449
  ---
416
450
 
@@ -432,6 +466,7 @@ JIRA_EMAIL=your.email@company.com
432
466
  "tags": ["smoke", "regression"],
433
467
  "folder": "/MyService/HealthCheck/Validation",
434
468
  "testSet": "Health Check",
469
+ "preconditions": ["APIEE-50"],
435
470
  "requirementKeys": [],
436
471
  "xray": {
437
472
  "summary": "Service returns 200 OK when healthy",
@@ -455,7 +490,8 @@ JIRA_EMAIL=your.email@company.com
455
490
  | `skip` | boolean | — | `true` to exclude from `push-tests` |
456
491
  | `tags` | string[] | — | QE tags for categorisation and filtering. Allowed: `regression`, `smoke`, `edge`, `critical`, `integration`, `e2e`, `security`, `performance`, `contract`, `functional`, `negative`, `positive`, `boundary`, `acceptance`, `sanity`, `data-driven`, `exploratory`, `accessibility` |
457
492
  | `folder` | string | — | Xray repository folder path — must start with `/` |
458
- | `testSet` | string | — | Test Set name in Jira — tests are grouped into Test Sets by this value |
493
+ | `testSet` | string **or** string[] | — | Test Set name(s) in Jira — a single string or an array to assign the test to multiple Test Sets |
494
+ | `preconditions` | string[] | — | JIRA keys of Xray Precondition issues to link to this test (e.g. `["APIEE-50"]`) |
459
495
  | `requirementKeys` | string[] | — | JIRA keys this test covers (creates traceability links) |
460
496
  | `xray.summary` | string | Yes | Test case title (JIRA issue summary) |
461
497
  | `xray.description` | string | — | Detailed description |
@@ -472,17 +508,17 @@ JIRA_EMAIL=your.email@company.com
472
508
 
473
509
  Tests live in **Test Sets** in Jira. Each Test Set groups related tests by feature or area — they persist across every sprint.
474
510
 
475
- Set the `testSet` field on each test in `tests.json`:
511
+ Set the `testSet` field on each test in `tests.json` — use a string or an array of strings to assign the test to multiple sets:
476
512
 
477
513
  ```json
478
514
  { "test_id": "TC-001", "testSet": "Client Lookup", ... }
479
515
  { "test_id": "TC-002", "testSet": "Client Lookup", ... }
480
- { "test_id": "TC-003", "testSet": "Health Check", ... }
516
+ { "test_id": "TC-003", "testSet": ["Health Check", "Smoke"], ... }
481
517
  ```
482
518
 
483
519
  When you run `xqt push`:
484
520
  1. Tests are created/updated in Xray
485
- 2. For each unique `testSet` value, the Test Set JIRA issue is **created automatically** if it doesn't exist yet
521
+ 2. For each unique `testSet` value, the Test Set JIRA issue is **created automatically** if it doesn't exist yet (safe to run on a fresh clone — existing sets are found by name, not duplicated)
486
522
  3. Tests are added to their Test Set (idempotent — Xray ignores tests already in the set)
487
523
  4. Test Set → JIRA key mappings (e.g. `"Client Lookup" → APIEE-99`) are stored in `xray-mapping.json` under `_testSets`
488
524
 
@@ -509,6 +545,8 @@ A Test Plan scopes testing work for a specific sprint or release.
509
545
  - Key stored in `.xrayrc` (`testPlanKey`)
510
546
  - `xqt import` links executions to the active plan
511
547
 
548
+ `xqt push` performs a **bi-directional sync** with the Test Plan: new tests are added **and** tests removed from `tests.json` are removed from the plan. Use `--plan <key>` to override the key.
549
+
512
550
  ### Test Executions (ephemeral per run)
513
551
 
514
552
  A Test Execution represents a single CI run.
@@ -582,7 +620,7 @@ reporter: [
582
620
 
583
621
  ### Test steps → Xray step results
584
622
 
585
- Steps created with `test.step()` are mapped to Xray step results automatically:
623
+ Steps created with `test.step()` are mapped to Xray step results automatically. Screenshots captured on a **failing step** are attached directly to that step's evidence in Xray — giving per-step failure screenshots in the Xray UI. Traces and other attachments are attached at the test level.
586
624
 
587
625
  ```typescript
588
626
  test('Create and retrieve user', async ({ request }) => {
@@ -636,7 +674,9 @@ Generate a template: `npx xqt pipeline`
636
674
  - script: |
637
675
  npx xqt import \
638
676
  --file test-results/results.json \
639
- --env $(XQT_ENV)
677
+ --env $(XQT_ENV) \
678
+ --fix-version $(BUILD_BUILDNUMBER) \
679
+ --revision $(Build.SourceVersion)
640
680
  displayName: Import results to Xray
641
681
  env:
642
682
  XRAY_ID: $(XRAY_ID)
@@ -658,6 +698,7 @@ Generate a template: `npx xqt pipeline`
658
698
  | `JIRA_API_TOKEN` | JIRA API token |
659
699
  | `JIRA_EMAIL` | JIRA user email |
660
700
  | `XQT_ENV` | Environment label (`IOP-DEV` / `IOP-QA` / `IOP-PROD`) |
701
+ | `XQT_BULK_THRESHOLD` | Override bulk-import threshold (default: `50`) |
661
702
 
662
703
  ### Pre-create execution in pipeline (Mode B)
663
704
 
@@ -701,7 +742,7 @@ const { key } = await createTestExecution(cfg, token, {
701
742
  });
702
743
  ```
703
744
 
704
- Key exports: `authenticate`, `createIssue`, `updateIssue`, `getIssue`, `getTest`, `getTests`, `getTestPlan`, `createTestPlan`, `addTestsToTestPlan`, `addTestsToTestExecution`, `createTestExecution`, `importResultsMultipart`, `importTestsBulk`, `syncTestPlan`, `syncFolders`, `buildAndPush`, `loadMapping`, `saveMapping`, `loadConfig`, `validateConfig`.
745
+ Key exports: `authenticate`, `createIssue`, `updateIssue`, `getIssue`, `getTest`, `getTests`, `getTestPlan`, `createTestPlan`, `addTestsToTestPlan`, `removeTestsFromTestPlan`, `addTestsToTestExecution`, `createTestExecution`, `importResultsMultipart`, `importTestsBulk`, `waitForImportJob`, `searchIssues`, `getTestSets`, `addPreconditionsToTest`, `syncTestPlan`, `syncFolders`, `buildAndPush`, `loadMapping`, `saveMapping`, `loadConfig`, `validateConfig`.
705
746
 
706
747
  ---
707
748
 
@@ -752,7 +793,22 @@ test.info().annotations.push({ type: 'xray', description: 'APIEE-1234' });
752
793
  **Validate fails in CI**
753
794
  Fix schema errors before pushing. Common causes: missing `testId`, invalid `priority`, malformed `folder` path (must start with `/`).
754
795
 
755
- **Old command names still work**
796
+ **`xqt push` Test Repository folder errors**
797
+ ```
798
+ GraphQL errors: User doesn't have permissions to view/edit test repository for project
799
+ ```
800
+ The JIRA user in `JIRA_EMAIL` needs **Test Repository** write access in Xray. Fix in Jira:
801
+ > **Project Settings → Xray → Test Repository** → add your role or user to the allowed list.
802
+ Folder sync errors are non-fatal — tests are still created/updated correctly.
803
+
804
+ **Test Set creation returns HTTP 400**
805
+ Ensure the Xray **Test Set** issue type exists in your JIRA project and that the user has permission to create it.
806
+ > **Project Settings → Issue Types** — "Test Set" must be present.
807
+
808
+ **`updateTestType` "not found" on new tests**
809
+ Xray Cloud is eventually consistent — a newly created issue may not be immediately visible to the GraphQL API. `xqt push` automatically retries with backoff. If failures persist, rerun `xqt push` (existing tests update, not duplicate).
810
+
811
+
756
812
  All previous multi-word commands (`push-tests`, `pull-tests`, `create-plan`, `create-execution`, `import-results`, `sync-folders`, `gen-pipeline`, `mcp-server`) remain valid as aliases.
757
813
 
758
814
  **EU region**
package/bin/cli.js CHANGED
@@ -67,6 +67,7 @@ program
67
67
  "Create/update tests in Xray Cloud, sync to Test Plan, sync folder structure"
68
68
  )
69
69
  .option("--plan <key>", "Test Plan key (overrides .xrayrc testPlanKey)")
70
+ .option("--bulk", "Force bulk Xray REST import regardless of new-test count")
70
71
  .option("--create-execution", "Create a Test Execution linked to the Test Plan after pushing")
71
72
  .option("--execution-env <label>", "Environment label for created execution (e.g. IOP-QA)")
72
73
  .option("--execution-summary <text>", "Custom summary for created execution")
@@ -100,6 +101,7 @@ program
100
101
  .option("--tests <ids>", "Comma-separated testIds or JIRA keys to include (default: all mapped tests)")
101
102
  .option("--summary <text>", "Custom Test Execution summary")
102
103
  .option("--description <text>", "Custom description")
104
+ .option("--fix-version <version>", "JIRA Fix Version to stamp on the execution (e.g. '2.5.0')")
103
105
  .option("--quiet", "Print ONLY the execution key (for CI variable capture)")
104
106
  .action(async (opts, cmd) => {
105
107
  const mod = await import("../commands/createExecution.js");
@@ -117,7 +119,8 @@ program
117
119
  .option("--plan <key>", "Test Plan key to associate execution with (overrides .xrayrc)")
118
120
  .option("--exec <key>", "Import INTO an existing Test Execution key (from xqt exec)")
119
121
  .option("--version <version>", "Fix version / release version")
120
- .option("--revision <revision>", "Revision / build number")
122
+ .option("--revision <revision>", "Revision / build number (e.g. git SHA)")
123
+ .option("--fix-version <version>", "JIRA Fix Version name to stamp on the Test Execution (e.g. '2.5.0')")
121
124
  .option("--summary <text>", "Custom Test Execution summary")
122
125
  .option("--description <text>", "Custom Test Execution description")
123
126
  .action(async (opts, cmd) => {
@@ -113,6 +113,7 @@ export default async function createExecution(opts = {}) {
113
113
  environments,
114
114
  testIssueIds,
115
115
  testPlanKey: planKey || undefined,
116
+ fixVersion: opts.fixVersion || undefined,
116
117
  });
117
118
 
118
119
  if (quiet) {
@@ -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");
@@ -19,6 +19,7 @@ import fs from "node:fs";
19
19
  import { createRequire } from "node:module";
20
20
  import logger, { setVerbose } from "../lib/logger.js";
21
21
  import { loadConfig, validateConfig } from "../lib/config.js";
22
+ import { readJsonFile } from "../lib/jsonFile.js";
22
23
  import { authenticate, getIssue, createTestExecution } from "../lib/xrayClient.js";
23
24
  import {
24
25
  buildAndPush,
@@ -44,7 +45,7 @@ export default async function pushTests(opts = {}) {
44
45
  process.exit(1);
45
46
  }
46
47
 
47
- const testsConfig = JSON.parse(fs.readFileSync(cfg.testsPath, "utf8"));
48
+ const testsConfig = readJsonFile(cfg.testsPath);
48
49
  const allTests = testsConfig.tests || [];
49
50
  const tests = allTests.filter((t) => !t.skip);
50
51
  const skippedCount = allTests.length - tests.length;
@@ -61,9 +62,7 @@ export default async function pushTests(opts = {}) {
61
62
  try {
62
63
  const require = createRequire(import.meta.url);
63
64
  const Ajv = require("ajv");
64
- const schema = JSON.parse(
65
- fs.readFileSync(new URL("../schema/tests.schema.json", import.meta.url), "utf8")
66
- );
65
+ const schema = readJsonFile(new URL("../schema/tests.schema.json", import.meta.url));
67
66
  const ajv = new Ajv({ allErrors: true });
68
67
  const validate = ajv.compile(schema);
69
68
  if (!validate(testsConfig)) {
@@ -85,7 +84,7 @@ export default async function pushTests(opts = {}) {
85
84
 
86
85
  // ── Build & push tests ─────────────────────────────────────────────────────
87
86
  logger.send("Pushing tests to Xray...");
88
- const result = await buildAndPush(cfg, tests, mapping, xrayToken);
87
+ const result = await buildAndPush(cfg, tests, mapping, xrayToken, { forceBulk: !!opts.bulk });
89
88
 
90
89
  logger.blank();
91
90
  logger.success(
@@ -103,11 +102,11 @@ export default async function pushTests(opts = {}) {
103
102
  logger.save(`Mapping saved to ${cfg.mappingPath}`);
104
103
 
105
104
  // ── Sync Test Sets ──────────────────────────────────────────────────
106
- const testsWithSets = tests.filter((t) => t.testSet);
105
+ const testsWithSets = tests.filter((t) => t.testSet && (Array.isArray(t.testSet) ? t.testSet.length > 0 : true));
107
106
 
108
107
  if (testsWithSets.length > 0) {
109
108
  logger.blank();
110
- const uniqueSets = [...new Set(testsWithSets.map((t) => t.testSet))];
109
+ const uniqueSets = [...new Set(testsWithSets.flatMap((t) => Array.isArray(t.testSet) ? t.testSet : [t.testSet]))];
111
110
  logger.send(`Syncing ${uniqueSets.length} Test Set(s) (${testsWithSets.length} test(s))...`);
112
111
  try {
113
112
  const setResult = await syncTestSets(cfg, xrayToken, tests, mapping, cfg.mappingPath);
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) {