From 2a681605fa87728090158cff16b2b8f4412a9552 Mon Sep 17 00:00:00 2001 From: Aditya Bist Date: Fri, 17 Dec 2021 10:55:42 -0800 Subject: [PATCH] Updated credentials system (#17888) * linting * added flags * remove testing values * format * format doc * tested in linux * remove unused interface * comments * review comments * clean imports * pr comments * format doc * changed promise location * insiders december * pr comments * test ado change * fix test * comment out code for hygiene * remove unused imports * test creds from client only * remove unused import * trying enabling keytar * trying enabling keytar * disable in correct script * print statements * remove print statements * check mock output * add linux check * remove print statements --- .../src/credentialstore/credentialstore.ts | 70 ++------ .../credentialstore/sqlCredentialService.ts | 91 +++++++++++ extensions/mssql/src/sqlToolsServer.ts | 4 +- extensions/mssql/src/utils.ts | 15 +- .../src/jupyter/jupyterSessionManager.ts | 2 + .../src/test/model/sessionManager.test.ts | 150 +++++++++--------- scripts/test-extensions-unit.bat | 2 +- scripts/test-extensions-unit.js | 2 +- scripts/test-extensions-unit.sh | 2 +- scripts/test-integration.sh | 2 +- 10 files changed, 198 insertions(+), 142 deletions(-) create mode 100644 extensions/mssql/src/credentialstore/sqlCredentialService.ts diff --git a/extensions/mssql/src/credentialstore/credentialstore.ts b/extensions/mssql/src/credentialstore/credentialstore.ts index 125c509a7d..76237767a2 100644 --- a/extensions/mssql/src/credentialstore/credentialstore.ts +++ b/extensions/mssql/src/credentialstore/credentialstore.ts @@ -3,63 +3,13 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { SqlOpsDataClient, ClientOptions, SqlOpsFeature } from 'dataprotocol-client'; +import { SqlOpsDataClient, ClientOptions } from 'dataprotocol-client'; import { IConfig } from '@microsoft/ads-service-downloader'; -import { ServerOptions, RPCMessageType, ClientCapabilities, ServerCapabilities, TransportKind } from 'vscode-languageclient'; -import { Disposable } from 'vscode'; -import * as UUID from 'vscode-languageclient/lib/utils/uuid'; -import * as azdata from 'azdata'; - -import * as Contracts from './contracts'; +import { ServerOptions, TransportKind } from 'vscode-languageclient'; import * as Constants from './constants'; import * as Utils from '../utils'; - -class CredentialsFeature extends SqlOpsFeature { - - private static readonly messagesTypes: RPCMessageType[] = [ - Contracts.DeleteCredentialRequest.type, - Contracts.SaveCredentialRequest.type, - Contracts.ReadCredentialRequest.type - ]; - - constructor(client: SqlOpsDataClient) { - super(client, CredentialsFeature.messagesTypes); - } - - fillClientCapabilities(capabilities: ClientCapabilities): void { - Utils.ensure(Utils.ensure(capabilities, 'credentials')!, 'credentials')!.dynamicRegistration = true; - } - - initialize(capabilities: ServerCapabilities): void { - this.register(this.messages, { - id: UUID.generateUuid(), - registerOptions: undefined - }); - } - - protected registerProvider(options: any): Disposable { - const client = this._client; - - let readCredential = (credentialId: string): Thenable => { - return client.sendRequest(Contracts.ReadCredentialRequest.type, { credentialId, password: undefined }); - }; - - let saveCredential = (credentialId: string, password: string): Thenable => { - return client.sendRequest(Contracts.SaveCredentialRequest.type, { credentialId, password }); - }; - - let deleteCredential = (credentialId: string): Thenable => { - return client.sendRequest(Contracts.DeleteCredentialRequest.type, { credentialId, password: undefined }); - }; - - return azdata.credentials.registerProvider({ - deleteCredential, - readCredential, - saveCredential, - handle: 0 - }); - } -} +import { SqlCredentialService } from './sqlCredentialService'; +import { AppContext } from '../appContext'; /** * Implements a credential storage for Windows, Mac (darwin), or Linux. @@ -69,18 +19,24 @@ class CredentialsFeature extends SqlOpsFeature { export class CredentialStore { private _client: SqlOpsDataClient; private _config: IConfig; + private _logPath: string; - constructor(private logPath: string, baseConfig: IConfig) { + constructor( + private context: AppContext, + baseConfig: IConfig + ) { if (baseConfig) { this._config = JSON.parse(JSON.stringify(baseConfig)); this._config.executableFiles = ['MicrosoftSqlToolsCredentials.exe', 'MicrosoftSqlToolsCredentials']; } + this.context = context; + this._logPath = this.context.extensionContext.logPath; } public async start(): Promise { let clientOptions: ClientOptions = { providerId: Constants.providerId, - features: [CredentialsFeature] + features: [SqlCredentialService.asFeature(this.context)] }; const serverPath = await Utils.getOrDownloadServer(this._config); const serverOptions = this.generateServerOptions(serverPath); @@ -95,7 +51,7 @@ export class CredentialStore { } private generateServerOptions(executablePath: string): ServerOptions { - let launchArgs = Utils.getCommonLaunchArgsAndCleanupOldLogFiles(this.logPath, 'credentialstore.log', executablePath); + let launchArgs = Utils.getCommonLaunchArgsAndCleanupOldLogFiles(this._logPath, 'credentialstore.log', executablePath); return { command: executablePath, args: launchArgs, transport: TransportKind.stdio }; } } diff --git a/extensions/mssql/src/credentialstore/sqlCredentialService.ts b/extensions/mssql/src/credentialstore/sqlCredentialService.ts new file mode 100644 index 0000000000..71b24b50b3 --- /dev/null +++ b/extensions/mssql/src/credentialstore/sqlCredentialService.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AppContext } from '../appContext'; +import { SqlOpsDataClient, ISqlOpsFeature, SqlOpsFeature } from 'dataprotocol-client'; +import * as Utils from '../utils'; +import { ClientCapabilities, RPCMessageType, ServerCapabilities } from 'vscode-languageclient'; +import * as Contracts from './contracts'; +import { Disposable, SecretStorage } from 'vscode'; +import * as azdata from 'azdata'; +import * as UUID from 'vscode-languageclient/lib/utils/uuid'; + +export class SqlCredentialService extends SqlOpsFeature { + + private static readonly messagesTypes: RPCMessageType[] = [ + Contracts.DeleteCredentialRequest.type, + Contracts.SaveCredentialRequest.type, + Contracts.ReadCredentialRequest.type + ]; + + public static asFeature(context: AppContext): ISqlOpsFeature { + return class extends SqlCredentialService { + private _secretStorage: SecretStorage; + + constructor(client: SqlOpsDataClient) { + super(context, client); + this._secretStorage = context.extensionContext.secrets; + } + + override fillClientCapabilities(capabilities: ClientCapabilities): void { + Utils.ensure(Utils.ensure(capabilities, 'credentials')!, 'credentials')!.dynamicRegistration = true; + } + + override async initialize(capabilities: ServerCapabilities): Promise { + this.register(this.messages, { + id: UUID.generateUuid(), + registerOptions: undefined + }); + } + + + protected override registerProvider(options: any): Disposable { + let readCredential = async (credentialId: string): Promise => { + return this._client.sendRequest(Contracts.ReadCredentialRequest.type, { credentialId, password: undefined }); + }; + + let saveCredential = async (credentialId: string, password: string): Promise => { + if (Utils.isLinux) { + /** + * This is only done for linux because this is going to be + * the default credential system for linux in the next release + */ + await this._secretStorage.store(credentialId, password); + } + return this._client.sendRequest(Contracts.SaveCredentialRequest.type, { credentialId, password }); + }; + + let deleteCredential = async (credentialId: string): Promise => { + if (Utils.isLinux) { + try { + await this._secretStorage.delete(credentialId); + } catch (e) { + console.log('credential does not exist in native secret store'); + } + } + return this._client.sendRequest(Contracts.DeleteCredentialRequest.type, { credentialId, password: undefined }); + }; + + return azdata.credentials.registerProvider({ + deleteCredential, + readCredential, + saveCredential, + handle: 0 + }); + } + }; + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { } + + initialize(capabilities: ServerCapabilities): void { } + + protected registerProvider(options: any): Disposable { return undefined; } + + private constructor(context: AppContext, protected readonly client: SqlOpsDataClient) { + super(client, SqlCredentialService.messagesTypes); + } + +} diff --git a/extensions/mssql/src/sqlToolsServer.ts b/extensions/mssql/src/sqlToolsServer.ts index e4b6738958..c6d333d48d 100644 --- a/extensions/mssql/src/sqlToolsServer.ts +++ b/extensions/mssql/src/sqlToolsServer.ts @@ -25,6 +25,7 @@ import { LanguageExtensionService } from './languageExtension/languageExtensionS import { SqlAssessmentService } from './sqlAssessment/sqlAssessmentService'; import { NotebookConvertService } from './notebookConvert/notebookConvertService'; import { SqlMigrationService } from './sqlMigration/sqlMigrationService'; +import { SqlCredentialService } from './credentialstore/sqlCredentialService'; const localize = nls.loadMessageBundle(); const outputChannel = vscode.window.createOutputChannel(Constants.serviceName); @@ -86,7 +87,7 @@ export class SqlToolsServer { } private activateFeatures(context: AppContext): Promise { - const credsStore = new CredentialStore(context.extensionContext.logPath, this.config); + const credsStore = new CredentialStore(context, this.config); const resourceProvider = new AzureResourceProvider(context.extensionContext.logPath, this.config); this.disposables.push(credsStore); this.disposables.push(resourceProvider); @@ -163,6 +164,7 @@ function getClientOptions(context: AppContext): ClientOptions { NotebookConvertService.asFeature(context), ProfilerFeature, SqlMigrationService.asFeature(context), + SqlCredentialService.asFeature(context), TableDesignerFeature ], outputChannel: new CustomOutputChannel() diff --git a/extensions/mssql/src/utils.ts b/extensions/mssql/src/utils.ts index 598c94988f..8f3a6e98ca 100644 --- a/extensions/mssql/src/utils.ts +++ b/extensions/mssql/src/utils.ts @@ -21,6 +21,12 @@ const configLogFilesRemovalLimit = 'logFilesRemovalLimit'; const extensionConfigSectionName = 'mssql'; const configLogDebugInfo = 'logDebugInfo'; +/** + * + * @returns Whether the current OS is linux or not + */ +export const isLinux = os.platform() === 'linux'; + // The function is a duplicate of \src\paths.js. IT would be better to import path.js but it doesn't // work for now because the extension is running in different process. export function getAppDataPath() { @@ -64,8 +70,7 @@ export function getConfigLogFilesRemovalLimit(): number { let config = getConfiguration(); if (config) { return Number((config[configLogFilesRemovalLimit]).toFixed(0)); - } - else { + } else { return undefined; } } @@ -74,8 +79,7 @@ export function getConfigLogRetentionSeconds(): number { let config = getConfiguration(); if (config) { return Number((config[configLogRetentionMinutes] * 60).toFixed(0)); - } - else { + } else { return undefined; } } @@ -84,8 +88,7 @@ export function getConfigTracingLevel(): string { let config = getConfiguration(); if (config) { return config[configTracingLevel]; - } - else { + } else { return undefined; } } diff --git a/extensions/notebook/src/jupyter/jupyterSessionManager.ts b/extensions/notebook/src/jupyter/jupyterSessionManager.ts index a0e80f16d9..3010ade62d 100644 --- a/extensions/notebook/src/jupyter/jupyterSessionManager.ts +++ b/extensions/notebook/src/jupyter/jupyterSessionManager.ts @@ -323,6 +323,7 @@ export class JupyterSession implements nb.ISession { } } else { clusterController = await getClusterController(controllerEndpoint.endpoint, 'integrated'); + } let gatewayEndpoint: bdc.IEndpointModel = endpoints?.find(ep => ep.name.toLowerCase() === KNOX_ENDPOINT_GATEWAY); @@ -340,6 +341,7 @@ export class JupyterSession implements nb.ISession { Logger.log(`Parsed knox host and port ${JSON.stringify(gatewayHostAndPort)}`); connectionProfile.options[KNOX_ENDPOINT_SERVER] = gatewayHostAndPort.host; connectionProfile.options[KNOX_ENDPOINT_PORT] = gatewayHostAndPort.port; + } else { throw new Error(providerNotValidError); diff --git a/extensions/notebook/src/test/model/sessionManager.test.ts b/extensions/notebook/src/test/model/sessionManager.test.ts index 431a8e56ff..29b95e51cf 100644 --- a/extensions/notebook/src/test/model/sessionManager.test.ts +++ b/extensions/notebook/src/test/model/sessionManager.test.ts @@ -23,7 +23,7 @@ import { ExtensionContextHelper } from '../../common/extensionContextHelper'; import { AppContext } from '../../common/appContext'; import uuid = require('uuid'); -export class TestClusterController implements bdc.IClusterController { +class TestClusterController implements bdc.IClusterController { getClusterConfig(): Promise { return Promise.resolve({}); } @@ -254,80 +254,82 @@ describe('Jupyter Session', function (): void { }); it('should configure connection correctly for MSSQL and SqlLogin auth type', async function (): Promise { - let connectionProfile: IConnectionProfile = { - authenticationType: '', - connectionName: '', - databaseName: '', - id: 'id', - providerName: 'MSSQL', - options: { - authenticationType: 'SqlLogin', - }, - password: '', - savePassword: false, - saveProfile: false, - serverName: '', - userName: '' - }; - let futureMock = TypeMoq.Mock.ofType(FutureStub); - let kernelMock = TypeMoq.Mock.ofType(KernelStub); - kernelMock.setup(k => k.name).returns(() => 'spark'); - kernelMock.setup(m => m.requestExecute(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => futureMock.object); - mockJupyterSession.setup(s => s.kernel).returns(() => kernelMock.object); - let credentials = { [ConnectionOptionSpecialType.password]: 'password' }; - sinon.stub(connection, 'getCredentials').returns(Promise.resolve(credentials)); + const isLinux = os.platform() === 'linux'; + if (!isLinux) { + let connectionProfile: IConnectionProfile = { + authenticationType: '', + connectionName: '', + databaseName: '', + id: 'id', + providerName: 'MSSQL', + options: { + authenticationType: 'SqlLogin', + }, + password: '', + savePassword: false, + saveProfile: false, + serverName: '', + userName: '' + }; + let futureMock = TypeMoq.Mock.ofType(FutureStub); + let kernelMock = TypeMoq.Mock.ofType(KernelStub); + kernelMock.setup(k => k.name).returns(() => 'spark'); + kernelMock.setup(m => m.requestExecute(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => futureMock.object); + mockJupyterSession.setup(s => s.kernel).returns(() => kernelMock.object); + let creds = { [ConnectionOptionSpecialType.password]: 'password' }; + sinon.stub(connection, 'getCredentials').returns(Promise.resolve(creds)); - // Set up connection info to big data cluster - const mockServerInfo: ServerInfo = { - serverMajorVersion: 0, - serverMinorVersion: 0, - serverReleaseVersion: 0, - engineEditionId: 0, - serverVersion: '', - serverLevel: '', - serverEdition: '', - isCloud: false, - azureVersion: 0, - osVersion: '', - cpuCount: 0, - physicalMemoryInMb: -1, - options: { - isBigDataCluster: true - } - }; - const mockGatewayEndpoint: bdc.IEndpointModel = { - name: 'gateway', - description: '', - endpoint: '', - protocol: '', - }; - const mockControllerEndpoint: bdc.IEndpointModel = { - name: 'controller', - description: '', - endpoint: '', - protocol: '', - }; - const mockHostAndIp: utils.HostAndIp = { - host: '127.0.0.1', - port: '1337' - }; - const mockClustercontroller = new TestClusterController(); - mockClustercontroller.username = 'admin'; - mockClustercontroller.password = uuid.v4(); - let mockBdcExtension: TypeMoq.IMock = TypeMoq.Mock.ofType(); - let mockExtension: TypeMoq.IMock> = TypeMoq.Mock.ofType>(); - mockBdcExtension.setup(m => m.getClusterController(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => mockClustercontroller); - mockBdcExtension.setup((m: any) => m.then).returns(() => mockBdcExtension); - mockExtension.setup(m => m.activate()).returns(() => Promise.resolve(mockBdcExtension.object)); - mockExtension.setup((m: any) => m.then).returns(() => mockExtension); - sinon.stub(vscode.extensions, 'getExtension').returns(mockExtension.object); - sinon.stub(connection, 'getServerInfo').returns(Promise.resolve(mockServerInfo)); - sinon.stub(utils, 'getClusterEndpoints').returns([mockGatewayEndpoint, mockControllerEndpoint]); - sinon.stub(utils, 'getHostAndPortFromEndpoint').returns(mockHostAndIp); - - await session.configureConnection(connectionProfile); - should(connectionProfile.options['host']).equal(mockHostAndIp.host); - should(connectionProfile.options['knoxport']).equal(mockHostAndIp.port); + // Set up connection info to big data cluster + const mockServerInfo: ServerInfo = { + serverMajorVersion: 0, + serverMinorVersion: 0, + serverReleaseVersion: 0, + engineEditionId: 0, + serverVersion: '', + serverLevel: '', + serverEdition: '', + isCloud: false, + azureVersion: 0, + osVersion: '', + cpuCount: 0, + physicalMemoryInMb: -1, + options: { + isBigDataCluster: true + } + }; + const mockGatewayEndpoint: bdc.IEndpointModel = { + name: 'gateway', + description: '', + endpoint: '', + protocol: '', + }; + const mockControllerEndpoint: bdc.IEndpointModel = { + name: 'controller', + description: '', + endpoint: '', + protocol: '', + }; + const mockHostAndIp: utils.HostAndIp = { + host: '127.0.0.1', + port: '1337' + }; + const mockClustercontroller = new TestClusterController(); + mockClustercontroller.username = 'admin'; + mockClustercontroller.password = uuid.v4(); + let mockBdcExtension: TypeMoq.IMock = TypeMoq.Mock.ofType(); + let mockExtension: TypeMoq.IMock> = TypeMoq.Mock.ofType>(); + mockBdcExtension.setup(m => m.getClusterController(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => mockClustercontroller); + mockBdcExtension.setup((m: any) => m.then).returns(() => mockBdcExtension); + mockExtension.setup(m => m.activate()).returns(() => Promise.resolve(mockBdcExtension.object)); + mockExtension.setup((m: any) => m.then).returns(() => mockExtension); + sinon.stub(vscode.extensions, 'getExtension').returns(mockExtension.object); + sinon.stub(connection, 'getServerInfo').returns(Promise.resolve(mockServerInfo)); + sinon.stub(utils, 'getClusterEndpoints').returns([mockGatewayEndpoint, mockControllerEndpoint]); + sinon.stub(utils, 'getHostAndPortFromEndpoint').returns(mockHostAndIp); + await session.configureConnection(connectionProfile); + should(connectionProfile.options['host']).equal(mockHostAndIp.host); + should(connectionProfile.options['knoxport']).equal(mockHostAndIp.port); + } }); it('configure connection should throw error if there is no connection to big data cluster', async function (): Promise { diff --git a/scripts/test-extensions-unit.bat b/scripts/test-extensions-unit.bat index 4bc12be74c..ba9a218ec0 100755 --- a/scripts/test-extensions-unit.bat +++ b/scripts/test-extensions-unit.bat @@ -54,7 +54,7 @@ if "%ADS_TEST_GREP%" == "" ( SET ADS_TEST_INVERT_GREP=1 ) -set ALL_PLATFORMS_API_TESTS_EXTRA_ARGS=--disable-telemetry --crash-reporter-directory=%VSCODECRASHDIR% --no-cached-data --disable-updates --disable-keytar --user-data-dir=%VSCODEUSERDATADIR% --remote-debugging-port=9222 --extensions-dir=%VSCODEEXTENSIONSDIR% +set ALL_PLATFORMS_API_TESTS_EXTRA_ARGS=--disable-telemetry --crash-reporter-directory=%VSCODECRASHDIR% --no-cached-data --disable-updates --user-data-dir=%VSCODEUSERDATADIR% --remote-debugging-port=9222 --extensions-dir=%VSCODEEXTENSIONSDIR% echo *************************************************** echo *** starting admin tool extension windows tests *** diff --git a/scripts/test-extensions-unit.js b/scripts/test-extensions-unit.js index d5f16390a2..5655ca1f74 100644 --- a/scripts/test-extensions-unit.js +++ b/scripts/test-extensions-unit.js @@ -72,7 +72,7 @@ for (const ext of argv.extensions) { console.log(`VSCODEUSERDATADIR : ${VSCODEUSERDATADIR}`); console.log(`VSCODEEXTENSIONSDIR : ${VSCODEEXTENSIONSDIR}`); - const command = `${process.env.INTEGRATION_TEST_ELECTRON_PATH} ${LINUX_EXTRA_ARGS} --extensionDevelopmentPath=${path.join(__dirname, '..', 'extensions', ext)} --extensionTestsPath=${path.join(__dirname, '..', 'extensions', ext, 'out', 'test')} --user-data-dir=${VSCODEUSERDATADIR} --extensions-dir=${VSCODEEXTENSIONSDIR} --remote-debugging-port=9222 --disable-telemetry --disable-crash-reporter --disable-updates --no-cached-data --disable-keytar`; + const command = `${process.env.INTEGRATION_TEST_ELECTRON_PATH} ${LINUX_EXTRA_ARGS} --extensionDevelopmentPath=${path.join(__dirname, '..', 'extensions', ext)} --extensionTestsPath=${path.join(__dirname, '..', 'extensions', ext, 'out', 'test')} --user-data-dir=${VSCODEUSERDATADIR} --extensions-dir=${VSCODEEXTENSIONSDIR} --remote-debugging-port=9222 --disable-telemetry --disable-crash-reporter --disable-updates --no-cached-data`; console.log(`Command used: ${command}`); if (os.platform() === 'darwin') { diff --git a/scripts/test-extensions-unit.sh b/scripts/test-extensions-unit.sh index 52da0042c1..00b1b07a5a 100755 --- a/scripts/test-extensions-unit.sh +++ b/scripts/test-extensions-unit.sh @@ -87,7 +87,7 @@ cd $ROOT echo "VSCODEUSERDATADIR : '$VSCODEUSERDATADIR'" echo "VSCODEEXTDIR : '$VSCODEEXTDIR'" -ALL_PLATFORMS_API_TESTS_EXTRA_ARGS="--disable-telemetry --crash-reporter-directory=$VSCODECRASHDIR --no-cached-data --disable-updates --disable-keytar --disable-extensions --user-data-dir=$VSCODEUSERDATADIR --extensions-dir=$VSCODEEXTDIR" +ALL_PLATFORMS_API_TESTS_EXTRA_ARGS="--disable-telemetry --crash-reporter-directory=$VSCODECRASHDIR --no-cached-data --disable-updates --disable-extensions --user-data-dir=$VSCODEUSERDATADIR --extensions-dir=$VSCODEEXTDIR" echo *************************************************** echo *** starting admin tool extension windows tests *** diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index bdb7780a54..e71f9cebf9 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -66,7 +66,7 @@ after_suite # Tests in the extension host -ALL_PLATFORMS_API_TESTS_EXTRA_ARGS="--disable-telemetry --crash-reporter-directory=$VSCODECRASHDIR --no-cached-data --disable-updates --disable-keytar --disable-extensions --disable-workspace-trust --user-data-dir=$VSCODEUSERDATADIR" +ALL_PLATFORMS_API_TESTS_EXTRA_ARGS="--disable-telemetry --crash-reporter-directory=$VSCODECRASHDIR --no-cached-data --disable-updates --disable-extensions --disable-workspace-trust --user-data-dir=$VSCODEUSERDATADIR" # {{SQL CARBON EDIT}} Don't run tests for unused extensions # "$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $ROOT/extensions/vscode-api-tests/testWorkspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=$ROOT/extensions/vscode-api-tests --extensionTestsPath=$ROOT/extensions/vscode-api-tests/out/singlefolder-tests $ALL_PLATFORMS_API_TESTS_EXTRA_ARGS