diff --git a/package.json b/package.json index d05eabe29..78549a2ee 100644 --- a/package.json +++ b/package.json @@ -366,6 +366,12 @@ "markdownDescription": "A detailed runMode configuration. See details in [runMode](https://github.com/jest-community/vscode-jest#runmode)" } ] + }, + "jest.useJest30": { + "description": "Use Jest 30+ features", + "type": "boolean", + "default": null, + "scope": "resource" } } }, diff --git a/src/JestExt/helper.ts b/src/JestExt/helper.ts index 703d6ad4d..566ae5efb 100644 --- a/src/JestExt/helper.ts +++ b/src/JestExt/helper.ts @@ -107,6 +107,7 @@ export const getExtensionResourceSettings = ( parserPluginOptions: getSetting('parserPluginOptions'), enable: getSetting('enable'), useDashedArgs: getSetting('useDashedArgs') ?? false, + useJest30: getSetting('useJest30'), }; }; diff --git a/src/JestExt/process-listeners.ts b/src/JestExt/process-listeners.ts index bb2506aa3..9367a698f 100644 --- a/src/JestExt/process-listeners.ts +++ b/src/JestExt/process-listeners.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { JestTotalResults, RunnerEvent } from 'jest-editor-support'; import { cleanAnsi, toErrorString } from '../helpers'; import { JestProcess, ProcessStatus } from '../JestProcessManagement'; -import { ListenerSession, ListTestFilesCallback } from './process-session'; +import { JestExtRequestType, ListenerSession, ListTestFilesCallback } from './process-session'; import { Logging } from '../logging'; import { JestRunEvent } from './types'; import { MonitorLongRun } from '../Settings'; @@ -12,6 +12,11 @@ import { RunShell } from './run-shell'; // command not found error for anything but "jest", as it most likely not be caused by env issue const POSSIBLE_ENV_ERROR_REGEX = /^(((?!(jest|react-scripts)).)*)(command not found|no such file or directory)/im; + +const TEST_PATH_PATTERNS_V30_ERROR_REGEX = + /Option "testPathPattern" was replaced by "testPathPatterns"\./i; +const TEST_PATH_PATTERNS_NOT_V30_ERROR_REGEX = + /Unrecognized option "testPathPatterns". Did you mean "testPathPattern"\?/i; export class AbstractProcessListener { protected session: ListenerSession; protected readonly logging: Logging; @@ -271,9 +276,31 @@ export class RunTestListener extends AbstractProcessListener { ); return; } - this.logging('debug', '--watch is not supported, will start the --watchAll run instead'); - this.session.scheduleProcess({ type: 'watch-all-tests' }); - process.stop(); + this.reScheduleProcess( + process, + '--watch is not supported, will start the --watchAll run instead', + { type: 'watch-all-tests' } + ); + } + } + private reScheduleProcess( + process: JestProcess, + message: string, + overrideRequest?: JestExtRequestType + ): void { + this.logging('debug', message); + this.session.context.output.write(`${message}\r\nReSchedule the process...`, 'warn'); + + this.session.scheduleProcess(overrideRequest ?? process.request); + process.stop(); + } + private handleTestPatternsError(process: JestProcess, data: string) { + if (TEST_PATH_PATTERNS_V30_ERROR_REGEX.test(data)) { + this.session.context.settings.useJest30 = true; + this.reScheduleProcess(process, 'detected jest v30, enable useJest30 option'); + } else if (TEST_PATH_PATTERNS_NOT_V30_ERROR_REGEX.test(data)) { + this.session.context.settings.useJest30 = false; + this.reScheduleProcess(process, 'detected jest Not v30, disable useJest30 option'); } } @@ -307,6 +334,8 @@ export class RunTestListener extends AbstractProcessListener { this.handleRunComplete(process, message); this.handleWatchNotSupportedError(process, message); + + this.handleTestPatternsError(process, message); } private getNumTotalTestSuites(text: string): number | undefined { diff --git a/src/JestProcessManagement/JestProcess.ts b/src/JestProcessManagement/JestProcess.ts index 3ab6fb684..43d4d7db0 100644 --- a/src/JestProcessManagement/JestProcess.ts +++ b/src/JestProcessManagement/JestProcess.ts @@ -122,6 +122,11 @@ export class JestProcess implements JestProcessInfo { return `"${removeSurroundingQuote(aString)}"`; } + private getTestPathPattern(pattern: string): string[] { + return this.extContext.settings.useJest30 + ? ['--testPathPatterns', pattern] + : ['--testPathPattern', pattern]; + } public start(): Promise { if (this.status === ProcessStatus.Cancelled) { this.logging('warn', `the runner task has been cancelled!`); @@ -166,7 +171,7 @@ export class JestProcess implements JestProcessInfo { } case 'by-file-pattern': { const regex = this.quoteFilePattern(escapeRegExp(this.request.testFileNamePattern)); - args.push('--watchAll=false', '--testPathPattern', regex); + args.push('--watchAll=false', ...this.getTestPathPattern(regex)); if (this.request.updateSnapshot) { args.push('--updateSnapshot'); } @@ -191,7 +196,7 @@ export class JestProcess implements JestProcessInfo { escapeRegExp(this.request.testNamePattern), this.extContext.settings.shell.toSetting() ); - args.push('--watchAll=false', '--testPathPattern', regex); + args.push('--watchAll=false', ...this.getTestPathPattern(regex)); if (this.request.updateSnapshot) { args.push('--updateSnapshot'); } diff --git a/src/Settings/types.ts b/src/Settings/types.ts index 18753d6cc..119e2d5f2 100644 --- a/src/Settings/types.ts +++ b/src/Settings/types.ts @@ -77,6 +77,7 @@ export interface PluginResourceSettings { enable?: boolean; parserPluginOptions?: JESParserPluginOptions; useDashedArgs?: boolean; + useJest30?: boolean; } export interface DeprecatedPluginResourceSettings { diff --git a/src/test-provider/test-item-data.ts b/src/test-provider/test-item-data.ts index 57b9a1d0d..ae1bb6649 100644 --- a/src/test-provider/test-item-data.ts +++ b/src/test-provider/test-item-data.ts @@ -355,7 +355,6 @@ export class WorkspaceRoot extends TestItemDataBase { return process.userData.testItem; } - // should only come here for autoRun processes let fileName; switch (process.request.type) { case 'watch-tests': @@ -363,12 +362,17 @@ export class WorkspaceRoot extends TestItemDataBase { case 'all-tests': return this.item; case 'by-file': + case 'by-file-test': fileName = process.request.testFileName; break; case 'by-file-pattern': + case 'by-file-test-pattern': fileName = process.request.testFileNamePattern; break; default: + // the current flow would not reach here, but for future proofing + // and avoiding failed silently, we will keep the code around but disable coverage reporting + /* istanbul ignore next */ throw new Error(`unsupported external process type ${process.request.type}`); } @@ -404,8 +408,9 @@ export class WorkspaceRoot extends TestItemDataBase { return; } + let run; try { - const run = this.getJestRun(event, true); + run = this.getJestRun(event, true); switch (event.type) { case 'scheduled': { this.deepItemState(event.process.userData?.testItem, run.enqueued); @@ -460,7 +465,8 @@ export class WorkspaceRoot extends TestItemDataBase { } } catch (err) { this.log('error', ` ${event.type} failed:`, err); - this.context.output.write(` ${event.type} failed: ${err}`, 'error'); + run?.write(` ${event.type} failed: ${err}`, 'error'); + run?.end({ reason: 'Internal error onRunEvent' }); } }; diff --git a/tests/JestExt/helper.test.ts b/tests/JestExt/helper.test.ts index 8ace06726..b1d0606cf 100644 --- a/tests/JestExt/helper.test.ts +++ b/tests/JestExt/helper.test.ts @@ -177,6 +177,7 @@ describe('getExtensionResourceSettings()', () => { enable: true, nodeEnv: undefined, useDashedArgs: false, + useJest30: null, }); expect(createJestSettingGetter).toHaveBeenCalledWith(folder); }); diff --git a/tests/JestExt/process-listeners.test.ts b/tests/JestExt/process-listeners.test.ts index 9bab6ba9c..ef078cc28 100644 --- a/tests/JestExt/process-listeners.test.ts +++ b/tests/JestExt/process-listeners.test.ts @@ -56,6 +56,7 @@ describe('jest process listeners', () => { create: jest.fn(() => mockLogging), }, onRunEvent: { fire: jest.fn() }, + output: { write: jest.fn() }, }, }; mockProcess = initMockProcess('watch-tests'); @@ -240,6 +241,7 @@ describe('jest process listeners', () => { append: jest.fn(), clear: jest.fn(), show: jest.fn(), + write: jest.fn(), }; mockSession.context.updateWithData = jest.fn(); }); @@ -575,5 +577,47 @@ describe('jest process listeners', () => { }); }); }); + describe('jest 30 support', () => { + describe('can restart process if detected jest 30 related error', () => { + it.each` + case | output | useJest30Before | useJest30After | willRestart + ${1} | ${'Error in JestTestPatterns'} | ${null} | ${null} | ${false} + ${2} | ${'Error in JestTestPatterns'} | ${true} | ${true} | ${false} + ${3} | ${'Process Failed\nOption "testPathPattern" was replaced by "testPathPatterns".'} | ${null} | ${true} | ${true} + ${4} | ${'Process Failed\nOption "testPathPattern" was replaced by "testPathPatterns".'} | ${false} | ${true} | ${true} + `('case $case', ({ output, useJest30Before, useJest30After, willRestart }) => { + expect.hasAssertions(); + mockSession.context.settings.useJest30 = useJest30Before; + const listener = new RunTestListener(mockSession); + + listener.onEvent(mockProcess, 'executableStdErr', Buffer.from(output)); + + expect(mockSession.context.settings.useJest30).toEqual(useJest30After); + + if (willRestart) { + expect(mockSession.scheduleProcess).toHaveBeenCalledTimes(1); + expect(mockSession.scheduleProcess).toHaveBeenCalledWith(mockProcess.request); + expect(mockProcess.stop).toHaveBeenCalled(); + } else { + expect(mockSession.scheduleProcess).not.toHaveBeenCalled(); + expect(mockProcess.stop).not.toHaveBeenCalled(); + } + }); + }); + it('can restart process if setting useJest30 for a non jest 30 runtime', () => { + expect.hasAssertions(); + mockSession.context.settings.useJest30 = true; + const listener = new RunTestListener(mockSession); + + const output = `whatever\n Unrecognized option "testPathPatterns". Did you mean "testPathPattern"?\n`; + listener.onEvent(mockProcess, 'executableStdErr', Buffer.from(output)); + + expect(mockSession.context.settings.useJest30).toEqual(false); + + expect(mockSession.scheduleProcess).toHaveBeenCalledTimes(1); + expect(mockSession.scheduleProcess).toHaveBeenCalledWith(mockProcess.request); + expect(mockProcess.stop).toHaveBeenCalled(); + }); + }); }); }); diff --git a/tests/JestProcessManagement/JestProcess.test.ts b/tests/JestProcessManagement/JestProcess.test.ts index d459c9342..2407b7dc8 100644 --- a/tests/JestProcessManagement/JestProcess.test.ts +++ b/tests/JestProcessManagement/JestProcess.test.ts @@ -186,6 +186,28 @@ describe('JestProcess', () => { } ); }); + describe('supports jest v30 options', () => { + it.each` + case | type | extraProperty | useJest30 | expectedOption + ${1} | ${'by-file-pattern'} | ${{ testFileNamePattern: 'abc' }} | ${null} | ${'--testPathPattern'} + ${2} | ${'by-file-pattern'} | ${{ testFileNamePattern: 'abc' }} | ${true} | ${'--testPathPatterns'} + ${3} | ${'by-file-pattern'} | ${{ testFileNamePattern: 'abc' }} | ${false} | ${'--testPathPattern'} + ${4} | ${'by-file-test-pattern'} | ${{ testFileNamePattern: 'abc', testNamePattern: 'abc' }} | ${null} | ${'--testPathPattern'} + ${5} | ${'by-file-test-pattern'} | ${{ testFileNamePattern: 'abc', testNamePattern: 'abc' }} | ${true} | ${'--testPathPatterns'} + ${6} | ${'by-file-test-pattern'} | ${{ testFileNamePattern: 'abc', testNamePattern: 'abc' }} | ${false} | ${'--testPathPattern'} + `( + 'case $case: generate the correct TestPathPattern(s) option', + ({ type, extraProperty, useJest30, expectedOption }) => { + expect.hasAssertions(); + extContext.settings.useJest30 = useJest30; + const request = mockRequest(type, extraProperty); + const jp = new JestProcess(extContext, request); + jp.start(); + const [, options] = RunnerClassMock.mock.calls[0]; + expect(options.args.args).toContain(expectedOption); + } + ); + }); describe('common flags', () => { it.each` type | extraProperty | excludeWatch | withColors diff --git a/tests/test-provider/test-item-data.test.ts b/tests/test-provider/test-item-data.test.ts index f529b248e..e2a46c91e 100644 --- a/tests/test-provider/test-item-data.test.ts +++ b/tests/test-provider/test-item-data.test.ts @@ -1404,13 +1404,15 @@ describe('test-item-data', () => { mockedJestTestRun.mockClear(); }); describe.each` - request | withFile - ${{ type: 'watch-tests' }} | ${false} - ${{ type: 'watch-all-tests' }} | ${false} - ${{ type: 'all-tests' }} | ${false} - ${{ type: 'by-file', testFileName: file }} | ${true} - ${{ type: 'by-file', testFileName: 'source.ts', notTestFile: true }} | ${false} - ${{ type: 'by-file-pattern', testFileNamePattern: file }} | ${true} + request | withFile + ${{ type: 'watch-tests' }} | ${false} + ${{ type: 'watch-all-tests' }} | ${false} + ${{ type: 'all-tests' }} | ${false} + ${{ type: 'by-file', testFileName: file }} | ${true} + ${{ type: 'by-file', testFileName: 'source.ts', notTestFile: true }} | ${false} + ${{ type: 'by-file-test', testFileName: file, testNamePattern: 'whatever' }} | ${true} + ${{ type: 'by-file-pattern', testFileNamePattern: file }} | ${true} + ${{ type: 'by-file-test-pattern', testFileNamePattern: file, testNamePattern: 'whatever' }} | ${true} `('will create a new run and use it throughout: $request', ({ request, withFile }) => { it('if only reports assertion-update, everything should still work', () => { const process: any = { id: 'whatever', request }; @@ -1511,13 +1513,11 @@ describe('test-item-data', () => { expect(process.userData.run.write).toHaveBeenCalledWith('whatever', 'error'); }); }); - describe('request not supported', () => { + describe('on request not supported', () => { it.each` request ${{ type: 'not-test' }} - ${{ type: 'by-file-test', testFileName: file, testNamePattern: 'whatever' }} - ${{ type: 'by-file-test-pattern', testFileNamePattern: file, testNamePattern: 'whatever' }} - `('$request', ({ request }) => { + `('do nothing for request: $request', ({ request }) => { const process = { id: 'whatever', request }; // starting the process @@ -1557,6 +1557,40 @@ describe('test-item-data', () => { errors.LONG_RUNNING_TESTS ); }); + describe('will catch runtime error and close the run', () => { + let process, jestRun; + beforeEach(() => { + process = mockScheduleProcess(context); + jestRun = createTestRun(); + process.userData = { run: jestRun, testItem: env.testFile }; + }); + + it('when run failed to be created', () => { + // simulate a runtime error + jestRun.addProcess = jest.fn(() => { + throw new Error('forced error'); + }); + // this will not throw error + env.onRunEvent({ type: 'start', process }); + + expect(jestRun.started).toHaveBeenCalledTimes(0); + expect(jestRun.end).toHaveBeenCalledTimes(0); + expect(jestRun.write).toHaveBeenCalledTimes(0); + }); + it('when run is created', () => { + // simulate a runtime error + jestRun.started = jest.fn(() => { + throw new Error('forced error'); + }); + + // this will not throw error + env.onRunEvent({ type: 'start', process }); + + expect(jestRun.started).toHaveBeenCalledTimes(1); + expect(jestRun.end).toHaveBeenCalledTimes(1); + expect(jestRun.write).toHaveBeenCalledTimes(1); + }); + }); }); }); describe('createTestItem', () => {