diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 0b9ed04..e958b6c 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -16,7 +16,7 @@ export default defineConfig([ timeout: 20000, asyncOnly: true, }, - launchArgs: [`--user-data-dir=${userDir}`], + launchArgs: [`--user-data-dir=${userDir}`, '--disable-extensions'], }, // you can specify additional test configurations, too ]); diff --git a/src/controllers/HumanitecSidebarController.ts b/src/controllers/HumanitecSidebarController.ts index 0187f80..96b714b 100644 --- a/src/controllers/HumanitecSidebarController.ts +++ b/src/controllers/HumanitecSidebarController.ts @@ -144,6 +144,7 @@ export class HumanitecSidebarController { item.id ); } + vscode.commands.executeCommand('humanitec.score.validate'); } else if (item instanceof Application) { await configurationRepository.set(ConfigKey.HUMANITEC_ENV, ''); const orgId = await configurationRepository.get( diff --git a/src/controllers/ValidateScoreFileController.ts b/src/controllers/ValidateScoreFileController.ts index 8ee2510..077f5e5 100644 --- a/src/controllers/ValidateScoreFileController.ts +++ b/src/controllers/ValidateScoreFileController.ts @@ -10,6 +10,8 @@ import { } from 'vscode'; import { isHumanitecExtensionError } from '../errors/IHumanitecExtensionError'; import { ILoggerService } from '../services/LoggerService'; +import { IConfigurationRepository } from '../repos/ConfigurationRepository'; +import { ConfigKey } from '../domain/ConfigKey'; export class ValidateScoreFileController { private static instance: ValidateScoreFileController; @@ -18,7 +20,7 @@ export class ValidateScoreFileController { private constructor( private validationService: IScoreValidationService, - private logger: ILoggerService + private config: IConfigurationRepository ) { this.diagnosticCollections = new Map(); } @@ -26,12 +28,13 @@ export class ValidateScoreFileController { static register( context: vscode.ExtensionContext, validationService: IScoreValidationService, + config: IConfigurationRepository, logger: ILoggerService ) { if (this.instance === undefined) { this.instance = new ValidateScoreFileController( validationService, - logger + config ); } @@ -51,7 +54,11 @@ export class ValidateScoreFileController { if (this.instance.isScoreFile(textDocument)) { const diagnosticCollection = this.instance.getDiagnosticCollections(textDocument.uri.path); - await this.instance.validate(textDocument, diagnosticCollection); + await this.instance.validate( + textDocument, + diagnosticCollection, + context + ); } } catch (error) { if (isHumanitecExtensionError(error)) { @@ -81,7 +88,11 @@ export class ValidateScoreFileController { const diagnosticCollection = this.instance.getDiagnosticCollections( textDocument.uri.path ); - await this.instance.validate(textDocument, diagnosticCollection); + await this.instance.validate( + textDocument, + diagnosticCollection, + context + ); } } catch (error) { logger.error(JSON.stringify({ error })); @@ -107,7 +118,11 @@ export class ValidateScoreFileController { const diagnosticCollection = this.instance.getDiagnosticCollections( event.document.uri.path ); - await this.instance.validate(event.document, diagnosticCollection); + await this.instance.validate( + event.document, + diagnosticCollection, + context + ); } } catch (error) { logger.error(JSON.stringify({ error })); @@ -152,12 +167,35 @@ export class ValidateScoreFileController { } } + private statusBarItem: vscode.StatusBarItem | undefined; + private async validate( textDocument: TextDocument, - diagnosticCollection: vscode.DiagnosticCollection + diagnosticCollection: vscode.DiagnosticCollection, + context: vscode.ExtensionContext ) { + if (this.statusBarItem == undefined) { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right + ); + this.statusBarItem.text = '$(warning) Only local validation'; + this.statusBarItem.tooltip = + 'There is no organization set so Humanitec Extension could only validate the Score files locally'; + context.subscriptions.push(this.statusBarItem); + } + + const isOrganizationSet = + (await this.config.get(ConfigKey.HUMANITEC_ORG)) !== ''; + + if (!isOrganizationSet) { + this.statusBarItem.show(); + } else { + this.statusBarItem.hide(); + } + const validationErrors = await this.validationService.validate( - textDocument.uri.path + textDocument.uri.path, + !isOrganizationSet ); const diagnostics: Diagnostic[] = []; diff --git a/src/extension.ts b/src/extension.ts index e64bffb..6337880 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,7 +24,7 @@ export const loggerChannel = vscode.window.createOutputChannel('Humanitec'); export async function activate(context: vscode.ExtensionContext) { const logger = new LoggerService(loggerChannel); const configurationRepository = new ConfigurationRepository(); - const secretRepository = new SecretRepository(context.secrets, logger); + const secretRepository = new SecretRepository(); const humctl = new HumctlAdapter( configurationRepository, secretRepository, @@ -52,6 +52,7 @@ export async function activate(context: vscode.ExtensionContext) { ValidateScoreFileController.register( context, new ScoreValidationService(humctl), + configurationRepository, logger ); InitializeScoreFileController.register( diff --git a/src/repos/SecretRepository.ts b/src/repos/SecretRepository.ts index 9e16355..4cfc980 100644 --- a/src/repos/SecretRepository.ts +++ b/src/repos/SecretRepository.ts @@ -1,10 +1,8 @@ -import * as vscode from 'vscode'; import { SecretKey } from '../domain/SecretKey'; import { homedir } from 'os'; import path from 'path'; import { readFileSync, writeFileSync } from 'fs'; import { parse, stringify } from 'yaml'; -import { ILoggerService } from '../services/LoggerService'; export interface ISecretRepository { get(key: SecretKey): Promise; @@ -12,10 +10,7 @@ export interface ISecretRepository { } export class SecretRepository implements ISecretRepository { - constructor( - private secrets: vscode.SecretStorage, - private logger: ILoggerService - ) {} + constructor() {} async set(key: SecretKey, value: string): Promise { const configPath = path.join(homedir(), '.humctl'); diff --git a/src/services/ScoreValidationService.ts b/src/services/ScoreValidationService.ts index 31c26a4..ab665fe 100644 --- a/src/services/ScoreValidationService.ts +++ b/src/services/ScoreValidationService.ts @@ -1,7 +1,7 @@ import { IHumctlAdapter } from '../adapters/humctl/IHumctlAdapter'; export interface IScoreValidationService { - validate(filepath: string): Promise; + validate(filepath: string, onlyLocal: boolean): Promise; } export class ValidationError { @@ -25,8 +25,17 @@ interface RawValidationError { export class ScoreValidationService implements IScoreValidationService { constructor(private humctl: IHumctlAdapter) {} - async validate(filepath: string): Promise { - const result = await this.humctl.execute(['score', 'validate', filepath]); + async validate( + filepath: string, + onlyLocal: boolean + ): Promise { + const command = ['score', 'validate']; + if (onlyLocal) { + command.push('--local'); + } + command.push(filepath); + + const result = await this.humctl.execute(command); const validationErrors: ValidationError[] = []; // TODO: Make the handling better diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 868d3d6..68f5c1e 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -1,5 +1,4 @@ import { suite, beforeEach, afterEach, test } from 'mocha'; -import path from 'path'; import sinon from 'sinon'; import chai from 'chai'; import sinonChai from 'sinon-chai'; @@ -15,21 +14,10 @@ import { ConfigurationRepository } from '../../repos/ConfigurationRepository'; import { ConfigKey } from '../../domain/ConfigKey'; import { Environment } from '../../domain/Environment'; import { loggerChannel } from '../../extension'; - -const wait = (ms: number) => - new Promise(resolve => setTimeout(() => resolve(), ms)); - -const readEnv = (name: string): string => { - if (!process.env[name]) { - throw new Error(`${name} not set`); - } - return process.env[name] || ''; -}; +import { readEnv } from '../utils'; suite('Extension Test Suite', () => { - let workspaceFolder: string; let humanitecOrg: string; - let humanitecToken: string; let sandbox: sinon.SinonSandbox; let showErrorMessage: sinon.SinonSpy; @@ -52,12 +40,6 @@ suite('Extension Test Suite', () => { }); humanitecOrg = readEnv('TEST_HUMANITEC_ORG'); - humanitecToken = readEnv('TEST_HUMANITEC_TOKEN'); - - if (!vscode.workspace.workspaceFolders) { - throw new Error('Workspace folder not found'); - } - workspaceFolder = vscode.workspace.workspaceFolders[0].uri.path; const ext = vscode.extensions.getExtension('humanitec.humanitec'); if (!ext) { @@ -70,73 +52,6 @@ suite('Extension Test Suite', () => { sandbox.restore(); }); - test('score.validate - without login', async () => { - const doc = await vscode.workspace.openTextDocument( - path.join(workspaceFolder, './score.yaml') - ); - - await vscode.window.showTextDocument(doc); - await vscode.commands.executeCommand('humanitec.score.validate'); - - await waitForExpect( - () => { - expect(showErrorMessage).to.have.been.called; - }, - 10000, - 500 - ); - - expect(showErrorMessage).to.have.been.calledWith( - 'There is no enough context to process the request. Required context is: Organization' - ); - }); - - test('score.validate - with login', async () => { - const doc = await vscode.workspace.openTextDocument( - path.join(workspaceFolder, './score.yaml') - ); - - await vscode.window.showTextDocument(doc); - - sandbox.stub(vscode.window, 'showInputBox').resolves(humanitecToken); - - await vscode.commands.executeCommand('humanitec.set_token'); - - await wait(100); - - await vscode.commands.executeCommand( - 'humanitec.sidebar.organization_structure.set_in_workspace', - new Organization(humanitecOrg, 'test-org') - ); - - await wait(100); - - await vscode.commands.executeCommand('humanitec.score.validate'); - - let diagnostics: vscode.Diagnostic[] = []; - - await waitForExpect( - () => { - diagnostics = vscode.languages.getDiagnostics(doc.uri); - expect(diagnostics).not.to.be.empty; - }, - 10000, - 500 - ); - - const invalidPropertyErrorMessage = - "additionalProperties 'invalid' not allowed"; - - const invalidProperty = diagnostics.find( - diagnostic => diagnostic.message === invalidPropertyErrorMessage - ); - - expect( - invalidProperty, - `Expected invalid property error in: ${JSON.stringify(diagnostics, null, 2)}` - ).to.be.ok; - }); - test('humanitec.sidebar.organization_structure - set organization in workspace', async () => { const sidebarController = HumanitecSidebarController.getInstance(); diff --git a/src/test/suite/validate_score_file.test.ts b/src/test/suite/validate_score_file.test.ts new file mode 100644 index 0000000..ee76fc1 --- /dev/null +++ b/src/test/suite/validate_score_file.test.ts @@ -0,0 +1,155 @@ +import * as vscode from 'vscode'; +import { suite, beforeEach, before, afterEach, test } from 'mocha'; +import sinon from 'sinon'; +import path from 'path'; +import waitForExpect from 'wait-for-expect'; +import chai from 'chai'; +import sinonChai from 'sinon-chai'; +import { readEnv } from '../utils'; + +const expect = chai.expect; +chai.use(sinonChai); + +suite('When validate score file command triggered', () => { + const statusBarItemShow = sinon.spy(); + const statusBarItemHide = sinon.spy(); + const humanitecOrg = readEnv('TEST_HUMANITEC_ORG'); + const humanitecToken = readEnv('TEST_HUMANITEC_TOKEN'); + + let sandbox: sinon.SinonSandbox; + let statusBarItem: vscode.StatusBarItem; + let workspaceFolder: string; + let doc: vscode.TextDocument; + let errorMessageShow: sinon.SinonStub; + + const createFakeStatusBarItem = () => { + const fakeItem = { + id: 'id', + alignment: vscode.StatusBarAlignment.Right, + priority: undefined, + name: undefined, + text: 'text', + tooltip: undefined, + color: undefined, + backgroundColor: undefined, + command: undefined, + accessibilityInformation: undefined, + show: statusBarItemShow, + hide: statusBarItemHide, + dispose: sinon.spy(), + }; + statusBarItem = fakeItem; + return fakeItem; + }; + + before(async () => { + if (!vscode.workspace.workspaceFolders) { + throw new Error('Workspace folder not found'); + } + workspaceFolder = vscode.workspace.workspaceFolders[0].uri.path; + + const ext = vscode.extensions.getExtension('humanitec.humanitec'); + if (!ext) { + throw new Error('Extension not found'); + } + await ext.activate(); + + doc = await vscode.workspace.openTextDocument( + path.join(workspaceFolder, './score.yaml') + ); + //await vscode.window.showTextDocument(doc); + }); + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + sandbox + .stub(vscode.window, 'createStatusBarItem') + .callsFake(createFakeStatusBarItem); + errorMessageShow = sandbox + .stub(vscode.window, 'showErrorMessage') + .callsFake( + (message, ...items): Thenable => { + console.log('showErrorMessage', message, ...items); + return Promise.resolve(undefined); + } + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + test('given user is not logged, should validate the Score files locally', async () => { + sandbox.stub(vscode.window, 'showInputBox').resolves(''); + await vscode.commands.executeCommand('humanitec.set_token'); + + await vscode.workspace + .getConfiguration('humanitec') + .update('organization', ''); + + await vscode.commands.executeCommand('humanitec.score.validate'); + + await waitForExpect(() => { + const diagnostics = vscode.languages.getDiagnostics(doc.uri); + expect(diagnostics).not.to.be.empty; + expect(statusBarItemShow).to.have.been.called; + expect(statusBarItem.text).to.be.eq('$(warning) Only local validation'); + expect(statusBarItem.tooltip).to.be.eq( + 'There is no organization set so Humanitec Extension could only validate the Score files locally' + ); + }); + }); + + test('given user is logged, but workspace has no organization in use, should validate the Score files locally', async () => { + sandbox.stub(vscode.window, 'showInputBox').resolves(humanitecToken); + await vscode.commands.executeCommand('humanitec.set_token'); + + await vscode.workspace + .getConfiguration('humanitec') + .update('organization', ''); + + await vscode.commands.executeCommand('humanitec.score.validate'); + + await waitForExpect(() => { + const diagnostics = vscode.languages.getDiagnostics(doc.uri); + expect(diagnostics).not.to.be.empty; + expect(statusBarItemShow).to.have.been.called; + expect(statusBarItem.text).to.be.eq('$(warning) Only local validation'); + expect(statusBarItem.tooltip).to.be.eq( + 'There is no organization set so Humanitec Extension could only validate the Score files locally' + ); + }); + }); + + test('given user is logged and workspace has valid organization in use, should validate the Score files remotely', async () => { + await vscode.workspace + .getConfiguration('humanitec') + .update('organization', humanitecOrg); + + await vscode.commands.executeCommand('humanitec.score.validate'); + + await waitForExpect(() => { + const diagnostics = vscode.languages.getDiagnostics(doc.uri); + expect(diagnostics).not.to.be.empty; + const messages = diagnostics.map(diagnostic => diagnostic.message); + expect(messages).to.contain.any.members([ + `additionalProperties 'invalid' not allowed`, + ]); + expect(statusBarItemHide.called).to.be.true; + }); + }); + + test('given user is logged and workspace has invalid organization in use, should validate the Score files remotely', async () => { + await vscode.workspace + .getConfiguration('humanitec') + .update('organization', 'invalid'); + + await vscode.commands.executeCommand('humanitec.score.validate'); + + await waitForExpect(() => { + expect(errorMessageShow).to.have.been.calledWith( + 'Invalid or empty Humanitec token. Login to Humanitec using "Humanitec: Login" or set your token using "Humanitec: Set token" command.' + ); + }); + }); +}); diff --git a/src/test/utils.ts b/src/test/utils.ts new file mode 100644 index 0000000..c7612f0 --- /dev/null +++ b/src/test/utils.ts @@ -0,0 +1,9 @@ +export const wait = (ms: number) => + new Promise(resolve => setTimeout(() => resolve(), ms)); + +export const readEnv = (name: string): string => { + if (!process.env[name]) { + throw new Error(`${name} not set`); + } + return process.env[name] || ''; +};