diff --git a/README.md b/README.md
index 4da7c67e2..795b954df 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,7 @@ Content
- [coverageColors](#coveragecolors)
- [outputConfig](#outputconfig)
- [runMode](#runmode)
+ - [trimSymlinks](#trimSymlinks)
- [autoRun](#autorun)
- [testExplorer](#testexplorer)
- [shell](#shell)
@@ -288,6 +289,7 @@ useDashedArgs| Determine if to use dashed arguments for jest processes |undefine
|**UX**|
|[outputConfig](#outputconfig) 💼|Controls test output experience across the whole workspace.|undefined|`"jest.outputConfig": "neutral"` or `"jest.outputConfig": {"revealOn": "run", "revealWithFocus": "terminal", "clearOnRun": 'terminal"`| >= v6.1.0
|[runMode](#runmode)|Controls most test UX, including when tests should be run, output management, etc|undefined|`"jest.runMode": "watch"` or `"jest.runMode": "on-demand"` or `"jest.runMode": {"type": "on-demand", "deferred": true}`| >= v6.1.0
+|[trimSymlinks](#trimSymlinks)|Trims relative path walking-up a symbolic link in Test Explorer.|`false`|`"jest.trimSymlinks": true`| `T.B.D` - Fill near release
|:x: autoClearTerminal|Clear the terminal output at the start of any new test run.|false|`"jest.autoClearTerminal": true`| v6.0.0 (replaced by outputConfig)
|:x: [testExplorer](#testexplorer) |Configure jest test explorer|null|`{"showInlineError": "true"}`| < 6.1.0 (replaced by runMode)
|:x: [autoRun](#autorun)|Controls when and what tests should be run|undefined|`"jest.autoRun": "off"` or `"jest.autoRun": "watch"` or `"jest.autoRun": {"watch": false, "onSave":"test-only"}`| < v6.1.0 (replaced by runMode)
@@ -585,6 +587,16 @@ While the concepts of performance and automation are generally clear, "completen
>
> Starting from v6.1.0, if no runMode is defined in settings.json, the extension will automatically generate one using legacy settings (`autoRun`, `showCoverageOnLoad`). To migrate, simply use the `"Jest: Save Current RunMode"` command from the command palette to update the setting, then remove the deprecated settings.
+---
+
+#### trimSymlinks
+When enabled, this setting resolves symbolic links in the workspace path to avoid showing unnecessary parent folders in the Test Explorer.
+Useful if your workspace or any of its ancestor directories is a symlink—without it, test files may appear under awkward relative paths (e.g. starting with ../)
+due to symlink resolution behavior.
+
+Default: `false`
+
+
---
#### autoRun
diff --git a/package.json b/package.json
index cf6c6adae..318e91d49 100644
--- a/package.json
+++ b/package.json
@@ -372,6 +372,12 @@
"type": "boolean",
"default": null,
"scope": "resource"
+ },
+ "jest.trimSymlinks": {
+ "markdownDescription": "Enable to show test relative to workspace in case of symlinked workspace (or any directory above it). Use if your tests look like in [this image](https://private-user-images.githubusercontent.com/84509513/438940965-b7532345-0332-4265-a108-cac1703de39f.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDU5NTQ4MzcsIm5iZiI6MTc0NTk1NDUzNywicGF0aCI6Ii84NDUwOTUxMy80Mzg5NDA5NjUtYjc1MzIzNDUtMDMzMi00MjY1LWExMDgtY2FjMTcwM2RlMzlmLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA0MjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwNDI5VDE5MjIxN1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTcwZjEzMWIzZmFkOTBiODllMTU3NjYzMzBhOWIwMGI3MWMwMjI4YzBiYjhlZTA2NzYzOTM4N2NmNGMzMDkxNjUmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.bWHlErbUnTuXEP2_LPGUbJu509UdNl22Ee73q2f1FXU)",
+ "type": "boolean",
+ "default": false,
+ "scope": "resource"
}
}
},
diff --git a/src/JestExt/helper.ts b/src/JestExt/helper.ts
index 5c12a0c3d..452fb25bb 100644
--- a/src/JestExt/helper.ts
+++ b/src/JestExt/helper.ts
@@ -111,6 +111,7 @@ export const getExtensionResourceSettings = (
enable: getSetting('enable'),
useDashedArgs: getSetting('useDashedArgs') ?? false,
useJest30: getSetting('useJest30'),
+ trimSymlinks: getSetting('trimSymlinks'),
};
};
diff --git a/src/Settings/types.ts b/src/Settings/types.ts
index 119e2d5f2..28789efbc 100644
--- a/src/Settings/types.ts
+++ b/src/Settings/types.ts
@@ -78,6 +78,7 @@ export interface PluginResourceSettings {
parserPluginOptions?: JESParserPluginOptions;
useDashedArgs?: boolean;
useJest30?: boolean;
+ trimSymlinks?: boolean;
}
export interface DeprecatedPluginResourceSettings {
diff --git a/src/test-provider/test-item-data.ts b/src/test-provider/test-item-data.ts
index c29bce0e5..a2b691972 100644
--- a/src/test-provider/test-item-data.ts
+++ b/src/test-provider/test-item-data.ts
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
+import { realpathSync } from 'fs';
import { extensionId } from '../appGlobals';
import { JestRunEvent, RunEventBase } from '../JestExt';
import { TestSuiteResult } from '../TestResults';
@@ -175,15 +176,24 @@ export class WorkspaceRoot extends TestItemDataBase {
}
createTestItem(): vscode.TestItem {
const workspaceFolder = this.context.ext.workspace;
+ const settings = this.context.ext.settings;
+ let uri = isVirtualWorkspaceFolder(workspaceFolder)
+ ? workspaceFolder.effectiveUri
+ : workspaceFolder.uri;
+ if (settings.trimSymlinks) {
+ // In case the workspace root (or one of its ancestors) is a symlink, the relative path is going to resolve
+ // up-levels (../) until the first common ancestor with the link, resulting in many nodes we can hide from the user.
+ // In order to hide them, we get the workspaceRoot's "realpath" and use it instead.
+ uri = vscode.Uri.file(realpathSync(uri!.fsPath));
+ }
+
const item = this.context.createTestItem(
`${extensionId}:${workspaceFolder.name}`,
workspaceFolder.name,
- isVirtualWorkspaceFolder(workspaceFolder)
- ? workspaceFolder.effectiveUri
- : workspaceFolder.uri,
+ uri,
this
);
- const desc = runModeDescription(this.context.ext.settings.runMode.config);
+ const desc = runModeDescription(settings.runMode.config);
item.description = `(${desc.deferred?.label ?? desc.type.label})`;
item.canResolveChildren = true;
diff --git a/tests/JestExt/helper.test.ts b/tests/JestExt/helper.test.ts
index 87e5781f6..53fbef34d 100644
--- a/tests/JestExt/helper.test.ts
+++ b/tests/JestExt/helper.test.ts
@@ -179,6 +179,7 @@ describe('getExtensionResourceSettings()', () => {
nodeEnv: undefined,
useDashedArgs: false,
useJest30: null,
+ trimSymlinks: false,
});
expect(createJestSettingGetter).toHaveBeenCalledWith(folder);
});
diff --git a/tests/test-provider/test-helper.ts b/tests/test-provider/test-helper.ts
index 42854efe3..ea107e051 100644
--- a/tests/test-provider/test-helper.ts
+++ b/tests/test-provider/test-helper.ts
@@ -106,3 +106,19 @@ export const mockJestProcess = (id: string, extra?: any): any => {
...(extra ?? {}),
};
};
+
+export interface SymlinkMock {
+ pushSymlink: (config: SymlinkConfig) => void;
+ delink: (src: string) => void;
+}
+
+export type SymlinkConfig = {
+ src: string;
+ dst: string;
+ link: string;
+};
+
+export type MockedPath = typeof import('path') & SymlinkMock & {
+ setSep: (sep: string) => void;
+ sep: string;
+};
diff --git a/tests/test-provider/test-item-data.test.ts b/tests/test-provider/test-item-data.test.ts
index af0e1b161..7fb1f22f4 100644
--- a/tests/test-provider/test-item-data.test.ts
+++ b/tests/test-provider/test-item-data.test.ts
@@ -17,15 +17,51 @@ import { tiContextManager } from '../../src/test-provider/test-item-context-mana
import { toAbsoluteRootPath } from '../../src/helpers';
import { outputManager } from '../../src/output-manager';
+const symlinks = new Map();
+const setupSymlink = (config: SymlinkConfig) => {
+ symlinks.set(config.src, config);
+};
+const unsetSymlink = (src: string) => {
+ symlinks.delete(src)
+};
+const resolveSymlink = (p:string) => {
+ for (const [_, item] of symlinks) {
+ p = p.replace(item.src, item.dst);
+ }
+ return p;
+};
+
+jest.mock('fs', () => {
+ return {
+ readFileSync: jest.requireActual('fs').readFileSync,
+ statSync: jest.requireActual('fs').statSync,
+ realpathSync: (p:string) => {
+ const result = resolveSymlink(p);
+ return result;
+ },
+ };
+});
+
jest.mock('path', () => {
let sep = '/';
+ const maybeGetRelativeSymlink = (p1: string, p2: string) : string | undefined => {
+ for (const [_, item] of symlinks) {
+ if (p1.startsWith(item.src)) {
+ return p2.replace(item.dst, item.link);
+ }
+ }
+ };
return {
- relative: (p1, p2) => {
- const p = p2.split(p1)[1];
- if (p[0] === sep) {
- return p.slice(1);
+ relative: (p1: string, p2: string) => {
+ let res = maybeGetRelativeSymlink(p1, p2);
+ if (res) {
+ return res;
+ }
+ res = p2.split(p1)[1];
+ if (res[0] === sep) {
+ return res.slice(1);
}
- return p;
+ return res;
},
basename: (p) => p.split(sep).slice(-1),
sep,
@@ -48,7 +84,13 @@ import {
buildSourceContainer,
} from '../../src/TestResults/match-by-context';
import * as path from 'path';
-import { mockController, mockExtExplorerContext, mockJestProcess } from './test-helper';
+import {
+ mockController,
+ mockExtExplorerContext,
+ mockJestProcess,
+ MockedPath,
+ SymlinkConfig,
+} from './test-helper';
import * as errors from '../../src/errors';
import { ItemCommand } from '../../src/test-provider/types';
import { RunMode } from '../../src/JestExt/run-mode';
@@ -56,7 +98,7 @@ import { VirtualWorkspaceFolder } from '../../src/virtual-workspace-folder';
import { ProcessStatus } from '../../src/JestProcessManagement';
const mockPathSep = (newSep: string) => {
- (path as jest.Mocked).setSep(newSep);
+ (path as MockedPath).setSep(newSep);
(path as jest.Mocked).sep = newSep;
};
@@ -190,7 +232,7 @@ describe('test-item-data', () => {
vscode.Uri.joinPath = jest
.fn()
.mockImplementation((uri, p) => ({ fsPath: `${uri.fsPath}/${p}` }));
- vscode.Uri.file = jest.fn().mockImplementation((f) => ({ fsPath: f }));
+ vscode.Uri.file = jest.fn().mockImplementation((f) => ({ fsPath: f, path: f }));
(tiContextManager.setItemContext as jest.Mocked).mockClear();
(vscode.Location as jest.Mocked).mockReturnValue({});
@@ -241,6 +283,103 @@ describe('test-item-data', () => {
//verify state after the discovery
expect(wsRoot.item.canResolveChildren).toBe(false);
});
+ describe('when workspace is a symlink', () => {
+ const linkConfig = {
+ src: '/ws-link',
+ dst: '/ws-1',
+ link: '../ws-1',
+ };
+ beforeAll(() => {
+ setupSymlink(linkConfig);
+ });
+ afterAll(() => {
+ unsetSymlink(linkConfig.src);
+ });
+
+ beforeEach(() => {
+ // Symlink mock activation
+ context.ext.workspace.name = 'ws-link';
+ context.ext.workspace.uri.fsPath = '/ws-link';
+
+ const testFiles = [
+ '/ws-1/src/a.test.ts',
+ '/ws-1/src/b.test.ts',
+ '/ws-1/src/app/app.test.ts',
+ ];
+ context.ext.testResultProvider.getTestList.mockReturnValue(testFiles);
+ })
+ it('create test document tree with uplevels', () => {
+ const wsRoot = new WorkspaceRoot(context);
+ const jestRun = createTestRun();
+ wsRoot.discoverTest(jestRun);
+
+ // verify tree structure
+ // Walk up from linked workspace until the original workspace is found
+ expect(wsRoot.item.children.size).toEqual(1);
+ const childUplevel = getChildItem(wsRoot.item, '..');
+ expect(childUplevel).not.toBeUndefined();
+ expect(childUplevel.label).toEqual('..');
+ expect(context.getData(childUplevel) instanceof FolderData).toBeTruthy();
+ const actualWsUplevel = getChildItem(childUplevel, 'ws-1');
+ expect(context.getData(actualWsUplevel) instanceof FolderData).toBeTruthy();
+ const srcUplevel = getChildItem(actualWsUplevel, 'src');
+ expect(context.getData(srcUplevel) instanceof FolderData).toBeTruthy();
+
+ // Test the rest of the tree
+ const appItem = getChildItem(srcUplevel, 'app');
+ const aItem = getChildItem(srcUplevel, 'a.test.ts');
+ const bItem = getChildItem(srcUplevel, 'b.test.ts');
+
+ expect(context.getData(appItem) instanceof FolderData).toBeTruthy();
+ expect(appItem.children.size).toEqual(1);
+ const appFileItem = getChildItem(appItem, 'app.test.ts');
+ expect(context.getData(appFileItem) instanceof TestDocumentRoot).toBeTruthy();
+ expect(appFileItem.children.size).toEqual(0);
+
+ [aItem, bItem].forEach((fItem) => {
+ expect(context.getData(fItem) instanceof TestDocumentRoot).toBeTruthy();
+ expect(fItem.children.size).toEqual(0);
+ });
+
+ //verify state after the discovery
+ expect(wsRoot.item.canResolveChildren).toBe(false);
+ });
+ describe('when trimSymlinks is true', () => {
+ beforeEach(() => {
+ context.ext.settings.trimSymlinks = true;
+ });
+ it('create test document tree without uplevels', () => {
+ const wsRoot = new WorkspaceRoot(context);
+ const jestRun = createTestRun();
+ wsRoot.discoverTest(jestRun);
+
+ // verify tree structure
+ expect(wsRoot.item.children.size).toEqual(1);
+ const directChildSrc = getChildItem(wsRoot.item, 'src');
+ expect(directChildSrc).not.toBeUndefined();
+ expect(directChildSrc.label).toEqual('src');
+ expect(context.getData(directChildSrc) instanceof FolderData).toBeTruthy();
+
+ // Test the rest of the tree
+ const appItem = getChildItem(directChildSrc, 'app');
+ const aItem = getChildItem(directChildSrc, 'a.test.ts');
+ const bItem = getChildItem(directChildSrc, 'b.test.ts');
+
+ expect(context.getData(appItem) instanceof FolderData).toBeTruthy();
+ expect(appItem.children.size).toEqual(1);
+ const appFileItem = getChildItem(appItem, 'app.test.ts');
+ expect(context.getData(appFileItem) instanceof TestDocumentRoot).toBeTruthy();
+ expect(appFileItem.children.size).toEqual(0);
+
+ [aItem, bItem].forEach((fItem) => {
+ expect(context.getData(fItem) instanceof TestDocumentRoot).toBeTruthy();
+ expect(fItem.children.size).toEqual(0);
+ });
+ //verify state after the discovery
+ expect(wsRoot.item.canResolveChildren).toBe(false);
+ });
+ });
+ });
describe('when no testFiles yet', () => {
it('if no testFiles yet, will still turn off canResolveChildren and close the run', () => {
context.ext.testResultProvider.getTestList.mockReturnValue([]);