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)",
"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));
}

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 @@ 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;
};
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 { tiContextManager } from '../../src/test-provider/test-item-context-mana
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)
};
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,
Expand All @@ -48,15 +84,21 @@ 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';
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 @@ 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<any>).mockClear();

(vscode.Location as jest.Mocked<any>).mockReturnValue({});
Expand Down Expand Up @@ -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([]);
Expand Down