Repository: mochajs/mocha

Problem statement:
🚀 Feature: allow using test file's relative path in xunit reporter output
### Feature Request Checklist

- [x] I have read and agree to Mocha's [Code of Conduct](https://github.com/mochajs/mocha/blob/main/.github/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/mochajs/mocha/blob/main/.github/CONTRIBUTING.md)
- [x] I have searched for [related issues](https://github.com/mochajs/mocha/issues?q=is%3Aissue) and [issues with the `faq` label](https://github.com/mochajs/mocha/issues?utf8=%E2%9C%93&q=is%3Aissue%20label%3Afaq%20), but none matched my issue.
- [ ] I want to provide a PR to resolve this

### Overview

Today, the `xunit` reporter always fills the `file` attribute with the fully qualified path to the file.


```shell
> npx mocha --reporter=xunit
<testsuite name="Mocha Tests" tests="1" failures="0" errors="0" skipped="0" timestamp="Thu, 30 Jan 2025 00:11:09 GMT" time="0.001">
<testcase classname="add" name="should add two numbers" file="/private/tmp/test-mocha/test/add.test.js" time="0"/>
</testsuite>
```

In CircleCI, when a test fails, they provide a dedicated UI that reads junit-compatible `xml` files and shows failures in a user-friendly way:

![Image](https://github.com/user-attachments/assets/75317e18-b050-4dc3-8fa2-bd63f4e98abd)

However, because the fully qualified path is provided, I have to delete the prefix (`/home/circleci/project`) to paste it into my editor to pull up the file.

With my jest test suites and the junit reporter, I can easily just click the "copy" icon because it's the relative path:

![Image](https://github.com/user-attachments/assets/69652c3f-0651-4db1-a64d-0d682b80b17b)

### Suggested Solution

We should introduce a new `reporterOption` for this feature. Perhaps it's called `useRelativeFilePath` and defaults to `false` to retain backward compatibility.

We could alternatively use a mode like `filePathMode: 'relative' | 'absolute'`, defaulting to `absolute`.

### Alternatives

For now, I'm just using `sed` to edit the file after it's produced. But it would be nice to remove this additional step.

### Additional Info

_No response_


Hints:
👍 this strikes me as a very reasonable feature request. Absolute paths in CI output are irksome in a lot of contexts. The irritation of constantly having to remove the absolute prefix is definitely something I resonate with!

Accepting PRs as an opt-in reporter option. Thanks for filing!
Thanks, in the meantime this is what I'm doing, in case it helps others:

```yml
- run:
    name: Make JUnit XML report contain relative paths # Work around https://github.com/mochajs/mocha/issues/5289
    command: |
      sed -i "s|file=\"$(pwd)/|file=\"|" ./junit-reports/mocha.xml
```

Folks can adapt this pattern pretty easily to other CI platforms too 😄 

Patch:
diff --git a/lib/reporters/xunit.js b/lib/reporters/xunit.js
--- a/lib/reporters/xunit.js
+++ b/lib/reporters/xunit.js
@@ -104,7 +104,7 @@ function XUnit(runner, options) {
     );
 
     tests.forEach(function (t) {
-      self.test(t);
+      self.test(t, options);
     });
 
     self.write('</testsuite>');
@@ -152,13 +152,13 @@ XUnit.prototype.write = function (line) {
  *
  * @param {Test} test
  */
-XUnit.prototype.test = function (test) {
+XUnit.prototype.test = function (test, options) {
   Base.useColors = false;
 
   var attrs = {
     classname: test.parent.fullTitle(),
     name: test.title,
-    file: test.file,
+    file: testFilePath(test.file, options),
     time: test.duration / 1000 || 0
   };
 
@@ -215,4 +215,12 @@ function tag(name, attrs, close, content) {
   return tag;
 }
 
+function testFilePath(filepath, options) {
+  if (options && options.reporterOptions && options.reporterOptions.showRelativePaths) {
+    return path.relative(process.cwd(), filepath);
+  }
+
+  return filepath;
+}
+
 XUnit.description = 'XUnit-compatible XML output';


Test patch:
diff --git a/test/reporters/xunit.spec.js b/test/reporters/xunit.spec.js
--- a/test/reporters/xunit.spec.js
+++ b/test/reporters/xunit.spec.js
@@ -592,4 +592,69 @@ describe('XUnit reporter', function () {
       expect(lines[0], 'to contain', defaultSuiteName);
     });
   });
+
+  describe('showRelativePaths reporter option', function () {
+    const projectPath = path.join('home', 'username', 'demo-project');
+    const relativeTestPath = path.join('tests', 'demo-test.spec.js');
+    const absoluteTestPath = path.join(projectPath, relativeTestPath);
+
+    var expectedWrite = '';
+    const fakeThis = {
+      write: function (str) {
+        expectedWrite = expectedWrite + str;
+      }
+    };
+
+    const failingTest = {
+      state: STATE_FAILED,
+      title: expectedTitle,
+      file: absoluteTestPath,
+      parent: {
+        fullTitle: function () {
+          return expectedClassName;
+        }
+      },
+      duration: 1000,
+      err: {
+        actual: 'foo',
+        expected: 'bar',
+        message: expectedMessage,
+        stack: expectedStack
+      }
+    };
+
+    beforeEach(function () {
+      sinon.stub(process, 'cwd').returns(projectPath);
+    });
+
+    afterEach(function () {
+      sinon.restore();
+      expectedWrite = '';
+    });
+
+    it('shows relative paths for tests if showRelativePaths reporter option is set', function () {
+      const options = {
+        reporterOptions: {
+          showRelativePaths: true
+        }
+      };
+      const xunit = new XUnit(runner, options);
+
+      xunit.test.call(fakeThis, failingTest, options);
+
+      expect(expectedWrite, 'not to contain', absoluteTestPath);
+      expect(expectedWrite, 'to contain', relativeTestPath);
+    });
+
+    it('shows absolute paths for tests by default', function () {
+      const options = {};
+      const xunit = new XUnit(runner);
+
+      xunit.test.call(fakeThis, failingTest, options);
+
+      expect(expectedWrite, 'to contain', absoluteTestPath);
+      // Double quote included to ensure printed paths don't start with relative path. Example printed line: <testcase classname="suite" name="test" file="some/tesfile.js" time="0"/>
+      expect(expectedWrite, 'not to contain', `"${relativeTestPath}`);
+    });
+  });
 });