Skip to content

Commit

Permalink
support symlinks in parents of the root of the workspace (microsoft#2…
Browse files Browse the repository at this point in the history
  • Loading branch information
eleanorjboyd committed Apr 19, 2024
1 parent 1b2bd68 commit 577e64c
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 19 deletions.
35 changes: 35 additions & 0 deletions python_files/tests/pytestadapter/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import pathlib
import tempfile
import os
import sys

from .helpers import ( # noqa: E402
TEST_DATA_PATH,
)

script_dir = pathlib.Path(__file__).parent.parent.parent
sys.path.append(os.fspath(script_dir))
from vscode_pytest import has_symlink_parent # noqa: E402


def test_has_symlink_parent_with_symlink():
# Create a temporary directory and a file in it
with tempfile.TemporaryDirectory() as temp_dir:
file_path = pathlib.Path(temp_dir) / "file"
file_path.touch()

# Create a symbolic link to the temporary directory
symlink_path = pathlib.Path(temp_dir) / "symlink"
symlink_path.symlink_to(temp_dir)

# Check that has_symlink_parent correctly identifies the symbolic link
assert has_symlink_parent(symlink_path / "file")


def test_has_symlink_parent_without_symlink():
folder_path = TEST_DATA_PATH / "unittest_folder" / "test_add.py"
# Check that has_symlink_parent correctly identifies that there are no symbolic links
assert not has_symlink_parent(folder_path)
35 changes: 29 additions & 6 deletions python_files/vscode_pytest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,21 @@ def pytest_load_initial_conftests(early_config, parser, args):
raise VSCodePytestError(
f"The path set in the argument --rootdir={rootdir} does not exist."
)
if (
os.path.islink(rootdir)
and pathlib.Path(os.path.realpath(rootdir)) == pathlib.Path.cwd()
):

# Check if the rootdir is a symlink or a child of a symlink to the current cwd.
isSymlink = False

if os.path.islink(rootdir):
isSymlink = True
print(
f"Plugin info[vscode-pytest]: rootdir argument, {rootdir}, is identified as a symlink to the cwd, {pathlib.Path.cwd()}.",
"Therefore setting symlink path to rootdir argument.",
f"Plugin info[vscode-pytest]: rootdir argument, {rootdir}, is identified as a symlink."
)
elif pathlib.Path(os.path.realpath(rootdir)) != rootdir:
print("Plugin info[vscode-pytest]: Checking if rootdir is a child of a symlink.")
isSymlink = has_symlink_parent(rootdir)
if isSymlink:
print(
f"Plugin info[vscode-pytest]: rootdir argument, {rootdir}, is identified as a symlink or child of a symlink, adjusting pytest paths accordingly.",
)
global SYMLINK_PATH
SYMLINK_PATH = pathlib.Path(rootdir)
Expand Down Expand Up @@ -144,6 +152,21 @@ def pytest_exception_interact(node, call, report):
)


def has_symlink_parent(current_path):
"""Recursively checks if any parent directories of the given path are symbolic links."""
# Convert the current path to an absolute Path object
curr_path = pathlib.Path(current_path)
print("Checking for symlink parent starting at current path: ", curr_path)

# Iterate over all parent directories
for parent in curr_path.parents:
# Check if the parent directory is a symlink
if os.path.islink(parent):
print(f"Symlink found at: {parent}")
return True
return False


