diff --git a/README.md b/README.md index a6125d57..d839a5e0 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Open VS Code, launch the Commmand Palette (⌘+Shift+P on MacOS, Ctrl+Shift+P on ![Playgrounds](resources/screenshots/playground.png) +> Make sure you are connected to a cluster before using a playground. You can't run a playground and you won't get completions if you are not connected. + ### Quick access to the MongoDB Shell - Launch the MongoDB Shell from the command palette to quickly connect to the same cluster you have active in VS Code @@ -70,6 +72,22 @@ Open VS Code, launch the Commmand Palette (⌘+Shift+P on MacOS, Ctrl+Shift+P on ![Settings](resources/screenshots/settings.png) +## Additional Settings + +> These settings affect not only MongoDB extension but all installed extensions. If you don't want to change default settings, you can force VS Code to trigger suggestions by clicking `Ctrl+Space` inside a snippet or string literal. + +`editor.suggest.snippetsPreventQuickSuggestions`: By default, VS Code prevents code completion in the snippet mode (editing placeholders in inserted code). Setting this option to `false` stops that and allows for the `db.collection.aggregate()` expression having both snippets completion (eg. `$match`, `$addFields`) and fields completion based on the document schema. + +`editor.quickSuggestions`: By default, VS Code prevents code completion inside string literals. To enable database names completions for `use('')` expression use the following setting: + +``` +"editor.quickSuggestions": { + "other": true, + "comments": false, + "strings": true +} +``` + ## Telemetry MongoDB for VS Code collects usage data and sends it to MongoDB to help improve our products and services. Read our [privacy policy](https://www.mongodb.com/legal/privacy-policy) to learn more. If you don’t wish to send usage data to MongoDB, you can opt out in the extension's settings. diff --git a/languages/mongodb-language-configuration.json b/languages/mongodb-language-configuration.json index d4ef1835..5ee2d354 100644 --- a/languages/mongodb-language-configuration.json +++ b/languages/mongodb-language-configuration.json @@ -31,10 +31,5 @@ "start": "^\\s*//\\s*#?region\\b", "end": "^\\s*//\\s*#?endregion\\b" } - }, - "wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\@\\#\\%\\^\\&\\*\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s]+)", - "indentationRules": { - "increaseIndentPattern": "^((?!.*?\\/\\*).*\\*/)?\\s*[\\}\\]].*$", - "decreaseIndentPattern": "^((?!\\/\\/).)*(\\{[^}\"'`]*|\\([^)\"'`]*|\\[[^\\]\"'`]*)$" } } diff --git a/package.json b/package.json index 96a77cb7..5ee817a6 100644 --- a/package.json +++ b/package.json @@ -508,6 +508,7 @@ "@leafygreen-ui/toggle": "^3.0.0", "@mongosh/browser-runtime-electron": "0.0.1-alpha.12", "@mongosh/service-provider-server": "0.0.1-alpha.12", + "@mongosh/shell-api": "0.0.1-alpha.12", "analytics-node": "^3.4.0-beta.1", "bson": "^4.0.3", "classnames": "^2.2.6", diff --git a/scripts/update-snippets.ts b/scripts/update-snippets.ts index d1464076..1a70337f 100755 --- a/scripts/update-snippets.ts +++ b/scripts/update-snippets.ts @@ -43,7 +43,7 @@ const snippetTemplate = ( // but the variable is not known. // The solution is to escape this symbol before building the stage body. body = comment - ? [...comment.replace(re, '\\$').split('\n'), ...body] + ? [...comment.trim().replace(re, '\\$').split('\n'), ...body] : [...body]; return { prefix, body, description }; diff --git a/snippets/stage-autocompleter.json b/snippets/stage-autocompleter.json index 24987c28..a508b0f8 100644 --- a/snippets/stage-autocompleter.json +++ b/snippets/stage-autocompleter.json @@ -6,7 +6,6 @@ " * newField: The new field name.", " * expression: The new field expression.", " */", - "", "\\$addFields: {", " ${1:newField}: ${2:expression}, ${3:...}", "}" @@ -24,7 +23,6 @@ " * outputN: Optional. The output object may contain a single or numerous field names used to accumulate values per bucket.", " * }", " */", - "", "\\$bucket: {", " groupBy: ${1:expression},", " boundaries: [ ${2:lowerbound}, ${3:...} ],", @@ -47,7 +45,6 @@ " * }", " * granularity: Optional number series", " */", - "", "\\$bucketAuto: {", " groupBy: ${1:expression},", " buckets: ${2:number},", @@ -66,7 +63,6 @@ " * histograms: Optional latency histograms.", " * storageStats: Optional storage stats.", "*/", - "", "\\$collStats: {", " latencyStats: {", " histograms: ${1:boolean}", @@ -82,7 +78,6 @@ "/**", " * Provide the field name for the count.", " */", - "", "\\$count: '${1:string}'" ], "description": "Returns a count of the number of documents at this stage of the aggregation pipeline." @@ -94,7 +89,6 @@ " * outputFieldN: The first output field.", " * stageN: The first aggregation stage.", " */", - "", "\\$facet: {", " ${1:outputFieldN}: [ ${2:stageN}, ${3:...} ]", "}" @@ -107,7 +101,6 @@ "/**", " * options: The geo query options.", " */", - "", "\\$geoNear: {", " near: The point to search near.", " distanceField: The calculated distance.", @@ -133,7 +126,6 @@ " * depthField: Optional Name of the depth field.", " * restrictSearchWithMatch: Optional query.", " */", - "", "\\$graphLookup: {", " from: '${1:string}',", " startWith: ${2:expression},", @@ -154,7 +146,6 @@ " * _id: The id of the group.", " * fieldN: The first field name.", " */", - "", "\\$group: {", " _id: ${1:expression},", " ${2:fieldN}: {", @@ -170,7 +161,6 @@ "/**", " * No parameters.", " */", - "", "\\$indexStats: {}" ], "description": "Returns statistics regarding the use of each index for the collection." @@ -181,7 +171,6 @@ "/**", " * Provide the number of documents to limit.", " */", - "", "\\$limit: ${1:number}" ], "description": "Limits the number of documents that flow into subsequent stages." @@ -197,7 +186,6 @@ " * pipeline: The pipeline to run on the joined collection.", " * let: Optional variables to use in the pipeline field stages.", " */", - "", "\\$lookup: {", " from: '${1:string}',", " localField: '${2:string}',", @@ -213,7 +201,6 @@ "/**", " * query: The query in MQL.", " */", - "", "\\$match: {", " ${1:query}", "}" @@ -230,7 +217,6 @@ " * whenMatched: Action for matching docs.", " * whenNotMatched: Action for non-matching docs.", " */", - "", "\\$merge: {", " into: '${1:string}',", " on: '${2:string}',", @@ -247,7 +233,6 @@ "/**", " * Provide the name of the output collection.", " */", - "", "\\$out: '${1:string}'" ], "description": "Writes the result of a pipeline to a new or existing collection." @@ -259,7 +244,6 @@ " * specifications: The fields to", " * include or exclude.", " */", - "", "\\$project: {", " ${1:specification(s)}", "}" @@ -273,7 +257,6 @@ " * expression: Any valid expression that", " * evaluates to \\$\\$DESCEND, \\$\\$PRUNE, or \\$\\$KEEP.", " */", - "", "\\$redact: {", " ${1:expression}", "}" @@ -286,7 +269,6 @@ "/**", " * replacementDocument: A document or string.", " */", - "", "\\$replaceWith: {", " newWith: ${1:replacementDocument}", "}" @@ -299,7 +281,6 @@ "/**", " * replacementDocument: A document or string.", " */", - "", "\\$replaceRoot: {", " newRoot: ${1:replacementDocument}", "}" @@ -312,7 +293,6 @@ "/**", " * size: The number of documents to sample.", " */", - "", "\\$sample: {", " size: ${1:number}", "}" @@ -327,7 +307,6 @@ " * search: The search spec.", " * highlight: Search with highlights.", " */", - "", "\\$searchBeta: {", " index: '${1:string}',", " search: '${2:specification}',", @@ -343,7 +322,6 @@ " * field: The field name", " * expression: The expression.", " */", - "", "\\$set: {", " ${1:field}: ${2:expression}", "}" @@ -356,7 +334,6 @@ "/**", " * Provide the number of documents to skip.", " */", - "", "\\$skip: ${1:number}" ], "description": "Skips a specified number of documents before advancing to the next stage." @@ -367,7 +344,6 @@ "/**", " * Provide any number of field/order pairs.", " */", - "", "\\$sort: {", " ${1:field1}: ${2:sortOrder}", "}" @@ -380,7 +356,6 @@ "/**", " * expression: Grouping expression or string.", " */", - "", "\\$sortByCount: {", " ${1:expression}", "}" @@ -393,7 +368,6 @@ "/**", " * fields: The field name(s).", " */", - "", "\\$unset: {", " ${1:field}", "}" @@ -409,7 +383,6 @@ " * preserveNullAndEmptyArrays: Optional", " * toggle to unwind null and empty values.", " */", - "", "\\$unwind: {", " path: ${1:path},", " includeArrayIndex: '${2:string}',", diff --git a/src/language/mongoDBService.ts b/src/language/mongoDBService.ts index 43590421..32227f3c 100644 --- a/src/language/mongoDBService.ts +++ b/src/language/mongoDBService.ts @@ -2,6 +2,7 @@ import { CompletionItemKind, CancellationToken } from 'vscode-languageserver'; import { Worker as WorkerThreads } from 'worker_threads'; import { ElectronRuntime } from '@mongosh/browser-runtime-electron'; import { CliServiceProvider } from '@mongosh/service-provider-server'; +import { signatures } from '@mongosh/shell-api'; import * as util from 'util'; import { ServerCommands } from './serverCommands'; @@ -20,11 +21,17 @@ export default class MongoDBService { _connectionString?: string; _connectionOptions?: object; _cachedFields: object; + _cachedDatabases: []; + _cachedCollections: object; + _cachedShellSymbols: object; _extensionPath?: string; constructor(connection) { this._connection = connection; this._cachedFields = {}; + this._cachedDatabases = []; + this._cachedCollections = []; + this._cachedShellSymbols = this.getShellCompletionItems(); } public get connectionString(): string | undefined { @@ -35,6 +42,73 @@ export default class MongoDBService { return this._connectionOptions; } + private getDatabasesCompletionItems(): void { + const worker = new WorkerThreads( + path.resolve(this._extensionPath, 'dist', languageServerWorkerFileName), + { + workerData: { + connectionString: this._connectionString, + connectionOptions: this._connectionOptions + } + } + ); + + this._connection.console.log('MONGOSH get list databases...'); + worker.postMessage(ServerCommands.GET_LIST_DATABASES); + + worker.on('message', ([error, result]) => { + if (error) { + this._connection.console.log( + `MONGOSH get list databases error: ${util.inspect(error)}` + ); + } + + worker.terminate().then(() => { + this._connection.console.log( + `MONGOSH found ${result.length} databases` + ); + this.updateCurrentSessionDatabases(result); + }); + }); + } + + private getCollectionsCompletionItems( + databaseName: string + ): Promise { + return new Promise((resolve) => { + const worker = new WorkerThreads( + path.resolve(this._extensionPath, 'dist', languageServerWorkerFileName), + { + workerData: { + connectionString: this._connectionString, + connectionOptions: this._connectionOptions, + databaseName + } + } + ); + + this._connection.console.log('MONGOSH get list collections...'); + worker.postMessage(ServerCommands.GET_LIST_COLLECTIONS); + + worker.on('message', ([error, result]) => { + if (error) { + this._connection.console.log( + `MONGOSH get list collections error: ${util.inspect(error)}` + ); + } + + worker.terminate().then(() => { + this._connection.console.log( + `MONGOSH found ${result.length} collections` + ); + this.updateCurrentSessionCollections(databaseName, result); + + return resolve(true); + }); + }); + }); + } + public async connectToServiceProvider(params: { connectionString?: string; connectionOptions?: any; @@ -46,6 +120,10 @@ export default class MongoDBService { this._connectionOptions = params.connectionOptions; this._extensionPath = params.extensionPath; + this.clearCurrentSessionFields(); + this.clearCurrentSessionDatabases(); + this.clearCurrentSessionCollections(); + if (!this._connectionString) { return Promise.resolve(false); } @@ -56,6 +134,7 @@ export default class MongoDBService { this._connectionOptions ); this._runtime = new ElectronRuntime(this._serviceProvider); + this.getDatabasesCompletionItems(); return Promise.resolve(true); } catch (error) { @@ -80,7 +159,7 @@ export default class MongoDBService { executionParameters: PlaygroundRunParameters, token: CancellationToken ): Promise { - this._cachedFields = {}; + this.clearCurrentSessionFields(); return new Promise((resolve) => { // Use Node worker threads to run a playground to be able to cancel infinite loops. @@ -93,11 +172,7 @@ export default class MongoDBService { // TODO: After webpackifying the extension replace // the workaround with some similar 3rd-party plugin. const worker = new WorkerThreads( - path.resolve( - this._extensionPath, - 'dist', - languageServerWorkerFileName - ), + path.resolve(this._extensionPath, 'dist', languageServerWorkerFileName), { // The workerData parameter sends data to the created worker. workerData: { @@ -164,56 +239,19 @@ export default class MongoDBService { } // Get shell API symbols/methods completion from mongosh. - protected getShellCompletionItems(expression: string): Promise<[]> { - return new Promise(async (resolve) => { - let mongoshCompletions: any; - - if (!this._runtime) { - return resolve([]); - } - - try { - this._connection.console.log( - `MONGOSH completion body: "${expression}"` - ); - mongoshCompletions = await this._runtime?.getCompletions(expression); - } catch (error) { - this._connection.console.log( - `MONGOSH completion error: ${util.inspect(error)}` - ); - - return resolve([]); - } + private getShellCompletionItems(): object { + const shellSymbols = {}; - if ( - !mongoshCompletions || - !Array.isArray(mongoshCompletions) || - mongoshCompletions.length === 0 - ) { - return resolve([]); - } - - // Convert Completion[] format returned by `mongosh` - // to CompletionItem[] format required by VSCode. - mongoshCompletions = mongoshCompletions.map((item) => { - // The runtime.getCompletions() function returns complitions including the user input. - // Slice the user input and show only suggested keywords to complete the query. - const index = item.completion.indexOf(expression); - const newTextToComplete = `${item.completion.slice( - 0, - index - )}${item.completion.slice(index + expression.length)}`; - const label: string = - index === -1 ? item.completion : newTextToComplete; - - return { - label, + Object.keys(signatures).map((symbol) => { + shellSymbols[symbol] = Object.keys(signatures[symbol].attributes).map( + (item) => ({ + label: item, kind: CompletionItemKind.Method - }; - }); - - return resolve(mongoshCompletions); + }) + ); }); + + return shellSymbols; } private removeSymbolByIndex = (text: string, index: number): string => { @@ -250,6 +288,49 @@ export default class MongoDBService { return textForEsprima.join('\n'); }; + // Check if string is a valid property name + private validPropertyName(str) { + return /^(?![0-9])[a-zA-Z0-9$_]+$/.test(str); + } + + private provideCollectionsItems( + collections: Array, + dbCallPosition: { line: number; character: number } + ): any { + return collections.map((item) => { + const line = dbCallPosition.line; + const startCharacter = dbCallPosition.character - 3; + const endCharacter = dbCallPosition.character; + + if (this.validPropertyName(item.name)) { + return { + label: item.name, + kind: CompletionItemKind.Property + }; + } + + return { + label: item.name, + kind: CompletionItemKind.Property, + filterText: `db.`, + insertTextFormat: 2, + textEdit: { + range: { + start: { + line, + character: startCharacter + }, + end: { + line, + character: endCharacter + } + }, + newText: `db['${item.name}']` + } + }; + }); + } + public provideCompletionItems( textFromEditor: string, position: { line: number; character: number } @@ -268,42 +349,118 @@ export default class MongoDBService { const collectionName = dataFromAST.collectionName; const isObjectKey = dataFromAST.isObjectKey; const isMemberExpression = dataFromAST.isMemberExpression; + const isUseCallExpression = dataFromAST.isUseCallExpression; + const isDbCallExpression = dataFromAST.isDbCallExpression; + const dbCallPosition = dataFromAST.dbCallPosition; + const isAggregationCursor = dataFromAST.isAggregationCursor; + const isFindCursor = dataFromAST.isFindCursor; + + if (databaseName && !this._cachedCollections[databaseName]) { + await this.getCollectionsCompletionItems(databaseName); + } - if (isObjectKey && databaseName && collectionName) { - this._connection.console.log( - 'ESPRIMA response: "Found ObjectExpression"' - ); - + if (databaseName && collectionName) { const namespace = `${databaseName}.${collectionName}`; if (!this._cachedFields[namespace]) { - this._cachedFields[namespace] = await this.getFieldsFromSchema( - databaseName, - collectionName - ); + await this.getFieldsFromSchema(databaseName, collectionName); } - return resolve(this._cachedFields[namespace]); + if (isObjectKey) { + this._connection.console.log('ESPRIMA found field completion'); + + return resolve(this._cachedFields[namespace]); + } + } + + if (isAggregationCursor) { + this._connection.console.log('ESPRIMA found aggregation cursor'); + + return resolve(this._cachedShellSymbols['AggregationCursor']); + } + + if (isFindCursor) { + this._connection.console.log('ESPRIMA found cursor'); + + return resolve(this._cachedShellSymbols['Cursor']); } if (isMemberExpression && collectionName) { - this._connection.console.log( - 'ESPRIMA response: "Found MemberExpression"' - ); + this._connection.console.log('ESPRIMA found shell completion'); - const shellCompletion = await this.getShellCompletionItems( - `db.${collectionName}.` - ); + return resolve(this._cachedShellSymbols['Collection']); + } + + if (isDbCallExpression) { + this._connection.console.log('ESPRIMA found collection db symbol'); + + let dbCompletions: any = [...this._cachedShellSymbols['Database']]; + + if (databaseName) { + this._connection.console.log( + 'ESPRIMA found collection names completion' + ); + + const collectionCompletions = this.provideCollectionsItems( + this._cachedCollections[databaseName], + dbCallPosition + ); + + dbCompletions = dbCompletions.concat(collectionCompletions); + } - return resolve(shellCompletion); + return resolve(dbCompletions); } - this._connection.console.log('ESPRIMA response: "No completion"'); + if (isUseCallExpression) { + this._connection.console.log('ESPRIMA found database names completion'); + + return resolve(this._cachedDatabases); + } + + this._connection.console.log('ESPRIMA no completions'); return resolve([]); }); } + private checkIsUseCall(node: any): boolean { + if ( + node.callee.name === 'use' && + node.arguments && + node.arguments.length === 1 && + node.arguments[0].type === esprima.Syntax.Literal + ) { + return true; + } + + return false; + } + + private checkIsDbCall(node: any): boolean { + if (node.expression.name === 'db') { + return true; + } + + return false; + } + + private checkHasAggregationCall(node: any): boolean { + if (node.property.name === 'aggregate') { + return true; + } + + return false; + } + + private checkHasFindCall(node: any): boolean { + if (node.property.name === 'find') { + return true; + } + + return false; + } + private checkHasDatabaseName( node: any, currentPosition: { line: number; character: number } @@ -345,17 +502,18 @@ export default class MongoDBService { ): boolean { // Esprima counts lines from 1, when vscode counts position starting from 0 const nodeStartLine = node.loc.start.line - 1; - const nodeStartCharacter = node.loc.start.column; const nodeEndLine = node.loc.end.line - 1; - const nodeEndCharacter = node.loc.start.column; + // Do not count brackets + const nodeStartCharacter = node.loc.start.column + 1; + const nodeEndCharacter = node.loc.end.column - 1; + const cursorLine = currentPosition.line; + const cursorCharacter = currentPosition.character; if ( - (currentPosition.line > nodeStartLine && - currentPosition.line < nodeEndLine) || - (currentPosition.line === nodeStartLine && - currentPosition.character >= nodeStartCharacter) || - (currentPosition.line === nodeEndLine && - currentPosition.character <= nodeEndCharacter) + (cursorLine > nodeStartLine && cursorLine < nodeEndLine) || + (cursorLine === nodeStartLine && + cursorCharacter >= nodeStartCharacter && + cursorCharacter <= nodeEndCharacter) ) { return true; } @@ -371,11 +529,21 @@ export default class MongoDBService { collectionName: string | null; isObjectKey: boolean; isMemberExpression: boolean; + isUseCallExpression: boolean; + isDbCallExpression: boolean; + dbCallPosition: { line: number; character: number }; + isAggregationCursor: boolean; + isFindCursor: boolean; } { let databaseName = null; let collectionName = null; let isObjectKey = false; let isMemberExpression = false; + let isUseCallExpression = false; + let isDbCallExpression = false; + let isAggregationCursor = false; + let isFindCursor = false; + let dbCallPosition = { line: 0, character: 0 }; try { this._connection.console.log(`ESPRIMA completion body: "${text}"`); @@ -384,36 +552,72 @@ export default class MongoDBService { estraverse.traverse(ast, { enter: (node) => { - if ( - node.type === esprima.Syntax.CallExpression && - this.checkHasDatabaseName(node, position) - ) { - databaseName = node.arguments[0].value; + if (node.type === esprima.Syntax.CallExpression) { + const isCurrentNode = this.checkIsCurrentNode(node, { + line: position.line, + character: position.character + }); + + if (this.checkIsUseCall(node) && isCurrentNode) { + isUseCallExpression = true; + } + + if (this.checkHasDatabaseName(node, position)) { + databaseName = node.arguments[0].value; + } } - if ( - node.type === esprima.Syntax.MemberExpression && - this.checkHasCollectionName(node, position) - ) { - collectionName = node.property.name - ? node.property.name - : node.property.value; + if (node.type === esprima.Syntax.MemberExpression) { + if (node.object.type === esprima.Syntax.MemberExpression) { + if (this.checkHasAggregationCall(node)) { + isAggregationCursor = true; + } + + if (this.checkHasFindCall(node)) { + isFindCursor = true; + } + } if ( - this.checkIsCurrentNode(node, { - line: position.line, - character: position.character - 2 - }) + node.object.type === esprima.Syntax.Identifier && + this.checkHasCollectionName(node, position) ) { - isMemberExpression = true; + const isCurrentNode = this.checkIsCurrentNode(node, { + line: position.line, + character: position.character - 3 + }); + + collectionName = node.property.name + ? node.property.name + : node.property.value; + + if (isCurrentNode) { + isMemberExpression = true; + } } } - if ( - node.type === esprima.Syntax.ObjectExpression && - this.checkIsCurrentNode(node, position) - ) { - isObjectKey = true; + if (node.type === esprima.Syntax.ExpressionStatement) { + const isCurrentNode = this.checkIsCurrentNode(node, { + line: position.line, + character: position.character - 2 + }); + + if (this.checkIsDbCall(node) && isCurrentNode) { + isDbCallExpression = true; + dbCallPosition = position; + } + } + + if (node.type === esprima.Syntax.ObjectExpression) { + const isCurrentNode = this.checkIsCurrentNode(node, { + line: position.line, + character: position.character - 1 + }); + + if (isCurrentNode) { + isObjectKey = true; + } } } }); @@ -421,25 +625,70 @@ export default class MongoDBService { this._connection.console.log(`ESPRIMA error: ${util.inspect(error)}`); } - return { databaseName, collectionName, isObjectKey, isMemberExpression }; + return { + databaseName, + collectionName, + isObjectKey, + isMemberExpression, + isUseCallExpression, + isDbCallExpression, + isAggregationCursor, + isFindCursor, + dbCallPosition + }; } - public updatedCurrentSessionFields(fields: { - [key: string]: [{ label: string; kind: number }]; - }): void { - this._cachedFields = fields ? fields : {}; + public clearCurrentSessionFields(): void { + this._cachedFields = {}; + } + + public updateCurrentSessionFields( + namespace: string, + fields: [{ label: string; kind: number }] + ): [] { + if (!this._cachedFields[namespace]) { + this._cachedFields[namespace] = {}; + } + + this._cachedFields[namespace] = fields; + + return this._cachedFields[namespace]; + } + + public clearCurrentSessionDatabases(): void { + this._cachedDatabases = []; + } + + public updateCurrentSessionDatabases(databases: any): void { + this._cachedDatabases = databases ? databases : []; + } + + public clearCurrentSessionCollections(): void { + this._cachedCollections = {}; + } + + public updateCurrentSessionCollections( + database: string, + collections: any + ): [] { + if (database) { + this._cachedCollections[database] = collections; + + return this._cachedFields[database]; + } + + return []; } private getFieldsFromSchema( databaseName: string, collectionName: string - ): Promise<[]> { + ): Promise { return new Promise((resolve) => { const namespace = `${databaseName}.${collectionName}`; const worker = new WorkerThreads( path.resolve(this._extensionPath, 'dist', languageServerWorkerFileName), { - // The workerData parameter sends data to the created worker workerData: { connectionString: this._connectionString, connectionOptions: this._connectionOptions, @@ -450,22 +699,18 @@ export default class MongoDBService { ); this._connection.console.log(`SCHEMA for namespace: "${namespace}"`); - - // Evaluate runtime in the worker thread worker.postMessage(ServerCommands.GET_FIELDS_FROM_SCHEMA); - // Listen for results from the worker thread worker.on('message', ([error, fields]) => { if (error) { this._connection.console.log(`SCHEMA error: ${util.inspect(error)}`); - } else { - this._connection.console.log( - `SCHEMA response: "Found ${fields.length} fields"` - ); } worker.terminate().then(() => { - return resolve(fields ? fields : []); + this._connection.console.log(`SCHEMA found ${fields.length} fields`); + this.updateCurrentSessionFields(namespace, fields); + + return resolve(true); }); }); }); diff --git a/src/language/serverCommands.ts b/src/language/serverCommands.ts index 66b097bf..30ac937f 100644 --- a/src/language/serverCommands.ts +++ b/src/language/serverCommands.ts @@ -3,5 +3,7 @@ export enum ServerCommands { DISCONNECT_TO_SERVICE_PROVIDER = 'DISCONNECT_TO_SERVICE_PROVIDER', EXECUTE_ALL_FROM_PLAYGROUND = 'EXECUTE_ALL_FROM_PLAYGROUND', EXECUTE_RANGE_FROM_PLAYGROUND = 'EXECUTE_RANGE_FROM_PLAYGROUND', - GET_FIELDS_FROM_SCHEMA = 'GET_FIELDS_FROM_SCHEMA' + GET_FIELDS_FROM_SCHEMA = 'GET_FIELDS_FROM_SCHEMA', + GET_LIST_DATABASES = 'GET_LIST_DATABASES', + GET_LIST_COLLECTIONS = 'GET_LIST_COLLECTIONS' } diff --git a/src/language/worker.ts b/src/language/worker.ts index 40023b53..f425aca3 100644 --- a/src/language/worker.ts +++ b/src/language/worker.ts @@ -83,10 +83,10 @@ const findAndParse = ( }; const getFieldsFromSchema = async ( - connectionString, - connectionOptions, - databaseName, - collectionName + connectionString: string, + connectionOptions: any, + databaseName: string, + collectionName: string ): Promise => { try { const serviceProvider: CliServiceProvider = await CliServiceProvider.connect( @@ -106,6 +106,52 @@ const getFieldsFromSchema = async ( } }; +const getListDatabases = async ( + connectionString: string, + connectionOptions: any +) => { + try { + const serviceProvider: CliServiceProvider = await CliServiceProvider.connect( + connectionString, + connectionOptions + ); + + // TODO: There is a mistake in the service provider interface + // Use `admin` as arguments to get list of dbs + // and remove it later when `mongosh` will merge a fix + const result = await serviceProvider.listDatabases('admin'); + const databases = result + ? result.databases.map((item) => ({ + label: item.name, + kind: CompletionItemKind.Value + })) + : []; + + return [null, databases]; + } catch (error) { + return [error]; + } +}; + +const getListCollections = async ( + connectionString: string, + connectionOptions: any, + databaseName: string +) => { + try { + const serviceProvider: CliServiceProvider = await CliServiceProvider.connect( + connectionString, + connectionOptions + ); + const result = await serviceProvider.listCollections(databaseName); + const collections = result ? result : []; + + return [null, collections]; + } catch (error) { + return [error]; + } +}; + // parentPort allows communication with the parent thread. parentPort?.once( 'message', @@ -130,5 +176,24 @@ parentPort?.once( ) ); } + + if (message === ServerCommands.GET_LIST_DATABASES) { + parentPort?.postMessage( + await getListDatabases( + workerData.connectionString, + workerData.connectionOptions + ) + ); + } + + if (message === ServerCommands.GET_LIST_COLLECTIONS) { + parentPort?.postMessage( + await getListCollections( + workerData.connectionString, + workerData.connectionOptions, + workerData.databaseName + ) + ); + } } ); diff --git a/src/templates/playgroundTemplate.ts b/src/templates/playgroundTemplate.ts index 89bc4572..4f8693b6 100644 --- a/src/templates/playgroundTemplate.ts +++ b/src/templates/playgroundTemplate.ts @@ -2,27 +2,27 @@ const template: string = `// MongoDB Playground // To disable this template go to Settings | MongoDB | Use Default Template For Playground. // Select the database to use. -use("test"); +use('test'); // Insert a few documents into the sales collection. db.sales.insertMany([ - { "_id" : 1, "item" : "abc", "price" : 10, "quantity" : 2, "date" : new Date("2014-03-01T08:00:00Z") }, - { "_id" : 2, "item" : "jkl", "price" : 20, "quantity" : 1, "date" : new Date("2014-03-01T09:00:00Z") }, - { "_id" : 3, "item" : "xyz", "price" : 5, "quantity" : 10, "date" : new Date("2014-03-15T09:00:00Z") }, - { "_id" : 4, "item" : "xyz", "price" : 5, "quantity" : 20, "date" : new Date("2014-04-04T11:21:39.736Z") }, - { "_id" : 5, "item" : "abc", "price" : 10, "quantity" : 10, "date" : new Date("2014-04-04T21:23:13.331Z") }, - { "_id" : 6, "item" : "def", "price" : 7.5, "quantity": 5, "date" : new Date("2015-06-04T05:08:13Z") }, - { "_id" : 7, "item" : "def", "price" : 7.5, "quantity": 10, "date" : new Date("2015-09-10T08:43:00Z") }, - { "_id" : 8, "item" : "abc", "price" : 10, "quantity" : 5, "date" : new Date("2016-02-06T20:20:13Z") }, + { '_id' : 1, 'item' : 'abc', 'price' : 10, 'quantity' : 2, 'date' : new Date('2014-03-01T08:00:00Z') }, + { '_id' : 2, 'item' : 'jkl', 'price' : 20, 'quantity' : 1, 'date' : new Date('2014-03-01T09:00:00Z') }, + { '_id' : 3, 'item' : 'xyz', 'price' : 5, 'quantity' : 10, 'date' : new Date('2014-03-15T09:00:00Z') }, + { '_id' : 4, 'item' : 'xyz', 'price' : 5, 'quantity' : 20, 'date' : new Date('2014-04-04T11:21:39.736Z') }, + { '_id' : 5, 'item' : 'abc', 'price' : 10, 'quantity' : 10, 'date' : new Date('2014-04-04T21:23:13.331Z') }, + { '_id' : 6, 'item' : 'def', 'price' : 7.5, 'quantity': 5, 'date' : new Date('2015-06-04T05:08:13Z') }, + { '_id' : 7, 'item' : 'def', 'price' : 7.5, 'quantity': 10, 'date' : new Date('2015-09-10T08:43:00Z') }, + { '_id' : 8, 'item' : 'abc', 'price' : 10, 'quantity' : 5, 'date' : new Date('2016-02-06T20:20:13Z') }, ]); // Run a find command to view items sold on April 4th, 2014. -db.sales.find({ date: { $gte: new Date("2014-04-04"), $lt: new Date("2014-04-05") } }); +db.sales.find({ date: { $gte: new Date('2014-04-04'), $lt: new Date('2014-04-05') } }); // Run an aggregation to view total sales for each product in 2014. const aggregation = [ - { $match: { date: { $gte: new Date("2014-01-01"), $lt: new Date("2015-01-01") } } }, - { $group: { _id : "$item", totalSaleAmount: { $sum: { $multiply: [ "$price", "$quantity" ] } } } } + { $match: { date: { $gte: new Date('2014-01-01'), $lt: new Date('2015-01-01') } } }, + { $group: { _id : '$item', totalSaleAmount: { $sum: { $multiply: [ '$price', '$quantity' ] } } } } ]; db.sales.aggregate(aggregation); `; diff --git a/src/test/fixture/.vscode/settings.json b/src/test/fixture/.vscode/settings.json index d283111a..a2dd2c53 100644 --- a/src/test/fixture/.vscode/settings.json +++ b/src/test/fixture/.vscode/settings.json @@ -12,5 +12,11 @@ "mongodbLanguageServer.trace.server": { "format": "json", "verbosity": "verbose" + }, + "editor.suggest.snippetsPreventQuickSuggestions": false, + "editor.quickSuggestions": { + "other": true, + "comments": false, + "strings": true } } diff --git a/src/test/suite/language/mongoDBService.test.ts b/src/test/suite/language/mongoDBService.test.ts index 0331a2c8..a6205927 100644 --- a/src/test/suite/language/mongoDBService.test.ts +++ b/src/test/suite/language/mongoDBService.test.ts @@ -56,7 +56,6 @@ suite('MongoDBService Test Suite', () => { 'db.test.', { line: 0, character: 8 } ); - const findCompletion = result.find( (itme: { label: string; kind: number }) => itme.label === 'find' ); @@ -72,7 +71,6 @@ suite('MongoDBService Test Suite', () => { 'const name = () => { db.test. }', { line: 0, character: 29 } ); - const findCompletion = result.find( (itme: { label: string; kind: number }) => itme.label === 'find' ); @@ -88,7 +86,6 @@ suite('MongoDBService Test Suite', () => { ['use("test");', 'db["test"].'].join('\n'), { line: 1, character: 11 } ); - const findCompletion = result.find( (itme: { label: string; kind: number }) => itme.label === 'find' ); @@ -104,7 +101,6 @@ suite('MongoDBService Test Suite', () => { ["use('test');", "db['test']."].join('\n'), { line: 1, character: 11 } ); - const findCompletion = result.find( (itme: { label: string; kind: number }) => itme.label === 'find' ); @@ -115,15 +111,102 @@ suite('MongoDBService Test Suite', () => { ); }); - test('provide fields completion if has db, connection and is object key', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] + test('provide shell API symbols/methods completion if db symbol', async () => { + const result = await testMongoDBService.provideCompletionItems('db.', { + line: 0, + character: 3 }); + const findCompletion = result.find( + (itme: { label: string; kind: number }) => + itme.label === 'getCollectionNames' + ); + + expect(findCompletion).to.have.property( + 'kind', + CompletionItemKind.Method + ); + }); + + test('provide shell API symbols/methods completion if aggregation cursor', async () => { + const result = await testMongoDBService.provideCompletionItems( + 'db.collection.aggregate().', + { line: 0, character: 26 } + ); + const aggCompletion = result.find( + (itme: { label: string; kind: number }) => itme.label === 'toArray' + ); + const findCompletion = result.find( + (itme: { label: string; kind: number }) => + itme.label === 'allowPartialResults' + ); + + expect(aggCompletion).to.have.property('kind', CompletionItemKind.Method); + expect(findCompletion).to.be.undefined; + }); + + test('provide shell API symbols/methods completion if find cursor without args', async () => { + const result = await testMongoDBService.provideCompletionItems( + 'db.collection.find().', + { line: 0, character: 21 } + ); + const findCompletion = result.find( + (itme: { label: string; kind: number }) => + itme.label === 'allowPartialResults' + ); + + expect(findCompletion).to.have.property( + 'kind', + CompletionItemKind.Method + ); + }); + + test('provide shell API symbols/methods completion if find cursor with args at the same line', async () => { + const result = await testMongoDBService.provideCompletionItems( + ['use("companies");', '', 'db.companies.find({ blog_feed_url}).'].join( + '\n' + ), + { line: 2, character: 36 } + ); + const findCompletion = result.find( + (itme: { label: string; kind: number }) => + itme.label === 'allowPartialResults' + ); + + expect(findCompletion).to.have.property( + 'kind', + CompletionItemKind.Method + ); + }); + + test('provide shell API symbols/methods completion if find cursor with args below', async () => { + const result = await testMongoDBService.provideCompletionItems( + [ + 'use("companies");', + '', + 'const name = () => { db.companies.find({', + ' blog_feed_url', + '}).}' + ].join('\n'), + { line: 4, character: 3 } + ); + const findCompletion = result.find( + (itme: { label: string; kind: number }) => + itme.label === 'allowPartialResults' + ); + + expect(findCompletion).to.have.property( + 'kind', + CompletionItemKind.Method + ); + }); + + test('provide fields completion if has db, connection and is object key', async () => { + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( 'use("test"); db.collection.find({ j});', @@ -137,14 +220,12 @@ suite('MongoDBService Test Suite', () => { }); test('provide fields completion if text not formatted', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( 'use("test");db.collection.find({j});', @@ -158,14 +239,12 @@ suite('MongoDBService Test Suite', () => { }); test('provide fields completion if functions are multi-lined', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( [ @@ -184,14 +263,12 @@ suite('MongoDBService Test Suite', () => { }); test('provide fields completion if object is multi-lined', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( ['use("test");', '', 'db.collection.find({', ' j', '});'].join('\n'), @@ -205,14 +282,12 @@ suite('MongoDBService Test Suite', () => { }); test('provide fields completion if object key is surrounded by spaces', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( 'use("test"); db.collection.find({ j });', @@ -226,20 +301,18 @@ suite('MongoDBService Test Suite', () => { }); test('provide fields completion for proper db', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'first.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ], - 'second.collection': [ - { - label: 'TypeScript', - kind: CompletionItemKind.Field - } - ] - }); + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); + testMongoDBService.updateCurrentSessionFields('second.collection', [ + { + label: 'TypeScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( 'use("first"); use("second"); db.collection.find({ t});', @@ -258,14 +331,12 @@ suite('MongoDBService Test Suite', () => { }); test('provide fields completion if function scope', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( 'use("test"); const name = () => { db.collection.find({ j}); }', @@ -279,79 +350,78 @@ suite('MongoDBService Test Suite', () => { expect(findCompletion).to.have.property('kind', CompletionItemKind.Field); }); - test('do not provide fields completion if has not db', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + test('provide fields completion if snippets mode', async () => { + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( - 'db.collection.find({ j});', - { line: 0, character: 22 } + 'use("test"); db.collection.aggregate([ { $match: { j} } ])', + { line: 0, character: 52 } ); + const findCompletion = result.find( (itme: { label: string; kind: number }) => itme.label === 'JavaScript' ); - expect(findCompletion).to.be.undefined; + expect(findCompletion).to.have.property('kind', CompletionItemKind.Field); }); - test('do not provide fields completion if has wrong db', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + test('provide fields completion for proper collection', async () => { + testMongoDBService.updateCurrentSessionFields('test.firstCollection', [ + { + label: 'JavaScript First', + kind: CompletionItemKind.Field + } + ]); + testMongoDBService.updateCurrentSessionFields('test.secondCollection', [ + { + label: 'JavaScript Second', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( - 'use("other"); db.collection.find({ j});', - { line: 0, character: 36 } + 'use("test"); db.firstCollection.find({ j});', + { line: 0, character: 40 } ); const findCompletion = result.find( - (itme: { label: string; kind: number }) => itme.label === 'JavaScript' + (itme: { label: string; kind: number }) => + itme.label === 'JavaScript First' ); - expect(findCompletion).to.be.undefined; + expect(findCompletion).to.have.property('kind', CompletionItemKind.Field); }); - test('do not provide fields completion if has wrong collection', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + test('do not provide fields completion if has not db', async () => { + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( - 'use("test"); db.test.find({ j});', - { line: 0, character: 29 } + 'db.collection.find({ j});', + { line: 0, character: 22 } ); const findCompletion = result.find( (itme: { label: string; kind: number }) => itme.label === 'JavaScript' ); expect(findCompletion).to.be.undefined; - expect(result).to.be.deep.equal([]); }); test('do not provide fields completion if not object id', async () => { - testMongoDBService.updatedCurrentSessionFields({ - 'test.collection': [ - { - label: 'JavaScript', - kind: CompletionItemKind.Field - } - ] - }); + testMongoDBService.updateCurrentSessionFields('test.collection', [ + { + label: 'JavaScript', + kind: CompletionItemKind.Field + } + ]); const result = await testMongoDBService.provideCompletionItems( 'use("test"); db.collection(j);', @@ -364,20 +434,97 @@ suite('MongoDBService Test Suite', () => { expect(findCompletion).to.be.undefined; }); - test('do not provide shell completion if disconnected', async () => { - await testMongoDBService.disconnectFromServiceProvider(); + test('provide db names completion', async () => { + testMongoDBService.updateCurrentSessionDatabases([ + { + label: 'admin', + kind: CompletionItemKind.Value + } + ]); const result = await testMongoDBService.provideCompletionItems( - 'db.test.', - { line: 0, character: 8 } + 'use("a");', + { line: 0, character: 6 } ); - const findCompletion = result.find( - (itme: { label: string; kind: number }) => itme.label === 'find' + + expect(result.length).to.be.equal(1); + + const db = result.shift(); + + expect(db).to.have.property('label', 'admin'); + expect(db).to.have.property('kind', CompletionItemKind.Value); + }); + + test('provide collection names completion for valid object names', async () => { + testMongoDBService.updateCurrentSessionCollections('test', [ + { name: 'empty' } + ]); + + const result = await testMongoDBService.provideCompletionItems( + 'use("test"); db.', + { line: 0, character: 16 } + ); + const findCollectionCompletion = result.find( + (itme: any) => itme.label === 'empty' ); - expect(testMongoDBService.connectionString).to.be.undefined; - expect(testMongoDBService.connectionOptions).to.be.undefined; - expect(findCompletion).to.be.undefined; + expect(findCollectionCompletion).to.have.property( + 'kind', + CompletionItemKind.Property + ); + }); + + test('provide collection names completion for object names with dashes', async () => { + testMongoDBService.updateCurrentSessionCollections('berlin', [ + { + name: 'coll-name' + } + ]); + + const result = await testMongoDBService.provideCompletionItems( + "use('berlin'); db.", + { line: 0, character: 18 } + ); + const findCollectionCompletion = result.find( + (itme: any) => itme.label === 'coll-name' + ); + + expect(findCollectionCompletion).to.have.property( + 'kind', + CompletionItemKind.Property + ); + + expect(findCollectionCompletion) + .to.have.property('textEdit') + .that.has.property('newText', "db['coll-name']"); + }); + + test('provide collection names and shell db symbol completion', async () => { + testMongoDBService.updateCurrentSessionCollections('berlin', [ + { + name: 'coll-name' + } + ]); + + const result = await testMongoDBService.provideCompletionItems( + "use('berlin'); db.", + { line: 0, character: 18 } + ); + const findCollectionCompletion = result.find( + (itme: any) => itme.label === 'coll-name' + ); + const findShellCompletion = result.find( + (itme: any) => itme.label === 'getCollectionNames' + ); + + expect(findCollectionCompletion).to.have.property( + 'kind', + CompletionItemKind.Property + ); + expect(findShellCompletion).to.have.property( + 'kind', + CompletionItemKind.Method + ); }); }); @@ -396,10 +543,13 @@ suite('MongoDBService Test Suite', () => { this.timeout(INCREASED_TEST_TIMEOUT); const source = new CancellationTokenSource(); - const result = await testMongoDBService.executeAll({ - codeToEvaluate: '1 + 1', - extensionPath: mdbTestExtension.testExtensionContext.extensionPath - }, source.token); + const result = await testMongoDBService.executeAll( + { + codeToEvaluate: '1 + 1', + extensionPath: mdbTestExtension.testExtensionContext.extensionPath + }, + source.token + ); expect(result).to.be.equal('2'); }); @@ -408,10 +558,13 @@ suite('MongoDBService Test Suite', () => { this.timeout(INCREASED_TEST_TIMEOUT); const source = new CancellationTokenSource(); - const result = await testMongoDBService.executeAll( { - codeToEvaluate: 'const x = 1; x + 2', - extensionPath: mdbTestExtension.testExtensionContext.extensionPath - }, source.token); + const result = await testMongoDBService.executeAll( + { + codeToEvaluate: 'const x = 1; x + 2', + extensionPath: mdbTestExtension.testExtensionContext.extensionPath + }, + source.token + ); expect(result).to.be.equal('3'); }); @@ -420,17 +573,23 @@ suite('MongoDBService Test Suite', () => { this.timeout(INCREASED_TEST_TIMEOUT); const source = new CancellationTokenSource(); - const firstEvalResult = await testMongoDBService.executeAll( { - codeToEvaluate: 'const x = 1 + 1; x', - extensionPath: mdbTestExtension.testExtensionContext.extensionPath - }, source.token); + const firstEvalResult = await testMongoDBService.executeAll( + { + codeToEvaluate: 'const x = 1 + 1; x', + extensionPath: mdbTestExtension.testExtensionContext.extensionPath + }, + source.token + ); expect(firstEvalResult).to.be.equal('2'); - const secondEvalResult = await testMongoDBService.executeAll( { - codeToEvaluate: 'const x = 2 + 1; x', - extensionPath: mdbTestExtension.testExtensionContext.extensionPath - }, source.token); + const secondEvalResult = await testMongoDBService.executeAll( + { + codeToEvaluate: 'const x = 2 + 1; x', + extensionPath: mdbTestExtension.testExtensionContext.extensionPath + }, + source.token + ); expect(secondEvalResult).to.be.equal('3'); });