diff --git a/src/commands/LifecycleCommands.ts b/src/commands/LifecycleCommands.ts index a8fb8f2..edc139d 100644 --- a/src/commands/LifecycleCommands.ts +++ b/src/commands/LifecycleCommands.ts @@ -9,22 +9,15 @@ import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; -import { - ExtensionContext, - InputBoxOptions, - Progress, - ProgressLocation, - Uri, - workspace as vscWorkspace, - WorkspaceFolder, -} from "vscode"; +import { ExtensionContext, Progress, ProgressLocation, Uri, workspace as vscWorkspace, WorkspaceFolder } from "vscode"; import { - AuthenticationKind, CreateAuthState, ExtensionInfo, GenericResult, - IPQTestService, + IPQTestClient, + ParsedDocumentState, + ResolveResourceChallengeState, } from "../common/PQTestService"; import { extensionI18n, resolveI18nTemplate } from "../i18n/extension"; import { @@ -37,7 +30,6 @@ import { } from "../utils/vscodes"; import { GlobalEventBus, GlobalEvents } from "../GlobalEventBus"; import { InputStep, MultiStepInput } from "../common/MultiStepInput"; -import { PqServiceHostClient, PqServiceHostServerNotReady } from "../pqTestConnector/PqServiceHostClient"; import { PqTestResultViewPanel, SimplePqTestResultViewBroker } from "../panels/PqTestResultViewPanel"; import { prettifyJson, resolveTemplateSubstitutedValues } from "../utils/strings"; @@ -48,6 +40,8 @@ import { getCtimeOfAFile } from "../utils/files"; import { IDisposable } from "../common/Disposable"; import { PqSdkNugetPackageService } from "../common/PqSdkNugetPackageService"; import { PqSdkOutputChannel } from "../features/PqSdkOutputChannel"; +import { PqServiceHostClient } from "../pqTestConnector/PqServiceHostClient"; +import { RpcServerNotReady } from "../pqTestConnector/RpcClient"; const CommandPrefix: string = `powerquery.sdk.tools`; @@ -76,7 +70,7 @@ export class LifecycleCommands implements IDisposable { private readonly vscExtCtx: ExtensionContext, readonly globalEventBus: GlobalEventBus, private readonly pqSdkNugetPackageService: PqSdkNugetPackageService, - private readonly pqTestService: IPQTestService, + private readonly pqClient: IPQTestClient, private readonly outputChannel: PqSdkOutputChannel, ) { globalEventBus.on(GlobalEvents.VSCodeEvents.ConfigDidChangeExternalVersionTag, () => { @@ -133,7 +127,9 @@ export class LifecycleCommands implements IDisposable { ), vscode.commands.registerCommand( LifecycleCommands.GenerateAndSetCredentialCommand, - this.commandGuard(this.generateAndSetCredentialCommandV2).bind(this), + ExtensionConfigurations.featureUseServiceHost + ? this.commandGuard(this.generateAndSetCredentialCommandUsingHostApi).bind(this) + : this.commandGuard(this.generateAndSetCredentialCommandV2).bind(this), ), vscode.commands.registerCommand( LifecycleCommands.RefreshCredentialCommand, @@ -163,7 +159,7 @@ export class LifecycleCommands implements IDisposable { private intervalTaskHandler: NodeJS.Timeout | undefined; private activateIntervalTasks(): void { // update lastCtimeOfMezFileWhoseInfoSeized once its info:static-type-check got re-eval - this.pqTestService.currentExtensionInfos.subscribe(() => { + this.pqClient.currentExtensionInfos.subscribe(() => { const currentPQTestExtensionFileLocation: string | undefined = ExtensionConfigurations.DefaultExtensionLocation; @@ -216,11 +212,11 @@ export class LifecycleCommands implements IDisposable { resolvedPQTestExtensionFileLocation && fs.existsSync(resolvedPQTestExtensionFileLocation) && (!ExtensionConfigurations.featureUseServiceHost || - (this.pqTestService as PqServiceHostClient).pqServiceHostConnected) + (this.pqClient as PqServiceHostClient).pqServiceHostConnected) ) { const currentCtime: Date = getCtimeOfAFile(resolvedPQTestExtensionFileLocation); - if (currentCtime > this.lastCtimeOfMezFileWhoseInfoSeized && this.pqTestService.pqTestReady) { + if (currentCtime > this.lastCtimeOfMezFileWhoseInfoSeized && this.pqClient.pqTestReady) { // first check where we got an onGoing one or not, // if the ongGoing one were newer or equaled to the current one, just return if ( @@ -352,7 +348,7 @@ export class LifecycleCommands implements IDisposable { ): (...args: any[]) => Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any return debounce(async (...args: any[]): Promise => { - let pqTestServiceReady: boolean = this.pqTestService.pqTestReady; + let pqTestServiceReady: boolean = this.pqClient.pqTestReady; if (!pqTestServiceReady) { const curPqTestPath: string | undefined = await this.checkAndTryToUpdatePqTest(); @@ -367,7 +363,7 @@ export class LifecycleCommands implements IDisposable { public async doBuildProjectCommand(): Promise { await this.initPqSdkTool$deferred; - return this.pqTestService.ExecuteBuildTaskAndAwaitIfNeeded(); + return this.pqClient.ExecuteBuildTaskAndAwaitIfNeeded(); } public setupCurrentlyOpenedWorkspaceCommand(): Promise { @@ -526,7 +522,7 @@ export class LifecycleCommands implements IDisposable { // therefore do not try to update when, like, pqTestLocation.indexOf(maybeNewVersion) === -1 if ( !pqTestLocation || - !this.pqTestService.pqTestReady || + !this.pqClient.pqTestReady || !this.pqSdkNugetPackageService.nugetPqSdkExistsSync(theNextVersion) ) { let pqTestExecutableFullPath: string | undefined = @@ -599,7 +595,7 @@ export class LifecycleCommands implements IDisposable { if (histPqTestLocation === newPqTestLocation) { // update the pqtest location by force in case it equals the previous one - this.pqTestService.onPowerQueryTestLocationChanged(); + this.pqClient.onPowerQueryTestLocationChangedByConfig(ExtensionConfigurations); } } @@ -760,7 +756,7 @@ export class LifecycleCommands implements IDisposable { this.outputChannel.show(); try { - const result: GenericResult = await this.pqTestService.DeleteCredential(); + const result: GenericResult = await this.pqClient.pqTestService.DeleteCredential(); this.outputChannel.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.delete.credentials.result", { @@ -795,7 +791,7 @@ export class LifecycleCommands implements IDisposable { this.outputChannel.show(); try { - const result: ExtensionInfo[] = await this.pqTestService.DisplayExtensionInfo(); + const result: ExtensionInfo[] = await this.pqClient.pqTestService.DisplayExtensionInfo(); this.outputChannel.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.display.extension.info.result", { @@ -815,8 +811,8 @@ export class LifecycleCommands implements IDisposable { if ( !( ExtensionConfigurations.featureUseServiceHost && - !(this.pqTestService as PqServiceHostClient).pqServiceHostConnected && - error instanceof PqServiceHostServerNotReady + !(this.pqClient as PqServiceHostClient).pqServiceHostConnected && + error instanceof RpcServerNotReady ) ) { const errorMessage: string = error instanceof Error ? error.message : error; @@ -846,7 +842,7 @@ export class LifecycleCommands implements IDisposable { this.outputChannel.show(); try { - const result: unknown[] = await this.pqTestService.ListCredentials(); + const result: unknown[] = await this.pqClient.pqTestService.ListCredentials(); this.outputChannel.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.list.credentials.result", { @@ -869,146 +865,6 @@ export class LifecycleCommands implements IDisposable { ); } - private async doPopulateOneSubstitutedValue( - templateStr: string, - title: string, - valueName: string, - options?: Partial, - ): Promise { - const valueKey: string | undefined = await vscode.window.showInputBox({ - title, - placeHolder: valueName, - validateInput(value: string): string | Thenable | undefined | null { - if (!value) { - return resolveI18nTemplate("PQSdk.lifecycle.error.invalid.empty.value", { - valueName, - }); - } - - return undefined; - }, - ...options, - }); - - if (valueKey) { - templateStr = templateStr.replace(valueName, valueKey); - } - - return templateStr; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async populateCredentialTemplate(template: any): Promise { - const theAuthenticationKind: AuthenticationKind = template.AuthenticationKind as AuthenticationKind; - let templateStr: string = JSON.stringify(template); - - switch (theAuthenticationKind) { - case "Key": - // $$KEY$$ - templateStr = await this.doPopulateOneSubstitutedValue( - templateStr, - extensionI18n["PQSdk.lifecycle.credential.key.label"], - "$$KEY$$", - ); - - break; - case "Aad": - case "OAuth": - // $$ACCESS_TOKEN$$ - templateStr = await this.doPopulateOneSubstitutedValue( - templateStr, - extensionI18n["PQSdk.lifecycle.credential.accessToken.label"], - "$$ACCESS_TOKEN$$", - ); - - // $$REFRESH_TOKEN$$ - templateStr = await this.doPopulateOneSubstitutedValue( - templateStr, - extensionI18n["PQSdk.lifecycle.credential.refreshToken.label"], - "$$REFRESH_TOKEN$$", - ); - - break; - case "UsernamePassword": - case "Windows": - // $$USERNAME$$ - templateStr = await this.doPopulateOneSubstitutedValue( - templateStr, - extensionI18n["PQSdk.lifecycle.credential.username.label"], - "$$USERNAME$$", - ); - - // $$PASSWORD$$ - templateStr = await this.doPopulateOneSubstitutedValue( - templateStr, - extensionI18n["PQSdk.lifecycle.credential.password.label"], - "$$PASSWORD$$", - { - password: true, - }, - ); - - break; - case "Anonymous": - default: - break; - } - - return templateStr; - } - - public async generateAndSetCredentialCommand(): Promise { - await vscode.window.withProgress( - { - title: extensionI18n["PQSdk.lifecycle.command.generate.credentials.title"], - location: ProgressLocation.Window, - cancellable: true, - }, - async (progress: Progress<{ increment?: number; message?: string }>) => { - progress.report({ increment: 0 }); - this.outputChannel.show(); - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const credentialPayload: any = await this.pqTestService.GenerateCredentialTemplate(); - - this.outputChannel.appendInfoLine( - resolveI18nTemplate("PQSdk.lifecycle.command.generate.credentials.result", { - result: prettifyJson(credentialPayload), - }), - ); - - const credentialPayloadStr: string = await this.populateCredentialTemplate(credentialPayload); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: any = await this.pqTestService.SetCredential(credentialPayloadStr); - - this.outputChannel.appendInfoLine( - resolveI18nTemplate("PQSdk.lifecycle.command.set.credentials.result", { - result: prettifyJson(result), - }), - ); - - void vscode.window.showInformationMessage( - resolveI18nTemplate("PQSdk.lifecycle.command.set.credentials.info", { - authenticationKind: credentialPayload.AuthenticationKind, - }), - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any | string) { - const errorMessage: string = error instanceof Error ? error.message : error; - - void vscode.window.showErrorMessage( - resolveI18nTemplate("PQSdk.lifecycle.command.set.credentials.errorMessage", { - errorMessage, - }), - ); - } - - progress.report({ increment: 100 }); - }, - ); - } - /** * Validate createAuthState and return an error message if any * @param createAuthState @@ -1054,8 +910,8 @@ export class LifecycleCommands implements IDisposable { try { const currentExtensionInfos: ExtensionInfo[] = - this.pqTestService.currentExtensionInfos.value ?? - (await this.pqTestService.DisplayExtensionInfo()); + this.pqClient.currentExtensionInfos.value ?? + (await this.pqClient.pqTestService.DisplayExtensionInfo()); const dataSourceKinds: string[] = Array.from( new Set( @@ -1366,9 +1222,8 @@ export class LifecycleCommands implements IDisposable { void vscode.window.showWarningMessage(maybeErrorMessage); } else { try { - const result: GenericResult = await this.pqTestService.SetCredentialFromCreateAuthState( - createAuthState, - ); + const result: GenericResult = + await this.pqClient.pqTestService.SetCredentialFromCreateAuthState(createAuthState); this.outputChannel.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.createAuthState.result", { @@ -1409,6 +1264,260 @@ export class LifecycleCommands implements IDisposable { ); } + public async generateAndSetCredentialCommandUsingHostApi(): Promise { + const title: string = extensionI18n["PQSdk.lifecycle.command.generate.credentials.title"]; + + await vscode.window.withProgress( + { + title, + location: ProgressLocation.Window, + cancellable: true, + }, + async (progress: Progress<{ increment?: number; message?: string }>) => { + const pqServiceHostClient: PqServiceHostClient = this.pqClient as PqServiceHostClient; + + const connectorQueryFiles: vscode.Uri[] = await vscode.workspace.findFiles( + "**/*.{query,test}.pq", + "**/{bin,obj}/**", + 1e2, + ); + + let alreadyHaveTheResource: boolean = false; + + // eslint-disable-next-line no-inner-declarations + async function collectInputs(): Promise { + const state: Partial = {} as Partial; + await MultiStepInput.run((input: MultiStepInput) => populateQueryFile(input, state)); + + return state as ResolveResourceChallengeState; + } + + // eslint-disable-next-line no-inner-declarations + async function populateQueryFile( + input: MultiStepInput, + state: Partial, + ): Promise { + let thePreviousPickedDetail: string | undefined = undefined; + + if (connectorQueryFiles.length) { + const items: vscode.QuickPickItem[] = connectorQueryFiles.map((one: vscode.Uri) => ({ + label: vscode.workspace.asRelativePath(one), + detail: one.fsPath, + })); + + items.push({ + label: extensionI18n["PQSdk.lifecycle.command.choose.customizedQueryFilePath.label"], + detail: extensionI18n["PQSdk.lifecycle.command.choose.customizedQueryFilePath.detail"], + }); + + const picked: vscode.QuickPickItem = await input.showQuickPick({ + title, + step: 1, + totalSteps: 5, + placeholder: extensionI18n["PQSdk.lifecycle.command.choose.queryFile"], + activeItem: items[0], + items, + }); + + thePreviousPickedDetail = picked.detail; + + // eslint-disable-next-line require-atomic-updates + if ( + thePreviousPickedDetail && + thePreviousPickedDetail !== + extensionI18n["PQSdk.lifecycle.command.choose.customizedQueryFilePath.detail"] + ) { + state.DocumentScript = fs + .readFileSync(thePreviousPickedDetail, { encoding: "utf8" }) + .trim(); + } + } + + if ( + !state.DocumentScript || + thePreviousPickedDetail === + extensionI18n["PQSdk.lifecycle.command.choose.customizedDataSourceKind.label"] + ) { + // we did not have the PathToQueryFile populated, + // or it was set to customized PathToQueryFile detail + // then we should allow users to input arbitrarily + // eslint-disable-next-line require-atomic-updates + thePreviousPickedDetail = await input.showInputBox({ + title, + step: 2, + totalSteps: 5, + value: "", + prompt: extensionI18n["PQSdk.lifecycle.command.choose.queryFilePath.label"], + ignoreFocusOut: true, + validate: (key: string) => + Promise.resolve( + key.length && fs.existsSync(key) + ? undefined + : extensionI18n["PQSdk.lifecycle.error.empty.PathToQueryFilePath"], + ), + }); + + // eslint-disable-next-line require-atomic-updates + state.DocumentScript = fs.readFileSync(thePreviousPickedDetail, { encoding: "utf8" }); + } + + progress.report({ increment: 20 }); + + return (input: MultiStepInput) => pickDocumentQueriesIfNeeded(input, state); + } + + async function pickDocumentQueriesIfNeeded( + input: MultiStepInput, + state: Partial, + ): Promise { + const result: ParsedDocumentState = + await pqServiceHostClient.documentService.TryParseDocumentScript( + // DocumentScript should not be nullable when we reaching here + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + state.DocumentScript!, + ); + + if (result.Errors?.length) { + void vscode.window.showWarningMessage(result.Errors[0].Message); + + return; + } else { + switch (result.DocumentKind) { + case "Section": { + if (!result.Queries?.length) { + void vscode.window.showWarningMessage("Missing queries in the selected document"); + + return; + } + + // we need ask users to select a query out of it + const items: vscode.QuickPickItem[] = result.Queries.filter( + (one: ParsedDocumentState["Queries"][number]) => + one.nameIdentifier.argumentFragmentKind === "Identifier", + ).map((one: ParsedDocumentState["Queries"][number]) => ({ + label: one.nameIdentifier.value, + })); + + const picked: vscode.QuickPickItem = await input.showQuickPick({ + title, + step: 3, + totalSteps: 5, + placeholder: "Query name", + activeItem: items[0], + items, + }); + + // eslint-disable-next-line require-atomic-updates + state.QueryName = picked.label; + + break; + } + + case "Expression": + default: + // no need to do anything additional, we are good to continue with the expression + break; + } + // we need to select a query + } + + // try to infer DataSourceKind DataSourcePath + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const evalRes: any = await pqServiceHostClient.evaluationService.GetPreviewAsync({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + DocumentScript: state.DocumentScript!, + QueryName: state.QueryName, + }); + + if (evalRes.ResultType === "Table" || evalRes.ResultType === "BinaryMetadata") { + alreadyHaveTheResource = true; + + // we already had the datasource for the query, thus need not continue completing the form + return; + } else if ( + evalRes.ResultType === "Challenge" && + evalRes.ChallengeType === "Resource" && + evalRes.DataSources?.length + ) { + const dataSource: { Kind: string; Path: string } | undefined = JSON.parse( + evalRes.DataSources[0], + ); + + if (dataSource) { + // eslint-disable-next-line require-atomic-updates + state.ResourceKind = dataSource.Kind ?? state.ResourceKind; + // eslint-disable-next-line require-atomic-updates + state.ResourcePath = dataSource.Path ?? state.ResourcePath; + } + } + } catch (_e) { + // noop + } + + progress.report({ increment: 20 }); + + return (input: MultiStepInput) => populateOptionalDataSourceKinds(input, state); + } + + async function populateOptionalDataSourceKinds( + input: MultiStepInput, + state: Partial, + ): Promise { + // eslint-disable-next-line require-atomic-updates + state.ResourceKind = await input.showInputBox({ + title, + step: 4, + totalSteps: 5, + value: state.ResourceKind ?? "", + prompt: "Optional data source kind", + ignoreFocusOut: true, + validate: (_key: string) => Promise.resolve(undefined), + }); + + progress.report({ increment: 20 }); + + return (input: MultiStepInput) => populateOptionalDataSourcePath(input, state); + } + + async function populateOptionalDataSourcePath( + input: MultiStepInput, + state: Partial, + ): Promise { + // eslint-disable-next-line require-atomic-updates + state.ResourcePath = await input.showInputBox({ + title, + step: 5, + totalSteps: 5, + value: state.ResourcePath ?? "", + prompt: "Optional data source path", + ignoreFocusOut: true, + validate: (_key: string) => Promise.resolve(undefined), + }); + + progress.report({ increment: 20 }); + } + + const resolveResourceChallengeState: ResolveResourceChallengeState = await collectInputs(); + + if (alreadyHaveTheResource) { + void vscode.window.showInformationMessage("The resource of the file have already existed"); + } else if (!resolveResourceChallengeState.DocumentScript) { + void vscode.window.showWarningMessage("The content of the document selected cannot be null"); + } else { + // do trigger the resolve the resource challenge + await pqServiceHostClient.pqTestService.ResolveResourceChallengeAsync( + resolveResourceChallengeState, + ); + + void vscode.window.showInformationMessage("The resource of the file have been resolved"); + } + + progress.report({ increment: 100 }); + }, + ); + } + public async refreshCredentialCommand(): Promise { await vscode.window.withProgress( { @@ -1421,7 +1530,7 @@ export class LifecycleCommands implements IDisposable { this.outputChannel.show(); try { - const result: GenericResult = await this.pqTestService.RefreshCredential(); + const result: GenericResult = await this.pqClient.pqTestService.RefreshCredential(); this.outputChannel.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.refresh.credentials.result", { @@ -1459,11 +1568,11 @@ export class LifecycleCommands implements IDisposable { try { if (ExtensionConfigurations.featureUseServiceHost) { - result = await (this.pqTestService as PqServiceHostClient).RunTestBatteryFromContent( + result = await (this.pqClient as PqServiceHostClient).pqTestService.RunTestBatteryFromContent( pathToQueryFile?.fsPath, ); } else { - result = await this.pqTestService.RunTestBattery(pathToQueryFile?.fsPath); + result = await this.pqClient.pqTestService.RunTestBattery(pathToQueryFile?.fsPath); } this.outputChannel.appendInfoLine( @@ -1486,7 +1595,7 @@ export class LifecycleCommands implements IDisposable { }, ); - if (result) { + if (result && !ExtensionConfigurations.featureUseServiceHost) { await vscode.commands.executeCommand(PqTestResultViewPanel.ShowResultWebViewCommand); SimplePqTestResultViewBroker.values.latestPqTestResult.emit(result); } @@ -1504,7 +1613,7 @@ export class LifecycleCommands implements IDisposable { this.outputChannel.show(); try { - const result: GenericResult = await this.pqTestService.TestConnection(); + const result: GenericResult = await this.pqClient.pqTestService.TestConnection(); this.outputChannel.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.test.connection.result", { diff --git a/src/common/PQTestService.ts b/src/common/PQTestService.ts index 58d58e1..17d4fad 100644 --- a/src/common/PQTestService.ts +++ b/src/common/PQTestService.ts @@ -6,6 +6,7 @@ */ import { PQTestTask } from "./PowerQueryTask"; +import { RpcRequestParamBase } from "../pqTestConnector/RpcClient"; import type { ValueEventEmitter } from "./ValueEventEmitter"; export interface GenericResult { @@ -111,14 +112,62 @@ export interface CreateAuthState { $$PASSWORD$$?: string; } +export interface ParsedDocumentState { + DocumentKind: "Section" | "Expression"; + Queries: Array<{ + nameIdentifier: { + argumentFragmentKind: + | "Date" + | "DateTime" + | "DateTimeZone" + | "Duration" + | "FieldAccess" + | "Identifier" + | "Logical" + | "NaN" + | "NegativeInfinity" + | "Null" + | "Number" + | "PositiveInfinity" + | "Text" + | "Time"; + fragmentKind: + | "Aggregation" + | "ArbitrarySubstring" + | "Argument" + | "ArgumentRecord" + | "ArithmeticBinaryTransform" + | "ArithmeticFunctionTransform" + | "BinaryContents" + | "BoundarySubstring" + | string; + Name: string; + value: string; + }; + queryScript: string; + steps: unknown[]; + }>; + Errors: Array<{ + Kind: unknown; + Location: unknown; + ErrorRange: unknown; + Message: string; + }>; +} + +export interface ResolveResourceChallengeState { + DocumentScript: string; + QueryName?: string; + ResourceKind?: string; + ResourcePath?: string; +} + +export interface GetPreviewRequest { + DocumentScript: string; + QueryName?: string; +} + export interface IPQTestService { - readonly pqTestReady: boolean; - readonly pqTestLocation: string; - readonly pqTestFullPath: string; - readonly currentExtensionInfos: ValueEventEmitter; - readonly currentCredentials: ValueEventEmitter; - readonly onPowerQueryTestLocationChanged: () => void; - readonly ExecuteBuildTaskAndAwaitIfNeeded: () => Promise; readonly DeleteCredential: () => Promise; readonly DisplayExtensionInfo: () => Promise; readonly ListCredentials: () => Promise; @@ -132,6 +181,107 @@ export interface IPQTestService { readonly TestConnection: () => Promise; } +export interface PqServiceHostRequestParamBase extends RpcRequestParamBase { + ExtensionPaths?: string[]; + KeyVaultSecretName?: string; + DataSourceKind?: string; + DataSourcePath?: string; + PrettyPrint?: boolean; + EnvironmentConfigurationFile?: string; + EnvironmentSetting?: string[]; + ApplicationPropertyFile?: string; + ApplicationProperties?: string[]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export interface PqServiceHostCreateAuthRequest extends PqServiceHostRequestParamBase { + AuthenticationKind: string; + TemplateValueKey?: string; + TemplateValueUsername?: string; + TemplateValuePassword?: string; + TemplateValueAccessToken?: string; + TemplateValueRefreshToken?: string; + AllowUserInteraction?: string; +} + +export interface PqServiceHostDeleteCredentialRequest extends PqServiceHostRequestParamBase { + AllCredentials?: boolean; +} + +export interface PqServiceHostRunTestRequest extends PqServiceHostRequestParamBase { + LogMashupEngineTraceLevel?: string; + FailOnFoldingFailure?: boolean; + AutomaticFileCredentials?: boolean; + RunAsAction?: boolean; +} + +export interface PqServiceHostRunTestRequest extends PqServiceHostRequestParamBase { + LogMashupEngineTraceLevel?: string; + FailOnFoldingFailure?: boolean; + AutomaticFileCredentials?: boolean; + RunAsAction?: boolean; +} + +export interface PqServiceHostSetCredentialRequest extends PqServiceHostRequestParamBase { + AuthenticationKind?: string; + AllowUserInteraction?: boolean; + UseLegacyBrowser?: boolean; + UseSystemBrowser?: boolean; + InputTemplateString?: string; +} + +export interface PqServiceHostTestConnectionRequest extends PqServiceHostRequestParamBase { + LogMashupEngineTraceLevel?: string; +} + +export interface PqServiceHostValidateRequest extends PqServiceHostRequestParamBase { + LogMashupEngineTraceLevel?: string; +} + +export type PqServiceHostResolveResourceChallengeRequest = PqServiceHostRequestParamBase & + ResolveResourceChallengeState; + +export type PqServiceHostGetPreviewRequest = PqServiceHostRequestParamBase & GetPreviewRequest; + +export interface IHealthService { + readonly ForceShutdown: () => Promise; + readonly Ping: () => Promise; +} + +export interface IDocumentService { + readonly TryParseDocumentScript: (documentScript: string) => Promise; +} + +export interface IEvaluationService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly GetPreviewAsync: (state: GetPreviewRequest) => Promise; +} + +export interface IPQTestClient { + readonly pqTestReady: boolean; + readonly pqTestLocation: string; + readonly pqTestFullPath: string; + readonly currentExtensionInfos: ValueEventEmitter; + readonly currentCredentials: ValueEventEmitter; + readonly onPowerQueryTestLocationChangedByConfig: (config: { PQTestLocation: string | undefined }) => void; + readonly ExecuteBuildTaskAndAwaitIfNeeded: () => Promise; + readonly pqTestService: IPQTestService; +} + +export interface IPQServiceHostClient extends IPQTestClient { + readonly healthService: IHealthService; + readonly pqTestService: IPQTestService & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly RunTestBatteryFromContent: (pathToQueryFile?: string) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly ResolveResourceChallengeAsync: (state: ResolveResourceChallengeState) => Promise; + }; + readonly documentService: IDocumentService; + readonly evaluationService: IEvaluationService; +} + const CommonArgs: string[] = ["--prettyPrint"]; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/common/promises/fromEvent.ts b/src/common/promises/fromEvent.ts index 7dbfb8c..4610e25 100644 --- a/src/common/promises/fromEvent.ts +++ b/src/common/promises/fromEvent.ts @@ -13,7 +13,7 @@ import { cancelable } from "./cancelable"; import { CancellationToken } from "./CancellationToken"; import { noop } from "./noop"; import { once } from "./once"; -import WebSocket from "ws"; +import type WebSocket from "ws"; export type AnyEventListener = (type: string, callback: AnyFunction) => void; diff --git a/src/common/sockets/JsonRpcSocketClient.ts b/src/common/sockets/JsonRpcSocketClient.ts index 193b879..2d9bf70 100644 --- a/src/common/sockets/JsonRpcSocketClient.ts +++ b/src/common/sockets/JsonRpcSocketClient.ts @@ -15,13 +15,13 @@ import { SocketMessageReader, SocketMessageWriter, } from "vscode-jsonrpc/node"; -import { fibonacciNumbers } from "../iterables/FibonacciNumbers"; -import { NumberGenerator } from "../iterables/NumberIterator"; +import { EventEmitter } from "events"; import { CLOSED, OPEN, SocketClient, SocketConnectionError } from "./SocketClient"; import { AnyFunction } from "../promises/types"; import { BaseError } from "../errors"; -import { noop } from "../promises/noop"; +import { fibonacciNumbers } from "../iterables/FibonacciNumbers"; +import { NumberGenerator } from "../iterables/NumberIterator"; const JSON_RPC_VERSION: string = "2.0"; @@ -78,6 +78,7 @@ function defaultOnMessage(message: any): void { export type DeferredJsonRpcTask = { resolve: AnyFunction; reject: AnyFunction }; export class JsonRpcSocketClient extends SocketClient { + public readonly notificationMessageEmitter: EventEmitter = new EventEmitter(); private readonly _handle: (message: any) => Promise; private readonly _deferredDictionary: Map = new Map(); private reader?: SocketMessageReader; @@ -116,7 +117,11 @@ export class JsonRpcSocketClient extends SocketClient { return this._getDeferred(rawJsonRpcResponseMessage.id)?.resolve(rawJsonRpcResponseMessage.result); } else if (Message.isNotification(rawJsonRpcMessage)) { - return this._handle(rawJsonRpcMessage).catch(noop); + Array.isArray(rawJsonRpcMessage.params) + ? this.notificationMessageEmitter.emit(rawJsonRpcMessage.method, ...rawJsonRpcMessage.params) + : this.notificationMessageEmitter.emit(rawJsonRpcMessage.method, rawJsonRpcMessage.params); + + return Promise.resolve(rawJsonRpcMessage); } else { return this._handle(rawJsonRpcMessage).then((result: ResponseBase) => result.result); } diff --git a/src/constants/PowerQuerySdkExtension.ts b/src/constants/PowerQuerySdkExtension.ts index de8c419..2eec445 100644 --- a/src/constants/PowerQuerySdkExtension.ts +++ b/src/constants/PowerQuerySdkExtension.ts @@ -67,12 +67,12 @@ const PublicMsftPqSdkToolsNugetName: string = InternalMsftPqSdkToolsNugetName; /** * 2.114 or 2.114.x wil limit the version of the sdkTool seized beneath 2.115 */ -const MaximumPqTestNugetVersion: string = "2.114.x" as const; +const MaximumPqTestNugetVersion: string = "2.117.x" as const; /** * A suggestedPqTestNugetVersion that would be used as the initially tried pqTest version * thus, make sure it is lower than `MaximumPqTestNugetVersion` if it were specified */ -const SuggestedPqTestNugetVersion: string = "2.114.4" as const; +const SuggestedPqTestNugetVersion: string = "2.117.361" as const; const PqTestSubPath: string[] = [ `${InternalMsftPqSdkToolsNugetName}.${SuggestedPqTestNugetVersion}`, diff --git a/src/debugAdaptor/MQueryDebugSession.ts b/src/debugAdaptor/MQueryDebugSession.ts index bf0a1b8..c97b7b6 100644 --- a/src/debugAdaptor/MQueryDebugSession.ts +++ b/src/debugAdaptor/MQueryDebugSession.ts @@ -17,7 +17,7 @@ import { } from "@vscode/debugadapter"; import { DebugProtocol } from "@vscode/debugprotocol"; -import { DISCONNECTED, PqServiceHostClientLite, READY } from "../pqTestConnector/PqServiceHostClientLite"; +import { DISCONNECTED, READY } from "../pqTestConnector/RpcClient"; import { extensionI18n, resolveI18nTemplate } from "../i18n/extension"; import { ExtensionInfo, GenericResult } from "../common/PQTestService"; import { @@ -27,6 +27,7 @@ import { import { DeferredValue } from "../common/DeferredValue"; import { ExtensionConfigurations } from "../constants/PowerQuerySdkConfiguration"; import { fromEvents } from "../common/promises/fromEvents"; +import { PqServiceHostClientLite } from "../pqTestConnector/PqServiceHostClientLite"; import { stringifyJson } from "../utils/strings"; import { WaitNotify } from "../common/WaitNotify"; @@ -178,7 +179,7 @@ export class MQueryDebugSession extends LoggingDebugSession { private async doLaunchRequest(args: ILaunchRequestArguments): Promise { if (this.useServiceHost && this.pqServiceHostClientLite) { // activate pqServiceHostClientLite and make it connect to pqServiceHost - this.pqServiceHostClientLite.onPowerQueryTestLocationChanged(); + this.pqServiceHostClientLite.onPowerQueryTestLocationChangedByConfig(ExtensionConfigurations); try { // wait for the pqServiceHostClientLite's socket got ready @@ -189,7 +190,7 @@ export class MQueryDebugSession extends LoggingDebugSession { switch (theOperation) { case "info": { const displayExtensionInfoResult: ExtensionInfo[] = - await this.pqServiceHostClientLite.DisplayExtensionInfo(); + await this.pqServiceHostClientLite.pqTestService.DisplayExtensionInfo(); this.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.display.extension.info.result", { @@ -204,7 +205,8 @@ export class MQueryDebugSession extends LoggingDebugSession { } case "test-connection": { - const testConnectionResult: GenericResult = await this.pqServiceHostClientLite.TestConnection(); + const testConnectionResult: GenericResult = + await this.pqServiceHostClientLite.pqTestService.TestConnection(); this.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.test.connection.result", { @@ -217,7 +219,7 @@ export class MQueryDebugSession extends LoggingDebugSession { case "run-test": { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: any = await this.pqServiceHostClientLite.RunTestBatteryFromContent( + const result: any = await this.pqServiceHostClientLite.pqTestService.RunTestBatteryFromContent( path.resolve(args.program), ); diff --git a/src/extension.ts b/src/extension.ts index 7aa4c7e..3837e2f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,7 @@ import * as vscode from "vscode"; -import { convertExtensionInfoToLibraryJson, ExtensionInfo, IPQTestService } from "./common/PQTestService"; +import { convertExtensionInfoToLibraryJson, ExtensionInfo, IPQTestClient } from "./common/PQTestService"; import { getFirstWorkspaceFolder, maybeHandleNewWorkspaceCreated } from "./utils/vscodes"; import { activateMQueryDebug } from "./debugAdaptor/activateMQueryDebug"; import { ExtensionConfigurations } from "./constants/PowerQuerySdkConfiguration"; @@ -42,7 +42,7 @@ export function activate(vscExtCtx: vscode.ExtensionContext): void { pqSdkOutputChannel, ); - const disposablePqTestServices: IPQTestService & IDisposable = useServiceHost + const disposablePqTestServices: IPQTestClient & IDisposable = useServiceHost ? new PqServiceHostClient(globalEventBus, pqSdkOutputChannel) : new PqTestExecutableTaskQueue(vscExtCtx, globalEventBus, pqSdkOutputChannel); diff --git a/src/features/PowerQueryTaskProvider.ts b/src/features/PowerQueryTaskProvider.ts index 369c3a4..4fc98ab 100644 --- a/src/features/PowerQueryTaskProvider.ts +++ b/src/features/PowerQueryTaskProvider.ts @@ -8,7 +8,7 @@ import * as path from "path"; import * as vscode from "vscode"; -import { buildPqTestArgs, IPQTestService } from "../common/PQTestService"; +import { buildPqTestArgs, IPQTestClient } from "../common/PQTestService"; import { ExtensionConfigurations } from "../constants/PowerQuerySdkConfiguration"; import { ExtensionConstants } from "../constants/PowerQuerySdkExtension"; import { extensionI18n } from "../i18n/extension"; @@ -135,19 +135,19 @@ export class PowerQueryTaskProvider implements vscode.TaskProvider { }); } - constructor(protected readonly pqTestService: IPQTestService) { + constructor(protected readonly pqTestClient: IPQTestClient) { // noop } public provideTasks(_token: vscode.CancellationToken): vscode.ProviderResult { const result: vscode.Task[] = []; - if (!this.pqTestService.pqTestReady) { + if (!this.pqTestClient.pqTestReady) { return result; } result.push(PowerQueryTaskProvider.buildMsbuildTask()); - result.push(PowerQueryTaskProvider.buildMakePQXCompileTask(this.pqTestService.pqTestLocation)); + result.push(PowerQueryTaskProvider.buildMakePQXCompileTask(this.pqTestClient.pqTestLocation)); const useServiceHost: boolean = ExtensionConfigurations.featureUseServiceHost; @@ -158,7 +158,7 @@ export class PowerQueryTaskProvider implements vscode.TaskProvider { } else { pqTestOperations.forEach((taskDef: PowerQueryTaskDefinition) => { result.push( - PowerQueryTaskProvider.getTaskForPQTestTaskDefinition(taskDef, this.pqTestService.pqTestFullPath), + PowerQueryTaskProvider.getTaskForPQTestTaskDefinition(taskDef, this.pqTestClient.pqTestFullPath), ); }); } @@ -169,7 +169,7 @@ export class PowerQueryTaskProvider implements vscode.TaskProvider { public resolveTask(task: vscode.Task, token: vscode.CancellationToken): vscode.ProviderResult { const taskDef: PowerQueryTaskDefinition = task.definition as PowerQueryTaskDefinition; - const pqtestExe: string = this.pqTestService.pqTestFullPath; + const pqtestExe: string = this.pqTestClient.pqTestFullPath; if (taskDef.operation === "msbuild") { const msbuildFullPath: string | undefined = ExtensionConfigurations.msbuildPath; @@ -185,11 +185,11 @@ export class PowerQueryTaskProvider implements vscode.TaskProvider { const currentWorkingFolder: string | undefined = getFirstWorkspaceFolder()?.uri.fsPath; const makePQXExe: string = path.join( - this.pqTestService.pqTestLocation, + this.pqTestClient.pqTestLocation, ExtensionConstants.MakePQXExecutableName, ); - if (currentWorkingFolder && this.pqTestService.pqTestLocation && !token.isCancellationRequested) { + if (currentWorkingFolder && this.pqTestClient.pqTestLocation && !token.isCancellationRequested) { const args: string[] = buildPqTestArgs(taskDef); args.push(currentWorkingFolder); const processExecution: vscode.ProcessExecution = new vscode.ProcessExecution(makePQXExe, args); diff --git a/src/features/PqSdkTaskTerminal.ts b/src/features/PqSdkTaskTerminal.ts index b213123..2d54e6b 100644 --- a/src/features/PqSdkTaskTerminal.ts +++ b/src/features/PqSdkTaskTerminal.ts @@ -8,11 +8,13 @@ import * as os from "os"; import * as vscode from "vscode"; -import { DISCONNECTED, PqServiceHostClientLite, READY } from "../pqTestConnector/PqServiceHostClientLite"; +import { DISCONNECTED, READY } from "../pqTestConnector/RpcClient"; import { extensionI18n, resolveI18nTemplate } from "../i18n/extension"; import { ExtensionInfo, GenericResult } from "../common/PQTestService"; +import { ExtensionConfigurations } from "../constants/PowerQuerySdkConfiguration"; import { fromEvents } from "../common/promises/fromEvents"; import { PowerQueryTaskDefinition } from "../common/PowerQueryTask"; +import { PqServiceHostClientLite } from "../pqTestConnector/PqServiceHostClientLite"; import { resolveSubstitutedValues } from "../utils/vscodes"; import { stringifyJson } from "../utils/strings"; @@ -45,7 +47,7 @@ export class PqSdkTaskTerminal implements vscode.Pseudoterminal { async open(_initialDimensions: vscode.TerminalDimensions | undefined): Promise { // activate pqServiceHostClientLite and make it connect to pqServiceHost - this.pqServiceHostClientLite.onPowerQueryTestLocationChanged(); + this.pqServiceHostClientLite.onPowerQueryTestLocationChangedByConfig(ExtensionConfigurations); try { // wait for the pqServiceHostClientLite socket got ready @@ -53,7 +55,7 @@ export class PqSdkTaskTerminal implements vscode.Pseudoterminal { switch (this.taskDefinition.operation) { case "list-credential": { - const result: unknown[] = await this.pqServiceHostClientLite.ListCredentials(); + const result: unknown[] = await this.pqServiceHostClientLite.pqTestService.ListCredentials(); this.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.list.credentials.result", { @@ -65,7 +67,8 @@ export class PqSdkTaskTerminal implements vscode.Pseudoterminal { } case "delete-credential": { - const deleteCredentialResult: GenericResult = await this.pqServiceHostClientLite.DeleteCredential(); + const deleteCredentialResult: GenericResult = + await this.pqServiceHostClientLite.pqTestService.DeleteCredential(); this.appendInfoLine(deleteCredentialResult.Message); break; @@ -73,7 +76,7 @@ export class PqSdkTaskTerminal implements vscode.Pseudoterminal { case "info": { const displayExtensionInfoResult: ExtensionInfo[] = - await this.pqServiceHostClientLite.DisplayExtensionInfo(); + await this.pqServiceHostClientLite.pqTestService.DisplayExtensionInfo(); this.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.display.extension.info.result", { @@ -90,7 +93,7 @@ export class PqSdkTaskTerminal implements vscode.Pseudoterminal { case "set-credential": { if (this.taskDefinition.credentialTemplate) { const setCredentialGenericResult: GenericResult = - await this.pqServiceHostClientLite.SetCredential( + await this.pqServiceHostClientLite.pqTestService.SetCredential( JSON.stringify(this.taskDefinition.credentialTemplate), ); @@ -110,7 +113,7 @@ export class PqSdkTaskTerminal implements vscode.Pseudoterminal { case "refresh-credential": { const refreshCredentialResult: GenericResult = - await this.pqServiceHostClientLite.RefreshCredential(); + await this.pqServiceHostClientLite.pqTestService.RefreshCredential(); this.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.refresh.credentials.result", { @@ -123,7 +126,7 @@ export class PqSdkTaskTerminal implements vscode.Pseudoterminal { case "run-test": { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: any = await this.pqServiceHostClientLite.RunTestBatteryFromContent( + const result: any = await this.pqServiceHostClientLite.pqTestService.RunTestBatteryFromContent( resolveSubstitutedValues(this.taskDefinition.pathToQueryFile), ); @@ -137,7 +140,8 @@ export class PqSdkTaskTerminal implements vscode.Pseudoterminal { } case "test-connection": { - const testConnectionResult: GenericResult = await this.pqServiceHostClientLite.TestConnection(); + const testConnectionResult: GenericResult = + await this.pqServiceHostClientLite.pqTestService.TestConnection(); this.appendInfoLine( resolveI18nTemplate("PQSdk.lifecycle.command.test.connection.result", { diff --git a/src/pqTestConnector/PqServiceHostClient.ts b/src/pqTestConnector/PqServiceHostClient.ts index df6b02c..0a3352a 100644 --- a/src/pqTestConnector/PqServiceHostClient.ts +++ b/src/pqTestConnector/PqServiceHostClient.ts @@ -5,22 +5,28 @@ * LICENSE file in the root of this projects source tree. */ +import * as fs from "fs"; import * as vscode from "vscode"; -import { Credential, ExtensionInfo, IPQTestService } from "../common/PQTestService"; -import { GlobalEventBus, GlobalEvents } from "../GlobalEventBus"; import { - PqServiceHostClientLite, + Credential, + ExtensionInfo, + IPQServiceHostClient, PqServiceHostRequestParamBase, - PqServiceHostResponseResult, -} from "./PqServiceHostClientLite"; +} from "../common/PQTestService"; +import { GlobalEventBus, GlobalEvents } from "../GlobalEventBus"; +import { ExtensionConfigurations } from "../constants/PowerQuerySdkConfiguration"; import { IDisposable } from "../common/Disposable"; import { PqSdkOutputChannel } from "../features/PqSdkOutputChannel"; +import { PqServiceHostClientLite } from "./PqServiceHostClientLite"; +import { resolveSubstitutedValues } from "../utils/vscodes"; +import { RpcResponseResult } from "./RpcClient"; import { ValueEventEmitter } from "../common/ValueEventEmitter"; export * from "./PqServiceHostClientLite"; -export class PqServiceHostClient extends PqServiceHostClientLite implements IPQTestService, IDisposable { +export class PqServiceHostClient extends PqServiceHostClientLite implements IPQServiceHostClient, IDisposable { + private firstTimeStarted: boolean = true; private pingTimer: NodeJS.Timer | undefined = undefined; public override get pqServiceHostConnected(): boolean { @@ -39,11 +45,11 @@ export class PqServiceHostClient extends PqServiceHostClientLite implements IPQT this._disposables.unshift( this.globalEventBus.subscribeOneEvent( GlobalEvents.VSCodeEvents.ConfigDidChangePowerQueryTestLocation, - this.onPowerQueryTestLocationChanged.bind(this), + this.onPowerQueryTestLocationChangedByConfig.bind(this, ExtensionConfigurations), ), ); - this.onPowerQueryTestLocationChanged(); + this.onPowerQueryTestLocationChangedByConfig(ExtensionConfigurations); vscode.workspace.onDidSaveTextDocument((textDocument: vscode.TextDocument) => { if ( @@ -70,19 +76,42 @@ export class PqServiceHostClient extends PqServiceHostClientLite implements IPQT } protected override onConnected(): void { + super.onConnected(); + + // 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.pqTestService.DisplayExtensionInfo(); + } + + this.firstTimeStarted = false; + } + this.startToSendPingMessages(); } protected override onDisconnecting(): void { + super.onDisconnecting(); this.stopSendingPingMessages(); } protected override onReconnecting(): void { + super.onReconnecting(); + // 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(); + void this.healthService.ForceShutdown(); // and clear the ping interval handler this.stopSendingPingMessages(); } @@ -98,7 +127,7 @@ export class PqServiceHostClient extends PqServiceHostClientLite implements IPQT } = {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { - const responseResultPayload: PqServiceHostResponseResult["Payload"] = await super.requestRemoteRpcMethod( + const responseResultPayload: RpcResponseResult["Payload"] = await super.requestRemoteRpcMethod( method, parameters, options, @@ -117,7 +146,7 @@ export class PqServiceHostClient extends PqServiceHostClientLite implements IPQT private startToSendPingMessages(): void { this.pingTimer = setInterval(() => { - void this.Ping(); + void this.healthService.Ping(); }, 1950); } @@ -133,20 +162,4 @@ export class PqServiceHostClient extends PqServiceHostClientLite implements IPQT this.currentCredentials.dispose(); super.dispose(); } - - ForceShutdown(): Promise { - return this.requestRemoteRpcMethod("v1/HealthService/Shutdown", [ - { - SessionId: this.sessionId, - }, - ]); - } - - Ping(): Promise { - return this.requestRemoteRpcMethod("v1/HealthService/Ping", [ - { - SessionId: this.sessionId, - }, - ]); - } } diff --git a/src/pqTestConnector/PqServiceHostClientLite.ts b/src/pqTestConnector/PqServiceHostClientLite.ts index 6beb9a2..d962856 100644 --- a/src/pqTestConnector/PqServiceHostClientLite.ts +++ b/src/pqTestConnector/PqServiceHostClientLite.ts @@ -6,127 +6,47 @@ */ 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 { + CreateAuthState, + GetPreviewRequest, + IDocumentService, + IEvaluationService, + IHealthService, + IPQServiceHostClient, + PqServiceHostCreateAuthRequest, + PqServiceHostDeleteCredentialRequest, + PqServiceHostGetPreviewRequest, + PqServiceHostResolveResourceChallengeRequest, + PqServiceHostRunTestRequest, + PqServiceHostSetCredentialRequest, + PqServiceHostTestConnectionRequest, + ResolveResourceChallengeState, +} from "../common/PQTestService"; import { executeBuildTaskAndAwaitIfNeeded } from "./PqTestTaskUtils"; import { ExtensionConfigurations } from "../constants/PowerQuerySdkConfiguration"; -import { IDisposable } from "../common/Disposable"; import { PqSdkOutputChannelLight } from "../features/PqSdkOutputChannel"; -import { SpawnedProcess } from "../common/SpawnedProcess"; +import { resolveSubstitutedValues } from "../utils/vscodes"; +import { RpcClient } from "./RpcClient"; -export interface PqServiceHostRequestParamBase { - SessionId: string; - PathToConnector?: string; - PathToQueryFile?: string; +type OmittedPqTestMethods = "currentExtensionInfos" | "currentCredentials"; - // 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; +export class PqServiceHostClientLite extends RpcClient implements Omit { 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(); + constructor(outputChannel: PqSdkOutputChannelLight) { + super(outputChannel); } /** * Synchronized post-connection event * @protected */ - protected onConnected(): void { + protected override onConnected(): void { + super.onConnected(); // noop } @@ -134,7 +54,8 @@ export class PqServiceHostClientLite * Synchronized pre-disconnection event * @protected */ - protected onDisconnecting(): void { + protected override onDisconnecting(): void { + super.onDisconnecting(); // noop } @@ -142,291 +63,11 @@ export class PqServiceHostClientLite * Synchronized pre-reconnection event * @protected */ - protected onReconnecting(): void { + protected override onReconnecting(): void { + super.onReconnecting(); // 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, @@ -437,163 +78,234 @@ export class PqServiceHostClientLite ); } - 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", - [ + public readonly pqTestService: IPQServiceHostClient["pqTestService"] = { + DeleteCredential: () => + this.requestRemoteRpcMethod("v1/PqTestService/DeleteCredential", [ { SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + AllCredentials: true, }, - ], - { shouldParsePayload: true }, - ); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - GenerateCredentialTemplate(): Promise { - return this.requestRemoteRpcMethod( - "v1/PqTestService/GenerateCredentialTemplate", - [ + ]), + DisplayExtensionInfo: () => + this.requestRemoteRpcMethod( + "v1/PqTestService/DisplayExtensionInfo", + [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + }, + ], + { shouldParsePayload: true }, + ), + ListCredentials: () => + this.requestRemoteRpcMethod("v1/PqTestService/ListCredentials", [ { SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, + }, + ]), + GenerateCredentialTemplate: () => + this.requestRemoteRpcMethod( + "v1/PqTestService/GenerateCredentialTemplate", + [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }, + ], + { shouldParsePayload: true }, + ), + SetCredential: (payloadStr: string) => + this.requestRemoteRpcMethod("v1/PqTestService/SetCredential", [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + InputTemplateString: payloadStr, }, - ], - { shouldParsePayload: true }, - ); - } + ]), + SetCredentialFromCreateAuthState: (createAuthState: CreateAuthState) => + this.requestRemoteRpcMethod( + "v1/PqTestService/SetCredentialFromCreateAuthState", + [ + { + SessionId: this.sessionId, + PathToConnector: + createAuthState.PathToConnectorFile || + resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + PathToQueryFile: resolveSubstitutedValues(createAuthState.PathToQueryFile), + // DataSourceKind: createAuthState.DataSourceKind, + AuthenticationKind: createAuthState.AuthenticationKind, + TemplateValueKey: createAuthState.$$KEY$$, + TemplateValueUsername: createAuthState.$$USERNAME$$, + TemplateValuePassword: createAuthState.$$PASSWORD$$, + }, + ], + ), + RefreshCredential: () => + this.requestRemoteRpcMethod("v1/PqTestService/RefreshCredential", [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }, + ]), + RunTestBattery: (pathToQueryFile: string | undefined) => { + const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; - ListCredentials(): Promise { - return this.requestRemoteRpcMethod("v1/PqTestService/ListCredentials", [ - { - SessionId: this.sessionId, - }, - ]); - } + const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( + ExtensionConfigurations.DefaultQueryFileLocation, + ); - RefreshCredential(): Promise { - return this.requestRemoteRpcMethod("v1/PqTestService/RefreshCredential", [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: 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; + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - RunTestBattery(pathToQueryFile: string | undefined): Promise { - const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; + if (!pathToQueryFile && configPQTestQueryFileLocation) { + pathToQueryFile = configPQTestQueryFileLocation; + } - const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( - ExtensionConfigurations.DefaultQueryFileLocation, - ); + return this.requestRemoteRpcMethod("v1/PqTestService/RunTestBattery", [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + PathToQueryFile: pathToQueryFile, + }, + ]); + }, + TestConnection: () => + this.requestRemoteRpcMethod("v1/PqTestService/TestConnection", [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }, + ]), + RunTestBatteryFromContent: async (pathToQueryFile: string | undefined) => { + const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; - // 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; - } + const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( + ExtensionConfigurations.DefaultQueryFileLocation, + ); - if (!pathToQueryFile && configPQTestQueryFileLocation) { - pathToQueryFile = configPQTestQueryFileLocation; - } + // 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; + } - return this.requestRemoteRpcMethod("v1/PqTestService/RunTestBattery", [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: pathToQueryFile, - }, - ]); - } + if (!pathToQueryFile && configPQTestQueryFileLocation) { + pathToQueryFile = configPQTestQueryFileLocation; + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async RunTestBatteryFromContent(pathToQueryFile: string | undefined): Promise { - const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; + if (!pathToQueryFile || !fs.existsSync(pathToQueryFile)) return Promise.resolve(); - const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( - ExtensionConfigurations.DefaultQueryFileLocation, - ); + let currentContent: string = fs.readFileSync(pathToQueryFile, { encoding: "utf8" }); + let currentEditor: vscode.TextEditor | undefined = undefined; - // 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; - } + vscode.window.visibleTextEditors.forEach((oneEditor: vscode.TextEditor) => { + if ( + oneEditor?.document.languageId === "powerquery" && + oneEditor.document.uri.fsPath === pathToQueryFile + ) { + currentEditor = oneEditor; + 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 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any = await this.requestRemoteRpcMethod( + "v1/PqTestService/RunTestBatteryFromContent", + [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + PathToQueryFile: currentContent, + }, + ], + ); - if (!pathToQueryFile && configPQTestQueryFileLocation) { - pathToQueryFile = configPQTestQueryFileLocation; - } + if (result.Kind === 0 && result.CompletedResult.modifiedDocument && currentEditor) { + const theCurrentEditor: vscode.TextEditor = currentEditor as vscode.TextEditor; + const firstLine: vscode.TextLine = theCurrentEditor.document.lineAt(0); - if (!pathToQueryFile || !fs.existsSync(pathToQueryFile)) return Promise.resolve(); + const lastLine: vscode.TextLine = theCurrentEditor.document.lineAt( + theCurrentEditor.document.lineCount - 1, + ); - let currentContent: string = fs.readFileSync(pathToQueryFile, { encoding: "utf8" }); + const textRange: vscode.Range = new vscode.Range(firstLine.range.start, lastLine.range.end); - vscode.window.visibleTextEditors.forEach((oneEditor: vscode.TextEditor) => { - if (oneEditor?.document.languageId === "powerquery" && oneEditor.document.uri.fsPath === pathToQueryFile) { - currentContent = oneEditor.document.getText(); + void theCurrentEditor.edit((editBuilder: vscode.TextEditorEdit) => { + editBuilder.replace(textRange, result.CompletedResult.modifiedDocument); + }); } - }); - - // 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, - }, - ]); - } + return result; + }, + ResolveResourceChallengeAsync: (state: ResolveResourceChallengeState) => + this.requestRemoteRpcMethod( + "v1/PqTestService/ResolveResourceChallengeAsync", + [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + DocumentScript: state.DocumentScript, + QueryName: state.QueryName, + ResourceKind: state.ResourceKind, + ResourcePath: state.ResourcePath, + }, + ], + ), + }; - 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$$, - }, - ]); - } + public readonly healthService: IHealthService = { + ForceShutdown: () => + this.requestRemoteRpcMethod("v1/HealthService/Shutdown", [ + { + SessionId: this.sessionId, + }, + ]), + Ping: () => + this.requestRemoteRpcMethod("v1/HealthService/Ping", [ + { + SessionId: this.sessionId, + }, + ]), + }; + + public readonly documentService: IDocumentService = { + TryParseDocumentScript: (documentScript: string) => + this.requestRemoteRpcMethod( + "v1/DocumentService/TryParseDocumentScript", + [ + { + SessionId: this.sessionId, + // PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + // PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + DocumentScript: documentScript, + }, + ], + ), + }; - TestConnection(): Promise { - return this.requestRemoteRpcMethod("v1/PqTestService/TestConnection", [ - { - SessionId: this.sessionId, - PathToConnector: getFirstWorkspaceFolder()?.uri.fsPath, - PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - }, - ]); - } + public readonly evaluationService: IEvaluationService = { + GetPreviewAsync: (state: GetPreviewRequest) => + this.requestRemoteRpcMethod("v1/EvaluationService/GetPreview", [ + { + SessionId: this.sessionId, + PathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + PathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + DocumentScript: state.DocumentScript, + QueryName: state.QueryName, + }, + ]), + }; } diff --git a/src/pqTestConnector/PqTestExecutableTaskQueue.ts b/src/pqTestConnector/PqTestExecutableTaskQueue.ts index 25db96b..b447527 100644 --- a/src/pqTestConnector/PqTestExecutableTaskQueue.ts +++ b/src/pqTestConnector/PqTestExecutableTaskQueue.ts @@ -19,7 +19,7 @@ import { Credential, ExtensionInfo, GenericResult, - IPQTestService, + IPQTestClient, } from "../common/PQTestService"; import { Disposable, IDisposable } from "../common/Disposable"; import { DisposableEventEmitter, ExtractEventTypes } from "../common/DisposableEventEmitter"; @@ -80,7 +80,7 @@ export class PqTestExecutableDetailedTaskError extends Error { } } -export class PqTestExecutableTaskQueue implements IPQTestService, IDisposable { +export class PqTestExecutableTaskQueue implements IPQTestClient, IDisposable { public static readonly ExecutableName: string = "PQTest.exe"; public static readonly ExecutablePidLockFileName: string = "pqTest.pid"; @@ -126,11 +126,11 @@ export class PqTestExecutableTaskQueue implements IPQTestService, IDisposable { this._disposables.unshift( this.globalEventBus.subscribeOneEvent( GlobalEvents.VSCodeEvents.ConfigDidChangePowerQueryTestLocation, - this.onPowerQueryTestLocationChanged.bind(this), + this.onPowerQueryTestLocationChangedByConfig.bind(this, ExtensionConfigurations), ), ); - this.onPowerQueryTestLocationChanged(); + this.onPowerQueryTestLocationChangedByConfig(ExtensionConfigurations); vscode.workspace.onDidSaveTextDocument((textDocument: vscode.TextDocument) => { if ( @@ -387,9 +387,10 @@ export class PqTestExecutableTaskQueue implements IPQTestService, IDisposable { return result; } - public onPowerQueryTestLocationChanged(): void { + public onPowerQueryTestLocationChangedByConfig(configs: { PQTestLocation: string | undefined }): void { // PQTestLocation getter - const nextPQTestLocation: string | undefined = ExtensionConfigurations.PQTestLocation; + const nextPQTestLocation: string | undefined = configs.PQTestLocation; + const pqTestExe: string | undefined = this.resolvePQTestPath(nextPQTestLocation); if (!pqTestExe || !nextPQTestLocation) { @@ -419,7 +420,7 @@ export class PqTestExecutableTaskQueue implements IPQTestService, IDisposable { if (resolvedPQTestExtensionFileLocation && fs.existsSync(resolvedPQTestExtensionFileLocation)) { // trigger one display extension info task to populate modules in the pq-lang ext - void this.DisplayExtensionInfo(); + void this.pqTestService.DisplayExtensionInfo(); } this.firstTimeReady = false; @@ -462,59 +463,49 @@ export class PqTestExecutableTaskQueue implements IPQTestService, IDisposable { ); } - public DeleteCredential(): Promise { - return this.doEnqueueOneTask({ - operation: "delete-credential", - additionalArgs: [`--ALL`], - }); - } - - public DisplayExtensionInfo(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.doEnqueueOneTask({ - operation: "info", - pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), - }); - } - - public ListCredentials(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.doEnqueueOneTask({ - operation: "list-credential", - }); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public GenerateCredentialTemplate(): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.doEnqueueOneTask({ - operation: "credential-template", - // additionalArgs: [`--authentication-kind ${authenticationKind}`], - pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), - pathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - }); - } - - public SetCredential(payloadStr: string): Promise { - return this.doEnqueueOneTask({ - operation: "set-credential", - // additionalArgs: [`${JSON.stringify(payload)}`], - pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), - pathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - stdinStr: payloadStr, - }); - } - - public SetCredentialFromCreateAuthState(createAuthState: CreateAuthState): Promise { - // it feels like set-credential task has to wait for the std input - let payloadStr: string | undefined = undefined; + public readonly pqTestService: IPQTestClient["pqTestService"] = { + DeleteCredential: () => + this.doEnqueueOneTask({ + operation: "delete-credential", + additionalArgs: [`--ALL`], + }), + DisplayExtensionInfo: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.doEnqueueOneTask({ + operation: "info", + pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + }), + ListCredentials: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.doEnqueueOneTask({ + operation: "list-credential", + }), + GenerateCredentialTemplate: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.doEnqueueOneTask({ + operation: "credential-template", + // additionalArgs: [`--authentication-kind ${authenticationKind}`], + pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + pathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }), + SetCredential: (payloadStr: string) => + this.doEnqueueOneTask({ + operation: "set-credential", + // additionalArgs: [`${JSON.stringify(payload)}`], + pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + pathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + stdinStr: payloadStr, + }), + SetCredentialFromCreateAuthState: (createAuthState: CreateAuthState) => { + // it feels like set-credential task has to wait for the std input + let payloadStr: string | undefined = undefined; - let additionalArgs: string[] | undefined = []; - let theAuthKind: string = createAuthState.AuthenticationKind; + let additionalArgs: string[] | undefined = []; + let theAuthKind: string = createAuthState.AuthenticationKind; - if (theAuthKind.toLowerCase() === "key") { - /* eslint-disable @typescript-eslint/no-non-null-assertion*/ - payloadStr = `{ + if (theAuthKind.toLowerCase() === "key") { + /* eslint-disable @typescript-eslint/no-non-null-assertion*/ + payloadStr = `{ "AuthenticationKind": "Key", "AuthenticationProperties": { "Key": "${createAuthState.$$KEY$$!}" @@ -522,10 +513,10 @@ export class PqTestExecutableTaskQueue implements IPQTestService, IDisposable { "PrivacySetting": "None", "Permissions": [] }`; - /* eslint-enable*/ - } else if (theAuthKind.toLowerCase() === "usernamepassword") { - /* eslint-disable @typescript-eslint/no-non-null-assertion*/ - payloadStr = `{ + /* eslint-enable*/ + } else if (theAuthKind.toLowerCase() === "usernamepassword") { + /* eslint-disable @typescript-eslint/no-non-null-assertion*/ + payloadStr = `{ "AuthenticationKind": "UsernamePassword", "AuthenticationProperties": { "Username": "${createAuthState.$$USERNAME$$!}", @@ -534,93 +525,89 @@ export class PqTestExecutableTaskQueue implements IPQTestService, IDisposable { "PrivacySetting": "None", "Permissions": [] }`; - /* eslint-enable*/ - } else if (theAuthKind.toLowerCase() === "oauth" || theAuthKind.toLowerCase() === "aad") { - additionalArgs.unshift("--interactive"); - } else if (theAuthKind.toLowerCase() === "implicit" || theAuthKind.toLowerCase() === "anonymous") { - theAuthKind = "Anonymous"; + /* eslint-enable*/ + } else if (theAuthKind.toLowerCase() === "oauth" || theAuthKind.toLowerCase() === "aad") { + additionalArgs.unshift("--interactive"); + } else if (theAuthKind.toLowerCase() === "implicit" || theAuthKind.toLowerCase() === "anonymous") { + theAuthKind = "Anonymous"; - payloadStr = `{ + payloadStr = `{ "AuthenticationKind": "Anonymous", "AuthenticationProperties": {}, "PrivacySetting": "None", "Permissions": [] }`; - } else if (theAuthKind.toLowerCase() === "windows") { - payloadStr = `{ + } else if (theAuthKind.toLowerCase() === "windows") { + payloadStr = `{ "AuthenticationKind": "Windows", "AuthenticationProperties": {}, "PrivacySetting": "None", "Permissions": [] }`; - } - - if (payloadStr === undefined) { - // AuthenticationKind must be specified if it's not provided in the json input - additionalArgs.unshift(`${theAuthKind}`); - additionalArgs.unshift(`-ak`); - } - - // in case latter we turn additionalArgs an empty array, we should set it undefined at that moment - if (Array.isArray(additionalArgs) && additionalArgs.length === 0) { - additionalArgs = undefined; - } - - return this.doEnqueueOneTask({ - operation: "set-credential", - additionalArgs, - pathToConnector: - createAuthState.PathToConnectorFile || - resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), - pathToQueryFile: resolveSubstitutedValues(createAuthState.PathToQueryFile), - stdinStr: payloadStr, - }); - } - - public RefreshCredential(): Promise { - return this.doEnqueueOneTask({ - operation: "refresh-credential", - pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), - pathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - }); - } + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public async RunTestBattery(pathToQueryFile: string = ""): Promise { - // maybe we need to execute the build task before evaluating. - await this.ExecuteBuildTaskAndAwaitIfNeeded(); + if (payloadStr === undefined) { + // AuthenticationKind must be specified if it's not provided in the json input + additionalArgs.unshift(`${theAuthKind}`); + additionalArgs.unshift(`-ak`); + } - const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; + // in case latter we turn additionalArgs an empty array, we should set it undefined at that moment + if (Array.isArray(additionalArgs) && additionalArgs.length === 0) { + additionalArgs = undefined; + } - const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( - ExtensionConfigurations.DefaultQueryFileLocation, - ); + return this.doEnqueueOneTask({ + operation: "set-credential", + additionalArgs, + pathToConnector: + createAuthState.PathToConnectorFile || + resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + pathToQueryFile: resolveSubstitutedValues(createAuthState.PathToQueryFile), + stdinStr: payloadStr, + }); + }, + RefreshCredential: () => + this.doEnqueueOneTask({ + operation: "refresh-credential", + pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + pathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }), + RunTestBattery: async (pathToQueryFile: string = "") => { + // maybe we need to execute the build task before evaluating. + await this.ExecuteBuildTaskAndAwaitIfNeeded(); - // 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; - } + const activeTextEditor: TextEditor | undefined = vscode.window.activeTextEditor; - if (!pathToQueryFile && configPQTestQueryFileLocation) { - pathToQueryFile = configPQTestQueryFileLocation; - } + const configPQTestQueryFileLocation: string | undefined = resolveSubstitutedValues( + ExtensionConfigurations.DefaultQueryFileLocation, + ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return this.doEnqueueOneTask({ - operation: "run-test", - pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), - pathToQueryFile, - }); - } + // 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; + } - public async TestConnection(): Promise { - // maybe we need to execute the build task before evaluating. - await this.ExecuteBuildTaskAndAwaitIfNeeded(); + if (!pathToQueryFile && configPQTestQueryFileLocation) { + pathToQueryFile = configPQTestQueryFileLocation; + } - return this.doEnqueueOneTask({ - operation: "test-connection", - pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), - pathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), - }); - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return this.doEnqueueOneTask({ + operation: "run-test", + pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + pathToQueryFile, + }); + }, + TestConnection: async () => { + // maybe we need to execute the build task before evaluating. + await this.ExecuteBuildTaskAndAwaitIfNeeded(); + + return this.doEnqueueOneTask({ + operation: "test-connection", + pathToConnector: resolveSubstitutedValues(ExtensionConfigurations.DefaultExtensionLocation), + pathToQueryFile: resolveSubstitutedValues(ExtensionConfigurations.DefaultQueryFileLocation), + }); + }, + }; } diff --git a/src/pqTestConnector/RpcClient.ts b/src/pqTestConnector/RpcClient.ts new file mode 100644 index 0000000..6571131 --- /dev/null +++ b/src/pqTestConnector/RpcClient.ts @@ -0,0 +1,416 @@ +/** + * 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 { ChildProcess } from "child_process"; +import { EventEmitter } from "events"; + +import { CLOSED, ERROR, OPEN } from "../common/sockets/SocketClient"; +import { defaultBackOff, JsonRpcSocketClient } from "../common/sockets/JsonRpcSocketClient"; +import { delay, isPortBusy, pidIsRunning } from "../utils/pids"; + +import { AnyFunction } from "../common/promises/types"; +import { BaseError } from "../common/errors"; +import { convertStringToInteger } from "../utils/numbers"; +import { IDisposable } from "../common/Disposable"; +import type { PqSdkOutputChannelLight } from "../features/PqSdkOutputChannel"; +import { SpawnedProcess } from "../common/SpawnedProcess"; + +export interface RpcRequestParamBase { + 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, +} + +// renamed from PqServiceHostResponseResult +export interface RpcResponseResult { + SessionId: string; + Status: ResponseStatus; + Payload: T; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + InnerException?: any; +} + +// renamed from PqServiceHostServerNotReady +export class RpcServerNotReady extends BaseError { + constructor() { + super("Cannot connect to the pqServiceHost"); + } +} + +// renamed from PqInternalError +export class RpcInternalError extends BaseError { + // 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); +} + +// 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; + +/** + * The RpcClient Class handles the connection to PQServiceHost + */ +export class RpcClient extends EventEmitter implements IDisposable { + public static readonly ExecutableName: string = "PQServiceHost.exe"; + public static readonly ExecutablePidLockFileName: string = "PQServiceHost.pid"; + public static readonly ExecutablePortLockFileName: string = "PQServiceHost.port"; + + private _isDisposed: boolean = false; + + pqTestReady: boolean = false; + pqTestLocation: string = ""; + pqTestFullPath: string = ""; + + 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: RpcResponseResult = (await this.jsonRpcSocketClient.request( + method, + parameters, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + )) as RpcResponseResult; + + 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 RpcInternalError(errorMessage, errorData)); + } + } else { + throw new RpcServerNotReady(); + } + } + + 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}`); + 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(() => { + if (!this._isDisposed) { + this.onPowerQueryTestLocationChangedByConfig(this.currentConfigs); + } + }, 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, RpcClient.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 { + // since we are about to start the pqServiceHost, this client is definitely not disposed. + this._isDisposed = false; + this.doStartAndListenPqServiceHostIfNeededInProgress = true; + + const pidFileFullPath: string = path.resolve(nextPQTestLocation, RpcClient.ExecutablePidLockFileName); + + const portFileFullPath: string = path.resolve(nextPQTestLocation, RpcClient.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, RpcClient.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); + } + + private currentConfigs: { PQTestLocation: string | undefined } = { PQTestLocation: undefined }; + /** + * Event handler of the PQSdkTool folder change event + * And it could also act as the init event consumer for a new connection + * @param configs the configs of a getter PQTestLocation returning + * the folder containing the executable like: + * D:\Repo\PowerQuerySdkTools\out\AnyCPU\Release\SdkTools\tools + */ + public onPowerQueryTestLocationChangedByConfig(configs: typeof this.currentConfigs): void { + this.currentConfigs = configs; + // PQTestLocation getter + const nextPQTestLocation: string | undefined = configs.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 { + this._isDisposed = true; + + for (const oneDisposable of this._disposables) { + oneDisposable.dispose(); + } + + this.disposeCurrentJsonRpcSocketClient(); + } +} diff --git a/src/test/common.ts b/src/test/common.ts index 8144cfe..b463cb7 100644 --- a/src/test/common.ts +++ b/src/test/common.ts @@ -40,9 +40,9 @@ export const NugetBaseFolderName: string = ExtensionConstants.NugetBaseFolder; export const NugetPackagesDirectory: string = path.join(extensionInstalledDirectory, NugetBaseFolderName); +export const MaximumPqTestNugetVersion: string = ExtensionConstants.MaximumPqTestNugetVersion; export const PqTestSubPath: string[] = ExtensionConstants.PqTestSubPath; export const buildPqSdkSubPath: (version: string) => string[] = (version: string) => ExtensionConstants.buildNugetPackageSubPath(ExtensionConstants.InternalMsftPqSdkToolsNugetName, version); export const PublicMsftPqSdkToolsNugetName: string = ExtensionConstants.PublicMsftPqSdkToolsNugetName; -export const MaximumPqTestNugetVersion: string = ExtensionConstants.MaximumPqTestNugetVersion; diff --git a/src/test/commonSuite/NewProject.spec.ts b/src/test/commonSuite/NewProject.spec.ts index 82944ec..76995b1 100644 --- a/src/test/commonSuite/NewProject.spec.ts +++ b/src/test/commonSuite/NewProject.spec.ts @@ -6,6 +6,7 @@ */ import * as chai from "chai"; +import * as path from "path"; import { Workbench } from "vscode-extension-tester"; @@ -21,7 +22,9 @@ import { } from "../utils"; import { delay } from "../../utils/pids"; +import { fromEvents } from "../../common/promises/fromEvents"; import { makeOneTmpDir } from "../../utils/osUtils"; +import { PqTestConnectors } from "../utils/pqTestConnectors"; import { tryRemoveDirectoryRecursively } from "../../utils/files"; const expect = chai.expect; @@ -107,7 +110,7 @@ describe("New extension project Tests", () => { const workbench = new Workbench(); await VscSettings.ensureUseServiceHostEnabled(workbench); - await PqSdkNugetPackages.assertPqSdkToolExisting(); + const pqSdkToolFullPath: string = await PqSdkNugetPackages.assertPqSdkToolExisting(); await ConnectorProjects.createOneNewExtensionProject(workbench, newExtensionName, oneTmpDir!); @@ -125,42 +128,62 @@ describe("New extension project Tests", () => { // await few time to ensure new mez got built await delay(15e3); - // Clear ALL credentials - await VscSideBars.clickClearAllCredentials(pqSdkViewSection); + // connect to the running pqServiceHost + const e2eRpcClient: PqTestConnectors.E2EPqTestClient = + await PqTestConnectors.createPqServiceHostClientLiteAndConnect(pqSdkToolFullPath); - await VscSideBars.clickSetCredentialAndPick(pqSdkViewSection, [ - newExtensionName, - `${newExtensionName}.query.pq`, - "Anonymous", - ]); + // activate the e2e test service and subscribe debugger events of Flow events + await e2eRpcClient.RegisterDebuggerListener(); - // await few time to ensure notification popped up - await delay(10e3); - // New Anonymous credential has been generated successfully - await VscNotifications.assetNotificationsLength(1); + try { + // Clear ALL credentials + await VscSideBars.clickClearAllCredentials(pqSdkViewSection); - await VscSideBars.openFileFromDefaultViewSection( - newExtensionName, - `${newExtensionName}.query.pq`, - workbench, - ); + // manually set an anonymous credential + await e2eRpcClient.SetDefaultAnonymousCredentialByPath( + path.join(oneTmpDir!, newExtensionName), + newExtensionName, + ); - await VscEditors.evalCurPqOfAnEditor(`${newExtensionName}.query.pq`, workbench); + // await few time to ensure notification popped up + await delay(750); - // PQTest result + // list credentials + await VscSideBars.clickListCredentials(pqSdkViewSection); - // await few time to ensure pqtest result popped up - await delay(10e3); + // open a query file and start to evaluate + await VscSideBars.openFileFromDefaultViewSection( + newExtensionName, + `${newExtensionName}.query.pq`, + workbench, + ); - await VscEditors.assertPqTestResultEditorExisting(workbench); + // subscribe EditQueriesAsync event first before starting the pqServiceHost + const editQueriesOpenedPromise: Promise = fromEvents( + e2eRpcClient.pqUIFlowEvent, + ["EditQueriesAsync"], + [], + ); - const outputView = await VscOutputChannels.bringUpPQSdkOutputChannel(); + await VscEditors.evalCurPqOfAnEditor(`${newExtensionName}.query.pq`, workbench); - const currentPqSdkOutputText = await outputView.getText(); - expect(currentPqSdkOutputText.indexOf(`Hello from ${newExtensionName}: (no message)`)).gt(-1); - await outputView.clearText(); + // PQDesktop UiFlow result - await VscTitleBar.closeFolder(workbench); + // await few time to ensure pqtest result popped up + await delay(10e3); + + // we just ensure we got the pqDesktop opened + await editQueriesOpenedPromise; + + // send a cancel request to close all opened webviews + await e2eRpcClient.CancelAllOpenedWebview(); + + await VscTitleBar.closeFolder(workbench); + } finally { + await e2eRpcClient.DeregisterDebuggerListener(); + + e2eRpcClient.dispose(); + } }).timeout(0); after(() => { diff --git a/src/test/utils/pqSdkNugetPackageUtils.ts b/src/test/utils/pqSdkNugetPackageUtils.ts index 010a1d0..ea46696 100644 --- a/src/test/utils/pqSdkNugetPackageUtils.ts +++ b/src/test/utils/pqSdkNugetPackageUtils.ts @@ -40,7 +40,7 @@ export module PqSdkNugetPackages { return path.resolve(NugetPackagesDirectory, ...pqTestSubPath); } - export async function getAllPQSdkVersions(): Promise { + export async function getAllPQSdkVersionsBelowItsMaximumNugetVersion(): Promise { const releasedVersions = await nugetHttpService.getSortedPackageReleasedVersions( PublicMsftPqSdkToolsNugetName, { @@ -53,11 +53,14 @@ export module PqSdkNugetPackages { return releasedVersions; } - export async function assertPqSdkToolExisting(): Promise { + /** * + * Return the full path of the PQSdkTool folder + */ + export async function assertPqSdkToolExisting(): Promise { let i = 0; if (!latestPQSdkNugetVersion) { - const allNugetVersions: NugetVersions[] = await getAllPQSdkVersions(); + const allNugetVersions: NugetVersions[] = await getAllPQSdkVersionsBelowItsMaximumNugetVersion(); // eslint-disable-next-line require-atomic-updates latestPQSdkNugetVersion = allNugetVersions[allNugetVersions.length - 1]; @@ -79,10 +82,12 @@ export module PqSdkNugetPackages { if (fs.existsSync(expectedPqTestExePath)) { expect(true).true; - return; + return path.dirname(mayBeExpectedPQSDKToolExePath!)!; } } expect(false).true; + // I need this line to please tsc and its return type check + throw "never reached"; } } diff --git a/src/test/utils/pqTestConnectors.ts b/src/test/utils/pqTestConnectors.ts new file mode 100644 index 0000000..9d4e2fe --- /dev/null +++ b/src/test/utils/pqTestConnectors.ts @@ -0,0 +1,110 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the MIT license found in the + * LICENSE file in the root of this projects source tree. + */ + +import * as path from "path"; +import { EventEmitter } from "events"; +import { expect } from "chai"; +import { GenericResult } from "../../common/PQTestService"; + +import { DISCONNECTED, READY, RpcClient } from "../../pqTestConnector/RpcClient"; +import { delay } from "../../utils/pids"; +import { fromEvents } from "../../common/promises/fromEvents"; +import type { PqSdkOutputChannelLight } from "../../features/PqSdkOutputChannel"; + +export module PqTestConnectors { + export class E2EPqTestClient extends RpcClient { + public readonly pqUIFlowEvent: EventEmitter = new EventEmitter(); + + constructor(outputChannel: PqSdkOutputChannelLight) { + super(outputChannel); + } + + subscribeNotificationEventEmitter(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.jsonRpcSocketClient?.notificationMessageEmitter.on("PqFlowEvent", (pqFlowBaseEvent: any) => { + this.pqUIFlowEvent.emit(pqFlowBaseEvent.Method, pqFlowBaseEvent); + }); + } + + RegisterDebuggerListener(sessionId: string = "DefaultSession"): Promise { + return this.requestRemoteRpcMethod("v1/E2ETestHelperService/RegisterDebuggerListener", [ + { + SessionId: sessionId, + }, + ]); + } + + DeregisterDebuggerListener(sessionId: string = "DefaultSession"): Promise { + return this.requestRemoteRpcMethod("v1/E2ETestHelperService/DeregisterDebuggerListener", [ + { + SessionId: sessionId, + }, + ]); + } + + CancelAllOpenedWebview(sessionId: string = "DefaultSession"): Promise { + return this.requestRemoteRpcMethod("v1/E2ETestHelperService/CancelAllOpenedWebview", [ + { + SessionId: sessionId, + }, + ]); + } + + SetDefaultAnonymousCredentialByPath( + pathToConnectorProject: string, + connectorName: string, + sessionId: string = "DefaultSession", + ): Promise { + return this.requestRemoteRpcMethod("v1/PqTestService/SetCredentialFromCreateAuthState", [ + { + SessionId: sessionId, + PathToConnector: path.join( + pathToConnectorProject, + "bin", + "AnyCPU", + "Debug", + `${connectorName}.mez`, + ), + PathToQueryFile: path.join(pathToConnectorProject, `${connectorName}.query.pq`), + AuthenticationKind: "Anonymous", + }, + ]); + } + } + + export async function createPqServiceHostClientLiteAndConnect(pqSdkToolFullPath: string): Promise { + const theClient: E2EPqTestClient = new E2EPqTestClient({ + appendInfoLine(value: string): void { + // these console logs are harmless and were merely used within e2e tests + console.log(`[E2E_PQServiceHostList::Info] ${value}`); + }, + appendErrorLine(value: string): void { + // these console logs are harmless and were merely used within e2e tests + console.log(`[E2E_PQServiceHostList::Error] ${value}`); + }, + }); + + // subscribe READY event first before starting the pqServiceHost + const connectedPromise: Promise = fromEvents(theClient, [READY], [DISCONNECTED]); + + // pause few sec to let vsc sdk ext boot pqServiceHost + await delay(3e3); + + theClient.onPowerQueryTestLocationChangedByConfig({ PQTestLocation: pqSdkToolFullPath }); + + await connectedPromise; + + expect( + theClient.pqServiceHostConnected, + "E2E_PQServiceHostLite should have connected to the running pqServiceHost but it was not", + ).to.be.true; + + theClient.subscribeNotificationEventEmitter(); + + return theClient; + } +} diff --git a/src/test/utils/sideBarUtils.ts b/src/test/utils/sideBarUtils.ts index 0853772..3449e4e 100644 --- a/src/test/utils/sideBarUtils.ts +++ b/src/test/utils/sideBarUtils.ts @@ -36,6 +36,13 @@ export module VscSideBars { return theFile?.click(); } + export async function clickListCredentials(pqSdkViewSection: ViewSection): Promise { + const listCredentialsTitle = extensionI18n["PQSdk.lifecycleTreeView.item.listCredentials.title"]; + const listCredentialsItem = await pqSdkViewSection.findItem(listCredentialsTitle); + listCredentialsItem?.click(); + await delay(750); + } + export async function clickClearAllCredentials(pqSdkViewSection: ViewSection): Promise { const deleteAllCredentialsTitle = extensionI18n["PQSdk.lifecycleTreeView.item.deleteAllCredentials.title"]; const clearAllCredentialsItem = await pqSdkViewSection.findItem(deleteAllCredentialsTitle);