Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[jest-each] refactor into multiple files with better types #8018

Merged
merged 16 commits into from Mar 2, 2019
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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))
Expand Down
250 changes: 49 additions & 201 deletions packages/jest-each/src/bind.ts
Expand Up @@ -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/';
mattphillips marked this conversation as resolved.
Show resolved Hide resolved
import {ErrorWithStack} from 'jest-util';

type Table = Array<Array<any>>;
type PrettyArgs = {
args: Array<any>;
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<unknown>;
}>;

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<any> | 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<any>) => table.length === 0;
const isEmptyString = (str: string) =>
typeof str === 'string' && str.trim() === '';

const getPrettyIndexes = (placeholders: RegExpMatchArray) =>
placeholders.reduce((indexes: Array<number>, placeholder, index) => {
if (placeholder === PRETTY_PLACEHOLDER) {
indexes.push(index);
}
return indexes;
}, []);

const arrayFormat = (title: string, rowIndex: number, ...args: Array<any>) => {
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<string> =>
headings.replace(/\s/g, '').split('|');

const applyRestParams = (
const applyArguments = (
supportsDone: boolean,
params: Array<any>,
test: Function,
) =>
params: Array<unknown>,
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<string> =>
headings.replace(/\s/g, '').split('|');

const buildTable = (
data: Array<any>,
rowSize: number,
keys: Array<string>,
): Array<any> =>
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<string>,
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<string>,
): any => {
if (!head || !o.hasOwnProperty || !o.hasOwnProperty(head)) return o;
return getPath(o[head], tail);
};
57 changes: 33 additions & 24 deletions packages/jest-each/src/index.ts
Expand Up @@ -6,38 +6,47 @@
*
*/

type Global = NodeJS.Global;

import {Global} from '@jest/types';
import bind from './bind';

const install = (g: Global, ...args: Array<any>) => {
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<any>) => install(global, ...args);
const each = (table: Global.EachTable, ...data: Global.TemplateData) =>
install(global, table, ...data);

each.withGlobal = (g: Global) => (...args: Array<any>) => install(g, ...args);
each.withGlobal = (g: Global) => (
table: Global.EachTable,
...data: Global.TemplateData
) => install(g, table, ...data);

export {bind};

Expand Down