├── .gitignore ├── .travis.yaml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── README.md ├── client ├── __mocks__ │ └── vscode.ts ├── package-lock.json ├── package.json ├── src │ ├── Configuration.ts │ ├── LanguageClientAdapter.ts │ ├── LanguageClientController.ts │ ├── Notify.ts │ └── extension.ts ├── syntaxes │ └── phpunit.tmLanguage ├── tests │ ├── LanguageClientAdapter.test.ts │ ├── LanguageClientController.test.ts │ └── Notify.test.ts ├── tsconfig.json └── webpack.config.js ├── codeql-analysis.yml ├── img ├── icon.png ├── run.png └── screenshot.png ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts └── e2e.sh ├── server ├── package-lock.json ├── package.json ├── src │ ├── Configuration.ts │ ├── Filesystem.ts │ ├── OutputProblemMatcher.ts │ ├── Parser.ts │ ├── ProblemCollection.ts │ ├── ProblemMatcher.ts │ ├── ProblemNode.ts │ ├── Process.ts │ ├── Snippets.ts │ ├── TestEventCollection.ts │ ├── TestExplorer.ts │ ├── TestNode.ts │ ├── TestResponse.ts │ ├── TestRunner.ts │ ├── TestSuiteCollection.ts │ ├── WorkspaceFolder.ts │ ├── WorkspaceFolders.ts │ └── server.ts ├── tests │ ├── Filesystem.test.ts │ ├── Parser.test.ts │ ├── ProblemMatcher.test.ts │ ├── Process.test.ts │ ├── TestEventCollection.test.ts │ ├── TestResponse.test.ts │ ├── TestRunner.test.ts │ ├── TestSuiteCollection.test.ts │ ├── WorkspaceFolder.test.ts │ ├── fixtures │ │ ├── bin │ │ │ ├── cmd │ │ │ └── ls │ │ ├── project-sub │ │ │ ├── .gitignore │ │ │ ├── .vscode │ │ │ │ └── settings.json │ │ │ ├── composer.json │ │ │ ├── composer.lock │ │ │ ├── phpunit.ini │ │ │ ├── phpunit.xml │ │ │ ├── phpunit.xml.dist │ │ │ ├── src │ │ │ │ ├── Calculator.php │ │ │ │ └── Item.php │ │ │ └── tests │ │ │ │ ├── AbstractTest.php │ │ │ │ ├── AssertionsTest.php │ │ │ │ ├── CalculatorTest.php │ │ │ │ ├── Directory │ │ │ │ ├── HasPropertyTest.php │ │ │ │ ├── LeadingCommentsTest.php │ │ │ │ └── UseTraitTest.php │ │ │ │ ├── StaticMethodTest.php │ │ │ │ └── bootstrap.php │ │ ├── test-result.txt │ │ └── usr │ │ │ └── local │ │ │ └── bin │ │ │ ├── cmd.cmd │ │ │ └── ls.cmd │ └── helpers.ts ├── tsconfig.json ├── types │ └── php-parser.d.ts └── webpack.config.js ├── shared.webpack.config.js ├── tsconfig.base.json ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | client/server 4 | .vscode-test 5 | 6 | /coverage 7 | .phpunit.result.cache 8 | tsconfig.tsbuildinfo 9 | *.vsix -------------------------------------------------------------------------------- /.travis.yaml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | 7 | node_js: 8 | - 8 9 | - 9 10 | - 10 11 | 12 | sudo: false 13 | 14 | os: 15 | - osx 16 | - linux 17 | 18 | cache: 19 | directories: 20 | - $HOME/.composer/cache 21 | - node_modules 22 | 23 | install: 24 | - cd server/tests/fixtures/project-sub; composer install; 25 | - npm install 26 | 27 | script: 28 | - npm test --silent 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "name": "Launch Client", 9 | "runtimeExecutable": "${execPath}", 10 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 11 | "stopOnEntry": false, 12 | "sourceMaps": true, 13 | "outFiles": ["${workspaceFolder}/client/out/**/*.js"], 14 | "preLaunchTask": "npm: watch" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "attach", 19 | "name": "Attach to Server", 20 | "address": "localhost", 21 | "protocol": "inspector", 22 | "port": 6009, 23 | "sourceMaps": true, 24 | "outFiles": ["${workspaceFolder}/server/out/**/*.js"] 25 | }, 26 | { 27 | "name": "Language Server E2E Test", 28 | "type": "extensionHost", 29 | "request": "launch", 30 | "runtimeExecutable": "${execPath}", 31 | "args": [ 32 | "--extensionDevelopmentPath=${workspaceRoot}", 33 | "--extensionTestsPath=${workspaceRoot}/client/out/test", 34 | "${workspaceRoot}/client/testFixture" 35 | ], 36 | "outFiles": ["${workspaceRoot}/client/out/test/**/*.js"] 37 | } 38 | ], 39 | "compounds": [ 40 | { 41 | "name": "Client + Server", 42 | "configurations": ["Launch Client", "Attach to Server"] 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false 5 | }, 6 | "search.exclude": { 7 | "out": true, 8 | "server": true 9 | }, 10 | "files.trimTrailingWhitespace": true, 11 | "editor.insertSpaces": false, 12 | "editor.tabSize": 4, 13 | "typescript.tsdk": "./node_modules/typescript/lib", 14 | "typescript.tsc.autoDetect": "off" 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "isBackground": true, 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | }, 14 | "presentation": { 15 | "reveal": "never", 16 | "panel": "dedicated" 17 | }, 18 | "problemMatcher": ["$tsc-watch"] 19 | }, 20 | { 21 | "type": "npm", 22 | "script": "compile", 23 | "isBackground": false, 24 | "group": "build", 25 | "presentation": { 26 | "reveal": "never", 27 | "panel": "dedicated" 28 | }, 29 | "problemMatcher": ["$tsc"] 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | **/*.ts 3 | **/*.map 4 | .gitignore 5 | **/tsconfig.json 6 | **/tsconfig.base.json 7 | contributing.md 8 | .travis.yml 9 | client/node_modules/** 10 | 11 | **/.phpunit.result.cache 12 | **/tsconfig.tsbuildinfo 13 | **/webpack.config.js 14 | client/tests/** 15 | coverage/** 16 | img/** 17 | server/node_modules/** 18 | server/tests/** 19 | server/types/** 20 | shared.webpack.config.js 21 | jest.config.js 22 | *.vsix 23 | !img/icon.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP: Unit Test Explorer UI for Visual Studio Code 2 | 3 | Run your PHP tests using the 4 | [Test Explorer UI](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer). 5 | 6 | ![Screenshot](img/screenshot.png) 7 | 8 | > This project is a fork from [recca0120/vscode-phpunit](https://github.com/recca0120/vscode-phpunit) ❤️ 9 | 10 | ## Features 11 | 12 | - Shows a Test Explorer in the Test view in VS Code's sidebar with all detected tests and suites and their state 13 | - Adds CodeLenses to your test files for starting and debugging tests 14 | - Adds Gutter decorations to your test files showing the tests' state 15 | - Adds line decorations to the source line where a test failed 16 | - Shows a failed test's log when the test is selected in the explorer 17 | - Lets you choose test suites or individual tests in the explorer that should be run automatically after each file change 18 | - Forwards the console output from PHPUnit to a VS Code output channel 19 | - Run tests into the docker container 20 | 21 | ## Getting started 22 | 23 | - Install the extension 24 | - Restart VS Code and open the Test view 25 | - Run your tests using the ![Run](img/run.png) icons in the Test Explorer or the CodeLenses in your test file 26 | - For running tests on a docker container see the configuration 27 | - For running phpunit on a remote system or using vagrant see Troubleshooting 28 | 29 | ## Configuration 30 | 31 | ### Custom debugger configuration 32 | 33 | ### Other options 34 | 35 | | Property | Description | 36 | | ------------------------------- | --------------------------------------------------------------------------- | 37 | | `testExplorer.codeLens` | Show a CodeLens above each test or suite for running or debugging the tests | 38 | | `testExplorer.gutterDecoration` | Show the state of each test in the editor using Gutter Decorations | 39 | | `testExplorer.onStart` | Retire or reset all test states whenever a test run is started | 40 | | `testExplorer.onReload` | Retire or reset all test states whenever the test tree is reloaded | 41 | 42 | ## Commands 43 | 44 | The following commands are available in VS Code's command palette, use the ID to add them to your keyboard shortcuts: 45 | 46 | | ID | Command | 47 | | ---------------------------------- | ------------------------------------------- | 48 | | `test-explorer.reload` | Reload tests | 49 | | `test-explorer.run-all` | Run all tests | 50 | | `test-explorer.run-file` | Run tests in current file | 51 | | `test-explorer.run-test-at-cursor` | Run the test at the current cursor position | 52 | | `test-explorer.cancel` | Cancel running tests | 53 | 54 | ## Troubleshooting 55 | 56 | ### All tests are appearing twice with "\*** duplicate ID **\*" found. 57 | 58 | You likely have a file sensitive file system. The default value of `"phpunit.files": "{test,tests,Test,Tests}/**/*Test.php",` should be changed to `"phpunit.files": "{test,tests}/**/*Test.php",` 59 | 60 | ### I'm using Vagrant / Homestead for remote execution 61 | 62 | Your tests will list in the explorer for reading locally, but you need to run your tests remotely using PHP on your vagrant machine. 63 | 64 | To do this you need to configure the following settings: 65 | 66 | `"phpunit.relativeFilePath": true,` - This will ensure your files are located correctly for local checks 67 | 68 | `"phpunit.phpunit": "/FULL_PATH_TO/vendor/bin/phpunit",` - your remote path, likely in `/var/www/html/` 69 | 70 | `"phpunit.discoverConfigFile": false,` if true the extension automatically try to find phpunit.xml or phpunit.xml.dist in you project. 71 | 72 | `"phpunit.configFile": "/FULL_PATH_TO/phpunit.xml", ` path to phpunit.xml or phpunit.xml.dist file. If this config is specified the `"phpunit.discoverConfigFile"` automatically turns to `false` 73 | 74 | `"phpunit.php": "/usr/local/bin/vagrant exec php",` this is to execute PHP on the remote machine. You'll need to install and configure https://github.com/p0deje/vagrant-exec - this wraps the command like using `ssh -C` 75 | 76 | You can also use the method above to execute on Docker remotely 77 | 78 | `"phpunit.docker": true, ` this is to enable run the tests into the docker container 79 | 80 | `"phpunit.dockerImage": "docker run --rm -v $(pwd):$(pwd) -w=$(pwd) php:8", ` this is to use the docker container to run the tests 81 | 82 | ### My `/usr/local/bin/vagrant` isn't found? 83 | 84 | Spawn likely can't find vagrant locally. You need to switch to using your regular terminal using something like `"phpunit.shell": "/bin/bash",` or `"phpunit.shell": "/bin/zsh",` 85 | 86 | ## Wallaby.js 87 | 88 | [![Wallaby.js](https://img.shields.io/badge/wallaby.js-powered-blue.svg?style=for-the-badge&logo=github)](https://wallabyjs.com/oss/) 89 | 90 | This repository contributors are welcome to use 91 | [Wallaby.js OSS License](https://wallabyjs.com/oss/) to get 92 | test results immediately as you type, and see the results in 93 | your editor right next to your code. 94 | -------------------------------------------------------------------------------- /client/__mocks__/vscode.ts: -------------------------------------------------------------------------------- 1 | const commands = { 2 | executeCommand: jest.fn(), 3 | registerTextEditorCommand: jest.fn(), 4 | }; 5 | 6 | const workspace = { 7 | getConfiguration: () => { 8 | return { 9 | get: function() {}, 10 | }; 11 | }, 12 | onDidChangeConfiguration: () => {}, 13 | }; 14 | 15 | export { workspace, commands }; 16 | 17 | export enum ProgressLocation { 18 | Notification = 15, 19 | } 20 | 21 | export class Uri { 22 | static parse(path: string) { 23 | return path; 24 | } 25 | } 26 | 27 | export class EventEmitter { 28 | fire(...args: any[]) { 29 | return args; 30 | } 31 | dispose() {} 32 | } 33 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lsp-phpunit-client", 3 | "description": "VSCode part of a language server", 4 | "author": "renandelmonico", 5 | "license": "MIT", 6 | "version": "0.0.1", 7 | "publisher": "renandelmonico", 8 | "contributors": [ 9 | "recca0120" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/renandelmonico/vscode-phpunit" 14 | }, 15 | "engines": { 16 | "vscode": "^1.30.0" 17 | }, 18 | "scripts": { 19 | "update-vscode": "vscode-install" 20 | }, 21 | "dependencies": { 22 | "md5": "^2.3.0", 23 | "vscode-languageclient": "^5.2.1", 24 | "vscode-test-adapter-api": "^1.9.0", 25 | "vscode-test-adapter-util": "^0.7.1" 26 | }, 27 | "devDependencies": { 28 | "@types/md5": "^2.3.1", 29 | "diff": "^4.0.2", 30 | "vscode": "^1.1.37" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/Configuration.ts: -------------------------------------------------------------------------------- 1 | import { workspace } from 'vscode'; 2 | 3 | export class Configuration { 4 | constructor(private _workspace = workspace) {} 5 | 6 | get clearOutputOnRun() { 7 | return this.get('clearOutputOnRun', true); 8 | } 9 | 10 | get showAfterExecution() { 11 | return this.get('showAfterExecution', 'onFailure'); 12 | } 13 | 14 | get(property: string, defaultValue?: any) { 15 | return this._workspace 16 | .getConfiguration('phpunit') 17 | .get(property, defaultValue); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/LanguageClientAdapter.ts: -------------------------------------------------------------------------------- 1 | import md5 from 'md5'; 2 | import { Event, EventEmitter, WorkspaceFolder } from 'vscode'; 3 | import { LanguageClient } from 'vscode-languageclient'; 4 | import { Log } from 'vscode-test-adapter-util'; 5 | import { 6 | TestAdapter, 7 | TestLoadStartedEvent, 8 | TestLoadFinishedEvent, 9 | TestRunStartedEvent, 10 | TestRunFinishedEvent, 11 | TestSuiteEvent, 12 | TestEvent, 13 | RetireEvent, 14 | } from 'vscode-test-adapter-api'; 15 | 16 | export class LanguageClientAdapter implements TestAdapter { 17 | private disposables: { dispose(): void }[] = []; 18 | 19 | private readonly testsEmitter = new EventEmitter< 20 | TestLoadStartedEvent | TestLoadFinishedEvent 21 | >(); 22 | 23 | private readonly testStatesEmitter = new EventEmitter< 24 | TestRunStartedEvent | TestRunFinishedEvent | TestSuiteEvent | TestEvent 25 | >(); 26 | 27 | private readonly retireEmitter = new EventEmitter(); 28 | 29 | get tests(): Event { 30 | return this.testsEmitter.event; 31 | } 32 | 33 | get testStates(): Event< 34 | TestRunStartedEvent | TestRunFinishedEvent | TestSuiteEvent | TestEvent 35 | > { 36 | return this.testStatesEmitter.event; 37 | } 38 | 39 | get retire(): Event { 40 | return this.retireEmitter.event; 41 | } 42 | 43 | constructor( 44 | public workspaceFolder: WorkspaceFolder, 45 | private client: LanguageClient, 46 | private log: Log 47 | ) { 48 | this.onTestLoadStartedEvent(); 49 | this.onTestLoadFinishedEvent(); 50 | this.onTestRunStartedEvent(); 51 | this.onTestRunFinishedEvent(); 52 | this.onTestRetryEvent(); 53 | 54 | this.disposables.push(this.testsEmitter); 55 | this.disposables.push(this.testStatesEmitter); 56 | this.disposables.push(this.retireEmitter); 57 | } 58 | 59 | public requestName(name: string) { 60 | return [name, md5(this.workspaceFolder.uri.toString())].join('-'); 61 | } 62 | 63 | async load(): Promise { 64 | await this.client.onReady(); 65 | 66 | this.client.sendNotification(this.requestName('TestLoadStartedEvent')); 67 | } 68 | 69 | async run(tests: string[]): Promise { 70 | await this.client.onReady(); 71 | 72 | this.client.sendNotification(this.requestName('TestRunStartedEvent'), { 73 | tests, 74 | }); 75 | } 76 | 77 | // debug?(tests: string[]): Promise { 78 | // console.log(tests); 79 | // throw new Error('Method not implemented.'); 80 | // } 81 | 82 | async cancel() { 83 | await this.client.onReady(); 84 | 85 | this.client.sendNotification(this.requestName('TestCancelEvent')); 86 | } 87 | 88 | async dispose(): Promise { 89 | await this.cancel(); 90 | for (const disposable of this.disposables) { 91 | disposable.dispose(); 92 | } 93 | this.disposables = []; 94 | } 95 | 96 | private async onTestLoadStartedEvent() { 97 | await this.client.onReady(); 98 | 99 | this.client.onRequest(this.requestName('TestLoadStartedEvent'), () => 100 | this.testsEmitter.fire({ type: 'started' }) 101 | ); 102 | } 103 | 104 | private async onTestLoadFinishedEvent() { 105 | await this.client.onReady(); 106 | 107 | this.client.onRequest( 108 | this.requestName('TestLoadFinishedEvent'), 109 | ({ suite }) => { 110 | this.testsEmitter.fire({ 111 | type: 'finished', 112 | suite: suite, 113 | }); 114 | } 115 | ); 116 | } 117 | 118 | private async onTestRunStartedEvent() { 119 | await this.client.onReady(); 120 | 121 | this.client.onRequest( 122 | this.requestName('TestRunStartedEvent'), 123 | ({ tests, events }) => { 124 | this.testStatesEmitter.fire({ 125 | type: 'started', 126 | tests, 127 | }); 128 | 129 | this.updateEvents(events); 130 | } 131 | ); 132 | } 133 | 134 | private async onTestRunFinishedEvent() { 135 | await this.client.onReady(); 136 | 137 | this.client.onRequest( 138 | this.requestName('TestRunFinishedEvent'), 139 | ({ events, command }) => { 140 | this.log.info(command); 141 | this.updateEvents(events); 142 | 143 | this.testStatesEmitter.fire({ 144 | type: 'finished', 145 | }); 146 | } 147 | ); 148 | } 149 | 150 | private async onTestRetryEvent() { 151 | await this.client.onReady(); 152 | 153 | this.client.onRequest(this.requestName('TestRetryEvent'), () => { 154 | this.retireEmitter.fire(); 155 | }); 156 | } 157 | 158 | private updateEvents(events: (TestSuiteEvent | TestEvent)[]): void { 159 | events.forEach(event => { 160 | event.type === 'suite' 161 | ? this.testStatesEmitter.fire(event) 162 | : this.testStatesEmitter.fire(event); 163 | }); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /client/src/LanguageClientController.ts: -------------------------------------------------------------------------------- 1 | import { commands, Disposable, OutputChannel, TextEditor } from 'vscode'; 2 | import { Configuration } from './Configuration'; 3 | import { ExecuteCommandRequest } from 'vscode-languageserver-protocol'; 4 | import { LanguageClient } from 'vscode-languageclient'; 5 | import { TestEvent } from 'vscode-test-adapter-api'; 6 | import { Notify } from './Notify'; 7 | 8 | export class LanguageClientController implements Disposable { 9 | private disposables: Disposable[] = []; 10 | 11 | constructor( 12 | private client: LanguageClient, 13 | private config: Configuration, 14 | private outputChannel: OutputChannel, 15 | private notify: Notify, 16 | private _commands = commands 17 | ) {} 18 | 19 | init() { 20 | this.runAll(); 21 | this.rerun(); 22 | this.runFile(); 23 | this.runTestAtCursor(); 24 | this.cancel(); 25 | this.onTestRunStartedEvent(); 26 | this.onTestRunFinishedEvent(); 27 | 28 | return this; 29 | } 30 | 31 | dispose() { 32 | for (const disposable of this.disposables) { 33 | disposable.dispose(); 34 | } 35 | this.disposables = []; 36 | } 37 | 38 | private async onTestRunStartedEvent() { 39 | await this.client.onReady(); 40 | 41 | this.client.onNotification('TestRunStartedEvent', () => { 42 | this.notify.show('PHPUnit Running'); 43 | 44 | if (this.config.clearOutputOnRun === true) { 45 | this.outputChannel.clear(); 46 | } 47 | }); 48 | } 49 | 50 | private async onTestRunFinishedEvent() { 51 | await this.client.onReady(); 52 | 53 | this.client.onNotification('TestRunFinishedEvent', ({ events }) => { 54 | this.notify.hide(); 55 | 56 | const showAfterExecution = this.config.showAfterExecution; 57 | 58 | const hasFailure = (events: TestEvent[]) => { 59 | return events.some(event => 60 | ['failed', 'errored'].includes(event.state) 61 | ); 62 | }; 63 | 64 | if (showAfterExecution === 'never') { 65 | return; 66 | } 67 | 68 | if (showAfterExecution === 'always' || hasFailure(events)) { 69 | this.outputChannel.show(true); 70 | } 71 | }); 72 | } 73 | 74 | private runAll() { 75 | this.registerCommand('phpunit.run-all'); 76 | } 77 | 78 | private rerun() { 79 | this.registerCommand('phpunit.rerun'); 80 | } 81 | 82 | private runFile() { 83 | this.registerCommand('phpunit.run-file'); 84 | } 85 | 86 | private runTestAtCursor() { 87 | this.registerCommand('phpunit.run-test-at-cursor'); 88 | } 89 | 90 | private cancel() { 91 | this.registerCommand('phpunit.cancel'); 92 | } 93 | 94 | private registerCommand(command: string) { 95 | this.disposables.push( 96 | this._commands.registerTextEditorCommand( 97 | command, 98 | async textEditor => { 99 | await this.client.onReady(); 100 | 101 | if (this.isValidTextEditor(textEditor) === false) { 102 | return; 103 | } 104 | 105 | const document = textEditor.document; 106 | 107 | this.client.sendRequest(ExecuteCommandRequest.type, { 108 | command: command.replace(/^phpunit/, 'phpunit.lsp'), 109 | arguments: [ 110 | document.uri.toString(), 111 | document.uri.toString(), 112 | textEditor.selection.active.line, 113 | ], 114 | }); 115 | } 116 | ) 117 | ); 118 | } 119 | 120 | private isValidTextEditor(editor: TextEditor): boolean { 121 | if (!editor || !editor.document) { 122 | return false; 123 | } 124 | 125 | return editor.document.languageId === 'php'; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /client/src/Notify.ts: -------------------------------------------------------------------------------- 1 | import { Progress, ProgressLocation, window } from 'vscode'; 2 | 3 | interface ProgressOptions { 4 | message?: string; 5 | increment?: number; 6 | } 7 | 8 | export class Notify { 9 | private progress: Progress | null = null; 10 | protected promise: Promise | null = null; 11 | private resolve: Function | null = null; 12 | // private token?: CancellationToken; 13 | 14 | constructor(private _window = window) {} 15 | 16 | show(title: string, cancellable = false) { 17 | this._window.withProgress( 18 | { 19 | location: ProgressLocation.Notification, 20 | title: title, 21 | cancellable: cancellable, 22 | }, 23 | // (progress: Progress, token: CancellationToken) => { 24 | (progress: Progress) => { 25 | this.progress = progress; 26 | // this.token = token; 27 | 28 | return (this.promise = new Promise( 29 | (resolve: Function) => (this.resolve = resolve) 30 | )); 31 | } 32 | ); 33 | } 34 | 35 | report(options: ProgressOptions) { 36 | this.progress!.report(options); 37 | 38 | return this; 39 | } 40 | 41 | hide() { 42 | if (this.resolve) { 43 | this.resolve(); 44 | } 45 | 46 | this.progress = null; 47 | this.resolve = null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/extension.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from './Configuration'; 2 | import * as path from 'path'; 3 | import { 4 | ExtensionContext, 5 | window, 6 | workspace, 7 | extensions, 8 | commands, 9 | } from 'vscode'; 10 | import { 11 | LanguageClient, 12 | LanguageClientOptions, 13 | ServerOptions, 14 | TransportKind, 15 | // WillSaveTextDocumentWaitUntilRequest, 16 | } from 'vscode-languageclient'; 17 | import { TestHub, testExplorerExtensionId } from 'vscode-test-adapter-api'; 18 | import { TestAdapterRegistrar, Log } from 'vscode-test-adapter-util'; 19 | import { LanguageClientAdapter } from './LanguageClientAdapter'; 20 | import { LanguageClientController } from './LanguageClientController'; 21 | import { Notify } from './Notify'; 22 | // import { SocketOutputChannel } from './SocketOutputChannel'; 23 | // import { Notify } from './Notify'; 24 | 25 | let client: LanguageClient; 26 | export function activate(context: ExtensionContext) { 27 | const outputChannel = window.createOutputChannel('PHPUnit Language Server'); 28 | 29 | // The server is implemented in node 30 | let serverModule = context.asAbsolutePath( 31 | path.join('server', 'out', 'server.js') 32 | ); 33 | // The debug options for the server 34 | // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging 35 | let debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] }; 36 | 37 | // If the extension is launched in debug mode then the debug server options are used 38 | // Otherwise the run options are used 39 | let serverOptions: ServerOptions = { 40 | run: { module: serverModule, transport: TransportKind.ipc }, 41 | debug: { 42 | module: serverModule, 43 | transport: TransportKind.ipc, 44 | options: debugOptions, 45 | }, 46 | }; 47 | 48 | // Options to control the language client 49 | let clientOptions: LanguageClientOptions = { 50 | // Register the server for plain text documents 51 | documentSelector: [{ scheme: 'file', language: 'php' }], 52 | synchronize: { 53 | // Notify the server about file changes to '.clientrc files contained in the workspace 54 | fileEvents: workspace.createFileSystemWatcher('**/*.php'), 55 | }, 56 | // Hijacks all LSP logs and redirect them to a specific port through WebSocket connection 57 | // outputChannel: websocketOutputChannel, 58 | outputChannel, 59 | middleware: { 60 | provideCodeLenses: () => { 61 | return null; 62 | }, 63 | }, 64 | }; 65 | 66 | // Create the language client and start the client. 67 | client = new LanguageClient( 68 | 'phpunit', 69 | 'PHPUnit Language Server', 70 | serverOptions, 71 | clientOptions 72 | ); 73 | 74 | const config = new Configuration(workspace); 75 | const notify = new Notify(); 76 | const controller = new LanguageClientController( 77 | client, 78 | config, 79 | outputChannel, 80 | notify, 81 | commands 82 | ); 83 | 84 | context.subscriptions.push(controller.init()); 85 | 86 | const workspaceFolder = (workspace.workspaceFolders || [])[0]; 87 | const log = new Log('phpunit', workspaceFolder, 'PHPUnit Test Explorer'); 88 | context.subscriptions.push(log); 89 | 90 | const testExplorerExtension = extensions.getExtension( 91 | testExplorerExtensionId 92 | ); 93 | 94 | if (testExplorerExtension) { 95 | const testHub = testExplorerExtension.exports; 96 | 97 | // this will register an ExampleTestAdapter for each WorkspaceFolder 98 | context.subscriptions.push( 99 | new TestAdapterRegistrar( 100 | testHub, 101 | workspaceFolder => 102 | new LanguageClientAdapter(workspaceFolder, client, log), 103 | log 104 | ) 105 | ); 106 | } 107 | 108 | // Start the client. This will also launch the server 109 | client.start(); 110 | } 111 | 112 | export function deactivate(): Thenable | undefined { 113 | if (!client) { 114 | return undefined; 115 | } 116 | 117 | return client.stop(); 118 | } 119 | -------------------------------------------------------------------------------- /client/syntaxes/phpunit.tmLanguage: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | scopeName 6 | code.log 7 | fileTypes 8 | 9 | log 10 | 11 | name 12 | Phpunit file 13 | patterns 14 | 15 | 16 | 17 | match 18 | \b(?i:(([a-z]|[0-9]|[_|-])*(\.([a-z]|[0-9]|[_|-])*)+))\b 19 | name 20 | support.type 21 | 22 | 23 | match 24 | \b(?i:(local))(\:|\b) 25 | name 26 | support.function 27 | 28 | 29 | 30 | match 31 | \b(?i:([a-z]|[0-9])+\:((\/\/)|((\/\/)?(\S)))+) 32 | name 33 | storage 34 | 35 | 36 | 39 | 40 | include 41 | source.diff 42 | 43 | 46 | 47 | begin 48 | ^(?=(\.|E|F|I|R|S)+\s+) 49 | end 50 | \s+(\d+\s+\/\s+\d+\s+\(\s*\d+\%\))?$ 51 | patterns 52 | 53 | 54 | match 55 | \. 56 | name 57 | strong 58 | 59 | 60 | match 61 | E 62 | name 63 | markup.deleted 64 | 65 | 66 | match 67 | F 68 | name 69 | markup.deleted 70 | 71 | 72 | match 73 | I 74 | name 75 | markup.changed 76 | 77 | 78 | match 79 | R 80 | name 81 | markup.changed 82 | 83 | 84 | match 85 | S 86 | name 87 | markup.changed 88 | 89 | 90 | 91 | 92 | 95 | 96 | match 97 | ^(?:\e\[[0-9;]+m)?No tests executed\!(?:\e\[[0-9;]+m)?$ 98 | name 99 | markup.changed 100 | 101 | 102 | 105 | 106 | match 107 | ^(?:\e\[[0-9;]+m)?OK \(\d+ test(?:s)?, \d+ assertion(?:s)?\)(?:\e\[[0-9;]+m)?$ 108 | name 109 | markup.inserted 110 | 111 | 112 | 115 | 116 | match 117 | ^(?:\e\[[0-9;]+m)?OK, but incomplete, skipped, or risky tests\!(?:\e\[[0-9;]+m)?$ 118 | name 119 | markup.changed 120 | 121 | 122 | match 123 | ^(?:\e\[[0-9;]+m)?Tests\: \d+, Assertions\: \d+(?:, (?:Incomplete|Skipped|Risky)\: \d+)+\.(?:\e\[[0-9;]+m)?$ 124 | name 125 | markup.changed 126 | 127 | 128 | 131 | 132 | match 133 | ^(?:\e\[[0-9;]+m)?FAILURES\!(?:\e\[[0-9;]+m\s*)?$ 134 | name 135 | markup.deleted 136 | 137 | 138 | match 139 | ^(?:\e\[[0-9;]+m)?Tests\: \d+, Assertions\: \d+(?:, (?:Errors|Failures|Skipped|Incomplete|Risky)\: \d+)+\.(?:\e\[[0-9;]+m)?$ 140 | name 141 | markup.deleted 142 | 143 | 144 | uuid 145 | bceb84e7-6acb-408e-a937-1e2219695ade 146 | 147 | -------------------------------------------------------------------------------- /client/tests/LanguageClientAdapter.test.ts: -------------------------------------------------------------------------------- 1 | import { LanguageClientAdapter } from './../src/LanguageClientAdapter'; 2 | import { Log } from 'vscode-test-adapter-util'; 3 | import { Uri, WorkspaceFolder } from 'vscode'; 4 | 5 | describe('LanguageClientAdapterTest', () => { 6 | const workspaceFolder: WorkspaceFolder = { 7 | uri: Uri.parse(__dirname), 8 | name: 'folder', 9 | index: 1, 10 | }; 11 | 12 | const log = new Log('phpunit', workspaceFolder, 'PHPUnit TestExplorer'); 13 | 14 | const client: any = { 15 | notifications: {}, 16 | requests: {}, 17 | onReady: () => Promise.resolve(true), 18 | onRequest: (name: string, cb: Function) => { 19 | client.requests[name] = cb; 20 | }, 21 | triggerRequest: (name: string, params?: any) => { 22 | return client.requests[name](params); 23 | }, 24 | sendRequest: () => {}, 25 | onNotification: (name: string, cb: Function) => { 26 | client.notifications[name] = cb; 27 | }, 28 | sendNotification: () => {}, 29 | }; 30 | 31 | let adapter: LanguageClientAdapter; 32 | 33 | beforeEach(() => { 34 | adapter = new LanguageClientAdapter(workspaceFolder, client, log); 35 | }); 36 | 37 | it('load', async () => { 38 | spyOn(client, 'sendNotification'); 39 | 40 | await adapter.load(); 41 | 42 | expect(client.sendNotification).toHaveBeenCalledWith( 43 | adapter.requestName('TestLoadStartedEvent') 44 | ); 45 | }); 46 | 47 | it('run', async () => { 48 | const tests = ['foo', 'bar']; 49 | spyOn(client, 'sendNotification'); 50 | 51 | await adapter.run(tests); 52 | 53 | expect(client.sendNotification).toHaveBeenCalledWith( 54 | adapter.requestName('TestRunStartedEvent'), 55 | { 56 | tests, 57 | } 58 | ); 59 | }); 60 | 61 | it('cancel', async () => { 62 | spyOn(client, 'sendNotification'); 63 | 64 | await adapter.cancel(); 65 | 66 | expect(client.sendNotification).toHaveBeenCalledWith( 67 | adapter.requestName('TestCancelEvent') 68 | ); 69 | }); 70 | 71 | it('dispose', async () => { 72 | spyOn(client, 'sendNotification'); 73 | 74 | await adapter.dispose(); 75 | 76 | expect(client.sendNotification).toHaveBeenCalledWith( 77 | adapter.requestName('TestCancelEvent') 78 | ); 79 | }); 80 | 81 | it('test load started event', () => { 82 | const testsEmitter = adapter['testsEmitter']; 83 | spyOn(testsEmitter, 'fire'); 84 | 85 | client.triggerRequest(adapter.requestName('TestLoadStartedEvent')); 86 | 87 | expect(testsEmitter.fire).toHaveBeenCalledWith({ type: 'started' }); 88 | }); 89 | 90 | it('test load finished event', () => { 91 | const testsEmitter = adapter['testsEmitter']; 92 | spyOn(testsEmitter, 'fire'); 93 | const fooSuite = { 94 | foo: 'bar', 95 | }; 96 | 97 | client.triggerRequest(adapter.requestName('TestLoadFinishedEvent'), { 98 | suite: fooSuite, 99 | }); 100 | 101 | expect(testsEmitter.fire).toHaveBeenCalledWith({ 102 | type: 'finished', 103 | suite: fooSuite, 104 | }); 105 | }); 106 | 107 | it('test run started event', () => { 108 | const testStatesEmitter = adapter['testStatesEmitter']; 109 | spyOn(testStatesEmitter, 'fire'); 110 | const tests = ['foo']; 111 | const fooEvent = { 112 | type: 'suite', 113 | state: 'fail', 114 | }; 115 | 116 | client.triggerRequest(adapter.requestName('TestRunStartedEvent'), { 117 | tests: tests, 118 | events: [fooEvent], 119 | }); 120 | 121 | expect(testStatesEmitter.fire).toHaveBeenCalledWith({ 122 | type: 'started', 123 | tests, 124 | }); 125 | 126 | expect(testStatesEmitter.fire).toHaveBeenCalledWith(fooEvent); 127 | }); 128 | 129 | it('test run finished event', () => { 130 | const testStatesEmitter = adapter['testStatesEmitter']; 131 | spyOn(testStatesEmitter, 'fire'); 132 | const fooCommand = { 133 | title: '', 134 | command: 'foo', 135 | }; 136 | const fooEvent = { 137 | type: 'test', 138 | state: 'fail', 139 | }; 140 | 141 | client.triggerRequest(adapter.requestName('TestRunFinishedEvent'), { 142 | events: [fooEvent], 143 | command: fooCommand, 144 | }); 145 | 146 | expect(testStatesEmitter.fire).toHaveBeenCalledWith(fooEvent); 147 | expect(testStatesEmitter.fire).toHaveBeenCalledWith({ 148 | type: 'finished', 149 | }); 150 | }); 151 | 152 | it('test retry event', async () => { 153 | const retireEmitter = adapter['retireEmitter']; 154 | spyOn(retireEmitter, 'fire'); 155 | 156 | client.triggerRequest(adapter.requestName('TestRetryEvent')); 157 | 158 | expect(retireEmitter.fire).toHaveBeenCalled(); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /client/tests/LanguageClientController.test.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'vscode-languageclient'; 2 | import { Configuration } from '../src/Configuration'; 3 | import { LanguageClientController } from '../src/LanguageClientController'; 4 | import { Notify } from '../src/Notify'; 5 | 6 | describe('LanguageClientController', () => { 7 | const config = { 8 | get: () => {}, 9 | }; 10 | 11 | const workspace: any = { 12 | getConfiguration: () => { 13 | return config; 14 | }, 15 | }; 16 | const outputChannel: any = { 17 | clear: () => {}, 18 | show: () => {}, 19 | }; 20 | 21 | const notify = new Notify(); 22 | 23 | const commands: any = { 24 | commands: {}, 25 | registerTextEditorCommand: (name: string, cb: Function) => { 26 | commands.commands[name] = cb; 27 | 28 | return { 29 | dispose: () => {}, 30 | }; 31 | }, 32 | }; 33 | 34 | const textEditor = { 35 | document: { 36 | uri: 'foo.php', 37 | languageId: 'php', 38 | }, 39 | selection: { 40 | active: { 41 | line: 0, 42 | character: 0, 43 | }, 44 | }, 45 | }; 46 | 47 | const client: any = { 48 | notifications: {}, 49 | requests: {}, 50 | onReady: () => { 51 | return Promise.resolve(true); 52 | }, 53 | onNotification: (name: string, cb: Function) => { 54 | client.notifications[name] = cb; 55 | }, 56 | triggerNotification: (name: string, params?: any) => { 57 | client.notifications[name](params); 58 | }, 59 | sendRequest: (_type: any, command: Command) => { 60 | client.requests[command.command] = command; 61 | }, 62 | triggerCommand: async (name: string) => { 63 | await commands.commands[name](textEditor); 64 | 65 | return client.requests[name.replace(/phpunit\./, 'phpunit.lsp.')]; 66 | }, 67 | }; 68 | 69 | const configuration = new Configuration(workspace); 70 | 71 | let controller: LanguageClientController; 72 | 73 | beforeEach(() => { 74 | controller = new LanguageClientController( 75 | client, 76 | configuration, 77 | outputChannel, 78 | notify, 79 | commands 80 | ); 81 | controller.init(); 82 | }); 83 | 84 | it('execute run all', async () => { 85 | expect(await client.triggerCommand('phpunit.run-all')).toEqual({ 86 | command: 'phpunit.lsp.run-all', 87 | arguments: ['foo.php', 'foo.php', 0], 88 | }); 89 | }); 90 | 91 | it('execute rerun', async () => { 92 | expect(await client.triggerCommand('phpunit.rerun')).toEqual({ 93 | command: 'phpunit.lsp.rerun', 94 | arguments: ['foo.php', 'foo.php', 0], 95 | }); 96 | }); 97 | 98 | it('execute run file', async () => { 99 | expect(await client.triggerCommand('phpunit.run-file')).toEqual({ 100 | command: 'phpunit.lsp.run-file', 101 | arguments: ['foo.php', 'foo.php', 0], 102 | }); 103 | }); 104 | 105 | it('execute run test at cursor', async () => { 106 | expect( 107 | await client.triggerCommand('phpunit.run-test-at-cursor') 108 | ).toEqual({ 109 | command: 'phpunit.lsp.run-test-at-cursor', 110 | arguments: ['foo.php', 'foo.php', 0], 111 | }); 112 | }); 113 | 114 | it('execute cancel', async () => { 115 | expect(await client.triggerCommand('phpunit.cancel')).toEqual({ 116 | command: 'phpunit.lsp.cancel', 117 | arguments: ['foo.php', 'foo.php', 0], 118 | }); 119 | }); 120 | 121 | it('dispose', () => { 122 | controller.dispose(); 123 | 124 | expect(controller['disposables']).toEqual([]); 125 | }); 126 | 127 | it('run test and clear outputChannel', () => { 128 | spyOn(config, 'get').and.returnValue(true); 129 | spyOn(outputChannel, 'clear'); 130 | spyOn(notify, 'show'); 131 | 132 | client.triggerNotification('TestRunStartedEvent'); 133 | 134 | expect(outputChannel.clear).toHaveBeenCalled(); 135 | expect(notify.show).toHaveBeenCalled(); 136 | }); 137 | 138 | it('show outputChanel when has error', () => { 139 | spyOn(config, 'get').and.returnValue('onFailure'); 140 | spyOn(outputChannel, 'show'); 141 | spyOn(notify, 'hide'); 142 | 143 | const params = { 144 | command: { 145 | title: '', 146 | command: 'foo', 147 | }, 148 | events: [ 149 | { 150 | state: 'failed', 151 | }, 152 | ], 153 | }; 154 | 155 | client.triggerNotification('TestRunFinishedEvent', params); 156 | 157 | expect(outputChannel.show).toHaveBeenCalled(); 158 | expect(notify.hide).toHaveBeenCalled(); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /client/tests/Notify.test.ts: -------------------------------------------------------------------------------- 1 | import { Notify } from '../src/Notify'; 2 | 3 | class StubNotify extends Notify { 4 | public getResult() { 5 | return this.promise; 6 | } 7 | } 8 | 9 | describe('Notify', () => { 10 | it('show and hide', async () => { 11 | const window: any = { 12 | withProgress: () => {}, 13 | }; 14 | const progress: any = {}; 15 | const token: any = {}; 16 | 17 | spyOn(window, 'withProgress').and.callFake((...args) => { 18 | args[1](progress, token); 19 | }); 20 | 21 | const notify = new StubNotify(window); 22 | notify.show('testing'); 23 | notify.hide(); 24 | 25 | const result = await notify.getResult(); 26 | 27 | expect(result).toBeUndefined(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es6", 6 | "strict": true, 7 | "outDir": "out", 8 | "rootDir": "src", 9 | "sourceMap": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["node_modules", "__mocks__"] 13 | } 14 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | //@ts-check 7 | 8 | 'use strict'; 9 | 10 | const withDefaults = require('../shared.webpack.config'); 11 | const path = require('path'); 12 | 13 | module.exports = withDefaults({ 14 | context: path.join(__dirname), 15 | entry: { 16 | extension: './src/extension.ts', 17 | }, 18 | output: { 19 | filename: 'extension.js', 20 | path: path.join(__dirname, 'out'), 21 | } 22 | }); -------------------------------------------------------------------------------- /codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '20 11 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renandelmonico/vscode-phpunit/c8a0476e68c1fe3593a1fe4147f6a9aa8a6e167f/img/icon.png -------------------------------------------------------------------------------- /img/run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renandelmonico/vscode-phpunit/c8a0476e68c1fe3593a1fe4147f6a9aa8a6e167f/img/run.png -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renandelmonico/vscode-phpunit/c8a0476e68c1fe3593a1fe4147f6a9aa8a6e167f/img/screenshot.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/client", 4 | "/server" 5 | ], 6 | "transform": { 7 | "^.+\\.tsx?$": "ts-jest" 8 | }, 9 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$" 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-php-test-explorer", 3 | "description": "PHP: Unit Test Explorer UI", 4 | "displayName": "PHP: Unit Test Explorer UI", 5 | "icon": "img/icon.png", 6 | "author": "renandelmonico", 7 | "license": "MIT", 8 | "version": "1.0.2", 9 | "preview": true, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/renandelmonico/vscode-phpunit" 13 | }, 14 | "publisher": "renandelmonico", 15 | "contributors": [ 16 | "recca0120" 17 | ], 18 | "categories": [ 19 | "Other", 20 | "Debuggers", 21 | "Programming Languages" 22 | ], 23 | "keywords": [ 24 | "test", 25 | "testing", 26 | "test explorer", 27 | "phpunit", 28 | "language server protocol", 29 | "codeception" 30 | ], 31 | "engines": { 32 | "vscode": "^1.30.0" 33 | }, 34 | "activationEvents": [ 35 | "*" 36 | ], 37 | "extensionDependencies": [ 38 | "hbenl.vscode-test-explorer" 39 | ], 40 | "main": "./client/out/extension", 41 | "contributes": { 42 | "commands": [ 43 | { 44 | "command": "phpunit.run-all", 45 | "title": "PHPUnit: Run all tests" 46 | }, 47 | { 48 | "command": "phpunit.run-file", 49 | "title": "PHPUnit: Run tests in current file" 50 | }, 51 | { 52 | "command": "phpunit.run-test-at-cursor", 53 | "title": "PHPUnit: Run the test at the current cursor position" 54 | }, 55 | { 56 | "command": "phpunit.rerun", 57 | "title": "PHPUnit: Repeat the last test run" 58 | } 59 | ], 60 | "keybindings": [ 61 | { 62 | "key": "cmd+t cmd+f", 63 | "command": "phpunit.run-file", 64 | "when": "editorTextFocus && editorLangId == php" 65 | }, 66 | { 67 | "key": "cmd+t cmd+t", 68 | "command": "phpunit.run-test-at-cursor", 69 | "when": "editorTextFocus && editorLangId == php" 70 | }, 71 | { 72 | "key": "cmd+t cmd+l", 73 | "command": "phpunit.rerun", 74 | "when": "editorTextFocus && editorLangId == php" 75 | }, 76 | { 77 | "key": "cmd+t cmd+d", 78 | "command": "phpunit.run-directory", 79 | "when": "editorTextFocus && editorLangId == php" 80 | }, 81 | { 82 | "key": "cmd+t cmd+s", 83 | "command": "phpunit.run-all" 84 | } 85 | ], 86 | "configuration": { 87 | "type": "object", 88 | "title": "PHP: Unit Test Explorer UI", 89 | "properties": { 90 | "phpunit.maxNumberOfProblems": { 91 | "scope": "resource", 92 | "type": "number", 93 | "default": 100, 94 | "description": "Controls the maximum number of problems produced by the server." 95 | }, 96 | "phpunit.trace.server": { 97 | "scope": "window", 98 | "type": "string", 99 | "enum": [ 100 | "off", 101 | "messages", 102 | "verbose" 103 | ], 104 | "default": "off", 105 | "description": "Traces the communication between VS Code and the language server." 106 | }, 107 | "phpunit.logpanel": { 108 | "description": "write diagnotic logs to an output panel", 109 | "type": "boolean", 110 | "scope": "resource" 111 | }, 112 | "phpunit.logfile": { 113 | "description": "write diagnostic logs to the given file", 114 | "type": "string", 115 | "scope": "resource" 116 | }, 117 | "phpunit.php": { 118 | "type": "string", 119 | "description": "Absolute path to php. Fallback to global php if it exists on the command line. If docker configuration is true this configuration is ignored." 120 | }, 121 | "phpunit.phpunit": { 122 | "type": "string", 123 | "description": "Path to phpunit. Can be the phpunit file or phpunit.phar.\n\nAutomatically finds it in common places:\n - Composer vendor directory\n - phpunit.phar in your project\n - phpunit (or phpunit.bat for windows) globally on the command line" 124 | }, 125 | "phpunit.discoverConfigFile": { 126 | "type": "boolean", 127 | "default": false, 128 | "description": "If true the extension automatically try to find phpunit.xml or phpunit.xml.dist in you project." 129 | }, 130 | "phpunit.configFile": { 131 | "type": "string", 132 | "description": "Path to phpunit.xml or phpunit.xml.dist file." 133 | }, 134 | "phpunit.args": { 135 | "type": "array", 136 | "default": [], 137 | "description": "Any phpunit args (phpunit --help) E.g. --configuration ./phpunit.xml.dist" 138 | }, 139 | "phpunit.files": { 140 | "description": "The glob(s) describing the location of your test files (relative to the workspace folder)", 141 | "type": "string", 142 | "items": { 143 | "type": "string" 144 | }, 145 | "default": "{test,tests,Test,Tests}/**/*Test.php", 146 | "scope": "resource" 147 | }, 148 | "phpunit.relativeFilePath": { 149 | "description": "File path as relative argument instead of full path.", 150 | "type": "boolean", 151 | "default": false 152 | }, 153 | "phpunit.remoteCwd": { 154 | "description": "Remote file path to the location of the root folder.", 155 | "type": "string", 156 | "default": "" 157 | }, 158 | "phpunit.shell": { 159 | "description": "Shell to be used to call php.", 160 | "type": "string", 161 | "default": "" 162 | }, 163 | "phpunit.clearOutputOnRun": { 164 | "type": "boolean", 165 | "default": true, 166 | "description": "True will clear the output when we run a new test. False will leave the output after every test." 167 | }, 168 | "phpunit.showAfterExecution": { 169 | "type": "string", 170 | "enum": [ 171 | "always", 172 | "onFailure", 173 | "never" 174 | ], 175 | "default": "onFailure", 176 | "description": "Specify if the test report will automatically be shown after execution", 177 | "scope": "application" 178 | }, 179 | "phpunit.docker": { 180 | "type": "boolean", 181 | "default": false 182 | }, 183 | "phpunit.dockerImage": { 184 | "type": "string", 185 | "description": "Specifies the docker run command line" 186 | } 187 | } 188 | }, 189 | "grammars": [ 190 | { 191 | "language": "Log", 192 | "scopeName": "code.log", 193 | "path": "./client/syntaxes/phpunit.tmLanguage" 194 | } 195 | ] 196 | }, 197 | "scripts": { 198 | "vscode:prepublish": "cd client && npm run update-vscode && cd .. && npm run clean && npm run compile", 199 | "postinstall": "cd client && npm install && cd ../server && npm install && cd ..", 200 | "compile": "npm run compile:client && npm run compile:server", 201 | "compile:client": "webpack --mode production --config ./client/webpack.config.js", 202 | "_compile:client": "tsc -b ./client/tsconfig.json", 203 | "compile:server": "webpack --mode production --config ./server/webpack.config.js", 204 | "watch:client": "tsc -b -w ./client/tsconfig.json", 205 | "_watch:client": "webpack --mode none --config ./client/webpack.config.js --watch", 206 | "watch:server": "webpack --mode none --config ./server/webpack.config.js --watch", 207 | "watch": "tsc -b -w", 208 | "clean": "rimraf client/out && rimraf server/out", 209 | "test": "sh ./scripts/e2e.sh", 210 | "jest": "npm run compile && jest", 211 | "jest:watch": "jest --watch", 212 | "report": "jest --coverage", 213 | "report:watch": "jest --coverage --watch" 214 | }, 215 | "devDependencies": { 216 | "@types/jest": "^24.9.1", 217 | "@types/node": "^12.20.41", 218 | "@types/vscode": "^1.30.0", 219 | "jest": "^28.1.0", 220 | "merge-options": "^1.0.1", 221 | "rimraf": "^2.7.1", 222 | "ts-jest": "^28.0.1", 223 | "ts-loader": "^6.2.2", 224 | "tslint": "^5.20.1", 225 | "typescript": "^3.9.10", 226 | "webpack": "^5.72.0", 227 | "webpack-cli": "^3.3.12" 228 | }, 229 | "dependencies": { 230 | "@vscode/test-electron": "^2.0.3" 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /scripts/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export CODE_TESTS_PATH="$(pwd)/client/out/test" 4 | export CODE_TESTS_WORKSPACE="$(pwd)/client/testFixture" 5 | 6 | node "$(pwd)/client/node_modules/vscode/bin/test" -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lsp-phpunit-server", 3 | "description": "PHPUnit Language Server.", 4 | "version": "1.0.0", 5 | "author": "renandelmonico", 6 | "license": "MIT", 7 | "engines": { 8 | "node": "*" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/renandelmonico/vscode-phpunit" 13 | }, 14 | "dependencies": { 15 | "glob": "^7.1.4", 16 | "he": "^1.2.0", 17 | "md5": "^2.2.1", 18 | "php-parser": "^3.0.0-prerelease.8", 19 | "strip-ansi": "^5.2.0", 20 | "vscode-languageserver": "^5.2.1" 21 | }, 22 | "devDependencies": { 23 | "@types/glob": "^7.1.1", 24 | "@types/he": "^1.1.0", 25 | "@types/md5": "^2.1.33", 26 | "@types/strip-ansi": "^5.2.1" 27 | }, 28 | "scripts": {} 29 | } 30 | -------------------------------------------------------------------------------- /server/src/Configuration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Connection, 3 | WorkspaceFolder as _WorkspaceFolder, 4 | } from 'vscode-languageserver'; 5 | 6 | interface IConfiguration { 7 | remoteCwd: string; 8 | shell: string; 9 | maxNumberOfProblems: number; 10 | files: string; 11 | relativeFilePath: boolean; 12 | php?: string; 13 | phpunit?: string; 14 | args?: string[]; 15 | docker?: boolean; 16 | dockerImage?: string; 17 | configFile?: string; 18 | discoverConfigFile: boolean; 19 | } 20 | 21 | export class Configuration implements IConfiguration { 22 | defaults: IConfiguration = { 23 | maxNumberOfProblems: 10000, 24 | files: '**/*.php', 25 | relativeFilePath: false, 26 | shell: '', 27 | remoteCwd: '', 28 | discoverConfigFile: false 29 | }; 30 | 31 | constructor( 32 | private connection: Connection, 33 | private workspaceFolder: _WorkspaceFolder 34 | ) {} 35 | 36 | get maxNumberOfProblems(): number { 37 | return this.defaults.maxNumberOfProblems; 38 | } 39 | 40 | get files(): string { 41 | return this.defaults.files; 42 | } 43 | 44 | get relativeFilePath(): boolean { 45 | return this.defaults.relativeFilePath; 46 | } 47 | 48 | get remoteCwd(): string { 49 | return this.defaults.remoteCwd; 50 | } 51 | 52 | get shell(): string { 53 | return this.defaults.shell; 54 | } 55 | 56 | get php(): string | undefined { 57 | return this.defaults.php; 58 | } 59 | 60 | get phpunit(): string | undefined { 61 | return this.defaults.phpunit; 62 | } 63 | 64 | get args(): string[] | undefined { 65 | return this.defaults.args; 66 | } 67 | 68 | get docker(): boolean | undefined { 69 | return this.defaults.docker; 70 | } 71 | 72 | get dockerImage(): string | undefined { 73 | return this.defaults.dockerImage; 74 | } 75 | 76 | get configFile(): string | undefined { 77 | return this.defaults.configFile; 78 | } 79 | 80 | get discoverConfigFile(): boolean { 81 | return this.defaults.discoverConfigFile; 82 | } 83 | 84 | async update(configurationCapability = true) { 85 | if (configurationCapability) { 86 | this.defaults = await this.connection.workspace.getConfiguration({ 87 | scopeUri: this.workspaceFolder.uri, 88 | section: 'phpunit', 89 | }); 90 | } 91 | 92 | return this; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /server/src/Filesystem.ts: -------------------------------------------------------------------------------- 1 | import glob from 'glob'; 2 | import URI, { setUriThrowOnMissingScheme } from 'vscode-uri'; 3 | import { access, createReadStream, PathLike, readFile, writeFile } from 'fs'; 4 | import { createInterface } from 'readline'; 5 | import { dirname, join } from 'path'; 6 | import { Location, Position, Range } from 'vscode-languageserver-protocol'; 7 | import { SpawnOptions } from 'child_process'; 8 | 9 | setUriThrowOnMissingScheme(false); 10 | 11 | export class Env { 12 | private delimiter = ':'; 13 | public extensions = ['']; 14 | 15 | constructor( 16 | private _paths: string | string[] = process.env.PATH as string, 17 | private platform: string = process.platform as string 18 | ) { 19 | if (this.isWin()) { 20 | this.delimiter = ';'; 21 | this.extensions = ['.bat', '.exe', '.cmd', '']; 22 | } 23 | } 24 | 25 | paths(): string[] { 26 | return this.splitPaths(this._paths); 27 | } 28 | 29 | isWin(): boolean { 30 | return Env.isWindows(this.platform); 31 | } 32 | 33 | private splitPaths(paths: string | string[]): string[] { 34 | if (paths instanceof Array) { 35 | return paths; 36 | } 37 | 38 | return paths 39 | .split(new RegExp(this.delimiter, 'g')) 40 | .map((path: string) => 41 | path.replace(new RegExp(`${this.delimiter}$`, 'g'), '').trim() 42 | ); 43 | } 44 | static isWindows(platform: string = process.platform): boolean { 45 | return /win32|mswin(?!ce)|mingw|bccwin|cygwin/i.test(platform) 46 | ? true 47 | : false; 48 | } 49 | 50 | private static _instance = new Env(); 51 | 52 | static instance() { 53 | return Env._instance; 54 | } 55 | } 56 | 57 | export class Filesystem { 58 | private paths: string[] = []; 59 | private extensions: string[] = []; 60 | private remoteCwd: string = ''; 61 | 62 | constructor(private env: Env = Env.instance()) { 63 | this.paths = this.env.paths(); 64 | this.extensions = this.env.extensions; 65 | } 66 | 67 | public setRemoteCwd(remoteCwd: string) { 68 | this.remoteCwd = remoteCwd; 69 | 70 | return this; 71 | } 72 | 73 | get(uri: PathLike | URI): Promise { 74 | return new Promise((resolve, reject) => { 75 | readFile( 76 | this.asUri(uri).fsPath, 77 | (err: NodeJS.ErrnoException | null, data: Buffer) => 78 | err ? reject(err) : resolve(data.toString()) 79 | ); 80 | }); 81 | } 82 | 83 | put(uri: PathLike | URI, text: string): Promise { 84 | return new Promise(resolve => { 85 | writeFile( 86 | this.asUri(uri).fsPath, 87 | text, 88 | (err: NodeJS.ErrnoException | null) => 89 | resolve(err ? false : true) 90 | ); 91 | }); 92 | } 93 | 94 | exists(uri: PathLike | URI): Promise { 95 | return new Promise(resolve => { 96 | access( 97 | this.asUri(uri).fsPath, 98 | (err: NodeJS.ErrnoException | null) => 99 | resolve(err ? false : true) 100 | ); 101 | }); 102 | } 103 | 104 | dirname(uri: PathLike | URI): string { 105 | return dirname(this.asUri(uri).fsPath); 106 | } 107 | 108 | async find( 109 | search: string | string[], 110 | paths: string[] = [] 111 | ): Promise { 112 | for (let file of this.searchFile(search, paths.concat(this.paths))) { 113 | if (await this.exists(file)) { 114 | return file; 115 | } 116 | } 117 | } 118 | 119 | async which( 120 | search: string | string[], 121 | cwd: string = process.cwd() 122 | ): Promise { 123 | return await this.find(search, [cwd]); 124 | } 125 | 126 | async findup(search: string | string[], options?: SpawnOptions) { 127 | const cwd = options && options.cwd ? options.cwd : process.cwd(); 128 | const paths = [cwd]; 129 | 130 | do { 131 | const current = paths[paths.length - 1]; 132 | const parent = this.dirname(current); 133 | 134 | if (current === parent) { 135 | break; 136 | } 137 | paths.push(parent); 138 | } while (true); 139 | 140 | return await this.find(search, [cwd].concat(paths)); 141 | } 142 | 143 | asUri(uri: PathLike | URI): URI { 144 | if (URI.isUri(uri)) { 145 | return uri; 146 | } 147 | 148 | uri = uri as string; 149 | 150 | if (this.env.isWin()) { 151 | uri = uri.replace(/\\/g, '/').replace(/^(\w):/i, m => { 152 | return `/${m[0].toLowerCase()}%3A`; 153 | }); 154 | } 155 | 156 | return URI.parse(uri).with({ scheme: 'file' }); 157 | } 158 | 159 | lineAt(uri: PathLike | URI, lineNumber: number): Promise { 160 | return new Promise((resolve, reject) => { 161 | 162 | let filename = this.asUri(uri).fsPath; 163 | if (this.remoteCwd) { 164 | // for remote systems remove the root path to prevent a filestream error 165 | filename = filename.replace(this.remoteCwd, ''); 166 | } 167 | 168 | const rl = createInterface({ 169 | input: createReadStream(filename), 170 | crlfDelay: Infinity, 171 | }); 172 | 173 | let current = 0; 174 | let found = false; 175 | rl.on('line', line => { 176 | if (lineNumber === current) { 177 | found = true; 178 | rl.close(); 179 | resolve(line); 180 | } 181 | 182 | current++; 183 | }); 184 | 185 | rl.on('close', () => { 186 | if (found === false) { 187 | reject(''); 188 | } 189 | }); 190 | }); 191 | } 192 | 193 | async lineRange(uri: PathLike | URI, lineNumber: number): Promise { 194 | const line = await this.lineAt(uri, lineNumber); 195 | 196 | return Range.create( 197 | Position.create(lineNumber, line.search(/\S|$/)), 198 | Position.create(lineNumber, line.replace(/\s+$/, '').length) 199 | ); 200 | } 201 | 202 | async lineLocation( 203 | uri: PathLike | URI, 204 | lineNumber: number 205 | ): Promise { 206 | uri = this.asUri(uri).with({ scheme: 'file' }); 207 | 208 | return Location.create( 209 | uri.toString(), 210 | await this.lineRange(uri, lineNumber) 211 | ); 212 | } 213 | 214 | async glob( 215 | pattern: string, 216 | options: glob.IOptions = {} 217 | ): Promise { 218 | return new Promise((resolve, reject) => { 219 | glob(pattern, options, (error, matches) => { 220 | error ? reject(error) : resolve(matches); 221 | }); 222 | }); 223 | } 224 | 225 | private *searchFile(search: string[] | string, paths: string[]) { 226 | search = search instanceof Array ? search : [search]; 227 | 228 | for (let path of paths) { 229 | for (let extension of this.extensions) { 230 | for (let value of search) { 231 | yield join(path, `${value}${extension}`); 232 | } 233 | } 234 | } 235 | } 236 | } 237 | 238 | const files = new Filesystem(); 239 | 240 | export default files; 241 | -------------------------------------------------------------------------------- /server/src/OutputProblemMatcher.ts: -------------------------------------------------------------------------------- 1 | import { ProblemMatcher } from './ProblemMatcher'; 2 | import { ProblemNode, Status } from './ProblemNode'; 3 | import { TestNode } from './TestNode'; 4 | import { TestSuiteCollection } from './TestSuiteCollection'; 5 | import he from 'he'; 6 | 7 | const statusString = [ 8 | 'UNKNOWN', 9 | 'PASSED', 10 | 'SKIPPED', 11 | 'INCOMPLETE', 12 | 'FAILURE', 13 | 'ERROR', 14 | 'RISKY', 15 | 'WARNING', 16 | ].join('|'); 17 | 18 | const statusPattern = new RegExp( 19 | `There (was|were) \\d+ (${statusString})(s?)( test?)(:?)`, 20 | 'i' 21 | ); 22 | const classPattern = new RegExp('^\\d+\\)\\s(([^:]*)::([^\\s]*).*)$'); 23 | const messagePattern = new RegExp('^(.*)$'); 24 | const filesPattern = new RegExp('^(.*):(\\d+)$'); 25 | 26 | export class OutputProblemMatcher extends ProblemMatcher { 27 | private currentStatus: Status = this.asStatus('failure'); 28 | 29 | constructor(private suites?: TestSuiteCollection) { 30 | super([classPattern, messagePattern, filesPattern]); 31 | } 32 | 33 | async parse(contents: string): Promise { 34 | this.currentStatus = this.asStatus('failure'); 35 | const problems = (await super.parse(contents)).map(problem => { 36 | problem.message = he.decode(problem.message); 37 | 38 | return problem; 39 | }); 40 | 41 | return problems.map(problem => { 42 | let location: any = problem.files 43 | .slice() 44 | .reverse() 45 | .find(loation => { 46 | return new RegExp(`${problem.class}.php`).test( 47 | loation.file 48 | ); 49 | }); 50 | 51 | if (!location) { 52 | location = this.findLocationFromSuites(problem); 53 | } else { 54 | problem.files = problem.files.filter(l => l !== location); 55 | } 56 | 57 | return Object.assign(problem, location); 58 | }); 59 | } 60 | 61 | protected parseLine(line: string) { 62 | let m: RegExpMatchArray | null; 63 | if ((m = line.match(statusPattern))) { 64 | this.currentStatus = this.asStatus(m[2].trim().toLowerCase()); 65 | } 66 | } 67 | 68 | protected async create(): Promise { 69 | return new ProblemNode(); 70 | } 71 | 72 | protected async update( 73 | problem: ProblemNode, 74 | m: RegExpMatchArray, 75 | index: number 76 | ) { 77 | switch (index) { 78 | case 0: 79 | Object.assign(problem, this.parseNamespace(m[2]), { 80 | method: m[3], 81 | status: this.currentStatus, 82 | }); 83 | 84 | problem.updateId(); 85 | 86 | break; 87 | case 1: 88 | problem.message += `${m[1]}\n`; 89 | break; 90 | case 2: 91 | problem.files.push({ 92 | file: m[1], 93 | line: parseInt(m[2], 10) - 1, 94 | }); 95 | break; 96 | } 97 | } 98 | 99 | private parseNamespace(name: string) { 100 | const lastIndexOfSlash = name.lastIndexOf('\\'); 101 | let namespace = ''; 102 | let clazz = ''; 103 | if (lastIndexOfSlash >= 0) { 104 | namespace = name.substr(0, lastIndexOfSlash).replace(/\\$/, ''); 105 | clazz = name.substr(lastIndexOfSlash).replace(/^\\/, ''); 106 | } else { 107 | clazz = name; 108 | } 109 | 110 | return { namespace, class: clazz }; 111 | } 112 | 113 | private asStatus(status: string): Status { 114 | for (const name in Status) { 115 | if (name.toLowerCase() === status) { 116 | return Status[name] as any; 117 | } 118 | } 119 | 120 | return Status.ERROR; 121 | } 122 | 123 | private findLocationFromSuites(problem: ProblemNode) { 124 | const location = { 125 | file: '', 126 | line: -1, 127 | }; 128 | 129 | if (!this.suites) { 130 | return location; 131 | } 132 | 133 | const suiteId = [problem.namespace, problem.class] 134 | .filter(s => !!s) 135 | .join('\\'); 136 | 137 | const suites = this.suites.where(suite => suite.id === suiteId, true); 138 | 139 | if (suites.length === 0) { 140 | return location; 141 | } 142 | 143 | const suite = suites[0]; 144 | 145 | const test: TestNode = suite.children.find( 146 | (test: TestNode) => test.id === `${suiteId}::${problem.method}` 147 | ); 148 | 149 | if (!test) { 150 | return location; 151 | } 152 | 153 | return { 154 | file: test.file, 155 | line: test.line, 156 | }; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /server/src/Parser.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import Engine from 'php-parser'; 3 | import files from './Filesystem'; 4 | import URI from 'vscode-uri'; 5 | import { PathLike } from 'fs'; 6 | import { TestNode, TestOptions, TestSuiteNode } from './TestNode'; 7 | import { 8 | TextDocument, 9 | WorkspaceFolder as _WorkspaceFolder, 10 | } from 'vscode-languageserver'; 11 | 12 | const engine = Engine.create({ 13 | ast: { 14 | withPositions: true, 15 | withSource: true, 16 | }, 17 | parser: { 18 | php7: true, 19 | debug: false, 20 | extractDoc: true, 21 | suppressErrors: true, 22 | }, 23 | lexer: { 24 | all_tokens: true, 25 | comment_tokens: true, 26 | mode_eval: true, 27 | asp_tags: true, 28 | short_tags: true, 29 | }, 30 | }); 31 | 32 | class ClassNode { 33 | constructor(private node: any, private options: TestOptions) {} 34 | 35 | asTestSuite(): TestSuiteNode | undefined { 36 | const options = this.getTestOptions(); 37 | const methods = this.getMethods(); 38 | 39 | const tests = methods 40 | .map((node: any) => this.asTest(node, options)) 41 | .filter((method: TestNode) => method.isTest()); 42 | 43 | if (tests.length === 0) { 44 | return undefined; 45 | } 46 | 47 | return new TestSuiteNode(this.node, tests, options); 48 | } 49 | 50 | private asTest(node: any, testOptions: any) { 51 | return new TestNode(node, testOptions); 52 | } 53 | 54 | private fixLeadingComments(node: any, prev: any) { 55 | if (!node.body) { 56 | node.body = { 57 | leadingComments: '', 58 | }; 59 | } 60 | 61 | if (node.leadingComments) { 62 | node.body.leadingComments = node.leadingComments; 63 | 64 | return node; 65 | } 66 | 67 | if (node.body.leadingComments || !prev) { 68 | return node; 69 | } 70 | 71 | if (prev.trailingComments) { 72 | node.body.leadingComments = prev.trailingComments; 73 | 74 | return node; 75 | } 76 | 77 | if (prev.body && prev.body.trailingComments) { 78 | node.body.leadingComments = prev.body.trailingComments; 79 | 80 | return node; 81 | } 82 | 83 | return node; 84 | } 85 | 86 | private getMethods() { 87 | return this.node.body 88 | .map((node: any, index: number, childrens: any[]) => { 89 | return this.fixLeadingComments( 90 | node, 91 | index === 0 ? this.node : childrens[index - 1] 92 | ); 93 | }) 94 | .filter((node: any) => node.kind === 'method'); 95 | } 96 | 97 | private getTestOptions() { 98 | return Object.assign({ class: this.node.name.name }, this.options); 99 | } 100 | } 101 | 102 | export default class Parser { 103 | constructor( 104 | private workspaceFolder: _WorkspaceFolder = { 105 | uri: process.cwd(), 106 | name: '', 107 | }, 108 | private _engine = engine, 109 | private _files = files 110 | ) {} 111 | 112 | async parse(uri: PathLike | URI): Promise { 113 | return this.parseCode(await this._files.get(uri), uri); 114 | } 115 | 116 | parseTextDocument(textDocument: TextDocument | undefined): TestSuiteNode | undefined { 117 | if (!textDocument) { 118 | return undefined; 119 | } 120 | 121 | return this.parseCode(textDocument.getText(), textDocument.uri); 122 | } 123 | 124 | parseCode(code: string, uri: PathLike | URI): TestSuiteNode | undefined { 125 | const tree: any = this._engine.parseCode(code); 126 | const classes = this.findClasses(this._files.asUri(uri), tree.children); 127 | 128 | return !classes || classes.length === 0 129 | ? undefined 130 | : classes[0].asTestSuite(); 131 | } 132 | 133 | private findClasses(uri: URI, nodes: any[], namespace = ''): ClassNode[] { 134 | return nodes.reduce((classes: any[], node: any) => { 135 | if (node.kind === 'namespace') { 136 | return classes.concat( 137 | this.findClasses(uri, node.children, node.name) 138 | ); 139 | } 140 | 141 | return this.isTestClass(node) 142 | ? classes.concat( 143 | new ClassNode(node, { 144 | workspaceFolder: this.workspaceFolder, 145 | uri, 146 | namespace, 147 | }) 148 | ) 149 | : classes; 150 | }, []); 151 | } 152 | 153 | private isTestClass(node: any): boolean { 154 | return node.kind === 'class' && !node.isAbstract; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /server/src/ProblemCollection.ts: -------------------------------------------------------------------------------- 1 | import files from './Filesystem'; 2 | import { Diagnostic } from 'vscode-languageserver'; 3 | import { Problem, ProblemNode, Status } from './ProblemNode'; 4 | import { TestEvent, TestSuiteEvent } from './TestExplorer'; 5 | import { TestEventGroup } from './TestEventCollection'; 6 | import { TestNode, TestSuiteNode } from './TestNode'; 7 | 8 | export class ProblemCollection { 9 | private problems: Map = new Map(); 10 | private remoteCwd: string = ''; 11 | 12 | constructor(private _files = files) {} 13 | 14 | setRemoteCwd(remoteCwd: string) { 15 | this.remoteCwd = remoteCwd; 16 | this._files.setRemoteCwd(this.remoteCwd); 17 | 18 | return this; 19 | } 20 | 21 | put(tests: TestEventGroup | TestEventGroup[]) { 22 | const problems = this.asProblems( 23 | tests instanceof Array ? tests : [tests] 24 | ); 25 | 26 | problems.forEach(problem => 27 | problem instanceof ProblemNode 28 | ? this.problems.set(problem.id, problem) 29 | : this.setProblemPassed(problem.id) 30 | ); 31 | 32 | return this; 33 | } 34 | 35 | all(): ProblemNode[] { 36 | return Array.from(this.problems.values()); 37 | } 38 | 39 | async asDiagnosticGroup() { 40 | const problemGroups = this.groupByProblems(); 41 | 42 | const groups = new Map(); 43 | for (const [file, problems] of problemGroups) { 44 | groups.set( 45 | this._files.asUri(file).toString(), 46 | await Promise.all( 47 | problems.map(problem => problem.asDiagnostic()) 48 | ) 49 | ); 50 | } 51 | 52 | return groups; 53 | } 54 | 55 | private groupByProblems() { 56 | const passedStatus = [Status.PASSED, Status.INCOMPLETE, Status.SKIPPED]; 57 | 58 | return this.all().reduce((group, problem) => { 59 | const items = group.has(problem.file) 60 | ? group.get(problem.file)! 61 | : []; 62 | 63 | if (!passedStatus.includes(problem.status)) { 64 | items.push(problem); 65 | } else { 66 | this.problems.delete(problem.id); 67 | } 68 | 69 | group.set(problem.file, items); 70 | 71 | return group; 72 | }, new Map()); 73 | } 74 | 75 | private asProblems(tests: TestEventGroup[]) { 76 | return tests.reduce( 77 | (problems: (Problem | ProblemNode)[], test: TestEventGroup) => { 78 | return test instanceof ProblemNode 79 | ? problems.concat(test) 80 | : problems.concat(this.testAsProblems(test)); 81 | }, 82 | [] 83 | ); 84 | } 85 | 86 | private setProblemPassed(id: string) { 87 | if (this.problems.has(id)) { 88 | const problem = this.problems.get(id)!; 89 | problem.status = Status.PASSED; 90 | this.problems.set(problem.id, problem); 91 | } 92 | } 93 | 94 | private testAsProblems(test: TestEventGroup): Problem[] { 95 | const problems: Problem[] = []; 96 | 97 | if (test instanceof TestSuiteNode) { 98 | return problems.concat( 99 | ...test.children.map(test => this.testAsProblems(test)) 100 | ); 101 | } 102 | 103 | return problems.concat([ 104 | { 105 | type: 'problem', 106 | id: this.asProblemId(test), 107 | file: '', 108 | line: -1, 109 | message: '', 110 | status: Status.PASSED, 111 | files: [], 112 | }, 113 | ]); 114 | } 115 | 116 | private asProblemId(test: TestEventGroup) { 117 | if (this.isNode(test)) { 118 | return (test as (TestSuiteNode | TestNode)).id; 119 | } 120 | 121 | const value = 122 | test.type === 'suite' 123 | ? (test as TestSuiteEvent).suite 124 | : (test as TestEvent).test; 125 | 126 | return typeof value === 'string' ? value : value.id; 127 | } 128 | 129 | private isNode(test: TestEventGroup) { 130 | return test instanceof TestSuiteNode || test instanceof TestNode; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /server/src/ProblemMatcher.ts: -------------------------------------------------------------------------------- 1 | import { ProblemNode } from './ProblemNode'; 2 | 3 | export abstract class ProblemMatcher { 4 | private problems: ProblemNode[] = []; 5 | private problemIndex = -1; 6 | private currentIndex = -1; 7 | 8 | constructor(private patterns: RegExp[] = []) {} 9 | 10 | async parse(contents: string): Promise { 11 | const lines: string[] = contents.split(/\r\n|\r|\n/g); 12 | 13 | this.problems = []; 14 | this.problemIndex = -1; 15 | let current: RegExpMatchArray | null; 16 | let next: RegExpMatchArray | null; 17 | for (const line of lines) { 18 | this.parseLine(line); 19 | if ((next = line.match(this.nextRule))) { 20 | if (this.nextIndex === 0) { 21 | this.problemIndex++; 22 | } 23 | 24 | this.currentIndex = this.nextIndex; 25 | await this.doUpdate(next); 26 | 27 | continue; 28 | } 29 | 30 | if (this.currentIndex === -1) { 31 | continue; 32 | } 33 | 34 | if ((current = line.match(this.currentRule))) { 35 | await this.doUpdate(current); 36 | } else { 37 | this.currentIndex = -1; 38 | } 39 | } 40 | 41 | return this.problems; 42 | } 43 | 44 | protected abstract parseLine(line: string): void; 45 | 46 | protected abstract async create(m: RegExpMatchArray): Promise; 47 | 48 | protected abstract async update( 49 | problem: ProblemNode, 50 | m: RegExpMatchArray, 51 | index: number 52 | ): Promise; 53 | 54 | private async doUpdate(m: RegExpMatchArray) { 55 | if (!this.problems[this.problemIndex]) { 56 | this.problems[this.problemIndex] = await this.create(m); 57 | } 58 | 59 | await this.update( 60 | this.problems[this.problemIndex], 61 | m, 62 | this.currentIndex 63 | ); 64 | } 65 | 66 | private get currentRule() { 67 | return this.patterns[this.currentIndex]; 68 | } 69 | 70 | private get nextRule() { 71 | return this.patterns[this.nextIndex]; 72 | } 73 | 74 | private get nextIndex() { 75 | return this.currentIndex === this.patterns.length - 1 76 | ? 0 77 | : this.currentIndex + 1; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /server/src/ProblemNode.ts: -------------------------------------------------------------------------------- 1 | import files from './Filesystem'; 2 | import { TestEvent } from './TestExplorer'; 3 | import { 4 | Diagnostic, 5 | DiagnosticSeverity, 6 | DiagnosticRelatedInformation, 7 | } from 'vscode-languageserver'; 8 | 9 | export enum Status { 10 | UNKNOWN, 11 | PASSED, 12 | SKIPPED, 13 | INCOMPLETE, 14 | FAILURE, 15 | ERROR, 16 | RISKY, 17 | WARNING, 18 | } 19 | 20 | export const states: Map = new Map([ 21 | [Status.UNKNOWN, 'errored'], 22 | [Status.PASSED, 'passed'], 23 | [Status.SKIPPED, 'skipped'], 24 | [Status.INCOMPLETE, 'skipped'], 25 | [Status.FAILURE, 'failed'], 26 | [Status.ERROR, 'errored'], 27 | [Status.RISKY, 'failed'], 28 | [Status.WARNING, 'failed'], 29 | ]); 30 | 31 | export interface Location { 32 | file: string; 33 | line: number; 34 | } 35 | 36 | export interface Problem extends Location { 37 | type: 'problem'; 38 | id: string; 39 | namespace?: string; 40 | class?: string; 41 | method?: string; 42 | status: Status; 43 | message: string; 44 | files: Location[]; 45 | } 46 | 47 | export class ProblemNode implements Problem { 48 | type: 'problem' = 'problem'; 49 | id = ''; 50 | namespace = ''; 51 | class = ''; 52 | method = ''; 53 | status = Status.FAILURE; 54 | file = ''; 55 | line = 0; 56 | message = ''; 57 | files: Location[] = []; 58 | 59 | constructor(private _files = files) {} 60 | 61 | updateId() { 62 | const qualifiedClassName = [this.namespace, this.class] 63 | .filter(name => !!name) 64 | .join('\\'); 65 | 66 | this.id = `${qualifiedClassName}::${this.method}`; 67 | 68 | return this; 69 | } 70 | 71 | asTestEvent(): TestEvent { 72 | return { 73 | type: 'test', 74 | test: this.id, 75 | state: this.getEventState() as TestEvent['state'], 76 | message: this.message, 77 | decorations: this.asTestDecorations(), 78 | }; 79 | } 80 | 81 | async asDiagnostic(): Promise { 82 | const message = this.message.trim(); 83 | 84 | return { 85 | severity: 86 | this.status === Status.WARNING 87 | ? DiagnosticSeverity.Warning 88 | : DiagnosticSeverity.Error, 89 | range: await this._files.lineRange(this.file, this.line), 90 | message: message, 91 | source: 'PHPUnit', 92 | relatedInformation: await Promise.all( 93 | this.files.map(async l => { 94 | return DiagnosticRelatedInformation.create( 95 | await this._files.lineLocation(l.file, l.line), 96 | message 97 | ); 98 | }) 99 | ), 100 | }; 101 | } 102 | 103 | private asTestDecorations() { 104 | return [{ file: this.file, line: this.line }] 105 | .concat(this.files) 106 | .filter(l => l.file === this.file && l.line >= 0) 107 | .map(location => ({ 108 | line: location.line, 109 | message: this.message, 110 | })); 111 | } 112 | 113 | private getEventState(): TestEvent['state'] | undefined { 114 | return states.get(this.status); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /server/src/Process.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn, SpawnOptions } from 'child_process'; 2 | import { Command } from 'vscode-languageserver-protocol'; 3 | 4 | export class Process { 5 | private process: ChildProcess | null = null; 6 | private reject: Function | null = null; 7 | 8 | run(command: Command, options?: SpawnOptions): Promise { 9 | return new Promise((resolve, reject) => { 10 | this.reject = reject; 11 | 12 | const buffers: any[] = []; 13 | command.arguments = command.arguments || []; 14 | 15 | this.process = spawn( 16 | command.command, 17 | command.arguments, 18 | options || {} 19 | ); 20 | 21 | if (!this.process) { 22 | return; 23 | } 24 | 25 | this.process.stdout!.on('data', data => { 26 | buffers.push(data); 27 | }); 28 | 29 | this.process.stderr!.on('data', data => { 30 | buffers.push(data); 31 | }); 32 | 33 | this.process.on('error', (err: any) => { 34 | resolve(err.message); 35 | }); 36 | 37 | this.process.on('close', () => { 38 | const output = buffers.reduce((response, buffer) => { 39 | return (response += buffer.toString()); 40 | }, ''); 41 | 42 | resolve(output); 43 | }); 44 | }); 45 | } 46 | 47 | kill(): boolean { 48 | if (!this.process) { 49 | return false; 50 | } 51 | 52 | this.process.kill(); 53 | 54 | if (this.process.killed === true && this.reject) { 55 | this.reject('killed'); 56 | 57 | return true; 58 | } 59 | 60 | return false; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /server/src/TestEventCollection.ts: -------------------------------------------------------------------------------- 1 | import { ProblemNode } from './ProblemNode'; 2 | import { TestEvent, TestSuiteEvent } from './TestExplorer'; 3 | import { TestNode, TestSuiteNode } from './TestNode'; 4 | 5 | export declare type NodeGroup = TestSuiteNode | TestNode | ProblemNode; 6 | export declare type TestEventGroup = NodeGroup | TestSuiteEvent | TestEvent; 7 | 8 | export class TestEventCollection { 9 | private events: Map = new Map(); 10 | 11 | put(tests: TestEventGroup | TestEventGroup[]) { 12 | tests = tests instanceof Array ? tests : [tests]; 13 | 14 | this.asEvents(tests).forEach(event => 15 | this.events.set(this.asEventId(event), event) 16 | ); 17 | 18 | return this; 19 | } 20 | 21 | get(id: string): TestSuiteEvent | TestEvent | undefined { 22 | return this.events.get(id); 23 | } 24 | 25 | delete(test: TestEventGroup | TestEventGroup) { 26 | return this.events.delete(this.asEventId(test)); 27 | } 28 | 29 | clear() { 30 | this.events.clear(); 31 | 32 | return this; 33 | } 34 | 35 | where(filter: (test: TestSuiteEvent | TestEvent) => {}, single = false) { 36 | const events = this.all(); 37 | const items: (TestSuiteEvent | TestEvent)[] = []; 38 | 39 | for (const event of events) { 40 | if (filter(event)) { 41 | items.push(event); 42 | 43 | if (single === true) { 44 | return items; 45 | } 46 | } 47 | } 48 | 49 | return items; 50 | } 51 | 52 | find(id: string): TestSuiteEvent | TestEvent { 53 | return this.where(event => id === this.asEventId(event))[0]; 54 | } 55 | 56 | all(): (TestSuiteEvent | TestEvent)[] { 57 | return Array.from(this.events.values()); 58 | } 59 | 60 | private asEvents(tests: TestEventGroup[]) { 61 | return tests.reduce( 62 | (events: (TestSuiteEvent | TestEvent)[], test: TestEventGroup) => { 63 | return this.isNode(test) 64 | ? events.concat(this.nodeAsEvents(test as NodeGroup)) 65 | : events.concat([test as (TestSuiteEvent | TestEvent)]); 66 | }, 67 | [] 68 | ); 69 | } 70 | 71 | private nodeAsEvents(test: NodeGroup): (TestSuiteEvent | TestEvent)[] { 72 | const events: (TestSuiteEvent | TestEvent)[] = []; 73 | 74 | return test instanceof TestSuiteNode 75 | ? events 76 | .concat([test.asTestSuiteEvent()]) 77 | .concat(...test.children.map(test => this.nodeAsEvents(test))) 78 | : events.concat([test.asTestEvent()]); 79 | } 80 | 81 | private asEventId(test: TestEventGroup) { 82 | if (this.isNode(test)) { 83 | return (test as NodeGroup).id; 84 | } 85 | 86 | const value = 87 | test.type === 'suite' 88 | ? (test as TestSuiteEvent).suite 89 | : (test as TestEvent).test; 90 | 91 | return typeof value === 'string' ? value : value.id; 92 | } 93 | 94 | private isNode(test: TestEventGroup) { 95 | return ( 96 | test instanceof TestSuiteNode || 97 | test instanceof TestNode || 98 | test instanceof ProblemNode 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /server/src/TestExplorer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This event is sent by a Test Adapter when it starts loading the test definitions. 3 | */ 4 | export interface TestLoadStartedEvent { 5 | type: 'started'; 6 | } 7 | /** 8 | * This event is sent by a Test Adapter when it finished loading the test definitions. 9 | */ 10 | export interface TestLoadFinishedEvent { 11 | type: 'finished'; 12 | /** The test definitions that have just been loaded */ 13 | suite?: TestSuiteInfo; 14 | /** If loading the tests failed, this should contain the reason for the failure */ 15 | errorMessage?: string; 16 | } 17 | /** 18 | * This event is sent by a Test Adapter when it starts a test run. 19 | */ 20 | export interface TestRunStartedEvent { 21 | type: 'started'; 22 | /** 23 | * The test(s) that will be run, this should be the same as the `tests` argument from the call 24 | * to `run(tests)` or `debug(tests)` that started the test run. 25 | */ 26 | tests: string[]; 27 | } 28 | /** 29 | * This event is sent by a Test Adapter when it finished a test run. 30 | */ 31 | export interface TestRunFinishedEvent { 32 | type: 'finished'; 33 | } 34 | /** 35 | * Information about a test suite. 36 | */ 37 | export interface TestSuiteInfo { 38 | type: 'suite'; 39 | id: string; 40 | /** The label to be displayed by the Test Explorer for this suite. */ 41 | label: string; 42 | /** The description to be displayed next to the label. */ 43 | description?: string; 44 | /** The tooltip text to be displayed by the Test Explorer when you hover over this suite. */ 45 | tooltip?: string; 46 | /** 47 | * The file containing this suite (if known). 48 | * This can either be an absolute path (if it is a local file) or a URI. 49 | * Note that this should never contain a `file://` URI. 50 | */ 51 | file?: string; 52 | /** The line within the specified file where the suite definition starts (if known). */ 53 | line?: number; 54 | children: (TestSuiteInfo | TestInfo)[]; 55 | } 56 | /** 57 | * Information about a test. 58 | */ 59 | export interface TestInfo { 60 | type: 'test'; 61 | id: string; 62 | /** The label to be displayed by the Test Explorer for this test. */ 63 | label: string; 64 | /** The description to be displayed next to the label. */ 65 | description?: string; 66 | /** The tooltip text to be displayed by the Test Explorer when you hover over this test. */ 67 | tooltip?: string; 68 | /** 69 | * The file containing this test (if known). 70 | * This can either be an absolute path (if it is a local file) or a URI. 71 | * Note that this should never contain a `file://` URI. 72 | */ 73 | file?: string; 74 | /** The line within the specified file where the test definition starts (if known). */ 75 | line?: number; 76 | /** Indicates whether this test will be skipped during test runs */ 77 | skipped?: boolean; 78 | } 79 | /** 80 | * Information about a suite being started or completed during a test run. 81 | */ 82 | export interface TestSuiteEvent { 83 | type: 'suite'; 84 | /** 85 | * The suite that is being started or completed. This field usually contains the ID of the 86 | * suite, but it may also contain the full information about a suite that is started if that 87 | * suite had not been sent to the Test Explorer yet. 88 | */ 89 | suite: string | TestSuiteInfo; 90 | state: 'running' | 'completed'; 91 | /** 92 | * This property allows you to update the description of the suite in the Test Explorer. 93 | * When the test states are reset, the description will change back to the one from `TestSuiteInfo`. 94 | */ 95 | description?: string; 96 | /** 97 | * This property allows you to update the tooltip of the suite in the Test Explorer. 98 | * When the test states are reset, the tooltip will change back to the one from `TestSuiteInfo`. 99 | */ 100 | tooltip?: string; 101 | } 102 | /** 103 | * Information about a test being started, completed or skipped during a test run. 104 | */ 105 | export interface TestEvent { 106 | type: 'test'; 107 | /** 108 | * The test that is being started, completed or skipped. This field usually contains 109 | * the ID of the test, but it may also contain the full information about a test that is 110 | * started if that test had not been sent to the Test Explorer yet. 111 | */ 112 | test: string | TestInfo; 113 | state: 'running' | 'passed' | 'failed' | 'skipped' | 'errored'; 114 | /** 115 | * This message will be displayed by the Test Explorer when the user selects the test. 116 | * It is usually used for information about why a test has failed. 117 | */ 118 | message?: string; 119 | /** 120 | * These messages will be shown as decorations for the given lines in the editor. 121 | * They are usually used to show information about a test failure at the location of that failure. 122 | */ 123 | decorations?: TestDecoration[]; 124 | /** 125 | * This property allows you to update the description of the test in the Test Explorer. 126 | * When the test states are reset, the description will change back to the one from `TestInfo`. 127 | */ 128 | description?: string; 129 | /** 130 | * This property allows you to update the tooltip of the test in the Test Explorer. 131 | * When the test states are reset, the tooltip will change back to the one from `TestInfo`. 132 | */ 133 | tooltip?: string; 134 | } 135 | export interface TestDecoration { 136 | /** 137 | * The line for which the decoration should be shown 138 | */ 139 | line: number; 140 | /** 141 | * The message to show in the decoration. This must be a single line of text. 142 | */ 143 | message: string; 144 | /** 145 | * This text is shown when the user hovers over the decoration's message. 146 | * If this isn't defined then the hover will show the test's log. 147 | */ 148 | hover?: string; 149 | } 150 | export interface RetireEvent { 151 | /** 152 | * An array of test or suite IDs. For every suite ID, all tests in that suite will be retired. 153 | * If this isn't defined then all tests will be retired. 154 | */ 155 | tests?: string[]; 156 | } 157 | -------------------------------------------------------------------------------- /server/src/TestNode.ts: -------------------------------------------------------------------------------- 1 | import URI from 'vscode-uri'; 2 | import { 3 | CodeLens, 4 | Command, 5 | Range, 6 | WorkspaceFolder as _WorkspaceFolder, 7 | } from 'vscode-languageserver-protocol'; 8 | import { 9 | TestInfo, 10 | TestSuiteInfo, 11 | TestEvent, 12 | TestSuiteEvent, 13 | } from './TestExplorer'; 14 | 15 | export interface TestOptions { 16 | [propName: string]: any; 17 | workspaceFolder: _WorkspaceFolder; 18 | class?: string; 19 | namespace?: string; 20 | method?: string; 21 | uri: URI; 22 | } 23 | 24 | interface ExportCodeLens { 25 | exportCodeLens(): CodeLens[]; 26 | } 27 | 28 | abstract class BaseTestNode { 29 | [propName: string]: any; 30 | 31 | constructor(private node: any, private options?: TestOptions) {} 32 | 33 | get workspaceFolder() { 34 | return this.options && this.options.workspaceFolder 35 | ? this.options.workspaceFolder.uri 36 | : undefined; 37 | } 38 | 39 | get name(): string { 40 | return this.node.name.name; 41 | } 42 | 43 | get file(): string | undefined { 44 | return this.options && this.options.uri 45 | ? this.options.uri.toString() 46 | : undefined; 47 | } 48 | 49 | get line(): number { 50 | return this.node.loc.start.line - 1; 51 | } 52 | 53 | get depends(): string[] { 54 | const comments: any[] = this.node.body.leadingComments || []; 55 | 56 | return comments.reduce((depends: any[], comment: any) => { 57 | const matches = (comment.value.match(/@depends\s+[^\n\s]+/g) || []) 58 | .map((depend: string) => depend.replace('@depends', '').trim()) 59 | .filter((depend: string) => !!depend); 60 | 61 | return depends.concat(matches); 62 | }, []); 63 | } 64 | 65 | get kind(): string { 66 | return this.node.kind; 67 | } 68 | 69 | get qualifiedClassName(): string { 70 | return [this.namespace, this.class].filter(name => !!name).join('\\'); 71 | } 72 | 73 | get namespace(): string | undefined { 74 | return this.options ? this.options.namespace : undefined; 75 | } 76 | 77 | get class(): string | undefined { 78 | return this.options ? this.options.class : undefined; 79 | } 80 | 81 | get method(): string { 82 | return this.kind === 'method' ? this.node.name.name : ''; 83 | } 84 | 85 | get range(): Range { 86 | const start = this.node.loc.start; 87 | const startCharacter = this.node.visibility 88 | ? start.column - this.node.visibility.length 89 | : start.column; 90 | 91 | const end = this.node.loc.end; 92 | 93 | return Range.create( 94 | start.line - 1, 95 | startCharacter, 96 | end.line - 1, 97 | end.column 98 | ); 99 | } 100 | 101 | get uri(): URI | undefined { 102 | return this.options ? this.options.uri : undefined; 103 | } 104 | 105 | isTest(): boolean { 106 | return ( 107 | this.acceptModifier() && 108 | (this.acceptComments() || this.acceptMethodName()) 109 | ); 110 | } 111 | 112 | asCodeLens(): CodeLens { 113 | const codeLens = CodeLens.create(this.range); 114 | 115 | codeLens.command = { 116 | title: 'Run Test', 117 | command: 'phpunit.lsp.run-test-at-cursor', 118 | arguments: [this.workspaceFolder, this.id], 119 | } as Command; 120 | 121 | return codeLens; 122 | } 123 | 124 | private acceptModifier(): boolean { 125 | return ['', 'public'].indexOf(this.node.visibility) !== -1; 126 | } 127 | 128 | private acceptComments(): boolean { 129 | const comments: any[] = this.node.body.leadingComments || []; 130 | 131 | return comments.some((comment: any) => /@test/.test(comment.value)); 132 | } 133 | 134 | private acceptMethodName(): boolean { 135 | return /^test/.test(this.node.name.name); 136 | } 137 | } 138 | 139 | export class TestSuiteNode extends BaseTestNode 140 | implements TestSuiteInfo, ExportCodeLens { 141 | type: 'suite' = 'suite'; 142 | 143 | constructor( 144 | node: any, 145 | public children: (TestSuiteNode | TestNode)[], 146 | options?: TestOptions 147 | ) { 148 | super(node, options); 149 | } 150 | 151 | get id(): string { 152 | return this.qualifiedClassName; 153 | } 154 | 155 | get label(): string { 156 | return this.qualifiedClassName || ''; 157 | } 158 | 159 | asTestSuiteEvent(): TestSuiteEvent { 160 | return { 161 | type: 'suite', 162 | suite: this.id, 163 | state: 'running', 164 | }; 165 | } 166 | 167 | exportCodeLens(): CodeLens[] { 168 | return [this.asCodeLens()].concat( 169 | this.children.map(test => test.asCodeLens()) 170 | ); 171 | } 172 | } 173 | 174 | export class TestNode extends BaseTestNode implements TestInfo { 175 | type: 'test' = 'test'; 176 | 177 | constructor(node: any, options?: TestOptions) { 178 | super(node, options); 179 | } 180 | 181 | get id(): string { 182 | return [this.qualifiedClassName, this.name].join('::'); 183 | } 184 | 185 | get label(): string { 186 | return this.method; 187 | } 188 | 189 | asTestEvent(): TestEvent { 190 | return { 191 | type: 'test', 192 | test: this.id, 193 | state: 'running', 194 | }; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /server/src/TestResponse.ts: -------------------------------------------------------------------------------- 1 | import stripAnsi from 'strip-ansi'; 2 | import { ProblemMatcher } from './ProblemMatcher'; 3 | import { ProblemNode } from './ProblemNode'; 4 | 5 | export interface TestResult { 6 | [index: string]: number | undefined; 7 | tests?: number; 8 | assertions?: number; 9 | errors?: number; 10 | failures?: number; 11 | warnings?: number; 12 | skipped?: number; 13 | incomplete?: number; 14 | risky?: number; 15 | } 16 | 17 | export interface ITestResponse { 18 | asProblems: () => Promise; 19 | getTestResult: () => TestResult; 20 | toString: () => string; 21 | } 22 | 23 | export class FailedTestResponse implements ITestResponse { 24 | constructor(private output: string) {} 25 | 26 | asProblems() { 27 | return Promise.resolve([]); 28 | } 29 | 30 | getTestResult() { 31 | return { 32 | tests: 0, 33 | assertions: 0, 34 | errors: 0, 35 | failures: 0, 36 | warnings: 0, 37 | skipped: 0, 38 | incomplete: 0, 39 | risky: 0, 40 | }; 41 | } 42 | 43 | toString(): string { 44 | return this.output; 45 | } 46 | } 47 | 48 | export class TestResponse implements ITestResponse { 49 | private output: string; 50 | constructor(output: string, private problemMatcher: ProblemMatcher) { 51 | this.output = stripAnsi(output); 52 | } 53 | 54 | async asProblems(): Promise { 55 | return await this.problemMatcher.parse(this.output); 56 | } 57 | 58 | getTestResult() { 59 | const result: TestResult = { 60 | tests: 0, 61 | assertions: 0, 62 | errors: 0, 63 | failures: 0, 64 | warnings: 0, 65 | skipped: 0, 66 | incomplete: 0, 67 | risky: 0, 68 | }; 69 | 70 | if (!this.isTestResult()) { 71 | return result; 72 | } 73 | 74 | return Object.assign( 75 | {}, 76 | this.parseSuccessFul() || this.parseTestResult() 77 | ); 78 | } 79 | 80 | private parseSuccessFul() { 81 | const matches = this.output.match( 82 | new RegExp('OK \\((\\d+) test(s?), (\\d+) assertion(s?)\\)') 83 | ); 84 | 85 | if (matches) { 86 | return { 87 | tests: parseInt(matches[1], 10), 88 | assertions: parseInt(matches[3], 10), 89 | }; 90 | } 91 | 92 | return false; 93 | } 94 | 95 | private isTestResult() { 96 | const pattern = [ 97 | 'OK \\((\\d+) test(s?), (\\d+) assertion(s?)\\)', 98 | 'ERRORS!', 99 | 'FAILURES!', 100 | 'WARNINGS!', 101 | 'OK, but incomplete, skipped, or risky tests!', 102 | ].join('|'); 103 | 104 | return new RegExp(pattern, 'ig').test(this.output); 105 | } 106 | 107 | private parseTestResult() { 108 | const pattern = [ 109 | 'Test(s?)', 110 | 'Assertion(s?)', 111 | 'Error(s?)', 112 | 'Failure(s?)', 113 | 'Warning(s?)', 114 | 'Skipped', 115 | 'Incomplete', 116 | 'Risky', 117 | ].join('|'); 118 | 119 | const matches = this.output.match( 120 | new RegExp(`(${pattern}):\\s(\\d+)`, 'ig') 121 | ); 122 | 123 | if (!matches) { 124 | return undefined; 125 | } 126 | 127 | const plural = ['test', 'assertion', 'error', 'failure', 'warning']; 128 | const result: TestResult = {}; 129 | 130 | for (const text of matches) { 131 | const match = text.match(new RegExp('(\\w+?(s?)):\\s(\\d+)')); 132 | const value = parseInt(match![3], 10); 133 | let key = match![1].toLowerCase(); 134 | if (plural.includes(key)) { 135 | key = `${key}s`; 136 | } 137 | 138 | result[key] = value; 139 | } 140 | 141 | return result; 142 | } 143 | 144 | toString(): string { 145 | return this.output; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /server/src/TestRunner.ts: -------------------------------------------------------------------------------- 1 | import files from './Filesystem'; 2 | import URI from 'vscode-uri'; 3 | import { Command } from 'vscode-languageserver-protocol'; 4 | import { PathLike } from 'fs'; 5 | import { Process } from './Process'; 6 | import { SpawnOptions } from 'child_process'; 7 | 8 | export interface Params { 9 | file?: PathLike | URI; 10 | method?: string; 11 | depends?: string[]; 12 | } 13 | 14 | export class TestRunner { 15 | private phpBinary = ''; 16 | private phpUnitBinary = ''; 17 | private isDocker: boolean = false; 18 | private dockerImage = ''; 19 | private args: string[] = []; 20 | private lastArgs: string[] = []; 21 | private lastOutput: string = ''; 22 | private relativeFilePath: boolean = false; 23 | private discoverConfigFile: boolean = false; 24 | private lastCommand: Command = { 25 | title: '', 26 | command: '', 27 | arguments: [], 28 | }; 29 | 30 | constructor(private process = new Process(), private _files = files) { } 31 | 32 | setPhpBinary(phpBinary: PathLike | URI | undefined) { 33 | this.phpBinary = phpBinary ? this._files.asUri(phpBinary).fsPath : ''; 34 | 35 | return this; 36 | } 37 | 38 | setPhpUnitBinary(phpUnitBinary: PathLike | URI | undefined) { 39 | this.phpUnitBinary = phpUnitBinary 40 | ? this._files.asUri(phpUnitBinary).fsPath 41 | : ''; 42 | 43 | return this; 44 | } 45 | 46 | setIsDocker(isDocker: boolean | undefined) { 47 | this.isDocker = isDocker ? isDocker : false; 48 | 49 | return this; 50 | } 51 | 52 | setDockerImage(dockerImage: string | undefined) { 53 | if (dockerImage) this.dockerImage = dockerImage; 54 | 55 | return this; 56 | } 57 | 58 | setConfigFile(configFile: PathLike | URI | undefined) { 59 | if (configFile) { 60 | this.args.push(`-c ${this._files.asUri(configFile).fsPath}`); 61 | } 62 | 63 | return this; 64 | } 65 | 66 | setDiscoverConfigFile(discoverConfigFile: boolean) { 67 | this.discoverConfigFile = discoverConfigFile; 68 | 69 | return this; 70 | } 71 | 72 | setArgs(args: string[] | undefined) { 73 | this.args = args || []; 74 | 75 | return this; 76 | } 77 | 78 | setRelativeFilePath(relativeFilePath: boolean) { 79 | this.relativeFilePath = relativeFilePath; 80 | 81 | return this; 82 | } 83 | 84 | async rerun(p?: Params, options?: SpawnOptions) { 85 | if (p && this.lastArgs.length === 0) { 86 | return await this.run(p, options); 87 | } 88 | 89 | return await this.doRun(this.lastArgs, options); 90 | } 91 | 92 | async run(p?: Params, options?: SpawnOptions) { 93 | if (!p) { 94 | return await this.doRun([], options); 95 | } 96 | 97 | const params = []; 98 | const deps: string[] = []; 99 | 100 | if (p.method) { 101 | deps.push(p.method); 102 | } 103 | 104 | if (p.depends) { 105 | deps.push(...p.depends); 106 | } 107 | 108 | if (deps.length > 0) { 109 | params.push('--filter'); 110 | let filter = `/^.*::${deps.join('|')}.*$/`; 111 | if (options && options.shell) { 112 | if (process.platform === 'win32') { 113 | filter = `"${filter}"`; 114 | } else { 115 | filter = `'${filter}'`; 116 | } 117 | } 118 | params.push(filter); 119 | } 120 | 121 | if (p.file) { 122 | let testFilePath = this._files.asUri(p.file).fsPath; 123 | if (this.relativeFilePath && options && options.cwd) { 124 | testFilePath = testFilePath.replace(new RegExp(options.cwd.replace(/\\/g, '\\\\') + '[\\/\\\\]'), ''); 125 | } 126 | params.push(testFilePath); 127 | } 128 | 129 | return await this.doRun(params, options); 130 | } 131 | 132 | async doRun(args: string[] = [], options?: SpawnOptions) { 133 | try { 134 | this.lastArgs = args; 135 | this.lastCommand = await this.toCommand(args, options); 136 | this.lastOutput = await this.process.run(this.lastCommand, options); 137 | 138 | return 0; 139 | } catch (e) { 140 | return 1; 141 | } 142 | } 143 | 144 | getOutput() { 145 | return this.lastOutput; 146 | } 147 | 148 | getCommand() { 149 | return this.lastCommand; 150 | } 151 | 152 | cancel(): boolean { 153 | const killed = this.process.kill(); 154 | this.lastOutput = ''; 155 | this.lastCommand = { 156 | title: '', 157 | command: '', 158 | arguments: [], 159 | }; 160 | 161 | return killed; 162 | } 163 | 164 | private async toCommand( 165 | args: string[], 166 | spawnOptions?: SpawnOptions 167 | ): Promise { 168 | let params = []; 169 | 170 | const [phpBinary, phpUnitBinary, phpUnitXml, dockerImage] = await Promise.all([ 171 | this.getPhpBinary(), 172 | this.getPhpUnitBinary(spawnOptions), 173 | this.getPhpUnitXml(spawnOptions), 174 | this.getDockerImage() 175 | ]); 176 | 177 | if (phpBinary && !this.isDocker) { 178 | params.push(phpBinary); 179 | } 180 | 181 | if (phpUnitBinary && !this.isDocker) { 182 | params.push(phpUnitBinary); 183 | } 184 | 185 | if (phpUnitXml && this.discoverConfigFile) { 186 | params.push('-c'); 187 | params.push(phpUnitXml); 188 | } 189 | 190 | params = params.concat(this.args, args).filter(arg => !!arg); 191 | 192 | if (this.isDocker) { 193 | const phpUnitFile = phpUnitBinary ? phpUnitBinary.substring(1) : ''; 194 | const command = `${dockerImage} bash -c "${phpUnitFile} ${params.join(' ')}"`; 195 | 196 | return { 197 | title: 'PHPUnit LSP', 198 | command: command as string, 199 | arguments: [] 200 | }; 201 | } 202 | 203 | return { 204 | title: 'PHPUnit LSP', 205 | command: params.shift() as string, 206 | arguments: params, 207 | }; 208 | } 209 | 210 | private getPhpBinary(): Promise { 211 | return Promise.resolve(this.phpBinary); 212 | } 213 | 214 | private async getPhpUnitBinary( 215 | spawnOptions?: SpawnOptions 216 | ): Promise { 217 | if (this.phpUnitBinary) { 218 | return this.phpUnitBinary; 219 | } 220 | 221 | return await this._files.findup( 222 | ['vendor/bin/phpunit', 'phpunit'], 223 | spawnOptions 224 | ); 225 | } 226 | 227 | private getDockerImage(): Promise { 228 | return Promise.resolve(this.dockerImage); 229 | } 230 | 231 | private async getPhpUnitXml(spawnOptions?: SpawnOptions) { 232 | return await this._files.findup( 233 | ['phpunit.xml', 'phpunit.xml.dist'], 234 | spawnOptions 235 | ); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /server/src/TestSuiteCollection.ts: -------------------------------------------------------------------------------- 1 | import files from './Filesystem'; 2 | import Parser from './Parser'; 3 | import URI from 'vscode-uri'; 4 | import { IOptions } from 'glob'; 5 | import { PathLike } from 'fs'; 6 | import { TestInfo, TestSuiteInfo } from './TestExplorer'; 7 | import { TestNode, TestSuiteNode } from './TestNode'; 8 | import { TextDocument } from 'vscode-languageserver-protocol'; 9 | 10 | export class TestSuiteCollection { 11 | private suites: Map = new Map< 12 | string, 13 | TestSuiteNode 14 | >(); 15 | 16 | constructor(private parser = new Parser(), private _files = files) {} 17 | 18 | async load(pattern: string, options: IOptions = { cwd: process.cwd() }) { 19 | const defaults = { 20 | absolute: true, 21 | strict: false, 22 | }; 23 | 24 | const files = await this._files.glob( 25 | pattern, 26 | Object.assign(defaults, options) 27 | ); 28 | 29 | (await Promise.all( 30 | files.map(async file => [file, await this._files.get(file)]) 31 | )).forEach(([file, code]) => { 32 | this.put(file, code); 33 | }); 34 | 35 | return this; 36 | } 37 | 38 | async put( 39 | uri: PathLike | URI, 40 | code?: string 41 | ): Promise { 42 | const suite = code 43 | ? this.parser.parseCode(code, uri) 44 | : await this.parser.parse(uri); 45 | 46 | return this.putTestSuite(this._files.asUri(uri), suite); 47 | } 48 | 49 | putTextDocument(document: TextDocument | undefined): TestSuiteCollection { 50 | if (!document) { 51 | return this; 52 | } 53 | 54 | return this.putTestSuite( 55 | this._files.asUri(document.uri), 56 | this.parser.parseTextDocument(document) 57 | ); 58 | } 59 | 60 | get(uri: PathLike | URI) { 61 | return this.suites.get(this._files.asUri(uri).toString()); 62 | } 63 | 64 | delete(uri: PathLike | URI) { 65 | return this.suites.delete(this._files.asUri(uri).toString()); 66 | } 67 | 68 | clear() { 69 | this.suites.clear(); 70 | 71 | return this; 72 | } 73 | 74 | tree(): TestSuiteInfo { 75 | const children: TestSuiteInfo[] = []; 76 | 77 | this.suites.forEach(suite => { 78 | children.push(this.toTestSuiteInfo(suite)); 79 | }); 80 | 81 | return { 82 | type: 'suite', 83 | id: 'root', 84 | label: 'PHPUnit', 85 | children: children, 86 | }; 87 | } 88 | 89 | where(filter: (test: TestSuiteNode | TestNode) => {}, single = false) { 90 | const suites = this.all(); 91 | const tests: (TestSuiteNode | TestNode)[] = []; 92 | 93 | for (const suite of suites) { 94 | if (filter(suite)) { 95 | tests.push(suite); 96 | 97 | if (single === true) { 98 | return tests; 99 | } 100 | } 101 | 102 | for (const test of suite.children) { 103 | if (filter(test)) { 104 | tests.push(test); 105 | 106 | if (single === true) { 107 | return tests; 108 | } 109 | } 110 | } 111 | } 112 | 113 | return tests; 114 | } 115 | 116 | find(id: string): TestSuiteNode | TestNode { 117 | return this.where(test => { 118 | return id === test.id; 119 | }, true)[0]; 120 | } 121 | 122 | all(): TestSuiteNode[] { 123 | return Array.from(this.suites.values()); 124 | } 125 | 126 | private putTestSuite(uri: URI, suite: TestSuiteNode | undefined) { 127 | if (!suite) { 128 | return this; 129 | } 130 | 131 | const key = uri.toString(); 132 | 133 | if (this.suites.has(key)) { 134 | this.suites.delete(key); 135 | } 136 | 137 | this.suites.set(key, suite); 138 | 139 | return this; 140 | } 141 | 142 | private toTestSuiteInfo(suite: TestSuiteNode): TestSuiteInfo { 143 | return { 144 | type: 'suite', 145 | id: suite.id, 146 | label: suite.label, 147 | file: suite.file, 148 | line: suite.line, 149 | children: suite.children.map(test => { 150 | return test instanceof TestSuiteNode 151 | ? this.toTestSuiteInfo(test) 152 | : ({ 153 | type: 'test', 154 | id: test.id, 155 | label: test.label, 156 | file: test.file, 157 | line: test.line, 158 | } as TestInfo); 159 | }), 160 | }; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /server/src/WorkspaceFolders.ts: -------------------------------------------------------------------------------- 1 | import files from './Filesystem'; 2 | import Parser from './Parser'; 3 | import URI from 'vscode-uri'; 4 | import { Configuration } from './Configuration'; 5 | import { OutputProblemMatcher } from './OutputProblemMatcher'; 6 | import { PathLike } from 'fs'; 7 | import { ProblemCollection } from './ProblemCollection'; 8 | import { TestEventCollection } from './TestEventCollection'; 9 | import { TestRunner } from './TestRunner'; 10 | import { TestSuiteCollection } from './TestSuiteCollection'; 11 | import { WorkspaceFolder } from './WorkspaceFolder'; 12 | import { 13 | WorkspaceFolder as _WorkspaceFolder, 14 | Connection, 15 | } from 'vscode-languageserver'; 16 | 17 | export class WorkspaceFolders { 18 | private workspaceFolders: Map = new Map(); 19 | 20 | constructor(private connection: Connection, private _files = files) {} 21 | 22 | create(workspaceFolders: _WorkspaceFolder[]) { 23 | workspaceFolders.map(folder => { 24 | if (!this.workspaceFolders.has(folder.uri)) { 25 | this.workspaceFolders.set( 26 | folder.uri, 27 | this.createWorkspaceFolder(folder) 28 | ); 29 | } 30 | 31 | return this.workspaceFolders.get(folder.uri); 32 | }); 33 | 34 | return this; 35 | } 36 | 37 | async update(configurationCapability = true) { 38 | return Promise.all( 39 | Array.from(this.workspaceFolders.values()).map( 40 | async workspaceFolder => { 41 | await workspaceFolder 42 | .getConfig() 43 | .update(configurationCapability); 44 | } 45 | ) 46 | ); 47 | } 48 | 49 | get(uri: PathLike | URI): WorkspaceFolder { 50 | const _uri = this._files.asUri(uri).toString(); 51 | 52 | const current = Array.from(this.workspaceFolders.keys()) 53 | .sort((a, b) => b.length - a.length) 54 | .find(uri => _uri.indexOf(uri) !== -1); 55 | 56 | return this.workspaceFolders.get(current!)!; 57 | } 58 | 59 | all() { 60 | return Array.from(this.workspaceFolders.values()); 61 | } 62 | 63 | delete(workspaceFolders: _WorkspaceFolder[]) { 64 | workspaceFolders.forEach(folder => 65 | this.workspaceFolders.delete(folder.uri) 66 | ); 67 | 68 | return this; 69 | } 70 | 71 | private createWorkspaceFolder(workspaceFolder: _WorkspaceFolder) { 72 | const config = new Configuration(this.connection, workspaceFolder); 73 | const suites = new TestSuiteCollection(new Parser(workspaceFolder)); 74 | const events = new TestEventCollection(); 75 | const problems = new ProblemCollection(); 76 | const problemMatcher = new OutputProblemMatcher(suites); 77 | const testRunner = new TestRunner(); 78 | 79 | return new WorkspaceFolder( 80 | workspaceFolder, 81 | this.connection, 82 | config, 83 | suites, 84 | events, 85 | problems, 86 | problemMatcher, 87 | testRunner, 88 | this._files 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | import { Snippets } from './Snippets'; 2 | import { WorkspaceFolders } from './WorkspaceFolders'; 3 | import { 4 | createConnection, 5 | TextDocuments, 6 | // TextDocument, 7 | ProposedFeatures, 8 | InitializeParams, 9 | DidChangeConfigurationNotification, 10 | ExecuteCommandParams, 11 | // Position, 12 | // MessageType, 13 | // LogMessageNotification, 14 | // WillSaveTextDocumentWaitUntilRequest, 15 | // TextDocumentSaveReason, 16 | WorkspaceFolder as _WorkspaceFolder, 17 | CompletionItem, 18 | FileChangeType, 19 | } from 'vscode-languageserver'; 20 | 21 | // Create a connection for the server. The connection uses Node's IPC as a transport. 22 | // Also include all preview / proposed LSP features. 23 | let connection = createConnection(ProposedFeatures.all); 24 | 25 | // Create a simple text document manager. The text document manager 26 | // supports full document sync only 27 | let documents: TextDocuments = new TextDocuments(); 28 | const snippets = new Snippets(); 29 | const workspaceFolders = new WorkspaceFolders(connection); 30 | 31 | let hasConfigurationCapability: boolean = false; 32 | let hasWorkspaceFolderCapability: boolean = false; 33 | // let hasDiagnosticRelatedInformationCapability: boolean = false; 34 | 35 | connection.onInitialize((params: InitializeParams) => { 36 | workspaceFolders.create( 37 | params.workspaceFolders || [{ uri: params.rootUri || '', name: '' }] 38 | ); 39 | 40 | let capabilities = params.capabilities; 41 | 42 | // Does the client support the `workspace/configuration` request? 43 | // If not, we will fall back using global settings 44 | hasConfigurationCapability = 45 | !!capabilities.workspace && !!capabilities.workspace.configuration; 46 | 47 | hasWorkspaceFolderCapability = !!( 48 | capabilities.workspace && !!capabilities.workspace.workspaceFolders 49 | ); 50 | 51 | // hasDiagnosticRelatedInformationCapability = !!( 52 | // capabilities.textDocument && 53 | // capabilities.textDocument.publishDiagnostics && 54 | // capabilities.textDocument.publishDiagnostics.relatedInformation 55 | // ); 56 | 57 | // config.setConfigurationCapability(hasConfigurationCapability); 58 | 59 | return { 60 | capabilities: { 61 | textDocumentSync: documents.syncKind, 62 | completionProvider: { 63 | resolveProvider: true, 64 | }, 65 | codeLensProvider: { 66 | resolveProvider: true, 67 | }, 68 | executeCommandProvider: { 69 | commands: [ 70 | 'phpunit.lsp.load', 71 | 'phpunit.lsp.run-all', 72 | 'phpunit.lsp.rerun', 73 | 'phpunit.lsp.run-file', 74 | 'phpunit.lsp.run-test-at-cursor', 75 | 'phpunit.lsp.cancel', 76 | ], 77 | }, 78 | }, 79 | }; 80 | }); 81 | 82 | connection.onInitialized(async () => { 83 | if (hasConfigurationCapability) { 84 | // Register for all configuration changes. 85 | connection.client.register( 86 | DidChangeConfigurationNotification.type, 87 | undefined 88 | ); 89 | } 90 | if (hasWorkspaceFolderCapability) { 91 | connection.workspace.onDidChangeWorkspaceFolders(async params => { 92 | connection.console.log('Workspace folder change event received.'); 93 | 94 | workspaceFolders.create(params.added).delete(params.removed); 95 | 96 | await workspaceFolders.update(hasConfigurationCapability); 97 | }); 98 | } 99 | await workspaceFolders.update(hasConfigurationCapability); 100 | }); 101 | 102 | connection.onDidChangeConfiguration(async () => { 103 | await workspaceFolders.update(hasConfigurationCapability); 104 | }); 105 | 106 | // Only keep settings for open documents 107 | documents.onDidClose(() => { 108 | // connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] }); 109 | }); 110 | 111 | // The content of a text document has changed. This event is emitted 112 | // when the text document first opened or when its content has changed. 113 | documents.onDidChangeContent(() => {}); 114 | 115 | connection.onDidChangeWatchedFiles(async params => { 116 | const changes = (await Promise.all( 117 | params.changes.map( 118 | async event => 119 | await workspaceFolders.get(event.uri).detectChange(event) 120 | ) 121 | )).filter(suite => !!suite); 122 | 123 | if (changes.length > 0) { 124 | await Promise.all( 125 | params.changes.map(event => 126 | connection.sendDiagnostics({ 127 | uri: event.uri, 128 | diagnostics: [], 129 | }) 130 | ) 131 | ); 132 | 133 | await Promise.all( 134 | changes.map(suite => 135 | workspaceFolders.get(suite!.workspaceFolder!).loadTest() 136 | ) 137 | ); 138 | } 139 | 140 | await Promise.all(workspaceFolders.all().map(folder => folder.retryTest())); 141 | // connection.console.log('We received an file change event'); 142 | }); 143 | 144 | // This handler provides the initial list of the completion items. 145 | connection.onCompletion(() => { 146 | return snippets.all(); 147 | }); 148 | 149 | // This handler resolve additional information for the item selected in 150 | // the completion list. 151 | connection.onCompletionResolve((item: CompletionItem) => { 152 | return item; 153 | }); 154 | 155 | connection.onCodeLens(async params => { 156 | const uri = params.textDocument.uri; 157 | 158 | const suite = await workspaceFolders.get(uri).detectChange({ 159 | uri, 160 | type: FileChangeType.Changed, 161 | }); 162 | 163 | return suite ? suite.exportCodeLens() : []; 164 | }); 165 | 166 | connection.onExecuteCommand(async (params: ExecuteCommandParams) => { 167 | const command = params.command; 168 | const args: string[] = params.arguments || []; 169 | const workspaceFolder = args.shift() || ''; 170 | 171 | workspaceFolders.get(workspaceFolder).executeCommand({ 172 | command, 173 | arguments: args, 174 | }); 175 | }); 176 | 177 | /* 178 | connection.onDidOpenTextDocument((params) => { 179 | // A text document got opened in VSCode. 180 | // params.uri uniquely identifies the document. For documents store on disk this is a file URI. 181 | // params.text the initial full content of the document. 182 | connection.console.log(`${params.textDocument.uri} opened.`); 183 | }); 184 | connection.onDidChangeTextDocument((params) => { 185 | // The content of a text document did change in VSCode. 186 | // params.uri uniquely identifies the document. 187 | // params.contentChanges describe the content changes to the document. 188 | connection.console.log(`${params.textDocument.uri} changed: ${JSON.stringify(params.contentChanges)}`); 189 | }); 190 | connection.onDidCloseTextDocument((params) => { 191 | // A text document got closed in VSCode. 192 | // params.uri uniquely identifies the document. 193 | connection.console.log(`${params.textDocument.uri} closed.`); 194 | }); 195 | */ 196 | 197 | // Make the text document manager listen on the connection 198 | // for open, change and close text document events 199 | documents.listen(connection); 200 | 201 | // Listen on the connection 202 | connection.listen(); 203 | -------------------------------------------------------------------------------- /server/tests/Filesystem.test.ts: -------------------------------------------------------------------------------- 1 | import { Env, Filesystem } from '../src/Filesystem'; 2 | import { fixturePath, projectPath } from './helpers'; 3 | import { readFileSync, unlinkSync } from 'fs'; 4 | 5 | describe('Filesystem', () => { 6 | const paths = [ 7 | fixturePath('bin').fsPath, 8 | fixturePath('usr/local/bin').fsPath, 9 | ]; 10 | const env = new Env(paths, process.platform); 11 | const files = new Filesystem(env); 12 | 13 | it('get content from file', async () => { 14 | const uri = projectPath('tests/AssertionsTest.php').fsPath; 15 | 16 | const contents = await files.get(uri); 17 | 18 | expect(contents).toContain(readFileSync(uri).toString()); 19 | }); 20 | 21 | it('put content to file', async () => { 22 | const uri = fixturePath('write-file.txt').fsPath; 23 | 24 | expect(await files.put(uri, 'write file')).toBeTruthy(); 25 | 26 | unlinkSync(uri); 27 | }); 28 | 29 | it('which ls', async () => { 30 | expect(await files.which(['ls.exe', 'ls'])).toBe( 31 | fixturePath('bin/ls').fsPath 32 | ); 33 | }); 34 | 35 | it('findUp types/php-parser.d.ts', async () => { 36 | const file = await files.findup('types/php-parser.d.ts', { 37 | cwd: __filename, 38 | } as any); 39 | 40 | expect(file).toContain( 41 | fixturePath('../../types/php-parser.d.ts').fsPath 42 | ); 43 | }); 44 | 45 | it('lineAt', async () => { 46 | const uri = projectPath('tests/AssertionsTest.php'); 47 | 48 | const line = await files.lineAt(uri, 13); 49 | 50 | expect(line).toContain('$this->assertTrue(true);'); 51 | }); 52 | 53 | it('lineRange', async () => { 54 | const uri = projectPath('tests/AssertionsTest.php').fsPath; 55 | 56 | const range = await files.lineRange(uri, 13); 57 | 58 | expect(range).toEqual({ 59 | end: { line: 13, character: 32 }, 60 | start: { line: 13, character: 8 }, 61 | }); 62 | }); 63 | 64 | it('lineLocation', async () => { 65 | const uri = projectPath('tests/AssertionsTest.php'); 66 | 67 | const range = await files.lineLocation(uri, 13); 68 | 69 | expect(range).toEqual({ 70 | uri: uri.with({ scheme: 'file' }).toString(), 71 | range: { 72 | end: { line: 13, character: 32 }, 73 | start: { line: 13, character: 8 }, 74 | }, 75 | }); 76 | }); 77 | 78 | it('glob', async () => { 79 | const matches = await files.glob('**/*.php', { 80 | ignore: 'vendor/**', 81 | cwd: projectPath('').fsPath, 82 | }); 83 | 84 | expect(matches).toEqual([ 85 | 'src/Calculator.php', 86 | 'src/Item.php', 87 | 'tests/AbstractTest.php', 88 | 'tests/AssertionsTest.php', 89 | 'tests/bootstrap.php', 90 | 'tests/CalculatorTest.php', 91 | 'tests/Directory/HasPropertyTest.php', 92 | 'tests/Directory/LeadingCommentsTest.php', 93 | 'tests/Directory/UseTraitTest.php', 94 | 'tests/StaticMethodTest.php', 95 | ]); 96 | }); 97 | 98 | it('fix wndows path', () => { 99 | const env = new Env( 100 | [fixturePath('bin').fsPath, fixturePath('usr/local/bin').fsPath], 101 | 'win32' 102 | ); 103 | const files = new Filesystem(env); 104 | 105 | const uri = files.asUri('D:\\foo\\bar').with({ scheme: 'file' }); 106 | 107 | expect(uri.toString()).toEqual('file:///d%3A/foo/bar'); 108 | }); 109 | 110 | it('which cmd.cmd', async () => { 111 | const env = new Env([fixturePath('usr/local/bin').fsPath], 'win32'); 112 | const files = new Filesystem(env); 113 | 114 | expect(await files.which('cmd')).toBe( 115 | fixturePath('usr/local/bin/cmd.cmd').fsPath 116 | ); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /server/tests/Parser.test.ts: -------------------------------------------------------------------------------- 1 | import files from '../src/Filesystem'; 2 | import Parser from '../src/Parser'; 3 | import URI from 'vscode-uri'; 4 | import { projectPath } from './helpers'; 5 | import { TestSuiteNode } from '../src/TestNode'; 6 | import { TextDocument } from 'vscode-languageserver-protocol'; 7 | 8 | describe('Parser', () => { 9 | const workspaceFolder = URI.parse(__dirname).toString(); 10 | const parser = new Parser({ 11 | uri: workspaceFolder, 12 | name: '', 13 | }); 14 | const file = projectPath('tests/AssertionsTest.php'); 15 | 16 | const getId = ( 17 | id: string, 18 | clazz = 'Recca0120\\VSCode\\Tests\\AssertionsTest' 19 | ) => { 20 | return `${clazz}::${id}`; 21 | }; 22 | 23 | const getTestSuite = async ( 24 | testfile: URI = file 25 | ): Promise => { 26 | return await parser.parse(testfile); 27 | }; 28 | 29 | const getTest = (suite: TestSuiteNode, options: any = {}) => { 30 | return suite.children.find(test => { 31 | for (const key in options) { 32 | if ( 33 | JSON.stringify(test[key]) !== JSON.stringify(options[key]) 34 | ) { 35 | return false; 36 | } 37 | } 38 | return true; 39 | }); 40 | }; 41 | 42 | it('class', async () => { 43 | const suite = await getTestSuite(); 44 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 45 | const label = id; 46 | 47 | expect(suite).toEqual( 48 | jasmine.objectContaining({ 49 | workspaceFolder, 50 | id, 51 | label, 52 | }) 53 | ); 54 | }); 55 | 56 | it('passed', async () => { 57 | const suite = await getTestSuite(); 58 | 59 | const label = 'test_passed'; 60 | const id = getId(label); 61 | const test = getTest(suite, { id }); 62 | 63 | expect(test).toEqual( 64 | jasmine.objectContaining({ 65 | workspaceFolder, 66 | id: id, 67 | label, 68 | file: file.toString(), 69 | line: jasmine.anything(), 70 | }) 71 | ); 72 | }); 73 | 74 | it('failed', async () => { 75 | const suite = await getTestSuite(); 76 | 77 | const label = 'test_failed'; 78 | const id = getId(label); 79 | const test = getTest(suite, { id }); 80 | 81 | expect(test).toEqual( 82 | jasmine.objectContaining({ 83 | workspaceFolder, 84 | id: id, 85 | label, 86 | file: file.toString(), 87 | line: jasmine.any(Number), 88 | depends: ['test_passed'], 89 | }) 90 | ); 91 | }); 92 | 93 | it('test_isnt_same', async () => { 94 | const suite = await getTestSuite(); 95 | 96 | const label = 'test_isnt_same'; 97 | const id = getId(label); 98 | const test = getTest(suite, { id }); 99 | 100 | expect(test).toEqual( 101 | jasmine.objectContaining({ 102 | workspaceFolder, 103 | id: id, 104 | label, 105 | file: file.toString(), 106 | line: jasmine.anything(), 107 | }) 108 | ); 109 | }); 110 | 111 | it('test_risky', async () => { 112 | const suite = await getTestSuite(); 113 | 114 | const label = 'test_risky'; 115 | const id = getId(label); 116 | const test = getTest(suite, { id }); 117 | 118 | expect(test).toEqual( 119 | jasmine.objectContaining({ 120 | workspaceFolder, 121 | id: id, 122 | label, 123 | file: file.toString(), 124 | line: jasmine.any(Number), 125 | }) 126 | ); 127 | }); 128 | 129 | it('annotation_test', async () => { 130 | const suite = await getTestSuite(); 131 | 132 | const label = 'annotation_test'; 133 | const id = getId(label); 134 | const test = getTest(suite, { id }); 135 | 136 | expect(test).toEqual( 137 | jasmine.objectContaining({ 138 | workspaceFolder, 139 | id: id, 140 | label, 141 | file: file.toString(), 142 | line: jasmine.any(Number), 143 | }) 144 | ); 145 | }); 146 | 147 | it('test_skipped', async () => { 148 | const suite = await getTestSuite(); 149 | 150 | const label = 'test_skipped'; 151 | const id = getId(label); 152 | const test = getTest(suite, { id }); 153 | 154 | expect(test).toEqual( 155 | jasmine.objectContaining({ 156 | workspaceFolder, 157 | id: id, 158 | label, 159 | file: file.toString(), 160 | line: jasmine.any(Number), 161 | }) 162 | ); 163 | }); 164 | 165 | it('test_incomplete', async () => { 166 | const suite = await getTestSuite(); 167 | 168 | const label = 'test_incomplete'; 169 | const id = getId(label); 170 | const test = getTest(suite, { id }); 171 | 172 | expect(test).toEqual( 173 | jasmine.objectContaining({ 174 | workspaceFolder, 175 | id: id, 176 | label, 177 | file: file.toString(), 178 | line: jasmine.any(Number), 179 | }) 180 | ); 181 | }); 182 | 183 | it('addition_provider', async () => { 184 | const suite = await getTestSuite(); 185 | 186 | const label = 'addition_provider'; 187 | const id = getId(label); 188 | const test = getTest(suite, { id }); 189 | 190 | expect(test).toEqual( 191 | jasmine.objectContaining({ 192 | workspaceFolder, 193 | id: id, 194 | label, 195 | file: file.toString(), 196 | line: jasmine.any(Number), 197 | }) 198 | ); 199 | }); 200 | 201 | it('abstract class', async () => { 202 | const file = projectPath('tests/AbstractTest.php'); 203 | const suite = await getTestSuite(file); 204 | 205 | expect(suite).toBeUndefined(); 206 | }); 207 | 208 | it('static method', async () => { 209 | const file = projectPath('tests/StaticMethodTest.php'); 210 | const suite = await getTestSuite(file); 211 | const label = 'test_static_public_fail'; 212 | const id = `Recca0120\\VSCode\\Tests\\StaticMethodTest::${label}`; 213 | const test = getTest(suite, { id }); 214 | 215 | expect(test).toEqual( 216 | jasmine.objectContaining({ 217 | workspaceFolder, 218 | id: id, 219 | label, 220 | file: file.toString(), 221 | line: jasmine.any(Number), 222 | }) 223 | ); 224 | }); 225 | 226 | it('parse text document', async () => { 227 | const parser = new Parser({ 228 | uri: workspaceFolder, 229 | name: '', 230 | }); 231 | 232 | const suite = parser.parseTextDocument( 233 | TextDocument.create( 234 | file.toString(), 235 | 'php', 236 | 1, 237 | await files.get(file) 238 | ) 239 | ); 240 | 241 | const label = 'test_passed'; 242 | const id = getId(label); 243 | const test = getTest(suite, { id }); 244 | 245 | expect(test).toEqual( 246 | jasmine.objectContaining({ 247 | workspaceFolder, 248 | id: id, 249 | label, 250 | file: file.toString(), 251 | line: jasmine.any(Number), 252 | }) 253 | ); 254 | }); 255 | 256 | it('leading comments', async () => { 257 | const file = projectPath('tests/Directory/LeadingCommentsTest.php'); 258 | const suite = await getTestSuite(file); 259 | 260 | const label = 'firstLeadingComments'; 261 | const id = getId( 262 | label, 263 | 'Recca0120\\VSCode\\Tests\\Directory\\LeadingCommentsTest' 264 | ); 265 | const test = getTest(suite, { id }); 266 | 267 | expect(test).toEqual( 268 | jasmine.objectContaining({ 269 | workspaceFolder, 270 | id, 271 | label, 272 | file: file.toString(), 273 | line: jasmine.any(Number), 274 | }) 275 | ); 276 | }); 277 | 278 | it('use trait', async () => { 279 | const file = projectPath('tests/Directory/UseTraitTest.php'); 280 | const suite = await getTestSuite(file); 281 | 282 | const label = 'use_trait'; 283 | const id = getId( 284 | label, 285 | 'Recca0120\\VSCode\\Tests\\Directory\\UseTraitTest' 286 | ); 287 | const test = getTest(suite, { id }); 288 | 289 | expect(test).toEqual( 290 | jasmine.objectContaining({ 291 | workspaceFolder, 292 | id, 293 | label, 294 | file: file.toString(), 295 | line: jasmine.any(Number), 296 | }) 297 | ); 298 | }); 299 | 300 | it('has property', async () => { 301 | const file = projectPath('tests/Directory/HasPropertyTest.php'); 302 | const suite = await getTestSuite(file); 303 | 304 | const label = 'property'; 305 | const id = getId( 306 | label, 307 | 'Recca0120\\VSCode\\Tests\\Directory\\HasPropertyTest' 308 | ); 309 | const test = getTest(suite, { id }); 310 | 311 | expect(test).toEqual( 312 | jasmine.objectContaining({ 313 | workspaceFolder, 314 | id, 315 | label, 316 | file: file.toString(), 317 | line: jasmine.any(Number), 318 | }) 319 | ); 320 | }); 321 | 322 | it('parse code error', () => { 323 | const parser = new Parser(); 324 | 325 | expect( 326 | parser.parseCode('a"bcde', URI.parse('/usr/bin')) 327 | ).toBeUndefined(); 328 | }); 329 | 330 | it('class as codelens', async () => { 331 | const suite = await getTestSuite(); 332 | 333 | expect(suite.asCodeLens()).toEqual({ 334 | range: suite.range, 335 | command: { 336 | title: 'Run Test', 337 | command: 'phpunit.lsp.run-test-at-cursor', 338 | arguments: [workspaceFolder.toString(), suite.id], 339 | }, 340 | }); 341 | }); 342 | 343 | it('method as codelens', async () => { 344 | const suite = await getTestSuite(); 345 | 346 | const test = getTest(suite, { 347 | method: 'test_failed', 348 | depends: ['test_passed'], 349 | }); 350 | 351 | expect(test.asCodeLens()).toEqual({ 352 | range: test.range, 353 | command: { 354 | title: 'Run Test', 355 | command: 'phpunit.lsp.run-test-at-cursor', 356 | arguments: [workspaceFolder.toString(), test.id], 357 | }, 358 | }); 359 | }); 360 | }); 361 | -------------------------------------------------------------------------------- /server/tests/ProblemMatcher.test.ts: -------------------------------------------------------------------------------- 1 | import { fixturePath, projectPath } from './helpers'; 2 | import { OutputProblemMatcher } from '../src/OutputProblemMatcher'; 3 | import { readFileSync } from 'fs'; 4 | import { Status } from '../src/ProblemNode'; 5 | import { TestSuiteCollection } from '../src/TestSuiteCollection'; 6 | 7 | describe('OutputProblemMatcher', () => { 8 | const file = fixturePath('test-result.txt').fsPath; 9 | const contents: string = readFileSync(file).toString('UTF-8'); 10 | const suites = new TestSuiteCollection(); 11 | const problemMatcher = new OutputProblemMatcher(suites); 12 | 13 | let problems: any[] = []; 14 | 15 | function getProblem(id: string) { 16 | return problems.find(problem => problem.id === id); 17 | } 18 | 19 | beforeAll(async () => { 20 | await suites.load('**/*.php', { cwd: projectPath('tests').fsPath }); 21 | }); 22 | 23 | beforeEach(async () => { 24 | problems = await problemMatcher.parse(contents); 25 | }); 26 | 27 | describe('Problem', () => { 28 | it('test_isnt_same', () => { 29 | const id = 30 | 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_isnt_same'; 31 | const problem = getProblem(id); 32 | 33 | expect({ 34 | type: problem.type, 35 | namespace: problem.namespace, 36 | class: problem.class, 37 | method: problem.method, 38 | status: problem.status, 39 | line: problem.line 40 | }).toMatchObject({ 41 | type: 'problem', 42 | namespace: 'Recca0120\\VSCode\\Tests', 43 | class: 'AssertionsTest', 44 | method: 'test_isnt_same', 45 | status: Status.FAILURE, 46 | line: 26 47 | }); 48 | 49 | expect(problem.message).toContain('Failed asserting that two arrays are identical.') 50 | }); 51 | 52 | it('addition_provider', () => { 53 | const id = 54 | 'Recca0120\\VSCode\\Tests\\AssertionsTest::addition_provider'; 55 | const problem = getProblem(id); 56 | 57 | expect({ 58 | type: problem.type, 59 | namespace: problem.namespace, 60 | class: problem.class, 61 | method: problem.method, 62 | status: problem.status, 63 | line: problem.line 64 | }).toMatchObject({ 65 | type: 'problem', 66 | namespace: 'Recca0120\\VSCode\\Tests', 67 | class: 'AssertionsTest', 68 | method: 'addition_provider', 69 | status: Status.FAILURE, 70 | line: 58 71 | }); 72 | 73 | expect(problem.message).toContain(`Failed asserting that 1 matches expected 2.`); 74 | }); 75 | 76 | it('test_failed', () => { 77 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_failed'; 78 | const problem = getProblem(id); 79 | 80 | expect({ 81 | type: problem.type, 82 | namespace: problem.namespace, 83 | class: problem.class, 84 | method: problem.method, 85 | status: problem.status, 86 | line: problem.line 87 | }).toMatchObject({ 88 | type: 'problem', 89 | namespace: 'Recca0120\\VSCode\\Tests', 90 | class: 'AssertionsTest', 91 | method: 'test_failed', 92 | status: Status.FAILURE, 93 | line: 21 94 | }); 95 | 96 | expect(problem.message).toContain('Failed asserting that false is true.'); 97 | }); 98 | 99 | it('test_risky', () => { 100 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_risky'; 101 | const problem = getProblem(id); 102 | 103 | expect({ 104 | type: problem.type, 105 | namespace: problem.namespace, 106 | class: problem.class, 107 | method: problem.method, 108 | status: problem.status, 109 | line: problem.line 110 | }).toMatchObject({ 111 | type: 'problem', 112 | namespace: 'Recca0120\\VSCode\\Tests', 113 | class: 'AssertionsTest', 114 | method: 'test_risky', 115 | status: Status.RISKY, 116 | line: 29 117 | }); 118 | 119 | expect(problem.message).toContain('This test did not perform any assertions'); 120 | }); 121 | 122 | it('test_incomplete', () => { 123 | const id = 124 | 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_incomplete'; 125 | const problem = getProblem(id); 126 | 127 | expect({ 128 | type: problem.type, 129 | namespace: problem.namespace, 130 | class: problem.class, 131 | method: problem.method, 132 | status: problem.status, 133 | line: problem.line 134 | }).toMatchObject({ 135 | type: 'problem', 136 | namespace: 'Recca0120\\VSCode\\Tests', 137 | class: 'AssertionsTest', 138 | method: 'test_incomplete', 139 | status: Status.INCOMPLETE, 140 | line: 49 141 | }); 142 | 143 | expect(problem.message).toContain('This test has not been implemented yet.'); 144 | }); 145 | 146 | it('test_skipped', () => { 147 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_skipped'; 148 | const problem = getProblem(id); 149 | 150 | expect({ 151 | type: problem.type, 152 | namespace: problem.namespace, 153 | class: problem.class, 154 | method: problem.method, 155 | status: problem.status, 156 | line: problem.line 157 | }).toMatchObject({ 158 | type: 'problem', 159 | namespace: 'Recca0120\\VSCode\\Tests', 160 | class: 'AssertionsTest', 161 | method: 'test_skipped', 162 | status: Status.SKIPPED, 163 | line: 44 164 | }); 165 | 166 | expect(problem.message).toContain('The MySQLi extension is not available.'); 167 | }); 168 | 169 | it('test_sum_item_method_not_call', () => { 170 | const id = 171 | 'Recca0120\\VSCode\\Tests\\CalculatorTest::test_sum_item_method_not_call'; 172 | const problem = getProblem(id); 173 | 174 | expect(problem).toMatchObject({ 175 | type: 'problem', 176 | id, 177 | namespace: 'Recca0120\\VSCode\\Tests', 178 | class: 'CalculatorTest', 179 | method: 'test_sum_item_method_not_call', 180 | status: Status.FAILURE, 181 | file: projectPath('tests/CalculatorTest.php').toString(), 182 | line: 38, 183 | message: jasmine.anything(), 184 | files: jasmine.anything(), 185 | }); 186 | 187 | expect(problem.message).toContain( 188 | `Mockery\\Exception\\InvalidCountException: Method test() from Mockery_0_Recca0120_VSCode_Item_Recca0120_VSCode_Item should be called 189 | exactly 1 times but called 0 times.` 190 | ); 191 | }); 192 | 193 | it('test_throw_exception', () => { 194 | const id = 195 | 'Recca0120\\VSCode\\Tests\\CalculatorTest::test_throw_exception'; 196 | const problem = getProblem(id); 197 | 198 | expect({ 199 | type: problem.type, 200 | namespace: problem.namespace, 201 | class: problem.class, 202 | method: problem.method, 203 | status: problem.status, 204 | line: problem.line 205 | }).toMatchObject({ 206 | type: 'problem', 207 | namespace: 'Recca0120\\VSCode\\Tests', 208 | class: 'CalculatorTest', 209 | method: 'test_throw_exception', 210 | status: Status.FAILURE, 211 | line: 53 212 | }); 213 | 214 | expect(problem.message).toContain('Exception:'); 215 | }); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /server/tests/Process.test.ts: -------------------------------------------------------------------------------- 1 | import files from '../src/Filesystem'; 2 | import { Process } from '../src/Process'; 3 | import { projectPath } from './helpers'; 4 | 5 | describe('Process', () => { 6 | it('running phpunit', async () => { 7 | const phpUnitBinary = await files.findup( 8 | ['vendor/bin/phpunit', 'phpunit'], 9 | { cwd: projectPath('tests').fsPath } 10 | ); 11 | const process = new Process(); 12 | const command = { 13 | title: 'phpunit', 14 | command: phpUnitBinary || '', 15 | arguments: ['--configuration', projectPath('phpunit.xml').fsPath], 16 | }; 17 | 18 | const response = await process.run(command); 19 | 20 | expect(response).toMatch('PHPUnit'); 21 | }); 22 | 23 | it('kill', done => { 24 | const process = new Process(); 25 | const run = function(process: Process, cb: any) { 26 | process 27 | .run({ 28 | title: '', 29 | command: 'sleep', 30 | arguments: [5], 31 | }) 32 | .catch(cb); 33 | }; 34 | 35 | const caller = { 36 | catch: function(error: Error) { 37 | expect(error).toEqual('killed'); 38 | }, 39 | }; 40 | spyOn(caller, 'catch').and.callThrough(); 41 | 42 | expect(process.kill()).toBeFalsy(); 43 | run(process, caller.catch); 44 | expect(process.kill()).toBeTruthy(); 45 | setTimeout(() => { 46 | expect(caller.catch).toBeCalledTimes(1); 47 | done(); 48 | }, 100); 49 | }); 50 | 51 | it('command not found', async () => { 52 | const process = new Process(); 53 | const response = await process.run({ 54 | title: 'test', 55 | command: 'abc', 56 | arguments: ['def', 'xyz'], 57 | }); 58 | 59 | expect(response).toEqual('spawn abc ENOENT'); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /server/tests/TestEventCollection.test.ts: -------------------------------------------------------------------------------- 1 | import files from '../src/Filesystem'; 2 | import { fixturePath, projectPath } from './helpers'; 3 | import { OutputProblemMatcher } from '../src/OutputProblemMatcher'; 4 | import { ProblemNode } from '../src/ProblemNode'; 5 | import { TestEvent, TestSuiteEvent } from '../src/TestExplorer'; 6 | import { TestEventCollection } from '../src/TestEventCollection'; 7 | import { TestSuiteCollection } from '../src/TestSuiteCollection'; 8 | 9 | describe('TestEventCollection', () => { 10 | const cwd = projectPath('').fsPath; 11 | const pattern = 'tests/**/*.php'; 12 | const suites = new TestSuiteCollection(); 13 | const events = new TestEventCollection(); 14 | 15 | beforeAll(async () => { 16 | await suites.load(pattern, { cwd: cwd }); 17 | }); 18 | 19 | it('instance', () => { 20 | expect(events).toBeInstanceOf(TestEventCollection); 21 | }); 22 | 23 | it('put test info', async () => { 24 | const suite = suites.get(projectPath('tests/AssertionsTest.php')); 25 | 26 | events.put(suite.children[0]); 27 | 28 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_passed'; 29 | expect(events.get(id)).toEqual({ 30 | type: 'test', 31 | test: id, 32 | state: 'running', 33 | }); 34 | }); 35 | 36 | describe('put test suite info', () => { 37 | beforeAll(async () => { 38 | const suite = suites.get(projectPath('tests/AssertionsTest.php')); 39 | 40 | events.put(suite); 41 | }); 42 | 43 | it('get test suite event', () => { 44 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 45 | expect(events.get(id)).toEqual({ 46 | type: 'suite', 47 | suite: id, 48 | state: 'running', 49 | }); 50 | }); 51 | 52 | it('get test event', () => { 53 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_passed'; 54 | 55 | expect(events.get(id)).toEqual({ 56 | type: 'test', 57 | test: id, 58 | state: 'running', 59 | }); 60 | }); 61 | }); 62 | 63 | describe('modify test suite info', () => { 64 | beforeAll(async () => { 65 | const suite = suites.get(projectPath('tests/AssertionsTest.php')); 66 | 67 | events.put(suite); 68 | }); 69 | 70 | it('modify test suite event', () => { 71 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 72 | const suite = events.get(id) as TestSuiteEvent; 73 | suite.state = 'completed'; 74 | 75 | expect(events.get(id)).toEqual({ 76 | type: 'suite', 77 | suite: id, 78 | state: 'completed', 79 | }); 80 | }); 81 | 82 | it('modify test event', () => { 83 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_passed'; 84 | const test = events.get(id) as TestEvent; 85 | test.state = 'passed'; 86 | events.put(test); 87 | 88 | expect(events.get(id)).toEqual({ 89 | type: 'test', 90 | test: id, 91 | state: 'passed', 92 | }); 93 | }); 94 | }); 95 | 96 | describe('put problems', () => { 97 | let problems: ProblemNode[]; 98 | 99 | function findProblem(id: string) { 100 | return problems.filter(problem => problem.id === id)[0]; 101 | } 102 | 103 | beforeAll(async () => { 104 | problems = await new OutputProblemMatcher().parse( 105 | await files.get(fixturePath('test-result.txt')) 106 | ); 107 | }); 108 | 109 | it('put problem', () => { 110 | const id = 111 | 'Recca0120\\VSCode\\Tests\\AssertionsTest::addition_provider'; 112 | const problem = findProblem(id); 113 | 114 | events.put(problem); 115 | 116 | expect(events.get(id)).toEqual( 117 | jasmine.objectContaining({ 118 | type: 'test', 119 | test: id, 120 | state: 'failed', 121 | }) 122 | ); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /server/tests/TestResponse.test.ts: -------------------------------------------------------------------------------- 1 | import files from '../src/Filesystem'; 2 | import { fixturePath } from './helpers'; 3 | import { OutputProblemMatcher } from '../src/OutputProblemMatcher'; 4 | import { ProblemNode, Status } from '../src/ProblemNode'; 5 | import { TestResponse, TestResult } from '../src/TestResponse'; 6 | 7 | describe('TestResponse', () => { 8 | const problemMatcher = new OutputProblemMatcher(); 9 | 10 | let testResponse: TestResponse; 11 | 12 | describe('PHPUnit', () => { 13 | it('assertion ok', () => { 14 | testResponse = new TestResponse( 15 | 'OK (1 test, 1 assertion)', 16 | problemMatcher 17 | ); 18 | const result: TestResult = testResponse.getTestResult(); 19 | 20 | expect(result.tests).toEqual(1); 21 | expect(result.assertions).toEqual(1); 22 | }); 23 | 24 | it('assertions ok', () => { 25 | testResponse = new TestResponse( 26 | 'OK (2 tests, 2 assertions)', 27 | problemMatcher 28 | ); 29 | const result: TestResult = testResponse.getTestResult(); 30 | 31 | expect(result.tests).toEqual(2); 32 | expect(result.assertions).toEqual(2); 33 | }); 34 | 35 | it('assertions has errors', () => { 36 | testResponse = new TestResponse( 37 | `ERRORS! 38 | Test: 20, Assertions: 14, Errors: 2, Failures: 4, Warnings: 2, Skipped: 1, Incomplete: 1, Risky: 2.`, 39 | problemMatcher 40 | ); 41 | const result: TestResult = testResponse.getTestResult(); 42 | 43 | expect(result).toEqual({ 44 | tests: 20, 45 | assertions: 14, 46 | errors: 2, 47 | failures: 4, 48 | warnings: 2, 49 | skipped: 1, 50 | incomplete: 1, 51 | risky: 2, 52 | }); 53 | }); 54 | 55 | it('no tests executed', () => { 56 | testResponse = new TestResponse( 57 | 'No tests executed!', 58 | problemMatcher 59 | ); 60 | const result: TestResult = testResponse.getTestResult(); 61 | 62 | expect(result.tests).toEqual(0); 63 | }); 64 | 65 | it('OK, but incomplete, skipped, or risky tests!', () => { 66 | testResponse = new TestResponse( 67 | `OK, but incomplete, skipped, or risky tests! 68 | Tests: 3, Assertions: 2, Skipped: 1. 69 | `, 70 | problemMatcher 71 | ); 72 | const result: TestResult = testResponse.getTestResult(); 73 | 74 | expect(result).toMatchObject({ 75 | tests: 3, 76 | assertions: 2, 77 | skipped: 1, 78 | }); 79 | }); 80 | }); 81 | 82 | describe('problems', () => { 83 | let output = ''; 84 | let problems: ProblemNode[]; 85 | 86 | beforeAll(async () => { 87 | output = await files.get(fixturePath('test-result.txt')); 88 | testResponse = new TestResponse(output, problemMatcher); 89 | }); 90 | 91 | it('output', () => { 92 | expect(testResponse.toString()).toEqual(output); 93 | }); 94 | 95 | it('test_sum_item_method_not_call', async () => { 96 | problems = await testResponse.asProblems(); 97 | expect(problems[0]).toMatchObject({ 98 | type: 'problem', 99 | id: 100 | 'Recca0120\\VSCode\\Tests\\CalculatorTest::test_sum_item_method_not_call', 101 | namespace: 'Recca0120\\VSCode\\Tests', 102 | class: 'CalculatorTest', 103 | method: 'test_sum_item_method_not_call', 104 | file: '', 105 | line: jasmine.any(Number), 106 | status: Status.FAILURE, 107 | message: jasmine.any(String), 108 | files: jasmine.anything(), 109 | }); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /server/tests/TestRunner.test.ts: -------------------------------------------------------------------------------- 1 | import { Filesystem } from '../src/Filesystem'; 2 | import { Process } from '../src/Process'; 3 | import { TestRunner } from '../src/TestRunner'; 4 | 5 | describe('TestRunner', () => { 6 | let process: Process; 7 | let files: Filesystem; 8 | let testRunner: TestRunner; 9 | 10 | beforeEach(async () => { 11 | process = new Process(); 12 | files = new Filesystem(); 13 | testRunner = new TestRunner(process, files); 14 | }); 15 | 16 | describe('run', () => { 17 | beforeEach(() => { 18 | spyOn(process, 'run').and.returnValue('PHPUnit'); 19 | }); 20 | 21 | afterEach(() => { 22 | expect(testRunner.getOutput()).toEqual('PHPUnit'); 23 | }); 24 | 25 | describe('configuration', () => { 26 | beforeEach(() => { 27 | spyOn(files, 'findup').and.returnValues( 28 | 'phpunit', 29 | 'phpunit.xml' 30 | ); 31 | }); 32 | 33 | afterEach(() => { 34 | expect(files.findup).toHaveBeenCalledWith( 35 | ['vendor/bin/phpunit', 'phpunit'], 36 | undefined 37 | ); 38 | expect(files.findup).toHaveBeenCalledWith( 39 | ['phpunit.xml', 'phpunit.xml.dist'], 40 | undefined 41 | ); 42 | }); 43 | 44 | it('run all', async () => { 45 | await testRunner.run(); 46 | 47 | expect(process.run).toBeCalledWith( 48 | { 49 | title: 'PHPUnit LSP', 50 | command: 'phpunit', 51 | arguments: ['-c', 'phpunit.xml'], 52 | }, 53 | undefined 54 | ); 55 | }); 56 | 57 | it('run file', async () => { 58 | const params = { 59 | file: '/foo.php', 60 | }; 61 | 62 | await testRunner.run(params); 63 | 64 | expect(process.run).toHaveBeenCalledWith( 65 | { 66 | title: 'PHPUnit LSP', 67 | command: 'phpunit', 68 | arguments: ['-c', 'phpunit.xml', params.file], 69 | }, 70 | undefined 71 | ); 72 | }); 73 | 74 | it('rerun', async () => { 75 | const params = { 76 | file: '/foo.php', 77 | }; 78 | 79 | await testRunner.run(params); 80 | await testRunner.rerun({}); 81 | 82 | expect(process.run).toHaveBeenCalledTimes(2); 83 | expect(process.run).toHaveBeenCalledWith( 84 | { 85 | title: 'PHPUnit LSP', 86 | command: 'phpunit', 87 | arguments: ['-c', 'phpunit.xml', params.file], 88 | }, 89 | undefined 90 | ); 91 | }); 92 | 93 | it('run test', async () => { 94 | const params = { 95 | file: '/foo.php', 96 | method: 'test_passed', 97 | depends: ['test_failed'], 98 | }; 99 | 100 | await testRunner.run(params); 101 | 102 | expect(process.run).toHaveBeenCalledWith( 103 | { 104 | title: 'PHPUnit LSP', 105 | command: 'phpunit', 106 | arguments: [ 107 | '-c', 108 | 'phpunit.xml', 109 | '--filter', 110 | '/^.*::test_passed|test_failed.*$/', 111 | params.file, 112 | ], 113 | }, 114 | undefined 115 | ); 116 | }); 117 | }); 118 | 119 | it('custom php, phpunit, args', async () => { 120 | spyOn(files, 'findup').and.returnValues('phpunit.ini'); 121 | 122 | testRunner 123 | .setPhpBinary('/php') 124 | .setPhpUnitBinary('/phpunit') 125 | .setArgs(['foo', 'bar']); 126 | 127 | await testRunner.run(); 128 | 129 | expect(process.run).toHaveBeenCalledWith( 130 | { 131 | title: 'PHPUnit LSP', 132 | command: '/php', 133 | arguments: ['/phpunit', '-c', 'phpunit.ini', 'foo', 'bar'], 134 | }, 135 | undefined 136 | ); 137 | }); 138 | }); 139 | 140 | it('cancel', async () => { 141 | spyOn(process, 'kill'); 142 | await testRunner.cancel(); 143 | expect(process.kill).toBeCalled(); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /server/tests/TestSuiteCollection.test.ts: -------------------------------------------------------------------------------- 1 | import files from '../src/Filesystem'; 2 | import { projectPath } from './helpers'; 3 | import { TestSuiteCollection } from '../src/TestSuiteCollection'; 4 | import { TextDocument } from 'vscode-languageserver-protocol'; 5 | 6 | describe('TestSuiteCollection', () => { 7 | const cwd = projectPath('').fsPath; 8 | const pattern = 'tests/**/*.php'; 9 | const suites = new TestSuiteCollection(); 10 | const getLabelById = function(id: string) { 11 | return id; 12 | }; 13 | 14 | it('instance', () => { 15 | expect(suites).toBeInstanceOf(TestSuiteCollection); 16 | }); 17 | 18 | describe('all', () => { 19 | let items: any[]; 20 | 21 | beforeAll(async () => { 22 | items = (await suites.load(pattern, { 23 | cwd: cwd, 24 | })) 25 | .all() 26 | .map(suite => { 27 | return { 28 | id: suite.id, 29 | label: suite.label, 30 | }; 31 | }); 32 | }); 33 | 34 | it('Recca0120\\VSCode\\Tests\\AssertionsTest', () => { 35 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 36 | const label = getLabelById(id); 37 | 38 | expect(items.find(item => item.id === id)).toEqual({ 39 | id, 40 | label, 41 | }); 42 | }); 43 | 44 | it('Recca0120\\VSCode\\Tests\\CalculatorTest', () => { 45 | const id = 'Recca0120\\VSCode\\Tests\\CalculatorTest'; 46 | const label = getLabelById(id); 47 | 48 | expect(items.find(item => item.id === id)).toEqual({ 49 | id, 50 | label, 51 | }); 52 | }); 53 | 54 | it('Recca0120\\VSCode\\Tests\\Directory\\HasPropertyTest', () => { 55 | const id = 'Recca0120\\VSCode\\Tests\\Directory\\HasPropertyTest'; 56 | const label = getLabelById(id); 57 | 58 | expect(items.find(item => item.id === id)).toEqual({ 59 | id, 60 | label, 61 | }); 62 | }); 63 | 64 | it('Recca0120\\VSCode\\Tests\\Directory\\LeadingCommentsTest', () => { 65 | const id = 66 | 'Recca0120\\VSCode\\Tests\\Directory\\LeadingCommentsTest'; 67 | const label = getLabelById(id); 68 | 69 | expect(items.find(item => item.id === id)).toEqual({ 70 | id, 71 | label, 72 | }); 73 | }); 74 | }); 75 | 76 | it('get', async () => { 77 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 78 | const label = getLabelById(id); 79 | 80 | expect( 81 | await suites.get(projectPath('tests/AssertionsTest.php')) 82 | ).toMatchObject({ 83 | id, 84 | label, 85 | }); 86 | }); 87 | 88 | it('put text document', async () => { 89 | const file = projectPath('tests/AssertionsTest.php'); 90 | const textDocument = TextDocument.create( 91 | 'foo.php', 92 | 'php', 93 | 0, 94 | await files.get(file) 95 | ); 96 | 97 | suites.putTextDocument(textDocument); 98 | 99 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 100 | const label = getLabelById(id); 101 | 102 | expect(await suites.get(file)).toMatchObject({ 103 | id, 104 | label, 105 | }); 106 | }); 107 | 108 | it('find test suite', async () => { 109 | await suites.load(pattern, { cwd: cwd }); 110 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 111 | const test = suites.find(id); 112 | 113 | expect(test).toMatchObject({ 114 | id: id, 115 | }); 116 | }); 117 | 118 | it('find test', async () => { 119 | await suites.load(pattern, { cwd: cwd }); 120 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_passed'; 121 | 122 | const test = suites.find(id); 123 | 124 | expect(test).toMatchObject({ 125 | id: id, 126 | }); 127 | }); 128 | 129 | it('where test', async () => { 130 | await suites.load(pattern, { cwd: cwd }); 131 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest::test_passed'; 132 | 133 | const tests = suites.where(test => { 134 | return test.id === id; 135 | }); 136 | 137 | expect(tests[0]).toMatchObject({ 138 | id: id, 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /server/tests/WorkspaceFolder.test.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from '../src/Configuration'; 2 | import { LogMessageNotification, MessageType } from 'vscode-languageserver'; 3 | import { OutputProblemMatcher } from '../src/OutputProblemMatcher'; 4 | import { ProblemCollection } from '../src/ProblemCollection'; 5 | import { projectPath } from './helpers'; 6 | import { TestEventCollection } from '../src/TestEventCollection'; 7 | import { TestRunner } from '../src/TestRunner'; 8 | import { TestSuiteCollection } from '../src/TestSuiteCollection'; 9 | import { WorkspaceFolder } from '../src/WorkspaceFolder'; 10 | 11 | describe('WorkspaceFolder', () => { 12 | const folder = { 13 | uri: projectPath('').toString(), 14 | name: '', 15 | }; 16 | 17 | const connection: any = { 18 | notifications: {}, 19 | requests: {}, 20 | onNotification: (name: string, cb: Function) => { 21 | connection.notifications[name] = cb; 22 | }, 23 | triggerNotification: (name: string, params?: any) => { 24 | return connection.notifications[name](params); 25 | }, 26 | sendNotification: () => {}, 27 | onRequest: (name: string, cb: Function) => { 28 | connection.requests[name] = cb; 29 | }, 30 | triggerRequest: (name: string, params?: any) => { 31 | return connection.requests[name](params); 32 | }, 33 | sendRequest: () => {}, 34 | sendDiagnostics: () => {}, 35 | }; 36 | const config = new Configuration(connection, folder); 37 | const suites = new TestSuiteCollection(); 38 | const events = new TestEventCollection(); 39 | const problems = new ProblemCollection(); 40 | const problemMatcher = new OutputProblemMatcher(suites); 41 | const testRunner = new TestRunner(); 42 | 43 | const workspaceFolder = new WorkspaceFolder( 44 | folder, 45 | connection, 46 | config, 47 | suites, 48 | events, 49 | problems, 50 | problemMatcher, 51 | testRunner 52 | ); 53 | 54 | it('TestRunStartedEvent Run All', async () => { 55 | spyOn(workspaceFolder, 'executeCommand'); 56 | await connection.triggerNotification( 57 | workspaceFolder.requestName('TestLoadStartedEvent') 58 | ); 59 | await connection.triggerNotification( 60 | workspaceFolder.requestName('TestRunStartedEvent'), 61 | { 62 | tests: ['root'], 63 | } 64 | ); 65 | 66 | expect(workspaceFolder.executeCommand).toHaveBeenCalledWith({ 67 | command: 'phpunit.lsp.run-all', 68 | arguments: [], 69 | }); 70 | }); 71 | 72 | it('TestRunStartedEvent Run Test At Cursor', async () => { 73 | spyOn(workspaceFolder, 'executeCommand'); 74 | await connection.triggerNotification( 75 | workspaceFolder.requestName('TestLoadStartedEvent') 76 | ); 77 | await connection.triggerNotification( 78 | workspaceFolder.requestName('TestRunStartedEvent'), 79 | { 80 | tests: ['foo'], 81 | } 82 | ); 83 | 84 | expect(workspaceFolder.executeCommand).toHaveBeenCalledWith({ 85 | command: 'phpunit.lsp.run-test-at-cursor', 86 | arguments: ['foo'], 87 | }); 88 | }); 89 | 90 | describe('execute command', () => { 91 | beforeAll(async () => { 92 | await workspaceFolder.loadTest(); 93 | }); 94 | 95 | beforeEach(() => { 96 | spyOn(connection, 'sendNotification'); 97 | spyOn(connection, 'sendRequest'); 98 | }); 99 | 100 | afterEach(() => { 101 | expect(connection.sendRequest).toHaveBeenCalledWith( 102 | workspaceFolder.requestName('TestRunStartedEvent'), 103 | jasmine.anything() 104 | ); 105 | expect(connection.sendNotification).toHaveBeenCalledWith( 106 | 'TestRunStartedEvent', 107 | jasmine.anything() 108 | ); 109 | expect(connection.sendNotification).toHaveBeenCalledWith( 110 | 'TestRunFinishedEvent', 111 | jasmine.anything() 112 | ); 113 | expect(connection.sendNotification).toHaveBeenCalledWith( 114 | LogMessageNotification.type, 115 | { 116 | type: MessageType.Log, 117 | message: jasmine.any(String), 118 | } 119 | ); 120 | }); 121 | 122 | it('run all', async () => { 123 | await workspaceFolder.executeCommand({ 124 | command: 'phpunit.lsp.run-all', 125 | }); 126 | 127 | expect(connection.sendRequest).toHaveBeenCalledWith( 128 | workspaceFolder.requestName('TestRunFinishedEvent'), 129 | jasmine.anything() 130 | ); 131 | }); 132 | 133 | it('run file', async () => { 134 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 135 | 136 | await workspaceFolder.executeCommand({ 137 | command: 'phpunit.lsp.run-file', 138 | arguments: [id], 139 | }); 140 | 141 | expect(connection.sendRequest).toHaveBeenCalledWith( 142 | workspaceFolder.requestName('TestRunFinishedEvent'), 143 | jasmine.anything() 144 | ); 145 | }); 146 | 147 | it('run test at cursor with id', async () => { 148 | const id = 'Recca0120\\VSCode\\Tests\\AssertionsTest'; 149 | 150 | await workspaceFolder.executeCommand({ 151 | command: 'phpunit.lsp.run-test-at-cursor', 152 | arguments: [id], 153 | }); 154 | 155 | expect(connection.sendRequest).toHaveBeenCalledWith( 156 | workspaceFolder.requestName('TestRunFinishedEvent'), 157 | jasmine.anything() 158 | ); 159 | }); 160 | 161 | it('run test at cursor with cursor', async () => { 162 | const file = projectPath('tests/AssertionsTest.php').toString(); 163 | 164 | await workspaceFolder.executeCommand({ 165 | command: 'phpunit.lsp.run-test-at-cursor', 166 | arguments: [file, 14], 167 | }); 168 | 169 | expect(connection.sendRequest).toHaveBeenCalledWith( 170 | workspaceFolder.requestName('TestRunFinishedEvent'), 171 | jasmine.anything() 172 | ); 173 | }); 174 | 175 | it('rerun', async () => { 176 | const file = projectPath('tests/AssertionsTest.php').toString(); 177 | 178 | await workspaceFolder.executeCommand({ 179 | command: 'phpunit.lsp.rerun', 180 | arguments: [file, 14], 181 | }); 182 | 183 | expect(connection.sendRequest).toHaveBeenCalledWith( 184 | workspaceFolder.requestName('TestRunFinishedEvent'), 185 | jasmine.anything() 186 | ); 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /server/tests/fixtures/bin/cmd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renandelmonico/vscode-phpunit/c8a0476e68c1fe3593a1fe4147f6a9aa8a6e167f/server/tests/fixtures/bin/cmd -------------------------------------------------------------------------------- /server/tests/fixtures/bin/ls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renandelmonico/vscode-phpunit/c8a0476e68c1fe3593a1fe4147f6a9aa8a6e167f/server/tests/fixtures/bin/ls -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /vendor 3 | .phpunit.result.cache -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recca0120/vscode-phpunit", 3 | "autoload": { 4 | "psr-4": { 5 | "Recca0120\\VSCode\\": "src/", 6 | "Recca0120\\VSCode\\Tests\\": "tests/" 7 | } 8 | }, 9 | "require": { 10 | "phpunit/phpunit": "^8.1" 11 | }, 12 | "authors": [ 13 | { 14 | "name": "recca0120", 15 | "email": "recca0120@gmail.com" 16 | } 17 | ], 18 | "require-dev": { 19 | "mockery/mockery": "^1.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/phpunit.ini: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./tests 17 | 18 | 19 | 20 | 21 | ./src 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./tests 17 | 18 | 19 | 20 | 21 | ./src 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./tests 17 | 18 | 19 | 20 | 21 | ./src 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/src/Calculator.php: -------------------------------------------------------------------------------- 1 | value() + $b->value(); 17 | } 18 | 19 | public function throwException() 20 | { 21 | throw new Exception; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/src/Item.php: -------------------------------------------------------------------------------- 1 | value = $value; 12 | } 13 | 14 | public function value() 15 | { 16 | return $this->value; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/tests/AbstractTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 15 | } 16 | 17 | /** 18 | * @depends test_passed 19 | */ 20 | public function test_failed() 21 | { 22 | $this->assertTrue(false); 23 | } 24 | 25 | public function test_isnt_same() 26 | { 27 | $this->assertSame(['a' => 'b', 'c' => 'd'], ['e' => 'f', 'g', 'h']); 28 | } 29 | 30 | public function test_risky() 31 | { 32 | $a = 1; 33 | } 34 | 35 | /** 36 | * @test 37 | */ 38 | public function annotation_test() 39 | { 40 | $this->assertTrue(true); 41 | } 42 | 43 | public function test_skipped() 44 | { 45 | $this->markTestSkipped('The MySQLi extension is not available.'); 46 | } 47 | 48 | public function test_incomplete() 49 | { 50 | $this->markTestIncomplete('This test has not been implemented yet.'); 51 | } 52 | 53 | /** 54 | * @test 55 | * @dataProvider additionProvider 56 | */ 57 | public function addition_provider($a, $b, $expected) 58 | { 59 | $this->assertEquals($expected, $a + $b); 60 | } 61 | 62 | public function additionProvider() 63 | { 64 | return [ 65 | [0, 0, 0], 66 | [0, 1, 1], 67 | [1, 0, 2], 68 | ]; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/tests/CalculatorTest.php: -------------------------------------------------------------------------------- 1 | assertSame($calculator->sum(1, 2), 3); 20 | } 21 | 22 | public function test_sum_fail() 23 | { 24 | $calculator = new Calculator(); 25 | 26 | $this->assertSame($calculator->sum(1, 2), 4); 27 | } 28 | 29 | public function test_sum_item() 30 | { 31 | $calculator = new Calculator(); 32 | 33 | $a = new Item(1); 34 | $b = new Item(2); 35 | 36 | $this->assertSame($calculator->sumItem($a, $b), 3); 37 | } 38 | 39 | public function test_sum_item_method_not_call() 40 | { 41 | $calculator = new Calculator(); 42 | 43 | $a = m::mock(new Item(1)); 44 | $b = new Item(2); 45 | 46 | $a->shouldReceive('test')->once(); 47 | 48 | $this->assertSame($calculator->sumItem($a, $b), 3); 49 | } 50 | 51 | public function test_throw_exception() 52 | { 53 | $calculator = new Calculator(); 54 | $calculator->throwException(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/tests/Directory/HasPropertyTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/tests/Directory/LeadingCommentsTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/tests/Directory/UseTraitTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 15 | } 16 | } 17 | 18 | trait UseTrait 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /server/tests/fixtures/project-sub/tests/StaticMethodTest.php: -------------------------------------------------------------------------------- 1 | ) from Mockery_0_Recca0120_VSCode_Item_Recca0120_VSCode_Item should be called 14 | exactly 1 times but called 0 times. 15 | 16 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery/CountValidator/Exact.php:38 17 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery/Expectation.php:310 18 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery/ExpectationDirector.php:119 19 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery/Container.php:303 20 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery/Container.php:288 21 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery.php:204 22 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery/Adapter/Phpunit/MockeryPHPUnitIntegration.php:74 23 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery/Adapter/Phpunit/MockeryPHPUnitIntegration.php:49 24 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/vendor/mockery/mockery/library/Mockery/Adapter/Phpunit/MockeryPHPUnitIntegrationAssertPostConditionsForV8.php:29 25 | 26 | 2) Recca0120\VSCode\Tests\CalculatorTest::test_throw_exception 27 | Exception: 28 | 29 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/src/Calculator.php:21 30 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/tests/CalculatorTest.php:54 31 | 32 | -- 33 | 34 | There were 4 failures: 35 | 36 | 1) Recca0120\VSCode\Tests\AssertionsTest::test_isnt_same 37 | Failed asserting that two arrays are identical. 38 | --- Expected 39 | +++ Actual 40 | @@ @@ 41 | Array &0 ( 42 | - 'a' => 'b' 43 | - 'c' => 'd' 44 | + 'e' => 'f' 45 | + 0 => 'g' 46 | + 1 => 'h' 47 | ) 48 | 49 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/tests/AssertionsTest.php:27 50 | 51 | 2) Recca0120\VSCode\Tests\AssertionsTest::addition_provider with data set #2 (1, 0, 2) 52 | Failed asserting that 1 matches expected 2. 53 | 54 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/tests/AssertionsTest.php:59 55 | 56 | 3) Recca0120\VSCode\Tests\AssertionsTest::test_failed 57 | Failed asserting that false is true. 58 | 59 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/tests/AssertionsTest.php:22 60 | 61 | 4) Recca0120\VSCode\Tests\CalculatorTest::test_sum_fail 62 | Failed asserting that 4 is identical to 3. 63 | 64 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/tests/CalculatorTest.php:26 65 | 66 | -- 67 | 68 | There was 1 risky test: 69 | 70 | 1) Recca0120\VSCode\Tests\AssertionsTest::test_risky 71 | This test did not perform any assertions 72 | 73 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/tests/AssertionsTest.php:30 74 | 75 | -- 76 | 77 | There was 1 incomplete test: 78 | 79 | 1) Recca0120\VSCode\Tests\AssertionsTest::test_incomplete 80 | This test has not been implemented yet. 81 | 82 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/tests/AssertionsTest.php:50 83 | 84 | -- 85 | 86 | There was 1 skipped test: 87 | 88 | 1) Recca0120\VSCode\Tests\AssertionsTest::test_skipped 89 | The MySQLi extension is not available. 90 | 91 | /Users/recca0120/Desktop/vscode-phpunit/server/tests/fixtures/project-sub/tests/AssertionsTest.php:45 92 | 93 | ERRORS! 94 | Tests: 15, Assertions: 12, Errors: 2, Failures: 4, Skipped: 1, Incomplete: 1, Risky: 1. 95 | -------------------------------------------------------------------------------- /server/tests/fixtures/usr/local/bin/cmd.cmd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renandelmonico/vscode-phpunit/c8a0476e68c1fe3593a1fe4147f6a9aa8a6e167f/server/tests/fixtures/usr/local/bin/cmd.cmd -------------------------------------------------------------------------------- /server/tests/fixtures/usr/local/bin/ls.cmd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renandelmonico/vscode-phpunit/c8a0476e68c1fe3593a1fe4147f6a9aa8a6e167f/server/tests/fixtures/usr/local/bin/ls.cmd -------------------------------------------------------------------------------- /server/tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import files from '../src/Filesystem'; 2 | import { join } from 'path'; 3 | 4 | export function fixturePath(...paths: string[]) { 5 | return files.asUri(join(__dirname, 'fixtures', ...paths)); 6 | } 7 | 8 | export function projectPath(...paths: string[]) { 9 | return files.asUri(fixturePath('project-sub', ...paths).fsPath); 10 | } 11 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "es6", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "strict": true, 9 | "outDir": "out", 10 | "rootDir": "src" 11 | }, 12 | "include": ["types", "src"], 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /server/types/php-parser.d.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Copyright (C) 2017 Glayzzle (BSD3 License) 3 | * @authors https://github.com/glayzzle/php-parser/graphs/contributors 4 | * @url http://glayzzle.com 5 | */ 6 | 7 | declare module 'php-parser' { 8 | /** 9 | * Token items 10 | */ 11 | const enum TokenEnum { 12 | T_HALT_COMPILER = 101, 13 | T_USE = 102, 14 | T_ENCAPSED_AND_WHITESPACE = 103, 15 | T_OBJECT_OPERATOR = 104, 16 | T_STRING = 105, 17 | T_DOLLAR_OPEN_CURLY_BRACES = 106, 18 | T_STRING_VARNAME = 107, 19 | T_CURLY_OPEN = 108, 20 | T_NUM_STRING = 109, 21 | T_ISSET = 110, 22 | T_EMPTY = 111, 23 | T_INCLUDE = 112, 24 | T_INCLUDE_ONCE = 113, 25 | T_EVAL = 114, 26 | T_REQUIRE = 115, 27 | T_REQUIRE_ONCE = 116, 28 | T_NAMESPACE = 117, 29 | T_NS_SEPARATOR = 118, 30 | T_AS = 119, 31 | T_IF = 120, 32 | T_ENDIF = 121, 33 | T_WHILE = 122, 34 | T_DO = 123, 35 | T_FOR = 124, 36 | T_SWITCH = 125, 37 | T_BREAK = 126, 38 | T_CONTINUE = 127, 39 | T_RETURN = 128, 40 | T_GLOBAL = 129, 41 | T_STATIC = 130, 42 | T_ECHO = 131, 43 | T_INLINE_HTML = 132, 44 | T_UNSET = 133, 45 | T_FOREACH = 134, 46 | T_DECLARE = 135, 47 | T_TRY = 136, 48 | T_THROW = 137, 49 | T_GOTO = 138, 50 | T_FINALLY = 139, 51 | T_CATCH = 140, 52 | T_ENDDECLARE = 141, 53 | T_LIST = 142, 54 | T_CLONE = 143, 55 | T_PLUS_EQUAL = 144, 56 | T_MINUS_EQUAL = 145, 57 | T_MUL_EQUAL = 146, 58 | T_DIV_EQUAL = 147, 59 | T_CONCAT_EQUAL = 148, 60 | T_MOD_EQUAL = 149, 61 | T_AND_EQUAL = 150, 62 | T_OR_EQUAL = 151, 63 | T_XOR_EQUAL = 152, 64 | T_SL_EQUAL = 153, 65 | T_SR_EQUAL = 154, 66 | T_INC = 155, 67 | T_DEC = 156, 68 | T_BOOLEAN_OR = 157, 69 | T_BOOLEAN_AND = 158, 70 | T_LOGICAL_OR = 159, 71 | T_LOGICAL_AND = 160, 72 | T_LOGICAL_XOR = 161, 73 | T_SL = 162, 74 | T_SR = 163, 75 | T_IS_IDENTICAL = 164, 76 | T_IS_NOT_IDENTICAL = 165, 77 | T_IS_EQUAL = 166, 78 | T_IS_NOT_EQUAL = 167, 79 | T_IS_SMALLER_OR_EQUAL = 168, 80 | T_IS_GREATER_OR_EQUAL = 169, 81 | T_INSTANCEOF = 170, 82 | T_INT_CAST = 171, 83 | T_DOUBLE_CAST = 172, 84 | T_STRING_CAST = 173, 85 | T_ARRAY_CAST = 174, 86 | T_OBJECT_CAST = 175, 87 | T_BOOL_CAST = 176, 88 | T_UNSET_CAST = 177, 89 | T_EXIT = 178, 90 | T_PRINT = 179, 91 | T_YIELD = 180, 92 | T_YIELD_FROM = 181, 93 | T_FUNCTION = 182, 94 | T_DOUBLE_ARROW = 183, 95 | T_DOUBLE_COLON = 184, 96 | T_ARRAY = 185, 97 | T_CALLABLE = 186, 98 | T_CLASS = 187, 99 | T_ABSTRACT = 188, 100 | T_TRAIT = 189, 101 | T_FINAL = 190, 102 | T_EXTENDS = 191, 103 | T_INTERFACE = 192, 104 | T_IMPLEMENTS = 193, 105 | T_VAR = 194, 106 | T_PUBLIC = 195, 107 | T_PROTECTED = 196, 108 | T_PRIVATE = 197, 109 | T_CONST = 198, 110 | T_NEW = 199, 111 | T_INSTEADOF = 200, 112 | T_ELSEIF = 201, 113 | T_ELSE = 202, 114 | T_ENDSWITCH = 203, 115 | T_CASE = 204, 116 | T_DEFAULT = 205, 117 | T_ENDFOR = 206, 118 | T_ENDFOREACH = 207, 119 | T_ENDWHILE = 208, 120 | T_CONSTANT_ENCAPSED_STRING = 209, 121 | T_LNUMBER = 210, 122 | T_DNUMBER = 211, 123 | T_LINE = 212, 124 | T_FILE = 213, 125 | T_DIR = 214, 126 | T_TRAIT_C = 215, 127 | T_METHOD_C = 216, 128 | T_FUNC_C = 217, 129 | T_NS_C = 218, 130 | T_START_HEREDOC = 219, 131 | T_END_HEREDOC = 220, 132 | T_CLASS_C = 221, 133 | T_VARIABLE = 222, 134 | T_OPEN_TAG = 223, 135 | T_OPEN_TAG_WITH_ECHO = 224, 136 | T_CLOSE_TAG = 225, 137 | T_WHITESPACE = 226, 138 | T_COMMENT = 227, 139 | T_DOC_COMMENT = 228, 140 | T_ELLIPSIS = 229, 141 | T_COALESCE = 230, 142 | T_POW = 231, 143 | T_POW_EQUAL = 232, 144 | T_SPACESHIP = 233, 145 | } 146 | 147 | /** 148 | * The tokens dictionnary 149 | */ 150 | interface TokenDefinition { 151 | /** List of token names as texts */ 152 | values: String[]; 153 | /** Define tokens */ 154 | names: TokenEnum[]; 155 | } 156 | 157 | /** 158 | * The token structure 159 | */ 160 | interface Token extends Array { 161 | // token name 162 | 0: String; 163 | // the token value 164 | 1: TokenEnum; 165 | // the current line 166 | 2: Number; 167 | } 168 | 169 | /** 170 | * Each Position object consists of a line number (1-indexed) and a column number (0-indexed): 171 | */ 172 | interface Position { 173 | line: Number; 174 | column: Number; 175 | offset: Number; 176 | } 177 | 178 | /** 179 | * Defines the location of the node (with it's source contents as string) 180 | */ 181 | interface Location { 182 | source: string; 183 | start: Position; 184 | end: Position; 185 | } 186 | 187 | /** 188 | * 189 | */ 190 | interface Node { 191 | kind: String; 192 | loc: Location; 193 | } 194 | 195 | /** 196 | * Error node 197 | */ 198 | interface ParserError extends Node { 199 | message: String; 200 | token: Token; 201 | line: Number; 202 | expected: any; 203 | } 204 | 205 | /** 206 | * A block statement, i.e., a sequence of statements surrounded by braces. 207 | */ 208 | interface Block extends Node { 209 | children: Node[]; 210 | } 211 | 212 | /** 213 | * The main root node 214 | */ 215 | interface Program extends Block { 216 | errors: ParserError[]; 217 | } 218 | 219 | interface Parser { 220 | lexer: Lexer; 221 | ast: AST; 222 | token: TokenEnum; 223 | prev: TokenEnum; 224 | debug: Boolean; 225 | extractDoc: Boolean; 226 | suppressErrors: Boolean; 227 | getTokenName(token: TokenEnum): String; 228 | parse(code: String, filename: String): Program; 229 | raiseError( 230 | message: String, 231 | msgExpect: String, 232 | expect: any, 233 | token: TokenEnum 234 | ): ParserError; 235 | error(expect: String): ParserError; 236 | node(kind: String): Node; 237 | expectEndOfStatement(): Boolean; 238 | showlog(): Parser; 239 | expect(token: TokenEnum): Boolean; 240 | expect(tokens: TokenEnum[]): Boolean; 241 | text(): String; 242 | next(): Parser; 243 | ignoreComments(): Parser; 244 | nextWithComments(): Parser; 245 | is(type: String): Boolean; 246 | // @todo other parsing functions ... 247 | } 248 | 249 | interface KeywordsDictionnary { 250 | [index: string]: TokenEnum; 251 | } 252 | 253 | interface yylloc { 254 | first_offset: Number; 255 | first_line: Number; 256 | first_column: Number; 257 | last_line: Number; 258 | last_column: Number; 259 | } 260 | 261 | interface LexerState { 262 | yytext: String; 263 | offset: Number; 264 | yylineno: Number; 265 | yyprevcol: Number; 266 | yylloc: yylloc; 267 | } 268 | 269 | interface Lexer { 270 | debug: Boolean; 271 | all_tokens: Boolean; 272 | comment_tokens: Boolean; 273 | mode_eval: Boolean; 274 | asp_tags: Boolean; 275 | short_tags: Boolean; 276 | keywords: KeywordsDictionnary; 277 | castKeywords: KeywordsDictionnary; 278 | setInput(input: String): Lexer; 279 | input(size: Number): String; 280 | unput(size: Number): Lexer; 281 | tryMatch(match: String): Boolean; 282 | tryMatchCaseless(match: String): Boolean; 283 | ahead(size: Number): String; 284 | consume(size: Number): Lexer; 285 | getState(): LexerState; 286 | setState(state: LexerState): Lexer; 287 | appendToken(value: TokenEnum, ahead: Number): Lexer; 288 | lex(): TokenEnum; 289 | begin(state: String): Lexer; 290 | popState(): String; 291 | next(): TokenEnum; 292 | // @todo other lexer functions ... 293 | } 294 | 295 | interface AST { 296 | /** 297 | * 298 | */ 299 | withPositions: Boolean; 300 | /** 301 | * Option, if true extracts original source code attached to the node (by default false) 302 | */ 303 | withSource: Boolean; 304 | /** 305 | * Constructor 306 | */ 307 | constructor(withPositions: Boolean, withSource: Boolean): AST; 308 | constructor(withPositions: Boolean): AST; 309 | constructor(): AST; 310 | /** 311 | * Create a position node from specified parser 312 | * including it's lexer current state 313 | */ 314 | position(parser: Parser): Position; 315 | /** 316 | * Prepares an AST node 317 | */ 318 | prepare(kind: String, parser: Parser): Function; 319 | } 320 | 321 | /** 322 | * List of options / extensions 323 | */ 324 | interface Options { 325 | ast?: { 326 | withPositions?: Boolean; 327 | withSource?: Boolean; 328 | }; 329 | lexer?: { 330 | debug?: Boolean; 331 | all_tokens?: Boolean; 332 | comment_tokens?: Boolean; 333 | mode_eval?: Boolean; 334 | asp_tags?: Boolean; 335 | short_tags?: Boolean; 336 | }; 337 | parser?: { 338 | php7?: Boolean; 339 | debug?: Boolean; 340 | extractDoc?: Boolean; 341 | suppressErrors?: Boolean; 342 | }; 343 | } 344 | 345 | /** 346 | * Initialise a new parser instance with the specified options 347 | */ 348 | export default class Engine { 349 | // ----- STATIC HELPERS 350 | static create(options?: Options): Engine; 351 | static parseEval(buffer: String, options: Options): Program; 352 | static parseEval(buffer: String): Program; 353 | static parseCode( 354 | buffer: String, 355 | filename: String, 356 | options: Options 357 | ): Program; 358 | static parseCode(buffer: String, options: Options): Program; 359 | static parseCode(buffer: String): Program; 360 | static tokenGetAll(buffer: String, options: Options): Token[]; 361 | static tokenGetAll(buffer: String): Token[]; 362 | // ----- INSTANCE FUNCTIONS 363 | ast: AST; 364 | lexer: Lexer; 365 | parser: Parser; 366 | tokens: TokenDefinition; 367 | constructor(options?: Options); 368 | parseEval(buffer: String): Program; 369 | parseCode(buffer: String, filename: String): Program; 370 | parseCode(buffer: String): Program; 371 | tokenGetAll(buffer: String): Token[]; 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /server/webpack.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | //@ts-check 7 | 8 | 'use strict'; 9 | 10 | const withDefaults = require('../shared.webpack.config'); 11 | const path = require('path'); 12 | var webpack = require("webpack") 13 | 14 | module.exports = withDefaults({ 15 | context: path.join(__dirname), 16 | entry: { 17 | extension: './src/server.ts', 18 | }, 19 | output: { 20 | filename: 'server.js', 21 | path: path.join(__dirname, 'out'), 22 | }, 23 | plugins: [ 24 | // ... 25 | new webpack.ContextReplacementPlugin( 26 | /angular(\\|\/)core(\\|\/)@angular/, 27 | __dirname + "./app" // Angular Source 28 | ) 29 | ] 30 | }); -------------------------------------------------------------------------------- /shared.webpack.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | //@ts-check 7 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 8 | 9 | 'use strict'; 10 | 11 | const path = require('path'); 12 | const merge = require('merge-options'); 13 | 14 | module.exports = function withDefaults( /**@type WebpackConfig*/ extConfig) { 15 | 16 | /** @type WebpackConfig */ 17 | let defaultConfig = { 18 | mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 19 | target: 'node', // extensions run in a node context 20 | node: { 21 | __dirname: false // leave the __dirname-behaviour intact 22 | }, 23 | resolve: { 24 | mainFields: ['module', 'main'], 25 | extensions: ['.ts', '.js'] // support ts-files and js-files 26 | }, 27 | devtool: 'source-map', 28 | externals: { 29 | vscode: 'commonjs vscode', // ignored because it doesn't exist 30 | }, 31 | module: { 32 | rules: [{ 33 | test: /\.ts$/, 34 | exclude: /node_modules/, 35 | use: [{ 36 | // configure TypeScript loader: 37 | // * enable sources maps for end-to-end source maps 38 | loader: 'ts-loader', 39 | options: { 40 | compilerOptions: { 41 | "sourceMap": true, 42 | } 43 | } 44 | }] 45 | }] 46 | }, 47 | output: { 48 | // all output goes into `dist`. 49 | // packaging depends on that and this must always be like it 50 | filename: '[name].js', 51 | path: path.join(extConfig.context, 'out'), 52 | libraryTarget: "commonjs2", 53 | devtoolModuleFilenameTemplate: "../[resource-path]", 54 | }, 55 | // yes, really source maps 56 | }; 57 | 58 | return merge(defaultConfig, extConfig); 59 | }; -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "noUnusedParameters": true, 7 | "esModuleInterop": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es6", 6 | "outDir": "out", 7 | "rootDir": "src", 8 | "lib": ["es6"], 9 | "sourceMap": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["node_modules", "**/__mocks__/*", "**/tests/*"], 13 | "references": [ 14 | { 15 | "path": "./client" 16 | }, 17 | { 18 | "path": "./server" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [true], 4 | "semicolon": [true, "always"] 5 | } 6 | } 7 | --------------------------------------------------------------------------------