diff --git a/client/src/connection/com/index.ts b/client/src/connection/com/index.ts index de9898b1e..7b751246a 100644 --- a/client/src/connection/com/index.ts +++ b/client/src/connection/com/index.ts @@ -1,13 +1,11 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri, l10n, window, workspace } from "vscode"; +import { ExtensionContext, Uri, l10n, window, workspace } from "vscode"; import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import { resolve } from "path"; -import { v4 } from "uuid"; import { BaseConfig, RunResult } from ".."; -import { extensionContext } from "../../node/extension"; import { Session } from "../session"; import { scriptContent } from "./script"; @@ -38,11 +36,13 @@ export class COMSession extends Session { private _runResolve: ((value?) => void) | undefined; private _runReject: ((reason?) => void) | undefined; private _workDirectory: string; - private password: string; + private _password: string; + private _context: ExtensionContext; - constructor() { + constructor(extensionContext: ExtensionContext) { super(); - this.password = ""; + this._password = ""; + this._context = extensionContext; } public set config(value: Config) { @@ -54,83 +54,82 @@ export class COMSession extends Session { * @returns void promise. */ public setup = async (): Promise => { - const password = await this.fetchPassword(); - return new Promise((resolve, reject) => { + const setupPromise = new Promise((resolve, reject) => { this._runResolve = resolve; this._runReject = reject; + }); - if (this._shellProcess && !this._shellProcess.killed) { - resolve(); - return; // manually terminate to avoid executing the code below - } + if (this._shellProcess && !this._shellProcess.killed) { + this._runResolve(); + return; // manually terminate to avoid executing the code below + } + + this._shellProcess = spawn("powershell.exe /nologo -Command -", { + shell: true, + env: process.env, + }); + this._shellProcess.stdout.on("data", this.onShellStdOut); + this._shellProcess.stderr.on("data", this.onShellStdErr); + this._shellProcess.stdin.write(scriptContent + "\n", this.onWriteComplete); + this._shellProcess.stdin.write( + "$runner = New-Object -TypeName SASRunner\n", + this.onWriteComplete, + ); - this._shellProcess = spawn("powershell.exe /nologo -Command -", { - shell: true, - env: process.env, - }); - this._shellProcess.stdout.on("data", this.onShellStdOut); - this._shellProcess.stderr.on("data", this.onShellStdErr); + /* + * There are cases where the higher level run command will invoke setup multiple times. + * Avoid re-initializing the session when this happens. In a first run scenario a work dir + * will not exist. The work dir should only be deleted when close is invoked. + */ + if (!this._workDirectory) { + const { host, port, protocol, username } = this._config; + this._shellProcess.stdin.write(`$profileHost = "${host}"\n`); + this._shellProcess.stdin.write(`$port = ${port}\n`); + this._shellProcess.stdin.write(`$protocol = ${protocol}\n`); + this._shellProcess.stdin.write(`$username = "${username}"\n`); + const password = await this.fetchPassword(); + this._shellProcess.stdin.write(`$password = "${password}"\n`); + this._shellProcess.stdin.write( + `$serverName = "${ + protocol === ITCProtocol.COM ? "ITC Local" : "ITC IOM Bridge" + }"\n`, + ); this._shellProcess.stdin.write( - scriptContent + "\n", + `$runner.Setup($profileHost,$username,$password,$port,$protocol,$serverName)\n`, this.onWriteComplete, ); this._shellProcess.stdin.write( - "$runner = New-Object -TypeName SASRunner\n", + "$runner.ResolveSystemVars()\n", this.onWriteComplete, ); - /* - * There are cases where the higher level run command will invoke setup multiple times. - * Avoid re-initializing the session when this happens. In a first run scenario a work dir - * will not exist. The work dir should only be deleted when close is invoked. - */ - if (!this._workDirectory) { - const { host, port, protocol, username } = this._config; - this._shellProcess.stdin.write(`$profileHost = "${host}"\n`); - this._shellProcess.stdin.write(`$port = ${port}\n`); - this._shellProcess.stdin.write(`$protocol = ${protocol}\n`); - this._shellProcess.stdin.write(`$username = "${username}"\n`); - this._shellProcess.stdin.write(`$password = "${password}"\n`); - this._shellProcess.stdin.write( - `$serverName = "${ - protocol === ITCProtocol.COM ? "ITC Local" : "ITC IOM Bridge" - }"\n`, - ); - this._shellProcess.stdin.write( - `$runner.Setup($profileHost,$username,$password,$port,$protocol,$serverName)\n`, - this.onWriteComplete, - ); + if (this._config.sasOptions?.length > 0) { + const sasOptsInput = `$sasOpts=${this.formatSASOptions( + this._config.sasOptions, + )}\n`; + this._shellProcess.stdin.write(sasOptsInput, this.onWriteComplete); this._shellProcess.stdin.write( - "$runner.ResolveSystemVars()\n", + `$runner.SetOptions($sasOpts)\n`, this.onWriteComplete, ); - - if (this._config.sasOptions?.length > 0) { - const sasOptsInput = `$sasOpts=${this.formatSASOptions( - this._config.sasOptions, - )}\n`; - this._shellProcess.stdin.write(sasOptsInput, this.onWriteComplete); - this._shellProcess.stdin.write( - `$runner.SetOptions($sasOpts)\n`, - this.onWriteComplete, - ); - } } + } - // free objects in the scripting env - process.on("exit", async () => { - close(); - }); + // free objects in the scripting env + process.on("exit", async () => { + close(); }); + + return setupPromise; }; private storePassword = async () => { - await extensionContext.secrets.store(PASSWORD_KEY, this.password); + await this._context.secrets.store(PASSWORD_KEY, this._password); }; private clearPassword = async () => { - await extensionContext.secrets.delete(PASSWORD_KEY); - this.password = ""; + await this._context.secrets.delete(PASSWORD_KEY); + this._password = ""; }; private fetchPassword = async (): Promise => { @@ -138,12 +137,12 @@ export class COMSession extends Session { return ""; } - const storedPassword = await extensionContext.secrets.get(PASSWORD_KEY); + const storedPassword = await this._context.secrets.get(PASSWORD_KEY); if (storedPassword) { return storedPassword; } - this.password = + this._password = (await window.showInputBox({ ignoreFocusOut: true, password: true, @@ -151,7 +150,7 @@ export class COMSession extends Session { title: l10n.t("Enter your password"), })) || ""; - return this.password; + return this._password; }; /** @@ -161,29 +160,31 @@ export class COMSession extends Session { * @returns A promise that eventually resolves to contain the given {@link RunResult} for the input code execution. */ public run = async (code: string): Promise => { - return new Promise((resolve, reject) => { + const runPromise = new Promise((resolve, reject) => { this._runResolve = resolve; this._runReject = reject; + }); - //write ODS output to work so that the session cleans up after itself - const codeWithODSPath = code.replace( - "ods html5;", - `ods html5 path="${this._workDirectory}";`, - ); + //write ODS output to work so that the session cleans up after itself + const codeWithODSPath = code.replace( + "ods html5;", + `ods html5 path="${this._workDirectory}";`, + ); - //write an end mnemonic so that the handler knows when execution has finished - const codeWithEnd = `${codeWithODSPath}\n%put ${endCode};`; - const codeToRun = `$code=\n@'\n${codeWithEnd}\n'@\n`; + //write an end mnemonic so that the handler knows when execution has finished + const codeWithEnd = `${codeWithODSPath}\n%put ${endCode};`; + const codeToRun = `$code=\n@'\n${codeWithEnd}\n'@\n`; - this._shellProcess.stdin.write(codeToRun); - this._shellProcess.stdin.write(`$runner.Run($code)\n`, async (error) => { - if (error) { - this._runReject(error); - } + this._shellProcess.stdin.write(codeToRun); + this._shellProcess.stdin.write(`$runner.Run($code)\n`, async (error) => { + if (error) { + this._runReject(error); + } - await this.fetchLog(); - }); + await this.fetchLog(); }); + + return runPromise; }; /** @@ -249,6 +250,11 @@ do { "There was an error executing the SAS Program.\nSee console log for more details.", ), ); + // If we encountered an error in setup, we need to go through everything again + if (/Setup error/.test(msg)) { + this._shellProcess.kill(); + this._workDirectory = undefined; + } }; /** @@ -302,10 +308,15 @@ do { * Fetches the ODS output results for the latest html results file. */ private fetchResults = async () => { - await workspace.fs.createDirectory(extensionContext.globalStorageUri); + try { + await workspace.fs.readDirectory(this._context.globalStorageUri); + } catch (e) { + await workspace.fs.createDirectory(this._context.globalStorageUri); + } + const outputFileUri = Uri.joinPath( - extensionContext.globalStorageUri, - `${v4()}.htm`, + this._context.globalStorageUri, + `${this._html5FileName}.htm`, ); this._shellProcess.stdin.write( ` @@ -325,7 +336,6 @@ do { clearInterval(interval); resolve(file); } catch (e) { - // Intentionally blank if (Date.now() - maxTime > start) { clearInterval(interval); resolve(null); @@ -357,6 +367,7 @@ do { export const getSession = ( c: Partial, protocol: ITCProtocol, + extensionContext: ExtensionContext, ): Session => { const defaults = { host: "localhost", @@ -366,7 +377,7 @@ export const getSession = ( }; if (!sessionInstance) { - sessionInstance = new COMSession(); + sessionInstance = new COMSession(extensionContext); } sessionInstance.config = { ...defaults, ...c }; return sessionInstance; diff --git a/client/src/connection/index.ts b/client/src/connection/index.ts index 2f30e58b9..6e522222f 100644 --- a/client/src/connection/index.ts +++ b/client/src/connection/index.ts @@ -9,6 +9,7 @@ import { ViyaProfile, toAutoExecLines, } from "../components/profile"; +import { extensionContext } from "../node/extension"; import { ITCProtocol, getSession as getITCSession } from "./com"; import { Config as RestConfig, getSession as getRestSession } from "./rest"; import { @@ -54,9 +55,17 @@ export function getSession(): Session { case ConnectionType.SSH: return getSSHSession(validProfile.profile); case ConnectionType.COM: - return getITCSession(validProfile.profile, ITCProtocol.COM); + return getITCSession( + validProfile.profile, + ITCProtocol.COM, + extensionContext, + ); case ConnectionType.IOM: - return getITCSession(validProfile.profile, ITCProtocol.IOMBridge); + return getITCSession( + validProfile.profile, + ITCProtocol.IOMBridge, + extensionContext, + ); default: throw new Error( l10n.t("Invalid connectionType. Check Profile settings."), diff --git a/client/test/connection/com/index.test.ts b/client/test/connection/com/index.test.ts index ac314e80e..c540477ff 100644 --- a/client/test/connection/com/index.test.ts +++ b/client/test/connection/com/index.test.ts @@ -1,11 +1,16 @@ +import * as vscode from "vscode"; + import { expect } from "chai"; import proc from "child_process"; -import fs from "fs"; +import { unlinkSync, writeFileSync } from "fs"; +import { join } from "path"; import { SinonSandbox, SinonStub, createSandbox } from "sinon"; +import { stubInterface } from "ts-sinon"; -import { getSession } from "../../../src/connection/com"; +import { ITCProtocol, getSession } from "../../../src/connection/com"; import { scriptContent } from "../../../src/connection/com/script"; import { Session } from "../../../src/connection/session"; +import { extensionContext } from "../../../src/node/extension"; describe("COM connection", () => { let sandbox: SinonSandbox; @@ -20,9 +25,7 @@ describe("COM connection", () => { let onDataCallback; beforeEach(() => { - sandbox = createSandbox({ - useFakeTimers: { shouldClearNativeTimers: true }, - }); + sandbox = createSandbox({}); spawnStub = sandbox.stub(proc, "spawn"); @@ -52,7 +55,14 @@ describe("COM connection", () => { host: "localhost", }; - session = getSession(config); + const secretStore = stubInterface(); + const stubbedExtensionContext: vscode.ExtensionContext = { + ...extensionContext, + globalStorageUri: vscode.Uri.from({ scheme: "file", path: __dirname }), + secrets: secretStore, + }; + + session = getSession(config, ITCProtocol.COM, stubbedExtensionContext); session.onLogFn = () => { return; }; @@ -85,35 +95,43 @@ describe("COM connection", () => { expect(stdinStub.args[2][0]).to.deep.equal( `$profileHost = "localhost"\n`, ); - expect(stdinStub.args[3][0]).to.deep.equal( - "$runner.Setup($profileHost)\n", + expect(stdinStub.args[3][0]).to.deep.equal(`$port = 0\n`); + expect(stdinStub.args[4][0]).to.deep.equal(`$protocol = 0\n`); + expect(stdinStub.args[5][0]).to.deep.equal(`$username = ""\n`); + expect(stdinStub.args[6][0]).to.deep.equal(`$password = ""\n`); + expect(stdinStub.args[7][0]).to.deep.equal(`$serverName = "ITC Local"\n`); + expect(stdinStub.args[8][0]).to.deep.equal( + "$runner.Setup($profileHost,$username,$password,$port,$protocol,$serverName)\n", ); - expect(stdinStub.args[4][0]).to.deep.equal( + expect(stdinStub.args[9][0]).to.deep.equal( "$runner.ResolveSystemVars()\n", ); - expect(stdinStub.args[5][0]).to.deep.equal( + expect(stdinStub.args[10][0]).to.deep.equal( `$sasOpts=@("-PAGESIZE=MAX")\n`, ); - expect(stdinStub.args[6][0]).to.deep.equal( + expect(stdinStub.args[11][0]).to.deep.equal( `$runner.SetOptions($sasOpts)\n`, ); }); }); describe("run", () => { - let fsStub: SinonStub; + const html5 = '
'; beforeEach(async () => { - fsStub = sandbox.stub(fs, "readFileSync"); - + writeFileSync(join(__dirname, "sashtml.htm"), html5); const setupPromise = session.setup(); onDataCallback(Buffer.from(`WORKDIR=/work/dir`)); await setupPromise; }); + afterEach(() => { + try { + unlinkSync(join(__dirname, "sashtml.htm")); + } catch (e) { + // Intentionally blank + } + }); it("calls run function from script", async () => { - const html5 = '
'; - fsStub.returns(html5); - const runPromise = session.run( "ods html5;\nproc print data=sashelp.cars;\nrun;", ); @@ -127,11 +145,11 @@ describe("COM connection", () => { expect(runResult.html5).to.equal(html5); expect(runResult.title).to.equal("Result"); - expect(stdinStub.args[7][0]).to.deep.equal( + expect(stdinStub.args[12][0]).to.deep.equal( `$code=\n@'\nods html5 path="/work/dir";\nproc print data=sashelp.cars;\nrun;\n%put --vscode-sas-extension-submit-end--;\n'@\n`, ); - expect(stdinStub.args[8][0]).to.deep.equal(`$runner.Run($code)\n`); + expect(stdinStub.args[13][0]).to.deep.equal(`$runner.Run($code)\n`); }); });