diff --git a/.gitignore b/.gitignore index 2a6babf..2afe4fa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,14 @@ node_modules .vscode-test/ *.vsix src/test/examples/*/.tox + +# Python +__pycache__ +.pytest_cache + +# VS Code +vscode.d.ts +vscode.proposed.testCoverage.d.ts + +# Tox +.tox \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 670d6e6..904f1e1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,8 @@ "type": "extensionHost", "request": "launch", "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" + "--extensionDevelopmentPath=${workspaceFolder}", + "${workspaceFolder}/sample" ], "outFiles": [ "${workspaceFolder}/out/**/*.js" diff --git a/README.md b/README.md index 7353ebe..acc9e1e 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,14 @@ but use at your own risk. ## Installing -Install the extension via the [Visual Studio +- Install the extension via the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=the-compiler.python-tox) or the [Open VSX -Registry](https://open-vsx.org/extension/the-compiler/python-tox). Also, make -sure that [tox](https://github.com/tox-dev/tox) is installed. +Registry](https://open-vsx.org/extension/the-compiler/python-tox) -Finally, run one of the [commands](#extension-commands) via the command palette +- make sure that [tox](https://github.com/tox-dev/tox) is installed. + +- finally, run one of the [commands](#extension-commands) via the command palette or bind them to a shortcut. No default shortcuts are provided. For [VSpaceCode](https://vspacecode.github.io/), consider a configuration such as: diff --git a/package-lock.json b/package-lock.json index 052128d..cadf247 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1453,6 +1453,12 @@ "prebuild-install": "^7.0.1" } }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -1925,6 +1931,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -2159,6 +2175,12 @@ "simple-concat": "^1.0.0" } }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -2505,6 +2527,29 @@ } } }, + "vscode-dts": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/vscode-dts/-/vscode-dts-0.3.3.tgz", + "integrity": "sha512-JfOsWL0NvfVw0UF9bcTjlv1Onz3Ted7cgpPWKWMnHGB+72t/tn8WFDeKLZO42l2k9KJq/NGS9rFC5gZbyI4FTg==", + "dev": true, + "requires": { + "minimist": "^1.2.0", + "prompts": "^2.1.0", + "rimraf": "^3.0.0" + } + }, + "vscode-test": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/vscode-test/-/vscode-test-1.5.2.tgz", + "integrity": "sha512-x9PVfKxF6EInH9iSFGQi0V8H5zIW1fC7RAer6yNQR6sy3WyOwlWkuT3I+wf75xW/cO53hxMi1aj/EvqQfDFOAg==", + "dev": true, + "requires": { + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "rimraf": "^3.0.2", + "unzipper": "^0.10.11" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 324ed2d..233a709 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,11 @@ "activationEvents": [ "onCommand:python-tox.select", "onCommand:python-tox.selectMultiple", - "onCommand:python-tox.openDocs" + "onCommand:python-tox.openDocs", + "workspaceContains:tox.ini" + ], + "enabledApiProposals": [ + "testCoverage" ], "main": "./out/extension.js", "contributes": { @@ -63,6 +67,9 @@ "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", + "download-api": "vscode-dts dev", + "postdownload-api": "vscode-dts main", + "postinstall": "npm run download-api", "pretest": "npm run compile && npm run lint", "lint": "eslint src --ext ts --max-warnings=0", "test": "node ./out/test/runTest.js", @@ -82,6 +89,8 @@ "mocha": "^9.2.2", "ts-mockito": "^2.6.1", "typescript": "^4.6.3", - "vsce": "^2.7.0" + "vsce": "^2.7.0", + "vscode-test": "^1.5.2", + "vscode-dts": "^0.3.3" } } diff --git a/sample/requirements.txt b/sample/requirements.txt new file mode 100644 index 0000000..49435c9 --- /dev/null +++ b/sample/requirements.txt @@ -0,0 +1 @@ +pytest==7.1.1 \ No newline at end of file diff --git a/sample/tests/test_sample.py b/sample/tests/test_sample.py new file mode 100644 index 0000000..59790f3 --- /dev/null +++ b/sample/tests/test_sample.py @@ -0,0 +1,10 @@ +def test_sample_1(): + assert True, "Something went very wrong here..." + + +def test_sample_2(): + assert type("Hello") is str, "Somehow the string is not a string." + + +def test_sample_3(): + assert 3 == 3, "Math failed, please reboot." diff --git a/sample/tox.ini b/sample/tox.ini new file mode 100644 index 0000000..17c3ae2 --- /dev/null +++ b/sample/tox.ini @@ -0,0 +1,30 @@ +; tox docs: https://tox.wiki/en/latest/index.html + +[tox] +envlist = py310 +skipsdist = True + +[testenv] +deps = -rrequirements.txt + +[testenv:test-all] +commands = + pytest -vv tests/ + +[testenv:test-1] +commands = + pytest {posargs} tests/test_sample.py::test_sample_1 + +[testenv:test-2] +commands = + pytest {posargs} tests/test_sample.py::test_sample_2 + +[testenv:test-3] +commands = + pytest {posargs} tests/test_sample.py::test_sample_3 + +; Will be ignored from test UI +[testenv:test-{four,five}] +commands = + four: echo 4 + five: echo 5 diff --git a/src/extension.ts b/src/extension.ts index 59b7fd1..79af318 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -60,7 +60,18 @@ function runTox(envs: string[], toxArguments: string, terminal: vscode.Terminal terminal.sendText(terminalCommand); } +/** + * Get a new terminal or use an existing one with the same name. + * @param projDir The directory of the project. + * @param name The name of the terminal + * @returns The terminal to run commands on. + */ function getTerminal(projDir : string = findProjectDir(), name : string = "tox") : vscode.Terminal { + for (const terminal of vscode.window.terminals) { + if (terminal.name === name){ + return terminal; + } + } return vscode.window.createTerminal({"cwd": projDir, "name": name}); } @@ -103,6 +114,173 @@ async function openDocumentationCommand() { } export function activate(context: vscode.ExtensionContext) { + const controller = vscode.tests.createTestController('toxTestController', 'Tox Testing'); + context.subscriptions.push(controller); + + controller.resolveHandler = async (test) => { + if (!test) { + await discoverAllFilesInWorkspace(); + } + else { + await parseTestsInFileContents(test); + } + }; + + async function runHandler( + shouldDebug: boolean, + request: vscode.TestRunRequest, + token: vscode.CancellationToken) + { + const run = controller.createTestRun(request); + const queue: vscode.TestItem[] = []; + + if (request.include) { + request.include.forEach(test => queue.push(test)); + } + + while (queue.length > 0 && !token.isCancellationRequested) { + const test = queue.pop()!; + + // Skip tests the user asked to exclude + if (request.exclude?.includes(test)) { + continue; + } + + const start = Date.now(); + try { + const cwd = vscode.workspace.getWorkspaceFolder(test.uri!)!.uri.path; + runTox([test.label], cwd); + run.passed(test, Date.now() - start); + } + catch (e: any) { + run.failed(test, new vscode.TestMessage(e.message), Date.now() - start); + } + } + + // Make sure to end the run after all tests have been executed: + run.end(); + } + + const runProfile = controller.createRunProfile( + 'Run', + vscode.TestRunProfileKind.Run, + (request, token) => { + runHandler(false, request, token); + } + ); + + // When text documents are open, parse tests in them. + vscode.workspace.onDidOpenTextDocument(parseTestsInDocument); + + // We could also listen to document changes to re-parse unsaved changes: + vscode.workspace.onDidSaveTextDocument(document => parseTestsInDocument(document)); + + /** + * In this function, we'll get the file TestItem if we've already found it, + * otherwise we'll create it with `canResolveChildren = true` to indicate it + * can be passed to the `controller.resolveHandler` to gets its children. + * @param uri The uri of the file to get or create + * @returns vscode.TestItem + */ + function getOrCreateFile(uri: vscode.Uri): vscode.TestItem + { + const existing = controller.items.get(uri.toString()); + if (existing) { + return existing; + } + + const file = controller.createTestItem(uri.toString(), uri.path.split('/').pop()!, uri); + controller.items.add(file); + + file.canResolveChildren = true; + return file; + } + + /** + * Parses for tests in the document. + * @param e The provided document + * @param filename The name of the file to look for. Default = tox.ini + */ + async function parseTestsInDocument(e: vscode.TextDocument, filename: string = 'tox.ini') { + if (e.uri.scheme === 'file' && e.uri.path.endsWith(filename)) { + const file = getOrCreateFile(e.uri); + const content = e.getText(); + + // Empty existing children + file.children.forEach(element => { + file.children.delete(element.id); + }); + + const listOfChildren = await parseTestsInFileContents(file, content); + listOfChildren.forEach((testItem) => { + file.children.add(testItem); + }); + } + } + + /** + * Parses the file to fill in the test.children from the contents + * @param file The file to parse + * @param contents The contents of the file + */ + async function parseTestsInFileContents(file: vscode.TestItem, contents?: string): Promise { + + if (contents === undefined) { + const rawContent = await vscode.workspace.fs.readFile(file.uri!); + contents = new util.TextDecoder().decode(rawContent); + } + + let listOfChildren: vscode.TestItem[] = []; + + const testRegex = /(\[testenv):(.*)\]/gm; // made with https://regex101.com + let lines = contents.split('\n'); + + for (let lineNo = 0; lineNo < lines.length; lineNo++) { + let line = lines[lineNo]; + let regexResult = testRegex.exec(line); + + // Excluding tox permutations for now + if (regexResult && !regexResult[2].includes('{')) { + let range = new vscode.Range(new vscode.Position(lineNo, 0), new vscode.Position(lineNo, regexResult[0].length)); + + const newTestItem = controller.createTestItem(regexResult[2], regexResult[2], file.uri); + newTestItem.range = range; + + listOfChildren.push(newTestItem); + } + } + + return listOfChildren; + } + + async function discoverAllFilesInWorkspace() { + if (!vscode.workspace.workspaceFolders) { + return []; // handle the case of no open folders + } + + return Promise.all( + vscode.workspace.workspaceFolders.map(async workspaceFolder => { + const pattern = new vscode.RelativePattern(workspaceFolder, 'tox.ini'); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + + // When files are created, make sure there's a corresponding "file" node in the tree + watcher.onDidCreate(uri => getOrCreateFile(uri)); + // When files change, re-parse them. Note that you could optimize this so + // that you only re-parse children that have been resolved in the past. + watcher.onDidChange(uri => parseTestsInFileContents(getOrCreateFile(uri))); + // And, finally, delete TestItems for removed files. This is simple, since + // we use the URI as the TestItem's ID. + watcher.onDidDelete(uri => controller.items.delete(uri.toString())); + + for (const file of await vscode.workspace.findFiles(pattern)) { + getOrCreateFile(file); + } + + return watcher; + }) + ); + } + context.subscriptions.push( vscode.commands.registerCommand('python-tox.select', selectCommand), vscode.commands.registerCommand('python-tox.selectMultiple', selectMultipleCommand),