diff --git a/package.json b/package.json index df4a748e..8e4ca4f1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "activationEvents": [ "onStartupFinished" ], + "extensionDependencies": [ + "salesforce.salesforcedx-vscode-core" + ], "main": "./out/extension.js", "l10n": "./l10n", "contributes": { diff --git a/src/extension.ts b/src/extension.ts index 0980bc88..ae27a1b3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,8 +9,23 @@ // Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; import * as onboardingWizard from './commands/wizard/onboardingWizard'; +import { CoreExtensionService } from './services/CoreExtensionService'; export function activate(context: vscode.ExtensionContext) { + // We need to do this first in case any other services need access to those provided by the core extension + try { + CoreExtensionService.loadDependencies(); + } catch (err) { + console.error(err); + vscode.window.showErrorMessage( + vscode.l10n.t( + 'Failed to activate the extension! Could not load required services from the Salesforce Extension Pack: {0}', + (err as Error).message + ) + ); + return; + } + onboardingWizard.registerCommand(context); onboardingWizard.onActivate(context); } diff --git a/src/services/CoreExtensionService.ts b/src/services/CoreExtensionService.ts new file mode 100644 index 00000000..a44d5b7e --- /dev/null +++ b/src/services/CoreExtensionService.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { extensions } from 'vscode'; +import { satisfies, valid } from 'semver'; +import type { + CoreExtensionApi, + WorkspaceContext +} from '../types'; +import { + CORE_EXTENSION_ID, + MINIMUM_REQUIRED_VERSION_CORE_EXTENSION +} from '../utils/constants'; + +const NOT_INITIALIZED_ERROR = 'CoreExtensionService not initialized'; +const CORE_EXTENSION_NOT_FOUND = 'Core extension not found'; +const WORKSPACE_CONTEXT_NOT_FOUND = 'Workspace Context not found'; +const coreExtensionMinRequiredVersionError = + 'You are running an older version of the Salesforce CLI Integration VSCode Extension. Please update the Salesforce Extension pack and try again.'; + +export class CoreExtensionService { + private static initialized = false; + private static workspaceContext: WorkspaceContext; + + static loadDependencies() { + if (!CoreExtensionService.initialized) { + const coreExtension = extensions.getExtension(CORE_EXTENSION_ID); + if (!coreExtension) { + throw new Error(CORE_EXTENSION_NOT_FOUND); + } + const coreExtensionVersion = coreExtension.packageJSON.version; + if ( + !CoreExtensionService.isAboveMinimumRequiredVersion( + MINIMUM_REQUIRED_VERSION_CORE_EXTENSION, + coreExtensionVersion + ) + ) { + throw new Error(coreExtensionMinRequiredVersionError); + } + + const coreExtensionApi = coreExtension.exports as CoreExtensionApi; + + CoreExtensionService.initializeWorkspaceContext( + coreExtensionApi?.services.WorkspaceContext + ); + + CoreExtensionService.initialized = true; + } + } + + private static initializeWorkspaceContext( + workspaceContext: WorkspaceContext | undefined + ) { + if (!workspaceContext) { + throw new Error(WORKSPACE_CONTEXT_NOT_FOUND); + } + CoreExtensionService.workspaceContext = + workspaceContext.getInstance(false); + } + + private static isAboveMinimumRequiredVersion( + minRequiredVersion: string, + actualVersion: string + ) { + // Check to see if version is in the expected MAJOR.MINOR.PATCH format + if (!valid(actualVersion)) { + console.debug( + 'Invalid version format found for the Core Extension.' + + `\nActual version: ${actualVersion}` + + `\nMinimum required version: ${minRequiredVersion}` + ); + } + return satisfies(actualVersion, '>=' + minRequiredVersion); + } + + static getWorkspaceContext(): WorkspaceContext { + if (CoreExtensionService.initialized) { + return CoreExtensionService.workspaceContext; + } + throw new Error(NOT_INITIALIZED_ERROR); + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 00000000..2db26498 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +export * from './CoreExtensionService'; diff --git a/src/test/TestHelper.ts b/src/test/TestHelper.ts index 7ea8d73c..550cc424 100644 --- a/src/test/TestHelper.ts +++ b/src/test/TestHelper.ts @@ -10,6 +10,8 @@ import { mkdtemp, rm, stat } from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import * as process from 'process'; +import sinon = require('sinon'); +import { WorkspaceUtils } from '../utils/workspaceUtils'; export class TempProjectDirManager { readonly projectDir: string; @@ -65,3 +67,13 @@ export function createPlatformAbsolutePath(...pathArgs: string[]): string { } return absPath; } + +// Create a stub of WorkspaceUtis.getWorkspaceDir() that returns a path to +// a temporary directory. +export function setupTempWorkspaceDirectoryStub( + projectDirManager: TempProjectDirManager +): sinon.SinonStub<[], string> { + const getWorkspaceDirStub = sinon.stub(WorkspaceUtils, 'getWorkspaceDir'); + getWorkspaceDirStub.returns(projectDirManager.projectDir); + return getWorkspaceDirStub; +} diff --git a/src/test/runTest.ts b/src/test/runTest.ts index d08ccdd4..ec408526 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -7,7 +7,13 @@ import * as path from 'path'; -import { runTests } from '@vscode/test-electron'; +import { + downloadAndUnzipVSCode, + resolveCliArgsFromVSCodeExecutablePath, + runTests +} from '@vscode/test-electron'; +import { spawnSync } from 'child_process'; +import { CORE_EXTENSION_ID } from '../utils/constants'; async function main() { try { @@ -24,8 +30,46 @@ async function main() { process.env['CODE_COVERAGE'] = '1'; } - // Download VS Code, unzip it and run the integration test - await runTests({ extensionDevelopmentPath, extensionTestsPath }); + // Download VS Code, unzip it and run the integration tests. + // NB: We'll use the 'stable' version of VSCode for tests, to catch + // potential incompatibilities in newer versions than the minmum we + // support in the `engines` section of our package. + const vscodeExecutablePath = await downloadAndUnzipVSCode('stable'); + const [cliPath, ...args] = + resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath); + + // Install the Salesforce Extensions, which is a pre-req for our + // extension. Bail if there's an error. + const installExtensionDepsOuput = spawnSync( + cliPath, + [ + ...args, + '--install-extension', + CORE_EXTENSION_ID + ], + { stdio: 'inherit', encoding: 'utf-8' } + ); + if (installExtensionDepsOuput.error) { + console.error( + `Error installing Salesforce Extensions in test: ${installExtensionDepsOuput.error.message}` + ); + throw installExtensionDepsOuput.error; + } + if ( + installExtensionDepsOuput.status && + installExtensionDepsOuput.status !== 0 + ) { + const installNonZeroError = `Install of Salesforce Extensions finished with status ${installExtensionDepsOuput.status}. See console output for more information.`; + console.error(installNonZeroError); + throw new Error(installNonZeroError); + } + + // All clear! Should be able to run the tests. + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + vscodeExecutablePath + }); } catch (err) { console.error('Failed to run tests', err); process.exit(1); diff --git a/src/test/suite/commands/wizard/lwcGenerationCommand.test.ts b/src/test/suite/commands/wizard/lwcGenerationCommand.test.ts index 88a50a6b..136232c8 100644 --- a/src/test/suite/commands/wizard/lwcGenerationCommand.test.ts +++ b/src/test/suite/commands/wizard/lwcGenerationCommand.test.ts @@ -8,12 +8,14 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as fs from 'fs'; +import * as path from 'path'; import { afterEach, beforeEach } from 'mocha'; import { LwcGenerationCommand, SObjectQuickActionStatus } from '../../../../commands/wizard/lwcGenerationCommand'; import { WorkspaceUtils } from '../../../../utils/workspaceUtils'; +import { TempProjectDirManager } from '../../../TestHelper'; suite('LWC Generation Command Test Suite', () => { beforeEach(function () {}); @@ -94,43 +96,57 @@ suite('LWC Generation Command Test Suite', () => { }); test('Should return error status for landing page with invalid json', async () => { + const dirManager = await TempProjectDirManager.createTempProjectDir(); const getWorkspaceDirStub = sinon.stub( WorkspaceUtils, 'getStaticResourcesDir' ); - getWorkspaceDirStub.returns(Promise.resolve('.')); - const fsAccess = sinon.stub(fs, 'access'); - fsAccess.returns(); - const invalidJsonFile = 'landing_page.json'; - const invalidJsonContents = 'invalid_json_here'; - fs.writeFileSync(invalidJsonFile, invalidJsonContents, 'utf8'); - - const status = await LwcGenerationCommand.getSObjectsFromLandingPage(); - - assert.ok(status.error && status.error.length > 0); - - fs.unlinkSync(invalidJsonFile); + try { + getWorkspaceDirStub.returns(Promise.resolve(dirManager.projectDir)); + const invalidJsonFile = 'landing_page.json'; + const invalidJsonContents = 'invalid_json_here'; + fs.writeFileSync( + path.join(dirManager.projectDir, invalidJsonFile), + invalidJsonContents, + 'utf8' + ); + + const status = + await LwcGenerationCommand.getSObjectsFromLandingPage(); + + assert.ok(status.error && status.error.length > 0); + } finally { + getWorkspaceDirStub.restore(); + await dirManager.removeDir(); + } }); test('Should return 2 sObjects', async () => { + const dirManager = await TempProjectDirManager.createTempProjectDir(); const getWorkspaceDirStub = sinon.stub( WorkspaceUtils, 'getStaticResourcesDir' ); - getWorkspaceDirStub.returns(Promise.resolve('.')); - const fsAccess = sinon.stub(fs, 'access'); - fsAccess.returns(); - const validJsonFile = 'landing_page.json'; - const jsonContents = - '{ "definition": "mcf/list", "properties": { "objectApiName": "Account" }, "nested": { "definition": "mcf/timedList", "properties": { "objectApiName": "Contact"} } }'; - fs.writeFileSync(validJsonFile, jsonContents, 'utf8'); - - const status = await LwcGenerationCommand.getSObjectsFromLandingPage(); - - assert.equal(status.sobjects.length, 2); - assert.equal(status.sobjects[0], 'Account'); - assert.equal(status.sobjects[1], 'Contact'); - - fs.unlinkSync(validJsonFile); + try { + getWorkspaceDirStub.returns(Promise.resolve(dirManager.projectDir)); + const validJsonFile = 'landing_page.json'; + const jsonContents = + '{ "definition": "mcf/list", "properties": { "objectApiName": "Account" }, "nested": { "definition": "mcf/timedList", "properties": { "objectApiName": "Contact"} } }'; + fs.writeFileSync( + path.join(dirManager.projectDir, validJsonFile), + jsonContents, + 'utf8' + ); + + const status = + await LwcGenerationCommand.getSObjectsFromLandingPage(); + + assert.equal(status.sobjects.length, 2); + assert.equal(status.sobjects[0], 'Account'); + assert.equal(status.sobjects[1], 'Contact'); + } finally { + getWorkspaceDirStub.restore(); + await dirManager.removeDir(); + } }); }); diff --git a/src/test/suite/utils/orgUtils.test.ts b/src/test/suite/utils/orgUtils.test.ts index a9f48be3..cc8950f6 100644 --- a/src/test/suite/utils/orgUtils.test.ts +++ b/src/test/suite/utils/orgUtils.test.ts @@ -13,7 +13,6 @@ import { afterEach, beforeEach } from 'mocha'; import { ConfigAggregator, Connection, - Org, OrgConfigProperties } from '@salesforce/core'; import { @@ -21,6 +20,7 @@ import { DescribeSObjectResult, Field as FieldType } from 'jsforce'; +import { CoreExtensionService } from '../../../services'; suite('Org Utils Test Suite', () => { const describeGlobalResult: DescribeGlobalResult = { @@ -118,18 +118,11 @@ suite('Org Utils Test Suite', () => { }); test('Returns list of sobjects', async () => { - const orgStub: SinonStub = sinon.stub(Org, 'create'); - const stubConnection = sinon.createStubInstance(Connection); + const stubConnection = stubWorkspaceContextConnection(); stubConnection.describeGlobal.returns( Promise.resolve(describeGlobalResult) ); - orgStub.returns({ - getConnection: () => { - return stubConnection; - } - }); - const sobjects = await OrgUtils.getSobjects(); assert.equal(sobjects.length, 1); @@ -293,18 +286,11 @@ suite('Org Utils Test Suite', () => { supportedScopes: null }; - const orgStub: SinonStub = sinon.stub(Org, 'create'); - const stubConnection = sinon.createStubInstance(Connection); + const stubConnection = stubWorkspaceContextConnection(); stubConnection.describe .withArgs('SomeObject') .returns(Promise.resolve(describeSobjectResult)); - orgStub.returns({ - getConnection: () => { - return stubConnection; - } - }); - const fields = await OrgUtils.getFieldsForSObject( describeSobjectResult.name ); @@ -380,4 +366,23 @@ suite('Org Utils Test Suite', () => { writeRequiresMasterRead: true }; } + + function stubWorkspaceContextConnection(): sinon.SinonStubbedInstance< + Connection + > { + const stubConnection = sinon.createStubInstance(Connection); + const getWorkspaceContextInstance = { + getConnection: () => { + return Promise.resolve(stubConnection); + }, + onOrgChange: sinon.stub(), + getInstance: sinon.stub(), + username: sinon.stub(), + alias: sinon.stub() + }; + sinon + .stub(CoreExtensionService, 'getWorkspaceContext') + .returns(getWorkspaceContextInstance); + return stubConnection; + } }); diff --git a/src/test/suite/utils/workspaceUtils.test.ts b/src/test/suite/utils/workspaceUtils.test.ts index 2c49aafa..a69dfe12 100644 --- a/src/test/suite/utils/workspaceUtils.test.ts +++ b/src/test/suite/utils/workspaceUtils.test.ts @@ -13,19 +13,36 @@ import { NoWorkspaceError, WorkspaceUtils } from '../../../utils/workspaceUtils'; -import { TempProjectDirManager } from '../../TestHelper'; +import { + TempProjectDirManager, + setupTempWorkspaceDirectoryStub +} from '../../TestHelper'; import { afterEach, beforeEach } from 'mocha'; import sinon = require('sinon'); suite('Workspace Test Suite', () => { - beforeEach(function () {}); + let getWorkspaceDirStub: sinon.SinonStub<[], string>; + let tempProjectDirManager: TempProjectDirManager; + + beforeEach(async function () { + tempProjectDirManager = + await TempProjectDirManager.createTempProjectDir(); + getWorkspaceDirStub = setupTempWorkspaceDirectoryStub( + tempProjectDirManager + ); + }); - afterEach(function () { + afterEach(async function () { + getWorkspaceDirStub.restore(); + await tempProjectDirManager.removeDir(); sinon.restore(); }); test('Static resources dir: workspace does not exist', async () => { try { + // Currently, the only time we *don't* want to stub + // WorkspaceUtils.getWorkspaceDir(). + getWorkspaceDirStub.restore(); await WorkspaceUtils.getStaticResourcesDir(); assert.fail('There should have been an error thrown.'); } catch (noWorkspaceErr) { @@ -33,17 +50,14 @@ suite('Workspace Test Suite', () => { noWorkspaceErr instanceof NoWorkspaceError, 'No workspace should be defined in this test.' ); + } finally { + getWorkspaceDirStub = setupTempWorkspaceDirectoryStub( + tempProjectDirManager + ); } }); test('Static resources dir: static resources dir does not exist', async () => { - const projectDirMgr = - await TempProjectDirManager.createTempProjectDir(); - const getWorkspaceDirStub = sinon.stub( - WorkspaceUtils, - 'getWorkspaceDir' - ); - getWorkspaceDirStub.returns(projectDirMgr.projectDir); try { await WorkspaceUtils.getStaticResourcesDir(); assert.fail('There should have been an error thrown.'); @@ -52,33 +66,17 @@ suite('Workspace Test Suite', () => { noStaticDirErr instanceof NoStaticResourcesDirError, 'No static resources dir should be defined in this test.' ); - } finally { - await projectDirMgr.removeDir(); - getWorkspaceDirStub.restore(); } }); test('Static resources dir: static resources dir exists', async () => { - const projectDirMgr = - await TempProjectDirManager.createTempProjectDir(); - const getWorkspaceDirStub = sinon.stub( - WorkspaceUtils, - 'getWorkspaceDir' - ); - getWorkspaceDirStub.returns(projectDirMgr.projectDir); - const staticResourcesAbsPath = path.join( - projectDirMgr.projectDir, + tempProjectDirManager.projectDir, WorkspaceUtils.STATIC_RESOURCES_PATH ); await mkdir(staticResourcesAbsPath, { recursive: true }); - try { - const outputDir = await WorkspaceUtils.getStaticResourcesDir(); - assert.equal(outputDir, staticResourcesAbsPath); - } finally { - await projectDirMgr.removeDir(); - getWorkspaceDirStub.restore(); - } + const outputDir = await WorkspaceUtils.getStaticResourcesDir(); + assert.equal(outputDir, staticResourcesAbsPath); }); }); diff --git a/src/types/AuthFields.ts b/src/types/AuthFields.ts new file mode 100644 index 00000000..f524f148 --- /dev/null +++ b/src/types/AuthFields.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +export type AuthFields = { + accessToken?: string; + alias?: string; + authCode?: string; + clientId?: string; + clientSecret?: string; + created?: string; + createdOrgInstance?: string; + devHubUsername?: string; + instanceUrl?: string; + instanceApiVersion?: string; + instanceApiVersionLastRetrieved?: string; + isDevHub?: boolean; + loginUrl?: string; + orgId?: string; + password?: string; + privateKey?: string; + refreshToken?: string; + scratchAdminUsername?: string; + snapshot?: string; + userId?: string; + username?: string; + usernames?: string[]; + userProfileName?: string; + expirationDate?: string; + tracksSource?: boolean; +}; diff --git a/src/types/CoreExtensionApi.ts b/src/types/CoreExtensionApi.ts new file mode 100644 index 00000000..0d9f0370 --- /dev/null +++ b/src/types/CoreExtensionApi.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { WorkspaceContext } from './WorkspaceContext'; + +export interface CoreExtensionApi { + services: { + // eslint-disable-next-line @typescript-eslint/naming-convention + WorkspaceContext: WorkspaceContext; + }; +} diff --git a/src/types/SingleRecordQueryOptions.ts b/src/types/SingleRecordQueryOptions.ts new file mode 100644 index 00000000..10cf3322 --- /dev/null +++ b/src/types/SingleRecordQueryOptions.ts @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +export interface SingleRecordQueryOptions { + tooling?: boolean; + returnChoicesOnMultiple?: boolean; + choiceField?: string; +} diff --git a/src/types/WorkspaceContext.ts b/src/types/WorkspaceContext.ts new file mode 100644 index 00000000..9fb27764 --- /dev/null +++ b/src/types/WorkspaceContext.ts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { Connection } from '@salesforce/core'; +import { Event } from 'vscode'; + +export interface WorkspaceContext { + readonly onOrgChange: Event<{ + username?: string; + alias?: string; + }>; + getInstance(forceNew: boolean): WorkspaceContext; + getConnection(): Promise; + username(): string | undefined; + alias(): string | undefined; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..9a264697 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +export * from './AuthFields'; +export * from './CoreExtensionApi'; +export * from './SingleRecordQueryOptions'; +export * from './WorkspaceContext'; diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 00000000..9cdfa83a --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +export const MINIMUM_REQUIRED_VERSION_CORE_EXTENSION = '58.4.1'; +export const CORE_EXTENSION_ID = 'salesforce.salesforcedx-vscode-core'; diff --git a/src/utils/orgUtils.ts b/src/utils/orgUtils.ts index 6e0af0d2..818afdcc 100644 --- a/src/utils/orgUtils.ts +++ b/src/utils/orgUtils.ts @@ -5,7 +5,8 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { ConfigAggregator, Org, OrgConfigProperties } from '@salesforce/core'; +import { ConfigAggregator, OrgConfigProperties } from '@salesforce/core'; +import { CoreExtensionService } from '../services/CoreExtensionService'; export interface SObject { apiName: string; @@ -48,8 +49,8 @@ export type SObjectCompactLayout = { export class OrgUtils { public static async getSobjects(): Promise { try { - const org = await Org.create(); - const conn = org.getConnection(); + const conn = + await CoreExtensionService.getWorkspaceContext().getConnection(); const result = await conn.describeGlobal(); const sobjects = result.sobjects @@ -71,8 +72,8 @@ export class OrgUtils { public static async getFieldsForSObject(apiName: string): Promise { try { - const org = await Org.create(); - const conn = org.getConnection(); + const conn = + await CoreExtensionService.getWorkspaceContext().getConnection(); const result = await conn.describe(apiName); const fields = result.fields @@ -95,9 +96,13 @@ export class OrgUtils { public static async getCompactLayoutsForSObject( sObjectName: string ): Promise { - const org = await Org.create(); - const conn = org.getConnection(); + const conn = + await CoreExtensionService.getWorkspaceContext().getConnection(); + // TODO: We should probably tack to an approach that uses the underlying + // `jsforce` functions for getting compact layouts. For example, something + // based on + // https://github.com/jsforce/jsforce/blob/c04515846e91f84affa4eb87a7b2adb1f58bf04d/lib/sobject.js#L441-L444 const result = await conn.request( `/services/data/v59.0/sobjects/${sObjectName}/describe/compactLayouts` ); @@ -109,9 +114,8 @@ export class OrgUtils { sObjectName: string, recordTypeId: string ): Promise { - const org = await Org.create(); - const conn = org.getConnection(); - + const conn = + await CoreExtensionService.getWorkspaceContext().getConnection(); const result = await conn.request( `/services/data/v59.0/sobjects/${sObjectName}/describe/compactLayouts/${recordTypeId}` );