mirror of
https://github.com/callstack/react-native-testing-library.git
synced 2026-09-18 23:09:04 +08:00
await calls
This commit is contained in:
@@ -8,6 +8,19 @@ const FUNCTIONS_TO_RENAME = new Map([
|
||||
['fireEvent', 'fireEventAsync'],
|
||||
]);
|
||||
|
||||
const FIRE_EVENT_METHODS_TO_MAKE_ASYNC = new Set(['press', 'changeText', 'scroll']);
|
||||
const TEST_FUNCTION_NAMES = new Set([
|
||||
'test',
|
||||
'it',
|
||||
'beforeEach',
|
||||
'afterEach',
|
||||
'beforeAll',
|
||||
'afterAll',
|
||||
]);
|
||||
const TEST_FUNCTION_PREFIXES = new Set(['test', 'it']);
|
||||
const TEST_MODIFIERS = new Set(['skip', 'only']);
|
||||
const TEST_EACH_METHOD = 'each';
|
||||
|
||||
export default async function transform(
|
||||
root: Parameters<Transform<TSX>>[0],
|
||||
options?: Parameters<Transform<TSX>>[1],
|
||||
@@ -27,13 +40,64 @@ export default async function transform(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collect function calls that will be renamed, BEFORE renaming
|
||||
// We need to find calls to the OLD names (render, renderHook, fireEvent)
|
||||
// that are in importedFunctions, as these will be renamed to async variants
|
||||
const functionCalls: SgNode<TSX>[] = [];
|
||||
functionCalls.push(...findDirectFunctionCallsThatWillBeRenamed(rootNode, importedFunctions));
|
||||
functionCalls.push(...findFireEventMethodCallsThatWillBeRenamed(rootNode, importedFunctions));
|
||||
|
||||
// Now rename the functions
|
||||
renameFunctionsInUsages(rootNode, importedFunctions, edits);
|
||||
|
||||
// Add await to the calls we found
|
||||
const functionsToMakeAsync = new Map<number, SgNode<TSX>>();
|
||||
|
||||
for (const functionCall of functionCalls) {
|
||||
if (isCallAlreadyAwaited(functionCall)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const containingFunction = findContainingTestFunction(functionCall);
|
||||
if (!containingFunction) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!isFunctionAlreadyAsync(containingFunction) &&
|
||||
!functionsToMakeAsync.has(containingFunction.id())
|
||||
) {
|
||||
functionsToMakeAsync.set(containingFunction.id(), containingFunction);
|
||||
}
|
||||
|
||||
addAwaitBeforeCall(functionCall, edits);
|
||||
}
|
||||
|
||||
for (const func of functionsToMakeAsync.values()) {
|
||||
addAsyncKeywordToFunction(func, edits);
|
||||
}
|
||||
|
||||
if (edits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
edits.sort((a, b) => b.startPos - a.startPos);
|
||||
// Sort edits: descending by startPos, but insertion edits (startPos == endPos)
|
||||
// come before replacement edits at the same position
|
||||
edits.sort((a, b) => {
|
||||
if (a.startPos !== b.startPos) {
|
||||
return b.startPos - a.startPos;
|
||||
}
|
||||
// If same startPos, insertion edits (startPos == endPos) come first
|
||||
const aIsInsertion = a.startPos === a.endPos;
|
||||
const bIsInsertion = b.startPos === b.endPos;
|
||||
if (aIsInsertion && !bIsInsertion) {
|
||||
return -1; // a comes before b
|
||||
}
|
||||
if (!aIsInsertion && bIsInsertion) {
|
||||
return 1; // b comes before a
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return rootNode.commitEdits(edits);
|
||||
}
|
||||
@@ -174,3 +238,263 @@ function renameFunctionsInUsages(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findDirectFunctionCallsThatWillBeRenamed(
|
||||
rootNode: SgNode<TSX>,
|
||||
importedFunctions: Set<string>,
|
||||
): SgNode<TSX>[] {
|
||||
const functionCalls: SgNode<TSX>[] = [];
|
||||
|
||||
for (const funcName of importedFunctions) {
|
||||
if (!FUNCTIONS_TO_RENAME.has(funcName)) {
|
||||
continue;
|
||||
}
|
||||
const calls = rootNode.findAll({
|
||||
rule: {
|
||||
kind: 'call_expression',
|
||||
has: {
|
||||
field: 'function',
|
||||
kind: 'identifier',
|
||||
regex: `^${funcName}$`,
|
||||
},
|
||||
},
|
||||
});
|
||||
functionCalls.push(...calls);
|
||||
}
|
||||
|
||||
return functionCalls;
|
||||
}
|
||||
|
||||
function findFireEventMethodCallsThatWillBeRenamed(
|
||||
rootNode: SgNode<TSX>,
|
||||
importedFunctions: Set<string>,
|
||||
): SgNode<TSX>[] {
|
||||
const functionCalls: SgNode<TSX>[] = [];
|
||||
|
||||
if (!importedFunctions.has('fireEvent')) {
|
||||
return functionCalls;
|
||||
}
|
||||
|
||||
const fireEventMethodCalls = rootNode.findAll({
|
||||
rule: {
|
||||
kind: 'call_expression',
|
||||
has: {
|
||||
field: 'function',
|
||||
kind: 'member_expression',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const call of fireEventMethodCalls) {
|
||||
const funcNode = call.field('function');
|
||||
if (funcNode && funcNode.is('member_expression')) {
|
||||
try {
|
||||
const object = funcNode.field('object');
|
||||
const property = funcNode.field('property');
|
||||
if (object && property) {
|
||||
const objText = object.text();
|
||||
const propText = property.text();
|
||||
if (objText === 'fireEvent' && FIRE_EVENT_METHODS_TO_MAKE_ASYNC.has(propText)) {
|
||||
functionCalls.push(call);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip nodes where field() is not available or AST structure doesn't match expectations.
|
||||
// This is expected for malformed or edge-case AST structures and should be silently ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return functionCalls;
|
||||
}
|
||||
|
||||
function isCallAlreadyAwaited(functionCall: SgNode<TSX>): boolean {
|
||||
const parent = functionCall.parent();
|
||||
return parent !== null && parent.is('await_expression');
|
||||
}
|
||||
|
||||
function addAwaitBeforeCall(functionCall: SgNode<TSX>, edits: Edit[]): void {
|
||||
const callStart = functionCall.range().start.index;
|
||||
edits.push({
|
||||
startPos: callStart,
|
||||
endPos: callStart,
|
||||
insertedText: 'await ',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a function is already marked as async using AST-based detection.
|
||||
* This is more reliable than string matching and handles edge cases better.
|
||||
*/
|
||||
function isFunctionAlreadyAsync(func: SgNode<TSX>): boolean {
|
||||
if (func.is('arrow_function')) {
|
||||
// For arrow functions, check if 'async' is a direct child
|
||||
const children = func.children();
|
||||
return children.some((child) => child.text() === 'async');
|
||||
} else if (func.is('function_declaration') || func.is('function_expression')) {
|
||||
// For function declarations/expressions, check for async modifier
|
||||
// The async keyword appears before the 'function' keyword
|
||||
const children = func.children();
|
||||
const functionKeywordIndex = children.findIndex((child) => child.text() === 'function');
|
||||
if (functionKeywordIndex > 0) {
|
||||
// Check if any child before 'function' is 'async'
|
||||
return children.slice(0, functionKeywordIndex).some((child) => child.text() === 'async');
|
||||
}
|
||||
// Also check if the first child is 'async'
|
||||
return children.length > 0 && children[0].text() === 'async';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function addAsyncKeywordToFunction(func: SgNode<TSX>, edits: Edit[]): void {
|
||||
if (func.is('arrow_function')) {
|
||||
const funcStart = func.range().start.index;
|
||||
edits.push({
|
||||
startPos: funcStart,
|
||||
endPos: funcStart,
|
||||
insertedText: 'async ',
|
||||
});
|
||||
} else if (func.is('function_declaration') || func.is('function_expression')) {
|
||||
const children = func.children();
|
||||
const firstChild = children.length > 0 ? children[0] : null;
|
||||
if (firstChild && firstChild.text() === 'function') {
|
||||
const funcKeywordStart = firstChild.range().start.index;
|
||||
edits.push({
|
||||
startPos: funcKeywordStart,
|
||||
endPos: funcKeywordStart,
|
||||
insertedText: 'async ',
|
||||
});
|
||||
} else {
|
||||
const funcStart = func.range().start.index;
|
||||
edits.push({
|
||||
startPos: funcStart,
|
||||
endPos: funcStart,
|
||||
insertedText: 'async ',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the containing test function (test, it, beforeEach, etc.) for a given node.
|
||||
* Traverses up the AST tree to find the nearest test function that contains the node.
|
||||
*
|
||||
* Handles various test patterns:
|
||||
* - Direct test functions: test(), it()
|
||||
* - Test modifiers: test.skip(), it.only()
|
||||
* - Test.each patterns: test.each(), it.each()
|
||||
* - Hooks: beforeEach(), afterEach(), beforeAll(), afterAll()
|
||||
*
|
||||
* @param node - The AST node to find the containing test function for
|
||||
* @returns The containing test function node, or null if not found
|
||||
*/
|
||||
function findContainingTestFunction(node: SgNode<TSX>): SgNode<TSX> | null {
|
||||
let current: SgNode<TSX> | null = node;
|
||||
|
||||
while (current) {
|
||||
if (
|
||||
current.is('arrow_function') ||
|
||||
current.is('function_declaration') ||
|
||||
current.is('function_expression')
|
||||
) {
|
||||
const parent = current.parent();
|
||||
if (parent) {
|
||||
if (parent.is('arguments')) {
|
||||
const grandParent = parent.parent();
|
||||
if (grandParent && grandParent.is('call_expression')) {
|
||||
const funcNode = grandParent.field('function');
|
||||
if (funcNode) {
|
||||
const funcText = funcNode.text();
|
||||
if (TEST_FUNCTION_NAMES.has(funcText)) {
|
||||
return current;
|
||||
}
|
||||
if (funcNode.is('member_expression')) {
|
||||
try {
|
||||
const object = funcNode.field('object');
|
||||
const property = funcNode.field('property');
|
||||
if (object && property) {
|
||||
const objText = object.text();
|
||||
const propText = property.text();
|
||||
if (TEST_FUNCTION_PREFIXES.has(objText) && TEST_MODIFIERS.has(propText)) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip nodes where field() is not available or AST structure doesn't match expectations.
|
||||
// This is expected for malformed or edge-case AST structures and should be silently ignored.
|
||||
}
|
||||
}
|
||||
if (funcNode.is('call_expression')) {
|
||||
try {
|
||||
const innerFuncNode = funcNode.field('function');
|
||||
if (innerFuncNode && innerFuncNode.is('member_expression')) {
|
||||
const object = innerFuncNode.field('object');
|
||||
const property = innerFuncNode.field('property');
|
||||
if (object && property) {
|
||||
const objText = object.text();
|
||||
const propText = property.text();
|
||||
if (TEST_FUNCTION_PREFIXES.has(objText) && propText === TEST_EACH_METHOD) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip nodes where field() is not available or AST structure doesn't match expectations.
|
||||
// This is expected for malformed or edge-case AST structures and should be silently ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parent.is('call_expression')) {
|
||||
const funcNode = parent.field('function');
|
||||
if (funcNode) {
|
||||
const funcText = funcNode.text();
|
||||
if (TEST_FUNCTION_NAMES.has(funcText)) {
|
||||
return current;
|
||||
}
|
||||
if (funcNode.is('member_expression')) {
|
||||
try {
|
||||
const object = funcNode.field('object');
|
||||
const property = funcNode.field('property');
|
||||
if (object && property) {
|
||||
const objText = object.text();
|
||||
const propText = property.text();
|
||||
if (TEST_FUNCTION_PREFIXES.has(objText) && TEST_MODIFIERS.has(propText)) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip nodes where field() is not available or AST structure doesn't match expectations.
|
||||
// This is expected for malformed or edge-case AST structures and should be silently ignored.
|
||||
}
|
||||
}
|
||||
if (funcNode.is('call_expression')) {
|
||||
try {
|
||||
const innerFuncNode = funcNode.field('function');
|
||||
if (innerFuncNode && innerFuncNode.is('member_expression')) {
|
||||
const object = innerFuncNode.field('object');
|
||||
const property = innerFuncNode.field('property');
|
||||
if (object && property) {
|
||||
const objText = object.text();
|
||||
const propText = property.text();
|
||||
if (TEST_FUNCTION_PREFIXES.has(objText) && propText === TEST_EACH_METHOD) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip nodes where field() is not available or AST structure doesn't match expectations.
|
||||
// This is expected for malformed or edge-case AST structures and should be silently ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
current = current.parent();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { renderAsync, renderHookAsync, fireEventAsync } from '@testing-library/react-native';
|
||||
|
||||
test('renders component', () => {
|
||||
const component = renderAsync(<MyComponent />);
|
||||
const { result } = renderHookAsync(() => useMyHook());
|
||||
fireEventAsync.press(component.getByText('Button'));
|
||||
test('renders component', async () => {
|
||||
const component = await renderAsync(<MyComponent />);
|
||||
const { result } = await renderHookAsync(() => useMyHook());
|
||||
await fireEventAsync.press(component.getByText('Button'));
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fireEventAsync } from '@testing-library/react-native';
|
||||
|
||||
test('handles events', () => {
|
||||
test('handles events', async () => {
|
||||
const input = getByTestId('input');
|
||||
fireEventAsync.changeText(input, 'Hello');
|
||||
fireEventAsync.press(input);
|
||||
fireEventAsync.scroll(input);
|
||||
await fireEventAsync.changeText(input, 'Hello');
|
||||
await fireEventAsync.press(input);
|
||||
await fireEventAsync.scroll(input);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user