From 371a95a2d92faa57900e6837b1b96b032d304b9b Mon Sep 17 00:00:00 2001 From: Nitzan Kohan Date: Fri, 30 May 2025 11:28:32 +0300 Subject: [PATCH 1/2] Fix test results path/tree showing uplevel (..) nodes - Add feature flag - Minor refactor: consolidate settings usage - Update documentation - Adjust tests to pass --- README.md | 12 ++++++++++++ package.json | 6 ++++++ src/JestExt/helper.ts | 1 + src/Settings/types.ts | 1 + src/test-provider/test-item-data.ts | 18 ++++++++++++++---- tests/JestExt/helper.test.ts | 1 + 6 files changed, 35 insertions(+), 4 deletions(-) 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); }); From fe31dc7c518276a0173545887241adfad6030548 Mon Sep 17 00:00:00 2001 From: Nitzan Kohan Date: Sun, 1 Jun 2025 13:35:44 +0300 Subject: [PATCH 2/2] Implement tests --- tests/test-provider/test-helper.ts | 16 +++ tests/test-provider/test-item-data.test.ts | 155 +++++++++++++++++++-- 2 files changed, 163 insertions(+), 8 deletions(-) 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([]);