diff --git a/package.json b/package.json index 8e4ca4f..f569200 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,41 @@ "main": "./out/extension.js", "l10n": "./l10n", "contributes": { + "menus": { + "commandPalette": [ + { + "command": "salesforcedx-vscode-offline-app.configureLintingTools", + "when": "sfdx_project_opened" + } + ] + }, "commands": [ { "command": "salesforcedx-vscode-offline-app.onboardingWizard", "title": "%extension.commands.config-wizard.title%", "category": "%extension.commands.config-wizard.category%" + }, + { + "command": "salesforcedx-vscode-offline-app.configureLintingTools", + "title": "%extension.commands.config-linting-tools.title%", + "category": "%extension.commands.salesforce-mobile-offline.category%" + } + ], + "configuration": { + "title": "%salesforce.mobile.extensions%", + "properties": { + "mobileOfflineLinting.eslint-plugin-lwc-graph-analyzer": { + "type": "string", + "default": "^0.9.0", + "description": "%extension.commands.salesforce-mobile-offline.komaci.version%" + }, + "mobileOfflineLinting.eslint": { + "type": "string", + "default": "^8.47.0", + "description": "%extension.commands.salesforce-mobile-offline.eslint.version%" + } } - ] + } }, "volta": { "node": "18.17.1" diff --git a/package.nls.json b/package.nls.json index 4ed2ed3..65b976a 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,6 +1,11 @@ { "extension.commands.config-wizard.title": "Configuration Wizard", "extension.commands.config-wizard.category": "Offline Starter Kit", + "extension.commands.config-linting-tools.title": "Configure Linting Tools", + "extension.commands.salesforce-mobile-offline.category": "Salesforce Mobile Offline", + "extension.commands.salesforce-mobile-offline.komaci.version": "Version of ESLint Plugin LWC Graph Analyzer to include in devDependencies", + "extension.commands.salesforce-mobile-offline.eslint.version": "Version of ESLint to include in devDependencies", + "salesforce.mobile.extensions": "Salesforce Mobile Extensions", "extension.displayName": "Salesforce Mobile Extensions for Visual Studio Code", "extension.description": "Tools to help developers create their Salesforce Mobile experiences in a VSCode development environment." } diff --git a/src/commands/lint/configureLintingToolsCommand.ts b/src/commands/lint/configureLintingToolsCommand.ts new file mode 100644 index 0000000..e7c7e89 --- /dev/null +++ b/src/commands/lint/configureLintingToolsCommand.ts @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2024, 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 { commands, l10n, window, workspace, ExtensionContext } from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { WorkspaceUtils } from '../../utils/workspaceUtils'; +import { JSON_INDENTATION_SPACES } from '../../utils/constants'; + +const config = workspace.getConfiguration(); +const eslintPluginLwcGraphAnalyzer = + '@salesforce/eslint-plugin-lwc-graph-analyzer'; +const eslintPluginLwcGraphAnalyzerConfig = + 'mobileOfflineLinting.eslint-plugin-lwc-graph-analyzer'; +const eslintPluginLwcGraphAnalyzerVersion = config.get( + eslintPluginLwcGraphAnalyzerConfig +) as string; + +const eslint = 'eslint'; +const eslintConfig = 'mobileOfflineLinting.eslint'; +const eslintVersion = config.get(eslintConfig) as string; + +const configureLintingToolsCommand = + 'salesforcedx-vscode-offline-app.configureLintingTools'; +const eslintDependencies = [ + [eslintPluginLwcGraphAnalyzer, eslintPluginLwcGraphAnalyzerVersion], + [eslint, eslintVersion] +]; + +const lwcGraphAnalyzerRecommended: string = + 'plugin:@salesforce/lwc-graph-analyzer/recommended'; +const eslintRecommended = 'eslint:recommended'; + +interface PackageJson { + devDependencies?: Record; +} + +enum MessageType { + Error, + InformationYesNo, + InformationOk +} + +export class ConfigureLintingToolsCommand { + static async configure(): Promise { + try { + if (!WorkspaceUtils.lwcFolderExists()) { + await this.showMessage( + 'The "force-app/main/default/lwc" folder does not exist in your project. This folder is required to create a configuration file for ESLint.' + ); + return Promise.resolve(false); + } + + if (!WorkspaceUtils.packageJsonExists()) { + await this.showMessage( + 'Your project does not contain a "package.json" specification. You must have a package specification to configure these ESLint packages and their dependencies in your project.' + ); + return Promise.resolve(false); + } + + // Ask user to add eslint plugin + const result = await this.showMessage( + 'Do you want to add the ESLint plugin for LWC graph analysis to your project? This will give you linting feedback on code patterns that will not support your LWCs working offline, for mobile use cases.', + MessageType.InformationYesNo + ); + + if (!result || result.title === l10n.t('No')) { + return Promise.resolve(false); + } else { + let modifiedDevDependencies = false; + try { + modifiedDevDependencies = this.updateDevDependencies(); + } catch (error) { + await this.showMessage( + `Error updating package.json: ${error}` + ); + return Promise.resolve(false); + } + + let modifiedEslintrc = false; + try { + modifiedEslintrc = this.updateEslintrc(); + } catch (error) { + await this.showMessage( + `Error updating .eslintrc.json: ${error}` + ); + return Promise.resolve(false); + } + + if (modifiedDevDependencies) { + this.showMessage( + `Updated package.json to include offline linting packages and dependencies.`, + MessageType.InformationOk + ); + } + + if (modifiedEslintrc) { + this.showMessage( + `Updated .eslintrc.json to include recommended linting rules.`, + MessageType.InformationOk + ); + } + + if (modifiedDevDependencies || modifiedEslintrc) { + this.showMessage( + `In the Terminal window, be sure to run the install command for your configured package manager, to install the updated dependencies. For example, "npm install" or "yarn install".`, + MessageType.InformationOk + ); + } + + if (!modifiedDevDependencies && !modifiedEslintrc) { + this.showMessage( + `All offline linting packages and dependencies are already configured in your project. No update has been made to package.json.`, + MessageType.InformationOk + ); + } + + return Promise.resolve(true); + } + } catch (error) { + await this.showMessage( + `There was an error trying to update either the offline linting dependencies or linting configuration: ${error}` + ); + return Promise.resolve(false); + } + } + + static updateDevDependencies(): boolean { + const packageJson: PackageJson = WorkspaceUtils.getPackageJson(); + const devDependencies = packageJson.devDependencies; + let modified = false; + + if (devDependencies) { + eslintDependencies.forEach((nameValuePair) => { + const [name, value] = nameValuePair; + if (!devDependencies[name]) { + devDependencies[name] = value; + modified = true; + } + }); + } + + if (modified) { + // Save json only if the content was modified. + WorkspaceUtils.setPackageJson(packageJson); + } + + return modified; + } + + static updateEslintrc(): boolean { + const eslintrcPath = path.join( + WorkspaceUtils.getWorkspaceDir(), + WorkspaceUtils.LWC_PATH, + '.eslintrc.json' + ); + + if (fs.existsSync(eslintrcPath)) { + const eslintrc = JSON.parse(fs.readFileSync(eslintrcPath, 'utf-8')); + + if (!eslintrc.extends) { + eslintrc.extends = []; + } + + const eslintrcExtends = eslintrc.extends as Array; + + let modified = false; + + if (!eslintrcExtends.includes(eslintRecommended)) { + eslintrcExtends.push(eslintRecommended); + modified = true; + } + + if (!eslintrcExtends.includes(lwcGraphAnalyzerRecommended)) { + eslintrc.extends.push(lwcGraphAnalyzerRecommended); + modified = true; + } + + if (modified) { + // Save json only if the content was modified. + fs.writeFileSync( + eslintrcPath, + JSON.stringify(eslintrc, null, JSON_INDENTATION_SPACES) + ); + } + + return modified; + } else { + // Create eslintrc + const eslintrc = { + extends: [ + `${eslintRecommended}`, + `${lwcGraphAnalyzerRecommended}` + ] + }; + const jsonString = JSON.stringify( + eslintrc, + null, + JSON_INDENTATION_SPACES + ); + + fs.writeFileSync(eslintrcPath, jsonString); + + return true; + } + } + + static async showMessage( + message: string, + messageType: MessageType = MessageType.Error + ): Promise<{ title: string } | undefined> { + const localizedMessage = l10n.t(message); + switch (messageType) { + case MessageType.Error: + return await window.showErrorMessage(localizedMessage, { + title: l10n.t('OK') + }); + case MessageType.InformationYesNo: + return await window.showInformationMessage( + localizedMessage, + { title: l10n.t('Yes') }, + { title: l10n.t('No') } + ); + case MessageType.InformationOk: + return await window.showInformationMessage(localizedMessage, { + title: l10n.t('OK') + }); + } + } +} + +export function registerCommand(context: ExtensionContext) { + commands.registerCommand(configureLintingToolsCommand, async () => { + await ConfigureLintingToolsCommand.configure(); + }); +} diff --git a/src/extension.ts b/src/extension.ts index ae27a1b..638cb7f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,7 +9,9 @@ // 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 * as configureLintingToolsCommand from './commands/lint/configureLintingToolsCommand'; import { CoreExtensionService } from './services/CoreExtensionService'; +import { WorkspaceUtils } from './utils/workspaceUtils'; 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 @@ -26,8 +28,16 @@ export function activate(context: vscode.ExtensionContext) { return; } + vscode.commands.executeCommand( + 'setContext', + 'sfdx_project_opened', + WorkspaceUtils.isSfdxProjectOpened() + ); + onboardingWizard.registerCommand(context); onboardingWizard.onActivate(context); + + configureLintingToolsCommand.registerCommand(context); } // This method is called when your extension is deactivated diff --git a/src/test/suite/commands/lint/configureLintingToolsCommand.test.ts b/src/test/suite/commands/lint/configureLintingToolsCommand.test.ts new file mode 100644 index 0000000..0a3e0f2 --- /dev/null +++ b/src/test/suite/commands/lint/configureLintingToolsCommand.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2024, 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 * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as path from 'path'; +import * as fs from 'fs'; +import { window } from 'vscode'; +import { afterEach } from 'mocha'; +import { ConfigureLintingToolsCommand } from '../../../../commands/lint/configureLintingToolsCommand'; +import { WorkspaceUtils } from '../../../../utils/workspaceUtils'; +import { + TempProjectDirManager, + setupTempWorkspaceDirectoryStub +} from '../../../TestHelper'; + +suite('Configure Linting Tools Command Test Suite', () => { + afterEach(function () { + sinon.restore(); + }); + + test('Configure linting cancelled because LWC folder does not exist', async () => { + sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(false); + const showErrorMessageStub = sinon.stub(window, 'showErrorMessage'); + showErrorMessageStub.onCall(0).resolves({ title: 'OK' }); + const result = await ConfigureLintingToolsCommand.configure(); + assert.equal(result, false); + }); + + test('Configure linting cancelled because package.json does not exist', async () => { + sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); + sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(false); + const showErrorMessageStub = sinon.stub(window, 'showErrorMessage'); + showErrorMessageStub.onCall(0).resolves({ title: 'OK' }); + const result = await ConfigureLintingToolsCommand.configure(); + assert.equal(result, false); + }); + + test('Configure linting cancelled by the user', async () => { + sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); + sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(true); + const showInformationMessageStub = sinon.stub( + window, + 'showInformationMessage' + ); + showInformationMessageStub.onCall(0).resolves({ title: 'No' }); + const result = await ConfigureLintingToolsCommand.configure(); + assert.equal(result, false); + }); + + test('Configure linting cancelled because updating pacakge.json failed', async () => { + sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); + sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(true); + const showInformationMessageStub = sinon.stub( + window, + 'showInformationMessage' + ); + showInformationMessageStub.onCall(0).resolves({ title: 'Yes' }); + sinon + .stub(ConfigureLintingToolsCommand, 'updateDevDependencies') + .throws('error'); + const showErrorMessageStub = sinon.stub(window, 'showErrorMessage'); + showErrorMessageStub.onCall(0).resolves({ title: 'OK' }); + const result = await ConfigureLintingToolsCommand.configure(); + assert.equal(result, false); + }); + + test('Configure linting cancelled because updating .eslintrc.json failed', async () => { + sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); + sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(true); + const showInformationMessageStub = sinon.stub( + window, + 'showInformationMessage' + ); + showInformationMessageStub.onCall(0).resolves({ title: 'Yes' }); + sinon + .stub(ConfigureLintingToolsCommand, 'updateDevDependencies') + .returns(true); + sinon + .stub(ConfigureLintingToolsCommand, 'updateEslintrc') + .throws('error'); + const showErrorMessageStub = sinon.stub(window, 'showErrorMessage'); + showErrorMessageStub.onCall(0).resolves({ title: 'OK' }); + const result = await ConfigureLintingToolsCommand.configure(); + assert.equal(result, false); + }); + + test('Configure linting did not update package.json because plugin was already included in the dev dependency', async () => { + sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); + sinon.stub(WorkspaceUtils, 'packageJsonExists').returns(true); + let showInformationMessageStub = sinon.stub( + window, + 'showInformationMessage' + ); + showInformationMessageStub.onCall(0).resolves({ title: 'Yes' }); + sinon + .stub(ConfigureLintingToolsCommand, 'updateDevDependencies') + .returns(false); + sinon + .stub(ConfigureLintingToolsCommand, 'updateEslintrc') + .returns(false); + showInformationMessageStub = sinon.stub(window, 'showErrorMessage'); + showInformationMessageStub.onCall(0).resolves({ title: 'OK' }); + const result = await ConfigureLintingToolsCommand.configure(); + assert.equal(result, true); + }); + + test('Configure linting updated package.json successfully', async () => { + let getWorkspaceDirStub: sinon.SinonStub<[], string>; + let tempProjectDirManager: TempProjectDirManager; + tempProjectDirManager = + await TempProjectDirManager.createTempProjectDir(); + getWorkspaceDirStub = setupTempWorkspaceDirectoryStub( + tempProjectDirManager + ); + const packageJson = { devDependencies: { lwc: '1.2.3' } }; + WorkspaceUtils.setPackageJson(packageJson); + + sinon.stub(WorkspaceUtils, 'lwcFolderExists').returns(true); + let showInformationMessageStub = sinon.stub( + window, + 'showInformationMessage' + ); + showInformationMessageStub.onCall(0).resolves({ title: 'Yes' }); + showInformationMessageStub.onCall(1).resolves({ title: 'Yes' }); + + try { + // Creating directories recursively + const lwcPath = path.join( + WorkspaceUtils.getWorkspaceDir(), + WorkspaceUtils.LWC_PATH + ); + fs.mkdirSync(lwcPath, { recursive: true }); + } catch (error) { + console.error('Error creating directories:', error); + } + + const result = await ConfigureLintingToolsCommand.configure(); + assert.equal(result, true); + + const content = WorkspaceUtils.getPackageJson(); + const updatedPackageJson = { + devDependencies: { + lwc: '1.2.3', + // eslint-disable-next-line @typescript-eslint/naming-convention + '@salesforce/eslint-plugin-lwc-graph-analyzer': '^0.9.0', + eslint: '^8.47.0' + } + }; + assert.equal( + JSON.stringify(updatedPackageJson), + JSON.stringify(content) + ); + }); +}); diff --git a/src/test/suite/utils/workspaceUtils.test.ts b/src/test/suite/utils/workspaceUtils.test.ts index a715660..96d8fd9 100644 --- a/src/test/suite/utils/workspaceUtils.test.ts +++ b/src/test/suite/utils/workspaceUtils.test.ts @@ -7,6 +7,7 @@ import * as assert from 'assert'; import * as path from 'path'; +import * as fs from 'fs'; import { mkdir } from 'fs/promises'; import { NoStaticResourcesDirError, @@ -19,6 +20,7 @@ import { } from '../../TestHelper'; import { afterEach, beforeEach } from 'mocha'; import * as sinon from 'sinon'; +import { SFDX_PROJECT_FILE } from '../../../utils/constants'; suite('Workspace Test Suite', () => { let getWorkspaceDirStub: sinon.SinonStub<[], string>; @@ -79,4 +81,46 @@ suite('Workspace Test Suite', () => { const outputDir = await WorkspaceUtils.getStaticResourcesDir(); assert.equal(outputDir, staticResourcesAbsPath); }); + + test('Existence of package.json can be determined', () => { + let exists = WorkspaceUtils.packageJsonExists(); + assert.equal(exists, false); + + const packageJson = { a: 'b' }; + WorkspaceUtils.setPackageJson(packageJson); + + exists = WorkspaceUtils.packageJsonExists(); + assert.equal(exists, true); + + const content = WorkspaceUtils.getPackageJson(); + assert.equal(JSON.stringify(content), JSON.stringify(packageJson)); + }); + + test('Existence of LWC folder can be determined', async () => { + let exists = WorkspaceUtils.lwcFolderExists(); + assert.equal(exists, false); + + const lwcPath = path.join( + tempProjectDirManager.projectDir, + WorkspaceUtils.LWC_PATH + ); + await mkdir(lwcPath, { recursive: true }); + + exists = WorkspaceUtils.lwcFolderExists(); + assert.equal(exists, true); + }); + + test('Sfdx project is opened', () => { + let opened = WorkspaceUtils.isSfdxProjectOpened(); + assert.equal(opened, false); + + const sfdxJson = path.join( + tempProjectDirManager.projectDir, + SFDX_PROJECT_FILE + ); + fs.writeFileSync(sfdxJson, ''); + + opened = WorkspaceUtils.isSfdxProjectOpened(); + assert.equal(opened, true); + }); }); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 9cdfa83..e92652c 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -7,3 +7,6 @@ export const MINIMUM_REQUIRED_VERSION_CORE_EXTENSION = '58.4.1'; export const CORE_EXTENSION_ID = 'salesforce.salesforcedx-vscode-core'; +export const SFDX_PROJECT_FILE = 'sfdx-project.json'; +export const PACKAGE_JSON = 'package.json'; +export const JSON_INDENTATION_SPACES = 2; diff --git a/src/utils/workspaceUtils.ts b/src/utils/workspaceUtils.ts index 5dcc112..f19c5a3 100644 --- a/src/utils/workspaceUtils.ts +++ b/src/utils/workspaceUtils.ts @@ -7,6 +7,12 @@ import { workspace } from 'vscode'; import { access } from 'fs/promises'; +import { + PACKAGE_JSON, + SFDX_PROJECT_FILE, + JSON_INDENTATION_SPACES +} from './constants'; +import * as fs from 'fs'; import * as path from 'path'; export class WorkspaceUtils { @@ -67,6 +73,52 @@ export class WorkspaceUtils { return resolve(staticResourcesPath); }); } + + static getPackageJson(): object { + return JSON.parse( + fs.readFileSync( + path.join(this.getWorkspaceDir(), PACKAGE_JSON), + 'utf8' + ) + ); + } + + static setPackageJson(packageJson: object) { + fs.writeFileSync( + path.join(this.getWorkspaceDir(), PACKAGE_JSON), + JSON.stringify(packageJson, null, JSON_INDENTATION_SPACES) + ); + } + + static packageJsonExists(): boolean { + try { + return fs.existsSync( + path.join(this.getWorkspaceDir(), PACKAGE_JSON) + ); + } catch { + return false; + } + } + + static lwcFolderExists(): boolean { + try { + return fs.existsSync( + path.join(this.getWorkspaceDir(), WorkspaceUtils.LWC_PATH) + ); + } catch { + return false; + } + } + + static isSfdxProjectOpened(): boolean { + try { + return fs.existsSync( + path.join(this.getWorkspaceDir(), SFDX_PROJECT_FILE) + ); + } catch { + return false; + } + } } export class NoWorkspaceError extends Error {