diff --git a/CHANGELOG.md b/CHANGELOG.md index 18b0b770a7bc..dba97dcdc03a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ ### Chore & Maintenance +- `[jest-each]`: Refactor into multiple files with better types ([#8018](https://github.com/facebook/jest/pull/8018)) - `[jest-each]`: Migrate to Typescript ([#8007](https://github.com/facebook/jest/pull/8007)) - `[jest-environment-jsdom]`: Migrate to TypeScript ([#7985](https://github.com/facebook/jest/pull/8003)) - `[jest-environment-node]`: Migrate to TypeScript ([#7985](https://github.com/facebook/jest/pull/7985)) diff --git a/packages/jest-each/src/bind.ts b/packages/jest-each/src/bind.ts index 173c26e159c1..c32542e9ea41 100644 --- a/packages/jest-each/src/bind.ts +++ b/packages/jest-each/src/bind.ts @@ -6,227 +6,75 @@ * */ -import util from 'util'; -import chalk from 'chalk'; -import pretty from 'pretty-format'; -import {isPrimitive} from 'jest-get-type'; +import {Global} from '@jest/types'; import {ErrorWithStack} from 'jest-util'; -type Table = Array>; -type PrettyArgs = { - args: Array; - title: string; -}; - -const EXPECTED_COLOR = chalk.green; -const RECEIVED_COLOR = chalk.red; -const SUPPORTED_PLACEHOLDERS = /%[sdifjoOp%]/g; -const PRETTY_PLACEHOLDER = '%p'; -const INDEX_PLACEHOLDER = '%#'; - -export default (cb: Function, supportsDone: boolean = true) => (...args: any) => - function eachBind(title: string, test: Function, timeout?: number): void { - if (args.length === 1) { - const [tableArg] = args; - - if (!Array.isArray(tableArg)) { - const error = new ErrorWithStack( - '`.each` must be called with an Array or Tagged Template Literal.\n\n' + - `Instead was called with: ${pretty(tableArg, { - maxDepth: 1, - min: true, - })}\n`, - eachBind, - ); - return cb(title, () => { - throw error; - }); - } +import convertArrayTable from './table/array'; +import convertTemplateTable from './table/template'; +import {validateArrayTable, validateTemplateTableHeadings} from './validation'; - if (isTaggedTemplateLiteral(tableArg)) { - if (isEmptyString(tableArg[0])) { - const error = new ErrorWithStack( - 'Error: `.each` called with an empty Tagged Template Literal of table data.\n', - eachBind, - ); - return cb(title, () => { - throw error; - }); - } +export type EachTests = Array<{ + title: string; + arguments: Array; +}>; - const error = new ErrorWithStack( - 'Error: `.each` called with a Tagged Template Literal with no data, remember to interpolate with ${expression} syntax.\n', - eachBind, - ); - return cb(title, () => { - throw error; - }); - } +type TestFn = (done?: Global.DoneFn) => Promise | void | undefined; +type GlobalCallback = (testName: string, fn: TestFn, timeout?: number) => void; - if (isEmptyTable(tableArg)) { - const error = new ErrorWithStack( - 'Error: `.each` called with an empty Array of table data.\n', - eachBind, - ); - return cb(title, () => { - throw error; - }); - } - const table: Table = tableArg.every(Array.isArray) - ? tableArg - : tableArg.map(entry => [entry]); - return table.forEach((row, i) => +export default (cb: GlobalCallback, supportsDone: boolean = true) => ( + table: Global.EachTable, + ...taggedTemplateData: Global.TemplateData +) => + function eachBind( + title: string, + test: Global.EachTestFn, + timeout?: number, + ): void { + try { + const tests = isArrayTable(taggedTemplateData) + ? buildArrayTests(title, table) + : buildTemplateTests(title, table, taggedTemplateData); + + return tests.forEach(row => cb( - arrayFormat(title, i, ...row), - applyRestParams(supportsDone, row, test), + row.title, + applyArguments(supportsDone, row.arguments, test), timeout, ), ); - } - - const templateStrings = args[0]; - const data = args.slice(1); - - const keys = getHeadingKeys(templateStrings[0]); - const table = buildTable(data, keys.length, keys); - - const missingData = data.length % keys.length; - - if (missingData > 0) { - const error = new ErrorWithStack( - 'Not enough arguments supplied for given headings:\n' + - EXPECTED_COLOR(keys.join(' | ')) + - '\n\n' + - 'Received:\n' + - RECEIVED_COLOR(pretty(data)) + - '\n\n' + - `Missing ${RECEIVED_COLOR(missingData.toString())} ${pluralize( - 'argument', - missingData, - )}`, - eachBind, - ); - + } catch (e) { + const error = new ErrorWithStack(e.message, eachBind); return cb(title, () => { throw error; }); } - - return table.forEach(row => - cb( - interpolate(title, row), - applyObjectParams(supportsDone, row, test), - timeout, - ), - ); }; -const isTaggedTemplateLiteral = (array: any) => array.raw !== undefined; -const isEmptyTable = (table: Array) => table.length === 0; -const isEmptyString = (str: string) => - typeof str === 'string' && str.trim() === ''; - -const getPrettyIndexes = (placeholders: RegExpMatchArray) => - placeholders.reduce((indexes: Array, placeholder, index) => { - if (placeholder === PRETTY_PLACEHOLDER) { - indexes.push(index); - } - return indexes; - }, []); - -const arrayFormat = (title: string, rowIndex: number, ...args: Array) => { - const placeholders = title.match(SUPPORTED_PLACEHOLDERS) || []; - const prettyIndexes = getPrettyIndexes(placeholders); - - const {title: prettyTitle, args: remainingArgs} = args.reduce( - (acc: PrettyArgs, arg, index) => { - if (prettyIndexes.indexOf(index) !== -1) { - return { - args: acc.args, - title: acc.title.replace( - PRETTY_PLACEHOLDER, - pretty(arg, {maxDepth: 1, min: true}), - ), - }; - } +const isArrayTable = (data: Global.TemplateData) => data.length === 0; - return { - args: acc.args.concat([arg]), - title: acc.title, - }; - }, - {args: [], title}, - ); +const buildArrayTests = (title: string, table: Global.EachTable): EachTests => { + validateArrayTable(table); + return convertArrayTable(title, table as Global.ArrayTable); +}; - return util.format( - prettyTitle.replace(INDEX_PLACEHOLDER, rowIndex.toString()), - ...remainingArgs.slice(0, placeholders.length - prettyIndexes.length), - ); +const buildTemplateTests = ( + title: string, + table: Global.EachTable, + taggedTemplateData: Global.TemplateData, +): EachTests => { + const headings = getHeadingKeys(table[0] as string); + validateTemplateTableHeadings(headings, taggedTemplateData); + return convertTemplateTable(title, headings, taggedTemplateData); }; -type Done = () => {}; +const getHeadingKeys = (headings: string): Array => + headings.replace(/\s/g, '').split('|'); -const applyRestParams = ( +const applyArguments = ( supportsDone: boolean, - params: Array, - test: Function, -) => + params: Array, + test: Global.EachTestFn, +): Global.EachTestFn => supportsDone && params.length < test.length - ? (done: Done) => test(...params, done) + ? (done: Global.DoneFn) => test(...params, done) : () => test(...params); - -const getHeadingKeys = (headings: string): Array => - headings.replace(/\s/g, '').split('|'); - -const buildTable = ( - data: Array, - rowSize: number, - keys: Array, -): Array => - Array.from({length: data.length / rowSize}) - .map((_, index) => data.slice(index * rowSize, index * rowSize + rowSize)) - .map(row => - row.reduce( - (acc, value, index) => Object.assign(acc, {[keys[index]]: value}), - {}, - ), - ); - -const getMatchingKeyPaths = (title: string) => ( - matches: Array, - key: string, -) => matches.concat(title.match(new RegExp(`\\$${key}[\\.\\w]*`, 'g')) || []); - -const replaceKeyPathWithValue = (data: any) => ( - title: string, - match: string, -) => { - const keyPath = match.replace('$', '').split('.'); - const value = getPath(data, keyPath); - - if (isPrimitive(value)) { - return title.replace(match, String(value)); - } - return title.replace(match, pretty(value, {maxDepth: 1, min: true})); -}; - -const interpolate = (title: string, data: any) => - Object.keys(data) - .reduce(getMatchingKeyPaths(title), []) // aka flatMap - .reduce(replaceKeyPathWithValue(data), title); - -const applyObjectParams = (supportsDone: boolean, obj: any, test: Function) => - supportsDone && test.length > 1 - ? (done: Done) => test(obj, done) - : () => test(obj); - -const pluralize = (word: string, count: number) => - word + (count === 1 ? '' : 's'); - -const getPath = ( - o: {[key: string]: any}, - [head, ...tail]: Array, -): any => { - if (!head || !o.hasOwnProperty || !o.hasOwnProperty(head)) return o; - return getPath(o[head], tail); -}; diff --git a/packages/jest-each/src/index.ts b/packages/jest-each/src/index.ts index b77335f361b5..82b04f4a5737 100644 --- a/packages/jest-each/src/index.ts +++ b/packages/jest-each/src/index.ts @@ -6,38 +6,47 @@ * */ -type Global = NodeJS.Global; - +import {Global} from '@jest/types'; import bind from './bind'; -const install = (g: Global, ...args: Array) => { - const test = (title: string, test: Function, timeout?: number) => - bind(g.test)(...args)(title, test, timeout); - test.skip = bind(g.test.skip)(...args); - test.only = bind(g.test.only)(...args); - - const it = (title: string, test: Function, timeout?: number) => - bind(g.it)(...args)(title, test, timeout); - it.skip = bind(g.it.skip)(...args); - it.only = bind(g.it.only)(...args); - - const xit = bind(g.xit)(...args); - const fit = bind(g.fit)(...args); - const xtest = bind(g.xtest)(...args); +type Global = NodeJS.Global; - const describe = (title: string, suite: Function, timeout?: number) => - bind(g.describe, false)(...args)(title, suite, timeout); - describe.skip = bind(g.describe.skip, false)(...args); - describe.only = bind(g.describe.only, false)(...args); - const fdescribe = bind(g.fdescribe, false)(...args); - const xdescribe = bind(g.xdescribe, false)(...args); +const install = ( + g: Global, + table: Global.EachTable, + ...data: Global.TemplateData +) => { + const test = (title: string, test: Global.TestFn, timeout?: number) => + bind(g.test)(table, ...data)(title, test, timeout); + test.skip = bind(g.test.skip)(table, ...data); + test.only = bind(g.test.only)(table, ...data); + + const it = (title: string, test: Global.TestFn, timeout?: number) => + bind(g.it)(table, ...data)(title, test, timeout); + it.skip = bind(g.it.skip)(table, ...data); + it.only = bind(g.it.only)(table, ...data); + + const xit = bind(g.xit)(table, ...data); + const fit = bind(g.fit)(table, ...data); + const xtest = bind(g.xtest)(table, ...data); + + const describe = (title: string, suite: Global.TestFn, timeout?: number) => + bind(g.describe, false)(table, ...data)(title, suite, timeout); + describe.skip = bind(g.describe.skip, false)(table, ...data); + describe.only = bind(g.describe.only, false)(table, ...data); + const fdescribe = bind(g.fdescribe, false)(table, ...data); + const xdescribe = bind(g.xdescribe, false)(table, ...data); return {describe, fdescribe, fit, it, test, xdescribe, xit, xtest}; }; -const each = (...args: Array) => install(global, ...args); +const each = (table: Global.EachTable, ...data: Global.TemplateData) => + install(global, table, ...data); -each.withGlobal = (g: Global) => (...args: Array) => install(g, ...args); +each.withGlobal = (g: Global) => ( + table: Global.EachTable, + ...data: Global.TemplateData +) => install(g, table, ...data); export {bind}; diff --git a/packages/jest-each/src/table/array.ts b/packages/jest-each/src/table/array.ts new file mode 100644 index 000000000000..de9be8c0c57b --- /dev/null +++ b/packages/jest-each/src/table/array.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. 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 util from 'util'; +import pretty from 'pretty-format'; + +import {Global} from '@jest/types'; +import {EachTests} from '../bind'; + +const SUPPORTED_PLACEHOLDERS = /%[sdifjoOp%]/g; +const PRETTY_PLACEHOLDER = '%p'; +const INDEX_PLACEHOLDER = '%#'; + +export default (title: string, arrayTable: Global.ArrayTable): EachTests => + normaliseTable(arrayTable).map((row, index) => ({ + arguments: row, + title: formatTitle(title, row, index), + })); + +const normaliseTable = (table: Global.ArrayTable): Global.Table => + isTable(table) ? table : table.map(colToRow); + +const isTable = (table: Global.ArrayTable): table is Global.Table => + table.every(Array.isArray); + +const colToRow = (col: Global.Col): Global.Row => [col]; + +const formatTitle = ( + title: string, + row: Global.Row, + rowIndex: number, +): string => + row.reduce((formattedTitle, value) => { + const [placeholder] = getMatchingPlaceholders(formattedTitle); + if (!placeholder) return formattedTitle; + + if (placeholder === PRETTY_PLACEHOLDER) + return interpolatePrettyPlaceholder(formattedTitle, value); + + return util.format(formattedTitle, value); + }, interpolateTitleIndex(title, rowIndex)); + +const getMatchingPlaceholders = (title: string) => + title.match(SUPPORTED_PLACEHOLDERS) || []; + +const interpolateTitleIndex = (title: string, index: number) => + title.replace(INDEX_PLACEHOLDER, index.toString()); + +const interpolatePrettyPlaceholder = (title: string, value: unknown) => + title.replace(PRETTY_PLACEHOLDER, pretty(value, {maxDepth: 1, min: true})); diff --git a/packages/jest-each/src/table/template.ts b/packages/jest-each/src/table/template.ts new file mode 100644 index 000000000000..a618f163bd43 --- /dev/null +++ b/packages/jest-each/src/table/template.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. 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 pretty from 'pretty-format'; +import {isPrimitive} from 'jest-get-type'; +import {Global} from '@jest/types'; +import {EachTests} from '../bind'; + +type Template = {[key: string]: unknown}; +type Templates = Array