Skip to content

Commit

Permalink
[jest-each] refactor into multiple files with better types (#8018)
Browse files Browse the repository at this point in the history
  • Loading branch information
mattphillips authored and SimenB committed Mar 2, 2019
1 parent ae8280f commit 392a815
Show file tree
Hide file tree
Showing 7 changed files with 309 additions and 227 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -33,6 +33,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';
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

0 comments on commit 392a815

Please sign in to comment.