From 622aa311bfdbed40280a4e43b025ad02a0372a93 Mon Sep 17 00:00:00 2001 From: Ross Knudsen Date: Mon, 28 Sep 2020 22:09:30 +1300 Subject: [PATCH 1/3] feat: provide public API to subscribe to test results. --- CHANGELOG.md | 1 + src/JestExt.ts | 7 +++- src/extension.ts | 6 +++- src/extensionManager.ts | 72 ++++++++++++++++++++++++++++++++--------- tests/JestExt.test.ts | 54 ++++++++++++++++++++----------- 5 files changed, 105 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d0f11a3..5dced84ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Bug-fixes within the same version aren't needed ## Master +* add public api to expose interface for other vscode extensions to subscribe to test results updates. --> diff --git a/src/JestExt.ts b/src/JestExt.ts index 2253b2725..21743ba47 100644 --- a/src/JestExt.ts +++ b/src/JestExt.ts @@ -77,7 +77,8 @@ export class JestExt { debugConfigurationProvider: DebugConfigurationProvider, failDiagnostics: vscode.DiagnosticCollection, instanceSettings: InstanceSettings, - coverageCodeLensProvider: CoverageCodeLensProvider + coverageCodeLensProvider: CoverageCodeLensProvider, + private onTestResultsChanged: (results: JestTotalResults) => void ) { this.workspaceFolder = workspaceFolder; this.jestWorkspace = jestWorkspace; @@ -515,6 +516,10 @@ export class JestExt { private updateWithData(data: JestTotalResults): void { const noAnsiData = resultsWithoutAnsiEscapeSequence(data); const normalizedData = resultsWithLowerCaseWindowsDriveLetters(noAnsiData); + + // notify that there are new test results. + this.onTestResultsChanged(normalizedData); + this._updateCoverageMap(normalizedData.coverageMap); const statusList = this.testResultProvider.updateTestResults(normalizedData); diff --git a/src/extension.ts b/src/extension.ts index 4635c0317..88f2403f1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,9 @@ import { registerSnapshotCodeLens, registerSnapshotPreview } from './SnapshotCod let extensionManager: ExtensionManager; -export function activate(context: vscode.ExtensionContext): void { +export function activate( + context: vscode.ExtensionContext +): ReturnType { extensionManager = new ExtensionManager(context); const languages = [ @@ -82,6 +84,8 @@ export function activate(context: vscode.ExtensionContext): void { extensionManager ) ); + + return extensionManager.getPublicApi(); } export function deactivate(): void { diff --git a/src/extensionManager.ts b/src/extensionManager.ts index a0c1d183d..2b1c884c3 100644 --- a/src/extensionManager.ts +++ b/src/extensionManager.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import { ProjectWorkspace } from 'jest-editor-support'; +import { JestTotalResults, ProjectWorkspace } from 'jest-editor-support'; import { pathToJest, pathToConfig } from './helpers'; import { JestExt } from './JestExt'; import { DebugCodeLensProvider, TestState } from './DebugCodeLens'; @@ -48,6 +48,8 @@ export class ExtensionManager { private extByWorkspace: Map = new Map(); private context: vscode.ExtensionContext; private commonPluginSettings: PluginWindowSettings; + // keep track of any subscribers that want to be notified when there are new test results. + private subscribers: Array<(results: JestTotalResults) => void> = []; constructor(context: vscode.ExtensionContext) { this.context = context; @@ -97,21 +99,21 @@ export class ExtensionManager { `Jest (${workspaceFolder.name})` ); - this.extByWorkspace.set( - workspaceFolder.name, - new JestExt( - this.context, - workspaceFolder, - jestWorkspace, - channel, - pluginSettings, - this.debugCodeLensProvider, - this.debugConfigurationProvider, - failDiagnostics, - instanceSettings, - this.coverageCodeLensProvider - ) + const jestExt = new JestExt( + this.context, + workspaceFolder, + jestWorkspace, + channel, + pluginSettings, + this.debugCodeLensProvider, + this.debugConfigurationProvider, + failDiagnostics, + instanceSettings, + this.coverageCodeLensProvider, + this.onTestResultsChanged.bind(this) ); + + this.extByWorkspace.set(workspaceFolder.name, jestExt); } registerAll(): void { vscode.workspace.workspaceFolders.forEach(this.register, this); @@ -131,6 +133,8 @@ export class ExtensionManager { for (const key of keys) { this.unregisterByName(key); } + + this.subscribers = []; } shouldStart(workspaceFolderName: string): boolean { const { @@ -166,6 +170,30 @@ export class ExtensionManager { throw new Error(`No Jest instance in ${workspace.name} workspace`); } } + + /** + * Provides the public API for this extension. + */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + public getPublicApi() { + return { + /** + * Provides a means to subscribe to test results events. + * + * @param callback method to be called when there are new test results. + */ + subscribeToTestResults: (callback: (results: JestTotalResults) => void): (() => void) => { + this.subscribers.push(callback); + + // return an unsubscribe function. + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + return () => { + this.subscribers = this.subscribers.filter((s) => s !== callback); + }; + }, + }; + } + registerCommand( command: string, callback: (extension: JestExt, ...args: unknown[]) => unknown, @@ -216,4 +244,18 @@ export class ExtensionManager { ext.onDidChangeTextDocument(event); } } + + /** + * A handler for when there are new test results from one of the JestExt instances. + * @param results the new test results + */ + private onTestResultsChanged(results: JestTotalResults): void { + this.subscribers.forEach((subscriber) => { + try { + subscriber(results); + } catch (error) { + // swallow the error as we are not responsible for it. + } + }); + } } diff --git a/tests/JestExt.test.ts b/tests/JestExt.test.ts index 1cc02f824..9a924243c 100644 --- a/tests/JestExt.test.ts +++ b/tests/JestExt.test.ts @@ -79,7 +79,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); sut.canUpdateActiveEditor = jest.fn().mockReturnValueOnce(true); @@ -145,7 +146,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); const editor: any = { document: { fileName: 'file.js' }, @@ -196,7 +198,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); ((sut.debugConfigurationProvider .provideDebugConfigurations as unknown) as jest.Mock<{}>).mockReturnValue([ @@ -230,7 +233,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); const document = {} as any; sut.removeCachedTestResults = jest.fn(); @@ -259,7 +263,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); sut.testResultProvider.removeCachedResults = jest.fn(); @@ -295,7 +300,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); beforeEach(() => { @@ -331,7 +337,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); sut.triggerUpdateActiveEditor = jest.fn(); @@ -369,7 +376,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); }); @@ -447,7 +455,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); sut.triggerUpdateSettings = jest.fn(); sut.toggleCoverageOverlay(); @@ -467,7 +476,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); expect(projectWorkspace.collectCoverage).toBe(true); @@ -496,7 +506,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); sut.triggerUpdateActiveEditor(editor); @@ -513,7 +524,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); sut.updateDecorators = jest.fn(); const mockEditor: any = { @@ -554,7 +566,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); }); it('will skip if there is no document in editor', () => { @@ -613,7 +626,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); mockEditor.setDecorations = jest.fn(); @@ -685,7 +699,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); mockEditor.setDecorations = jest.fn(); @@ -725,7 +740,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, instanceSettings, - null + null, + () => {} ); const mockProcessManager: any = (JestProcessManager as jest.Mock).mock.instances[0]; mockProcessManager.startJestProcess.mockReturnValue(mockProcess); @@ -809,7 +825,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - null + null, + () => {} ); }); @@ -851,7 +868,8 @@ describe('JestExt', () => { debugConfigurationProvider, null, null, - coverageCodeLensProvider + coverageCodeLensProvider, + () => {} ); await sut._updateCoverageMap({}); expect(coverageCodeLensProvider.coverageChanged).toBeCalled(); From f81e223d85881c5662d3edd6229fe704d9e04b07 Mon Sep 17 00:00:00 2001 From: Ross Knudsen Date: Mon, 28 Sep 2020 22:48:38 +1300 Subject: [PATCH 2/3] test: fixed mock for existing tests --- tests/extension.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/extension.test.ts b/tests/extension.test.ts index be014468a..fea7e2b09 100644 --- a/tests/extension.test.ts +++ b/tests/extension.test.ts @@ -32,6 +32,7 @@ const extensionManager = { register: jest.fn(), getByName: jest.fn().mockReturnValue(jestInstance), get: jest.fn().mockReturnValue(jestInstance), + getPublicApi: jest.fn(), unregisterAll: jest.fn(), registerCommand: jest.fn().mockImplementation((...args) => args), }; From 2ea18069075f0f98d724821b67611693f950be2f Mon Sep 17 00:00:00 2001 From: Ross Knudsen Date: Tue, 29 Sep 2020 20:55:44 +1300 Subject: [PATCH 3/3] test: added tests for public API --- __mocks__/supports-color.ts | 3 ++ __mocks__/vscode.ts | 1 + tests/JestExt.test.ts | 53 +++++++++++++++++++++++- tests/extension.test.ts | 13 +++++- tests/extensionManager.test.ts | 76 ++++++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 __mocks__/supports-color.ts diff --git a/__mocks__/supports-color.ts b/__mocks__/supports-color.ts new file mode 100644 index 000000000..0b4534d7c --- /dev/null +++ b/__mocks__/supports-color.ts @@ -0,0 +1,3 @@ +module.exports = { + supportsColor: () => false, +}; diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts index f952e034c..7fffe3811 100644 --- a/__mocks__/vscode.ts +++ b/__mocks__/vscode.ts @@ -17,6 +17,7 @@ const window = { showWorkspaceFolderPick: jest.fn(), onDidChangeActiveTextEditor: jest.fn(), showInformationMessage: jest.fn(), + visibleTextEditors: [], } const workspace = { diff --git a/tests/JestExt.test.ts b/tests/JestExt.test.ts index 9a924243c..78e821213 100644 --- a/tests/JestExt.test.ts +++ b/tests/JestExt.test.ts @@ -24,7 +24,7 @@ const statusBar = { jest.mock('../src/StatusBar', () => ({ statusBar })); import { JestExt } from '../src/JestExt'; -import { ProjectWorkspace } from 'jest-editor-support'; +import { JestTotalResults, ProjectWorkspace } from 'jest-editor-support'; import { window, workspace, debug, ExtensionContext, TextEditorDecorationType } from 'vscode'; import { hasDocument, isOpenInMultipleEditors } from '../src/editor'; import { TestStatus } from '../src/decorations/test-status'; @@ -33,6 +33,8 @@ import { JestProcessManager, JestProcess } from '../src/JestProcessManagement'; import * as messaging from '../src/messaging'; import { CoverageMapProvider } from '../src/Coverage'; import inlineError from '../src/decorations/inline-error'; +import { resultsWithLowerCaseWindowsDriveLetters } from '../src/TestResults'; +import { resultsWithoutAnsiEscapeSequence } from '../src/TestResults/TestResult'; /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "expectItTakesNoAction"] }] */ @@ -804,6 +806,55 @@ describe('JestExt', () => { }); }); + describe('updateWithData', () => { + let sut: JestExt; + const testResultsCallback = jest.fn(); + + const settings: any = { + debugCodeLens: {}, + enableSnapshotUpdateMessages: true, + }; + + beforeEach(() => { + jest.resetAllMocks(); + const projectWorkspace = new ProjectWorkspace(null, null, null, null); + sut = new JestExt( + context, + workspaceFolder, + projectWorkspace, + channelStub, + settings, + debugCodeLensProvider, + debugConfigurationProvider, + null, + null, + null, + testResultsCallback + ); + + ((resultsWithLowerCaseWindowsDriveLetters as unknown) as jest.Mock< + typeof resultsWithLowerCaseWindowsDriveLetters + >).mockImplementationOnce(() => (data: JestTotalResults) => data); + ((resultsWithoutAnsiEscapeSequence as unknown) as jest.Mock< + typeof resultsWithoutAnsiEscapeSequence + >).mockImplementationOnce(() => (data: JestTotalResults) => data); + }); + + it('should call onTestResultsChanged when new test results are available', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + const testResults: JestTotalResults = { + coverageMap: {}, + }; + // the _updateCoverageMap method is tested elsewhere. We just stub it out. + (sut as any)._updateCoverageMap = () => Promise.resolve(); + + (sut as any).updateWithData(testResults); + + expect(testResultsCallback).toHaveBeenCalled(); + }); + }); + describe('handleJestEditorSupportEvent()', () => { let sut: JestExt; diff --git a/tests/extension.test.ts b/tests/extension.test.ts index fea7e2b09..b77cfabe3 100644 --- a/tests/extension.test.ts +++ b/tests/extension.test.ts @@ -28,11 +28,13 @@ const jestInstance = { restartProcess: jest.fn(), }; +const publicApi = {}; + const extensionManager = { register: jest.fn(), getByName: jest.fn().mockReturnValue(jestInstance), get: jest.fn().mockReturnValue(jestInstance), - getPublicApi: jest.fn(), + getPublicApi: jest.fn().mockReturnValue(publicApi), unregisterAll: jest.fn(), registerCommand: jest.fn().mockImplementation((...args) => args), }; @@ -116,6 +118,15 @@ describe('Extension', () => { expect(context.subscriptions.push.mock.calls[0]).toContain('onDidChangeWorkspaceFolders'); }); + it('should return ExtensionManager.getPublicApi', () => { + extensionManager.getPublicApi.mockClear(); + + const api = activate(context); + + expect(extensionManager.getPublicApi).toHaveBeenCalledTimes(1); + expect(api).toBe(publicApi); + }); + describe('should register a command', () => { beforeEach(() => { jestInstance.toggleCoverageOverlay.mockReset(); diff --git a/tests/extensionManager.test.ts b/tests/extensionManager.test.ts index 3fe8a040d..680b022c5 100644 --- a/tests/extensionManager.test.ts +++ b/tests/extensionManager.test.ts @@ -386,4 +386,80 @@ describe('InstancesManager', () => { }); }); }); + + describe('getPublicApi()', () => { + const callbackMock = jest.fn(); + let api: any; + beforeEach(() => { + callbackMock.mockClear(); + + // grab a reference to the public API each time the extension is recreated. + api = extensionManager.getPublicApi(); + }); + + it('should return an object with a "subscribeToTestResults" method on it', () => { + expect(typeof api.subscribeToTestResults).toBe('function'); + + const unsubscribe = api.subscribeToTestResults(callbackMock); + + expect(callbackMock).not.toBeCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + describe('subscribeToTestResults()', () => { + const fakeTestResults = {}; + let notifyNewTestResults: () => void; + + beforeEach(() => { + // pretend that an instance of JestExt has called to JestExtension with test results. + // alternative ways this could work, is to receive the constructor parameters to the + // fake JestExt and invoke the callback there. + notifyNewTestResults = () => + (extensionManager as any).onTestResultsChanged(fakeTestResults); + }); + + it('should not throw an error if the callback throws', () => { + callbackMock.mockImplementationOnce(() => { + throw new Error(); + }); + + api.subscribeToTestResults(callbackMock); + notifyNewTestResults(); + + expect(callbackMock).toBeCalledTimes(1); + expect(callbackMock).toBeCalledWith(fakeTestResults); + }); + + it('should invoke subscribe callback when new test results are available', () => { + api.subscribeToTestResults(callbackMock); + + notifyNewTestResults(); + + expect(callbackMock).toBeCalledTimes(1); + expect(callbackMock).toBeCalledWith(fakeTestResults); + }); + + it('should invoke multiple subscribers when new test results are available', () => { + api.subscribeToTestResults(callbackMock); + const secondSubscriber = jest.fn(); + api.subscribeToTestResults(secondSubscriber); + + notifyNewTestResults(); + + expect(callbackMock).toBeCalledTimes(1); + expect(callbackMock).toBeCalledWith(fakeTestResults); + expect(secondSubscriber).toBeCalledTimes(1); + expect(secondSubscriber).toBeCalledWith(fakeTestResults); + }); + + it('should not invoke subscribe callback after unsubscribe', () => { + const unsubscribe = api.subscribeToTestResults(callbackMock); + + unsubscribe(); + notifyNewTestResults(); + + expect(callbackMock).not.toBeCalled(); + }); + }); + }); });