Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading