diff --git a/.vscode/launch.json b/.vscode/launch.json index d32afdc76..81a4b4f26 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,7 +38,7 @@ "request": "attach", "name": "Attach to Language Server", "protocol": "inspector", - "port": 6005, + "port": 6009, "sourceMaps": true, "outFiles": [ "${workspaceFolder}/out/**/*.js" diff --git a/package.json b/package.json index 3053c4b29..47f0967ce 100644 --- a/package.json +++ b/package.json @@ -475,7 +475,7 @@ "redux": "^4.0.5", "ts-log": "^2.1.4", "uuid": "^7.0.0", - "vscode-languageclient": "^6.1.1", + "vscode-languageclient": "^6.1.3", "vscode-languageserver": "^6.1.1" }, "devDependencies": { diff --git a/scripts/generate-keyfile.ts b/scripts/generate-keyfile.ts index 0d64fd9e6..ea590d637 100644 --- a/scripts/generate-keyfile.ts +++ b/scripts/generate-keyfile.ts @@ -25,5 +25,5 @@ config({ path: resolve(__dirname, '../.env') }); } })().catch((error) => { ui.fail('Failed to generate constants keyfile'); - console.log(error); + console.log(error.message); }) diff --git a/src/editors/playgroundController.ts b/src/editors/playgroundController.ts index fc8d87ccd..db56b9561 100644 --- a/src/editors/playgroundController.ts +++ b/src/editors/playgroundController.ts @@ -1,9 +1,8 @@ import * as vscode from 'vscode'; import ConnectionController, { DataServiceEventTypes } from '../connectionController'; +import { LanguageServerController } from '../language'; import TelemetryController, { TelemetryEventTypes } from '../telemetry/telemetryController'; -import { ElectronRuntime } from '@mongosh/browser-runtime-electron'; -import { CompassServiceProvider } from '@mongosh/service-provider-server'; import ActiveConnectionCodeLensProvider from './activeConnectionCodeLensProvider'; import formatOutput from '../utils/formatOutput'; import { OutputChannel } from 'vscode'; @@ -14,16 +13,23 @@ import playgroundTemplate from '../templates/playgroundTemplate'; */ export default class PlaygroundController { _context: vscode.ExtensionContext; - _telemetryController?: TelemetryController; _connectionController: ConnectionController; + _languageServerController: LanguageServerController; + _telemetryController?: TelemetryController; _activeDB?: any; _activeConnectionCodeLensProvider?: ActiveConnectionCodeLensProvider; _outputChannel: OutputChannel; - constructor(context: vscode.ExtensionContext, connectionController: ConnectionController, telemetryController?: TelemetryController) { + constructor( + context: vscode.ExtensionContext, + connectionController: ConnectionController, + languageServerController: LanguageServerController, + telemetryController?: TelemetryController + ) { this._context = context; - this._telemetryController = telemetryController; this._connectionController = connectionController; + this._languageServerController = languageServerController; + this._telemetryController = telemetryController; this._outputChannel = vscode.window.createOutputChannel( 'Playground output' ); @@ -97,19 +103,16 @@ export default class PlaygroundController { } async evaluate(codeToEvaluate: string): Promise { - const activeConnection = this._connectionController.getActiveDataService(); + const activeConnectionString = this._connectionController.getActiveConnectionDriverUrl(); - if (!activeConnection) { + if (!activeConnectionString) { return Promise.reject( new Error('Please connect to a database before running a playground.') ); } - const serviceProvider = CompassServiceProvider.fromDataService( - activeConnection - ); - const runtime = new ElectronRuntime(serviceProvider); - const res = await runtime.evaluate(codeToEvaluate); + // Run playground as a background process using the Language Server + const res = await this._languageServerController.executeAll(codeToEvaluate, activeConnectionString); if (res) { this._telemetryController?.track( diff --git a/src/language/languageServerController.ts b/src/language/languageServerController.ts index 96d3d7ba0..1c07550a4 100644 --- a/src/language/languageServerController.ts +++ b/src/language/languageServerController.ts @@ -19,20 +19,17 @@ const log = createLogger('LanguageServerController'); */ export default class LanguageServerController { _connectionController?: ConnectionController; - client?: LanguageClient; + client: LanguageClient; + constructor( context: vscode.ExtensionContext, - connectionController: ConnectionController + connectionController?: ConnectionController ) { this._connectionController = connectionController; - this.activate(context); - } - async activate(context: ExtensionContext): Promise { // The server is implemented in node - const serverModule = context.asAbsolutePath( - path.join('out', 'language', 'server.js') - ); + const serverModule = path.join(context.extensionPath, 'out', 'language', 'server.js'); + // The debug options for the server // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging const debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] }; @@ -61,7 +58,7 @@ export default class LanguageServerController { } }; - log.info('Activating MongoDB language server', { + log.info('Creating MongoDB Language Server', { serverOptions, clientOptions }); @@ -73,25 +70,28 @@ export default class LanguageServerController { serverOptions, clientOptions ); + } + activate() { // Start the client. This will also launch the server this.client.start(); - - await this.client.onReady(); - /** - * TODO: Notification is for setup docs only. - */ - this.client.onNotification('mongodbNotification', (messsage) => { - vscode.window.showInformationMessage(messsage); + this.client.onReady().then(() => { + /** + * TODO: Notification is for setup docs only. + */ + this.client.onNotification('mongodbNotification', (messsage) => { + vscode.window.showInformationMessage(messsage); + }); }); - - return new Promise((resolve) => resolve(this.client)); } deactivate(): Thenable | undefined { - if (!this.client) { - return undefined; - } return this.client.stop(); } + + executeAll(codeToEvaluate: string, connectionString: string, connectionOptions: any = {}): Thenable | undefined { + return this.client.onReady().then(() => { + return this.client.sendRequest('executeAll', { codeToEvaluate, connectionString, connectionOptions }); + }); + } } diff --git a/src/language/server.ts b/src/language/server.ts index 4d1ad6a4e..f51da0bd7 100644 --- a/src/language/server.ts +++ b/src/language/server.ts @@ -13,6 +13,8 @@ import { TextDocumentPositionParams, RequestType } from 'vscode-languageserver'; +import { ElectronRuntime } from '@mongosh/browser-runtime-electron'; +import { CliServiceProvider } from '@mongosh/service-provider-server'; // Create a connection for the server. The connection uses Node's IPC as a transport. // Also include all preview / proposed LSP features. @@ -275,9 +277,13 @@ connection.onCompletionResolve( /** * Execute the entire playground script. */ -connection.onRequest('executeAll', (event) => { - // connection.console.log(`executeAll: ${JSON.stringify(event)}`); - return ''; +connection.onRequest('executeAll', async (params) => { + const connectionOptions = params.connectionOptions || {}; + const runtime = new ElectronRuntime( + await CliServiceProvider.connect(params.connectionString, connectionOptions) + ); + + return await runtime.evaluate(params.codeToEvaluate); }); /** diff --git a/src/mdbExtensionController.ts b/src/mdbExtensionController.ts index 5c2bad52e..6c439cbc7 100644 --- a/src/mdbExtensionController.ts +++ b/src/mdbExtensionController.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; import ConnectionController from './connectionController'; import { EditorsController, PlaygroundController } from './editors'; import { ExplorerController, CollectionTreeItem } from './explorer'; +import { LanguageServerController } from './language'; import { TelemetryController } from './telemetry'; import { StatusView } from './views'; import { createLogger } from './logging'; @@ -30,6 +31,7 @@ export default class MDBExtensionController implements vscode.Disposable { _statusView: StatusView; _storageController: StorageController; _telemetryController: TelemetryController; + _languageServerController: LanguageServerController; constructor( context: vscode.ExtensionContext, @@ -52,6 +54,7 @@ export default class MDBExtensionController implements vscode.Disposable { ); } + this._languageServerController = new LanguageServerController(context); this._editorsController = new EditorsController( context, this._connectionController @@ -62,6 +65,7 @@ export default class MDBExtensionController implements vscode.Disposable { this._playgroundController = new PlaygroundController( context, this._connectionController, + this._languageServerController, this._telemetryController ); } @@ -70,6 +74,7 @@ export default class MDBExtensionController implements vscode.Disposable { this._connectionController.loadSavedConnections(); this._explorerController.createTreeView(); this._telemetryController.activate(); + this._languageServerController.activate(); log.info('Registering commands...'); @@ -407,5 +412,6 @@ export default class MDBExtensionController implements vscode.Disposable { this._explorerController.deactivate(); this._playgroundController.deactivate(); this._telemetryController.deactivate(); + this._languageServerController.deactivate(); } } diff --git a/src/test/suite/editors/playgroundController.test.ts b/src/test/suite/editors/playgroundController.test.ts index 13b838e4a..88bda2dac 100644 --- a/src/test/suite/editors/playgroundController.test.ts +++ b/src/test/suite/editors/playgroundController.test.ts @@ -1,4 +1,7 @@ import * as vscode from 'vscode'; +import * as path from 'path'; +import { PlaygroundController } from '../../../editors'; +import { LanguageServerController } from '../../../language'; import ConnectionController from '../../../connectionController'; import { StatusView } from '../../../views'; import { StorageController } from '../../../storage'; @@ -8,15 +11,32 @@ import { seedDataAndCreateDataService, cleanupTestDB } from '../dbTestHelper'; +import { beforeEach, afterEach } from 'mocha'; +const sinon = require('sinon'); const chai = require('chai'); const expect = chai.expect; chai.use(require('chai-as-promised')); -import { PlaygroundController } from '../../../editors'; +const getDocUri = (docName: string) => { + const docPath = path.resolve(__dirname, '../../../../src/test/fixture', docName); -const sinon = require('sinon'); + return vscode.Uri.file(docPath); +}; + +/** + * Opens the MongoDB playground + */ +async function openPlayground(docUri: vscode.Uri) { + try { + const doc = await vscode.workspace.openTextDocument(docUri); + + await vscode.window.showTextDocument(doc); + } catch (e) { + console.error(e); + } +} suite('Playground Controller Test Suite', () => { vscode.window.showInformationMessage('Starting tests...'); @@ -24,40 +44,136 @@ suite('Playground Controller Test Suite', () => { const mockExtensionContext = new TestExtensionContext(); const mockStorageController = new StorageController(mockExtensionContext); + const sandbox = sinon.createSandbox(); + + mockExtensionContext.extensionPath = '../../'; + suite('when user is not connected', () => { test('evaluate should throw the missing active connection error', async () => { const testConnectionController = new ConnectionController( new StatusView(mockExtensionContext), mockStorageController ); - const testPlaygroundController = new PlaygroundController(mockExtensionContext, testConnectionController); + const testLanguageServerController = new LanguageServerController(mockExtensionContext, testConnectionController); + const testPlaygroundController = new PlaygroundController(mockExtensionContext, testConnectionController, testLanguageServerController); + + testLanguageServerController.activate(); expect(testPlaygroundController.evaluate('1 + 1')).to.be.rejectedWith(Error, 'Please connect to a database before running a playground.'); }); }); + suite('test confirmation message', () => { + const testConnectionController = new ConnectionController( + new StatusView(mockExtensionContext), + mockStorageController + ); + const testLanguageServerController = new LanguageServerController(mockExtensionContext, testConnectionController); + + testLanguageServerController.activate(); + testConnectionController.getActiveConnectionDriverUrl = () => 'mongodb://localhost:27018'; + + const testPlaygroundController = new PlaygroundController(mockExtensionContext, testConnectionController, testLanguageServerController); + let fakeShowInformationMessage; + + beforeEach(() => { + fakeShowInformationMessage = sandbox.stub(vscode.window, 'showInformationMessage').resolves('Yes'); + }); + + afterEach(async () => { + sandbox.restore(); + await cleanupTestDB() + }); + + test('show a confirmation message before running commands in a playground if mdb.confirmRunAll is true', (done) => { + const mockDocument = { + _id: new ObjectId('5e32b4d67bf47f4525f2f833'), + example: 'field' + }; + + seedDataAndCreateDataService('forest', [mockDocument]).then( + async (dataService) => { + testConnectionController.setActiveConnection(dataService); + + await testPlaygroundController.runAllPlaygroundBlocks(); + + const expectedMessage = + 'Are you sure you want to run this playground against fakeName? This confirmation can be disabled in the extension settings.'; + + expect(fakeShowInformationMessage.calledOnce).to.be.true; + } + ).then(done, done); + }); + + test('show a confirmation message before running commands in a playground if mdb.confirmRunAll is false', (done) => { + const mockDocument = { + _id: new ObjectId('5e32b4d67bf47f4525f2f844'), + example: 'field' + }; + + seedDataAndCreateDataService('forest', [mockDocument]).then( + async (dataService) => { + testConnectionController.setActiveConnection(dataService); + + await vscode.workspace + .getConfiguration('mdb') + .update('confirmRunAll', false); + await testPlaygroundController.runAllPlaygroundBlocks(); + + expect(fakeShowInformationMessage.calledOnce).to.be.false; + } + ).then(done, done); + }); + }); + suite('when user is connected', () => { - const mockActiveConnection = { - find: (namespace, filter, options, callback): void => { - return callback(null, ['Text message']); - }, - client: {} - }; + afterEach(async () => { + await cleanupTestDB() + }); + const testConnectionController = new ConnectionController( new StatusView(mockExtensionContext), mockStorageController ); + const testLanguageServerController = new LanguageServerController(mockExtensionContext, testConnectionController); + + testLanguageServerController.activate(); testConnectionController.getActiveConnectionName = () => 'fakeName'; + testConnectionController.getActiveConnectionDriverUrl = () => 'mongodb://localhost:27018'; + + const testPlaygroundController = new PlaygroundController(mockExtensionContext, testConnectionController, testLanguageServerController); + + test('evaluate should sum numbers', async function () { + const mockActiveConnection = { + find: (namespace, filter, options, callback): void => { + return callback(null, ['Text message']); + }, + client: { + _id: new ObjectId('5e32b4d67bf47f4525f2f841'), + example: 'field' + } + }; - const testPlaygroundController = new PlaygroundController(mockExtensionContext, testConnectionController); + await openPlayground(getDocUri('test.mongodb')); - test('evaluate should sum numbers', async () => { testConnectionController.setActiveConnection(mockActiveConnection); expect(await testPlaygroundController.evaluate('1 + 1')).to.be.equal('2'); }); test('evaluate multiple commands at once', async () => { + const mockActiveConnection = { + find: (namespace, filter, options, callback): void => { + return callback(null, ['Text message']); + }, + client: { + _id: new ObjectId('5e32b4d67bf47f4525f2f842'), + example: 'field' + } + }; + + await openPlayground(getDocUri('test.mongodb')); + testConnectionController.setActiveConnection(mockActiveConnection); expect(await testPlaygroundController.evaluate(` @@ -68,7 +184,7 @@ suite('Playground Controller Test Suite', () => { test('evaluate interaction with a database', (done) => { const mockDocument = { - _id: new ObjectId('5e32b4d67bf47f4525f2f8ab'), + _id: new ObjectId('5e32b4d67bf47f4525f2f811'), example: 'field' }; @@ -76,20 +192,20 @@ suite('Playground Controller Test Suite', () => { async (dataService) => { testConnectionController.setActiveConnection(dataService); + await openPlayground(getDocUri('test.mongodb')); + const actualResult = await testPlaygroundController.evaluate(` use('vscodeTestDatabaseAA'); db.forest.find({}) `); const expectedResult = '[\n' + ' {\n' + - ' _id: 5e32b4d67bf47f4525f2f8ab,\n' + + ' _id: \'5e32b4d67bf47f4525f2f811\',\n' + ' example: \'field\'\n' + ' }\n' + ']'; expect(actualResult).to.be.equal(expectedResult); - - await cleanupTestDB(); } ).then(done, done); }); @@ -192,9 +308,9 @@ suite('Playground Controller Test Suite', () => { expect(type).to.deep.equal({ type: 'other' }); }); - test('create a new playground instance for each run', () => { + test('create a new playground instance for each run', (done) => { const mockDocument = { - _id: new ObjectId('5e32b4d67bf47f4525f2f777'), + _id: new ObjectId('5e32b4d67bf47f4525f2f722'), valueOfTheField: 'is not important' }; const codeToEvaluate = ` @@ -211,61 +327,6 @@ suite('Playground Controller Test Suite', () => { const result = await testPlaygroundController.evaluate(codeToEvaluate); expect(result).to.be.equal('2'); - - await cleanupTestDB(); - } - ); - }); - - test('show a confirmation message before running commands in a playground if mdb.confirmRunAll is true', (done) => { - const mockDocument = { - _id: new ObjectId('5e32b4d67bf47f4525f2f8ab'), - example: 'field' - }; - const fakeShowInformationMessage = sinon.stub(vscode.window, 'showInformationMessage'); - - fakeShowInformationMessage.returns('Yes'); - - seedDataAndCreateDataService('forest', [mockDocument]).then( - async (dataService) => { - testConnectionController.setActiveConnection(dataService); - - await testPlaygroundController.runAllPlaygroundBlocks(); - - const expectedMessage = - 'Are you sure you want to run this playground against fakeName? This confirmation can be disabled in the extension settings.'; - - expect(fakeShowInformationMessage.calledOnce).to.be.true; - expect(fakeShowInformationMessage.calledWith(expectedMessage)).to.be.true; - fakeShowInformationMessage.restore(); - - await cleanupTestDB(); - } - ).then(done, done); - }); - - test('show a confirmation message before running commands in a playground if mdb.confirmRunAll is false', (done) => { - const mockDocument = { - _id: new ObjectId('5e32b4d67bf47f4525f2f8ab'), - example: 'field' - }; - const fakeShowInformationMessage = sinon.stub(vscode.window, 'showInformationMessage'); - - fakeShowInformationMessage.returns('Yes'); - - seedDataAndCreateDataService('forest', [mockDocument]).then( - async (dataService) => { - testConnectionController.setActiveConnection(dataService); - - await vscode.workspace - .getConfiguration('mdb') - .update('confirmRunAll', false); - await testPlaygroundController.runAllPlaygroundBlocks(); - - expect(fakeShowInformationMessage.calledOnce).to.be.false; - fakeShowInformationMessage.restore(); - - await cleanupTestDB(); } ).then(done, done); }); diff --git a/src/test/suite/views/connectFormView.test.ts b/src/test/suite/views/connectFormView.test.ts index c69cb70c7..b2ba55f1e 100644 --- a/src/test/suite/views/connectFormView.test.ts +++ b/src/test/suite/views/connectFormView.test.ts @@ -53,7 +53,7 @@ suite('Connect Form View Test Suite', () => { test('web view content is rendered with the js form', async () => { async function readFile(filePath): Promise { return new Promise((resolve, reject) => { - fs.readFile(filePath, 'utf8', function(err, data) { + fs.readFile(filePath, 'utf8', function (err, data) { if (err) { reject(err); }