def get_absolute_test_id(test_id: str, testPath: pathlib.Path) -> str:
"""A function that returns the absolute test id. This is necessary because testIds are relative to the rootdir.
This does not work for our case since testIds when referenced during run time are relative to the instantiation
Expand Down
30 changes: 30 additions & 0 deletions src/client/testing/testController/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.
import * as net from 'net';
import * as path from 'path';
import * as fs from 'fs';
import { CancellationToken, Position, TestController, TestItem, Uri, Range, Disposable } from 'vscode';
import { Message } from 'vscode-jsonrpc';
import { traceError, traceInfo, traceLog, traceVerbose } from '../../../logging';
Expand Down Expand Up @@ -502,3 +503,32 @@ export function argKeyExists(args: string[], key: string): boolean {
}
return false;
}

/**
* Checks recursively if any parent directories of the given path are symbolic links.
* @param {string} currentPath - The path to start checking from.
* @returns {Promise<boolean>} - Returns true if any parent directory is a symlink, otherwise false.
*/
export async function hasSymlinkParent(currentPath: string): Promise<boolean> {
try {
// Resolve the path to an absolute path
const absolutePath = path.resolve(currentPath);
// Get the parent directory
const parentDirectory = path.dirname(absolutePath);
// Check if the current directory is the root directory
if (parentDirectory === absolutePath) {
return false;
}
// Check if the parent directory is a symlink
const stats = await fs.promises.lstat(parentDirectory);
if (stats.isSymbolicLink()) {
traceLog(`Symlink found at: ${parentDirectory}`);
return true;
}
// Recurse up the directory tree
return await hasSymlinkParent(parentDirectory);
} catch (error) {
console.error('Error checking symlinks:', error);
return false;
}
}
16 changes: 14 additions & 2 deletions src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
fixLogLinesNoTrailing,
startDiscoveryNamedPipe,
addValueIfKeyNotExist,
hasSymlinkParent,
} from '../common/utils';
import { IEnvironmentVariablesProvider } from '../../../common/variables/types';

Expand Down Expand Up @@ -67,9 +68,20 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath;

// check for symbolic path
const stats = fs.lstatSync(cwd);
const stats = await fs.promises.lstat(cwd);
const resolvedPath = await fs.promises.realpath(cwd);
let isSymbolicLink = false;
if (stats.isSymbolicLink()) {
traceWarn("The cwd is a symbolic link, adding '--rootdir' to pytestArgs only if it doesn't already exist.");
isSymbolicLink = true;
traceWarn('The cwd is a symbolic link.');
} else if (resolvedPath !== cwd) {
traceWarn(
'The cwd resolves to a different path, checking if it has a symbolic link somewhere in its path.',
);
isSymbolicLink = await hasSymlinkParent(cwd);
}
if (isSymbolicLink) {
traceWarn("Symlink found, adding '--rootdir' to pytestArgs only if it doesn't already exist. cwd: ", cwd);
pytestArgs = addValueIfKeyNotExist(pytestArgs, '--rootdir', cwd);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter {
} finally {
// wait for EOT
await deferredTillEOT.promise;
console.log('deferredTill EOT resolved');
await deferredTillServerClose.promise;
console.log('Server closed await now resolved');
}
const executionPayload: ExecutionTestPayload = {
cwd: uri.fsPath,
Expand Down
129 changes: 126 additions & 3 deletions src/test/testing/common/testingAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as typeMoq from 'typemoq';
import * as path from 'path';
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import { PytestTestDiscoveryAdapter } from '../../../client/testing/testController/pytest/pytestDiscoveryAdapter';
import { ITestController, ITestResultResolver } from '../../../client/testing/testController/common/types';
import { IPythonExecutionFactory } from '../../../client/common/process/types';
Expand Down Expand Up @@ -62,6 +63,13 @@ suite('End to End Tests: test adapters', () => {
'testTestingRootWkspc',
'symlinkWorkspace',
);
const nestedTarget = path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testTestingRootWkspc', 'target workspace');
const nestedSymlink = path.join(
EXTENSION_ROOT_DIR_FOR_TESTS,
'src',
'testTestingRootWkspc',
'symlink_parent-folder',
);
suiteSetup(async () => {
serviceContainer = (await initialize()).serviceContainer;

Expand All @@ -73,7 +81,14 @@ suite('End to End Tests: test adapters', () => {
if (err) {
traceError(err);
} else {
traceLog('Symlink created successfully for end to end tests.');
traceLog('Symlink created successfully for regular symlink end to end tests.');
}
});
fs.symlink(nestedTarget, nestedSymlink, 'dir', (err) => {
if (err) {
traceError(err);
} else {
traceLog('Symlink created successfully for nested symlink end to end tests.');
}
});
} catch (err) {
Expand Down Expand Up @@ -116,11 +131,23 @@ suite('End to End Tests: test adapters', () => {
if (err) {
traceError(err);
} else {
traceLog('Symlink removed successfully after tests.');
traceLog('Symlink removed successfully after tests, rootPathDiscoverySymlink.');
}
});
} else {
traceLog('Symlink was not found to remove after tests, exiting successfully, rootPathDiscoverySymlink.');
}

if (fs.existsSync(nestedSymlink)) {
fs.unlink(nestedSymlink, (err) => {
if (err) {
traceError(err);
} else {
traceLog('Symlink removed successfully after tests, nestedSymlink.');
}
});
} else {
traceLog('Symlink was not found to remove after tests, exiting successfully');
traceLog('Symlink was not found to remove after tests, exiting successfully, nestedSymlink.');
}
});
test('unittest discovery adapter small workspace', async () => {
Expand Down Expand Up @@ -256,7 +283,103 @@ suite('End to End Tests: test adapters', () => {
assert.strictEqual(callCount, 1, 'Expected _resolveDiscovery to be called once');
});
});
test('pytest discovery adapter nested symlink', async () => {
if (os.platform() === 'win32') {
console.log('Skipping test for windows');
return;
}

// result resolver and saved data for assertions
let actualData: {
cwd: string;
tests?: unknown;
status: 'success' | 'error';
error?: string[];
};
// set workspace to test workspace folder
const workspacePath = path.join(nestedSymlink, 'custom_sub_folder');
const workspacePathParent = nestedSymlink;
workspaceUri = Uri.parse(workspacePath);
const filePath = path.join(workspacePath, 'test_simple.py');
const stats = fs.lstatSync(workspacePathParent);

// confirm that the path is a symbolic link
assert.ok(stats.isSymbolicLink(), 'The PARENT path is not a symbolic link but must be for this test.');

resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri);
let callCount = 0;
resultResolver._resolveDiscovery = async (payload, _token?) => {
traceLog(`resolveDiscovery ${payload}`);
callCount = callCount + 1;
actualData = payload;
return Promise.resolve();
};
// run pytest discovery
const discoveryAdapter = new PytestTestDiscoveryAdapter(
configService,
testOutputChannel.object,
resultResolver,
envVarsService,
);
configService.getSettings(workspaceUri).testing.pytestArgs = [];

await discoveryAdapter.discoverTests(workspaceUri, pythonExecFactory).finally(() => {
// verification after discovery is complete

// 1. Check the status is "success"
assert.strictEqual(
actualData.status,
'success',
`Expected status to be 'success' instead status is ${actualData.status}`,
); // 2. Confirm no errors
assert.strictEqual(actualData.error?.length, 0, "Expected no errors in 'error' field");
// 3. Confirm tests are found
assert.ok(actualData.tests, 'Expected tests to be present');
// 4. Confirm that the cwd returned is the symlink path and the test's path is also using the symlink as the root
if (process.platform === 'win32') {
// covert string to lowercase for windows as the path is case insensitive
traceLog('windows machine detected, converting path to lowercase for comparison');
const a = actualData.cwd.toLowerCase();
const b = filePath.toLowerCase();
const testSimpleActual = (actualData.tests as {
children: {
path: string;
}[];
}).children[0].path.toLowerCase();
const testSimpleExpected = filePath.toLowerCase();
assert.strictEqual(a, b, `Expected cwd to be the symlink path actual: ${a} expected: ${b}`);
assert.strictEqual(
testSimpleActual,
testSimpleExpected,
`Expected test path to be the symlink path actual: ${testSimpleActual} expected: ${testSimpleExpected}`,
);
} else {
assert.strictEqual(
path.join(actualData.cwd),
path.join(workspacePath),
'Expected cwd to be the symlink path, check for non-windows machines',
);
assert.strictEqual(
(actualData.tests as {
children: {
path: string;
}[];
}).children[0].path,
filePath,
'Expected test path to be the symlink path, check for non windows machines',
);
}

// 5. Confirm that resolveDiscovery was called once
assert.strictEqual(callCount, 1, 'Expected _resolveDiscovery to be called once');
});
});
test('pytest discovery adapter small workspace with symlink', async () => {
if (os.platform() === 'win32') {
console.log('Skipping test for windows');
return;
}

// result resolver and saved data for assertions
let actualData: {
cwd: string;
Expand Down
Loading

0 comments on commit 577e64c

Please sign in to comment.