diff --git a/package-lock.json b/package-lock.json index 677244ff..377e94a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/gulp": "^4.0.9", "@types/mocha": "^9.1.1", "@types/node": "^16.11.65", + "@types/sinon": "^10.0.13", "@types/vscode": "^1.62.0", "@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/parser": "^5.40.0", @@ -39,6 +40,7 @@ "mocha": "^9.2.2", "prettier": "^2.7.1", "rimraf": "^3.0.2", + "sinon": "^14.0.0", "ts-loader": "^9.4.1", "ts-node": "^10.9.1", "tsconfig-paths-webpack-plugin": "^3.5.2", @@ -237,6 +239,50 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", + "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-7.0.1.tgz", + "integrity": "sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -426,6 +472,21 @@ "@types/ws": "*" } }, + "node_modules/@types/sinon": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.13.tgz", + "integrity": "sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz", + "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", + "dev": true + }, "node_modules/@types/undertaker": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@types/undertaker/-/undertaker-1.2.8.tgz", @@ -5586,6 +5647,12 @@ "integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==", "dev": true }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "node_modules/keytar": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", @@ -5931,6 +5998,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6789,6 +6862,37 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/nise": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.3.tgz", + "integrity": "sha512-U597iWTTBBYIV72986jyU382/MMZ70ApWcRmkoF1AZ75bpqOtI3Gugv/6+0jLgoDOabmcSwYBkSSAWIp1eA5cg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^7.0.4", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz", + "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, "node_modules/node-abi": { "version": "3.26.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.26.0.tgz", @@ -7412,6 +7516,21 @@ "node": ">=0.10.0" } }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-to-regexp/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8511,6 +8630,24 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sinon": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.2.tgz", + "integrity": "sha512-PDpV0ZI3ZCS3pEqx0vpNp6kzPhHrLx72wA0G+ZLaaJjLIYeE0n8INlgaohKuGy7hP0as5tbUd23QWu5U233t+w==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^9.1.2", + "@sinonjs/samsam": "^7.0.1", + "diff": "^5.0.0", + "nise": "^5.1.2", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -11032,6 +11169,52 @@ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "dev": true }, + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", + "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + } + } + }, + "@sinonjs/samsam": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-7.0.1.tgz", + "integrity": "sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -11215,6 +11398,21 @@ "@types/ws": "*" } }, + "@types/sinon": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.13.tgz", + "integrity": "sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz", + "integrity": "sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==", + "dev": true + }, "@types/undertaker": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@types/undertaker/-/undertaker-1.2.8.tgz", @@ -15182,6 +15380,12 @@ "integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==", "dev": true }, + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, "keytar": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", @@ -15448,6 +15652,12 @@ "p-locate": "^5.0.0" } }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -16129,6 +16339,41 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "nise": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.3.tgz", + "integrity": "sha512-U597iWTTBBYIV72986jyU382/MMZ70ApWcRmkoF1AZ75bpqOtI3Gugv/6+0jLgoDOabmcSwYBkSSAWIp1eA5cg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^7.0.4", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "@sinonjs/fake-timers": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz", + "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + } + } + } + } + }, "node-abi": { "version": "3.26.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.26.0.tgz", @@ -16597,6 +16842,23 @@ "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", "dev": true }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + } + } + }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -17414,6 +17676,20 @@ "simple-concat": "^1.0.0" } }, + "sinon": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.2.tgz", + "integrity": "sha512-PDpV0ZI3ZCS3pEqx0vpNp6kzPhHrLx72wA0G+ZLaaJjLIYeE0n8INlgaohKuGy7hP0as5tbUd23QWu5U233t+w==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^9.1.2", + "@sinonjs/samsam": "^7.0.1", + "diff": "^5.0.0", + "nise": "^5.1.2", + "supports-color": "^7.2.0" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/package.json b/package.json index b5456ca3..265148e6 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,10 @@ "pathToQueryFile": { "type": "string", "description": "%extension.pqtest.taskDefinitions.properties.pathToQueryFile.description%" + }, + "credentialTemplate": { + "type": "object", + "description": "%extension.pqtest.taskDefinitions.properties.credentialTemplate.description%" } } } @@ -352,6 +356,7 @@ "@types/glob": "^8.0.0", "@types/gulp": "^4.0.9", "@types/mocha": "^9.1.1", + "@types/sinon": "^10.0.13", "@types/node": "^16.11.65", "@types/vscode": "^1.62.0", "@typescript-eslint/eslint-plugin": "^5.40.0", @@ -360,6 +365,7 @@ "@vscode/debugadapter-testsupport": "^1.57.0", "@vscode/test-electron": "^1.6.2", "chai": "^4.3.6", + "sinon": "^14.0.0", "eslint": "^8.25.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-license-header": "^0.4.0", diff --git a/package.nls.json b/package.nls.json index 99bcb690..d6714d0e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -21,6 +21,7 @@ "extension.pqtest.taskDefinitions.properties.additionalArgs.description": "Additional commandline arguments for the operation", "extension.pqtest.taskDefinitions.properties.pathToConnector.description": "Path to the connector file (--extension)", "extension.pqtest.taskDefinitions.properties.pathToQueryFile.description": "Path to the query file (--queryFile)", + "extension.pqtest.taskDefinitions.properties.credentialTemplate.description": "Credential template object.", "extension.pqtest.explorer.name": "Power query SDK", "extension.pqtest.welcome.contents": "In order to use extension features, you need to create an Power query extension project.\n[Create an extension project](command:powerquery.sdk.tools.CreateNewProjectCommand)\nTo learn more about how to create an extension, [read our docs](https://aka.ms/PowerQuerySDKDocs).", "extension.pqtest.debugger.properties.program.description": "Absolute path to a power query file.", diff --git a/src/common/PowerQueryTask.ts b/src/common/PowerQueryTask.ts index 4fab96e6..973c0a34 100644 --- a/src/common/PowerQueryTask.ts +++ b/src/common/PowerQueryTask.ts @@ -22,6 +22,7 @@ export interface PQTestTask extends PowerQueryTask { readonly pathToConnector?: string; readonly pathToQueryFile?: string; readonly stdinStr?: string; + readonly credentialTemplate?: object; } export interface PowerQueryTaskDefinition extends PQTestTask, vscode.TaskDefinition {} diff --git a/src/common/errors.ts b/src/common/errors.ts new file mode 100644 index 00000000..9f04021a --- /dev/null +++ b/src/common/errors.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +export class BaseError extends Error { + constructor(message: string) { + super(message); + } + + /** + * Capture current stack trace of the caller + */ + captureStackTrace(): string | undefined { + const container: Error = new Error(); + this.stack = container.stack; + + return this.stack; + } +} diff --git a/src/common/iterables/FibonacciNumbers.ts b/src/common/iterables/FibonacciNumbers.ts new file mode 100644 index 00000000..aaad0cef --- /dev/null +++ b/src/common/iterables/FibonacciNumbers.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import { NumberIterator } from "./NumberIterator"; + +export const fibonacciNumbers: () => NumberIterator = () => { + let curVal: number = 1; + let nextVal: number = 1; + + return new NumberIterator(() => { + const value: number = curVal; + curVal = nextVal; + nextVal += value; + + return { + done: false, + value, + }; + }); +}; diff --git a/src/common/iterables/NumberIterator.ts b/src/common/iterables/NumberIterator.ts new file mode 100644 index 00000000..fa01f3f9 --- /dev/null +++ b/src/common/iterables/NumberIterator.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +export const DONE: IteratorReturnResult = { done: true, value: undefined }; + +export type NumberIteratorResult = IteratorResult; +export type IterableNumbers = () => NumberIteratorResult; +export type NumberMapper = (para: number) => number; +export type NumberGenerator = () => NumberIterator; + +const toMsMapper: NumberMapper = (x: number) => Math.floor(x * 1e3); + +export class NumberIterator implements Iterator { + next: IterableNumbers; + + constructor(_next: IterableNumbers) { + this.next = _next; + } + + [Symbol.iterator](): Iterator { + return this; + } + + map(fn: NumberMapper): NumberIterator { + return new NumberIterator(() => { + const cursor: NumberIteratorResult = this.next(); + + if (cursor.done) { + return cursor; + } + + return { + done: false, + value: fn(cursor.value), + }; + }); + } + + addNoise(factor: number = 0.1): NumberIterator { + return this.map((value: number) => value * (1 + (Math.random() - 0.5) * factor)); + } + + toMs(): NumberIterator { + return this.map(toMsMapper); + } + + clamp(min: number, max: number): NumberIterator { + // eslint-disable-next-line no-nested-ternary + return this.map((value: number) => (value < min ? min : value > max ? max : value)); + } + + take(n: number): NumberIterator { + let i: number = 0; + + return new NumberIterator(() => { + if (i < n) { + ++i; + + return this.next(); + } + + return DONE; + }); + } +} diff --git a/src/common/promises/CancellationToken.ts b/src/common/promises/CancellationToken.ts new file mode 100644 index 00000000..b108ae3b --- /dev/null +++ b/src/common/promises/CancellationToken.ts @@ -0,0 +1,286 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import type { CancellationToken as IVscCancellationToken } from "vscode"; + +import { AnyFunction, BasicEvent } from "./types"; +import { defer, DeferredResult } from "./defer"; + +import { $$toStringTag } from "./symbols"; +import { BaseError } from "../errors"; +import { isPromise } from "./isPromise"; +import { noop } from "./noop"; + +export type CancelAction = (message: string | Cancel) => void; + +// eslint-disable-next-line @typescript-eslint/typedef +const cancellationTokenTag = "CancellationToken" as const; + +const InternalActionGetter: (action: CancelAction) => void = (_action: CancelAction) => { + // noop +}; + +type InternalHandler = AnyFunction & { listener: AnyFunction | { handleEvent: AnyFunction } }; + +export class Cancel extends BaseError { + // private readonly message: string; + + constructor(message: string = "this action has been canceled") { + super(message); + } +} + +export class CancellationToken implements IVscCancellationToken { + static readonly none: CancellationToken = new CancellationToken(InternalActionGetter); + static readonly canceled: CancellationToken = new CancellationToken(InternalActionGetter); + + static activateInternalTokens(): void { + void this.canceled.doCancel("canceled"); + + const none: Cancel = new Cancel("none"); + this.none.addHandler(() => noop); + this.none._promise = Promise.resolve(none); + } + + static isCancellationToken(value: { [index: string | symbol]: unknown } | CancellationToken): boolean { + return ( + Boolean(value) && (value as { [index: string | symbol]: unknown })[$$toStringTag] === cancellationTokenTag + ); + } + + static from(value: { [index: string | symbol]: unknown } | AbortSignal): CancellationToken { + if (this.isCancellationToken(value as { [index: string | symbol]: unknown })) { + return value as CancellationToken; + } + + // todo what!!!, nodeJs.AbortSignal missed onabort ??!! add it to global.d.ts + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const abortSignal: any = value as any; + // const abortSignal: AbortSignal = value as AbortSignal; + + const token: CancellationToken = new CancellationToken(InternalActionGetter); + + abortSignal.onabort = (): void => { + void token.doCancel(abortSignal.reason ?? "Aborted"); + }; + + return token; + } + + private _handlers: AnyFunction[] | undefined = undefined; + private _reason: Cancel | undefined; + private _promise: Promise | undefined; + private _resolve: ((value: Cancel) => void) | undefined; + public onAbort: AnyFunction | undefined; + public onCancellationRequested: AnyFunction = noop; + + constructor(cancelActionGetter: (action: CancelAction) => void = InternalActionGetter) { + if (cancelActionGetter !== InternalActionGetter) { + cancelActionGetter(this.doCancel.bind(this)); + } + } + + get reason(): Cancel | undefined { + return this._reason; + } + + get requested(): boolean { + return this._reason !== undefined; + } + + get aborted(): boolean { + return this.requested; + } + + get isCancellationRequested(): boolean { + return this.requested; + } + + get promise(): Promise { + if (!this._promise) { + if (this._reason) { + this._promise = Promise.resolve(this._reason); + } else { + this._promise = new Promise((resolve: (cancel: Cancel) => void) => { + this._resolve = resolve; + }); + } + } + + return this._promise as Promise; + } + + public addHandler(handler: AnyFunction): (handler: AnyFunction) => void { + if (!Array.isArray(this._handlers)) { + if (this.requested) { + throw new TypeError("cannot add a handler to an already canceled token"); + } + + this._handlers = []; + } + + this._handlers.push(handler); + + return this.removeHandler.bind(this, handler); + } + + private removeHandler(handler: AnyFunction): void { + if (this._handlers && this._handlers.length) { + const maybeHandlerIndex: number = this._handlers.indexOf(handler); + + if (maybeHandlerIndex !== -1) { + this._handlers.splice(maybeHandlerIndex, 1); + } + } + } + + public throwIfRequested(): void { + if (this._reason) { + throw this._reason; + } + } + + get [$$toStringTag](): typeof cancellationTokenTag { + return cancellationTokenTag; + } + + public addEventListener(type: string, listener: AnyFunction | { handleEvent: AnyFunction }): void { + if (type !== "abort") { + return; + } + + const event: BasicEvent = { type: "abort" }; + + const handler: AnyFunction = + typeof listener === "function" ? (): void => listener(event) : (): void => listener.handleEvent(event); + + // save the listener reference for removing + (handler as InternalHandler).listener = listener; + + this.addHandler(handler); + } + + public removeEventListener(type: string, listener: AnyFunction | { handleEvent: AnyFunction }): void { + if (type !== "abort") { + return; + } + + if (this._handlers) { + const indexToRemove: number = this._handlers.findIndex( + (internalHandler: AnyFunction) => (internalHandler as InternalHandler).listener === listener, + ); + + if (indexToRemove !== -1) { + this._handlers.splice(indexToRemove, 1); + } + } + } + + public dependsOn(others: CancellationToken[]): void { + for (const other of others) { + const { reason }: CancellationToken = other; + + if (reason) { + void this.doCancel(reason); + + return; + } + + other.addHandler(this.doCancel.bind(this)); + } + } + + private doCancel(message: string | Cancel): Promise { + if (this._reason) { + // it has already been cancelled + return Promise.resolve(); + } + + this._reason = + // eslint-disable-next-line no-nested-ternary + message instanceof Cancel + ? message + : typeof message === "string" + ? new Cancel(message) + : new Cancel("Unknown aborted reason"); + + // if we got _resolve handler of the cancellation, invoke it + if (this._resolve) { + const theResolve: ((value: Cancel) => void) | undefined = this._resolve; + this._resolve = undefined; + theResolve(this._reason); + } + + // if we got onAbort event emitter + if (this.onAbort) { + this.onAbort(); + } + + // if we got onAbort event emitter + if (this.onCancellationRequested !== noop) { + this.onCancellationRequested(this._reason); + } + + const currentHandlers: AnyFunction[] | undefined = this._handlers; + + // clear all the pending handler if any + // and if there were any handler returning a promise, + // we need a wait count to ensure all those promise got resolved + if (currentHandlers) { + this._handlers = undefined; + + const { promise, resolve }: DeferredResult = defer(); + let wait: number = 0; + + const onSettled: () => void = () => { + if (--wait === 0) { + return resolve(); + } + }; + + for (const oneHandler of currentHandlers) { + try { + const result: unknown = oneHandler(this._reason); + + if (isPromise(result)) { + ++wait; + (result as Promise).then(onSettled, onSettled); + } + } catch (_) { + // noop + } + } + + if (wait !== 0) { + return promise; + } + } + + // if not, directly return one resolved promise + return Promise.resolve(); + } +} + +export class CancellationTokenSource { + public token: CancellationToken; + public cancel: CancelAction; + constructor(dependents: CancellationToken[] = []) { + this.cancel = (_: string | Cancel): void => { + // noop + }; + + this.token = new CancellationToken((action: CancelAction) => { + this.cancel = action; + }); + + if (dependents.length) { + this.token.dependsOn(dependents); + } + } +} + +CancellationToken.activateInternalTokens(); diff --git a/src/common/promises/cancelable.ts b/src/common/promises/cancelable.ts new file mode 100644 index 00000000..f50669d4 --- /dev/null +++ b/src/common/promises/cancelable.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable prefer-rest-params */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { CancelAction, CancellationToken, CancellationTokenSource } from "./CancellationToken"; +import { AnyReturnedFunction } from "./types"; +import { setFunctionNameAndLength } from "./setFunctionNameAndLength"; + +export type InternalPromise = Promise & { cancel: CancelAction }; + +export const cancelable = >>( + target: F, + name: string | undefined = undefined, +): F extends (arg0: CancellationToken, ...args: unknown[]) => unknown + ? F + : (arg0: CancellationToken, ...args: Parameters) => ReturnType => + setFunctionNameAndLength( + function cancelableWrapper(this: any): ReturnType { + // eslint-disable-next-line @typescript-eslint/no-this-alias,no-invalid-this + const self: unknown = this as any; + const args: unknown[] = [...arguments]; + const length: number = arguments.length; + + if (length !== 0 && CancellationToken.isCancellationToken(arguments[0])) { + // eslint-disable-next-line no-invalid-this + return target.apply(self, args) as ReturnType; + } + + const cancellationTokenSource: CancellationTokenSource = new CancellationTokenSource(); + const newArgs: unknown[] = new Array(length + 1); + newArgs[0] = cancellationTokenSource.token; + + for (let i: number = 0; i < length; ++i) { + newArgs[i + 1] = arguments[i]; + } + + // eslint-disable-next-line no-invalid-this + const promise: InternalPromise = target.apply(this, newArgs) as unknown as InternalPromise; + promise.cancel = cancellationTokenSource.cancel; + + return promise as unknown as ReturnType; + }, + name ?? target.name, + target.length - 1, + ) as unknown as any; diff --git a/src/common/promises/defer.ts b/src/common/promises/defer.ts new file mode 100644 index 00000000..ada2041e --- /dev/null +++ b/src/common/promises/defer.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type DeferredResult = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: any) => void; +}; + +export function defer(): DeferredResult { + let resolve: (value: T) => void = undefined as any; + let reject: (reason: any) => void = undefined as any; + + const promise: Promise = new Promise((_resolve: (value: T) => void, _reject: (reason: any) => void) => { + resolve = _resolve; + reject = _reject; + }); + + return { + promise, + resolve, + reject, + }; +} diff --git a/src/common/promises/doResolve.ts b/src/common/promises/doResolve.ts new file mode 100644 index 00000000..516a83de --- /dev/null +++ b/src/common/promises/doResolve.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import { isPromise } from "./isPromise"; + +export type DidResolvedResult = (value: T) => T extends Promise ? T : Promise; + +export function doResolve(value: T): DidResolvedResult { + return (isPromise(value) ? value : Promise.resolve(value)) as unknown as DidResolvedResult; +} diff --git a/src/common/promises/fromEvent.ts b/src/common/promises/fromEvent.ts new file mode 100644 index 00000000..7dbfb8c6 --- /dev/null +++ b/src/common/promises/fromEvent.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable prefer-rest-params */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { AnyFunction } from "./types"; +import { cancelable } from "./cancelable"; +import { CancellationToken } from "./CancellationToken"; +import { noop } from "./noop"; +import { once } from "./once"; +import WebSocket from "ws"; + +export type AnyEventListener = (type: string, callback: AnyFunction) => void; + +export type ExpectedEmitter = + | { + addEventListener?: AnyEventListener; + removeEventListener?: AnyEventListener; + addListener?: AnyEventListener; + removeListener?: AnyEventListener; + on?: AnyEventListener; + off?: AnyEventListener; + } + | WebSocket; + +export function makeEventAdder( + cancellationToken: CancellationToken, + emitter: ExpectedEmitter, + allParametersInArray: boolean = false, +): AnyEventListener { + const add: AnyFunction | undefined = emitter.addEventListener || emitter.addListener || emitter.on; + + if (add === undefined) { + throw new Error("cannot register event listener"); + } + + const remove: AnyFunction | undefined = emitter.removeEventListener || emitter.removeListener || emitter.off; + + const eventsAndListeners: (AnyEventListener | string)[] = []; + + let clean: AnyFunction = noop; + + if (remove) { + clean = once(() => { + for (let i: number = 0, n: number = eventsAndListeners.length; i < n; i += 2) { + remove.call(emitter, eventsAndListeners[i], eventsAndListeners[i + 1]); + } + }); + + void cancellationToken.promise.then(clean); + } + + return allParametersInArray + ? (eventName: string, cb: AnyFunction): void => { + function listener(): void { + clean(); + const args: unknown[] = Array.prototype.slice.call(arguments); + (args as any).name = eventName; + cb(args); + } + + eventsAndListeners.push(eventName, listener); + add.call(emitter, eventName, listener); + } + : (event: string, cb: AnyFunction): void => { + const listener: AnyEventListener = (arg: unknown) => { + clean(); + cb(arg); + }; + + eventsAndListeners.push(event, listener); + add.call(emitter, event, listener); + }; +} + +export interface FromEventOption { + ignoreErrors?: boolean; + errorEventName?: string; + allParametersInArray?: boolean; +} + +export const fromEvent: (emitter: ExpectedEmitter, event: string, opt?: FromEventOption) => Promise = cancelable( + (cancellationToken: CancellationToken, emitter: ExpectedEmitter, event: string, opt: FromEventOption = {}) => + new Promise((resolve: AnyFunction, reject: AnyFunction) => { + const add: AnyEventListener = makeEventAdder(cancellationToken, emitter, opt.allParametersInArray); + add(event, resolve); + + if (!opt.ignoreErrors) { + const { errorEventName = "error" }: FromEventOption = opt; + + if (errorEventName !== event) { + add(errorEventName, reject); + } + } + }), +) as unknown as any; diff --git a/src/common/promises/fromEvents.ts b/src/common/promises/fromEvents.ts new file mode 100644 index 00000000..76c5cd47 --- /dev/null +++ b/src/common/promises/fromEvents.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable prefer-rest-params */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { AnyFunction } from "./types"; +import { cancelable } from "./cancelable"; +import { CancellationToken } from "./CancellationToken"; + +import { AnyEventListener, ExpectedEmitter, FromEventOption, makeEventAdder } from "./fromEvent"; + +export const fromEvents: ( + emitter: ExpectedEmitter, + successEvents: string[], + errorEvents?: string[], + opt?: FromEventOption, +) => Promise = cancelable( + ( + cancellationToken: CancellationToken, + emitter: ExpectedEmitter, + successEvents: string[], + errorEvents: string[] = ["error"], + opt: FromEventOption = { allParametersInArray: true }, + ) => { + if (typeof opt.allParametersInArray !== "boolean") { + opt.allParametersInArray = true; + } + + return new Promise((resolve: AnyFunction, reject: AnyFunction) => { + const add: AnyEventListener = makeEventAdder(cancellationToken, emitter, opt.allParametersInArray); + + for (const oneSuccessEvtName of successEvents) { + add(oneSuccessEvtName, resolve); + } + + if (!opt.ignoreErrors) { + for (const oneErrorEvtName of errorEvents) { + add(oneErrorEvtName, reject); + } + } + }); + }, +) as unknown as any; diff --git a/src/common/promises/isPromise.ts b/src/common/promises/isPromise.ts new file mode 100644 index 00000000..e4c46428 --- /dev/null +++ b/src/common/promises/isPromise.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isPromise: (value: unknown) => boolean = (value: any) => value != null && typeof value.then === "function"; diff --git a/src/common/promises/noop.ts b/src/common/promises/noop.ts new file mode 100644 index 00000000..1dc68e80 --- /dev/null +++ b/src/common/promises/noop.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +export function noop(): void { + // noop + // I am a lovely noop function, do not remove me +} diff --git a/src/common/promises/once.ts b/src/common/promises/once.ts new file mode 100644 index 00000000..551e8c84 --- /dev/null +++ b/src/common/promises/once.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable prefer-rest-params */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { AnyFunction } from "./types"; + +export const once = (fun: F): F => { + let result: ReturnType | undefined = undefined; + let internalFun: F | undefined = fun; + + return function (this: any) { + // eslint-disable-next-line @typescript-eslint/no-this-alias,no-invalid-this + const self: unknown = this as any; + const args: unknown[] = [...arguments]; + + if (internalFun) { + result = fun.apply(self, args); + internalFun = undefined; + } + + return result; + } as F; +}; diff --git a/src/common/promises/promisifyTry.ts b/src/common/promises/promisifyTry.ts new file mode 100644 index 00000000..785d2b6d --- /dev/null +++ b/src/common/promises/promisifyTry.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import { DidResolvedResult, doResolve } from "./doResolve"; +import { ReturnedFunction } from "./types"; + +export type PromisifyTriedResult = DidResolvedResult | Promise; + +export function promisifyTry(fn: ReturnedFunction): PromisifyTriedResult { + try { + return doResolve(fn()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + return Promise.reject(error); + } +} diff --git a/src/common/promises/setFunctionNameAndLength.ts b/src/common/promises/setFunctionNameAndLength.ts new file mode 100644 index 00000000..eefe7d6b --- /dev/null +++ b/src/common/promises/setFunctionNameAndLength.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import { AnyFunction } from "./types"; + +export function setFunctionNameAndLength( + fn: T, + name: string, + length: number, +): T & { length: number; name: string } { + return Object.defineProperties(fn, { + length: { + configurable: true, + value: length > 0 ? length : 0, + }, + name: { + configurable: true, + value: name, + }, + }) as T & { length: number; name: string }; +} diff --git a/src/common/promises/symbols.ts b/src/common/promises/symbols.ts new file mode 100644 index 00000000..a0fdddc1 --- /dev/null +++ b/src/common/promises/symbols.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +export const $$toStringTag: symbol = Symbol("toStringTag"); diff --git a/src/common/promises/types.ts b/src/common/promises/types.ts new file mode 100644 index 00000000..4e5a0c1e --- /dev/null +++ b/src/common/promises/types.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type AnyFunction = (...args: any[]) => any; +export type AnyReturnedFunction = (...args: any[]) => T; + +export type UnknownFunction = (...args: unknown[]) => unknown; + +export type ReturnedFunction = (...args: unknown[]) => T; + +export interface BasicEvent { + type: string; +} diff --git a/src/common/sockets/JsonRpcSocketClient.ts b/src/common/sockets/JsonRpcSocketClient.ts new file mode 100644 index 00000000..193b8797 --- /dev/null +++ b/src/common/sockets/JsonRpcSocketClient.ts @@ -0,0 +1,170 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable prefer-rest-params, no-invalid-this */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + Message, + RequestMessage, + ResponseMessage, + SocketMessageReader, + SocketMessageWriter, +} from "vscode-jsonrpc/node"; +import { fibonacciNumbers } from "../iterables/FibonacciNumbers"; +import { NumberGenerator } from "../iterables/NumberIterator"; + +import { CLOSED, OPEN, SocketClient, SocketConnectionError } from "./SocketClient"; +import { AnyFunction } from "../promises/types"; +import { BaseError } from "../errors"; +import { noop } from "../promises/noop"; + +const JSON_RPC_VERSION: string = "2.0"; + +export const defaultBackOff: NumberGenerator = (tries: number = 5) => fibonacciNumbers().addNoise().toMs().take(tries); + +export interface RequestBase extends RequestMessage { + /** + * The request id. + */ + id: number; + /** + * The method to be invoked. + */ + method: string; + /** + * The method's params. + */ + params: [T]; +} + +export type NotificationBase = Omit, "id">; + +export interface ResponseBase extends ResponseMessage { + id: number; +} + +let nextRequestId: number = -9007199254740990; + +function makeAsync(fn: T): (...args: Parameters) => Promise> { + return function (this: any) { + return new Promise((resolve: AnyFunction) => resolve(fn.apply(this, [...arguments]))); + } as unknown as (...args: Parameters) => Promise>; +} + +export class JsonRpcMethodNotFound extends BaseError { + public readonly jsonRpcMessage: any; + + constructor(message: string, jsonRpcMessage: any) { + super(message); + this.jsonRpcMessage = jsonRpcMessage; + } +} + +// Default onMessage implementation: +// +// - ignores notifications +// - throw MethodNotFound for all requests +function defaultOnMessage(message: any): void { + if (message.type === "request") { + throw new JsonRpcMethodNotFound(message.method, message); + } +} + +export type DeferredJsonRpcTask = { resolve: AnyFunction; reject: AnyFunction }; + +export class JsonRpcSocketClient extends SocketClient { + private readonly _handle: (message: any) => Promise; + private readonly _deferredDictionary: Map = new Map(); + private reader?: SocketMessageReader; + private writer?: SocketMessageWriter; + + constructor(port: number, host: string = "127.0.0.1", onMessage: (message: any) => void = defaultOnMessage) { + super(port, host); + + this._handle = makeAsync(onMessage); + + this.on(OPEN, () => { + if (!this.socket) { + throw new SocketConnectionError("Socket connection not found"); + } + + this.reader = new SocketMessageReader(this.socket, "utf-8"); + this.writer = new SocketMessageWriter(this.socket, "utf-8"); + + this.reader.listen((message: Message) => { + void this.exec(message); + }); + }); + + this.on(CLOSED, () => { + this.failPendingRequests(new SocketConnectionError("Connection has been closed")); + }); + } + + public exec(rawJsonRpcMessage: Message): Promise { + if (Message.isResponse(rawJsonRpcMessage)) { + const rawJsonRpcResponseMessage: ResponseBase = rawJsonRpcMessage as ResponseBase; + + if (rawJsonRpcMessage.error) { + return this._getDeferred(rawJsonRpcResponseMessage.id)?.reject(rawJsonRpcResponseMessage.error); + } + + return this._getDeferred(rawJsonRpcResponseMessage.id)?.resolve(rawJsonRpcResponseMessage.result); + } else if (Message.isNotification(rawJsonRpcMessage)) { + return this._handle(rawJsonRpcMessage).catch(noop); + } else { + return this._handle(rawJsonRpcMessage).then((result: ResponseBase) => result.result); + } + } + + public failPendingRequests(reason: any): void { + const deferredDictionary: Map = this._deferredDictionary; + + deferredDictionary.forEach((deferredJsonRpcTask: DeferredJsonRpcTask) => { + deferredJsonRpcTask.reject(reason); + }); + + deferredDictionary.clear(); + } + + public write(message: Message): boolean { + if (!this.writer) { + // reject all the unresolved promise so far + this.emit(CLOSED); + + return false; + } + + void this.writer.write(message); + + return true; + } + + public request(method: string, params: any[] = []): Promise { + return new Promise((resolve: AnyFunction, reject: AnyFunction) => { + const requestId: number = nextRequestId++; + this._deferredDictionary.set(requestId, { resolve, reject }); + + this.write({ jsonrpc: JSON_RPC_VERSION, id: requestId, method, params } as RequestBase); + }); + } + + public notify(method: string, params: any[]): void { + this.write({ jsonrpc: JSON_RPC_VERSION, method, params } as NotificationBase); + } + + private _getDeferred(id: number): DeferredJsonRpcTask | undefined { + const deferred: DeferredJsonRpcTask | undefined = this._deferredDictionary.get(id); + + if (deferred) { + this._deferredDictionary.delete(id); + } + + return deferred; + } +} diff --git a/src/common/sockets/SocketClient.ts b/src/common/sockets/SocketClient.ts new file mode 100644 index 00000000..9bf49399 --- /dev/null +++ b/src/common/sockets/SocketClient.ts @@ -0,0 +1,189 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { createConnection, Socket } from "net"; +import { EventEmitter } from "events"; + +import { NumberGenerator, NumberIterator } from "../iterables/NumberIterator"; +import { promisifyTry } from "../promises/promisifyTry"; + +import { BaseError } from "../errors"; +import { delay } from "../../utils/pids"; +import { fromEvent } from "../promises/fromEvent"; +import { fromEvents } from "../promises/fromEvents"; + +export class SocketConnectionError extends BaseError {} +export class SocketAbortedConnection extends SocketConnectionError { + constructor() { + super("Tcp socket connection aborted"); + } +} + +// eslint-disable-next-line @typescript-eslint/typedef +export const CLOSED = "closed" as const; +// eslint-disable-next-line @typescript-eslint/typedef +export const CONNECTING = "connecting" as const; +// eslint-disable-next-line @typescript-eslint/typedef +export const MESSAGE = "message" as const; +// eslint-disable-next-line @typescript-eslint/typedef +export const OPEN = "open" as const; +// eslint-disable-next-line @typescript-eslint/typedef +export const ERROR = "error" as const; + +export type StatusType = "closed" | "connecting" | "open"; + +export type SocketExtended = Socket & { abort?: boolean }; + +export class SocketClient extends EventEmitter { + private _status: StatusType = "closed"; + private _socket: SocketExtended | undefined = undefined; + + constructor(private readonly port: number, private readonly host: string) { + super(); + } + + get status(): StatusType { + return this._status; + } + + get socket(): Socket | undefined { + return this._socket; + } + + close(): Promise { + return promisifyTry(() => { + const status: StatusType = this._status; + + if (status === CLOSED) { + return; + } + + const currentSocket: SocketExtended | undefined = this._socket; + + if (!currentSocket) { + return; + } + + if (status === CONNECTING) { + currentSocket.abort = true; + currentSocket.destroy(); + + return; + } + + const promise: Promise = fromEvent(currentSocket, "close"); + currentSocket.destroy(); + + return promise; + }) as Promise; + } + + open(backOffGenerator?: NumberGenerator): Promise { + if (!backOffGenerator) { + return this._open(); + } + + const theNumberIterator: NumberIterator = backOffGenerator(); + + let __cancelled: boolean = false; + + const cancel = (): void => { + __cancelled = true; + }; + + let __error: any; + + const attempt = (): Promise => { + if (__cancelled) { + throw __error; + } + + return this._open().catch((reason: any) => { + let current: IteratorResult; + + if (reason instanceof SocketAbortedConnection || (current = theNumberIterator.next()).done) { + throw reason; + } + + const value: number = current.value; + + this.emit("scheduledAttempt", { cancel, delay: value }); + + __error = reason; + + return delay(value).then(attempt); + }); + }; + + const result: Promise = attempt(); + (result as any).cancel = cancel; + + return result; + } + + send(data: any): void { + this._assertStatus(OPEN); + this._socket?.write(data); + } + + private _assertStatus(expected: StatusType): void { + if (this._status !== expected) { + throw new SocketConnectionError(`invalid status ${this._status}, expected ${expected}`); + } + } + + private _onClose: (hadError: boolean) => void = (hadError: boolean) => { + const previousStatus: StatusType = this._status; + + this._socket = undefined; + this._status = CLOSED; + + if (previousStatus === OPEN) { + this.emit(CLOSED, hadError); + } + }; + + private _onError: (event: Error) => void = (error: Error) => { + this.emit(ERROR, error); + }; + + private _onMessage: (...args: any[]) => void = (...args: any[]) => { + this.emit(MESSAGE, ...args); + }; + + private _open: () => Promise = () => + promisifyTry(() => { + this._assertStatus(CLOSED); + this._status = CONNECTING; + + return promisifyTry(() => { + const socket: Socket = createConnection(this.port, this.host); + this._socket = socket; + this._socket.setTimeout(0); + this._socket.setKeepAlive(true); + + return fromEvents(socket, ["connect"], ["close", "error"]).then( + () => { + socket.on("close", this._onClose); + socket.on("error", this._onError); + socket.on("message", this._onMessage); + this._status = OPEN; + this.emit(OPEN); + }, + ([error]: any[]) => { + if ((socket as any).abort) { + throw new SocketAbortedConnection(); + } + + throw error; + }, + ); + }); + }) as unknown as Promise; +} diff --git a/src/debugAdaptor/MQueryDebugSession.ts b/src/debugAdaptor/MQueryDebugSession.ts index c301ffee..bf0a1b8c 100644 --- a/src/debugAdaptor/MQueryDebugSession.ts +++ b/src/debugAdaptor/MQueryDebugSession.ts @@ -17,11 +17,17 @@ import { } from "@vscode/debugadapter"; import { DebugProtocol } from "@vscode/debugprotocol"; +import { DISCONNECTED, PqServiceHostClientLite, READY } from "../pqTestConnector/PqServiceHostClientLite"; +import { extensionI18n, resolveI18nTemplate } from "../i18n/extension"; +import { ExtensionInfo, GenericResult } from "../common/PQTestService"; import { PqTestExecutableOnceTask, PqTestExecutableOnceTaskQueueEvents, } from "../pqTestConnector/PqTestExecutableOnceTask"; import { DeferredValue } from "../common/DeferredValue"; +import { ExtensionConfigurations } from "../constants/PowerQuerySdkConfiguration"; +import { fromEvents } from "../common/promises/fromEvents"; +import { stringifyJson } from "../utils/strings"; import { WaitNotify } from "../common/WaitNotify"; /** @@ -44,55 +50,66 @@ interface ILaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { } export class MQueryDebugSession extends LoggingDebugSession { - private readonly _configurationDone: WaitNotify = new WaitNotify(); - private readonly _processForked: DeferredValue = new DeferredValue(false); - private readonly _pqTestExecutableOnceTask: PqTestExecutableOnceTask; + private readonly configurationDone: WaitNotify = new WaitNotify(); + private readonly processForked: DeferredValue = new DeferredValue(false); + private readonly pqTestExecutableOnceTask?: PqTestExecutableOnceTask; + private readonly pqServiceHostClientLite?: PqServiceHostClientLite; + private readonly useServiceHost: boolean; + private currentProgram: string = ""; + private isTerminated: boolean = false; constructor() { super(); this.setDebuggerLinesStartAt1(false); this.setDebuggerColumnsStartAt1(false); - this._pqTestExecutableOnceTask = new PqTestExecutableOnceTask(); - this._pqTestExecutableOnceTask.eventBus.on(PqTestExecutableOnceTaskQueueEvents.processCreated, () => { - this._processForked.resolve(true); - }); + this.useServiceHost = ExtensionConfigurations.featureUseServiceHost; - this._pqTestExecutableOnceTask.eventBus.on( - PqTestExecutableOnceTaskQueueEvents.onOutput, - (type: "stdOutput" | "stdError", text: string) => { - let category: string; + if (this.useServiceHost) { + this.pqServiceHostClientLite = new PqServiceHostClientLite(this); + } else { + this.pqTestExecutableOnceTask = new PqTestExecutableOnceTask(); - switch (type) { - case "stdOutput": - category = "stdout"; - break; - case "stdError": - category = "stderr"; - break; - default: - category = "console"; - break; - } + this.pqTestExecutableOnceTask.eventBus.on(PqTestExecutableOnceTaskQueueEvents.processCreated, () => { + this.processForked.resolve(true); + }); - const e: DebugProtocol.OutputEvent = new OutputEvent(`${text}\n`, category); - const maybePathToQueryFile: string = this._pqTestExecutableOnceTask.pathToQueryFile; + this.pqTestExecutableOnceTask.eventBus.on( + PqTestExecutableOnceTaskQueueEvents.onOutput, + (type: "stdOutput" | "stdError", text: string) => { + let category: string; - if (maybePathToQueryFile) { - e.body.source = this.createSource(this._pqTestExecutableOnceTask.pathToQueryFile); - } + switch (type) { + case "stdOutput": + category = "stdout"; + break; + case "stdError": + category = "stderr"; + break; + default: + category = "console"; + break; + } - this.sendEvent(e); - }, - ); + const e: DebugProtocol.OutputEvent = new OutputEvent(`${text}\n`, category); + const maybePathToQueryFile: string = this.pqTestExecutableOnceTask?.pathToQueryFile ?? ""; - this._pqTestExecutableOnceTask.eventBus.on(PqTestExecutableOnceTaskQueueEvents.processExited, () => { - this.sendEvent(new TerminatedEvent()); + if (maybePathToQueryFile) { + e.body.source = this.createSource(maybePathToQueryFile); + } + + this.sendEvent(e); + }, + ); + + this.pqTestExecutableOnceTask.eventBus.on(PqTestExecutableOnceTaskQueueEvents.processExited, () => { + this.sendEvent(new TerminatedEvent()); - setTimeout(() => { - this._pqTestExecutableOnceTask.dispose(); - }, 0); - }); + setTimeout(() => { + this.pqTestExecutableOnceTask?.dispose(); + }, 0); + }); + } } /** @@ -130,7 +147,7 @@ export class MQueryDebugSession extends LoggingDebugSession { ): void { super.configurationDoneRequest(response, args); // notify the launchRequest that configuration has finished - this._configurationDone.notify(); + this.configurationDone.notify(); } protected override async launchRequest( @@ -141,32 +158,136 @@ export class MQueryDebugSession extends LoggingDebugSession { logger.setup(args.trace ? Logger.LogLevel.Verbose : Logger.LogLevel.Stop, false); // wait 1 second until configuration has finished (and configurationDoneRequest has been called) - await this._configurationDone.wait(2e3); + await this.configurationDone.wait(2e3); - // start the program in the runtime, do not await here - void this._pqTestExecutableOnceTask.run(args.program, { - operation: args.operation ?? "run-test", - additionalArgs: args.additionalArgs, - }); + this.currentProgram = args.program; + + if (this.useServiceHost) { + void this.doLaunchRequest(args); + } else { + // start the program in the runtime, do not await here + void this.pqTestExecutableOnceTask?.run(args.program, { + operation: args.operation ?? "run-test", + additionalArgs: args.additionalArgs, + }); + } this.sendResponse(response); } + private async doLaunchRequest(args: ILaunchRequestArguments): Promise { + if (this.useServiceHost && this.pqServiceHostClientLite) { + // activate pqServiceHostClientLite and make it connect to pqServiceHost + this.pqServiceHostClientLite.onPowerQueryTestLocationChanged(); + + try { + // wait for the pqServiceHostClientLite's socket got ready + await fromEvents(this.pqServiceHostClientLite, [READY], [DISCONNECTED]); + + const theOperation: string = args.operation ?? "run-test"; + + switch (theOperation) { + case "info": { + const displayExtensionInfoResult: ExtensionInfo[] = + await this.pqServiceHostClientLite.DisplayExtensionInfo(); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.display.extension.info.result", { + result: displayExtensionInfoResult + .map((info: ExtensionInfo) => info.Name ?? "") + .filter(Boolean) + .join(","), + }), + ); + + break; + } + + case "test-connection": { + const testConnectionResult: GenericResult = await this.pqServiceHostClientLite.TestConnection(); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.test.connection.result", { + result: stringifyJson(testConnectionResult), + }), + ); + + break; + } + + case "run-test": { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any = await this.pqServiceHostClientLite.RunTestBatteryFromContent( + path.resolve(args.program), + ); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.run.test.result", { + result: stringifyJson(result), + }), + ); + + break; + } + + default: + break; + } + } catch (e) { + // / noop + if (e instanceof Error || typeof e === "string") { + this.appendErrorLine(e.toString()); + } + + this.pqServiceHostClientLite.dispose(); + } + + this.sendEvent(new TerminatedEvent()); + this.pqServiceHostClientLite.dispose(); + } + } + protected override async loadedSourcesRequest( response: DebugProtocol.LoadedSourcesResponse, _args: DebugProtocol.LoadedSourcesArguments, _request?: DebugProtocol.Request, ): Promise { - await this._processForked.deferred$; + await this.processForked.deferred$; response.body = { - sources: [this.createSource(this._pqTestExecutableOnceTask.pathToQueryFile)], + sources: [this.createSource(this.pqTestExecutableOnceTask?.pathToQueryFile ?? "")], }; + this.isTerminated = true; this.sendResponse(response); } private createSource(filePath: string): Source { return new Source(path.dirname(filePath), this.convertDebuggerPathToClient(filePath), undefined, undefined); } + + appendLine(value: string, category: "stdout" | "stderr" | "console" = "stdout"): void { + const e: DebugProtocol.OutputEvent = new OutputEvent(`${this.prefixLineWithTimeStamp(value)}\n`, category); + + if (this.currentProgram) { + e.body.source = this.createSource(path.resolve(this.currentProgram)); + } + + this.sendEvent(e); + } + + private prefixLineWithTimeStamp(line: string): string { + const now: Date = new Date(); + + return `[${now.toLocaleTimeString()}]\t${line}`; + } + + public appendInfoLine(value: string): void { + this.appendLine(`[${extensionI18n["PQSdk.common.logLevel.Info"]}]\t${value}`); + } + + public appendErrorLine(value: string): void { + Boolean(!this.isTerminated) || + this.appendLine(`[${extensionI18n["PQSdk.common.logLevel.Error"]}]\t${value}`, "stderr"); + } } diff --git a/src/features/PowerQueryTaskProvider.ts b/src/features/PowerQueryTaskProvider.ts index 6ffe4a18..369c3a40 100644 --- a/src/features/PowerQueryTaskProvider.ts +++ b/src/features/PowerQueryTaskProvider.ts @@ -14,6 +14,7 @@ import { ExtensionConstants } from "../constants/PowerQuerySdkExtension"; import { extensionI18n } from "../i18n/extension"; import { getFirstWorkspaceFolder } from "../utils/vscodes"; import { PowerQueryTaskDefinition } from "../common/PowerQueryTask"; +import { PqSdkTaskTerminal } from "./PqSdkTaskTerminal"; const enum TaskLabelPrefix { Build = "build", @@ -134,7 +135,9 @@ export class PowerQueryTaskProvider implements vscode.TaskProvider { }); } - constructor(protected readonly pqTestService: IPQTestService) {} + constructor(protected readonly pqTestService: IPQTestService) { + // noop + } public provideTasks(_token: vscode.CancellationToken): vscode.ProviderResult { const result: vscode.Task[] = []; @@ -146,11 +149,19 @@ export class PowerQueryTaskProvider implements vscode.TaskProvider { result.push(PowerQueryTaskProvider.buildMsbuildTask()); result.push(PowerQueryTaskProvider.buildMakePQXCompileTask(this.pqTestService.pqTestLocation)); - pqTestOperations.forEach((taskDef: PowerQueryTaskDefinition) => { - result.push( - PowerQueryTaskProvider.getTaskForPQTestTaskDefinition(taskDef, this.pqTestService.pqTestFullPath), - ); - }); + const useServiceHost: boolean = ExtensionConfigurations.featureUseServiceHost; + + if (useServiceHost) { + pqTestOperations.forEach((taskDef: PowerQueryTaskDefinition) => { + result.push(PqSdkTaskTerminal.getTaskForPQTestTaskDefinition(taskDef)); + }); + } else { + pqTestOperations.forEach((taskDef: PowerQueryTaskDefinition) => { + result.push( + PowerQueryTaskProvider.getTaskForPQTestTaskDefinition(taskDef, this.pqTestService.pqTestFullPath), + ); + }); + } return result; } @@ -196,7 +207,11 @@ export class PowerQueryTaskProvider implements vscode.TaskProvider { return undefined; } - if (pqtestExe && !token.isCancellationRequested) { + const useServiceHost: boolean = ExtensionConfigurations.featureUseServiceHost; + + if (useServiceHost) { + return PqSdkTaskTerminal.getTaskForPQTestTaskDefinition(taskDef); + } else if (pqtestExe && !token.isCancellationRequested) { return PowerQueryTaskProvider.getTaskForPQTestTaskDefinition(taskDef, pqtestExe); } diff --git a/src/features/PqSdkOutputChannel.ts b/src/features/PqSdkOutputChannel.ts index 2c43243e..19cd67d9 100644 --- a/src/features/PqSdkOutputChannel.ts +++ b/src/features/PqSdkOutputChannel.ts @@ -73,3 +73,5 @@ export class PqSdkOutputChannel implements OutputChannel, IDisposable { this._channel.show(...(args as Parameters)); } } + +export type PqSdkOutputChannelLight = Pick; diff --git a/src/features/PqSdkTaskTerminal.ts b/src/features/PqSdkTaskTerminal.ts new file mode 100644 index 00000000..b2131232 --- /dev/null +++ b/src/features/PqSdkTaskTerminal.ts @@ -0,0 +1,183 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as os from "os"; +import * as vscode from "vscode"; + +import { DISCONNECTED, PqServiceHostClientLite, READY } from "../pqTestConnector/PqServiceHostClientLite"; +import { extensionI18n, resolveI18nTemplate } from "../i18n/extension"; +import { ExtensionInfo, GenericResult } from "../common/PQTestService"; +import { fromEvents } from "../common/promises/fromEvents"; +import { PowerQueryTaskDefinition } from "../common/PowerQueryTask"; +import { resolveSubstitutedValues } from "../utils/vscodes"; +import { stringifyJson } from "../utils/strings"; + +export class PqSdkTaskTerminal implements vscode.Pseudoterminal { + public static LineFeed: string = os.platform() === "win32" ? "\r\n" : "\n"; + public static getTaskForPQTestTaskDefinition(taskDefinition: PowerQueryTaskDefinition): vscode.Task { + return new vscode.Task( + taskDefinition, + vscode.TaskScope.Workspace, + taskDefinition.label ?? taskDefinition.operation, + taskDefinition.type, + new vscode.CustomExecution(() => Promise.resolve(new PqSdkTaskTerminal(taskDefinition))), + ); + } + + private readonly pqServiceHostClientLite: PqServiceHostClientLite; + private writeEmitter: vscode.EventEmitter = new vscode.EventEmitter(); + onDidWrite: vscode.Event = this.writeEmitter.event; + private readonly closeEmitter: vscode.EventEmitter = new vscode.EventEmitter(); + onDidClose?: vscode.Event = this.closeEmitter.event; + + constructor(private readonly taskDefinition: PowerQueryTaskDefinition) { + this.pqServiceHostClientLite = new PqServiceHostClientLite(this); + } + + close(): void { + // noop + this.pqServiceHostClientLite.dispose(); + } + + async open(_initialDimensions: vscode.TerminalDimensions | undefined): Promise { + // activate pqServiceHostClientLite and make it connect to pqServiceHost + this.pqServiceHostClientLite.onPowerQueryTestLocationChanged(); + + try { + // wait for the pqServiceHostClientLite socket got ready + await fromEvents(this.pqServiceHostClientLite, [READY], [DISCONNECTED]); + + switch (this.taskDefinition.operation) { + case "list-credential": { + const result: unknown[] = await this.pqServiceHostClientLite.ListCredentials(); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.list.credentials.result", { + result: stringifyJson(result), + }), + ); + + break; + } + + case "delete-credential": { + const deleteCredentialResult: GenericResult = await this.pqServiceHostClientLite.DeleteCredential(); + + this.appendInfoLine(deleteCredentialResult.Message); + break; + } + + case "info": { + const displayExtensionInfoResult: ExtensionInfo[] = + await this.pqServiceHostClientLite.DisplayExtensionInfo(); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.display.extension.info.result", { + result: displayExtensionInfoResult + .map((info: ExtensionInfo) => info.Name ?? "") + .filter(Boolean) + .join(","), + }), + ); + + break; + } + + case "set-credential": { + if (this.taskDefinition.credentialTemplate) { + const setCredentialGenericResult: GenericResult = + await this.pqServiceHostClientLite.SetCredential( + JSON.stringify(this.taskDefinition.credentialTemplate), + ); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.set.credentials.result", { + result: stringifyJson(setCredentialGenericResult), + }), + ); + } else { + this.appendErrorLine( + extensionI18n["PQSdk.lifecycle.command.set.credentials.template.missing.errorMessage"], + ); + } + + break; + } + + case "refresh-credential": { + const refreshCredentialResult: GenericResult = + await this.pqServiceHostClientLite.RefreshCredential(); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.refresh.credentials.result", { + result: stringifyJson(refreshCredentialResult), + }), + ); + + break; + } + + case "run-test": { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any = await this.pqServiceHostClientLite.RunTestBatteryFromContent( + resolveSubstitutedValues(this.taskDefinition.pathToQueryFile), + ); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.run.test.result", { + result: stringifyJson(result), + }), + ); + + break; + } + + case "test-connection": { + const testConnectionResult: GenericResult = await this.pqServiceHostClientLite.TestConnection(); + + this.appendInfoLine( + resolveI18nTemplate("PQSdk.lifecycle.command.test.connection.result", { + result: stringifyJson(testConnectionResult), + }), + ); + + break; + } + + default: + break; + } + + this.closeEmitter.fire(0); + } catch (e) { + // / noop + if (e instanceof Error || typeof e === "string") { + this.appendErrorLine(e.toString()); + } + + this.closeEmitter.fire(-1); + this.pqServiceHostClientLite.dispose(); + } + } + + appendLine(value: string): void { + this.writeEmitter.fire(value + PqSdkTaskTerminal.LineFeed); + } + + public appendLineWithTimeStamp(line: string): void { + const now: Date = new Date(); + this.appendLine(`[${now.toLocaleTimeString()}]\t${line}`); + } + + public appendInfoLine(value: string): void { + this.appendLineWithTimeStamp(`[${extensionI18n["PQSdk.common.logLevel.Info"]}]\t${value}`); + } + + public appendErrorLine(value: string): void { + this.appendLineWithTimeStamp(`[${extensionI18n["PQSdk.common.logLevel.Error"]}]\t${value}`); + } +} diff --git a/src/i18n/extension.json b/src/i18n/extension.json index bfc2a2f2..57d16f4b 100644 --- a/src/i18n/extension.json +++ b/src/i18n/extension.json @@ -42,6 +42,7 @@ "PQSdk.lifecycle.command.choose.auth": "Choose an authentication method", "PQSdk.lifecycle.command.choose.authKind.prompt": "Authentication key value", "PQSdk.lifecycle.command.set.credentials.result": "SetCredential {result} ", + "PQSdk.lifecycle.command.set.credentials.template.missing.errorMessage": "Failed to set credentials due to credential template object missing.", "PQSdk.lifecycle.command.set.credentials.errorMessage": "Failed to set credentials due to ${errorMessage}", "PQSdk.lifecycle.command.set.credentials.info": "New {authenticationKind} credential has been generated successfully", "PQSdk.lifecycle.command.createAuthState.result": "CreateAuthState {result} ", diff --git a/src/pqTestConnector/PqServiceHostClient.ts b/src/pqTestConnector/PqServiceHostClient.ts index 091f9a50..df6b02c5 100644 --- a/src/pqTestConnector/PqServiceHostClient.ts +++ b/src/pqTestConnector/PqServiceHostClient.ts @@ -5,168 +5,36 @@ * LICENSE file in the root of this projects source tree. */ -import * as fs from "fs"; -import * as net from "net"; -import * as path from "path"; import * as vscode from "vscode"; -import { - Message, - RequestMessage, - ResponseError, - ResponseMessage, - SocketMessageReader, - SocketMessageWriter, -} from "vscode-jsonrpc/node"; - -import { ChildProcess } from "child_process"; -import { TextEditor } from "vscode"; - -import { CreateAuthState, Credential, ExtensionInfo, GenericResult, IPQTestService } from "../common/PQTestService"; -import { delay, isPortBusy, pidIsRunning } from "../utils/pids"; -import { getFirstWorkspaceFolder, resolveSubstitutedValues } from "../utils/vscodes"; - +import { Credential, ExtensionInfo, IPQTestService } from "../common/PQTestService"; import { GlobalEventBus, GlobalEvents } from "../GlobalEventBus"; -import { convertStringToInteger } from "../utils/numbers"; -import { executeBuildTaskAndAwaitIfNeeded } from "./PqTestTaskUtils"; -import { ExtensionConfigurations } from "../constants/PowerQuerySdkConfiguration"; +import { + PqServiceHostClientLite, + PqServiceHostRequestParamBase, + PqServiceHostResponseResult, +} from "./PqServiceHostClientLite"; import { IDisposable } from "../common/Disposable"; import { PqSdkOutputChannel } from "../features/PqSdkOutputChannel"; -import { SpawnedProcess } from "../common/SpawnedProcess"; import { ValueEventEmitter } from "../common/ValueEventEmitter"; -interface ServerTransportTuple { - readonly status: { - port: number; - live: boolean; - }; - readonly socket: net.Socket; - readonly reader: SocketMessageReader; - readonly writer: SocketMessageWriter; -} - -interface PqServiceHostRequestParamBase { - SessionId: string; - PathToConnector?: string; - PathToQueryFile?: string; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; -} - -interface PqServiceHostRequest - extends RequestMessage { - /** - * The request id. - */ - id: string; - /** - * The method to be invoked. - */ - method: string; - /** - * The method's params. - */ - params: [T]; -} - -enum ResponseStatus { - Null = 0, - Acknowledged = 1, - Success = 2, - Failure = 3, -} - -interface PqServiceHostResponseBase extends ResponseMessage { - id: string; - result: { - SessionId: string; - Status: ResponseStatus; - Payload: T; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - InnerException?: any; - }; -} - -/** - * Internal interface within the module, we need not cast members as readonly - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -interface PqServiceHostTask { - request: PqServiceHostRequest; - options: { - shouldParsePayload?: boolean; - }; - resolve: (res: Res) => void; - reject: (reason: Error | string) => void; -} +export * from "./PqServiceHostClientLite"; -const JSON_RPC_VERSION: string = "2.0"; - -export class PqServiceHostServerNotReady extends Error { - constructor() { - super("Cannot connect to the pqServiceHost"); - } -} - -export class PqInternalError extends Error { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(message: string, public readonly data: any) { - super(message); - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getInternalErrorMessage(innerError: any): string { - if (typeof innerError === "string") { - return innerError; - } else if (typeof innerError === "object") { - if (typeof innerError["Message"] === "string") { - return innerError["Message"]; - } else if (typeof innerError["Details"] === "string") { - return innerError["Details"]; - } else if (typeof innerError["message"] === "string") { - return innerError["message"]; - } else if (typeof innerError["details"] === "string") { - return innerError["details"]; - } - } - - return JSON.stringify(innerError); -} - -export class PqServiceHostClient implements IPQTestService, IDisposable { - public static readonly ExecutableName: string = "PQServiceHost.exe"; - public static readonly ExecutablePidLockFileName: string = "PQServiceHost.pid"; - public static readonly ExecutablePortLockFileName: string = "PQServiceHost.port"; - - pqTestReady: boolean = false; - pqTestLocation: string = ""; - pqTestFullPath: string = ""; - - private firstTimeStarted: boolean = true; - private lastPqRelatedFileTouchedDate: Date = new Date(0); - private _sequenceSeed: number = Date.now(); - private readonly sessionId: string = vscode.env.sessionId; - private pendingTaskMap: Map = new Map(); - private serverTransportTuple: ServerTransportTuple | undefined = undefined; +export class PqServiceHostClient extends PqServiceHostClientLite implements IPQTestService, IDisposable { private pingTimer: NodeJS.Timer | undefined = undefined; - protected _disposables: Array = []; - public get pqServiceHostConnected(): boolean { + public override get pqServiceHostConnected(): boolean { return Boolean(this.pingTimer); } - private get nextSequenceId(): string { - return `${this.sessionId}-${this._sequenceSeed++}`; - } - public readonly currentExtensionInfos: ValueEventEmitter = new ValueEventEmitter( [], ); public readonly currentCredentials: ValueEventEmitter = new ValueEventEmitter([]); - constructor(private readonly globalEventBus: GlobalEventBus, private readonly outputChannel: PqSdkOutputChannel) { + constructor(private readonly globalEventBus: GlobalEventBus, outputChannel: PqSdkOutputChannel) { + super(outputChannel); + // watch vsc ConfigDidChangePowerQuerySDK changes this._disposables.unshift( this.globalEventBus.subscribeOneEvent( @@ -201,137 +69,50 @@ export class PqServiceHostClient implements IPQTestService, IDisposable { }); } - private handleRpcMessage(message: Message): void { - if (message.jsonrpc === JSON_RPC_VERSION && this.pendingTaskMap.has(`${(message as ResponseMessage).id}`)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const responseMessage: PqServiceHostResponseBase = message as PqServiceHostResponseBase; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const maybePendingTask: PqServiceHostTask | undefined = this.pendingTaskMap.get(responseMessage.id); - - if (maybePendingTask) { - // no need to check the session within the result - if (responseMessage.error) { - maybePendingTask.reject( - new ResponseError( - responseMessage.error.code, - responseMessage.error.message, - responseMessage.error.data, - ), - ); - } else if (responseMessage.result.Status === ResponseStatus.Success) { - if ( - maybePendingTask.options.shouldParsePayload && - typeof responseMessage.result.Payload === "string" - ) { - try { - let theStr: string = responseMessage.result.Payload; - - theStr = theStr - .replace(/\\n/g, "\\n") - .replace(/\\'/g, "\\'") - .replace(/\\"/g, '\\"') - .replace(/\\&/g, "\\&") - .replace(/\\r/g, "\\r") - .replace(/\\t/g, "\\t") - .replace(/\\b/g, "\\b") - .replace(/\\f/g, "\\f") - - // eslint-disable-next-line no-control-regex - .replace(/[\u0000-\u0019]+/g, ""); - - responseMessage.result.Payload = JSON.parse(theStr); - } catch (e) { - // noop - } - } - - // we need not infer general error string in serviceHost mode - // as it would be handled by the InnerException - - // todo, mv this logic to pqServiceHost - if (maybePendingTask.request.method === "v1/PqTestService/DisplayExtensionInfo") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.currentExtensionInfos.emit(responseMessage.result.Payload as any); - } else if (maybePendingTask.request.method === "v1/PqTestService/ListCredentials") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.currentCredentials.emit(responseMessage.result.Payload as any); - } - - maybePendingTask.resolve(responseMessage.result.Payload); - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const errorData: any = responseMessage.result.InnerException ?? responseMessage.result.Payload; - - const errorMessage: string = getInternalErrorMessage(errorData); - maybePendingTask.reject(new PqInternalError(errorMessage, errorData)); - } - - this.pendingTaskMap.delete(responseMessage.id); - } - } + protected override onConnected(): void { + this.startToSendPingMessages(); } - private createServerSocketTransport(port: number): void { - this.outputChannel.appendInfoLine(`Start to listen PqServiceHost.exe at ${port}`); - const socket: net.Socket = net.createConnection(port, "127.0.0.1"); - socket.setTimeout(0); - socket.setKeepAlive(true); - const reader: SocketMessageReader = new SocketMessageReader(socket, "utf-8"); - const writer: SocketMessageWriter = new SocketMessageWriter(socket, "utf-8"); - - const theServerTransportTuple: ServerTransportTuple = Object.freeze({ - status: { - port, - live: true, - }, - socket, - reader, - writer, - }); - - socket.on("connect", () => { - this.outputChannel.appendInfoLine(`Succeed listening PqServiceHost.exe at ${port}`); - - // check whether it were the first time staring for the current maybe existing workspace - if (this.firstTimeStarted) { - // and we also need to ensure we got a valid pq connector mez file - const currentPQTestExtensionFileLocation: string | undefined = - ExtensionConfigurations.DefaultExtensionLocation; - - const resolvedPQTestExtensionFileLocation: string | undefined = currentPQTestExtensionFileLocation - ? resolveSubstitutedValues(currentPQTestExtensionFileLocation) - : undefined; - - if (resolvedPQTestExtensionFileLocation && fs.existsSync(resolvedPQTestExtensionFileLocation)) { - // trigger one display extension info task to populate modules in the pq-lang ext - void this.DisplayExtensionInfo(); - } - - this.firstTimeStarted = false; - } - - this.startToSendPingMessages(); - }); - - socket.on("error", (err: Error) => { - this.outputChannel.appendErrorLine( - `Failed to listen PqServiceHost.exe at ${port} due to ${err.message}, will try to reconnect in 2 sec`, - ); + protected override onDisconnecting(): void { + this.stopSendingPingMessages(); + } + protected override onReconnecting(): void { + // we have already been listening to a service host + if (this.pingTimer) { + // there would only one single host expected running per machine + // thus, we need to shut the existing one down first + void this.ForceShutdown(); + // and clear the ping interval handler this.stopSendingPingMessages(); + } + } - setTimeout(() => { - this.onPowerQueryTestLocationChanged(); - }, 250); - }); + public override async requestRemoteRpcMethod< + P extends PqServiceHostRequestParamBase = PqServiceHostRequestParamBase, + >( + method: string, + parameters: P[], + options: { + shouldParsePayload?: boolean; + } = {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Promise { + const responseResultPayload: PqServiceHostResponseResult["Payload"] = await super.requestRemoteRpcMethod( + method, + parameters, + options, + ); - reader.listen((data: Message) => { - if (theServerTransportTuple.status.live) { - this.handleRpcMessage.call(this, data); - } - }); + if (method === "v1/PqTestService/DisplayExtensionInfo") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.currentExtensionInfos.emit(responseResultPayload as any); + } else if (method === "v1/PqTestService/ListCredentials") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.currentCredentials.emit(responseResultPayload as any); + } - this.serverTransportTuple = theServerTransportTuple; + return responseResultPayload; } private startToSendPingMessages(): void { @@ -347,512 +128,25 @@ export class PqServiceHostClient implements IPQTestService, IDisposable { } } - private resolvePQServiceHostPath(nextPQTestLocation: string | undefined): string | undefined { - if (!nextPQTestLocation) { - this.outputChannel.appendErrorLine("powerquery.sdk.tools.location configuration value is not set."); - - return undefined; - } else if (!fs.existsSync(nextPQTestLocation)) { - this.outputChannel.appendErrorLine( - `powerquery.sdk.tools.location set to '${nextPQTestLocation}' but directory does not exist.`, - ); - - return undefined; - } - - const pqServiceHostExe: string = path.resolve(nextPQTestLocation, PqServiceHostClient.ExecutableName); - - if (!fs.existsSync(pqServiceHostExe)) { - this.outputChannel.appendErrorLine(`PqServiceHost.exe not found at ${pqServiceHostExe}`); - - return undefined; - } - - return pqServiceHostExe; - } - - private disposeCurrentServerTransportTuple(): void { - if (this.serverTransportTuple) { - this.outputChannel.appendInfoLine( - `Stop listening PqServiceHost.exe at ${this.serverTransportTuple.status.port}`, - ); - - this.serverTransportTuple.status.live = false; - this.serverTransportTuple.socket.emit("close"); - this.serverTransportTuple = undefined; - } - } - - private doSeizeNumberFromLockFile(theLockFileFullPath: string): number | undefined { - if (!fs.existsSync(theLockFileFullPath)) { - return undefined; - } - - const pidString: string = fs.readFileSync(theLockFileFullPath).toString("utf8"); - - return convertStringToInteger(pidString); - } - - private doStartAndListenPqServiceHostIfNeededInProgress: boolean = false; - private async doStartAndListenPqServiceHostIfNeeded( - nextPQTestLocation: string, - tryNumber: number = 0, - ): Promise { - if (this.doStartAndListenPqServiceHostIfNeededInProgress || tryNumber > 4) return; - - try { - this.doStartAndListenPqServiceHostIfNeededInProgress = true; - - const pidFileFullPath: string = path.resolve( - nextPQTestLocation, - PqServiceHostClient.ExecutablePidLockFileName, - ); - - const portFileFullPath: string = path.resolve( - nextPQTestLocation, - PqServiceHostClient.ExecutablePortLockFileName, - ); - - let pidNumber: number | undefined = this.doSeizeNumberFromLockFile(pidFileFullPath); - - // check if we need to start the pqServiceHost for the first time - if (typeof pidNumber !== "number" || !pidIsRunning(pidNumber.valueOf())) { - // pause a little while to enlarge the chances that other service hosts fully shutdown - await delay(250); - - new SpawnedProcess( - path.resolve(nextPQTestLocation, PqServiceHostClient.ExecutableName), - [], - { cwd: this.pqTestLocation, detached: true }, - { - onSpawned: (childProcess: ChildProcess): void => { - if (Number.isInteger(childProcess.pid)) { - pidNumber = childProcess.pid; - } - }, - }, - ); - - this.outputChannel.appendInfoLine(`#${tryNumber + 1} try to boot PqServiceHost.exe`); - } - - if (!Number.isInteger(pidNumber)) { - // pause for effects - await delay(500); - // eslint-disable-next-line require-atomic-updates - pidNumber = this.doSeizeNumberFromLockFile(pidFileFullPath); - } - - let portNumber: number | undefined = undefined; - let portInUse: boolean = false; - let maxTry: number = 4; - - while (maxTry > 0 && !portInUse) { - // eslint-disable-next-line no-await-in-loop - await delay(895); - portNumber = this.doSeizeNumberFromLockFile(portFileFullPath); - - if (typeof portNumber === "number") { - // eslint-disable-next-line no-await-in-loop - portInUse = await isPortBusy(portNumber); - } - - this.outputChannel.appendInfoLine( - `Check #[${5 - maxTry}] whether PqServiceHost.exe exported at ${portNumber}, ${portInUse}`, - ); - - maxTry--; - } - - if (typeof pidNumber === "number" && typeof portNumber === "number") { - this.disposeCurrentServerTransportTuple(); - this.createServerSocketTransport(portNumber); - } - } finally { - this.doStartAndListenPqServiceHostIfNeededInProgress = false; - } - - setTimeout(() => { - if (!this.pqServiceHostConnected) { - void this.doStartAndListenPqServiceHostIfNeeded(nextPQTestLocation, tryNumber + 1); - } - }, 750); - } - - onPowerQueryTestLocationChanged(): void { - // PQTestLocation getter - const nextPQTestLocation: string | undefined = ExtensionConfigurations.PQTestLocation; - const pqServiceHostExe: string | undefined = this.resolvePQServiceHostPath(nextPQTestLocation); - - if (!pqServiceHostExe || !nextPQTestLocation) { - this.pqTestReady = false; - this.pqTestLocation = ""; - this.pqTestFullPath = ""; - } else { - this.pqTestReady = true; - this.pqTestLocation = nextPQTestLocation; - this.pqTestFullPath = pqServiceHostExe; - this.outputChannel.appendInfoLine(`PqServiceHost.exe found at ${this.pqTestFullPath}`); - - // we were already listening to a service host - if (this.pingTimer) { - // thus we need to shut it down - void this.ForceShutdown(); - // and clear the pinger interval - this.stopSendingPingMessages(); - } - - void this.doStartAndListenPqServiceHostIfNeeded(nextPQTestLocation); - } - } - - dispose(): void { - for (const oneDisposable of this._disposables) { - oneDisposable.dispose(); - } - - this.stopSendingPingMessages(); + public override dispose(): void { this.currentExtensionInfos.dispose(); this.currentCredentials.dispose(); - this.disposeCurrentServerTransportTuple(); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private enlistOnePqServiceHostTask( - theRequestMessage: PqServiceHostRequest, - options: PqServiceHostTask["options"] = {}, - ): Promise { - const theTask: PqServiceHostTask = { request: theRequestMessage, options } as PqServiceHostTask; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: Promise = new Promise((resolve: (value: T) => void, reject: (reason?: any) => void) => { - theTask.resolve = resolve; - theTask.reject = reject; - }); - - this.pendingTaskMap.set(theRequestMessage.id, theTask); - - // no need to await it - void this.serverTransportTuple?.writer.write(theRequestMessage); - - return result; - } - - ExecuteBuildTaskAndAwaitIfNeeded(): Promise { - return executeBuildTaskAndAwaitIfNeeded( - this.pqTestLocation, - this.lastPqRelatedFileTouchedDate, - (nextLastPqRelatedFileTouchedDate: Date) => { - this.lastPqRelatedFileTouchedDate = nextLastPqRelatedFileTouchedDate; - }, - ); - } - - DeleteCredential(): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/DeleteCredential", - params: [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - AllCredentials: true, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - DisplayExtensionInfo(): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/DisplayExtensionInfo", - params: [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage, { shouldParsePayload: true }); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - GenerateCredentialTemplate(): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/GenerateCredentialTemplate", - params: [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - }, - ], - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.enlistOnePqServiceHostTask(theRequestMessage, { shouldParsePayload: true }); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - ListCredentials(): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/ListCredentials", - params: [ - { - SessionId: this.sessionId, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - RefreshCredential(): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/RefreshCredential", - params: [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - RunTestBattery(pathToQueryFile: string | undefined): Promise { - if (this.serverTransportTuple) { - const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; - - const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( - ExtensionConfigurations.DefaultQueryFileLocation, - ); - - // todo, maybe we could export this lang id to from the lang svc extension - if (!pathToQueryFile && activeTextEditor?.document.languageId === "powerquery") { - pathToQueryFile = activeTextEditor.document.uri.fsPath; - } - - if (!pathToQueryFile && configPQTestQueryFileLocation) { - pathToQueryFile = configPQTestQueryFileLocation; - } - - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/RunTestBattery", - params: [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: pathToQueryFile, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async RunTestBatteryFromContent(pathToQueryFile: string | undefined): Promise { - if (this.serverTransportTuple) { - const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; - - const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( - ExtensionConfigurations.DefaultQueryFileLocation, - ); - - // todo, maybe we could export this lang id to from the lang svc extension - if (!pathToQueryFile && activeTextEditor?.document.languageId === "powerquery") { - pathToQueryFile = activeTextEditor.document.uri.fsPath; - } - - if (!pathToQueryFile && configPQTestQueryFileLocation) { - pathToQueryFile = configPQTestQueryFileLocation; - } - - if (!pathToQueryFile || !fs.existsSync(pathToQueryFile)) return Promise.resolve(); - - let currentContent: string = fs.readFileSync(pathToQueryFile, { encoding: "utf8" }); - - vscode.window.visibleTextEditors.forEach((oneEditor: vscode.TextEditor) => { - if ( - oneEditor?.document.languageId === "powerquery" && - oneEditor.document.uri.fsPath === pathToQueryFile - ) { - currentContent = oneEditor.document.getText(); - } - }); - - // maybe we need to execute the build task before evaluating. - await this.ExecuteBuildTaskAndAwaitIfNeeded(); - - // only for RunTestBatteryFromContent, - // PathToConnector would be full path of the current working folder - // PathToQueryFile would be either the saved or unsaved content of the query file to be evaluated - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/RunTestBatteryFromContent", - params: [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: currentContent, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - SetCredential(payloadStr: string): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/RefreshCredential", - params: [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - InputTemplateString: payloadStr, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - SetCredentialFromCreateAuthState(createAuthState: CreateAuthState): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/SetCredentialFromCreateAuthState", - params: [ - { - SessionId: this.sessionId, - PathToConnector: createAuthState.PathToConnectorFile || getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: resolveSubstitutedValues(createAuthState.PathToQueryFile), - // DataSourceKind: createAuthState.DataSourceKind, - // TODO: We might need to remove this arg when the service host command matches pqtest - AuthenticationKind: createAuthState.AuthenticationKind, - TemplateValueKey: createAuthState.$$KEY$$, - TemplateValueUsername: createAuthState.$$USERNAME$$, - TemplateValuePassword: createAuthState.$$PASSWORD$$, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } - } - - async TestConnection(): Promise { - if (this.serverTransportTuple) { - // maybe we need to execute the build task before evaluating. - await this.ExecuteBuildTaskAndAwaitIfNeeded(); - - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/PqTestService/TestConnection", - params: [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } + super.dispose(); } ForceShutdown(): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/HealthService/Shutdown", - params: [ - { - SessionId: this.sessionId, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } + return this.requestRemoteRpcMethod("v1/HealthService/Shutdown", [ + { + SessionId: this.sessionId, + }, + ]); } Ping(): Promise { - if (this.serverTransportTuple) { - const theRequestMessage: PqServiceHostRequest = { - jsonrpc: JSON_RPC_VERSION, - id: this.nextSequenceId, - method: "v1/HealthService/Ping", - params: [ - { - SessionId: this.sessionId, - }, - ], - }; - - return this.enlistOnePqServiceHostTask(theRequestMessage); - } else { - throw new PqServiceHostServerNotReady(); - } + return this.requestRemoteRpcMethod("v1/HealthService/Ping", [ + { + SessionId: this.sessionId, + }, + ]); } } diff --git a/src/pqTestConnector/PqServiceHostClientLite.ts b/src/pqTestConnector/PqServiceHostClientLite.ts new file mode 100644 index 00000000..6beb9a2a --- /dev/null +++ b/src/pqTestConnector/PqServiceHostClientLite.ts @@ -0,0 +1,599 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; + +import { ChildProcess } from "child_process"; +import { EventEmitter } from "events"; +import { TextEditor } from "vscode"; + +import { CLOSED, ERROR, OPEN } from "../common/sockets/SocketClient"; +import { CreateAuthState, Credential, ExtensionInfo, GenericResult, IPQTestService } from "../common/PQTestService"; +import { defaultBackOff, JsonRpcSocketClient } from "../common/sockets/JsonRpcSocketClient"; +import { delay, isPortBusy, pidIsRunning } from "../utils/pids"; +import { getFirstWorkspaceFolder, resolveSubstitutedValues } from "../utils/vscodes"; +import { AnyFunction } from "../common/promises/types"; +import { convertStringToInteger } from "../utils/numbers"; +import { executeBuildTaskAndAwaitIfNeeded } from "./PqTestTaskUtils"; +import { ExtensionConfigurations } from "../constants/PowerQuerySdkConfiguration"; +import { IDisposable } from "../common/Disposable"; +import { PqSdkOutputChannelLight } from "../features/PqSdkOutputChannel"; +import { SpawnedProcess } from "../common/SpawnedProcess"; + +export interface PqServiceHostRequestParamBase { + SessionId: string; + PathToConnector?: string; + PathToQueryFile?: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export enum ResponseStatus { + Null = 0, + Acknowledged = 1, + Success = 2, + Failure = 3, +} + +export interface PqServiceHostResponseResult { + SessionId: string; + Status: ResponseStatus; + Payload: T; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + InnerException?: any; +} + +export class PqServiceHostServerNotReady extends Error { + constructor() { + super("Cannot connect to the pqServiceHost"); + } +} + +export class PqInternalError extends Error { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(message: string, public readonly data: any) { + super(message); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getInternalErrorMessage(innerError: any): string { + if (typeof innerError === "string") { + return innerError; + } else if (typeof innerError === "object") { + if (typeof innerError["Message"] === "string") { + return innerError["Message"]; + } else if (typeof innerError["Details"] === "string") { + return innerError["Details"]; + } else if (typeof innerError["message"] === "string") { + return innerError["message"]; + } else if (typeof innerError["details"] === "string") { + return innerError["details"]; + } + } + + return JSON.stringify(innerError); +} + +type OmittedPqTestMethods = "currentExtensionInfos" | "currentCredentials" | "ForceShutdown" | "Ping"; + +// eslint-disable-next-line @typescript-eslint/typedef +export const INIT = "PqServiceHostClientEvent_Init" as const; +// eslint-disable-next-line @typescript-eslint/typedef +export const RETRYING = "PqServiceHostClientEvent_Retrying" as const; +// eslint-disable-next-line @typescript-eslint/typedef +export const DISCONNECTED = "PqServiceHostClientEvent_Disconnected" as const; +// eslint-disable-next-line @typescript-eslint/typedef +export const READY = "PqServiceHostClientEvent_Ready" as const; +// eslint-disable-next-line @typescript-eslint/typedef +export const DISPOSED = "PqServiceHostClientEvent_Disposed" as const; + +export class PqServiceHostClientLite + extends EventEmitter + implements Omit, IDisposable +{ + public static readonly ExecutableName: string = "PQServiceHost.exe"; + public static readonly ExecutablePidLockFileName: string = "PQServiceHost.pid"; + public static readonly ExecutablePortLockFileName: string = "PQServiceHost.port"; + + pqTestReady: boolean = false; + pqTestLocation: string = ""; + pqTestFullPath: string = ""; + + private firstTimeStarted: boolean = true; + protected readonly sessionId: string = vscode.env.sessionId; + protected jsonRpcSocketClient: JsonRpcSocketClient | undefined = undefined; + // private pingTimer: NodeJS.Timer | undefined = undefined; + protected lastPqRelatedFileTouchedDate: Date = new Date(0); + protected _disposables: Array = []; + + public get pqServiceHostConnected(): boolean { + return this.jsonRpcSocketClient?.status === OPEN; + } + + constructor(protected readonly outputChannel: PqSdkOutputChannelLight) { + super(); + } + + /** + * Synchronized post-connection event + * @protected + */ + protected onConnected(): void { + // noop + } + + /** + * Synchronized pre-disconnection event + * @protected + */ + protected onDisconnecting(): void { + // noop + } + + /** + * Synchronized pre-reconnection event + * @protected + */ + protected onReconnecting(): void { + // noop + } + + public async requestRemoteRpcMethod

( + method: string, + parameters: P[], + options: { + shouldParsePayload?: boolean; + } = {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Promise { + if (this.jsonRpcSocketClient) { + const responseResult: PqServiceHostResponseResult = (await this.jsonRpcSocketClient.request( + method, + parameters, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + )) as PqServiceHostResponseResult; + + if (responseResult.Status === ResponseStatus.Success) { + if (options.shouldParsePayload && typeof responseResult.Payload === "string") { + try { + let theStr: string = responseResult.Payload; + + theStr = theStr + .replace(/\\n/g, "\\n") + .replace(/\\'/g, "\\'") + .replace(/\\"/g, '\\"') + .replace(/\\&/g, "\\&") + .replace(/\\r/g, "\\r") + .replace(/\\t/g, "\\t") + .replace(/\\b/g, "\\b") + .replace(/\\f/g, "\\f") + + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0019]+/g, ""); + + responseResult.Payload = JSON.parse(theStr); + } catch (e) { + // noop + } + } + + return responseResult.Payload; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errorData: any = responseResult.InnerException ?? responseResult.Payload; + + const errorMessage: string = getInternalErrorMessage(errorData); + + return Promise.reject(new PqInternalError(errorMessage, errorData)); + } + } else { + throw new PqServiceHostServerNotReady(); + } + } + + private async createJsonRpcSocketClient(port: number): Promise { + this.outputChannel.appendInfoLine(`Start to listen PqServiceHost.exe at ${port}`); + const theJsonRpcSocketClient: JsonRpcSocketClient = new JsonRpcSocketClient(port); + + try { + await theJsonRpcSocketClient.open(defaultBackOff); + this.emit(READY); + this.jsonRpcSocketClient = theJsonRpcSocketClient; + } catch (error: unknown) { + this.outputChannel.appendErrorLine(`Failed to listen PqServiceHost.exe at ${port} due to ${error}`); + } + + if (theJsonRpcSocketClient.status === OPEN) { + this.outputChannel.appendInfoLine(`Succeed listening PqServiceHost.exe at ${port}`); + + // check whether it were the first time staring for the current maybe existing workspace + if (this.firstTimeStarted) { + // and we also need to ensure we got a valid pq connector mez file + const currentPQTestExtensionFileLocation: string | undefined = + ExtensionConfigurations.DefaultExtensionLocation; + + const resolvedPQTestExtensionFileLocation: string | undefined = currentPQTestExtensionFileLocation + ? resolveSubstitutedValues(currentPQTestExtensionFileLocation) + : undefined; + + if (resolvedPQTestExtensionFileLocation && fs.existsSync(resolvedPQTestExtensionFileLocation)) { + // trigger one display extension info task to populate modules in the pq-lang ext + void this.DisplayExtensionInfo(); + } + + this.firstTimeStarted = false; + } + + this.onConnected(); + + const handleJsonRpcSocketError: AnyFunction = (event: Error) => { + this.outputChannel.appendErrorLine( + `Connection to PqServiceHost.exe at ${port} encounter ${event.message}`, + ); + }; + + const handleJsonRpcSocketExiting: AnyFunction = () => { + this.outputChannel.appendErrorLine( + `Failed to listen PqServiceHost.exe at ${port}, will try to reconnect in 2 sec`, + ); + + this.onDisconnecting(); + + setTimeout(() => { + this.onPowerQueryTestLocationChanged(); + }, 250); + + theJsonRpcSocketClient.off(CLOSED, handleJsonRpcSocketExiting); + theJsonRpcSocketClient.off(ERROR, handleJsonRpcSocketError); + }; + + theJsonRpcSocketClient.on(CLOSED, handleJsonRpcSocketExiting); + theJsonRpcSocketClient.on(ERROR, handleJsonRpcSocketError); + } + } + + private resolvePQServiceHostPath(nextPQTestLocation: string | undefined): string | undefined { + if (!nextPQTestLocation) { + this.outputChannel.appendErrorLine("powerquery.sdk.tools.location configuration value is not set."); + + return undefined; + } else if (!fs.existsSync(nextPQTestLocation)) { + this.outputChannel.appendErrorLine( + `powerquery.sdk.tools.location set to '${nextPQTestLocation}' but directory does not exist.`, + ); + + return undefined; + } + + const pqServiceHostExe: string = path.resolve(nextPQTestLocation, PqServiceHostClientLite.ExecutableName); + + if (!fs.existsSync(pqServiceHostExe)) { + this.outputChannel.appendErrorLine(`PqServiceHost.exe not found at ${pqServiceHostExe}`); + + return undefined; + } + + return pqServiceHostExe; + } + + private disposeCurrentJsonRpcSocketClient(): void { + if (this.jsonRpcSocketClient) { + this.onDisconnecting(); + void this.jsonRpcSocketClient.close(); + this.jsonRpcSocketClient = undefined; + this.emit(DISPOSED); + + this.outputChannel.appendInfoLine(`Stop listening PqServiceHost.exe`); + } + } + + private doSeizeNumberFromLockFile(theLockFileFullPath: string): number | undefined { + if (!fs.existsSync(theLockFileFullPath)) { + return undefined; + } + + const pidString: string = fs.readFileSync(theLockFileFullPath).toString("utf8"); + + return convertStringToInteger(pidString); + } + + private doStartAndListenPqServiceHostIfNeededInProgress: boolean = false; + private async doStartAndListenPqServiceHostIfNeeded( + nextPQTestLocation: string, + tryNumber: number = 0, + ): Promise { + if (this.doStartAndListenPqServiceHostIfNeededInProgress) return; + + if (tryNumber > 4) { + this.emit(DISCONNECTED); + + return; + } + + try { + this.doStartAndListenPqServiceHostIfNeededInProgress = true; + + const pidFileFullPath: string = path.resolve( + nextPQTestLocation, + PqServiceHostClientLite.ExecutablePidLockFileName, + ); + + const portFileFullPath: string = path.resolve( + nextPQTestLocation, + PqServiceHostClientLite.ExecutablePortLockFileName, + ); + + let pidNumber: number | undefined = this.doSeizeNumberFromLockFile(pidFileFullPath); + + // check if we need to start the pqServiceHost for the first time + if (typeof pidNumber !== "number" || !pidIsRunning(pidNumber.valueOf())) { + // pause a little while to enlarge the chances that other service hosts fully shutdown + await delay(250); + + new SpawnedProcess( + path.resolve(nextPQTestLocation, PqServiceHostClientLite.ExecutableName), + [], + { cwd: this.pqTestLocation, detached: true }, + { + onSpawned: (childProcess: ChildProcess): void => { + if (Number.isInteger(childProcess.pid)) { + pidNumber = childProcess.pid; + } + }, + }, + ); + + this.outputChannel.appendInfoLine(`#${tryNumber + 1} try to boot PqServiceHost.exe`); + } + + if (!Number.isInteger(pidNumber)) { + // pause for effects + await delay(500); + // eslint-disable-next-line require-atomic-updates + pidNumber = this.doSeizeNumberFromLockFile(pidFileFullPath); + } + + let portNumber: number | undefined = undefined; + let portInUse: boolean = false; + let maxTry: number = 4; + + while (maxTry > 0 && !portInUse) { + // eslint-disable-next-line no-await-in-loop + await delay(895); + portNumber = this.doSeizeNumberFromLockFile(portFileFullPath); + + if (typeof portNumber === "number") { + // eslint-disable-next-line no-await-in-loop + portInUse = await isPortBusy(portNumber); + } + + this.outputChannel.appendInfoLine( + `Check #[${5 - maxTry}] whether PqServiceHost.exe exported at ${portNumber}, ${portInUse}`, + ); + + maxTry--; + } + + if (typeof pidNumber === "number" && typeof portNumber === "number") { + this.disposeCurrentJsonRpcSocketClient(); + await this.createJsonRpcSocketClient(portNumber); + } + } finally { + this.doStartAndListenPqServiceHostIfNeededInProgress = false; + } + + setTimeout(() => { + if (!this.pqServiceHostConnected) { + this.emit(RETRYING); + void this.doStartAndListenPqServiceHostIfNeeded(nextPQTestLocation, tryNumber + 1); + } + }, 750); + } + + public onPowerQueryTestLocationChanged(): void { + // PQTestLocation getter + const nextPQTestLocation: string | undefined = ExtensionConfigurations.PQTestLocation; + const pqServiceHostExe: string | undefined = this.resolvePQServiceHostPath(nextPQTestLocation); + + if (!pqServiceHostExe || !nextPQTestLocation) { + this.pqTestReady = false; + this.pqTestLocation = ""; + this.pqTestFullPath = ""; + } else { + this.pqTestReady = true; + this.pqTestLocation = nextPQTestLocation; + this.pqTestFullPath = pqServiceHostExe; + this.outputChannel.appendInfoLine(`PqServiceHost.exe found at ${this.pqTestFullPath}`); + + this.onReconnecting(); + this.emit(INIT); + void this.doStartAndListenPqServiceHostIfNeeded(nextPQTestLocation); + } + } + + public dispose(): void { + for (const oneDisposable of this._disposables) { + oneDisposable.dispose(); + } + + this.disposeCurrentJsonRpcSocketClient(); + } + + ExecuteBuildTaskAndAwaitIfNeeded(): Promise { + return executeBuildTaskAndAwaitIfNeeded( + this.pqTestLocation, + this.lastPqRelatedFileTouchedDate, + (nextLastPqRelatedFileTouchedDate: Date) => { + this.lastPqRelatedFileTouchedDate = nextLastPqRelatedFileTouchedDate; + }, + ); + } + + DeleteCredential(): Promise { + return this.requestRemoteRpcMethod("v1/PqTestService/DeleteCredential", [ + { + SessionId: this.sessionId, + PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + AllCredentials: true, + }, + ]); + } + + DisplayExtensionInfo(): Promise { + return this.requestRemoteRpcMethod( + "v1/PqTestService/DisplayExtensionInfo", + [ + { + SessionId: this.sessionId, + PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + }, + ], + { shouldParsePayload: true }, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + GenerateCredentialTemplate(): Promise { + return this.requestRemoteRpcMethod( + "v1/PqTestService/GenerateCredentialTemplate", + [ + { + SessionId: this.sessionId, + PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }, + ], + { shouldParsePayload: true }, + ); + } + + ListCredentials(): Promise { + return this.requestRemoteRpcMethod("v1/PqTestService/ListCredentials", [ + { + SessionId: this.sessionId, + }, + ]); + } + + RefreshCredential(): Promise { + return this.requestRemoteRpcMethod("v1/PqTestService/RefreshCredential", [ + { + SessionId: this.sessionId, + PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }, + ]); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + RunTestBattery(pathToQueryFile: string | undefined): Promise { + const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; + + const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( + ExtensionConfigurations.DefaultQueryFileLocation, + ); + + // todo, maybe we could export this lang id to from the lang svc extension + if (!pathToQueryFile && activeTextEditor?.document.languageId === "powerquery") { + pathToQueryFile = activeTextEditor.document.uri.fsPath; + } + + if (!pathToQueryFile && configPQTestQueryFileLocation) { + pathToQueryFile = configPQTestQueryFileLocation; + } + + return this.requestRemoteRpcMethod("v1/PqTestService/RunTestBattery", [ + { + SessionId: this.sessionId, + PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + PathToQueryFile: pathToQueryFile, + }, + ]); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async RunTestBatteryFromContent(pathToQueryFile: string | undefined): Promise { + const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; + + const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( + ExtensionConfigurations.DefaultQueryFileLocation, + ); + + // todo, maybe we could export this lang id to from the lang svc extension + if (!pathToQueryFile && activeTextEditor?.document.languageId === "powerquery") { + pathToQueryFile = activeTextEditor.document.uri.fsPath; + } + + if (!pathToQueryFile && configPQTestQueryFileLocation) { + pathToQueryFile = configPQTestQueryFileLocation; + } + + if (!pathToQueryFile || !fs.existsSync(pathToQueryFile)) return Promise.resolve(); + + let currentContent: string = fs.readFileSync(pathToQueryFile, { encoding: "utf8" }); + + vscode.window.visibleTextEditors.forEach((oneEditor: vscode.TextEditor) => { + if (oneEditor?.document.languageId === "powerquery" && oneEditor.document.uri.fsPath === pathToQueryFile) { + currentContent = oneEditor.document.getText(); + } + }); + + // maybe we need to execute the build task before evaluating. + await this.ExecuteBuildTaskAndAwaitIfNeeded(); + + // only for RunTestBatteryFromContent, + // PathToConnector would be full path of the current working folder + // PathToQueryFile would be either the saved or unsaved content of the query file to be evaluated + return this.requestRemoteRpcMethod("v1/PqTestService/RunTestBatteryFromContent", [ + { + SessionId: this.sessionId, + PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + PathToQueryFile: currentContent, + }, + ]); + } + + SetCredential(payloadStr: string): Promise { + return this.requestRemoteRpcMethod("v1/PqTestService/SetCredential", [ + { + SessionId: this.sessionId, + PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + InputTemplateString: payloadStr, + }, + ]); + } + + SetCredentialFromCreateAuthState(createAuthState: CreateAuthState): Promise { + return this.requestRemoteRpcMethod("v1/PqTestService/SetCredentialFromCreateAuthState", [ + { + SessionId: this.sessionId, + PathToConnector: createAuthState.PathToConnectorFile || getFirstWorkspaceFolder()?.uri.fsPath, + PathToQueryFile: resolveSubstitutedValues(createAuthState.PathToQueryFile), + // DataSourceKind: createAuthState.DataSourceKind, + AuthenticationKind: createAuthState.AuthenticationKind, + TemplateValueKey: createAuthState.$$KEY$$, + TemplateValueUsername: createAuthState.$$USERNAME$$, + TemplateValuePassword: createAuthState.$$PASSWORD$$, + }, + ]); + } + + TestConnection(): Promise { + return this.requestRemoteRpcMethod("v1/PqTestService/TestConnection", [ + { + SessionId: this.sessionId, + PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }, + ]); + } +} diff --git a/src/utils/strings.ts b/src/utils/strings.ts index aa25df98..b26aedbd 100644 --- a/src/utils/strings.ts +++ b/src/utils/strings.ts @@ -9,6 +9,11 @@ export function replaceAt(str: string, index: number, length: number, replacemen return str.substring(0, index) + replacement + str.substring(index + length); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function stringifyJson(obj: any): string { + return JSON.stringify(obj); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function prettifyJson(obj: any): string { return JSON.stringify(obj, null, 4); diff --git a/unit-tests/common/iterables.spec.ts b/unit-tests/common/iterables.spec.ts new file mode 100644 index 00000000..7085976d --- /dev/null +++ b/unit-tests/common/iterables.spec.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as chai from "chai"; + +import { NumberGenerator, NumberIterator } from "../../src/common/iterables/NumberIterator"; +import { fibonacciNumbers } from "../../src/common/iterables/FibonacciNumbers"; + +const expect = chai.expect; + +const testIterable: (iterable: NumberGenerator, values: number[]) => void = ( + iterable: () => NumberIterator, + values: number[], +) => { + let iterator: NumberIterator = iterable(); + + if (!iterable == null) { + throw new TypeError("is not iterable"); + } + + for (const value of values) { + const cursor = iterator.next(); + + if (cursor.done) { + throw new Error("unexpected end of iterable"); + } + + expect(cursor.value).eq(value); + } +}; + +describe("Promises::iterables", () => { + it("fibonacciNumbers generator", () => { + testIterable(fibonacciNumbers, [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]); + }); +}); diff --git a/unit-tests/common/promises/cancelable.spec.ts b/unit-tests/common/promises/cancelable.spec.ts new file mode 100644 index 00000000..6f1d0f7f --- /dev/null +++ b/unit-tests/common/promises/cancelable.spec.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as chai from "chai"; +import * as sinon from "sinon"; + +import { cancelable } from "../../../src/common/promises/cancelable"; +import { CancellationToken } from "../../../src/common/promises/CancellationToken"; +import { noop } from "../../../src/common/promises/noop"; + +const expect = chai.expect; + +describe("Promises::cancelable", () => { + it("do not replace the existing cancel token", () => { + const token = new CancellationToken(noop); + const spy = sinon.spy(); + cancelable(spy)(token, "yoo", "ha"); + expect(spy.calledOnceWith(token, "yoo", "ha")).true; + }); + + it("inject an existing cancel token", () => { + const token = new CancellationToken(noop); + const callerStub = sinon.stub().returns(Promise.resolve()); + const spy = sinon.spy(callerStub); + cancelable(spy)(token, "yoo", "ha"); + expect(spy.calledOnce).true; + const spiedFirstCall = spy.getCall(0); + expect(CancellationToken.isCancellationToken(spiedFirstCall.firstArg)).true; + }); +}); diff --git a/unit-tests/common/promises/cancellationToken.spec.ts b/unit-tests/common/promises/cancellationToken.spec.ts new file mode 100644 index 00000000..68e7bde9 --- /dev/null +++ b/unit-tests/common/promises/cancellationToken.spec.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as chai from "chai"; + +import { Cancel, CancellationToken, CancellationTokenSource } from "../../../src/common/promises/CancellationToken"; +import { noop } from "../../../src/common/promises/noop"; + +const expect = chai.expect; + +describe("Promises::CancellationTokenModule", () => { + describe("Cancel Class", () => { + it("property: message", () => { + const cancel = new Cancel("yoo"); + expect(cancel.message).eq("yoo"); + }); + + it("method: captureStackTrace", () => { + const cancel = new Cancel("ha"); + expect(cancel.captureStackTrace()?.length).gt(0); + }); + }); + + describe("CancellationToken Class", () => { + it("return the same token if any", () => { + expect(CancellationToken.from(CancellationToken.none)).eq(CancellationToken.none); + }); + + it("return the abortController", () => { + const controller = new AbortController(); + const token = CancellationToken.from(controller.signal); + + expect(token).instanceof(CancellationToken); + expect(token.requested).false; + + controller.abort(); + + expect(token.requested).true; + }); + + it("method: isCancelToken", () => { + expect(CancellationToken.isCancellationToken(undefined as never)).false; + expect(CancellationToken.isCancellationToken(null as never)).false; + expect(CancellationToken.isCancellationToken({})).false; + expect(CancellationToken.isCancellationToken(new CancellationToken(noop))).true; + }); + + it("property: promising", async () => { + const { token, cancel }: CancellationTokenSource = new CancellationTokenSource(); + const { promise }: CancellationToken = token; + expect(token.requested).false; + cancel("testing promise"); + const cancelError: Cancel = await promise; + expect(token.requested).true; + expect(cancelError.message).eq("testing promise"); + }); + + it("property: reason", () => { + const { token, cancel }: CancellationTokenSource = new CancellationTokenSource(); + + expect(token.requested).false; + expect(token.reason).undefined; + cancel("testing reason"); + expect(token.requested).true; + expect(token.reason?.message).eq("testing reason"); + }); + + it("property: requested", () => { + const { token, cancel }: CancellationTokenSource = new CancellationTokenSource(); + + expect(token.requested).false; + cancel("testing requested"); + expect(token.requested).true; + }); + + it("method: throwIfRequested", () => { + const { token, cancel }: CancellationTokenSource = new CancellationTokenSource(); + + token.throwIfRequested(); + cancel("testing throwIfRequested"); + + try { + token.throwIfRequested(); + expect(false).true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (reason: any) { + expect(reason).instanceof(Cancel); + expect(reason.message).eq("testing throwIfRequested"); + } + }); + }); + + describe("CancellationTokenSource Class", () => { + it("simple usage", () => { + const { token, cancel }: CancellationTokenSource = new CancellationTokenSource(); + expect(token.requested).false; + cancel("Simple reason"); + expect(token.requested).true; + }); + + it("of dependents, papa canceling", () => { + const { token, cancel }: CancellationTokenSource = new CancellationTokenSource(); + const { token: forked }: CancellationTokenSource = new CancellationTokenSource([token]); + expect(forked.requested).false; + cancel("Papa reason"); + expect(forked.requested).true; + expect(forked.reason?.message).eq("Papa reason"); + }); + + it("of dependents, child canceling", () => { + const { token }: CancellationTokenSource = new CancellationTokenSource(); + const { token: forked, cancel }: CancellationTokenSource = new CancellationTokenSource([token]); + expect(token.requested).false; + expect(forked.requested).false; + cancel("Child reason"); + expect(token.requested).false; + expect(forked.requested).true; + expect(forked.reason?.message).eq("Child reason"); + }); + }); +}); diff --git a/unit-tests/common/promises/fromEvent.spec.ts b/unit-tests/common/promises/fromEvent.spec.ts new file mode 100644 index 00000000..06299f87 --- /dev/null +++ b/unit-tests/common/promises/fromEvent.spec.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as chai from "chai"; + +import { EventEmitter } from "events"; + +import { fromEvent } from "../../../src/common/promises/fromEvent"; +import { noop } from "../../../src/common/promises/noop"; + +const expect = chai.expect; + +const emitter: EventEmitter = new EventEmitter(); + +describe("Promises::fromEvent", () => { + it("Nodejs EventEmitter", () => { + const promise = fromEvent(emitter, "yoo"); + emitter.emit("yoo"); + + return promise; + }); + + it("Nodejs EventEmitter: arg1, arg2", () => { + const promise = fromEvent(emitter, "yoo"); + emitter.emit("yoo", "arg1", "arg2"); + + return promise.then(value => { + expect(value).eq("arg1"); + }); + }); + + it("Nodejs EventEmitter: [arg1, arg2]", () => { + const promise = fromEvent(emitter, "yoo", { allParametersInArray: true }); + emitter.emit("yoo", "arg1", "arg2"); + + return promise.then(value => { + expect(value.name).eq("yoo"); + expect(value).eql(["arg1", "arg2"]); + }); + }); + + it("Nodejs EventEmitter: normal error event", () => { + const promise = fromEvent(emitter, "error"); + emitter.emit("error"); + + return promise; + }); + + it("Nodejs EventEmitter: rejected error event", () => { + const promise = fromEvent(emitter, "nonError"); + emitter.emit("error"); + + return promise.then( + _value => { + expect(false).true; + }, + _error => { + expect(true).true; + }, + ); + }); + + it("Nodejs EventEmitter: customized rejected error event", () => { + const dummyError = new Error(); + + const promise = fromEvent(emitter, "nonError", { + errorEventName: "customized-error", + }); + + emitter.emit("customized-error", dummyError); + + return promise.then( + _value => { + expect(false).true; + }, + error => { + expect(error).eq(dummyError); + }, + ); + }); + + it("Nodejs EventEmitter: ignored rejected error event", () => { + const dummyError = new Error(); + + emitter.once("error", noop); + + const promise = fromEvent(emitter, "nonError", { + ignoreErrors: true, + }); + + emitter.emit("error", dummyError); + emitter.emit("nonError", "arg1"); + + return promise.then( + value => { + expect(value).eq("arg1"); + }, + _error => { + expect(false).true; + }, + ); + }); + + it("Nodejs EventEmitter: listeners got removed once emitted", () => { + const promise = fromEvent(emitter, "yoo"); + emitter.emit("yoo"); + + return promise.then(_ => { + expect(emitter.listeners("yoo")).eql([]); + expect(emitter.listeners("error")).eql([]); + }); + }); + + it("Nodejs EventEmitter: listeners got removed once rejected", () => { + const promise = fromEvent(emitter, "yoo"); + emitter.emit("error"); + + return promise.catch(_ => { + expect(emitter.listeners("yoo")).eql([]); + expect(emitter.listeners("error")).eql([]); + }); + }); +}); diff --git a/unit-tests/common/promises/fromEvents.spec.ts b/unit-tests/common/promises/fromEvents.spec.ts new file mode 100644 index 00000000..23072f9e --- /dev/null +++ b/unit-tests/common/promises/fromEvents.spec.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as chai from "chai"; + +import { EventEmitter } from "events"; + +import { fromEvents } from "../../../src/common/promises/fromEvents"; + +const expect = chai.expect; + +const emitter: EventEmitter = new EventEmitter(); + +describe("Promises::fromEvents", () => { + it("Nodejs EventEmitter: success event", () => { + const promise = fromEvents(emitter, ["yoo", "ha"]); + emitter.emit("yoo", "arg1", "arg2"); + + return promise.then(value => { + expect(value.name).eq("yoo"); + expect(value).eql(["arg1", "arg2"]); + }); + }); + + it("Nodejs EventEmitter: error event", () => { + const promise = fromEvents(emitter, ["yoo", "ha"], ["oops"]); + emitter.emit("oops", "errArg1", "errArg2"); + + return promise.catch(value => { + expect(value.name).eq("oops"); + expect(value).eql(["errArg1", "errArg2"]); + }); + }); +}); diff --git a/unit-tests/common/sockets/JsonRpcSocketClient.spec.ts b/unit-tests/common/sockets/JsonRpcSocketClient.spec.ts new file mode 100644 index 00000000..ff881b18 --- /dev/null +++ b/unit-tests/common/sockets/JsonRpcSocketClient.spec.ts @@ -0,0 +1,237 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as chai from "chai"; +import * as net from "net"; + +import { Message, ResponseMessage, SocketMessageReader, SocketMessageWriter } from "vscode-jsonrpc/node"; +import { SocketAbortedConnection, SocketConnectionError } from "../../../src/common/sockets/SocketClient"; +import { fromEvent } from "../../../src/common/promises/fromEvent"; +import { JsonRpcSocketClient } from "../../../src/common/sockets/JsonRpcSocketClient"; +import { noop } from "../../../src/common/promises/noop"; + +const expect = chai.expect; + +describe("sockets::JsonRpcSocketClient.spec", function () { + let server: net.Server; + let serverPort: number; + + before( + (): Promise => + new Promise(resolve => { + server = new net.Server( + { + keepAlive: true, + }, + (socket: net.Socket) => { + const reader: SocketMessageReader = new SocketMessageReader(socket, "utf-8"); + const writer: SocketMessageWriter = new SocketMessageWriter(socket, "utf-8"); + + reader.listen((message: Message) => { + if (Message.isNotification(message)) { + return; + } + + if (Message.isRequest(message)) { + if (message.method === "echoMethod") { + void writer.write({ + id: message.id, + result: (message.params as Array)?.[0], + } as ResponseMessage); + } + + if (message.method === "error") { + void writer.write({ + id: message.id, + error: { + message: (message.params as Array)?.[0], + code: 0x1, + data: {}, + }, + } as ResponseMessage); + } + } + }); + }, + ); + + server.listen(); + serverPort = (server.address() as net.AddressInfo).port; + void resolve(); + }), + ); + + after(() => { + server.close(); + }); + + let client: JsonRpcSocketClient; + + beforeEach(() => { + client = new JsonRpcSocketClient(serverPort); + }); + + afterEach(() => { + client.close().catch(noop); + }); + + it("emit open event", () => { + void client.open(); + + return fromEvent(client, "open"); + }); + + it("emit close event", async () => { + await client.open(); + void client.close(); + + return fromEvent(client, "closed"); + }); + + describe("method open", () => { + it("cannot open twice in parallel", async () => { + client.open().catch(noop); + + try { + await client.open(); + } catch (error) { + expect(error).instanceof(SocketConnectionError); + } + }); + + it("cannot open twice in sequence", async () => { + await client.open(); + + try { + await client.open(); + } catch (error) { + expect(error).instanceof(SocketConnectionError); + } + }); + + it("open successfully", () => client.open()); + + it("cannot non-exising port", async () => { + let errorMessage: string = ""; + + try { + client = new JsonRpcSocketClient(81); + await client.open(); + } catch (error: any) { + errorMessage = error.message; + } + + expect(errorMessage.length).gt(0); + }); + }); + + describe("method close", () => { + it("close successfully v1", async () => { + await client.open(); + + return client.close(); + }); + + it("close successfully v2", () => client.close()); + + it("could be aborted", async () => { + const openingDeferred = client.open(); + await client.close(); + let thrown = false; + + try { + await openingDeferred; + } catch (e) { + expect(e).instanceof(SocketAbortedConnection); + thrown = true; + } + + expect(thrown).true; + }); + + it("reject any pending once closed", async () => { + await client.open(); + const callDeferred = client.request("yoo"); + await client.close(); + let thrown = false; + + try { + await callDeferred; + } catch (e) { + expect(e).instanceof(SocketConnectionError); + thrown = true; + } + + expect(thrown).true; + }); + }); + + describe("method call", () => { + it("reject if not opened yet", async () => { + let thrown = false; + + try { + await client.request("yoo"); + } catch (e) { + expect(e).instanceof(SocketConnectionError); + thrown = true; + } + + expect(thrown).true; + }); + + it("invoke RPC echoMethod method successfully", async () => { + await client.open(); + const result = await client.request("echoMethod", [77]); + expect(result).eq(77); + }); + + it("invoke RPC error method successfully", async () => { + await client.open(); + let thrown = false; + + try { + await client.request("error", ["dummy error"]); + } catch (e: any) { + expect(e.message).eq("dummy error"); + + thrown = true; + } + + expect(thrown).true; + }); + }); + + describe("property status", () => { + it("closed before opening", () => { + expect(client.status).eq("closed"); + }); + + it("connecting while opening", () => { + void client.open(); + expect(client.status).eq("connecting"); + }); + + it("open once opened", async () => { + await client.open(); + expect(client.status).eq("open"); + }); + + it("open once opened", async () => { + await client.open(); + expect(client.status).eq("open"); + }); + + it("closed once closed", async () => { + await client.open(); + await client.close(); + expect(client.status).eq("closed"); + }); + }); +});