diff --git a/packages/jest-editor-support/index.d.ts b/packages/jest-editor-support/index.d.ts index a69949aedb72..fbc621fd4e42 100644 --- a/packages/jest-editor-support/index.d.ts +++ b/packages/jest-editor-support/index.d.ts @@ -1,196 +1,199 @@ -/** - * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import {EventEmitter} from 'events'; -import {ChildProcess} from 'child_process'; - -export interface SpawnOptions { - shell?: boolean; -} - -export interface Options { - createProcess?( - workspace: ProjectWorkspace, - args: string[], - options?: SpawnOptions, - ): ChildProcess; - noColor?: boolean; - testNamePattern?: string; - testFileNamePattern?: string; - shell?: boolean; -} - -export class Runner extends EventEmitter { - constructor(workspace: ProjectWorkspace, options?: Options); - watchMode: boolean; - watchAll: boolean; - start(watchMode?: boolean, watchAll?: boolean): void; - closeProcess(): void; - runJestWithUpdateForSnapshots(completion: any): void; -} - -export class Settings extends EventEmitter { - constructor(workspace: ProjectWorkspace, options?: Options); - getConfig(completed: Function): void; - jestVersionMajor: number | null; - settings: { - testRegex: string, - testMatch: string[], - }; -} - -export class ProjectWorkspace { - constructor( - rootPath: string, - pathToJest: string, - pathToConfig: string, - localJestMajorVersin: number, - collectCoverage?: boolean, - debug?: boolean, - ); - pathToJest: string; - pathToConfig: string; - rootPath: string; - localJestMajorVersion: number; - collectCoverage?: boolean; - debug?: boolean; -} - -export interface IParseResults { - expects: Expect[]; - itBlocks: ItBlock[]; -} - -export function parse(file: string): IParseResults; - -export interface Location { - column: number; - line: number; -} - -export class Node { - start: Location; - end: Location; - file: string; -} - -export class ItBlock extends Node { - name: string; -} - -export class Expect extends Node {} - -export class TestReconciler { - stateForTestFile(file: string): TestReconcilationState; - assertionsForTestFile(file: string): TestAssertionStatus[] | null; - stateForTestAssertion( - file: string, - name: string, - ): TestFileAssertionStatus | null; - updateFileWithJestStatus(data: any): TestFileAssertionStatus[]; -} - -/** - * Did the thing pass, fail or was it not run? - */ -export type TestReconcilationState = - | 'Unknown' // The file has not changed, so the watcher didn't hit it - | 'KnownFail' // Definitely failed - | 'KnownSuccess' // Definitely passed - | 'KnownSkip'; // Definitely skipped - -/** - * The Jest Extension's version of a status for - * whether the file passed or not - * - */ -export interface TestFileAssertionStatus { - file: string; - message: string; - status: TestReconcilationState; - assertions: Array | null; -} - -/** - * The Jest Extension's version of a status for - * individual assertion fails - * - */ -export interface TestAssertionStatus { - title: string; - status: TestReconcilationState; - message: string; - shortMessage?: string; - terseMessage?: string; - line?: number; -} - -export interface JestFileResults { - name: string; - summary: string; - message: string; - status: 'failed' | 'passed'; - startTime: number; - endTime: number; - assertionResults: Array; -} - -export interface JestAssertionResults { - name: string; - title: string; - status: 'failed' | 'passed'; - failureMessages: string[]; - fullName: string; -} - -export interface JestTotalResults { - success: boolean; - startTime: number; - numTotalTests: number; - numTotalTestSuites: number; - numRuntimeErrorTestSuites: number; - numPassedTests: number; - numFailedTests: number; - numPendingTests: number; - coverageMap: any; - testResults: Array; -} - -export interface JestTotalResultsMeta { - noTestsFound: boolean; -} - -export enum messageTypes { - noTests = 1, - testResults = 3, - unknown = 0, - watchUsage = 2, -} - -export type MessageType = number; - -export interface SnapshotMetadata { - exists: boolean; - name: string; - node: { - loc: Node; - }; - content?: string; -} - -export class Snapshot { - constructor(parser?: any, customMatchers?: string[]); - getMetadata(filepath: string): SnapshotMetadata[]; -} - -type FormattedTestResults = { - testResults: TestResult[] -} - -type TestResult = { - name: string -} \ No newline at end of file +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {EventEmitter} from 'events'; +import {ChildProcess} from 'child_process'; + +export interface SpawnOptions { + shell?: boolean; +} + +export interface Options { + createProcess?( + workspace: ProjectWorkspace, + args: string[], + options?: SpawnOptions, + ): ChildProcess; + noColor?: boolean; + testNamePattern?: string; + testFileNamePattern?: string; + shell?: boolean; + useWsl?: boolean; +} + +export class Runner extends EventEmitter { + constructor(workspace: ProjectWorkspace, options?: Options); + watchMode: boolean; + watchAll: boolean; + start(watchMode?: boolean, watchAll?: boolean): void; + closeProcess(): void; + runJestWithUpdateForSnapshots(completion: any): void; +} + +export class Settings extends EventEmitter { + constructor(workspace: ProjectWorkspace, options?: Options); + getConfig(completed: Function): void; + jestVersionMajor: number | null; + settings: { + testRegex: string; + testMatch: string[]; + }; +} + +export class ProjectWorkspace { + constructor( + rootPath: string, + pathToJest: string, + pathToConfig: string, + localJestMajorVersin: number, + collectCoverage?: boolean, + debug?: boolean, + useWsl?: boolean | string, + ); + pathToJest: string; + pathToConfig: string; + rootPath: string; + localJestMajorVersion: number; + collectCoverage?: boolean; + debug?: boolean; + useWsl?: boolean | string; +} + +export interface IParseResults { + expects: Expect[]; + itBlocks: ItBlock[]; +} + +export function parse(file: string): IParseResults; + +export interface Location { + column: number; + line: number; +} + +export class Node { + start: Location; + end: Location; + file: string; +} + +export class ItBlock extends Node { + name: string; +} + +export class Expect extends Node {} + +export class TestReconciler { + stateForTestFile(file: string): TestReconcilationState; + assertionsForTestFile(file: string): TestAssertionStatus[] | null; + stateForTestAssertion( + file: string, + name: string, + ): TestFileAssertionStatus | null; + updateFileWithJestStatus(data: any): TestFileAssertionStatus[]; +} + +/** + * Did the thing pass, fail or was it not run? + */ +export type TestReconcilationState = + | 'Unknown' // The file has not changed, so the watcher didn't hit it + | 'KnownFail' // Definitely failed + | 'KnownSuccess' // Definitely passed + | 'KnownSkip'; // Definitely skipped + +/** + * The Jest Extension's version of a status for + * whether the file passed or not + * + */ +export interface TestFileAssertionStatus { + file: string; + message: string; + status: TestReconcilationState; + assertions: Array | null; +} + +/** + * The Jest Extension's version of a status for + * individual assertion fails + * + */ +export interface TestAssertionStatus { + title: string; + status: TestReconcilationState; + message: string; + shortMessage?: string; + terseMessage?: string; + line?: number; +} + +export interface JestFileResults { + name: string; + summary: string; + message: string; + status: 'failed' | 'passed'; + startTime: number; + endTime: number; + assertionResults: Array; +} + +export interface JestAssertionResults { + name: string; + title: string; + status: 'failed' | 'passed'; + failureMessages: string[]; + fullName: string; +} + +export interface JestTotalResults { + success: boolean; + startTime: number; + numTotalTests: number; + numTotalTestSuites: number; + numRuntimeErrorTestSuites: number; + numPassedTests: number; + numFailedTests: number; + numPendingTests: number; + coverageMap: any; + testResults: Array; +} + +export interface JestTotalResultsMeta { + noTestsFound: boolean; +} + +export enum messageTypes { + noTests = 1, + testResults = 3, + unknown = 0, + watchUsage = 2, +} + +export type MessageType = number; + +export interface SnapshotMetadata { + exists: boolean; + name: string; + node: { + loc: Node; + }; + content?: string; +} + +export class Snapshot { + constructor(parser?: any, customMatchers?: string[]); + getMetadata(filepath: string): SnapshotMetadata[]; +} + +type FormattedTestResults = { + testResults: TestResult[]; +}; + +type TestResult = { + name: string; +}; diff --git a/packages/jest-editor-support/package.json b/packages/jest-editor-support/package.json index 6b836825675b..e358aef4790f 100644 --- a/packages/jest-editor-support/package.json +++ b/packages/jest-editor-support/package.json @@ -10,7 +10,8 @@ "dependencies": { "babel-traverse": "^6.14.1", "babylon": "^6.14.1", - "jest-snapshot": "^23.6.0" + "jest-snapshot": "^23.6.0", + "wsl-path": "^1.1.0" }, "typings": "index.d.ts" } diff --git a/packages/jest-editor-support/src/Process.js b/packages/jest-editor-support/src/Process.js index 52e3c7623a46..268f1af254ac 100644 --- a/packages/jest-editor-support/src/Process.js +++ b/packages/jest-editor-support/src/Process.js @@ -9,6 +9,7 @@ import {ChildProcess, spawn} from 'child_process'; import ProjectWorkspace from './project_workspace'; +import {windowsToWslSync} from 'wsl-path'; import type {SpawnOptions} from './types'; /** @@ -27,9 +28,9 @@ export const createProcess = ( // any other bits into the args const runtimeExecutable = workspace.pathToJest; const parameters = runtimeExecutable.split(' '); - const command = parameters[0]; const initialArgs = parameters.slice(1); - const runtimeArgs = [].concat(initialArgs, args); + let command = parameters[0]; + let runtimeArgs = [].concat(initialArgs, args); // If a path to configuration file was defined, push it to runtimeArgs if (workspace.pathToConfig) { @@ -37,6 +38,16 @@ export const createProcess = ( runtimeArgs.push(workspace.pathToConfig); } + if (workspace.useWsl) { + // useWsl can be either true for the default ('wsl' or the explicit + // wsl call to use, e.g. 'ubuntu run') + const wslCommand = workspace.useWsl === true ? 'wsl' : workspace.useWsl; + runtimeArgs = [command, ...runtimeArgs].map(path => + convertWslPath(path, wslCommand), + ); + command = wslCommand; + } + // To use our own commands in create-react, we need to tell the command that // we're in a CI environment, or it will always append --watch const env = process.env; @@ -56,3 +67,20 @@ export const createProcess = ( return spawn(command, runtimeArgs, spawnOptions); }; + +const convertWslPath = (maybePath: string, wslCommand?: string): string => { + if (!/^\w:\\/.test(maybePath)) { + return maybePath; + } + // not every string containing a windows delimiter needs to be a + // path, but if it starts with C:\ or similar the chances are very high + try { + return windowsToWslSync(maybePath, {wslCommand}); + } catch (exception) { + console.log( + `Tried to translate ${maybePath} but received exception`, + exception, + ); + return maybePath; + } +}; diff --git a/packages/jest-editor-support/src/Runner.js b/packages/jest-editor-support/src/Runner.js index 8282502713b5..eeddf97696ca 100644 --- a/packages/jest-editor-support/src/Runner.js +++ b/packages/jest-editor-support/src/Runner.js @@ -12,7 +12,9 @@ import {messageTypes} from './types'; import {ChildProcess, spawn} from 'child_process'; import {readFile} from 'fs'; +import {readTestResults} from './readTestResults'; import {tmpdir} from 'os'; +import {join} from 'path'; import EventEmitter from 'events'; import ProjectWorkspace from './project_workspace'; import {createProcess} from './Process'; @@ -40,7 +42,7 @@ export default class Runner extends EventEmitter { this._createProcess = (options && options.createProcess) || createProcess; this.options = options || {}; this.workspace = workspace; - this.outputPath = tmpdir() + '/jest_runner.json'; + this.outputPath = join(tmpdir(), 'jest_runner.json'); this.prevMessageTypes = []; } @@ -116,7 +118,7 @@ export default class Runner extends EventEmitter { this.emit('terminalError', message); } else { const noTestsFound = this.doResultsFollowNoTestsFoundMessage(); - this.emit('executableJSON', JSON.parse(data), { + this.emit('executableJSON', readTestResults(data, this.workspace), { noTestsFound, }); } diff --git a/packages/jest-editor-support/src/__tests__/process.test.js b/packages/jest-editor-support/src/__tests__/process.test.js index 7b4bc0e844b5..827f274069d9 100644 --- a/packages/jest-editor-support/src/__tests__/process.test.js +++ b/packages/jest-editor-support/src/__tests__/process.test.js @@ -10,9 +10,12 @@ 'use strict'; jest.mock('child_process'); +jest.mock('wsl-path'); +jest.mock('../readTestResults', () => ({readTestResults: x => x})); import {createProcess} from '../Process'; import {spawn} from 'child_process'; +import * as wslPath from 'wsl-path'; describe('createProcess', () => { afterEach(() => { @@ -20,7 +23,9 @@ describe('createProcess', () => { }); it('spawns the process', () => { - const workspace: any = {pathToJest: ''}; + const workspace: any = { + pathToJest: '', + }; const args = []; createProcess(workspace, args); @@ -28,7 +33,9 @@ describe('createProcess', () => { }); it('spawns the command from workspace.pathToJest', () => { - const workspace: any = {pathToJest: 'jest'}; + const workspace: any = { + pathToJest: 'jest', + }; const args = []; createProcess(workspace, args); @@ -37,7 +44,9 @@ describe('createProcess', () => { }); it('spawns the first arg from workspace.pathToJest split on " "', () => { - const workspace: any = {pathToJest: 'npm test --'}; + const workspace: any = { + pathToJest: 'npm test --', + }; const args = []; createProcess(workspace, args); @@ -60,7 +69,9 @@ describe('createProcess', () => { }); it('appends args', () => { - const workspace: any = {pathToJest: 'npm test --'}; + const workspace: any = { + pathToJest: 'npm test --', + }; const args = ['--option', 'value', '--another']; createProcess(workspace, args); @@ -86,9 +97,13 @@ describe('createProcess', () => { }); it('defines the "CI" environment variable', () => { - const expected = Object.assign({}, process.env, {CI: 'true'}); + const expected = Object.assign({}, process.env, { + CI: 'true', + }); - const workspace: any = {pathToJest: ''}; + const workspace: any = { + pathToJest: '', + }; const args = []; createProcess(workspace, args); @@ -107,7 +122,9 @@ describe('createProcess', () => { }); it('should not set the "shell" property when "options" are not provided', () => { - const workspace: any = {pathToJest: ''}; + const workspace: any = { + pathToJest: '', + }; const args = []; createProcess(workspace, args); @@ -116,11 +133,61 @@ describe('createProcess', () => { it('should set the "shell" property when "options" are provided', () => { const expected = {}; - const workspace: any = {pathToJest: ''}; + const workspace: any = { + pathToJest: '', + }; const args = []; - const options: any = {shell: expected}; + const options: any = { + shell: expected, + }; createProcess(workspace, args, options); expect(spawn.mock.calls[0][2].shell).toBe(expected); }); + + it('should prepend wsl when useWsl is set in the ProjectWorkspace', () => { + const workspace: any = { + pathToJest: 'npm run jest', + useWsl: true, + }; + const args = []; + const options: any = { + shell: true, + }; + createProcess(workspace, args, options); + + expect(spawn.mock.calls[0][0]).toEqual('wsl'); + }); + + it('should keep the original command in the spawn arguments when using wsl', () => { + const expected = ['npm', 'run', 'jest']; + const workspace: any = { + pathToJest: expected.join(' '), + useWsl: true, + }; + const args = []; + const options: any = { + shell: true, + }; + createProcess(workspace, args, options); + + expect(spawn.mock.calls[0][1]).toEqual(expected); + }); + + it('should translate file paths in the spawn command into the wsl context', () => { + const expected = ['npm', 'run', '/mnt/c/Users/Bob/path']; + wslPath.windowsToWslSync = jest.fn(() => expected[2]); + + const workspace: any = { + pathToJest: 'npm run C:\\Users\\Bob\\path', + useWsl: true, + }; + const args = []; + const options: any = { + shell: true, + }; + createProcess(workspace, args, options); + + expect(spawn.mock.calls[0][1]).toEqual(expected); + }); }); diff --git a/packages/jest-editor-support/src/__tests__/readTestResults.test.js b/packages/jest-editor-support/src/__tests__/readTestResults.test.js new file mode 100644 index 000000000000..b8ea3ac2fa77 --- /dev/null +++ b/packages/jest-editor-support/src/__tests__/readTestResults.test.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +'use strict'; + +jest.mock('wsl-path', () => ({ + wslToWindowsSync: path => 'resolved:' + path, +})); + +import {readTestResults} from '../readTestResults'; + +const posixPath1 = '/mnt/c/Users/Path/PageTitle.test.tsx'; +const posixPath2 = '/mnt/c/Users/Path2/PageFooter.test.tsx'; + +const resultFixture = JSON.stringify({ + coverageMap: { + [posixPath1]: {path: posixPath1}, + [posixPath2]: {path: posixPath2}, + }, + testResults: [ + { + assertionResults: [], + name: posixPath1, + }, + { + assertionResults: [], + name: posixPath2, + }, + ], +}); + +describe('ResultReader', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should just parse the json result for non wsl environments', () => { + const result = readTestResults(resultFixture, {useWsl: false}); + + expect(result.testResults[0].name).toBe(posixPath1); + expect(Object.keys(result.coverageMap)).toEqual([posixPath1, posixPath2]); + }); + + it('should replace the testResult paths with the resolved paths when wsl is defined', () => { + const result = readTestResults(resultFixture, {useWsl: true}); + + expect(result.testResults[0].name).toBe('resolved:' + posixPath1); + expect(result.testResults[1].name).toBe('resolved:' + posixPath2); + }); + + it('should replace the coverageMap paths with the resolved paths when wsl is defined', () => { + const result = readTestResults(resultFixture, {useWsl: true}); + + const mapKey1 = Object.keys(result.coverageMap)[0]; + const mapKey2 = Object.keys(result.coverageMap)[1]; + + expect(mapKey1).toBe('resolved:' + posixPath1); + expect(mapKey2).toBe('resolved:' + posixPath2); + expect(result.coverageMap[mapKey1].path).toBe('resolved:' + posixPath1); + expect(result.coverageMap[mapKey2].path).toBe('resolved:' + posixPath2); + }); +}); diff --git a/packages/jest-editor-support/src/__tests__/runner.test.js b/packages/jest-editor-support/src/__tests__/runner.test.js index 1f110a6bd72a..62f7bb18fc7a 100644 --- a/packages/jest-editor-support/src/__tests__/runner.test.js +++ b/packages/jest-editor-support/src/__tests__/runner.test.js @@ -11,7 +11,9 @@ jest.mock('../Process'); jest.mock('child_process', () => ({spawn: jest.fn()})); -jest.mock('os', () => ({tmpdir: jest.fn()})); +jest.mock('../readTestResults', () => ({readTestResults: x => JSON.parse(x)})); + +jest.mock('os', () => ({tmpdir: jest.fn(() => 'tmpdir')})); jest.mock('fs', () => { // $FlowFixMe requireActual const readFileSync = require.requireActual('fs').readFileSync; @@ -61,7 +63,7 @@ describe('Runner', () => { const workspace: any = {}; const sut = new Runner(workspace); - expect(sut.outputPath).toBe('tmpdir/jest_runner.json'); + expect(sut.outputPath).toBe(path.join('tmpdir', 'jest_runner.json')); }); it('sets the default options', () => { diff --git a/packages/jest-editor-support/src/project_workspace.js b/packages/jest-editor-support/src/project_workspace.js index a092ea6a668e..4197f6348773 100644 --- a/packages/jest-editor-support/src/project_workspace.js +++ b/packages/jest-editor-support/src/project_workspace.js @@ -60,6 +60,8 @@ export default class ProjectWorkspace { */ debug: ?boolean; + useWsl: ?boolean; + constructor( rootPath: string, pathToJest: string, @@ -67,6 +69,7 @@ export default class ProjectWorkspace { localJestMajorVersion: number, collectCoverage: ?boolean, debug: ?boolean, + useWsl: ?boolean, ) { this.rootPath = rootPath; this.pathToJest = pathToJest; @@ -74,5 +77,6 @@ export default class ProjectWorkspace { this.localJestMajorVersion = localJestMajorVersion; this.collectCoverage = collectCoverage; this.debug = debug; + this.useWsl = useWsl; } } diff --git a/packages/jest-editor-support/src/readTestResults.js b/packages/jest-editor-support/src/readTestResults.js new file mode 100644 index 000000000000..31549106e62c --- /dev/null +++ b/packages/jest-editor-support/src/readTestResults.js @@ -0,0 +1,69 @@ +import {wslToWindowsSync} from 'wsl-path'; + +export const readTestResults = (data: Buffer, workspace: ProjectWorkspace) => { + const results = JSON.parse(data); + if (!workspace.useWsl) { + return results; + } + + return Object.assign({}, results, { + coverageMap: translateWslPathCoverateToWindowsPaths( + results.coverageMap, + workspace, + ), + testResults: translateWslTestResultsToWindowsPaths( + results.testResults, + workspace, + ), + }); +}; + +/** + * Return a rewritten copy a coverage map created by a jest run in wsl. All POSIX paths + * are rewritten to Windows paths, so vscode-jest running in windows can map the coverage. + * + * @param coverageMap The coverage map to rewrite + */ +const translateWslPathCoverateToWindowsPaths = ( + coverageMap, + workspace: ProjectWorkspace, +) => { + if (!coverageMap) { + return coverageMap; + } + const result = {}; + Object.keys(coverageMap).forEach(key => { + const translatedPath = wslToWindowsSync(key, { + wslCommand: getWslCommand(workspace), + }); + const entry = Object.assign({}, coverageMap[key], {path: translatedPath}); + result[translatedPath] = entry; + }); + return result; +}; + +/** + * Return a rewritten copy a {@see JestFileResults} array created by a jest run in wsl. All POSIX paths + * are rewritten to Windows paths, so vscode-jest running in windows can map the test + * status. + * + * @param testResults the TestResults to rewrite + */ +const translateWslTestResultsToWindowsPaths = ( + testResults, + workspace: ProjectWorkspace, +) => { + if (!testResults) { + return testResults; + } + return testResults.map(result => + Object.assign({}, result, { + name: wslToWindowsSync(result.name, { + wslCommand: getWslCommand(workspace), + }), + }), + ); +}; + +const getWslCommand = (workspace: ProjectWorkspace): string => + workspace.wslCommand === true ? 'wsl' : workspace.wslCommand;