Skip to content

Fix relative uplevels of Symlinked Workspace (Issue #798) #1244

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Content
- [coverageColors](#coveragecolors)
- [outputConfig](#outputconfig)
- [runMode](#runmode)
- [trimSymlinks](#trimSymlinks)
- [autoRun](#autorun)
- [testExplorer](#testexplorer)
- [shell](#shell)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.<br>
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
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't able to access this image link. Can you add this image file to the ./images folder and reference it from there.

"type": "boolean",
"default": false,
"scope": "resource"
}
}
},
Expand Down
1 change: 1 addition & 0 deletions src/JestExt/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const getExtensionResourceSettings = (
enable: getSetting<boolean>('enable'),
useDashedArgs: getSetting<boolean>('useDashedArgs') ?? false,
useJest30: getSetting<boolean>('useJest30'),
trimSymlinks: getSetting<boolean>('trimSymlinks'),
};
};

Expand Down
1 change: 1 addition & 0 deletions src/Settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export interface PluginResourceSettings {
parserPluginOptions?: JESParserPluginOptions;
useDashedArgs?: boolean;
useJest30?: boolean;
trimSymlinks?: boolean;
}

export interface DeprecatedPluginResourceSettings {
Expand Down
18 changes: 14 additions & 4 deletions src/test-provider/test-item-data.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be safe, maybe let's wrap it inside a try/catch block and fall back to the original uri...

}

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;
Expand Down
1 change: 1 addition & 0 deletions tests/JestExt/helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ describe('getExtensionResourceSettings()', () => {
nodeEnv: undefined,
useDashedArgs: false,
useJest30: null,
trimSymlinks: false,
});
expect(createJestSettingGetter).toHaveBeenCalledWith(folder);
});
Expand Down
16 changes: 16 additions & 0 deletions tests/test-provider/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,19 @@
...(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 & {

Check failure on line 121 in tests/test-provider/test-helper.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

Insert `⏎·`
setSep: (sep: string) => void;

Check failure on line 122 in tests/test-provider/test-helper.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

Insert `··`
sep: string;

Check failure on line 123 in tests/test-provider/test-helper.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

Insert `··`
};

Check failure on line 124 in tests/test-provider/test-helper.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

Insert `··`
155 changes: 147 additions & 8 deletions tests/test-provider/test-item-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,51 @@
import { toAbsoluteRootPath } from '../../src/helpers';
import { outputManager } from '../../src/output-manager';

const symlinks = new Map<string, SymlinkConfig>();
const setupSymlink = (config: SymlinkConfig) => {
symlinks.set(config.src, config);
};
const unsetSymlink = (src: string) => {
symlinks.delete(src)

Check failure on line 25 in tests/test-provider/test-item-data.test.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

Insert `;`
};
const resolveSymlink = (p:string) => {

Check failure on line 27 in tests/test-provider/test-item-data.test.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

Insert `·`
for (const [_, item] of symlinks) {

Check failure on line 28 in tests/test-provider/test-item-data.test.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

'_' is assigned a value but never used
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) => {

Check failure on line 38 in tests/test-provider/test-item-data.test.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

Insert `·`
const result = resolveSymlink(p);
return result;
},
};
});

jest.mock('path', () => {
let sep = '/';
const maybeGetRelativeSymlink = (p1: string, p2: string) : string | undefined => {

Check failure on line 47 in tests/test-provider/test-item-data.test.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

Delete `·`
for (const [_, item] of symlinks) {

Check failure on line 48 in tests/test-provider/test-item-data.test.ts

View workflow job for this annotation

GitHub Actions / Pre-test checks

'_' is assigned a value but never used
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,
Expand All @@ -48,15 +84,21 @@
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';
import { VirtualWorkspaceFolder } from '../../src/virtual-workspace-folder';
import { ProcessStatus } from '../../src/JestProcessManagement';

const mockPathSep = (newSep: string) => {
(path as jest.Mocked<any>).setSep(newSep);
(path as MockedPath).setSep(newSep);
(path as jest.Mocked<any>).sep = newSep;
};

Expand Down Expand Up @@ -190,7 +232,7 @@
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<any>).mockClear();

(vscode.Location as jest.Mocked<any>).mockReturnValue({});
Expand Down Expand Up @@ -241,6 +283,103 @@
//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([]);
Expand Down