diff --git a/README.md b/README.md index 2313787b..1ec48a23 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Content - [How to trigger the test run?](#how-to-trigger-the-test-run) - [How to debug tests?](#how-to-debug-tests) - [How to use code coverage?](#how-to-use-code-coverage) + - [How to update and view snpashots?](#how-to-update-and-view-snpashots) - [How to use the extension with monorepo projects?](#how-to-use-the-extension-with-monorepo-projects) - [How to read the StatusBar?](#how-to-read-the-statusbar) - [How to use the Test Explorer?](#how-to-use-the-test-explorer) @@ -83,7 +84,7 @@ Content * Show fails inside the problem inspector. * Highlights the errors next to the `expect` functions. * Adds syntax highlighting to snapshot files. -* A one button update for failed snapshots. +* Update and view snapshots at any level. * Show coverage information in files being tested. * Help debug jest tests in vscode. * Supports multiple test run modes (automated, manual, and hybrid onSave) to meet user's preferred development experience. @@ -170,6 +171,8 @@ You can customize coverage start up behavior, style and colors, see [customizati +### How to update and view snpashots? + ### How to use the extension with monorepo projects? The easiest way to setup the monorepo projects is to use the [Setup Tool](setup-wizard.md#setup-monorepo-project) and choose **Setup monorepo project** @@ -257,10 +260,6 @@ Users can use the following settings to tailor the extension for their environme |**Editor**| |enableInlineErrorMessages :x:| Whether errors should be reported inline on a file|--|This is now deprecated in favor of `jest.testExplorer` | |[testExplorer](#testexplorer) |Configure jest test explorer|null|`{"showInlineError": "true"}`| -|**Snapshot**| -|enableSnapshotUpdateMessages|Whether snapshot update messages should show|true|`"jest.enableSnapshotUpdateMessages": false`| -|enableSnapshotPreviews 💼|Whether snapshot previews should show|true|`"jest.enableSnapshotPreviews": false`| -|restartJestOnSnapshotUpdate :x:| Restart Jest runner after updating the snapshots|false|This is no longer needed in v4| |**Coverage**| |showCoverageOnLoad|Show code coverage when extension starts|false|`"jest.showCoverageOnLoad": true`| |[coverageFormatter](#coverageFormatter)|Determine the coverage overlay style|"DefaultFormatter"|`"jest.coverageFormatter": "GutterFormatter"`| diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts index 483ceef2..f0883d3f 100644 --- a/__mocks__/vscode.ts +++ b/__mocks__/vscode.ts @@ -17,6 +17,7 @@ const window = { showWorkspaceFolderPick: jest.fn(), onDidChangeActiveTextEditor: jest.fn(), showInformationMessage: jest.fn(), + createWebviewPanel: jest.fn(), }; const workspace = { @@ -79,6 +80,10 @@ const TestRunProfileKind = { Debug: 2, Coverage: 3, }; +const ViewColumn = { + One: 1, + Tow: 2, +}; const TestMessage = jest.fn(); const TestRunRequest = jest.fn(); @@ -112,4 +117,5 @@ export = { EventEmitter, TestMessage, TestRunRequest, + ViewColumn, }; diff --git a/package.json b/package.json index f25533e6..ee046260 100644 --- a/package.json +++ b/package.json @@ -299,6 +299,16 @@ "command": "io.orta.jest.test-item.coverage.toggle-on", "title": "Toggle Coverage On", "icon": "$(color-mode)" + }, + { + "command": "io.orta.jest.test-item.view-snapshot", + "title": "View Snapshot", + "icon": "$(camera)" + }, + { + "command": "io.orta.jest.test-item.update-snapshot", + "title": "Update Snapshot", + "icon": "$(export)" } ], "menus": { @@ -363,6 +373,19 @@ "command": "io.orta.jest.test-item.coverage.toggle-on", "group": "inline", "when": "testId in jest.coverage.off" + }, + { + "command": "io.orta.jest.test-item.update-snapshot" + } + ], + "testing/item/gutter": [ + { + "command": "io.orta.jest.test-item.view-snapshot", + "when": "testId in jest.editor-view-snapshot" + }, + { + "command": "io.orta.jest.test-item.update-snapshot", + "when": "testId in jest.editor-update-snapshot" } ] }, @@ -501,7 +524,7 @@ "dependencies": { "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", - "jest-editor-support": "^30.2.1" + "jest-editor-support": "^30.3.1" }, "devDependencies": { "@types/istanbul-lib-coverage": "^2.0.2", diff --git a/release-notes/release-note-v5.md b/release-notes/release-note-v5.md index c20844c8..7c99cbea 100644 --- a/release-notes/release-note-v5.md +++ b/release-notes/release-note-v5.md @@ -122,6 +122,10 @@ Users with jest coverageProvider `v8` should be able to see coverage like with t - can not be turned off any more. - removed the "enable" and "showClassicStatus" attributes. The only valid attribute is "showInlineError". - `"jest.autoRun` default value has changed. see detail above. +- Snapshot changes: + - Snapshot codeLenses are gone and replaced with commands in test status and test explorer tree menu. + - No more snapshot update messaging when running tests but users can update any snapshot any time they want. + - Retired the following snapshot related settings: `jest.enableSnapshotPreviews`, `jest.enableSnapshotUpdateMessages`, `restartJestOnSnapshotUpdate`. ### Change log - [v5.0.2 pre-release](https://github.com/jest-community/vscode-jest/releases/tag/v5.0.2) - [v5.0.1 pre-release](https://github.com/jest-community/vscode-jest/releases/tag/v5.0.1) diff --git a/src/JestExt/core.ts b/src/JestExt/core.ts index 64f04baf..d6d5dd7b 100644 --- a/src/JestExt/core.ts +++ b/src/JestExt/core.ts @@ -24,7 +24,7 @@ import { extensionName, SupportedLanguageIds } from '../appGlobals'; import { createJestExtContext, getExtensionResourceSettings, prefixWorkspace } from './helper'; import { PluginResourceSettings } from '../Settings'; import { WizardTaskId } from '../setup-wizard'; -import { JestExtExplorerContext } from '../test-provider/types'; +import { ItemCommand, JestExtExplorerContext } from '../test-provider/types'; import { JestTestProvider } from '../test-provider'; import { JestProcessInfo } from '../JestProcessManagement'; import { addFolderToDisabledWorkspaceFolders } from '../extensionManager'; @@ -124,7 +124,7 @@ export class JestExt { ...this.extContext, sessionEvents: this.events, session: this.processSession, - testResolveProvider: this.testResultProvider, + testResultProvider: this.testResultProvider, debugTests: this.debugTests, }; } @@ -631,6 +631,9 @@ export class JestExt { // restart jest since coverage condition has changed this.triggerUpdateSettings(this.extContext.settings); } + runItemCommand(testItem: vscode.TestItem, itemCommand: ItemCommand): void { + this.testProvider?.runItemCommand(testItem, itemCommand); + } enableLoginShell(): void { if (this.extContext.settings.shell.useLoginShell) { return; diff --git a/src/JestExt/helper.ts b/src/JestExt/helper.ts index fb600923..a33e3662 100644 --- a/src/JestExt/helper.ts +++ b/src/JestExt/helper.ts @@ -119,7 +119,6 @@ export const getExtensionResourceSettings = (uri: vscode.Uri): PluginResourceSet return { autoEnable, - enableSnapshotUpdateMessages: config.get('enableSnapshotUpdateMessages'), pathToConfig: config.get('pathToConfig'), jestCommandLine: config.get('jestCommandLine'), pathToJest: config.get('pathToJest'), diff --git a/src/JestExt/process-listeners.ts b/src/JestExt/process-listeners.ts index beb79c24..b88ed6f3 100644 --- a/src/JestExt/process-listeners.ts +++ b/src/JestExt/process-listeners.ts @@ -175,7 +175,6 @@ export class ListTestFileListener extends AbstractProcessListener { } } -const SnapshotFailRegex = /(snapshots? failed)|(snapshot test failed)/i; const IS_OUTSIDE_REPOSITORY_REGEXP = /Test suite failed to run[\s\S]*fatal:[\s\S]*is outside repository/im; const WATCH_IS_NOT_SUPPORTED_REGEXP = @@ -264,54 +263,12 @@ export class RunTestListener extends AbstractProcessListener { } //=== private methods === private shouldIgnoreOutput(text: string): boolean { - // this fails when snapshots change - to be revised - returning always false for now return text.length <= 0 || text.includes('Watch Usage'); } private cleanupOutput(text: string): string { return text.replace(CONTROL_MESSAGES, ''); } - // if snapshot error, offer update snapshot option and execute if user confirms - private handleSnapshotTestFailuer(process: JestProcess, data: string) { - // if already in the updateSnapshot run, do not prompt again - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((process.request as any).updateSnapshot) { - return; - } - - if ( - this.session.context.settings.enableSnapshotUpdateMessages && - SnapshotFailRegex.test(data) - ) { - const msg = - process.request.type === 'watch-all-tests' || process.request.type === 'watch-tests' - ? 'all files' - : 'files in this run'; - vscode.window - .showInformationMessage( - `[${this.session.context.workspace.name}] Would you like to update snapshots for ${msg}?`, - { - title: 'Replace them', - } - ) - .then((response) => { - // No response == cancel - if (response) { - this.session.scheduleProcess({ - type: 'update-snapshot', - baseRequest: process.request, - }); - this.onRunEvent.fire({ - type: 'data', - process, - text: 'Updating snapshots...', - newLine: true, - }); - } - }); - } - } - // restart the process with watch-all if it is due to "watch not supported" error private handleWatchNotSupportedError(process: JestProcess, data: string) { if (IS_OUTSIDE_REPOSITORY_REGEXP.test(data) || WATCH_IS_NOT_SUPPORTED_REGEXP.test(data)) { @@ -359,8 +316,6 @@ export class RunTestListener extends AbstractProcessListener { this.handleRunComplete(process, message); - this.handleSnapshotTestFailuer(process, message); - this.handleWatchNotSupportedError(process, message); } diff --git a/src/JestExt/process-session.ts b/src/JestExt/process-session.ts index 4869fb56..eb072b39 100644 --- a/src/JestExt/process-session.ts +++ b/src/JestExt/process-session.ts @@ -4,7 +4,6 @@ import { JestProcessRequestBase, ScheduleStrategy, requestString, - QueueType, JestProcessInfo, JestProcessRequestTransform, } from '../JestProcessManagement'; @@ -12,21 +11,16 @@ import { JestTestProcessType } from '../Settings'; import { RunTestListener, ListTestFileListener } from './process-listeners'; import { JestExtProcessContext } from './types'; -type InternalProcessType = 'list-test-files' | 'update-snapshot'; +type InternalProcessType = 'list-test-files'; export type ListTestFilesCallback = ( fileNames?: string[], error?: string, exitCode?: number ) => void; -export type InternalRequestBase = - | { - type: Extract; - onResult: ListTestFilesCallback; - } - | { - type: Extract; - baseRequest: JestProcessRequest; - }; +export type InternalRequestBase = { + type: Extract; + onResult: ListTestFilesCallback; +}; export type JestExtRequestType = JestProcessRequestBase | InternalRequestBase; const isJestProcessRequestBase = (request: JestExtRequestType): request is JestProcessRequestBase => @@ -122,32 +116,6 @@ export const createProcessSession = (context: JestExtProcessContext): ProcessSes }; const listenerSession: ListenerSession = { context, scheduleProcess }; - /** - * returns an update-snapshot process-request base on the current process - * @param process - * @returns undefined if the process already is updating snapshot - */ - const createSnapshotRequest = (baseRequest: JestProcessRequest): JestProcessRequestBase => { - switch (baseRequest.type) { - case 'watch-tests': - case 'watch-all-tests': - return { type: 'all-tests', updateSnapshot: true }; - case 'all-tests': - case 'by-file': - case 'by-file-pattern': - case 'by-file-test': - case 'by-file-test-pattern': - if (baseRequest.updateSnapshot) { - throw new Error( - 'schedule a update-snapshot run within an update-snapshot run is not supported' - ); - } - return { ...baseRequest, updateSnapshot: true }; - default: - throw new Error(`unexpeted baseRequest type for snapshot run: ${baseRequest.toString()}`); - } - }; - const createProcessRequest = (request: JestExtRequestType): JestProcessRequest => { const transform = (pRequest: JestProcessRequest): JestProcessRequest => { const t = getTransform(request); @@ -170,19 +138,6 @@ export const createProcessSession = (context: JestExtProcessContext): ProcessSes schedule, }); } - case 'update-snapshot': { - const snapshotRequest = createSnapshotRequest(request.baseRequest); - const schedule = { - ...ProcessScheduleStrategy[snapshotRequest.type], - queue: 'non-blocking' as QueueType, - }; - - return transform({ - ...snapshotRequest, - listener: new RunTestListener(lSession), - schedule, - }); - } case 'list-test-files': { const schedule = ProcessScheduleStrategy['not-test']; return transform({ diff --git a/src/JestProcessManagement/JestProcessManager.ts b/src/JestProcessManagement/JestProcessManager.ts index 7134c7db..b6639518 100644 --- a/src/JestProcessManagement/JestProcessManager.ts +++ b/src/JestProcessManagement/JestProcessManager.ts @@ -71,9 +71,7 @@ export class JestProcessManager implements TaskArrayFunctions { const process = task.data; try { - this.logging('debug', 'starting process:', process); await process.start(); - this.logging('debug', 'process ended:', process); } catch (e) { this.logging('error', `${queue.name}: process failed:`, process, e); } finally { diff --git a/src/Settings/index.ts b/src/Settings/index.ts index f324934e..09c98cf1 100644 --- a/src/Settings/index.ts +++ b/src/Settings/index.ts @@ -39,7 +39,6 @@ export type NodeEnv = ProjectWorkspace['nodeEnv']; export type MonitorLongRun = 'off' | number; export interface PluginResourceSettings { autoEnable?: boolean; - enableSnapshotUpdateMessages?: boolean; jestCommandLine?: string; pathToConfig?: string; pathToJest?: string; diff --git a/src/SnapshotCodeLens/SnapshotCodeLensProvider.ts b/src/SnapshotCodeLens/SnapshotCodeLensProvider.ts deleted file mode 100644 index b226b39e..00000000 --- a/src/SnapshotCodeLens/SnapshotCodeLensProvider.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as vscode from 'vscode'; -import { Snapshot } from 'jest-editor-support'; - -import { extensionName } from '../appGlobals'; -import { previewCommand } from './SnapshotPreviewProvider'; - -const missingSnapshotCommand = `${extensionName}.snapshot.missing`; - -class SnapshotCodeLensProvider implements vscode.CodeLensProvider { - public provideCodeLenses(document: vscode.TextDocument, _token: vscode.CancellationToken) { - const snapshots = new Snapshot(); - return snapshots.getMetadataAsync(document.uri.fsPath).then((metadata) => - metadata.map((snapshot) => { - const { line } = snapshot.node.loc.start; - const range = new vscode.Range(line - 1, 0, line - 1, 0); - let command: vscode.Command; - if (snapshot.exists) { - command = { - title: 'view snapshot', - command: previewCommand, - arguments: [snapshot], - }; - } else { - command = { - title: 'snapshot missing', - command: missingSnapshotCommand, - }; - } - - return new vscode.CodeLens(range, command); - }) - ); - } -} - -export function registerSnapshotCodeLens(enableSnapshotPreviews: boolean): vscode.Disposable[] { - if (!enableSnapshotPreviews) { - return []; - } - return [ - vscode.languages.registerCodeLensProvider( - { pattern: '**/*.{ts,tsx,js,jsx}' }, - new SnapshotCodeLensProvider() - ), - vscode.commands.registerCommand(missingSnapshotCommand, () => { - vscode.window.showInformationMessage('Run test to generate snapshot.'); - }), - ]; -} diff --git a/src/SnapshotCodeLens/SnapshotPreviewProvider.ts b/src/SnapshotCodeLens/SnapshotPreviewProvider.ts deleted file mode 100644 index 91764ca4..00000000 --- a/src/SnapshotCodeLens/SnapshotPreviewProvider.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as vscode from 'vscode'; -import { SnapshotMetadata } from 'jest-editor-support'; - -import { extensionName } from '../appGlobals'; - -export const previewCommand = `${extensionName}.snapshot.preview`; - -export function registerSnapshotPreview(): vscode.Disposable[] { - let panel: vscode.WebviewPanel | null = null; - - const escaped = (snapshot: string) => { - if (snapshot) { - // tslint:disable-next-line no-shadowed-variable - const escaped = snapshot - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(//g, '>'); - return `
${escaped}
`; - } - }; - - return [ - vscode.commands.registerCommand(previewCommand, (snapshot: SnapshotMetadata) => { - if (panel) { - panel.reveal(); - } else { - panel = vscode.window.createWebviewPanel( - 'view_snapshot', - snapshot.name, - vscode.ViewColumn.Two, - {} - ); - - panel.onDidDispose(() => { - panel = null; - }); - } - - panel.webview.html = (snapshot.content && escaped(snapshot.content)) || ''; - panel.title = snapshot.name; - }), - ]; -} diff --git a/src/SnapshotCodeLens/index.ts b/src/SnapshotCodeLens/index.ts deleted file mode 100644 index 4bc6a868..00000000 --- a/src/SnapshotCodeLens/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { registerSnapshotCodeLens } from './SnapshotCodeLensProvider'; -export { registerSnapshotPreview } from './SnapshotPreviewProvider'; diff --git a/src/TestResults/TestResultProvider.ts b/src/TestResults/TestResultProvider.ts index 5a7a4ea5..87f76655 100644 --- a/src/TestResults/TestResultProvider.ts +++ b/src/TestResults/TestResultProvider.ts @@ -5,6 +5,8 @@ import { IParseResults, parse, TestAssertionStatus, + ParsedRange, + ItBlock, } from 'jest-editor-support'; import { TestReconciliationState, TestReconciliationStateType } from './TestReconciliationState'; import { TestResult, TestResultStatusInfo } from './TestResult'; @@ -13,16 +15,29 @@ import { JestSessionEvents } from '../JestExt'; import { TestStats } from '../types'; import { emptyTestStats } from '../helpers'; import { createTestResultEvents, TestResultEvents } from './test-result-events'; -import { ContainerNode } from './match-node'; +import { ContainerNode, ROOT_NODE_NAME } from './match-node'; import { JestProcessInfo } from '../JestProcessManagement'; +import { ExtSnapshotBlock, SnapshotProvider } from './snapshot-provider'; -export interface TestSuiteResult { +type TestBlocks = IParseResults & { sourceContainer: ContainerNode }; +interface TestSuiteParseResultRaw { + testBlocks: TestBlocks | 'failed'; +} +interface TestSuiteResultRaw { + // test result status: TestReconciliationStateType; message: string; assertionContainer?: ContainerNode; results?: TestResult[]; sorted?: SortedTestResults; + // if we are certain the record is for a test file, set this flag to true + // otherwise isTestFile is determined by the testFileList + isTestFile?: boolean; } + +export type TestSuiteResult = Readonly; +type TestSuiteUpdatable = Readonly; + export interface SortedTestResults { fail: TestResult[]; skip: TestResult[]; @@ -36,18 +51,140 @@ const sortByStatus = (a: TestResult, b: TestResult): number => { } return TestResultStatusInfo[a.status].precedence - TestResultStatusInfo[b.status].precedence; }; + +export class TestSuiteRecord implements TestSuiteUpdatable { + private _status: TestReconciliationStateType; + private _message: string; + private _results?: TestResult[]; + private _sorted?: SortedTestResults; + private _isTestFile?: boolean; + + private _testBlocks?: TestBlocks | 'failed'; + // private _snapshotBlocks?: ExtSnapshotBlock[] | 'failed'; + private _assertionContainer?: ContainerNode; + + constructor( + public testFile: string, + private snapshotProvider: SnapshotProvider, + private events: TestResultEvents, + private reconciler: TestReconciler, + private verbose = false + ) { + this._status = TestReconciliationState.Unknown; + this._message = ''; + } + public get status(): TestReconciliationStateType { + return this._status; + } + public get message(): string { + return this._message; + } + public get results(): TestResult[] | undefined { + return this._results; + } + public get sorted(): SortedTestResults | undefined { + return this._sorted; + } + public get isTestFile(): boolean | undefined { + return this._isTestFile; + } + + public get testBlocks(): TestBlocks | 'failed' { + if (!this._testBlocks) { + try { + const pResult = parse(this.testFile); + if (![pResult.describeBlocks, pResult.itBlocks].find((blocks) => blocks.length > 0)) { + // nothing in this file yet, skip. Otherwise we might accidentally publish a source file, for example + return 'failed'; + } + const sourceContainer = match.buildSourceContainer(pResult.root); + this._testBlocks = { ...pResult, sourceContainer }; + + const snapshotBlocks = this.snapshotProvider.parse(this.testFile).blocks; + if (snapshotBlocks.length > 0) { + this.updateSnapshotAttr(sourceContainer, snapshotBlocks); + } + + this.events.testSuiteChanged.fire({ + type: 'test-parsed', + file: this.testFile, + sourceContainer: sourceContainer, + }); + } catch (e) { + // normal to fail, for example when source file has syntax error + if (this.verbose) { + console.log(`parseTestBlocks failed for ${this.testFile}`, e); + } + this._testBlocks = 'failed'; + } + } + + return this._testBlocks; + } + + public get assertionContainer(): ContainerNode | undefined { + if (!this._assertionContainer) { + const assertions = this.reconciler.assertionsForTestFile(this.testFile); + if (assertions && assertions.length > 0) { + this._assertionContainer = match.buildAssertionContainer(assertions); + } + } + return this._assertionContainer; + } + + private updateSnapshotAttr( + container: ContainerNode, + snapshots: ExtSnapshotBlock[] + ): void { + const isWithin = (snapshot: ExtSnapshotBlock, range?: ParsedRange): boolean => { + const zeroBasedLine = snapshot.node.loc.start.line - 1; + return !!range && range.start.line <= zeroBasedLine && range.end.line >= zeroBasedLine; + }; + + if ( + container.name !== ROOT_NODE_NAME && + container.attrs.range && + !snapshots.find((s) => isWithin(s, container.attrs.range)) + ) { + return; + } + container.childData.forEach((block) => { + const snapshot = snapshots.find((s) => isWithin(s, block.attrs.range)); + if (snapshot) { + block.attrs.snapshot = snapshot.isInline ? 'inline' : 'external'; + } + }); + container.childContainers.forEach((childContainer) => + this.updateSnapshotAttr(childContainer, snapshots) + ); + } + + public update(change: Partial): void { + this._status = change.status ?? this.status; + this._message = change.message ?? this.message; + + this._isTestFile = 'isTestFile' in change ? change.isTestFile : this._isTestFile; + this._results = 'results' in change ? change.results : this._results; + this._sorted = 'sorted' in change ? change.sorted : this._sorted; + this._testBlocks = 'testBlocks' in change ? change.testBlocks : this._testBlocks; + this._assertionContainer = + 'assertionContainer' in change ? change.assertionContainer : this._assertionContainer; + } +} export class TestResultProvider { verbose: boolean; events: TestResultEvents; private reconciler: TestReconciler; - private testSuites: Map; + private testSuites: Map; private testFiles?: string[]; + private snapshotProvider: SnapshotProvider; constructor(extEvents: JestSessionEvents, verbose = false) { this.reconciler = new TestReconciler(); this.verbose = verbose; this.events = createTestResultEvents(); this.testSuites = new Map(); + this.snapshotProvider = new SnapshotProvider(); extEvents.onTestSessionStarted.event(this.onSessionStart.bind(this)); } @@ -56,6 +193,17 @@ export class TestResultProvider { this.events.testSuiteChanged.dispose(); } + private addTestSuiteRecord(testFile: string): TestSuiteRecord { + const record = new TestSuiteRecord( + testFile, + this.snapshotProvider, + this.events, + this.reconciler, + this.verbose + ); + this.testSuites.set(testFile, record); + return record; + } private onSessionStart(): void { this.testSuites.clear(); this.reconciler = new TestReconciler(); @@ -106,147 +254,140 @@ export class TestResultProvider { return Array.from(this.testSuites.keys()); } - isTestFile(fileName: string): 'yes' | 'no' | 'unknown' { - if (this.testFiles?.includes(fileName) || this.testSuites.get(fileName) != null) { + isTestFile(fileName: string): 'yes' | 'no' | 'maybe' { + if (this.testFiles?.includes(fileName) || this.testSuites.get(fileName)?.isTestFile) { return 'yes'; } if (!this.testFiles) { - return 'unknown'; + return 'maybe'; } return 'no'; } public getTestSuiteResult(filePath: string): TestSuiteResult | undefined { - const cache = this.testSuites.get(filePath); - if (cache && !cache.assertionContainer) { - const assertions = this.reconciler.assertionsForTestFile(filePath); - if (assertions && assertions.length > 0) { - cache.assertionContainer = match.buildAssertionContainer(assertions); - this.testSuites.set(filePath, cache); - } - } - return cache; + return this.testSuites.get(filePath); } - private matchResults(filePath: string, { root, itBlocks }: IParseResults): TestSuiteResult { + + /** + * match assertions with source file, if successful, update cache, results and related. + * Will also fire testSuiteChanged event + * + * if the file is not a test or can not be parsed, the results will be undefined. + * any other errors will result the source blocks to be returned as unmatched block. + **/ + private updateMatchedResults(filePath: string, record: TestSuiteRecord): void { let error: string | undefined; - try { - const cache = this.getTestSuiteResult(filePath); - if (cache?.assertionContainer) { - cache.results = this.groupByRange( - match.matchTestAssertions(filePath, root, cache.assertionContainer, this.verbose) + if (record.testBlocks === 'failed') { + record.update({ status: 'KnownFail', message: 'test file parse error', results: [] }); + return; + } + + const { itBlocks } = record.testBlocks; + if (record.assertionContainer) { + try { + const results = this.groupByRange( + match.matchTestAssertions( + filePath, + record.testBlocks.sourceContainer, + record.assertionContainer, + this.verbose + ) ); + record.update({ results }); + this.events.testSuiteChanged.fire({ type: 'result-matched', file: filePath, }); - return cache; + return; + } catch (e) { + console.warn(`failed to match test results for ${filePath}:`, e); + error = `encountered internal match error: ${e}`; } + } else { error = 'no assertion generated for file'; - } catch (e) { - console.warn(`failed to match test results for ${filePath}:`, e); - error = `encountered internal match error: ${e}`; } - this.events.testSuiteChanged.fire({ - type: 'test-parsed', - file: filePath, - testContainer: match.buildSourceContainer(root), - }); - // no need to do groupByRange as the source block will not have blocks under the same location - return { - status: 'Unknown', + record.update({ + status: 'KnownFail', message: error, results: itBlocks.map((t) => match.toMatchResult(t, 'no assertion found', 'match-failed')), - }; + }); } /** * returns matched test results for the given file * @param filePath - * @returns valid test result list or undefined if the file is not a test. - * In the case when file can not be parsed or match error, empty results will be returned. - * @throws if parsing or matching internal error + * @returns valid test result list or an empty array if the source file is not a test or can not be parsed. */ - getResults(filePath: string): TestResult[] | undefined { - const results = this.testSuites.get(filePath)?.results; - if (results) { - return results; - } - + getResults(filePath: string, record?: TestSuiteRecord): TestResult[] | undefined { if (this.isTestFile(filePath) === 'no') { return; } - try { - const parseResult = parse(filePath); - this.testSuites.set(filePath, this.matchResults(filePath, parseResult)); - return this.testSuites.get(filePath)?.results; - } catch (e) { - const message = `failed to get test results for ${filePath}`; - console.warn(message, e); - this.testSuites.set(filePath, { status: 'KnownFail', message, results: [] }); - throw e; + const _record = record ?? this.testSuites.get(filePath) ?? this.addTestSuiteRecord(filePath); + if (_record.results) { + return _record.results; } + + this.updateMatchedResults(filePath, _record); + return _record.results; } /** * returns sorted test results for the given file * @param filePath * @returns valid sorted test result or undefined if the file is not a test. - * @throws if encountered internal error for test files */ getSortedResults(filePath: string): SortedTestResults | undefined { - const cached = this.testSuites.get(filePath)?.sorted; - if (cached) { - return cached; - } - if (this.isTestFile(filePath) === 'no') { return; } - const result: SortedTestResults = { + const record = this.testSuites.get(filePath) ?? this.addTestSuiteRecord(filePath); + if (record.sorted) { + return record.sorted; + } + + const sorted: SortedTestResults = { fail: [], skip: [], success: [], unknown: [], }; - try { - const testResults = this.getResults(filePath); - if (!testResults) { - return; - } - - for (const test of testResults) { - if (test.status === TestReconciliationState.KnownFail) { - result.fail.push(test); - } else if (test.status === TestReconciliationState.KnownSkip) { - result.skip.push(test); - } else if (test.status === TestReconciliationState.KnownSuccess) { - result.success.push(test); - } else { - result.unknown.push(test); - } - } - } finally { - const cached = this.testSuites.get(filePath); - if (cached) { - cached.sorted = result; + const testResults = this.getResults(filePath, record); + if (!testResults) { + return; + } + for (const test of testResults) { + if (test.status === TestReconciliationState.KnownFail) { + sorted.fail.push(test); + } else if (test.status === TestReconciliationState.KnownSkip) { + sorted.skip.push(test); + } else if (test.status === TestReconciliationState.KnownSuccess) { + sorted.success.push(test); + } else { + sorted.unknown.push(test); } } - return result; + record.update({ sorted }); + return sorted; } updateTestResults(data: JestTotalResults, process: JestProcessInfo): TestFileAssertionStatus[] { const results = this.reconciler.updateFileWithJestStatus(data); results?.forEach((r) => { - this.testSuites.set(r.file, { + const record = this.testSuites.get(r.file) ?? this.addTestSuiteRecord(r.file); + record.update({ status: r.status, message: r.message, - assertionContainer: r.assertions ? match.buildAssertionContainer(r.assertions) : undefined, + isTestFile: true, + assertionContainer: undefined, + results: undefined, + sorted: undefined, }); }); this.events.testSuiteChanged.fire({ @@ -288,4 +429,10 @@ export class TestResultProvider { } return stats; } + + // snapshot support + + public previewSnapshot(testPath: string, testFullName: string): Promise { + return this.snapshotProvider.previewSnapshot(testPath, testFullName); + } } diff --git a/src/TestResults/match-by-context.ts b/src/TestResults/match-by-context.ts index 467c91fd..b239969a 100644 --- a/src/TestResults/match-by-context.ts +++ b/src/TestResults/match-by-context.ts @@ -16,6 +16,7 @@ import { DescribeBlock, Location, NamedBlock, + ParsedNodeTypes, } from 'jest-editor-support'; import { TestReconciliationState } from './TestReconciliationState'; import { TestResult } from './TestResult'; @@ -197,6 +198,7 @@ const ContextMatch = (): ContextMatchAlgorithm => { tNode.addEvent(event); aNode.addEvent(event); aNode.attrs.range = tNode.attrs.range; + aNode.attrs.snapshot = tNode.attrs.snapshot; }; const onMatchResult = ( tNode: DataNode, @@ -452,14 +454,16 @@ const ContextMatch = (): ContextMatchAlgorithm => { */ const { match } = ContextMatch(); - +const isParsedNode = (source: ParsedNode | ContainerNode): source is ParsedNode => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (source as any).type in ParsedNodeTypes; export const matchTestAssertions = ( fileName: string, - sourceRoot: ParsedNode, + source: ParsedNode | ContainerNode, assertions: TestAssertionStatus[] | ContainerNode, verbose = false ): TestResult[] => { - const tContainer = buildSourceContainer(sourceRoot); + const tContainer = isParsedNode(source) ? buildSourceContainer(source) : source; const aContainer = Array.isArray(assertions) ? buildAssertionContainer(assertions) : assertions; const messaging = createMessaging(fileName, verbose); diff --git a/src/TestResults/match-node.ts b/src/TestResults/match-node.ts index c8213ce7..297de994 100644 --- a/src/TestResults/match-node.ts +++ b/src/TestResults/match-node.ts @@ -57,6 +57,7 @@ export interface OptionalAttributes { nonLiteralName?: boolean; // zero-based location range range?: ParsedRange; + snapshot?: 'inline' | 'external'; } export class BaseNode { name: string; diff --git a/src/TestResults/snapshot-provider.ts b/src/TestResults/snapshot-provider.ts new file mode 100644 index 00000000..9e7e3239 --- /dev/null +++ b/src/TestResults/snapshot-provider.ts @@ -0,0 +1,101 @@ +import * as vscode from 'vscode'; +import { Snapshot, SnapshotBlock } from 'jest-editor-support'; +import { escapeRegExp } from '../helpers'; + +export type SnapshotStatus = 'exists' | 'missing' | 'inline'; + +export interface ExtSnapshotBlock extends SnapshotBlock { + isInline: boolean; +} +export interface SnapshotSuite { + testPath: string; + blocks: ExtSnapshotBlock[]; +} + +const inlineKeys = ['toMatchInlineSnapshot', 'toThrowErrorMatchingInlineSnapshot']; +export class SnapshotProvider { + private snapshotSupport: Snapshot; + private panel?: vscode.WebviewPanel; + + constructor() { + this.snapshotSupport = new Snapshot(undefined, inlineKeys); + } + + public parse(testPath: string): SnapshotSuite { + try { + const sBlocks = this.snapshotSupport.parse(testPath); + const blocks = sBlocks.map((block) => ({ + ...block, + isInline: inlineKeys.find((key) => block.node.name.includes(key)) ? true : false, + })); + const snapshotSuite = { testPath, blocks }; + return snapshotSuite; + } catch (e) { + console.warn('[SnapshotProvider] parse failed:', e); + return { testPath, blocks: [] }; + } + } + + private escapeContent = (content: string) => { + if (content) { + const escaped = content + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + return `
${escaped}
`; + } + }; + public async previewSnapshot(testPath: string, testFullName: string): Promise { + const content = await this.snapshotSupport.getSnapshotContent( + testPath, + new RegExp(`^${escapeRegExp(testFullName)} [0-9]+$`) + ); + const noSnapshotFound = (): void => { + vscode.window.showErrorMessage('no snapshot is found, please run test to generate first'); + return; + }; + if (!content) { + return noSnapshotFound(); + } + let contentString: string | undefined; + if (typeof content === 'string') { + contentString = this.escapeContent(content); + } else { + const entries = Object.entries(content); + switch (entries.length) { + case 0: + return noSnapshotFound(); + case 1: + contentString = this.escapeContent(entries[0][1]); + break; + default: { + const strings = entries.map( + ([key, value]) => `

${key}

${this.escapeContent(value)}` + ); + contentString = strings.join('
'); + break; + } + } + } + + if (this.panel) { + this.panel.reveal(); + } else { + this.panel = vscode.window.createWebviewPanel( + 'view_snapshot', + testFullName, + vscode.ViewColumn.Two, + {} + ); + + this.panel.onDidDispose(() => { + this.panel = undefined; + }); + } + + this.panel.webview.html = contentString ?? ''; + this.panel.title = testFullName; + } +} diff --git a/src/TestResults/test-result-events.ts b/src/TestResults/test-result-events.ts index 6fb20635..774a9867 100644 --- a/src/TestResults/test-result-events.ts +++ b/src/TestResults/test-result-events.ts @@ -17,7 +17,7 @@ export type TestSuitChangeEvent = | { type: 'test-parsed'; file: string; - testContainer: ContainerNode; + sourceContainer: ContainerNode; }; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types diff --git a/src/extension.ts b/src/extension.ts index fbfed348..11093647 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,7 @@ import * as vscode from 'vscode'; import { statusBar } from './StatusBar'; -import { ExtensionManager, getExtensionWindowSettings } from './extensionManager'; -import { registerSnapshotCodeLens, registerSnapshotPreview } from './SnapshotCodeLens'; +import { ExtensionManager } from './extensionManager'; import { tiContextManager } from './test-provider/test-item-context-manager'; let extensionManager: ExtensionManager; @@ -21,8 +20,6 @@ const addSubscriptions = (context: vscode.ExtensionContext): void => { context.subscriptions.push( ...statusBar.register((folder: string) => extensionManager.getByName(folder)), ...extensionManager.register(), - ...registerSnapshotCodeLens(getExtensionWindowSettings()?.enableSnapshotPreviews ?? false), - ...registerSnapshotPreview(), vscode.languages.registerCodeLensProvider(languages, extensionManager.coverageCodeLensProvider), vscode.languages.registerCodeLensProvider(languages, extensionManager.debugCodeLensProvider), ...tiContextManager.registerCommands() diff --git a/src/extensionManager.ts b/src/extensionManager.ts index 565a8238..4ba6e47c 100644 --- a/src/extensionManager.ts +++ b/src/extensionManager.ts @@ -13,6 +13,7 @@ import { StartWizardOptions, WizardTaskId, } from './setup-wizard'; +import { ItemCommand } from './test-provider/types'; export type GetJestExtByURI = (uri: vscode.Uri) => JestExt | undefined; @@ -407,6 +408,13 @@ export class ExtensionManager { extension.enableLoginShell(); }, }), + this.registerCommand({ + type: 'workspace', + name: 'item-command', + callback: (extension, testItem: vscode.TestItem, itemCommand: ItemCommand) => { + extension.runItemCommand(testItem, itemCommand); + }, + }), // setup tool vscode.commands.registerCommand(`${extensionName}.setup-extension`, this.startWizard), diff --git a/src/test-provider/test-item-context-manager.ts b/src/test-provider/test-item-context-manager.ts index 37189f73..93223d9c 100644 --- a/src/test-provider/test-item-context-manager.ts +++ b/src/test-provider/test-item-context-manager.ts @@ -1,20 +1,30 @@ import * as vscode from 'vscode'; import { extensionName } from '../appGlobals'; +import { ItemCommand } from './types'; /** * A cross-workspace Context Manager that manages the testId context used in * TestExplorer menu when-condition */ -export type TEItemContextKey = 'jest.autoRun' | 'jest.coverage'; - -export interface ItemContext { - workspace: vscode.WorkspaceFolder; - key: TEItemContextKey; - /** the current value of the itemId */ - value: boolean; - itemIds: string[]; +export interface SnapshotItem { + itemId: string; + testFullName: string; } +export type ItemContext = + | { + key: 'jest.autoRun' | 'jest.coverage'; + workspace: vscode.WorkspaceFolder; + /** the current value of the itemId */ + value: boolean; + itemIds: string[]; + } + | { + key: 'jest.editor-view-snapshot' | 'jest.editor-update-snapshot'; + workspace: vscode.WorkspaceFolder; + itemIds: string[]; + }; +export type TEItemContextKey = ItemContext['key']; export class TestItemContextManager { private cache = new Map(); @@ -23,34 +33,47 @@ export class TestItemContextManager { return `${key}.${value ? 'on' : 'off'}`; } public setItemContext(context: ItemContext): void { - console.log(`setItemContext for context=`, context); - let list = this.cache.get(context.key); - if (!list) { - list = [context]; - } else { - list = list.filter((c) => c.workspace.name !== context.workspace.name).concat(context); - } - this.cache.set(context.key, list); + switch (context.key) { + case 'jest.autoRun': + case 'jest.coverage': { + let list = this.cache.get(context.key); + if (!list) { + list = [context]; + } else { + // itemIds are not accumulated, but toggled + list = list.filter((c) => c.workspace.name !== context.workspace.name).concat(context); + } + this.cache.set(context.key, list); - //set context for both on and off - let itemIds = list.filter((c) => c.value === true).flatMap((c) => c.itemIds); - vscode.commands.executeCommand('setContext', this.contextKey(context.key, true), itemIds); + //set context for both on and off + let itemIds = list + .flatMap((c) => (c.key === context.key && c.value === true ? c.itemIds : undefined)) + .filter((c) => c !== undefined); + vscode.commands.executeCommand('setContext', this.contextKey(context.key, true), itemIds); - itemIds = list.filter((c) => c.value === false).flatMap((c) => c.itemIds); - vscode.commands.executeCommand('setContext', this.contextKey(context.key, false), itemIds); + itemIds = list + .flatMap((c) => (c.key === context.key && c.value === false ? c.itemIds : undefined)) + .filter((c) => c !== undefined); + vscode.commands.executeCommand('setContext', this.contextKey(context.key, false), itemIds); + break; + } + case 'jest.editor-view-snapshot': + case 'jest.editor-update-snapshot': { + this.cache.set(context.key, [context]); + vscode.commands.executeCommand('setContext', context.key, context.itemIds); + break; + } + } } - private getWorkspace( - key: TEItemContextKey, - item: vscode.TestItem - ): vscode.WorkspaceFolder | undefined { + private getItemContext(key: TEItemContextKey, item: vscode.TestItem): ItemContext | undefined { const list = this.cache.get(key); - return list?.find((c) => c.itemIds.includes(item.id))?.workspace; + return list?.find((c) => c.itemIds.includes(item.id)); } public registerCommands(): vscode.Disposable[] { const autoRunCommands = ['test-item.auto-run.toggle-off', 'test-item.auto-run.toggle-on'].map( (n) => vscode.commands.registerCommand(`${extensionName}.${n}`, (testItem: vscode.TestItem) => { - const workspace = this.getWorkspace('jest.autoRun', testItem); + const workspace = this.getItemContext('jest.autoRun', testItem)?.workspace; if (workspace) { vscode.commands.executeCommand( `${extensionName}.with-workspace.toggle-auto-run`, @@ -62,7 +85,7 @@ export class TestItemContextManager { const coverageCommands = ['test-item.coverage.toggle-off', 'test-item.coverage.toggle-on'].map( (n) => vscode.commands.registerCommand(`${extensionName}.${n}`, (testItem: vscode.TestItem) => { - const workspace = this.getWorkspace('jest.coverage', testItem); + const workspace = this.getItemContext('jest.coverage', testItem)?.workspace; if (workspace) { vscode.commands.executeCommand( `${extensionName}.with-workspace.toggle-coverage`, @@ -71,7 +94,35 @@ export class TestItemContextManager { } }) ); - return [...autoRunCommands, ...coverageCommands]; + const viewSnapshotCommand = vscode.commands.registerCommand( + `${extensionName}.test-item.view-snapshot`, + (testItem: vscode.TestItem) => { + const workspace = this.getItemContext('jest.editor-view-snapshot', testItem)?.workspace; + if (workspace) { + vscode.commands.executeCommand( + `${extensionName}.with-workspace.item-command`, + workspace, + testItem, + ItemCommand.viewSnapshot + ); + } + } + ); + const updateSnapshotCommand = vscode.commands.registerCommand( + `${extensionName}.test-item.update-snapshot`, + (testItem: vscode.TestItem) => { + const workspace = this.getItemContext('jest.editor-update-snapshot', testItem)?.workspace; + if (workspace) { + vscode.commands.executeCommand( + `${extensionName}.with-workspace.item-command`, + workspace, + testItem, + ItemCommand.updateSnapshot + ); + } + } + ); + return [...autoRunCommands, ...coverageCommands, viewSnapshotCommand, updateSnapshotCommand]; } } diff --git a/src/test-provider/test-item-data.ts b/src/test-provider/test-item-data.ts index e829827b..8f7953bb 100644 --- a/src/test-provider/test-item-data.ts +++ b/src/test-provider/test-item-data.ts @@ -8,11 +8,12 @@ import { ItBlock, TestAssertionStatus } from 'jest-editor-support'; import { ContainerNode, DataNode, NodeType, ROOT_NODE_NAME } from '../TestResults/match-node'; import { Logging } from '../logging'; import { TestSuitChangeEvent } from '../TestResults/test-result-events'; -import { Debuggable, TestItemData } from './types'; +import { Debuggable, ItemCommand, TestItemData } from './types'; import { JestTestProviderContext, JestTestRun, JestTestRunOptions } from './test-provider-helper'; import { JestProcessInfo, JestProcessRequest } from '../JestProcessManagement'; import { GENERIC_ERROR, getExitErrorDef, LONG_RUNNING_TESTS } from '../errors'; import { JestExtOutput } from '../JestExt/output-terminal'; +import { tiContextManager } from './test-item-context-manager'; interface JestRunable { getJestRunRequest: () => JestExtRequestType; @@ -52,8 +53,8 @@ abstract class TestItemDataBase implements TestItemData, JestRunable, WithUri { item.children.forEach((child) => this.deepItemState(child, setState)); } - scheduleTest(run: JestTestRun): void { - const jestRequest = this.getJestRunRequest(); + scheduleTest(run: JestTestRun, itemCommand?: ItemCommand): void { + const jestRequest = this.getJestRunRequest(itemCommand); run.item = this.item; this.deepItemState(this.item, run.enqueued); @@ -70,7 +71,30 @@ abstract class TestItemDataBase implements TestItemData, JestRunable, WithUri { } } - abstract getJestRunRequest(): JestExtRequestType; + runItemCommand(command: ItemCommand): void | Promise { + switch (command) { + case ItemCommand.updateSnapshot: { + const request = new vscode.TestRunRequest([this.item]); + const run = this.context.createTestRun(request, { + name: `${command}-${this.item.id}`, + }); + this.scheduleTest(run, command); + break; + } + case ItemCommand.viewSnapshot: { + return this.viewSnapshot().catch((e) => this.log('error', e)); + } + } + } + viewSnapshot(): Promise { + return Promise.reject(`viewSnapshot is not supported for ${this.item.id}`); + } + abstract getJestRunRequest(itemCommand?: ItemCommand): JestExtRequestType; +} + +interface SnapshotItemCollection { + viewable: vscode.TestItem[]; + updatable: vscode.TestItem[]; } /** @@ -106,15 +130,16 @@ export class WorkspaceRoot extends TestItemDataBase { return item; } - getJestRunRequest(): JestExtRequestType { + getJestRunRequest(itemCommand?: ItemCommand): JestExtRequestType { const transform = (request: JestProcessRequest) => { request.schedule.queue = 'blocking-2'; return request; }; - return { type: 'all-tests', transform }; + const updateSnapshot = itemCommand === ItemCommand.updateSnapshot; + return { type: 'all-tests', updateSnapshot, transform }; } discoverTest(run: JestTestRun): void { - const testList = this.context.ext.testResolveProvider.getTestList(); + const testList = this.context.ext.testResultProvider.getTestList(); // only trigger update when testList is not empty because it's possible test-list is not available yet, // in such case we should just wait for the testListUpdated event to trigger the update if (testList.length > 0) { @@ -128,8 +153,8 @@ export class WorkspaceRoot extends TestItemDataBase { // test result event handling private registerEvents = (): void => { this.listeners = [ - this.context.ext.testResolveProvider.events.testListUpdated.event(this.onTestListUpdated), - this.context.ext.testResolveProvider.events.testSuiteChanged.event(this.onTestSuiteChanged), + this.context.ext.testResultProvider.events.testListUpdated.event(this.onTestListUpdated), + this.context.ext.testResultProvider.events.testSuiteChanged.event(this.onTestSuiteChanged), this.context.ext.sessionEvents.onRunEvent.event(this.onRunEvent), ]; }; @@ -238,16 +263,43 @@ export class WorkspaceRoot extends TestItemDataBase { break; } case 'result-matched': { - this.addTestFile(event.file, (testRoot) => testRoot.onTestMatched()); + const snapshotItems: SnapshotItemCollection = { + viewable: [], + updatable: [], + }; + this.addTestFile(event.file, (testRoot) => { + testRoot.onTestMatched(); + testRoot.gatherSnapshotItems(snapshotItems); + }); + this.updateSnapshotContext(snapshotItems); break; } case 'test-parsed': { - this.addTestFile(event.file, (testRoot) => - testRoot.discoverTest(undefined, event.testContainer) - ); + const snapshotItems: SnapshotItemCollection = { + viewable: [], + updatable: [], + }; + this.addTestFile(event.file, (testRoot) => { + testRoot.discoverTest(undefined, event.sourceContainer); + testRoot.gatherSnapshotItems(snapshotItems); + }); + this.updateSnapshotContext(snapshotItems); + break; } } }; + private updateSnapshotContext(snapshotItems: SnapshotItemCollection): void { + tiContextManager.setItemContext({ + workspace: this.context.ext.workspace, + key: 'jest.editor-view-snapshot', + itemIds: snapshotItems.viewable.map((item) => item.id), + }); + tiContextManager.setItemContext({ + workspace: this.context.ext.workspace, + key: 'jest.editor-update-snapshot', + itemIds: snapshotItems.updatable.map((item) => item.id), + }); + } /** get test item from jest process. If running tests from source file, will return undefined */ private getItemFromProcess = (process: JestProcessInfo): vscode.TestItem | undefined => { @@ -403,9 +455,11 @@ export class FolderData extends TestItemDataBase { item.canResolveChildren = false; return item; } - getJestRunRequest(): JestExtRequestType { + getJestRunRequest(itemCommand?: ItemCommand): JestExtRequestType { + const updateSnapshot = itemCommand === ItemCommand.updateSnapshot; return { type: 'by-file-pattern', + updateSnapshot, testFileNamePattern: this.uri.fsPath, }; } @@ -495,6 +549,7 @@ abstract class TestResultData extends TestItemDataBase { const truncateExtra = (id: string): string => id.replace(/(.*)(#[0-9]+$)/, '$1'); return truncateExtra(id1) === truncateExtra(id2); } + syncChildNodes(node: ItemNodeType): void { const testId = this.makeTestId(this.uri, node); if (!this.isSameId(testId, this.item.id)) { @@ -541,6 +596,15 @@ abstract class TestResultData extends TestItemDataBase { new vscode.Range(new vscode.Position(zeroBasedLine, 0), new vscode.Position(zeroBasedLine, 0)) ); } + + forEachChild(onTestData: (child: TestData) => void): void { + this.item.children.forEach((childItem) => { + const child = this.context.getData(childItem); + if (child) { + onTestData(child); + } + }); + } } export class TestDocumentRoot extends TestResultData { constructor( @@ -574,7 +638,7 @@ export class TestDocumentRoot extends TestResultData { private createChildItems = (parsedRoot?: ContainerNode): void => { const container = parsedRoot ?? - this.context.ext.testResolveProvider.getTestSuiteResult(this.item.id)?.assertionContainer; + this.context.ext.testResultProvider.getTestSuiteResult(this.item.id)?.assertionContainer; if (!container) { this.item.children.replace([]); } else { @@ -585,7 +649,7 @@ export class TestDocumentRoot extends TestResultData { }; public updateResultState(run: JestTestRun): void { - const suiteResult = this.context.ext.testResolveProvider.getTestSuiteResult(this.item.id); + const suiteResult = this.context.ext.testResultProvider.getTestSuiteResult(this.item.id); // only update suite status if the assertionContainer is empty, which can occur when // test file has syntax error or failed to run for whatever reason. @@ -594,27 +658,28 @@ export class TestDocumentRoot extends TestResultData { if (isEmpty(suiteResult?.assertionContainer)) { this.updateItemState(run, suiteResult); } - - this.item.children.forEach((childItem) => - this.context.getData(childItem)?.updateResultState(run) - ); + this.forEachChild((child) => child.updateResultState(run)); } - getJestRunRequest = (): JestExtRequestType => { + getJestRunRequest(itemCommand?: ItemCommand): JestExtRequestType { + const updateSnapshot = itemCommand === ItemCommand.updateSnapshot; return { type: 'by-file-pattern', + updateSnapshot, testFileNamePattern: this.uri.fsPath, }; - }; + } getDebugInfo(): ReturnType { return { fileName: this.uri.fsPath }; } - public onTestMatched = (): void => { - this.item.children.forEach((childItem) => - this.context.getData(childItem)?.onTestMatched() - ); - }; + + public onTestMatched(): void { + this.forEachChild((child) => child.onTestMatched()); + } + public gatherSnapshotItems(snapshotItems: SnapshotItemCollection): void { + this.forEachChild((child) => child.gatherSnapshotItems(snapshotItems)); + } } export class TestData extends TestResultData implements Debuggable { constructor( @@ -642,13 +707,15 @@ export class TestData extends TestResultData implements Debuggable { return item; } - getJestRunRequest(): JestExtRequestType { + getJestRunRequest(itemCommand?: ItemCommand): JestExtRequestType { return { type: 'by-file-test-pattern', + updateSnapshot: itemCommand === ItemCommand.updateSnapshot, testFileNamePattern: this.uri.fsPath, testNamePattern: this.node.fullName, }; } + getDebugInfo(): ReturnType { return { fileName: this.uri.fsPath, testNamePattern: this.node.fullName }; } @@ -677,11 +744,32 @@ export class TestData extends TestResultData implements Debuggable { public onTestMatched(): void { // assertion might have picked up source block location this.updateItemRange(); - this.item.children.forEach((childItem) => - this.context.getData(childItem)?.onTestMatched() - ); + this.forEachChild((child) => child.onTestMatched()); } + /** + * determine if a test contains dynamic content, such as template-literal or "test.each" variables from the node info. + * Once the test is run, the node should reflect the resolved names. + */ + private isTestNameResolved(): boolean { + //isGroup = true means "test.each" + return !(this.node.attrs.isGroup === 'yes' || this.node.attrs.nonLiteralName === true); + } + public gatherSnapshotItems(snapshotItems: SnapshotItemCollection): void { + // only response if not a "dynamic named" test, which we can't update or view snapshot until the names are resolved + // after running the tests + if (!this.isTestNameResolved()) { + return; + } + if (this.node.attrs.snapshot === 'inline') { + snapshotItems.updatable.push(this.item); + } + if (this.node.attrs.snapshot === 'external') { + snapshotItems.updatable.push(this.item); + snapshotItems.viewable.push(this.item); + } + this.forEachChild((child) => child.gatherSnapshotItems(snapshotItems)); + } public updateResultState(run: JestTestRun): void { if (this.node && isAssertDataNode(this.node)) { const assertion = this.node.data; @@ -689,8 +777,16 @@ export class TestData extends TestResultData implements Debuggable { assertion.line != null ? this.createLocation(this.uri, assertion.line - 1) : undefined; this.updateItemState(run, assertion, errorLine); } - this.item.children.forEach((childItem) => - this.context.getData(childItem)?.updateResultState(run) - ); + this.forEachChild((child) => child.updateResultState(run)); + } + public viewSnapshot(): Promise { + if (this.node.attrs.snapshot === 'external') { + return this.context.ext.testResultProvider.previewSnapshot( + this.uri.fsPath, + this.node.fullName + ); + } + this.log('error', `no external snapshot to be viewed: ${this.item.id}`); + return Promise.resolve(); } } diff --git a/src/test-provider/test-provider-helper.ts b/src/test-provider/test-provider-helper.ts index 3a8f5e83..4c2fda97 100644 --- a/src/test-provider/test-provider-helper.ts +++ b/src/test-provider/test-provider-helper.ts @@ -9,7 +9,7 @@ import { JestExtExplorerContext, TestItemData } from './types'; * as well as factory functions to create TestItem and TestRun that could impact the state */ -export type TagIdType = 'run' | 'debug'; +export type TagIdType = 'run' | 'debug' | 'update-snapshot'; let RunSeq = 0; export class JestTestProviderContext { @@ -84,8 +84,13 @@ export class JestTestProviderContext { }; // tags - getTag = (tagId: TagIdType): vscode.TestTag | undefined => - this.profiles.find((p) => p.tag?.id === tagId)?.tag; + getTag = (tagId: TagIdType): vscode.TestTag => { + const tag = this.profiles.find((p) => p.tag?.id === tagId)?.tag; + if (!tag) { + throw new Error(`unrecognized tag: ${tagId}`); + } + return tag; + }; } export interface JestTestRunOptions { diff --git a/src/test-provider/test-provider.ts b/src/test-provider/test-provider.ts index 3f7d2f55..cd509d9e 100644 --- a/src/test-provider/test-provider.ts +++ b/src/test-provider/test-provider.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { JestTestProviderContext, JestTestRun } from './test-provider-helper'; import { WorkspaceRoot } from './test-item-data'; -import { Debuggable, JestExtExplorerContext, TestItemData } from './types'; +import { Debuggable, ItemCommand, JestExtExplorerContext, TestItemData, TestTagId } from './types'; import { extensionId } from '../appGlobals'; import { Logging } from '../logging'; import { toErrorString } from '../helpers'; @@ -10,12 +10,6 @@ import { tiContextManager } from './test-item-context-manager'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const isDebuggable = (arg: any): arg is Debuggable => arg && typeof arg.getDebugInfo === 'function'; -export const RunProfileInfo: Record = { - [vscode.TestRunProfileKind.Run]: 'run', - [vscode.TestRunProfileKind.Debug]: 'debug', - [vscode.TestRunProfileKind.Coverage]: 'run with coverage', -}; - export class JestTestProvider { private readonly controller: vscode.TestController; private context: JestTestProviderContext; @@ -64,8 +58,8 @@ export class JestTestProvider { return controller; }; private createProfiles = (controller: vscode.TestController): vscode.TestRunProfile[] => { - const runTag = new vscode.TestTag('run'); - const debugTag = new vscode.TestTag('debug'); + const runTag = new vscode.TestTag(TestTagId.Run); + const debugTag = new vscode.TestTag(TestTagId.Debug); const profiles = [ controller.createRunProfile( 'run', @@ -191,6 +185,11 @@ export class JestTestProvider { run.end(); }; + public runItemCommand(testItem: vscode.TestItem, command: ItemCommand): void | Promise { + const data = this.context.getData(testItem); + return data?.runItemCommand(command); + } + dispose(): void { this.workspaceRoot.dispose(); this.controller.dispose(); diff --git a/src/test-provider/types.ts b/src/test-provider/types.ts index a51d196c..2dc16db1 100644 --- a/src/test-provider/types.ts +++ b/src/test-provider/types.ts @@ -8,7 +8,7 @@ export type TestItemDataType = WorkspaceRoot | FolderData | TestDocumentRoot | T /** JestExt context exposed to the test explorer */ export interface JestExtExplorerContext extends JestExtSessionContext { - readonly testResolveProvider: TestResultProvider; + readonly testResultProvider: TestResultProvider; readonly sessionEvents: JestSessionEvents; debugTests: DebugFunction; } @@ -18,9 +18,20 @@ export interface TestItemData { readonly uri: vscode.Uri; context: JestTestProviderContext; discoverTest?: (run: JestTestRun) => void; - scheduleTest: (run: JestTestRun) => void; + scheduleTest: (run: JestTestRun, itemCommand?: ItemCommand) => void; + runItemCommand: (command: ItemCommand) => void; } export interface Debuggable { getDebugInfo: () => { fileName: string; testNamePattern?: string }; } + +export enum TestTagId { + Run = 'run', + Debug = 'debug', +} + +export enum ItemCommand { + updateSnapshot = 'update-snapshot', + viewSnapshot = 'view-snapshot', +} diff --git a/tests/JestExt/core.test.ts b/tests/JestExt/core.test.ts index cc0d836b..9df82bd5 100644 --- a/tests/JestExt/core.test.ts +++ b/tests/JestExt/core.test.ts @@ -43,6 +43,7 @@ import { addFolderToDisabledWorkspaceFolders } from '../../src/extensionManager' import { JestOutputTerminal } from '../../src/JestExt/output-terminal'; import { RunShell } from '../../src/JestExt/run-shell'; import * as errors from '../../src/errors'; +import { ItemCommand } from '../../src/test-provider/types'; /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "expectItTakesNoAction"] }] */ const mockHelpers = helper as jest.Mocked; @@ -112,6 +113,7 @@ describe('JestExt', () => { const mockTestProvider: any = { dispose: jest.fn(), + runItemCommand: jest.fn(), }; beforeEach(() => { @@ -1245,4 +1247,14 @@ describe('JestExt', () => { ); }); }); + it('runItemCommand will delegate operation to testProvider', () => { + const jestExt = newJestExt(); + jestExt.startSession(); + const testItem: any = {}; + jestExt.runItemCommand(testItem, ItemCommand.updateSnapshot); + expect(mockTestProvider.runItemCommand).toHaveBeenCalledWith( + testItem, + ItemCommand.updateSnapshot + ); + }); }); diff --git a/tests/JestExt/helper.test.ts b/tests/JestExt/helper.test.ts index 9ee85173..9404c89d 100644 --- a/tests/JestExt/helper.test.ts +++ b/tests/JestExt/helper.test.ts @@ -172,7 +172,6 @@ describe('getExtensionResourceSettings()', () => { expect(getExtensionResourceSettings(uri)).toEqual({ autoEnable: true, coverageFormatter: 'DefaultFormatter', - enableSnapshotUpdateMessages: true, pathToConfig: '', pathToJest: null, jestCommandLine: undefined, diff --git a/tests/JestExt/process-listeners.test.ts b/tests/JestExt/process-listeners.test.ts index fbe2d38e..565484cd 100644 --- a/tests/JestExt/process-listeners.test.ts +++ b/tests/JestExt/process-listeners.test.ts @@ -227,7 +227,6 @@ describe('jest process listeners', () => { }); }); describe('RunTestListener', () => { - /* eslint-disable jest/no-conditional-expect */ beforeEach(() => { mockSession.context.output = { appendLine: jest.fn(), @@ -447,88 +446,6 @@ describe('jest process listeners', () => { expect(clearTimeout).toHaveBeenCalledTimes(1); }); }); - describe('when snapshot test failed', () => { - it.each` - seq | output | enableSnapshotUpdateMessages | expectUpdateSnapshot - ${1} | ${'Snapshot test failed'} | ${true} | ${true} - ${2} | ${'Snapshot test failed'} | ${false} | ${false} - ${3} | ${'Snapshot failed'} | ${true} | ${true} - ${4} | ${'Snapshots failed'} | ${true} | ${true} - ${5} | ${'Failed for some other reason'} | ${true} | ${false} - `( - 'can detect snapshot failure: #$seq', - async ({ output, enableSnapshotUpdateMessages, expectUpdateSnapshot }) => { - expect.hasAssertions(); - mockSession.context.settings.enableSnapshotUpdateMessages = enableSnapshotUpdateMessages; - (vscode.window.showInformationMessage as jest.Mocked).mockReturnValue( - Promise.resolve('something') - ); - - const listener = new RunTestListener(mockSession); - - await listener.onEvent(mockProcess, 'executableStdErr', Buffer.from(output)); - if (expectUpdateSnapshot) { - expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); - expect(mockSession.scheduleProcess).toHaveBeenCalledWith({ - type: 'update-snapshot', - baseRequest: mockProcess.request, - }); - } else { - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - expect(mockSession.scheduleProcess).not.toHaveBeenCalled(); - } - } - ); - it('will abort auto update snapshot if no user action is taken', async () => { - expect.hasAssertions(); - mockSession.context.settings.enableSnapshotUpdateMessages = true; - (vscode.window.showInformationMessage as jest.Mocked).mockReturnValue( - Promise.resolve(undefined) - ); - - const listener = new RunTestListener(mockSession); - - await listener.onEvent( - mockProcess, - 'executableStdErr', - Buffer.from('Snapshot test failed') - ); - expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); - expect(mockSession.scheduleProcess).not.toHaveBeenCalled(); - }); - it('auto update snapsot only apply for runs not already updating snapshots', async () => { - expect.hasAssertions(); - mockSession.context.settings.enableSnapshotUpdateMessages = true; - (vscode.window.showInformationMessage as jest.Mocked).mockReturnValue( - Promise.resolve('something') - ); - - const listener = new RunTestListener(mockSession); - - await listener.onEvent( - mockProcess, - 'executableStdErr', - Buffer.from('Snapshot test failed') - ); - expect(vscode.window.showInformationMessage).toHaveBeenCalledTimes(1); - expect(mockSession.scheduleProcess).toHaveBeenCalledWith({ - type: 'update-snapshot', - baseRequest: mockProcess.request, - }); - - // for a process already with updateSnapshot flag: do nothing - (vscode.window.showInformationMessage as jest.Mocked).mockClear(); - mockSession.scheduleProcess.mockClear(); - mockProcess.request.updateSnapshot = true; - await listener.onEvent( - mockProcess, - 'executableStdErr', - Buffer.from('Snapshot test failed') - ); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - expect(mockSession.scheduleProcess).not.toHaveBeenCalled(); - }); - }); describe('when "--watch" is not supported', () => { const outOfRepositoryOutput = ` diff --git a/tests/JestExt/process-session.test.ts b/tests/JestExt/process-session.test.ts index 029ed8a8..0d0efd34 100644 --- a/tests/JestExt/process-session.test.ts +++ b/tests/JestExt/process-session.test.ts @@ -80,35 +80,6 @@ describe('ProcessSession', () => { } } ); - it.each` - baseRequest | snapshotRequest - ${{ type: 'watch-tests' }} | ${{ type: 'all-tests', updateSnapshot: true }} - ${{ type: 'watch-all-tests' }} | ${{ type: 'all-tests', updateSnapshot: true }} - ${{ type: 'all-tests' }} | ${{ type: 'all-tests', updateSnapshot: true }} - ${{ type: 'by-file', testFileName: 'abc' }} | ${{ type: 'by-file', testFileName: 'abc', updateSnapshot: true }} - ${{ type: 'by-file', testFileName: 'abc', updateSnapshot: true }} | ${undefined} - ${{ type: 'by-file-pattern', testFileNamePattern: 'abc' }} | ${{ type: 'by-file-pattern', testFileNamePattern: 'abc', updateSnapshot: true }} - ${{ type: 'by-file-test', testFileName: 'abc', testNamePattern: 'a test' }} | ${{ type: 'by-file-test', testFileName: 'abc', testNamePattern: 'a test', updateSnapshot: true }} - ${{ type: 'by-file-test-pattern', testFileNamePattern: 'abc', testNamePattern: 'a test' }} | ${{ type: 'by-file-test-pattern', testFileNamePattern: 'abc', testNamePattern: 'a test', updateSnapshot: true }} - `( - 'can schedule update-snapshot request: $baseRequest', - async ({ baseRequest, snapshotRequest }) => { - expect.hasAssertions(); - const sm = createProcessSession(context); - expect(mockProcessManager).toHaveBeenCalledTimes(1); - - sm.scheduleProcess({ type: 'update-snapshot', baseRequest }); - - if (snapshotRequest) { - expect(processManagerMock.scheduleJestProcess).toHaveBeenCalledWith( - expect.objectContaining(snapshotRequest) - ); - } else { - expect(processManagerMock.scheduleJestProcess).not.toHaveBeenCalled(); - } - } - ); - it.each([['not-test']])('currently does not support "%s" request scheduling', (type) => { expect.hasAssertions(); const sm = createProcessSession(context); @@ -119,13 +90,12 @@ describe('ProcessSession', () => { expect(processManagerMock.scheduleJestProcess).not.toHaveBeenCalled(); }); describe.each` - type | inputProperty | defaultListener - ${'all-tests'} | ${undefined} | ${listeners.RunTestListener} - ${'watch-tests'} | ${undefined} | ${listeners.RunTestListener} - ${'watch-all-tests'} | ${undefined} | ${listeners.RunTestListener} - ${'by-file'} | ${{ testFileNamePattern: 'abc' }} | ${listeners.RunTestListener} - ${'list-test-files'} | ${undefined} | ${listeners.ListTestFileListener} - ${'update-snapshot'} | ${{ baseRequest: { type: 'all-tests' } }} | ${listeners.RunTestListener} + type | inputProperty | defaultListener + ${'all-tests'} | ${undefined} | ${listeners.RunTestListener} + ${'watch-tests'} | ${undefined} | ${listeners.RunTestListener} + ${'watch-all-tests'} | ${undefined} | ${listeners.RunTestListener} + ${'by-file'} | ${{ testFileNamePattern: 'abc' }} | ${listeners.RunTestListener} + ${'list-test-files'} | ${undefined} | ${listeners.ListTestFileListener} `('schedule $type', ({ type, inputProperty, defaultListener }) => { it('with default listener', () => { expect.hasAssertions(); diff --git a/tests/SnapshotCodeLens/SnapshotCodeLensProvider.test.ts b/tests/SnapshotCodeLens/SnapshotCodeLensProvider.test.ts deleted file mode 100644 index c201fa0f..00000000 --- a/tests/SnapshotCodeLens/SnapshotCodeLensProvider.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -jest.unmock('../../src/SnapshotCodeLens/SnapshotCodeLensProvider'); - -import * as vscode from 'vscode'; -import { registerSnapshotCodeLens } from '../../src/SnapshotCodeLens/SnapshotCodeLensProvider'; -import { Snapshot } from 'jest-editor-support'; - -describe('SnapshotCodeLensProvider', () => { - const mockMetadataAsync = jest.fn(); - const mockCodeLens = jest.fn(); - beforeEach(() => { - jest.resetAllMocks(); - (Snapshot as jest.Mocked).mockReturnValue({ - getMetadataAsync: mockMetadataAsync, - }); - (vscode as jest.Mocked).CodeLens = mockCodeLens; - }); - describe('registerSnapshotCodeLens', () => { - it('register if enableSnapshotPreviews is not false', () => { - const registration = registerSnapshotCodeLens(true); - expect(registration.length > 0).toBeTruthy(); - expect(vscode.languages.registerCodeLensProvider).toHaveBeenCalled(); - expect(vscode.commands.registerCommand).toHaveBeenCalledWith( - expect.stringContaining('snapshot.missing'), - expect.anything() - ); - }); - it('not register if enableSnapshotPreviews is false', () => { - const registration = registerSnapshotCodeLens(false); - expect(registration).toHaveLength(0); - expect(vscode.languages.registerCodeLensProvider).not.toHaveBeenCalled(); - expect(vscode.commands.registerCommand).not.toHaveBeenCalled(); - }); - }); - describe('provideCodeLenses', () => { - let provider; - const snapshotMetadata = (line: number, exists = true): any => ({ - node: { loc: { start: { line } } }, - exists, - }); - beforeEach(() => { - registerSnapshotCodeLens(true); - provider = (vscode.languages.registerCodeLensProvider as jest.Mocked).mock.calls[0][1]; - }); - it('create codeLens for each snapshot', async () => { - mockMetadataAsync.mockReturnValue( - Promise.resolve([snapshotMetadata(10, true), snapshotMetadata(20, false)]) - ); - - await provider.provideCodeLenses({ uri: { fsPath: 'whatever' } }, {}); - expect(mockCodeLens).toHaveBeenCalledTimes(2); - - let [, command] = mockCodeLens.mock.calls[0]; - let range = (vscode.Range as jest.Mocked).mock.calls[0]; - expect(range).toEqual([9, 0, 9, 0]); - expect(command.title).toEqual('view snapshot'); - - [, command] = mockCodeLens.mock.calls[1]; - range = (vscode.Range as jest.Mocked).mock.calls[1]; - expect(range).toEqual([19, 0, 19, 0]); - expect(command.title).toEqual('snapshot missing'); - }); - }); -}); diff --git a/tests/TestResults/TestResultProvider.test.ts b/tests/TestResults/TestResultProvider.test.ts index 7b9340ea..864375c0 100644 --- a/tests/TestResults/TestResultProvider.test.ts +++ b/tests/TestResults/TestResultProvider.test.ts @@ -17,8 +17,8 @@ const mockParse = jest.fn(); jest.mock('jest-editor-support', () => { const TestReconciler = mockTestReconciler; const parse = mockParse; - - return { TestReconciler, parse }; + const ParsedNodeTypes = []; + return { TestReconciler, parse, ParsedNodeTypes }; }); const pathProperties = { @@ -41,15 +41,17 @@ import * as helper from '../test-helper'; import { ItBlock, TestAssertionStatus, TestReconcilationState } from 'jest-editor-support'; import * as match from '../../src/TestResults/match-by-context'; import { mockJestExtEvents } from '../test-helper'; +import { ExtSnapshotBlock, SnapshotProvider } from '../../src/TestResults/snapshot-provider'; const setupMockParse = (itBlocks: ItBlock[]) => { mockParse.mockReturnValue({ root: helper.makeRoot(itBlocks), itBlocks, + describeBlocks: [], }); }; -const createDataSet = (): [ItBlock[], TestAssertionStatus[]] => { +const createDataSet = (): [ItBlock[], TestAssertionStatus[], ExtSnapshotBlock[]] => { const testBlocks = [ helper.makeItBlock('test 1', [2, 3, 4, 5]), helper.makeItBlock('test 2', [12, 13, 14, 15]), @@ -64,7 +66,11 @@ const createDataSet = (): [ItBlock[], TestAssertionStatus[]] => { helper.makeAssertion('test 4', TestReconciliationState.Unknown, undefined, [32, 0]), helper.makeAssertion('test 5', TestReconciliationState.KnownSuccess, undefined, [42, 0]), ]; - return [testBlocks, assertions]; + const snapshots = [ + helper.makeSnapshotBlock('test 2', false, 13), + helper.makeSnapshotBlock('test 5', true, 43), + ]; + return [testBlocks, assertions, snapshots]; }; interface TestData { @@ -98,6 +104,7 @@ const newProviderWithData = (testData: TestData[]): TestResultProvider => { return { root: helper.makeRoot(data.itBlocks), itBlocks: data.itBlocks, + describeBlocks: [], }; } }); @@ -142,17 +149,19 @@ describe('TestResultProvider', () => { throw new Error('forced error'); }); }; - const forceMatchResultError = (sut: any) => { - sut.matchResults = jest.fn(() => { - throw new Error('forced error'); - }); - }; + + let mockSnapshotProvider; beforeEach(() => { jest.resetAllMocks(); jest.restoreAllMocks(); console.warn = jest.fn(); mockTestReconciler.mockReturnValue(mockReconciler); (vscode.EventEmitter as jest.Mocked) = jest.fn().mockImplementation(helper.mockEvent); + mockSnapshotProvider = { + parse: jest.fn().mockReturnValue({ blocks: [] }), + previewSnapshot: jest.fn().mockReturnValue(Promise.resolve()), + }; + (SnapshotProvider as jest.Mocked).mockReturnValue(mockSnapshotProvider); }); describe('getResults()', () => { @@ -220,12 +229,22 @@ describe('TestResultProvider', () => { expect(actual[0].terseMessage).toBeUndefined(); }); - it('fire testSuiteChanged event for newly matched result', () => { - const sut = newProviderWithData([makeData([testBlock], [], filePath)]); - sut.getResults(filePath); - expect(sut.events.testSuiteChanged.fire).toHaveBeenCalledWith({ - type: 'result-matched', - file: filePath, + describe('fire "result-matched" event', () => { + it('fire testSuiteChanged event for newly matched result', () => { + const sut = newProviderWithData([makeData([testBlock], [assertion], filePath)]); + sut.getResults(filePath); + expect(sut.events.testSuiteChanged.fire).toHaveBeenCalledWith({ + type: 'result-matched', + file: filePath, + }); + }); + it('will not fire if no assertion to match', () => { + const sut = newProviderWithData([makeData([testBlock], [], filePath)]); + sut.getResults(filePath); + expect(sut.events.testSuiteChanged.fire).not.toHaveBeenCalledWith({ + type: 'result-matched', + file: filePath, + }); }); }); it('unmatched test will file test-parsed event instead', () => { @@ -234,7 +253,7 @@ describe('TestResultProvider', () => { expect(sut.events.testSuiteChanged.fire).toHaveBeenCalledWith({ type: 'test-parsed', file: filePath, - testContainer: expect.anything(), + sourceContainer: expect.anything(), }); }); @@ -573,23 +592,23 @@ describe('TestResultProvider', () => { const setupForNonTest = (sut: any) => { sut.updateTestFileList(['test-file']); + itBlocks = []; }; it.each` - desc | setup | expectedResults | statsChange - ${'parse failed'} | ${forceParseError} | ${'throw error'} | ${'fail'} - ${'matchResult failed'} | ${forceMatchResultError} | ${'throw error'} | ${'fail'} - ${'match failed'} | ${forceMatchError} | ${'Unknown'} | ${'unknown'} - ${'file is not a test file'} | ${setupForNonTest} | ${undefined} | ${undefined} + desc | setup | itBlockOverride | expectedResults | statsChange + ${'parse failed'} | ${forceParseError} | ${undefined} | ${[]} | ${'fail'} + ${'match failed'} | ${forceMatchError} | ${undefined} | ${'Unknown'} | ${'fail'} + ${'file is not a test file'} | ${setupForNonTest} | ${[]} | ${undefined} | ${'no change'} `( 'when $desc => returns $expectedResults, stats changed: $statsChange', - ({ setup, expectedResults, statsChange }) => { - const sut = newProviderWithData([makeData(itBlocks, assertions, 'whatever')]); + ({ setup, itBlockOverride, expectedResults, statsChange }) => { + const sut = newProviderWithData([ + makeData(itBlockOverride ?? itBlocks, assertions, 'whatever'), + ]); setup(sut); const stats = sut.getTestSuiteStats(); - if (expectedResults === 'throw error') { - expect(() => sut.getResults('whatever')).toThrow(); - } else if (expectedResults === 'Unknown') { + if (expectedResults === 'Unknown') { expect( sut.getResults('whatever').every((r) => r.status === expectedResults) ).toBeTruthy(); @@ -610,6 +629,12 @@ describe('TestResultProvider', () => { describe('getSortedResults()', () => { const filePath = 'file.js'; + const emptyResult = { + fail: [], + skip: [], + success: [], + unknown: [], + }; let sut; beforeEach(() => { const [itBlocks, assertions] = createDataSet(); @@ -644,17 +669,9 @@ describe('TestResultProvider', () => { sut.updateTestFileList(['test-file']); expect(sut.getSortedResults('test-file')).toBeUndefined(); }); - it('can throw for internal error for once', () => { + it('internal error cause empty result', () => { forceParseError(); - expect(() => sut.getSortedResults(filePath)).toThrow(); - - //2nd time will just return empty result - expect(sut.getSortedResults(filePath)).toEqual({ - fail: [], - skip: [], - success: [], - unknown: [], - }); + expect(sut.getSortedResults(filePath)).toEqual(emptyResult); }); }); @@ -672,6 +689,7 @@ describe('TestResultProvider', () => { const results2 = sut.getResults('file 2'); expect(results1).toHaveLength(5); expect(results2).toHaveLength(5); + expect(mockReconciler.assertionsForTestFile).toHaveBeenCalledTimes(2); // now let's update "file 1" mockReconciler.updateFileWithJestStatus.mockReturnValueOnce([ @@ -679,6 +697,8 @@ describe('TestResultProvider', () => { ]); sut.updateTestResults({} as any, {} as any); + mockReconciler.assertionsForTestFile.mockClear(); + // to get result from "file 1" should trigger mockReconciler.assertionsForTestFile const r1 = sut.getResults('file 1'); expect(r1).toEqual(results1); @@ -896,8 +916,8 @@ describe('TestResultProvider', () => { }); it.each` testFiles | testResults | expected - ${undefined} | ${undefined} | ${'unknown'} - ${undefined} | ${['file-2']} | ${'unknown'} + ${undefined} | ${undefined} | ${'maybe'} + ${undefined} | ${['file-2']} | ${'maybe'} ${[]} | ${[]} | ${'no'} ${[]} | ${['file-1']} | ${'yes'} ${[]} | ${['file-2']} | ${'no'} @@ -921,4 +941,57 @@ describe('TestResultProvider', () => { expect(sut.isTestFile(target)).toEqual(expected); }); }); + describe('snapshot', () => { + const testPath = 'test-file'; + let itBlocks, assertions, snapshotBlocks; + beforeEach(() => { + [itBlocks, assertions, snapshotBlocks] = createDataSet(); + const dBlock0 = helper.makeDescribeBlock('describe-test-1', [itBlocks[0]], { + start: itBlocks[0].start, + end: itBlocks[0].end, + }); + const dBlock4 = helper.makeDescribeBlock('describe-test-5', [itBlocks[4]], { + start: itBlocks[4].start, + end: itBlocks[4].end, + }); + itBlocks[0] = dBlock0; + itBlocks[4] = dBlock4; + + mockSnapshotProvider.parse.mockImplementation((testPath: string) => ({ + testPath, + blocks: snapshotBlocks, + })); + }); + it('parsing test file should fire event for testBlocks with snapshot info', () => { + const sut = newProviderWithData([makeData(itBlocks, assertions, testPath)]); + sut.getResults(testPath); + const testParsedCall = (sut.events.testSuiteChanged.fire as jest.Mocked).mock.calls.find( + (call) => call[0].type === 'test-parsed' + ); + expect(testParsedCall).not.toBeUndefined(); + const sourceContainer = testParsedCall[0].sourceContainer; + let matchCount = 0; + [ + ...sourceContainer.childContainers.flatMap((c) => c.childData), + ...sourceContainer.childData, + ].forEach((child) => { + const sBlock = snapshotBlocks.find((block) => block.marker === child.name); + if (sBlock) { + expect(child.attrs.snapshot).toEqual(sBlock.isInline ? 'inline' : 'external'); + matchCount += 1; + } else { + expect(child.attrs.snapshot).toBeUndefined(); + } + }); + expect(matchCount).toEqual(2); + }); + it('forward previewSnapshot to the snapshot provider', async () => { + const sut = newProviderWithData([makeData([], [], '')]); + await sut.previewSnapshot('whatever', 'full test name'); + expect(mockSnapshotProvider.previewSnapshot).toHaveBeenCalledWith( + 'whatever', + 'full test name' + ); + }); + }); }); diff --git a/tests/TestResults/snapshot-provider.test.ts b/tests/TestResults/snapshot-provider.test.ts new file mode 100644 index 00000000..0546d678 --- /dev/null +++ b/tests/TestResults/snapshot-provider.test.ts @@ -0,0 +1,144 @@ +jest.unmock('../../src/TestResults/snapshot-provider'); +jest.unmock('../../src/helpers'); + +import * as vscode from 'vscode'; +import { SnapshotProvider } from '../../src/TestResults/snapshot-provider'; + +const mockSnapshot = { + parse: jest.fn(), + getSnapshotContent: jest.fn(), +}; +jest.mock('jest-editor-support', () => { + const Snapshot = jest.fn(() => mockSnapshot); + return { Snapshot }; +}); + +const makeSnapshotNode = (name: string): any => ({ + node: { name }, +}); +describe('SnapshotProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('parse', () => { + it.each` + keyword | isInline + ${'toMatchSnapshot'} | ${false} + ${'toThrowErrorMatchingSnapshot'} | ${false} + ${'toMatchInlineSnapshot'} | ${true} + ${'toThrowErrorMatchingInlineSnapshot'} | ${true} + `('returns parsed result: $keyword', ({ keyword, isInline }) => { + const parseBlocks = [makeSnapshotNode(keyword)]; + mockSnapshot.parse.mockReturnValue(parseBlocks); + const provider = new SnapshotProvider(); + expect(provider.parse('a test file')).toEqual({ + testPath: 'a test file', + blocks: [{ ...parseBlocks[0], isInline }], + }); + }); + it('if parse failed, returns empty blocks', () => { + mockSnapshot.parse.mockImplementation(() => { + throw new Error('forced error'); + }); + const provider = new SnapshotProvider(); + expect(provider.parse('a test file')).toEqual({ + testPath: 'a test file', + blocks: [], + }); + }); + }); + + describe('previewSnapshot', () => { + let mockPanel; + beforeEach(() => { + mockPanel = { + reveal: jest.fn(), + onDidDispose: jest.fn(), + webview: { html: undefined }, + title: undefined, + }; + (vscode.window.createWebviewPanel as jest.Mocked).mockReturnValue(mockPanel); + }); + describe('create a regexp from test names', () => { + it.each` + testName | regString + ${'simple name'} | ${'simple name'} + ${'with $name'} | ${'with \\$name'} + ${'a string.with.dots*'} | ${'a string\\.with\\.dots\\*'} + ${'

title

'} | ${'

title<\\/p>'} + `('$testName', async ({ testName, regString }) => { + mockSnapshot.getSnapshotContent.mockReturnValue(Promise.resolve(undefined)); + const provider = new SnapshotProvider(); + await provider.previewSnapshot('whatever', testName); + expect(mockSnapshot.getSnapshotContent).toHaveBeenCalledWith( + 'whatever', + new RegExp(`^${regString} [0-9]+$`) + ); + }); + }); + it('display content in a WebviewPanel', async () => { + const content1 = { 'some test': ' result' }; + const content2 = ' "some quoted text"'; + const content3 = { '3rd test': " 'single quote' & this" }; + mockSnapshot.getSnapshotContent + .mockReturnValueOnce(Promise.resolve(content1)) + .mockReturnValueOnce(Promise.resolve(content2)) + .mockReturnValueOnce(Promise.resolve(content3)); + + const provider = new SnapshotProvider(); + await provider.previewSnapshot('test-file', 'some test'); + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mockPanel.onDidDispose).toHaveBeenCalled(); + expect(mockPanel.webview.html).toMatchInlineSnapshot(`"

<test 1> result
"`); + expect(mockPanel.title).toEqual(expect.stringContaining('some test')); + + //2nd time showing the content will reuse the panel + (vscode.window.createWebviewPanel as jest.Mocked).mockClear(); + await provider.previewSnapshot('test-file', 'some other test'); + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(0); + expect(mockPanel.webview.html).toMatchInlineSnapshot( + `"
<test 2> "some quoted text"
"` + ); + expect(mockPanel.title).toEqual(expect.stringContaining('some other test')); + + //if user close the panel, it will be recreated on the next request + const callback = mockPanel.onDidDispose.mock.calls[0][0]; + callback(); + await provider.previewSnapshot('test-file', '3rd test'); + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mockPanel.webview.html).toMatchInlineSnapshot( + `"
<test 3> 'single quote' & this
"` + ); + expect(mockPanel.title).toEqual(expect.stringContaining('3rd test')); + }); + it('show warning if no content is found', async () => { + mockSnapshot.getSnapshotContent.mockReturnValueOnce(undefined); + const provider = new SnapshotProvider(); + await provider.previewSnapshot('test-file', 'some test'); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled(); + + (vscode.window.showErrorMessage as jest.Mocked).mockClear(); + + mockSnapshot.getSnapshotContent.mockReturnValueOnce({}); + await provider.previewSnapshot('test-file', 'some test'); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled(); + }); + it('can show multiple snapshots within a test', async () => { + const content = { + 'test 1': 'test 1 content', + 'test 2': 'test 2 content', + }; + mockSnapshot.getSnapshotContent.mockReturnValueOnce(Promise.resolve(content)); + const provider = new SnapshotProvider(); + await provider.previewSnapshot('test-file', 'some test'); + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1); + expect(mockPanel.onDidDispose).toHaveBeenCalled(); + expect(mockPanel.webview.html).toMatchInlineSnapshot( + `"

test 1

test 1 content

test 2

test 2 content
"` + ); + expect(mockPanel.title).toEqual(expect.stringContaining('some test')); + }); + }); +}); diff --git a/tests/extension.test.ts b/tests/extension.test.ts index a58c9b67..17b32270 100644 --- a/tests/extension.test.ts +++ b/tests/extension.test.ts @@ -15,11 +15,6 @@ jest.mock('../src/Coverage', () => ({ CoverageCodeLensProvider: jest.fn().mockReturnValue({}), })); -jest.mock('../src/SnapshotCodeLens', () => ({ - registerSnapshotCodeLens: jest.fn(() => []), - registerSnapshotPreview: jest.fn(() => []), -})); - jest.mock('../src/test-provider/test-item-context-manager', () => ({ tiContextManager: { registerCommands: jest.fn(() => []) }, })); diff --git a/tests/extensionManager.test.ts b/tests/extensionManager.test.ts index 1d1ead8e..e24c0c41 100644 --- a/tests/extensionManager.test.ts +++ b/tests/extensionManager.test.ts @@ -73,6 +73,7 @@ const makeJestExt = (workspace: vscode.WorkspaceFolder): any => { toggleAutoRun: jest.fn(), toggleCoverageOverlay: jest.fn(), enableLoginShell: jest.fn(), + runItemCommand: jest.fn(), workspace, }; }; @@ -700,6 +701,7 @@ describe('ExtensionManager', () => { ${'with-workspace.toggle-auto-run'} | ${'toggleAutoRun'} ${'with-workspace.toggle-coverage'} | ${'toggleCoverageOverlay'} ${'with-workspace.enable-login-shell'} | ${'enableLoginShell'} + ${'with-workspace.item-command'} | ${'runItemCommand'} `('extension-based commands "$name"', async ({ name, extFunc }) => { extensionManager.register(); const expectedName = `${extensionName}.${name}`; diff --git a/tests/test-helper.ts b/tests/test-helper.ts index 7913f200..057caa33 100644 --- a/tests/test-helper.ts +++ b/tests/test-helper.ts @@ -19,7 +19,7 @@ export const makeLocation = (pos: [number, number]): Location => ({ line: pos[0], column: pos[1], }); -export const makePositionRange = (pos: [number, number, number, number]) => ({ +export const makePositionRange = (pos: [number, number, number, number]): any => ({ start: makeLocation([pos[0], pos[1]]), end: makeLocation([pos[2], pos[3]]), }); @@ -38,6 +38,16 @@ export const findResultForTest = (results: TestResult[], itBlock: ItBlock): Test }; // factory method +export const makeSnapshotBlock = (marker?: string, isInline?: boolean, line?: number): any => { + const loc = line ? makePositionRange([line, 0, line, 0]) : EmptyLocationRange; + return { + marker, + isInline: isInline ?? false, + node: { name: 'node', loc }, + parents: [], + }; +}; + export const makeItBlock = ( name?: string, pos?: [number, number, number, number], @@ -136,7 +146,7 @@ export const mockProjectWorkspace = (...args: any[]): any => { export const mockWworkspaceLogging = (): any => ({ create: () => jest.fn() }); -export const mockEvent = () => ({ +export const mockEvent = (): any => ({ event: jest.fn().mockReturnValue({ dispose: jest.fn() }), fire: jest.fn(), dispose: jest.fn(), diff --git a/tests/test-provider/test-helper.ts b/tests/test-provider/test-helper.ts index 0f83b572..74720060 100644 --- a/tests/test-provider/test-helper.ts +++ b/tests/test-provider/test-helper.ts @@ -29,7 +29,7 @@ export const mockExtExplorerContext = (wsName = 'ws-1', override: any = {}): any loggingFactory: { create: jest.fn().mockReturnValue(jest.fn()) }, session: { scheduleProcess: jest.fn() }, workspace: { name: wsName, uri: { fsPath: `/${wsName}` } }, - testResolveProvider: { + testResultProvider: { events: { testListUpdated: { event: jest.fn().mockReturnValue({ dispose: jest.fn() }) }, testSuiteChanged: { event: jest.fn().mockReturnValue({ dispose: jest.fn() }) }, @@ -37,6 +37,7 @@ export const mockExtExplorerContext = (wsName = 'ws-1', override: any = {}): any getTestList: jest.fn().mockReturnValue([]), isTestFile: jest.fn().mockReturnValue('yes'), getTestSuiteResult: jest.fn().mockReturnValue({}), + previewSnapshot: jest.fn(), }, debugTests: jest.fn(), sessionEvents: mockJestExtEvents(), @@ -70,12 +71,12 @@ export const mockController = (): any => { return run; }), dispose: jest.fn(), - createRunProfile: jest.fn().mockImplementation((label, kind, runHandler, isDefault, tags) => ({ + createRunProfile: jest.fn().mockImplementation((label, kind, runHandler, isDefault, tag) => ({ label, kind, runHandler, isDefault, - tags: tags ?? [], + tag, })), createTestItem: jest.fn().mockImplementation((id, label, uri) => { const item: any = { diff --git a/tests/test-provider/test-item-context-manager.test.ts b/tests/test-provider/test-item-context-manager.test.ts index 6c90ff2a..9fd7e235 100644 --- a/tests/test-provider/test-item-context-manager.test.ts +++ b/tests/test-provider/test-item-context-manager.test.ts @@ -4,74 +4,127 @@ jest.unmock('../../src/appGlobals'); import * as vscode from 'vscode'; import { extensionName } from '../../src/appGlobals'; import { TestItemContextManager } from '../../src/test-provider/test-item-context-manager'; +import { ItemCommand } from '../../src/test-provider/types'; describe('TestItemContextManager', () => { beforeEach(() => { jest.resetAllMocks(); }); describe('can set itemContext', () => { - it.each` - case | context | withItemKey | withoutItemKey - ${1} | ${{ key: 'jest.autoRun', value: true, itemIds: ['a'] }} | ${'jest.autoRun.on'} | ${'jest.autoRun.off'} - ${2} | ${{ key: 'jest.autoRun', value: false, itemIds: ['a'] }} | ${'jest.autoRun.off'} | ${'jest.autoRun.on'} - ${2} | ${{ key: 'jest.coverage', value: true, itemIds: ['a'] }} | ${'jest.coverage.on'} | ${'jest.coverage.off'} - ${2} | ${{ key: 'jest.coverage', value: false, itemIds: ['a'] }} | ${'jest.coverage.off'} | ${'jest.coverage.on'} - `('case $case: setContext for $expectedKey', ({ context, withItemKey, withoutItemKey }) => { - const workspace: any = { name: 'ws' }; - const manager = new TestItemContextManager(); - manager.setItemContext({ workspace, ...context }); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - withItemKey, - context.itemIds - ); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', withoutItemKey, []); + describe('jest.autoRun and jest.coverage', () => { + it.each` + case | context | withItemKey | withoutItemKey + ${1} | ${{ key: 'jest.autoRun', value: true, itemIds: ['a'] }} | ${'jest.autoRun.on'} | ${'jest.autoRun.off'} + ${2} | ${{ key: 'jest.autoRun', value: false, itemIds: ['a'] }} | ${'jest.autoRun.off'} | ${'jest.autoRun.on'} + ${3} | ${{ key: 'jest.coverage', value: true, itemIds: ['a'] }} | ${'jest.coverage.on'} | ${'jest.coverage.off'} + ${4} | ${{ key: 'jest.coverage', value: false, itemIds: ['a'] }} | ${'jest.coverage.off'} | ${'jest.coverage.on'} + `('case $case: setContext for $expectedKey', ({ context, withItemKey, withoutItemKey }) => { + const workspace: any = { name: 'ws' }; + const manager = new TestItemContextManager(); + manager.setItemContext({ workspace, ...context }); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + withItemKey, + context.itemIds + ); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + withoutItemKey, + [] + ); + }); + it('can manage itemContext for multiple workspaces', () => { + const ws1: any = { name: 'ws1' }; + const ws2: any = { name: 'ws2' }; + const manager = new TestItemContextManager(); + manager.setItemContext({ + workspace: ws1, + key: 'jest.autoRun', + value: true, + itemIds: ['a', 'b'], + }); + manager.setItemContext({ + workspace: ws2, + key: 'jest.autoRun', + value: true, + itemIds: ['c'], + }); + manager.setItemContext({ + workspace: ws2, + key: 'jest.autoRun', + value: false, + itemIds: ['d'], + }); + manager.setItemContext({ + workspace: ws2, + key: 'jest.coverage', + value: true, + itemIds: ['c'], + }); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'jest.autoRun.on', + ['a', 'b', 'c'] + ); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'jest.autoRun.off', + ['d'] + ); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'jest.coverage.on', + ['c'] + ); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'jest.coverage.off', + [] + ); + }); }); - it('can manage itemContext for multiple workspaces', () => { - const ws1: any = { name: 'ws1' }; - const ws2: any = { name: 'ws2' }; - const manager = new TestItemContextManager(); - manager.setItemContext({ - workspace: ws1, - key: 'jest.autoRun', - value: true, - itemIds: ['a', 'b'], + describe('jest.editor-view-snapshot', () => { + it('can set context with itemId and onClick action', () => { + const workspace: any = { name: 'ws' }; + const manager = new TestItemContextManager(); + const context: any = { + workspace, + key: 'jest.editor-view-snapshot', + itemIds: ['a'], + onClick: jest.fn(), + }; + manager.setItemContext(context); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'jest.editor-view-snapshot', + context.itemIds + ); }); - manager.setItemContext({ workspace: ws2, key: 'jest.autoRun', value: true, itemIds: ['c'] }); - manager.setItemContext({ workspace: ws2, key: 'jest.autoRun', value: false, itemIds: ['d'] }); - manager.setItemContext({ - workspace: ws2, - key: 'jest.coverage', - value: true, - itemIds: ['c'], + it('new context will override the olde one', () => { + const workspace: any = { name: 'ws' }; + const manager = new TestItemContextManager(); + const context1: any = { + workspace, + key: 'jest.editor-view-snapshot', + itemIds: ['a'], + onClick: jest.fn(), + }; + const context2 = { ...context1, itemIds: ['b'] }; + manager.setItemContext(context1); + manager.setItemContext(context2); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'jest.editor-view-snapshot', + context2.itemIds + ); }); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'jest.autoRun.on', [ - 'a', - 'b', - 'c', - ]); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - 'jest.autoRun.off', - ['d'] - ); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - 'jest.coverage.on', - ['c'] - ); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - 'jest.coverage.off', - [] - ); }); }); describe('can register item menu commands', () => { it('toggle-autoRun menu commands', () => { const manager = new TestItemContextManager(); const disposableList = manager.registerCommands(); - expect(disposableList).toHaveLength(4); + expect(disposableList.length).toBeGreaterThanOrEqual(4); const commands = [ `${extensionName}.test-item.auto-run.toggle-on`, @@ -99,7 +152,7 @@ describe('TestItemContextManager', () => { it('toggle-coverage menu commands', () => { const manager = new TestItemContextManager(); const disposableList = manager.registerCommands(); - expect(disposableList).toHaveLength(4); + expect(disposableList.length).toBeGreaterThanOrEqual(4); const commands = [ `${extensionName}.test-item.coverage.toggle-on`, @@ -124,5 +177,51 @@ describe('TestItemContextManager', () => { expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith(extCmd, workspace); }); }); + describe('snapshot menu commands', () => { + it.each` + contextId | contextCommand | itemCommand + ${'jest.editor-view-snapshot'} | ${'test-item.view-snapshot'} | ${ItemCommand.viewSnapshot} + ${'jest.editor-update-snapshot'} | ${'test-item.update-snapshot'} | ${ItemCommand.updateSnapshot} + `('$contextId', ({ contextId, contextCommand, itemCommand }) => { + const manager = new TestItemContextManager(); + const disposableList = manager.registerCommands(); + expect(disposableList.length).toBeGreaterThanOrEqual(6); + + const calls = (vscode.commands.registerCommand as jest.Mocked).mock.calls.filter( + (call) => call[0] === `${extensionName}.${contextCommand}` + ); + + expect(calls).toHaveLength(1); + + // set some itemContext then trigger the menu + const workspace: any = { name: 'ws' }; + const context: any = { + workspace, + key: contextId, + itemIds: ['a'], + }; + manager.setItemContext(context); + const callBack = calls[0][1]; + const testItem = { id: 'a' }; + callBack(testItem); + const extCmd = `${extensionName}.with-workspace.item-command`; + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + extCmd, + workspace, + testItem, + itemCommand + ); + + (vscode.commands.executeCommand as jest.Mocked).mockClear(); + + callBack({ id: 'b' }); + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( + extCmd, + workspace, + testItem, + itemCommand + ); + }); + }); }); }); diff --git a/tests/test-provider/test-item-data.test.ts b/tests/test-provider/test-item-data.test.ts index 60c5df63..dc4f2812 100644 --- a/tests/test-provider/test-item-data.test.ts +++ b/tests/test-provider/test-item-data.test.ts @@ -8,6 +8,7 @@ jest.unmock('./test-helper'); jest.unmock('../../src/errors'); import { JestTestRun } from '../../src/test-provider/test-provider-helper'; +import { tiContextManager } from '../../src/test-provider/test-item-context-manager'; jest.mock('path', () => { let sep = '/'; @@ -43,6 +44,7 @@ import { import * as path from 'path'; import { mockController, mockExtExplorerContext } from './test-helper'; import * as errors from '../../src/errors'; +import { ItemCommand } from '../../src/test-provider/types'; const mockPathSep = (newSep: string) => { (path as jest.Mocked).setSep(newSep); @@ -95,12 +97,12 @@ describe('test-item-data', () => { message: 'test file failed', assertionContainer, }; - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue(testSuiteResult); + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue(testSuiteResult); }; const setupTestEnv = () => { const file = '/ws-1/tests/a.test.ts'; - context.ext.testResolveProvider.getTestList.mockReturnValueOnce([file]); + context.ext.testResultProvider.getTestList.mockReturnValueOnce([file]); const wsRoot = new WorkspaceRoot(context); const onRunEvent = context.ext.sessionEvents.onRunEvent.event.mock.calls[0][0]; @@ -108,7 +110,7 @@ describe('test-item-data', () => { prepareTestResult(); // triggers testSuiteChanged event listener - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process: { id: 'whatever', request: { type: 'watch-tests' } }, files: [file], @@ -142,9 +144,23 @@ describe('test-item-data', () => { return { wsRoot, folder, testFile, testBlock, onRunEvent, scheduleItem, file }; }; + const createAllTestItems = () => { + const wsRoot = new WorkspaceRoot(context); + const folder = new FolderData(context, 'dir', wsRoot.item); + const uri: any = { fsPath: 'whatever' }; + const doc = new TestDocumentRoot(context, uri, folder.item); + const node: any = { fullName: 'a test', attrs: {}, data: {} }; + const testItem = new TestData(context, uri, node, doc.item); + return { wsRoot, folder, doc, testItem }; + }; + beforeEach(() => { controllerMock = mockController(); - const profiles: any = [{ tag: { id: 'run' } }, { tag: { id: 'debug' } }]; + const profiles: any = [ + { tag: { id: 'run' } }, + { tag: { id: 'debug' } }, + { tag: { id: 'update-snapshot' } }, + ]; context = new JestTestProviderContext(mockExtExplorerContext('ws-1'), controllerMock, profiles); context.output.write = jest.fn((t) => t); [jestRun, runEndSpy, runMock] = createTestRun(); @@ -153,6 +169,7 @@ describe('test-item-data', () => { .fn() .mockImplementation((uri, p) => ({ fsPath: `${uri.fsPath}/${p}` })); vscode.Uri.file = jest.fn().mockImplementation((f) => ({ fsPath: f })); + (tiContextManager.setItemContext as jest.Mocked).mockClear(); }); describe('discover children', () => { describe('WorkspaceRoot', () => { @@ -162,7 +179,7 @@ describe('test-item-data', () => { '/ws-1/src/b.test.ts', '/ws-1/src/app/app.test.ts', ]; - context.ext.testResolveProvider.getTestList.mockReturnValue(testFiles); + context.ext.testResultProvider.getTestList.mockReturnValue(testFiles); const wsRoot = new WorkspaceRoot(context); wsRoot.discoverTest(jestRun); @@ -195,7 +212,7 @@ describe('test-item-data', () => { }); describe('when no testFiles yet', () => { it('if no testFiles yet, will still turn off canResolveChildren and close the run', () => { - context.ext.testResolveProvider.getTestList.mockReturnValue([]); + context.ext.testResultProvider.getTestList.mockReturnValue([]); const wsRoot = new WorkspaceRoot(context); wsRoot.discoverTest(jestRun); expect(wsRoot.item.children.size).toEqual(0); @@ -204,7 +221,7 @@ describe('test-item-data', () => { }); it('will not wipe out existing test items', () => { // first time discover 1 file - context.ext.testResolveProvider.getTestList.mockReturnValue(['/ws-1/a.test.ts']); + context.ext.testResultProvider.getTestList.mockReturnValue(['/ws-1/a.test.ts']); const wsRoot = new WorkspaceRoot(context); wsRoot.discoverTest(jestRun); expect(jestRun.isClosed()).toBeTruthy(); @@ -213,7 +230,7 @@ describe('test-item-data', () => { expect(runEndSpy).toHaveBeenCalledTimes(1); // 2nd time if no test-file: testItems will not change - context.ext.testResolveProvider.getTestList.mockReturnValue([]); + context.ext.testResultProvider.getTestList.mockReturnValue([]); [jestRun, runEndSpy] = createTestRun(); wsRoot.discoverTest(jestRun); expect(jestRun.isClosed()).toBeTruthy(); @@ -226,8 +243,8 @@ describe('test-item-data', () => { const a1 = helper.makeAssertion('test-1', 'KnownSuccess', [], [1, 0]); const assertionContainer = buildAssertionContainer([a1]); const testFiles = ['/ws-1/a.test.ts']; - context.ext.testResolveProvider.getTestList.mockReturnValue(testFiles); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestList.mockReturnValue(testFiles); + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownSuccess', assertionContainer, }); @@ -235,14 +252,14 @@ describe('test-item-data', () => { wsRoot.discoverTest(jestRun); const docItem = wsRoot.item.children.get(testFiles[0]); expect(docItem.children.size).toEqual(0); - expect(context.ext.testResolveProvider.getTestSuiteResult).toHaveBeenCalled(); + expect(context.ext.testResultProvider.getTestSuiteResult).toHaveBeenCalled(); }); it('will remove folder item if no test file exist any more', () => { const a1 = helper.makeAssertion('test-1', 'KnownSuccess', [], [1, 0]); const assertionContainer = buildAssertionContainer([a1]); const testFiles = ['/ws-1/tests1/a.test.ts', '/ws-1/tests2/b.test.ts']; - context.ext.testResolveProvider.getTestList.mockReturnValue(testFiles); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestList.mockReturnValue(testFiles); + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownSuccess', assertionContainer, }); @@ -285,15 +302,15 @@ describe('test-item-data', () => { }); it('register for test result events', () => { new WorkspaceRoot(context); - expect(context.ext.testResolveProvider.events.testListUpdated.event).toHaveBeenCalled(); - expect(context.ext.testResolveProvider.events.testSuiteChanged.event).toHaveBeenCalled(); + expect(context.ext.testResultProvider.events.testListUpdated.event).toHaveBeenCalled(); + expect(context.ext.testResultProvider.events.testSuiteChanged.event).toHaveBeenCalled(); }); it('unregister events upon dispose', () => { const wsRoot = new WorkspaceRoot(context); const listeners = [ - context.ext.testResolveProvider.events.testListUpdated.event.mock.results[0].value, - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.results[0].value, + context.ext.testResultProvider.events.testListUpdated.event.mock.results[0].value, + context.ext.testResultProvider.events.testSuiteChanged.event.mock.results[0].value, context.ext.sessionEvents.onRunEvent.event.mock.results[0].value, ]; wsRoot.dispose(); @@ -302,12 +319,12 @@ describe('test-item-data', () => { describe('when testFile list is changed', () => { it('testListUpdated event will be fired', () => { const wsRoot = new WorkspaceRoot(context); - context.ext.testResolveProvider.getTestList.mockReturnValueOnce([]); + context.ext.testResultProvider.getTestList.mockReturnValueOnce([]); wsRoot.discoverTest(jestRun); expect(wsRoot.item.children.size).toBe(0); // invoke testListUpdated event listener - context.ext.testResolveProvider.events.testListUpdated.event.mock.calls[0][0]([ + context.ext.testResultProvider.events.testListUpdated.event.mock.calls[0][0]([ '/ws-1/a.test.ts', ]); // should have created a new run @@ -322,7 +339,7 @@ describe('test-item-data', () => { }); describe('when testSuiteChanged.assertions-updated event filed', () => { it('all item data will be updated accordingly', () => { - context.ext.testResolveProvider.getTestList.mockReturnValueOnce([]); + context.ext.testResultProvider.getTestList.mockReturnValueOnce([]); context.ext.settings = { testExplorer: { enabled: true, showInlineError: true }, autoRun: {}, @@ -343,10 +360,10 @@ describe('test-item-data', () => { message: 'test file failed', assertionContainer, }; - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue(testSuiteResult); + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue(testSuiteResult); // triggers testSuiteChanged event listener - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process: { id: 'whatever', request: { type: 'watch-tests' } }, files: ['/ws-1/a.test.ts'], @@ -374,27 +391,27 @@ describe('test-item-data', () => { }); }); describe('when testSuiteChanged.result-matched event fired', () => { - it('test data range will be updated accordingly', () => { + it('test data range and snapshot context will be updated accordingly', () => { // assertion should be discovered prior - context.ext.testResolveProvider.getTestList.mockReturnValueOnce(['/ws-1/a.test.ts']); + context.ext.testResultProvider.getTestList.mockReturnValueOnce(['/ws-1/a.test.ts']); const a1 = helper.makeAssertion('test-a', 'KnownFail', ['desc-1'], [1, 0]); const assertionContainer = buildAssertionContainer([a1]); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownFail', assertionContainer, }); const wsRoot = new WorkspaceRoot(context); wsRoot.discoverTest(jestRun); - expect(context.ext.testResolveProvider.getTestSuiteResult).toHaveBeenCalledTimes(1); + expect(context.ext.testResultProvider.getTestSuiteResult).toHaveBeenCalledTimes(1); expect(wsRoot.item.children.size).toBe(1); const docItem = getChildItem(wsRoot.item, 'a.test.ts'); expect(docItem.children.size).toEqual(0); // after jest test run, result suite should be updated and test block should be populated - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process: { id: 'whatever', request: { type: 'watch-tests' } }, files: ['/ws-1/a.test.ts'], @@ -405,9 +422,9 @@ describe('test-item-data', () => { const tItem = getChildItem(dItem, 'test-a'); expect(tItem.range).toEqual({ args: [1, 0, 1, 0] }); - expect(context.ext.testResolveProvider.getTestSuiteResult).toHaveBeenCalled(); + expect(context.ext.testResultProvider.getTestSuiteResult).toHaveBeenCalled(); controllerMock.createTestRun.mockClear(); - context.ext.testResolveProvider.getTestSuiteResult.mockClear(); + context.ext.testResultProvider.getTestSuiteResult.mockClear(); // after match, the assertion nodes would have updated range const descNode = assertionContainer.childContainers[0]; @@ -420,16 +437,18 @@ describe('test-item-data', () => { start: { line: 2, column: 2 }, end: { line: 10, column: 4 }, }; + // add snapshot marker + testNode.attrs.snapshot = 'inline'; // triggers testSuiteChanged event listener - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'result-matched', file: '/ws-1/a.test.ts', }); // no run should be created as we are not changing any test item tree expect(controllerMock.createTestRun).not.toHaveBeenCalled(); - expect(context.ext.testResolveProvider.getTestSuiteResult).not.toHaveBeenCalled(); + expect(context.ext.testResultProvider.getTestSuiteResult).not.toHaveBeenCalled(); // expect the item's range has picked up the updated nodes expect(dItem.range).toEqual({ @@ -448,41 +467,75 @@ describe('test-item-data', () => { testNode.attrs.range.end.column, ], }); + + // snapshot menu context is populated + expect(tiContextManager.setItemContext).toHaveBeenCalledTimes(2); + expect(tiContextManager.setItemContext).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'jest.editor-update-snapshot', + itemIds: [tItem.id], + }) + ); + expect(tiContextManager.setItemContext).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'jest.editor-view-snapshot', + itemIds: [], + }) + ); }); }); describe('when testSuiteChanged.test-parsed event filed', () => { - it('test items will be added based on parsed test files (test blocks)', () => { + it('test items will be added and snapshot context updated accordingly', () => { // assertion should be discovered prior - context.ext.testResolveProvider.getTestList.mockReturnValueOnce(['/ws-1/a.test.ts']); + context.ext.testResultProvider.getTestList.mockReturnValueOnce(['/ws-1/a.test.ts']); const t1 = helper.makeItBlock('test-1', [1, 1, 5, 1]); const t2 = helper.makeItBlock('test-2', [6, 1, 7, 1]); const sourceRoot = helper.makeRoot([t2, t1]); - const testContainer = buildSourceContainer(sourceRoot); + const sourceContainer = buildSourceContainer(sourceRoot); + const node1 = sourceContainer.childData.find((child) => child.fullName === 'test-1'); + const node2 = sourceContainer.childData.find((child) => child.fullName === 'test-2'); + node1.attrs = { ...node1.attrs, snapshot: 'external' }; + node2.attrs = { ...node2.attrs, snapshot: 'external', nonLiteralName: true }; const wsRoot = new WorkspaceRoot(context); wsRoot.discoverTest(jestRun); - expect(context.ext.testResolveProvider.getTestSuiteResult).toHaveBeenCalledTimes(1); + expect(context.ext.testResultProvider.getTestSuiteResult).toHaveBeenCalledTimes(1); expect(wsRoot.item.children.size).toBe(1); const docItem = getChildItem(wsRoot.item, 'a.test.ts'); expect(docItem.children.size).toEqual(0); controllerMock.createTestRun.mockClear(); - context.ext.testResolveProvider.getTestSuiteResult.mockClear(); + context.ext.testResultProvider.getTestSuiteResult.mockClear(); - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'test-parsed', file: '/ws-1/a.test.ts', - testContainer, + sourceContainer, }); expect(docItem.children.size).toEqual(2); - let dItem = getChildItem(docItem, 'test-1'); - expect(dItem.range).toEqual({ args: [0, 0, 4, 0] }); - dItem = getChildItem(docItem, 'test-2'); - expect(dItem.range).toEqual({ args: [5, 0, 6, 0] }); + const dItem1 = getChildItem(docItem, 'test-1'); + expect(dItem1.range).toEqual({ args: [0, 0, 4, 0] }); + const dItem2 = getChildItem(docItem, 'test-2'); + expect(dItem2.range).toEqual({ args: [5, 0, 6, 0] }); - expect(context.ext.testResolveProvider.getTestSuiteResult).not.toHaveBeenCalled(); + expect(context.ext.testResultProvider.getTestSuiteResult).not.toHaveBeenCalled(); expect(controllerMock.createTestRun).not.toHaveBeenCalled(); + + // snapshot menu context is populated for "test-1" only + expect(tiContextManager.setItemContext).toHaveBeenCalledTimes(2); + expect(tiContextManager.setItemContext).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'jest.editor-view-snapshot', + itemIds: [dItem1.id], + }) + ); + expect(tiContextManager.setItemContext).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'jest.editor-update-snapshot', + itemIds: [dItem1.id], + }) + ); }); }); }); @@ -493,18 +546,18 @@ describe('test-item-data', () => { const t1 = helper.makeItBlock('test-1', [1, 1, 5, 1]); const t2 = helper.makeItBlock('test-2', [6, 1, 7, 1]); const sourceRoot = helper.makeRoot([t2, t1]); - const testContainer = buildSourceContainer(sourceRoot); - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + const sourceContainer = buildSourceContainer(sourceRoot); + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'test-parsed', file: '/ws-1/a.test.ts', - testContainer, + sourceContainer, }); expect(wsRoot.item.children.size).toBe(1); let docItem = getChildItem(wsRoot.item, 'a.test.ts'); expect(docItem.children.size).toEqual(2); // now call discovery with additional files - context.ext.testResolveProvider.getTestList.mockReturnValueOnce([ + context.ext.testResultProvider.getTestList.mockReturnValueOnce([ '/ws-1/a.test.ts', '/ws-1/b.test.ts', ]); @@ -524,7 +577,7 @@ describe('test-item-data', () => { const a1 = helper.makeAssertion('test-1', 'KnownSuccess', [], [1, 0]); const assertionContainer = buildAssertionContainer([a1]); const uri: any = { fsPath: '/ws-1/a.test.ts' }; - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownSuccess', assertionContainer, }); @@ -537,7 +590,7 @@ describe('test-item-data', () => { expect(runMock.passed).toHaveBeenCalledWith(tData.item, undefined); }); it('if no test suite result yet, children list is empty', () => { - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue(undefined); + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue(undefined); const uri: any = { fsPath: '/ws-1/a.test.ts' }; const parentItem: any = controllerMock.createTestItem('ws-1', 'ws-1', uri); const docRoot = new TestDocumentRoot(context, uri, parentItem); @@ -634,6 +687,22 @@ describe('test-item-data', () => { expect(request.run).toBe(jestRun); expect(request.run.item).toBe(folderData.item); }); + describe('can update snapshot based on runProfile', () => { + let wsRoot, folder, doc, testItem; + beforeEach(() => { + ({ wsRoot, folder, doc, testItem } = createAllTestItems()); + }); + it('with snapshot profile', () => { + [wsRoot, folder, doc, testItem].forEach((testItem) => { + testItem.scheduleTest(jestRun, ItemCommand.updateSnapshot); + expect(context.ext.session.scheduleProcess).toHaveBeenCalledWith( + expect.objectContaining({ + updateSnapshot: true, + }) + ); + }); + }); + }); }); describe('when test result is ready', () => { @@ -642,14 +711,14 @@ describe('test-item-data', () => { let wsRoot; beforeEach(() => { jest.clearAllMocks(); - context.ext.testResolveProvider.getTestList.mockReturnValueOnce([file]); + context.ext.testResultProvider.getTestList.mockReturnValueOnce([file]); wsRoot = new WorkspaceRoot(context); // mocking test results const a1 = helper.makeAssertion('test-a', 'KnownSuccess', [], [1, 0]); const a2 = helper.makeAssertion('test-b', 'KnownFail', [], [10, 0], { line: 13 }); const assertionContainer = buildAssertionContainer([a1, a2]); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownFail', assertionContainer, }); @@ -663,7 +732,7 @@ describe('test-item-data', () => { expect(controllerMock.createTestRun).toHaveBeenCalledTimes(1); // triggers testSuiteChanged event listener - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process, files: [file], @@ -696,7 +765,7 @@ describe('test-item-data', () => { expect(controllerMock.createTestRun).toHaveBeenCalledTimes(0); // triggers testSuiteChanged event listener - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process, files: [file], @@ -729,7 +798,7 @@ describe('test-item-data', () => { wsRoot.scheduleTest(jestRun); // triggers testSuiteChanged event listener - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process, files: [file], @@ -771,7 +840,7 @@ describe('test-item-data', () => { 'c:\\ws-1\\src\\b.test.ts', 'c:\\ws-1\\src\\app\\app.test.ts', ]; - context.ext.testResolveProvider.getTestList.mockReturnValue(testFiles); + context.ext.testResultProvider.getTestList.mockReturnValue(testFiles); const wsRoot = new WorkspaceRoot(context); wsRoot.discoverTest(jestRun); @@ -806,7 +875,7 @@ describe('test-item-data', () => { beforeEach(() => { // establish baseline with 3 test files testFiles = ['/ws-1/src/a.test.ts', '/ws-1/src/b.test.ts', '/ws-1/src/app/app.test.ts']; - context.ext.testResolveProvider.getTestList.mockReturnValue(testFiles); + context.ext.testResultProvider.getTestList.mockReturnValue(testFiles); wsRoot = new WorkspaceRoot(context); wsRoot.discoverTest(jestRun); }); @@ -815,7 +884,7 @@ describe('test-item-data', () => { const withNewTestFiles = [...testFiles, '/ws-1/tests/d.test.ts', '/ws-1/src/c.test.ts']; // trigger event - context.ext.testResolveProvider.events.testListUpdated.event.mock.calls[0][0]( + context.ext.testResultProvider.events.testListUpdated.event.mock.calls[0][0]( withNewTestFiles ); @@ -834,7 +903,7 @@ describe('test-item-data', () => { const withoutAppFiles = [testFiles[0], testFiles[1]]; // trigger event - context.ext.testResolveProvider.events.testListUpdated.event.mock.calls[0][0]( + context.ext.testResultProvider.events.testListUpdated.event.mock.calls[0][0]( withoutAppFiles ); @@ -854,7 +923,7 @@ describe('test-item-data', () => { const withRenamed = ['/ws-1/c.test.ts', testFiles[1], testFiles[2]]; // trigger event - context.ext.testResolveProvider.events.testListUpdated.event.mock.calls[0][0](withRenamed); + context.ext.testResultProvider.events.testListUpdated.event.mock.calls[0][0](withRenamed); //should see the new files in the tree expect(wsRoot.item.children.size).toEqual(2); @@ -877,7 +946,7 @@ describe('test-item-data', () => { const parent: any = controllerMock.createTestItem('ws-1', 'ws-1', { fsPath: '/ws-1' }); a1 = helper.makeAssertion('test-1', 'KnownSuccess', ['desc-1'], [1, 0]); const assertionContainer = buildAssertionContainer([a1]); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValueOnce({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValueOnce({ status: 'KnownSuccess', assertionContainer, }); @@ -890,7 +959,7 @@ describe('test-item-data', () => { const a3 = helper.makeAssertion('test-3', 'KnownSuccess', ['desc-2'], [10, 0]); const a4 = helper.makeAssertion('test-4', 'KnownTodo', ['desc-2'], [15, 0]); const assertionContainer = buildAssertionContainer([a1, a2, a3, a4]); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownFail', assertionContainer, }); @@ -921,7 +990,7 @@ describe('test-item-data', () => { it('delete', () => { // delete the only test -1 const assertionContainer = buildAssertionContainer([]); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'Unknown', assertionContainer, }); @@ -931,7 +1000,7 @@ describe('test-item-data', () => { it('rename', () => { const a2 = helper.makeAssertion('test-2', 'KnownFail', [], [1, 0]); const assertionContainer = buildAssertionContainer([a2]); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownFail', assertionContainer, }); @@ -945,7 +1014,7 @@ describe('test-item-data', () => { }); it('with syntax error', () => { const assertionContainer = buildAssertionContainer([]); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownFail', assertionContainer, }); @@ -959,7 +1028,7 @@ describe('test-item-data', () => { runMock.failed.mockClear(); const assertionContainer = buildAssertionContainer(assertions); - context.ext.testResolveProvider.getTestSuiteResult.mockReturnValue({ + context.ext.testResultProvider.getTestSuiteResult.mockReturnValue({ status: 'KnownFail', assertionContainer, }); @@ -997,22 +1066,17 @@ describe('test-item-data', () => { }); }); describe('tags', () => { - let wsRoot, folder, doc, test; + let wsRoot, folder, doc, testItem; beforeEach(() => { - wsRoot = new WorkspaceRoot(context); - folder = new FolderData(context, 'dir', wsRoot.item); - const uri: any = { fsPath: 'whatever' }; - doc = new TestDocumentRoot(context, uri, folder.item); - const node: any = { fullName: 'a test', attrs: {}, data: {} }; - test = new TestData(context, uri, node, doc.item); + ({ wsRoot, folder, doc, testItem } = createAllTestItems()); }); it('all TestItem supports run tag', () => { - [wsRoot, folder, doc, test].forEach((itemData) => - expect(itemData.item.tags.find((t) => t.id === 'run')).toBeTruthy() - ); + [wsRoot, folder, doc, testItem].forEach((itemData) => { + expect(itemData.item.tags.find((t) => t.id === 'run')).toBeTruthy(); + }); }); it('only TestData and TestDocument supports debug tags', () => { - [doc, test].forEach((itemData) => + [doc, testItem].forEach((itemData) => expect(itemData.item.tags.find((t) => t.id === 'debug')).toBeTruthy() ); [wsRoot, folder].forEach((itemData) => @@ -1341,7 +1405,7 @@ describe('test-item-data', () => { createTestRunSpy.mockClear(); // triggers testSuiteChanged event listener - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process, files: [env.file], @@ -1389,7 +1453,7 @@ describe('test-item-data', () => { createTestRunSpy.mockClear(); // triggers testSuiteChanged event listener - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process, files: [env.file], @@ -1441,7 +1505,7 @@ describe('test-item-data', () => { createTestRunSpy.mockClear(); // process test results - context.ext.testResolveProvider.events.testSuiteChanged.event.mock.calls[0][0]({ + context.ext.testResultProvider.events.testSuiteChanged.event.mock.calls[0][0]({ type: 'assertions-updated', process, files: [env.file], @@ -1458,4 +1522,61 @@ describe('test-item-data', () => { }); }); }); + describe('runItemCommand', () => { + let wsRoot, folder, doc, testItem; + beforeEach(() => { + ({ wsRoot, folder, doc, testItem } = createAllTestItems()); + }); + it('can update-snapshot for every TestItemData', () => { + const createTestRunSpy = jest.spyOn(context, 'createTestRun'); + [wsRoot, folder, doc, testItem].forEach((itemData) => { + createTestRunSpy.mockClear(); + context.ext.session.scheduleProcess.mockClear(); + + itemData.runItemCommand(ItemCommand.updateSnapshot); + expect(createTestRunSpy).toHaveBeenCalledTimes(1); + expect(context.ext.session.scheduleProcess).toHaveBeenCalledWith( + expect.objectContaining({ updateSnapshot: true }) + ); + }); + }); + describe('view-snapshot', () => { + beforeEach(() => { + context.ext.testResultProvider.previewSnapshot.mockReturnValue(Promise.resolve()); + }); + it.each` + case | index | canView + ${'wsRoot'} | ${0} | ${false} + ${'folder'} | ${1} | ${false} + ${'doc'} | ${2} | ${false} + ${'testItem'} | ${3} | ${true} + `('$case supports view-snapshot? $canView', async ({ index, canView }) => { + testItem.node.attrs = { ...testItem.node.attrs, snapshot: 'external' }; + const data = [wsRoot, folder, doc, testItem][index]; + await data.runItemCommand(ItemCommand.viewSnapshot); + if (canView) { + expect(context.ext.testResultProvider.previewSnapshot).toHaveBeenCalled(); + } else { + expect(context.ext.testResultProvider.previewSnapshot).not.toHaveBeenCalled(); + } + }); + it.each` + snapshotAttr | canView + ${'inline'} | ${false} + ${'external'} | ${true} + ${undefined} | ${false} + `( + 'testItem: snapshot = $snapshotAttr, canView? $canView', + async ({ snapshotAttr, canView }) => { + testItem.node.attrs = { ...testItem.node.attrs, snapshot: snapshotAttr }; + await testItem.runItemCommand(ItemCommand.viewSnapshot); + if (canView) { + expect(context.ext.testResultProvider.previewSnapshot).toHaveBeenCalled(); + } else { + expect(context.ext.testResultProvider.previewSnapshot).not.toHaveBeenCalled(); + } + } + ); + }); + }); }); diff --git a/tests/test-provider/test-provider-helper.test.ts b/tests/test-provider/test-provider-helper.test.ts index d20541d5..24f10065 100644 --- a/tests/test-provider/test-provider-helper.test.ts +++ b/tests/test-provider/test-provider-helper.test.ts @@ -63,3 +63,12 @@ describe('JestTestRun', () => { }); }); }); +describe('JestTestProviderContext', () => { + it('when try to getTag not in any profiles, throw error', () => { + const whatever: any = {}; + const profile: any = { tag: { id: 'run' } }; + const context = new JestTestProviderContext(whatever, whatever, [profile]); + expect(context.getTag('run')).toEqual(profile.tag); + expect(() => context.getTag('debug')).toThrow(); + }); +}); diff --git a/tests/test-provider/test-provider.test.ts b/tests/test-provider/test-provider.test.ts index 105de3f1..1ea05af1 100644 --- a/tests/test-provider/test-provider.test.ts +++ b/tests/test-provider/test-provider.test.ts @@ -10,6 +10,7 @@ import { JestTestProviderContext } from '../../src/test-provider/test-provider-h import { extensionId } from '../../src/appGlobals'; import { mockController, mockExtExplorerContext } from './test-helper'; import { tiContextManager } from '../../src/test-provider/test-item-context-manager'; +import { ItemCommand } from '../../src/test-provider/types'; const throwError = () => { throw new Error('debug error'); @@ -20,6 +21,7 @@ describe('JestTestProvider', () => { const data: any = { discoverTest: jest.fn(), scheduleTest: jest.fn(), + runItemCommand: jest.fn(), dispose: jest.fn(), }; if (debuggable) { @@ -80,43 +82,23 @@ describe('JestTestProvider', () => { ); expect(controllerMock.createRunProfile).toHaveBeenCalledTimes(2); [ - [vscode.TestRunProfileKind.Run, 'run'], - [vscode.TestRunProfileKind.Debug, 'debug'], - ].forEach(([kind, id]) => { + [vscode.TestRunProfileKind.Run, 'run', true], + [vscode.TestRunProfileKind.Debug, 'debug', true], + ].forEach(([kind, id, isDefault]) => { expect(controllerMock.createRunProfile).toHaveBeenCalledWith( expect.anything(), kind, expect.anything(), - true, + isDefault, expect.objectContaining({ id }) ); }); expect(WorkspaceRoot).toHaveBeenCalled(); }); - it.each` - isWatchMode - ${true} - ${false} - `('will create Profiles regardless isWatchMode=$isWatchMode', ({ isWatchMode }) => { - extExplorerContextMock.settings.autoRun.isWatch = isWatchMode; - new JestTestProvider(extExplorerContextMock); - const kinds = [vscode.TestRunProfileKind.Debug, vscode.TestRunProfileKind.Run]; - - expect(controllerMock.createRunProfile).toHaveBeenCalledTimes(kinds.length); - kinds.forEach((kind) => { - expect(controllerMock.createRunProfile).toHaveBeenCalledWith( - expect.anything(), - kind, - expect.anything(), - true, - expect.anything() - ); - }); - }); }); - describe('can discover tests', () => { + describe('can discover tests', () => { it('should only discover items with canResolveChildren = true', () => { new JestTestProvider(extExplorerContextMock); const data = setupTestItemData('whatever', true, workspaceRootMock.context); @@ -535,7 +517,6 @@ describe('JestTestProvider', () => { const [run] = d.scheduleTest.mock.calls[0]; expect(run).toEqual(expect.objectContaining({ vscodeRun: controllerMock.lastRunMock() })); - /* eslint-disable jest/no-conditional-expect */ if (idx === 1) { expect(run.vscodeRun.errored).toHaveBeenCalledWith( d.item, @@ -611,4 +592,9 @@ describe('JestTestProvider', () => { ); }); }); + it('supports runItemCommand', () => { + const provider = new JestTestProvider(extExplorerContextMock); + provider.runItemCommand(workspaceRootMock.item, ItemCommand.updateSnapshot); + expect(workspaceRootMock.runItemCommand).toHaveBeenCalled(); + }); }); diff --git a/yarn.lock b/yarn.lock index 1b00a0ad..efaf2c84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2934,10 +2934,10 @@ jest-each@^29.2.1: jest-util "^29.2.1" pretty-format "^29.2.1" -jest-editor-support@^30.2.1: - version "30.2.1" - resolved "https://registry.yarnpkg.com/jest-editor-support/-/jest-editor-support-30.2.1.tgz#902911427fe46f052ec29320de8b8da41f832d97" - integrity sha512-zsWAv6Taoqvci1jSiEVqCEG/IS/+Lwhyu1VJ/skcdlrhjjFrrcV+W3PRPjjI6bgSWMVNi20yPU1CrA88+e56Tg== +jest-editor-support@^30.3.1: + version "30.3.1" + resolved "https://registry.yarnpkg.com/jest-editor-support/-/jest-editor-support-30.3.1.tgz#d7c3eb396c0c6d8a99bfb01e4f02dd416280d437" + integrity sha512-LcQubLda7j5CzSXkwxJqm4PELUw66e6JHtNMXR0erzz37aiEUOlGpOYgWKoVH7JnNsH7IgJ78jmhCqdB1LLdhw== dependencies: "@babel/parser" "^7.15.7" "@babel/runtime" "^7.15.4"