From 0a96497558baee69ea1cd66566f0b9c88e08451c Mon Sep 17 00:00:00 2001 From: ConnectDotz Date: Tue, 26 Jan 2021 21:13:06 -0500 Subject: [PATCH] Full parameterized tests support (#649) --- CHANGELOG.md | 2 + package.json | 2 +- src/DebugCodeLens/DebugCodeLens.ts | 17 +- src/DebugCodeLens/DebugCodeLensProvider.ts | 23 +-- src/DebugCodeLens/index.ts | 1 + src/JestExt.ts | 81 ++++++--- src/TestResults/TestReconciliationState.ts | 14 +- src/TestResults/TestResult.ts | 33 +++- src/TestResults/TestResultProvider.ts | 43 ++++- src/TestResults/index.ts | 7 +- src/TestResults/match-by-context.ts | 35 ++-- src/TestResults/match-node.ts | 32 +++- src/diagnostics.ts | 75 ++++---- src/extension.ts | 5 +- src/helpers.ts | 23 ++- tests/DebugCodeLens/DebugCodeLens.test.ts | 2 +- .../DebugCodeLensProvider.test.ts | 49 +++-- tests/JestExt.test.ts | 151 ++++++++++++++-- tests/TestResults/TestResultProvider.test.ts | 170 +++++++++++++++++- tests/TestResults/match-by-context.test.ts | 97 +++------- tests/diagnostics.test.ts | 67 ++++++- tests/helpers.test.ts | 31 ++++ tests/test-helper.ts | 21 ++- yarn.lock | 12 +- 24 files changed, 744 insertions(+), 249 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfa231e..4a8cddee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Please add your own contribution below inside the Master section Bug-fixes within the same version aren't needed ## Master +* fully support parameterized tests in matching, diagnosis and debugging - @connectdotz +* optimization: remove stop/start the internal jest tests process during debug - @connectdotz * add a new setting for "jest.jestCommandLine" that supersede "jest.pathToJest" and "jest.pathToConfig" - @connectdotz --> diff --git a/package.json b/package.json index da5202de..ba05bca7 100644 --- a/package.json +++ b/package.json @@ -292,7 +292,7 @@ "dependencies": { "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", - "jest-editor-support": "^28.0.0", + "jest-editor-support": "^28.1.0", "jest-snapshot": "^25.5.0", "vscode-codicons": "^0.0.4" }, diff --git a/src/DebugCodeLens/DebugCodeLens.ts b/src/DebugCodeLens/DebugCodeLens.ts index 893cfc5f..c13a2f0a 100644 --- a/src/DebugCodeLens/DebugCodeLens.ts +++ b/src/DebugCodeLens/DebugCodeLens.ts @@ -1,19 +1,30 @@ import * as vscode from 'vscode'; +import { TestIdentifier } from '../TestResults'; +export type DebugTestIdentifier = string | TestIdentifier; export class DebugCodeLens extends vscode.CodeLens { readonly fileName: string; - readonly testName: string; + readonly testIds: DebugTestIdentifier[]; readonly document: vscode.TextDocument; + /** + * + * @param document + * @param range + * @param fileName + * @param testIds test name/pattern. + * Because a test block can have multiple test results, such as for paramertized tests (i.e. test.each/describe.each), there could be multiple debuggable candidates, thus it takes multiple test identifiers. + * Noite: If a test id is a string array, it should represent the hierarchiical relationship of a test structure, such as [describe-id, test-id]. + */ constructor( document: vscode.TextDocument, range: vscode.Range, fileName: string, - testName: string + ...testIds: DebugTestIdentifier[] ) { super(range); this.document = document; this.fileName = fileName; - this.testName = testName; + this.testIds = testIds; } } diff --git a/src/DebugCodeLens/DebugCodeLensProvider.ts b/src/DebugCodeLens/DebugCodeLensProvider.ts index bd1b61c7..7298101a 100644 --- a/src/DebugCodeLens/DebugCodeLensProvider.ts +++ b/src/DebugCodeLens/DebugCodeLensProvider.ts @@ -1,9 +1,8 @@ import * as vscode from 'vscode'; import { extensionName } from '../appGlobals'; -import { escapeRegExp } from '../helpers'; import { basename } from 'path'; import { DebugCodeLens } from './DebugCodeLens'; -import { TestReconciliationState } from '../TestResults'; +import { TestReconciliationStateType } from '../TestResults'; import { TestState, TestStateByTestReconciliationState } from './TestState'; import { GetJestExtByURI } from '../extensionManager'; @@ -18,7 +17,7 @@ export class DebugCodeLensProvider implements vscode.CodeLensProvider { this.onDidChange = new vscode.EventEmitter(); } - get showWhenTestStateIn() { + get showWhenTestStateIn(): TestState[] { return this._showWhenTestStateIn; } @@ -31,7 +30,7 @@ export class DebugCodeLensProvider implements vscode.CodeLensProvider { return this.onDidChange.event; } - provideCodeLenses(document: vscode.TextDocument, _: vscode.CancellationToken): vscode.CodeLens[] { + provideCodeLenses(document: vscode.TextDocument, _: vscode.CancellationToken): DebugCodeLens[] { const result = []; const ext = this.getJestExt(document.uri); if (!ext || this._showWhenTestStateIn.length === 0 || document.isUntitled) { @@ -43,20 +42,24 @@ export class DebugCodeLensProvider implements vscode.CodeLensProvider { const fileName = basename(document.fileName); for (const test of testResults) { - if (!this.showCodeLensAboveTest(test)) { + const results = test.multiResults ? [test, ...test.multiResults] : [test]; + const allIds = results.filter((r) => this.showCodeLensAboveTest(r)).map((r) => r.identifier); + + if (!allIds.length) { continue; } const start = new vscode.Position(test.start.line, test.start.column); const end = new vscode.Position(test.end.line, test.start.column + 5); const range = new vscode.Range(start, end); - result.push(new DebugCodeLens(document, range, fileName, test.name)); + + result.push(new DebugCodeLens(document, range, fileName, ...allIds)); } return result; } - showCodeLensAboveTest(test: { status: TestReconciliationState }) { + showCodeLensAboveTest(test: { status: TestReconciliationStateType }): boolean { const state = TestStateByTestReconciliationState[test.status]; return this._showWhenTestStateIn.includes(state); } @@ -67,16 +70,16 @@ export class DebugCodeLensProvider implements vscode.CodeLensProvider { ): vscode.ProviderResult { if (codeLens instanceof DebugCodeLens) { codeLens.command = { - arguments: [codeLens.document, codeLens.fileName, escapeRegExp(codeLens.testName)], + arguments: [codeLens.document, codeLens.fileName, ...codeLens.testIds], command: `${extensionName}.run-test`, - title: 'Debug', + title: codeLens.testIds.length > 1 ? `Debug(${codeLens.testIds.length})` : 'Debug', }; } return codeLens; } - didChange() { + didChange(): void { this.onDidChange.fire(); } } diff --git a/src/DebugCodeLens/index.ts b/src/DebugCodeLens/index.ts index ada340ea..004d02c1 100644 --- a/src/DebugCodeLens/index.ts +++ b/src/DebugCodeLens/index.ts @@ -1,2 +1,3 @@ export { DebugCodeLensProvider } from './DebugCodeLensProvider'; export { TestState } from './TestState'; +export { DebugTestIdentifier } from './DebugCodeLens'; diff --git a/src/JestExt.ts b/src/JestExt.ts index 7940180e..0795fccc 100644 --- a/src/JestExt.ts +++ b/src/JestExt.ts @@ -11,8 +11,16 @@ import { TestResult, resultsWithLowerCaseWindowsDriveLetters, SortedTestResults, + TestResultStatusInfo, + TestReconciliationStateType, } from './TestResults'; -import { cleanAnsi, getJestCommandSettings } from './helpers'; +import { + cleanAnsi, + testIdString, + IdStringType, + getJestCommandSettings, + escapeRegExp, +} from './helpers'; import { CoverageMapProvider, CoverageCodeLensProvider } from './Coverage'; import { updateDiagnostics, @@ -20,7 +28,7 @@ import { resetDiagnostics, failedSuiteCount, } from './diagnostics'; -import { DebugCodeLensProvider } from './DebugCodeLens'; +import { DebugCodeLensProvider, DebugTestIdentifier } from './DebugCodeLens'; import { DebugConfigurationProvider } from './DebugConfigurationProvider'; import { DecorationOptions } from './types'; import { isOpenInMultipleEditors } from './editor'; @@ -35,6 +43,9 @@ interface InstanceSettings { multirootEnv: boolean; } +interface RunTestPickItem extends vscode.QuickPickItem { + id: DebugTestIdentifier; +} export class JestExt { coverageMapProvider: CoverageMapProvider; coverageOverlay: CoverageOverlay; @@ -297,19 +308,43 @@ export class JestExt { public runTest = async ( workspaceFolder: vscode.WorkspaceFolder, fileName: string, - identifier: string + ...ids: DebugTestIdentifier[] ): Promise => { - const restart = this.jestProcessManager.numberOfProcesses > 0; - this.jestProcessManager.stopAll(); + const idString = (type: IdStringType, id: DebugTestIdentifier): string => + typeof id === 'string' ? id : testIdString(type, id); + const selectTest = async ( + testIdentifiers: DebugTestIdentifier[] + ): Promise => { + const items: RunTestPickItem[] = testIdentifiers.map((id) => ({ + label: idString('display-reverse', id), + id, + })); + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'select a test to debug', + }); - this.debugConfigurationProvider.prepareTestRun(fileName, identifier); + return selected?.id; + }; + let testId: DebugTestIdentifier | undefined; + switch (ids.length) { + case 0: + return; + case 1: + testId = ids[0]; + break; + default: + testId = await selectTest(ids); + break; + } - const handle = vscode.debug.onDidTerminateDebugSession(() => { - handle.dispose(); - if (restart) { - this.startProcess(); - } - }); + if (!testId) { + return; + } + + this.debugConfigurationProvider.prepareTestRun( + fileName, + escapeRegExp(idString('full-name', testId)) + ); try { // try to run the debug configuration from launch.json @@ -551,22 +586,12 @@ export class JestExt { private generateDotsForItBlocks( blocks: TestResult[], - state: TestReconciliationState + state: TestReconciliationStateType ): DecorationOptions[] { - const nameForState = { - [TestReconciliationState.KnownSuccess]: 'Passed', - [TestReconciliationState.KnownFail]: 'Failed', - [TestReconciliationState.KnownSkip]: 'Skipped', - [TestReconciliationState.Unknown]: - 'Test has not run yet, due to Jest only running tests related to changes.', - }; - - return blocks.map((it) => { - return { - range: new vscode.Range(it.start.line, it.start.column, it.start.line, it.start.column + 1), - hoverMessage: nameForState[state], - identifier: it.name, - }; - }); + return blocks.map((it) => ({ + range: new vscode.Range(it.start.line, it.start.column, it.start.line, it.start.column + 1), + hoverMessage: TestResultStatusInfo[state].desc, + identifier: it.name, + })); } } diff --git a/src/TestResults/TestReconciliationState.ts b/src/TestResults/TestReconciliationState.ts index d704dd9a..9520a4dc 100644 --- a/src/TestResults/TestReconciliationState.ts +++ b/src/TestResults/TestReconciliationState.ts @@ -1,9 +1,11 @@ -export type TestReconciliationState = 'Unknown' | 'KnownSuccess' | 'KnownFail' | 'KnownSkip'; +export type TestReconciliationStateType = 'Unknown' | 'KnownSuccess' | 'KnownFail' | 'KnownSkip'; // tslint:disable-next-line variable-name -export const TestReconciliationState = { - Unknown: 'Unknown' as TestReconciliationState, - KnownSuccess: 'KnownSuccess' as TestReconciliationState, - KnownFail: 'KnownFail' as TestReconciliationState, - KnownSkip: 'KnownSkip' as TestReconciliationState, +export const TestReconciliationState: { + [key in TestReconciliationStateType]: TestReconciliationStateType; +} = { + Unknown: 'Unknown', + KnownSuccess: 'KnownSuccess', + KnownFail: 'KnownFail', + KnownSkip: 'KnownSkip', }; diff --git a/src/TestResults/TestResult.ts b/src/TestResults/TestResult.ts index d469afbc..c9903f01 100644 --- a/src/TestResults/TestResult.ts +++ b/src/TestResults/TestResult.ts @@ -1,4 +1,4 @@ -import { TestReconciliationState } from './TestReconciliationState'; +import { TestReconciliationStateType } from './TestReconciliationState'; import { JestFileResults, JestTotalResults } from 'jest-editor-support'; import { FileCoverage } from 'istanbul-lib-coverage'; import * as path from 'path'; @@ -17,21 +17,24 @@ export interface LocationRange { end: Location; } +export interface TestIdentifier { + title: string; + ancestorTitles: string[]; +} export interface TestResult extends LocationRange { name: string; - names: { - src: string; - assertionTitle?: string; - assertionFullName?: string; - }; + identifier: TestIdentifier; - status: TestReconciliationState; + status: TestReconciliationStateType; shortMessage?: string; terseMessage?: string; /** Zero-based line number */ lineNumberOfError?: number; + + // multiple results for the given range, common for parameterized (.each) tests + multiResults?: TestResult[]; } export const withLowerCaseWindowsDriveLetter = (filePath: string): string | undefined => { @@ -131,3 +134,19 @@ export const resultsWithoutAnsiEscapeSequence = (data: JestTotalResults): JestTo })), }; }; + +// export type StatusInfo = {[key in TestReconciliationState]: T}; +export interface StatusInfo { + precedence: number; + desc: string; +} + +export const TestResultStatusInfo: { [key in TestReconciliationStateType]: StatusInfo } = { + KnownFail: { precedence: 1, desc: 'Failed' }, + Unknown: { + precedence: 2, + desc: 'Test has not run yet, due to Jest only running tests related to changes.', + }, + KnownSkip: { precedence: 3, desc: 'Skipped' }, + KnownSuccess: { precedence: 4, desc: 'Passed' }, +}; diff --git a/src/TestResults/TestResultProvider.ts b/src/TestResults/TestResultProvider.ts index fd47c42b..4f9682ac 100644 --- a/src/TestResults/TestResultProvider.ts +++ b/src/TestResults/TestResultProvider.ts @@ -1,6 +1,6 @@ import { TestReconciler, JestTotalResults, TestFileAssertionStatus } from 'jest-editor-support'; import { TestReconciliationState } from './TestReconciliationState'; -import { TestResult } from './TestResult'; +import { TestResult, TestResultStatusInfo } from './TestResult'; import { parseTest } from '../TestParser'; import * as match from './match-by-context'; @@ -19,6 +19,12 @@ interface SortedTestResultsMap { [filePath: string]: SortedTestResults; } +const sortByStatus = (a: TestResult, b: TestResult): number => { + if (a.status === b.status) { + return 0; + } + return TestResultStatusInfo[a.status].precedence - TestResultStatusInfo[b.status].precedence; +}; export class TestResultProvider { verbose: boolean; private reconciler: TestReconciler; @@ -36,6 +42,36 @@ export class TestResultProvider { this.sortedResultsByFilePath = {}; } + private groupByRange(results: TestResult[]): TestResult[] { + if (!results.length) { + return results; + } + // build a range based map + const byRange: Map = new Map(); + results.forEach((r) => { + // Q: is there a better/efficient way to index the range? + const key = `${r.start.line}-${r.start.column}-${r.end.line}-${r.end.column}`; + const list = byRange.get(key); + if (!list) { + byRange.set(key, [r]); + } else { + list.push(r); + } + }); + // sort the test by status precedence + byRange.forEach((list) => list.sort(sortByStatus)); + + //merge multiResults under the primary (highest precedence) + const consolidated: TestResult[] = []; + byRange.forEach((list) => { + if (list.length > 1) { + list[0].multiResults = list.slice(1); + } + consolidated.push(list[0]); + }); + return consolidated; + } + getResults(filePath: string): TestResult[] { if (this.resultsByFilePath[filePath]) { return this.resultsByFilePath[filePath]; @@ -60,8 +96,9 @@ export class TestResultProvider { } catch (e) { console.warn(`failed to get test result for ${filePath}:`, e); } - this.resultsByFilePath[filePath] = matchResult; - return matchResult; + const testResults = this.groupByRange(matchResult); + this.resultsByFilePath[filePath] = testResults; + return testResults; } getSortedResults(filePath: string): SortedTestResults { diff --git a/src/TestResults/index.ts b/src/TestResults/index.ts index 2f1331cb..4625d279 100644 --- a/src/TestResults/index.ts +++ b/src/TestResults/index.ts @@ -1,3 +1,8 @@ export * from './TestReconciliationState'; -export { TestResult, resultsWithLowerCaseWindowsDriveLetters } from './TestResult'; +export { + TestResult, + resultsWithLowerCaseWindowsDriveLetters, + TestResultStatusInfo, + TestIdentifier, +} from './TestResult'; export * from './TestResultProvider'; diff --git a/src/TestResults/match-by-context.ts b/src/TestResults/match-by-context.ts index 514bd538..4135d2e4 100644 --- a/src/TestResults/match-by-context.ts +++ b/src/TestResults/match-by-context.ts @@ -74,10 +74,9 @@ export const toMatchResult = ( // Note the shift from one-based to zero-based line number and columns return { name: assertion?.fullName ?? assertion?.title ?? test.name, - names: { - src: test.name, - assertionTitle: assertion?.title, - assertionFullName: assertion?.fullName, + identifier: { + title: assertion?.title, + ancestorTitles: assertion?.ancestorTitles, }, start: adjustLocation(test.start), end: adjustLocation(test.end), @@ -90,7 +89,7 @@ export const toMatchResult = ( /** mark all data and child containers unmatched */ const toUnmatchedResults = (tContainer: ContainerNode, err: string): TestResult[] => { - const results = tContainer.childData.map((n) => toMatchResult(n.only(), err)); + const results = tContainer.childData.map((n) => toMatchResult(n.single(), err)); tContainer.childContainers.forEach((c) => results.push(...toUnmatchedResults(c, err))); return results; }; @@ -146,31 +145,25 @@ const ContextMatch = (messaging: Messaging): ContextMatchAlgorithm => { const handleTestBlockMatch = ( t: DataNode, matched: DataNode[] - ): TestResult => { + ): TestResult[] => { if (matched.length !== 1) { - return toMatchResult(t.only(), `found ${matched.length} matched assertion(s)`); + return [toMatchResult(t.single(), `found ${matched.length} matched assertion(s)`)]; } const a = matched[0]; - const itBlock = t.only(); + const itBlock = t.single(); switch (a.data.length) { case 0: throw new TypeError(`invalid state: assertion data should not be empty if it is a match!`); case 1: { - const assertion = a.only(); + const assertion = a.single(); if (a.name !== t.name && !matchPos(itBlock, assertion)) { messaging('unusual-match', 'data', t, a, 'neither name nor line matched'); } - return toMatchResult(itBlock, assertion); + return [toMatchResult(itBlock, assertion)]; } default: { - // 1-to-many - messaging('unusual-match', 'data', t, a, '1-to-many match, jest.each perhaps?'); - - // TODO: support multiple errorLine - // until we support multiple errors, choose the first error assertion, if any - const assertions = - a.data.find((assertion) => assertion.status === 'KnownFail') || a.first(); - return toMatchResult(itBlock, assertions); + // 1-to-many: parameterized tests + return a.data.map((a) => toMatchResult(itBlock, a)); } } }; @@ -231,12 +224,16 @@ const ContextMatch = (messaging: Messaging): ContextMatchAlgorithm => { ): TestResult[] => { const matchResults: TestResult[] = []; matchChildren('data', tContainer, aContainer, (t, a) => - matchResults.push(handleTestBlockMatch(t, a)) + matchResults.push(...handleTestBlockMatch(t, a)) ); matchChildren('container', tContainer, aContainer, (t, a) => matchResults.push(...handleDescribeBlockMatch(t, a)) ); + if (aContainer.group) { + aContainer.group.forEach((c) => matchResults.push(...matchContainers(tContainer, c))); + } + return matchResults; }; return { match: matchContainers }; diff --git a/src/TestResults/match-node.ts b/src/TestResults/match-node.ts index ee783519..7857f8a0 100644 --- a/src/TestResults/match-node.ts +++ b/src/TestResults/match-node.ts @@ -5,6 +5,7 @@ export interface BaseNodeType { zeroBasedLine: number; name: string; + merge: (another: this) => boolean; } /* interface implementation */ @@ -27,8 +28,8 @@ export class DataNode implements BaseNodeType { return true; } - /** return the only element in the list, exception otherwise */ - only(): T { + /** return the single element in the list, exception otherwise */ + single(): T { if (this.data.length !== 1) { throw new TypeError(`expect 1 element but got ${this.data.length} elements`); } @@ -48,6 +49,7 @@ export class ContainerNode implements BaseNodeType { public childData: DataNode[] = []; public zeroBasedLine: number; public name: string; + public group?: ContainerNode[]; constructor(name: string) { this.name = name; @@ -62,6 +64,18 @@ export class ContainerNode implements BaseNodeType { this.childData.push(dataNode); } + merge(another: ContainerNode): boolean { + if (another.zeroBasedLine !== this.zeroBasedLine) { + return false; + } + if (!this.group) { + this.group = [another]; + } else { + this.group.push(another); + } + return true; + } + public findContainer(path: string[], createIfMissing = true): ContainerNode | undefined { if (path.length <= 0) { return this; @@ -83,25 +97,29 @@ export class ContainerNode implements BaseNodeType { public sort(grouping = false): void { const sortByLine = (n1: BaseNodeType, n2: BaseNodeType): number => n1.zeroBasedLine - n2.zeroBasedLine; - const groupData = (list: DataNode[], data: DataNode): DataNode[] => { + // group nodes after sort + const groupNodes = (list: N[], node: N): N[] => { if (list.length <= 0) { - return [data]; + return [node]; } // if not able to merge with previous node, i.e . can not group, add it to the list - if (!list[list.length - 1].merge(data)) { - list.push(data); + if (!list[list.length - 1].merge(node)) { + list.push(node); } return list; }; this.childData.sort(sortByLine); if (grouping) { - this.childData = this.childData.reduce(groupData, []); + this.childData = this.childData.reduce(groupNodes, []); } // recursive to sort childContainers, which will update its lineNumber and then sort the list itself this.childContainers.forEach((c) => c.sort(grouping)); this.childContainers.sort(sortByLine); + if (grouping) { + this.childContainers = this.childContainers.reduce(groupNodes, []); + } // if container doesn't have valid line info, use the first child's if (this.zeroBasedLine < 0) { diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 7d38220c..90530eb4 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -4,12 +4,17 @@ */ import * as vscode from 'vscode'; import { existsSync } from 'fs'; -// import { DiagnosticCollection, Uri, Diagnostic, Range, DiagnosticSeverity } from 'vscode' import { TestFileAssertionStatus } from 'jest-editor-support'; import { TestReconciliationState, TestResult } from './TestResults'; +import { testIdString } from './helpers'; -function createDiagnosticWithRange(message: string, range: vscode.Range): vscode.Diagnostic { - const diag = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error); +function createDiagnosticWithRange( + message: string, + range: vscode.Range, + testName?: string +): vscode.Diagnostic { + const msg = testName ? `${testName}\n-----\n${message}` : message; + const diag = new vscode.Diagnostic(range, msg, vscode.DiagnosticSeverity.Error); diag.source = 'Jest'; return diag; } @@ -17,35 +22,42 @@ function createDiagnosticWithRange(message: string, range: vscode.Range): vscode function createDiagnostic( message: string, lineNumber: number, + name?: string, startCol = 0, endCol = Number.MAX_SAFE_INTEGER ): vscode.Diagnostic { const line = lineNumber > 0 ? lineNumber - 1 : 0; - return createDiagnosticWithRange(message, new vscode.Range(line, startCol, line, endCol)); + return createDiagnosticWithRange(message, new vscode.Range(line, startCol, line, endCol), name); } // update diagnostics for the active editor // it will utilize the parsed test result to mark actual text position. export function updateCurrentDiagnostics( - testResult: TestResult[], - diagnostics: vscode.DiagnosticCollection, + testResults: TestResult[], + collection: vscode.DiagnosticCollection, editor: vscode.TextEditor -) { +): void { const uri = editor.document.uri; - if (!testResult.length) { - diagnostics.delete(uri); + if (!testResults.length) { + collection.delete(uri); return; } + const allDiagnostics = testResults.reduce((list, tr) => { + const allResults = tr.multiResults ? [tr, ...tr.multiResults] : [tr]; + const diagnostics = allResults + .filter((r) => r.status === TestReconciliationState.KnownFail) + .map((r) => { + const line = r.lineNumberOfError || r.end.line; + const textLine = editor.document.lineAt(line); + const name = testIdString('display', r.identifier); + return createDiagnosticWithRange(r.shortMessage, textLine.range, name); + }); + list.push(...diagnostics); + return list; + }, []); - diagnostics.set( - uri, - testResult.map((r) => { - const line = r.lineNumberOfError || r.end.line; - const textLine = editor.document.lineAt(line); - return createDiagnosticWithRange(r.shortMessage, textLine.range); - }) - ); + collection.set(uri, allDiagnostics); } // update all diagnosis with jest test results @@ -56,20 +68,21 @@ export function updateCurrentDiagnostics( export function updateDiagnostics( testResults: TestFileAssertionStatus[], - diagnostics: vscode.DiagnosticCollection -) { - function addTestFileError(result: TestFileAssertionStatus, uri: vscode.Uri) { - const diag = createDiagnostic(result.message || 'test file error', 0, 0, 0); - diagnostics.set(uri, [diag]); + collection: vscode.DiagnosticCollection +): void { + function addTestFileError(result: TestFileAssertionStatus, uri: vscode.Uri): void { + const diag = createDiagnostic(result.message || 'test file error', 0, undefined, 0, 0); + collection.set(uri, [diag]); } - function addTestsError(result: TestFileAssertionStatus, uri: vscode.Uri) { + function addTestsError(result: TestFileAssertionStatus, uri: vscode.Uri): void { const asserts = result.assertions.filter((a) => a.status === TestReconciliationState.KnownFail); - diagnostics.set( + collection.set( uri, - asserts.map((assertion) => - createDiagnostic(assertion.shortMessage || assertion.message, assertion.line) - ) + asserts.map((assertion) => { + const name = testIdString('display', assertion); + return createDiagnostic(assertion.shortMessage || assertion.message, assertion.line, name); + }) ); } @@ -84,24 +97,24 @@ export function updateDiagnostics( } break; default: - diagnostics.delete(uri); + collection.delete(uri); break; } }); // Remove diagnostics for files no longer in existence const toBeDeleted = []; - diagnostics.forEach((uri) => { + collection.forEach((uri) => { if (!existsSync(uri.fsPath)) { toBeDeleted.push(uri); } }); toBeDeleted.forEach((uri) => { - diagnostics.delete(uri); + collection.delete(uri); }); } -export function resetDiagnostics(diagnostics: vscode.DiagnosticCollection) { +export function resetDiagnostics(diagnostics: vscode.DiagnosticCollection): void { diagnostics.clear(); } export function failedSuiteCount(diagnostics: vscode.DiagnosticCollection): number { diff --git a/src/extension.ts b/src/extension.ts index 4635c031..91edc6cc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,7 @@ import { extensionName } from './appGlobals'; import { statusBar } from './StatusBar'; import { ExtensionManager, getExtensionWindowSettings } from './extensionManager'; import { registerSnapshotCodeLens, registerSnapshotPreview } from './SnapshotCodeLens'; +import { DebugTestIdentifier } from './DebugCodeLens'; let extensionManager: ExtensionManager; @@ -39,9 +40,9 @@ export function activate(context: vscode.ExtensionContext): void { ), vscode.commands.registerCommand( `${extensionName}.run-test`, - (document: vscode.TextDocument, filename: string, identifier: string) => { + (document: vscode.TextDocument, filename: string, ...identifiers: DebugTestIdentifier[]) => { const workspace = vscode.workspace.getWorkspaceFolder(document.uri); - extensionManager.getByName(workspace.name).runTest(workspace, filename, identifier); + extensionManager.getByName(workspace.name).runTest(workspace, filename, ...identifiers); } ), ...registerSnapshotCodeLens(getExtensionWindowSettings().enableSnapshotPreviews), diff --git a/src/helpers.ts b/src/helpers.ts index efc75fc8..8a7a5c5d 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -4,6 +4,7 @@ import { normalize, join } from 'path'; import { ExtensionContext } from 'vscode'; import { PluginResourceSettings, hasUserSetPathToJest } from './Settings'; +import { TestIdentifier } from './TestResults'; /** * Known binary names of `react-scripts` forks @@ -83,7 +84,7 @@ function isBootstrappedWithCreateReactApp(rootPath: string): boolean { * @returns {string} */ // tslint:disable-next-line no-shadowed-variable -export function pathToJest({ pathToJest, rootPath }: PluginResourceSettings) { +export function pathToJest({ pathToJest, rootPath }: PluginResourceSettings): string { if (hasUserSetPathToJest(pathToJest)) { return normalize(pathToJest); } @@ -101,7 +102,7 @@ export function pathToJest({ pathToJest, rootPath }: PluginResourceSettings) { * * @returns {string} */ -export function pathToConfig(pluginSettings: PluginResourceSettings) { +export function pathToConfig(pluginSettings: PluginResourceSettings): string { if (pluginSettings.pathToConfig !== '') { return normalize(pluginSettings.pathToConfig); } @@ -127,6 +128,22 @@ export function cleanAnsi(str: string): string { ); } +export type IdStringType = 'display' | 'display-reverse' | 'full-name'; +export function testIdString(type: IdStringType, identifier: TestIdentifier): string { + if (!identifier.ancestorTitles.length) { + return identifier.title; + } + const parts = [...identifier.ancestorTitles, identifier.title]; + switch (type) { + case 'display': + return parts.join(' > '); + case 'display-reverse': + return parts.reverse().join(' < '); + case 'full-name': + return parts.join(' '); + } +} + /** * Generate path to icon used in decorations * NOTE: Should not be called repeatedly for the performance reasons. Cache your results. @@ -176,5 +193,3 @@ export function getJestCommandSettings( } return [pathToJest(settings), pathToConfig(settings)]; } - -export default prepareIconFile; diff --git a/tests/DebugCodeLens/DebugCodeLens.test.ts b/tests/DebugCodeLens/DebugCodeLens.test.ts index b31a2110..a99cdc81 100644 --- a/tests/DebugCodeLens/DebugCodeLens.test.ts +++ b/tests/DebugCodeLens/DebugCodeLens.test.ts @@ -29,6 +29,6 @@ describe('DebugCodeLens', () => { }); it('should specify the test name', () => { - expect(sut.testName).toBe(testName); + expect(sut.testIds).toEqual([testName]); }); }); diff --git a/tests/DebugCodeLens/DebugCodeLensProvider.test.ts b/tests/DebugCodeLens/DebugCodeLensProvider.test.ts index 809f9930..17bb3a46 100644 --- a/tests/DebugCodeLens/DebugCodeLensProvider.test.ts +++ b/tests/DebugCodeLens/DebugCodeLensProvider.test.ts @@ -1,6 +1,7 @@ jest.unmock('../../src/DebugCodeLens/DebugCodeLensProvider'); jest.unmock('../../src/DebugCodeLens/DebugCodeLens'); jest.unmock('../../src/helpers'); +jest.unmock('../test-helper'); jest.mock('path'); // tslint:disable max-classes-per-file @@ -54,6 +55,7 @@ import { extensionName } from '../../src/appGlobals'; import { basename } from 'path'; import * as vscode from 'vscode'; import { TestState } from '../../src/DebugCodeLens'; +import * as helper from '../test-helper'; describe('DebugCodeLensProvider', () => { const testResultProvider = new TestResultProvider(); @@ -128,6 +130,10 @@ describe('DebugCodeLensProvider', () => { const testResults = [ ({ name: 'should fail', + identifier: { + title: 'should fail', + ancestorTitles: [], + }, start: { line: 1, column: 2, @@ -228,7 +234,31 @@ describe('DebugCodeLensProvider', () => { const actual = sut.provideCodeLenses(document, token); expect(actual).toHaveLength(1); - expect((actual[0] as DebugCodeLens).testName).toBe(testResults[0].name); + expect((actual[0] as DebugCodeLens).testIds).toEqual([testResults[0].identifier]); + }); + + describe('parameterized tests', () => { + const tr1 = helper.makeTestResult('test-1', TestReconciliationState.KnownSuccess, []); + const tr2 = helper.makeTestResult('test-2', TestReconciliationState.KnownSuccess, []); + const tr3 = helper.makeTestResult('test-3', TestReconciliationState.KnownFail, []); + const tr4 = helper.makeTestResult('test-4', TestReconciliationState.KnownFail, []); + tr3.multiResults = [tr4, tr1, tr2]; + beforeEach(() => { + jest.clearAllMocks(); + getResults.mockReturnValueOnce([tr3]); + }); + + it.each` + showTestStates | expected + ${[TestState.Fail]} | ${[tr3.identifier, tr4.identifier]} + ${[TestState.Fail, TestState.Pass]} | ${[tr3.identifier, tr4.identifier, tr1.identifier, tr2.identifier]} + ${[TestState.Pass]} | ${[tr1.identifier, tr2.identifier]} + `('pass qualified test results: $showTestStates', ({ showTestStates, expected }) => { + const sut = new DebugCodeLensProvider(provideJestExt, showTestStates); + const list = sut.provideCodeLenses(document, token); + expect(list).toHaveLength(1); + expect(list[0].testIds).toEqual(expected); + }); }); }); @@ -250,23 +280,6 @@ describe('DebugCodeLensProvider', () => { }); }); - it('should escape testName for regex', () => { - const sut = new DebugCodeLensProvider(provideJestExt, allTestStates); - const document = {} as any; - const range = {} as any; - const fileName = 'fileName'; - const testName = 'testName()'; - const codeLens = new DebugCodeLens(document, range, fileName, testName); - const token = {} as any; - sut.resolveCodeLens(codeLens, token); - - expect(codeLens.command).toEqual({ - arguments: [document, fileName, 'testName\\(\\)'], - command: `${extensionName}.run-test`, - title: 'Debug', - }); - }); - it('should leave other CodeLenses unchanged', () => { const sut = new DebugCodeLensProvider(provideJestExt, []); const codeLens = {} as any; diff --git a/tests/JestExt.test.ts b/tests/JestExt.test.ts index 4cee19a1..61b08ae6 100644 --- a/tests/JestExt.test.ts +++ b/tests/JestExt.test.ts @@ -1,14 +1,6 @@ jest.unmock('events'); jest.unmock('../src/JestExt'); -const mockGetJestCommandSettings = jest.fn(); -jest.mock('../src/helpers', () => ({ - cleanAnsi: (str: string) => str, - pathToJest: jest.fn(), - pathToConfig: jest.fn(), - getJestCommandSettings: mockGetJestCommandSettings, -})); - jest.mock('../src/DebugCodeLens', () => ({ DebugCodeLensProvider: class MockCodeLensProvider {}, })); @@ -26,6 +18,7 @@ const statusBar = { }; jest.mock('../src/StatusBar', () => ({ statusBar })); +import * as vscode from 'vscode'; import { JestExt } from '../src/JestExt'; import { ProjectWorkspace } from 'jest-editor-support'; import { window, workspace, debug, ExtensionContext, TextEditorDecorationType } from 'vscode'; @@ -36,8 +29,11 @@ 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 * as helper from '../src/helpers'; +import { TestIdentifier } from '../src/TestResults'; /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "expectItTakesNoAction"] }] */ +const mockHelpers = helper as jest.Mocked; describe('JestExt', () => { const getConfiguration = workspace.getConfiguration as jest.Mock; @@ -61,7 +57,7 @@ describe('JestExt', () => { projectWorkspace = new ProjectWorkspace(null, null, null, null); getConfiguration.mockReturnValue({}); - mockGetJestCommandSettings.mockReturnValue([]); + mockHelpers.getJestCommandSettings.mockReturnValue([]); }); describe('resetInlineErrorDecorators()', () => { @@ -174,12 +170,19 @@ describe('JestExt', () => { }); describe('runTest()', () => { + const makeIdentifier = (title: string, ancestors?: string[]): TestIdentifier => ({ + title, + ancestorTitles: ancestors || [], + }); const workspaceFolder = {} as any; const fileName = 'fileName'; - const testNamePattern = 'testNamePattern'; - it('should run the supplied test', async () => { - const startDebugging = (debug.startDebugging as unknown) as jest.Mock<{}>; + let sut: JestExt; + let startDebugging, debugConfiguration; + const mockShowQuickPick = jest.fn(); + + beforeEach(() => { + startDebugging = (debug.startDebugging as unknown) as jest.Mock<{}>; ((startDebugging as unknown) as jest.Mock<{}>).mockImplementation( async (_folder: any, nameOrConfig: any) => { // trigger fallback to default configuration @@ -189,8 +192,13 @@ describe('JestExt', () => { } ); - const debugConfiguration = { type: 'dummyconfig' }; - const sut = new JestExt( + debugConfiguration = { type: 'dummyconfig' }; + debugConfigurationProvider.provideDebugConfigurations.mockReturnValue([debugConfiguration]); + vscode.window.showQuickPick = mockShowQuickPick; + mockHelpers.escapeRegExp.mockImplementation((s) => s); + mockHelpers.testIdString.mockImplementation((_, s) => s); + + sut = new JestExt( context, workspaceFolder, projectWorkspace, @@ -202,11 +210,10 @@ describe('JestExt', () => { null, null ); - ((sut.debugConfigurationProvider - .provideDebugConfigurations as unknown) as jest.Mock<{}>).mockReturnValue([ - debugConfiguration, - ]); + }); + it('should run the supplied test', async () => { + const testNamePattern = 'testNamePattern'; await sut.runTest(workspaceFolder, fileName, testNamePattern); expect(debug.startDebugging).toHaveBeenCalledWith(workspaceFolder, debugConfiguration); @@ -220,6 +227,113 @@ describe('JestExt', () => { testNamePattern ); }); + it('can handle testIdentifier argument', async () => { + const tId = makeIdentifier('test-1', ['d-1', 'd-1-1']); + const fullName = 'd-1 d-1-1 test-1'; + mockHelpers.testIdString.mockReturnValue(fullName); + await sut.runTest(workspaceFolder, fileName, tId); + + expect(debug.startDebugging).toHaveBeenCalledWith(workspaceFolder, debugConfiguration); + + const configuration = startDebugging.mock.calls[startDebugging.mock.calls.length - 1][1]; + expect(configuration).toBeDefined(); + expect(configuration.type).toBe('dummyconfig'); + + // test identifier is cleaned up before debug + expect(mockHelpers.testIdString).toBeCalledWith('full-name', tId); + expect(sut.debugConfigurationProvider.prepareTestRun).toBeCalledWith(fileName, fullName); + }); + it.each` + desc | testIds | testIdStringCount | startDebug + ${'0 id'} | ${[]} | ${0} | ${false} + ${'1 string id '} | ${['test-1']} | ${0} | ${true} + ${'1 testIdentifier id '} | ${[makeIdentifier('test-1', ['d-1'])]} | ${1} | ${true} + `('no selection needed: $desc', async ({ testIds, testIdStringCount, startDebug }) => { + await sut.runTest(workspaceFolder, fileName, ...testIds); + + expect(mockShowQuickPick).not.toBeCalled(); + + expect(mockHelpers.testIdString).toBeCalledTimes(testIdStringCount); + if (testIdStringCount >= 1) { + expect(mockHelpers.testIdString).toHaveBeenLastCalledWith('full-name', testIds[0]); + expect(mockHelpers.escapeRegExp).toHaveBeenCalled(); + } + if (startDebug) { + expect(debug.startDebugging).toHaveBeenCalledWith(workspaceFolder, debugConfiguration); + + const configuration = startDebugging.mock.calls[startDebugging.mock.calls.length - 1][1]; + expect(configuration).toBeDefined(); + expect(configuration.type).toBe('dummyconfig'); + + expect(sut.debugConfigurationProvider.prepareTestRun).toHaveBeenCalled(); + } else { + expect(sut.debugConfigurationProvider.prepareTestRun).not.toHaveBeenCalled(); + expect(debug.startDebugging).not.toHaveBeenCalled(); + } + }); + describe('paramerterized test', () => { + describe.each` + desc | tId1 | tId2 | tId3 | selectIdx + ${'testIdentifiers'} | ${makeIdentifier('test-1', ['d-1'])} | ${makeIdentifier('test-2', ['d-1'])} | ${makeIdentifier('test-3', ['d-1'])} | ${0} + ${'string ids'} | ${'d-1 test-1'} | ${'d-1 test-2'} | ${'d-1 test-3'} | ${2} + ${'mixed ids'} | ${'d-1 test-1'} | ${makeIdentifier('test-2', ['d-1'])} | ${'d-1 test-3'} | ${1} + `('with $desc', ({ tId1, tId2, tId3, selectIdx }) => { + let identifierIdCount = 0; + beforeEach(() => { + mockShowQuickPick.mockImplementation((items) => Promise.resolve(items[selectIdx])); + identifierIdCount = [tId1, tId2, tId3].filter((id) => typeof id !== 'string').length; + }); + it('can run selected test', async () => { + // user choose the 2nd test: tId2 + await sut.runTest(workspaceFolder, fileName, tId1, tId2, tId3); + + // user has made selection to choose from 3 candidates + expect(mockShowQuickPick).toHaveBeenCalledTimes(1); + const [items] = mockShowQuickPick.mock.calls[0]; + expect(items).toHaveLength(3); + + if (identifierIdCount) { + // id string is called 4 times: 3 to construt the quickPickIems, the last one is for jest test fullName + expect(mockHelpers.testIdString).toBeCalledTimes(identifierIdCount + 1); + const calls = mockHelpers.testIdString.mock.calls; + expect( + calls.slice(0, identifierIdCount).every((c) => c[0] === 'display-reverse') + ).toBeTruthy(); + expect(calls[calls.length - 1][0]).toEqual('full-name'); + } else { + expect(mockHelpers.testIdString).toBeCalledTimes(0); + } + const selected = [tId1, tId2, tId3][selectIdx]; + expect(mockHelpers.escapeRegExp).toBeCalledWith(selected); + + // verify the actual test to be run is the one we selected: tId2 + expect(debug.startDebugging).toHaveBeenCalledWith(workspaceFolder, debugConfiguration); + + const configuration = startDebugging.mock.calls[startDebugging.mock.calls.length - 1][1]; + expect(configuration).toBeDefined(); + expect(configuration.type).toBe('dummyconfig'); + + expect(sut.debugConfigurationProvider.prepareTestRun).toBeCalledWith(fileName, selected); + }); + it('if user did not choose any test, no debug will be run', async () => { + selectIdx = -1; + await sut.runTest(workspaceFolder, fileName, tId1, tId2, tId3); + + const mockProcessManager = (JestProcessManager as jest.Mocked).mock.instances[0]; + expect(mockProcessManager.stopAll).not.toHaveBeenCalled(); + + expect(mockShowQuickPick).toHaveBeenCalledTimes(1); + expect(debug.startDebugging).not.toHaveBeenCalled(); + }); + it('if pass zero testId, nothing will be run', async () => { + await sut.runTest(workspaceFolder, fileName); + + expect(mockShowQuickPick).not.toHaveBeenCalled(); + expect(mockHelpers.testIdString).not.toBeCalled(); + expect(debug.startDebugging).not.toHaveBeenCalled(); + }); + }); + }); }); describe('onDidCloseTextDocument()', () => { @@ -694,6 +808,7 @@ describe('JestExt', () => { mockEditor.setDecorations = jest.fn(); sut.debugCodeLensProvider.didChange = jest.fn(); + mockHelpers.cleanAnsi.mockImplementation((s) => s); }); it('will trigger snapshot update message when a snapshot test fails', () => { diff --git a/tests/TestResults/TestResultProvider.test.ts b/tests/TestResults/TestResultProvider.test.ts index f81ca93e..a96d793d 100644 --- a/tests/TestResults/TestResultProvider.test.ts +++ b/tests/TestResults/TestResultProvider.test.ts @@ -232,10 +232,15 @@ describe('TestResultProvider', () => { assertions[1].fullName, assertions[2].fullName, ]); - expect(actual.map((a) => a.names.src)).toEqual([ - testBlock.name, - testBlock2.name, - testBlock3.name, + expect(actual.map((a) => a.identifier.title)).toEqual([ + assertions[0].title, + assertions[1].title, + assertions[2].title, + ]); + expect(actual.map((a) => a.identifier.ancestorTitles)).toEqual([ + assertions[0].ancestorTitles, + assertions[1].ancestorTitles, + assertions[2].ancestorTitles, ]); expect(actual.map((a) => a.status)).toEqual([ TestReconciliationState.KnownSuccess, @@ -322,7 +327,6 @@ describe('TestResultProvider', () => { expect(actual).toHaveLength(1); expect(actual[0].status).toBe(TestReconciliationState.KnownSuccess); expect(actual[0].shortMessage).toBeUndefined(); - expect(consoleWarning).toHaveBeenCalled(); }); it('when all goes according to plan, no warning', () => { const sut = new TestResultProvider(); @@ -338,6 +342,162 @@ describe('TestResultProvider', () => { expect(consoleWarning).not.toHaveBeenCalled(); }); }); + describe('parameterized tests', () => { + let sut: TestResultProvider; + const testBlock2 = helper.makeItBlock('p-test-$status', [8, 0, 20, 20]); + beforeEach(() => { + sut = new TestResultProvider(); + mockParseTest([testBlock, testBlock2]); + }); + it('test results shared the same range will be grouped', () => { + const assertions = [ + helper.makeAssertion(testBlock.name, TestReconciliationState.KnownFail, [], [1, 12]), + helper.makeAssertion('p-test-success', TestReconciliationState.KnownSuccess, [], [8, 20]), + helper.makeAssertion('p-test-fail-1', TestReconciliationState.KnownFail, [], [8, 20]), + helper.makeAssertion('p-test-fail-2', TestReconciliationState.KnownFail, [], [8, 20]), + ]; + assertionsForTestFile.mockReturnValueOnce(assertions); + const actual = sut.getResults(filePath); + + // should only have 2 test results returned, as the last 3 assertions match to the same test block + expect(actual).toHaveLength(2); + expect(actual.map((a) => a.name)).toEqual([testBlock.name, 'p-test-fail-1']); + expect(actual.map((a) => a.status)).toEqual([ + TestReconciliationState.KnownFail, + TestReconciliationState.KnownFail, + ]); + + // the parameterized test use the first failed results as its "primary" result and + // put the other 2 tests in "extraResults" sorted by test precedence: fail > sucess + const pResult = actual[1]; + expect(pResult.multiResults).toHaveLength(2); + expect(pResult.multiResults.map((a) => [a.name, a.status])).toEqual([ + ['p-test-fail-2', TestReconciliationState.KnownFail], + ['p-test-success', TestReconciliationState.KnownSuccess], + ]); + }); + it('grouped test results are sorted by status precedence fail > unknown > skip > success', () => { + const assertions = [ + helper.makeAssertion(testBlock.name, TestReconciliationState.KnownFail, [], [1, 12]), + helper.makeAssertion('p-test-success', TestReconciliationState.KnownSuccess, [], [8, 20]), + helper.makeAssertion('p-test-skip', TestReconciliationState.KnownSkip, [], [8, 20]), + helper.makeAssertion('p-test-fail', TestReconciliationState.KnownFail, [], [8, 20]), + helper.makeAssertion('p-test-unknown', TestReconciliationState.Unknown, [], [8, 20]), + ]; + assertionsForTestFile.mockReturnValueOnce(assertions); + const actual = sut.getResults(filePath); + + // should only have 2 test results returned, as the last 4 assertions match to the same test block + expect(actual).toHaveLength(2); + + const pResult = actual[1]; + expect(pResult.name).toEqual('p-test-fail'); + expect(pResult.multiResults).toHaveLength(3); + expect(pResult.multiResults.map((a) => a.name)).toEqual([ + 'p-test-unknown', + 'p-test-skip', + 'p-test-success', + ]); + }); + it('parameterized test is consider failed/skip/unknown if any of its test has the corresponding status', () => { + const assertions = [ + helper.makeAssertion(testBlock.name, TestReconciliationState.KnownFail, [], [1, 12]), + helper.makeAssertion('p-test-success', TestReconciliationState.KnownSuccess, [], [8, 20]), + helper.makeAssertion('p-test-skip', TestReconciliationState.KnownSkip, [], [8, 20]), + helper.makeAssertion('p-test-unknown', TestReconciliationState.Unknown, [], [8, 20]), + ]; + assertionsForTestFile.mockReturnValueOnce(assertions); + const actual = sut.getResults(filePath); + + // should only have 2 test results returned, as the last 4 assertions match to the same test block + expect(actual).toHaveLength(2); + + const pResult = actual[1]; + expect(pResult.name).toEqual('p-test-unknown'); + expect(pResult.multiResults).toHaveLength(2); + expect(pResult.multiResults.map((a) => a.name)).toEqual(['p-test-skip', 'p-test-success']); + }); + it('parameterized test are successful only if all of its tests succeeded', () => { + const assertions = [ + helper.makeAssertion(testBlock.name, TestReconciliationState.KnownFail, [], [1, 12]), + helper.makeAssertion( + 'p-test-success-1', + TestReconciliationState.KnownSuccess, + [], + [8, 20] + ), + helper.makeAssertion( + 'p-test-success-2', + TestReconciliationState.KnownSuccess, + [], + [8, 20] + ), + ]; + assertionsForTestFile.mockReturnValueOnce(assertions); + const actual = sut.getResults(filePath); + + // should only have 2 test results returned, as the last 4 assertions match to the same test block + expect(actual).toHaveLength(2); + + const pResult = actual[1]; + expect(pResult.name).toEqual('p-test-success-1'); + expect(pResult.multiResults).toHaveLength(1); + expect(pResult.multiResults.map((a) => a.name)).toEqual(['p-test-success-2']); + }); + }); + describe('paramertized describes', () => { + let sut: TestResultProvider; + const tBlock = helper.makeItBlock('p-test-$count', [8, 0, 20, 20]); + const dBlock = helper.makeDescribeBlock('p-describe-scount', [tBlock]); + beforeEach(() => { + sut = new TestResultProvider(); + mockParseTest([dBlock]); + }); + it('test from different parameter block can still be grouped', () => { + const assertions = [ + helper.makeAssertion( + 'p-test-1', + TestReconciliationState.KnownSuccess, + ['p-describe-1'], + [8, 20] + ), + helper.makeAssertion( + 'p-test-2', + TestReconciliationState.KnownFail, + ['p-describe-1'], + [8, 20] + ), + helper.makeAssertion( + 'p-test-1', + TestReconciliationState.KnownSuccess, + ['p-describe-2'], + [8, 20] + ), + helper.makeAssertion( + 'p-test-2', + TestReconciliationState.KnownSuccess, + ['p-describe-2'], + [8, 20] + ), + ]; + assertionsForTestFile.mockReturnValueOnce(assertions); + const actual = sut.getResults(filePath); + + expect(actual).toHaveLength(1); + + const pResult = actual[0]; + expect([pResult.name, pResult.status]).toEqual([ + 'p-describe-1 p-test-2', + TestReconciliationState.KnownFail, + ]); + expect(pResult.multiResults).toHaveLength(3); + expect(pResult.multiResults.map((a) => a.name)).toEqual([ + 'p-describe-1 p-test-1', + 'p-describe-2 p-test-1', + 'p-describe-2 p-test-2', + ]); + }); + }); }); describe('getSortedResults()', () => { diff --git a/tests/TestResults/match-by-context.test.ts b/tests/TestResults/match-by-context.test.ts index c20b0900..2e5666ae 100644 --- a/tests/TestResults/match-by-context.test.ts +++ b/tests/TestResults/match-by-context.test.ts @@ -4,7 +4,7 @@ jest.unmock('../test-helper'); import * as helper from '../test-helper'; import * as match from '../../src/TestResults/match-by-context'; -import { TestReconciliationState } from '../../src/TestResults'; +import { TestReconciliationStateType } from '../../src/TestResults'; import { TestAssertionStatus, ParsedNode } from 'jest-editor-support'; describe('buildAssertionContainer', () => { @@ -56,6 +56,20 @@ describe('buildAssertionContainer', () => { expect(groupNode.data).toHaveLength(3); expect(groupNode.data.map((n) => n.title)).toEqual(['test-1', 'test-3', 'test-2']); }); + it('can group describe blocks with the same line', () => { + const a1 = helper.makeAssertion('test-1', 'KnownSuccess', ['d-1'], [2, 0]); + const a2 = helper.makeAssertion('test-1', 'KnownSuccess', ['d-2'], [2, 0]); + const a3 = helper.makeAssertion('test-1', 'KnownSuccess', ['d-3'], [2, 0]); + const a4 = helper.makeAssertion('test-2', 'KnownSuccess', [], [5, 0]); + const root = match.buildAssertionContainer([a1, a2, a3, a4]); + expect(root.childContainers).toHaveLength(1); + expect(root.childData).toHaveLength(1); + expect(root.childData[0]).toMatchObject({ zeroBasedLine: 5, name: 'test-2' }); + + const describeNode = root.childContainers[0]; + expect(describeNode).toMatchObject({ zeroBasedLine: 2, name: 'd-1' }); + expect(describeNode.group?.map((n) => n.name)).toEqual(['d-2', 'd-3']); + }); it('create a container based on assertion ancestorTitles structure', () => { const a1 = helper.makeAssertion('test-1', 'KnownSuccess', [], [1, 0]); @@ -150,9 +164,8 @@ describe('matchTestAssertions', () => { expect(matched).toHaveLength(2); expect(matched.map((m) => m.name)).toEqual(['test-1', 'test-2-100']); - expect(matched.map((m) => m.names.src)).toEqual(['test-1', 'test-2-${num}']); - expect(matched.map((m) => m.names.assertionTitle)).toEqual(['test-1', 'test-2-100']); - expect(matched.map((m) => m.names.assertionFullName)).toEqual(['test-1', 'test-2-100']); + expect(matched.map((m) => m.identifier.title)).toEqual(['test-1', 'test-2-100']); + expect(matched.map((m) => m.identifier.ancestorTitles)).toEqual([[], []]); expect(matched.map((m) => m.status)).toEqual(['KnownFail', 'KnownSuccess']); }); it('can match tests with the same name but in different describe blocks', () => { @@ -165,9 +178,8 @@ describe('matchTestAssertions', () => { const a2 = helper.makeAssertion('test-1', 'KnownSuccess', ['d-1'], [5, 0]); const matched = match.matchTestAssertions('a file', sourceRoot, [a1, a2]); expect(matched.map((m) => m.name)).toEqual(['test-1', 'd-1 test-1']); - expect(matched.map((m) => m.names.src)).toEqual(['test-1', 'test-1']); - expect(matched.map((m) => m.names.assertionTitle)).toEqual(['test-1', 'test-1']); - expect(matched.map((m) => m.names.assertionFullName)).toEqual(['test-1', 'd-1 test-1']); + expect(matched.map((m) => m.identifier.title)).toEqual(['test-1', 'test-1']); + expect(matched.map((m) => m.identifier.ancestorTitles)).toEqual([[], ['d-1']]); expect(matched.map((m) => m.status)).toEqual(['KnownFail', 'KnownSuccess']); expect(matched.map((m) => m.start.line)).toEqual([0, 5]); expect(matched.map((m) => m.end.line)).toEqual([4, 6]); @@ -245,14 +257,14 @@ describe('matchTestAssertions', () => { }); describe('1-many (jest.each) match', () => { const createTestData = ( - statusList: (TestReconciliationState | [TestReconciliationState, number])[] + statusList: (TestReconciliationStateType | [TestReconciliationStateType, number])[] ): [ParsedNode, TestAssertionStatus[]] => { const t1 = helper.makeItBlock('', [12, 1, 20, 1]); const sourceRoot = helper.makeRoot([t1]); // this match jest.each with 2 assertions const assertions = statusList.map((s, idx) => { - let state: TestReconciliationState; + let state: TestReconciliationStateType; let override: Partial; if (typeof s === 'string') { state = s; @@ -265,72 +277,19 @@ describe('matchTestAssertions', () => { }); return [sourceRoot, assertions]; }; - it('any failed assertion will fail the test', () => { + it('all assertions will be returned', () => { const [root, assertions] = createTestData([ 'KnownSuccess', ['KnownFail', 13], 'KnownSuccess', ]); const matched = match.matchTestAssertions('a file', root, assertions); - expect(matched).toHaveLength(1); - expect(matched[0].status).toEqual('KnownFail'); - expect(matched[0].start).toEqual({ line: 11, column: 0 }); - expect(matched[0].end).toEqual({ line: 19, column: 0 }); - expect(matched[0].lineNumberOfError).toEqual(12); - }); - describe('test result name', () => { - it('use the first failed assertion, if exist', () => { - const [root, assertions] = createTestData([ - 'KnownSuccess', - ['KnownFail', 13], - 'KnownSuccess', - ['KnownFail', 20], - ]); - const matched = match.matchTestAssertions('a file', root, assertions); - expect(matched).toHaveLength(1); - expect(matched[0].name).toEqual('test-1'); - expect(matched[0].status).toEqual('KnownFail'); - }); - describe('if no failed assertion, the first assertion will be used', () => { - // eat-our-own-dog-food: use jest .each so we can ensure it worked for our users - const entries = [ - [['KnownSuccess', 'KnownSuccess'], 'KnownSuccess'], - [['Unknown', 'Unknown'], 'Unknown'], - ]; - it.each(entries)('with test entry %#', (entry, status) => { - const [root, assertions] = createTestData(entry as TestReconciliationState[]); - const matched = match.matchTestAssertions('a file', root, assertions); - expect(matched).toHaveLength(1); - expect(matched[0].name).toEqual('test-0'); - expect(matched[0].status).toEqual(status); - }); - }); - }); - it('test is succeeded if all assertions are successful', () => { - const [root, assertions] = createTestData(['KnownSuccess', 'KnownSuccess', 'KnownSuccess']); - const matched = match.matchTestAssertions('a file', root, assertions); - expect(matched).toHaveLength(1); - expect(matched[0].status).toEqual('KnownSuccess'); - }); - it('test is skip when all assertions are skipped', () => { - const [root, assertions] = createTestData([ - TestReconciliationState.KnownSkip, - TestReconciliationState.KnownSkip, - TestReconciliationState.KnownSkip, - ]); - const matched = match.matchTestAssertions('a file', root, assertions); - expect(matched).toHaveLength(1); - expect(matched[0].status).toEqual(TestReconciliationState.KnownSkip); - }); - it('test name', () => { - const [root, assertions] = createTestData([ - TestReconciliationState.KnownSkip, - TestReconciliationState.KnownSkip, - TestReconciliationState.KnownSkip, - ]); - const matched = match.matchTestAssertions('a file', root, assertions); - expect(matched).toHaveLength(1); - expect(matched[0].status).toEqual(TestReconciliationState.KnownSkip); + expect(matched).toHaveLength(3); + expect(matched.map((m) => m.status)).toEqual(['KnownSuccess', 'KnownFail', 'KnownSuccess']); + expect(matched[1].status).toEqual('KnownFail'); + expect(matched[1].start).toEqual({ line: 11, column: 0 }); + expect(matched[1].end).toEqual({ line: 19, column: 0 }); + expect(matched[1].lineNumberOfError).toEqual(12); }); }); it('test name precedence: assertion.fullName > assertion.title > testSource.name', () => { diff --git a/tests/diagnostics.test.ts b/tests/diagnostics.test.ts index 0dd2891e..b62f0b3d 100644 --- a/tests/diagnostics.test.ts +++ b/tests/diagnostics.test.ts @@ -15,6 +15,7 @@ import { import { TestResult, TestReconciliationState } from '../src/TestResults'; import * as helper from './test-helper'; +import { testIdString } from '../src/helpers'; class MockDiagnosticCollection implements vscode.DiagnosticCollection { name = 'test'; set = jest.fn(); @@ -48,12 +49,6 @@ describe('test diagnostics', () => { const consoleWarn = console.warn; let lineNumber = 17; - function createAssertion(title: string, status: TestReconcilationState): TestAssertionStatus { - return helper.makeAssertion(title, status, undefined, undefined, { - message: `${title} ${status}`, - line: lineNumber++, - }); - } function createTestResult( file: string, assertions: TestAssertionStatus[], @@ -62,6 +57,13 @@ describe('test diagnostics', () => { return { file, message: `${file}:${status}`, status, assertions }; } + function createAssertion(title: string, status: TestReconcilationState): TestAssertionStatus { + return helper.makeAssertion(title, status, undefined, undefined, { + message: `${title} ${status}`, + line: lineNumber++, + }); + } + function validateDiagnostic(args: any[], message: string, severity: vscode.DiagnosticSeverity) { expect(args[1]).toEqual(message); expect(args[2]).toEqual(severity); @@ -240,6 +242,19 @@ describe('test diagnostics', () => { validateRange(rangeCalls[0], 0, 0); }); }); + it('message should contain full test name', () => { + const message = 'something is wrong'; + const mockDiagnostics = new MockDiagnosticCollection(); + const assertion = helper.makeAssertion('a', 'KnownFail', ['d-1'], [7, 0], { message }); + (testIdString as jest.Mock).mockReturnValueOnce(assertion.fullName); + + const tests = [createTestResult('f', [assertion])]; + updateDiagnostics(tests, mockDiagnostics); + + const [, msg] = (vscode.Diagnostic as jest.Mock).mock.calls[0]; + expect(msg).toContain('d-1 a'); + expect(msg).toContain(message); + }); }); describe('updateCurrentDiagnostics', () => { @@ -272,8 +287,9 @@ describe('test diagnostics', () => { const msg = 'a short error message'; const testBlock: TestResult = { name: 'a', - names: { - src: 'a', + identifier: { + title: 'a', + ancestorTitles: [], }, start: { line: 2, column: 3 }, end: { line: 4, column: 5 }, @@ -288,5 +304,40 @@ describe('test diagnostics', () => { expect(vscode.Diagnostic).toHaveBeenCalledTimes(1); expect(vscode.Diagnostic).toHaveBeenCalledWith(range, msg, vscode.DiagnosticSeverity.Error); }); + it('message should contain full test name', () => { + const shortMessage = 'something is wrong'; + const mockDiagnostics = new MockDiagnosticCollection(); + const testResult = helper.makeTestResult('test-1', 'KnownFail', ['d-1'], [1, 0, 10, 0], { + shortMessage, + }); + (testIdString as jest.Mock).mockReturnValueOnce(testResult.name); + + updateCurrentDiagnostics([testResult], mockDiagnostics, mockEditor as any); + + const [, msg] = (vscode.Diagnostic as jest.Mock).mock.calls[0]; + expect(msg).toContain('d-1 test-1'); + expect(msg).toContain(shortMessage); + }); + it('creates diagnostics for all failed parametertized tests', () => { + const mockDiagnostics = new MockDiagnosticCollection(); + const testResult1 = helper.makeTestResult('test-1', 'KnownFail', ['d-1'], [1, 0, 10, 0], { + shortMessage: 'fail-1', + }); + const testResult2 = helper.makeTestResult('test-2', 'KnownFail', ['d-1'], [1, 0, 10, 0], { + shortMessage: 'fail-2', + }); + const testResult3 = helper.makeTestResult('test-3', 'KnownSuccess', ['d-1'], [1, 0, 10, 0]); + testResult1.multiResults = [testResult2, testResult3]; + + mockLineAt.mockReturnValue({ range }); + + updateCurrentDiagnostics([testResult1], mockDiagnostics, mockEditor as any); + + expect(vscode.Diagnostic).toBeCalledTimes(2); + const [[, msg1], [, msg2]] = (vscode.Diagnostic as jest.Mock).mock.calls; + expect(msg1).toEqual('fail-1'); + expect(msg2).toEqual('fail-2'); + }); }); + describe('parameterized tests', () => {}); }); diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 8711199e..d914aca7 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -29,8 +29,10 @@ import { nodeBinExtension, cleanAnsi, prepareIconFile, + testIdString, getJestCommandSettings, pathToConfig, + escapeRegExp, } from '../src/helpers'; // Manually (forcefully) set the executable's file extension to test its addition independendly of the operating system. @@ -227,3 +229,32 @@ describe('ModuleHelpers', () => { }); }); }); + +describe('escapeRegExp', () => { + it.each` + str | expected + ${'no special char'} | ${'no special char'} + ${'with (a)'} | ${'with \\(a\\)'} + ${'with {} and $sign'} | ${'with \\{\\} and \\$sign'} + ${'with []'} | ${'with \\[\\]'} + `('escapeRegExp: $str', ({ str, expected }) => { + expect(escapeRegExp(str)).toEqual(expected); + }); +}); +describe('testIdString', () => { + it.each` + type | id | expected + ${'display'} | ${{ title: 'test', ancestorTitles: [] }} | ${'test'} + ${'display-reverse'} | ${{ title: 'test', ancestorTitles: [] }} | ${'test'} + ${'full-name'} | ${{ title: 'test', ancestorTitles: [] }} | ${'test'} + ${'display'} | ${{ title: 'regexp (a) $x/y', ancestorTitles: [] }} | ${'regexp (a) $x/y'} + ${'display-reverse'} | ${{ title: 'regexp (a) $x/y', ancestorTitles: [] }} | ${'regexp (a) $x/y'} + ${'full-name'} | ${{ title: 'regexp (a) $x/y', ancestorTitles: [] }} | ${'regexp (a) $x/y'} + ${'display'} | ${{ title: 'test', ancestorTitles: ['d-1', 'd-1-1'] }} | ${'d-1 > d-1-1 > test'} + ${'display-reverse'} | ${{ title: 'test', ancestorTitles: ['d-1', 'd-1-1'] }} | ${'test < d-1-1 < d-1'} + ${'full-name'} | ${{ title: 'test', ancestorTitles: ['d-1', 'd-1-1'] }} | ${'d-1 d-1-1 test'} + ${'full-name'} | ${{ title: 'regexp ($a)', ancestorTitles: ['d-1', 'd-1-1'] }} | ${'d-1 d-1-1 regexp ($a)'} + `('$type: $expected', ({ type, id, expected }) => { + expect(testIdString(type, id)).toEqual(expected); + }); +}); diff --git a/tests/test-helper.ts b/tests/test-helper.ts index e6730f38..aa390c28 100644 --- a/tests/test-helper.ts +++ b/tests/test-helper.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ import { Location, LocationRange, TestResult } from '../src/TestResults/TestResult'; -import { TestReconciliationState } from '../src/TestResults'; +import { TestReconciliationStateType } from '../src/TestResults'; import { ItBlock, TestAssertionStatus } from 'jest-editor-support'; export const EmptyLocation = { @@ -53,7 +53,7 @@ export const makeRoot = (children: any[]): any => ({ }); export const makeAssertion = ( title: string, - status: TestReconciliationState, + status: TestReconciliationStateType, ancestorTitles: string[] = [], location?: [number, number], override?: Partial @@ -66,3 +66,20 @@ export const makeAssertion = ( location: location ? makeLocation(location) : EmptyLocation, ...(override || {}), } as TestAssertionStatus); + +export const makeTestResult = ( + title: string, + status: TestReconciliationStateType, + ancestorTitles: string[] = [], + range?: [number, number, number, number], + override?: Partial +): TestResult => ({ + name: [...ancestorTitles, title].join(' '), + status, + identifier: { + title, + ancestorTitles, + }, + ...(range ? makePositionRange(range) : EmptyLocationRange), + ...(override || {}), +}); diff --git a/yarn.lock b/yarn.lock index 69bc5f29..7f8da992 100644 --- a/yarn.lock +++ b/yarn.lock @@ -499,7 +499,7 @@ source-map "^0.6.1" write-file-atomic "^3.0.0" -"@jest/types@^24.8.0", "@jest/types@^24.9.0": +"@jest/types@^24.9.0": version "24.9.0" resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" integrity sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== @@ -3950,15 +3950,15 @@ jest-each@^26.6.2: jest-util "^26.6.2" pretty-format "^26.6.2" -jest-editor-support@^28.0.0: - version "28.0.0" - resolved "https://registry.yarnpkg.com/jest-editor-support/-/jest-editor-support-28.0.0.tgz#bef5f030c8ce725e34eabb81238d5b2c39b7dafd" - integrity sha512-Q4dc96HI0Y9eBkN0RwZL4PEZhKU4VHOKetsu1AsuNe+aypV44p2wa9RrYgZN0JQQ6FSyV00cSX/2BfPiOsutNw== +jest-editor-support@^28.1.0: + version "28.1.0" + resolved "https://registry.yarnpkg.com/jest-editor-support/-/jest-editor-support-28.1.0.tgz#983b067436be81075907b47a9136265c5a59da69" + integrity sha512-h6Afk3+B+30HHq/UBmJdRqVC7B6rzw9lmFnlEvoKe2Bq73+Cr9FxuxSzi/znezi97nmVuS0AMcynFkemk9gmkg== dependencies: "@babel/parser" "^7.8.3" "@babel/traverse" "^7.6.2" "@babel/types" "^7.8.3" - "@jest/types" "^24.8.0" + "@jest/types" "^26.6.2" core-js "^3.2.1" jest-snapshot "^24.7.0"