Created
November 22, 2025 08:52
-
-
Save eliOcs/61092b2f406f4ebf2144edeed271bb4a to your computer and use it in GitHub Desktop.
Codemod to migrate to userEvent usages of React Testing Library from v13 to v14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* eslint-env node */ | |
| /* USAGE: jscodeshift -t codemods/migrate-to-userevent-v14.js <path-to-test-files> */ | |
| module.exports = function transformer(file, api) { | |
| const j = api.jscodeshift; | |
| const source = j(file.source); | |
| // ============================================================================ | |
| // CONFIGURATION | |
| // ============================================================================ | |
| const RENDER_FUNCTIONS = [ | |
| "renderWithTheme", | |
| "renderWithDataRouter", | |
| "renderWithQueryClient", | |
| "renderHook", | |
| "renderWithUserEvent", | |
| "renderHookWithUserEvent" | |
| ]; | |
| const KEYBOARD_DESCRIPTORS = { | |
| arrowdown: "ArrowDown", | |
| arrowup: "ArrowUp", | |
| arrowleft: "ArrowLeft", | |
| arrowright: "ArrowRight", | |
| enter: "Enter", | |
| esc: "Escape", | |
| escape: "Escape", | |
| backspace: "Backspace", | |
| del: "Delete", | |
| delete: "Delete", | |
| tab: "Tab", | |
| space: "Space", | |
| home: "Home", | |
| end: "End", | |
| pageup: "PageUp", | |
| pagedown: "PageDown", | |
| insert: "Insert", | |
| capslock: "CapsLock" | |
| }; | |
| // State | |
| let hasUserEventImport = false; | |
| let userEventImportName = null; | |
| // ============================================================================ | |
| // UTILITY FUNCTIONS | |
| // ============================================================================ | |
| const createUserEventIdentifier = () => j.identifier("userEvent"); | |
| const createUserEventProperty = () => { | |
| const userEventIdentifier = createUserEventIdentifier(); | |
| return j.property.from({ | |
| kind: "init", | |
| key: userEventIdentifier, | |
| value: userEventIdentifier, | |
| shorthand: true | |
| }); | |
| }; | |
| const isTestBlock = (node) => | |
| j.CallExpression.check(node) && | |
| j.Identifier.check(node.callee) && | |
| (node.callee.name === "it" || node.callee.name === "test"); | |
| const isFunctionType = (node) => | |
| j.ArrowFunctionExpression.check(node) || j.FunctionExpression.check(node); | |
| const findAndRemoveEnclosingStatement = (path) => { | |
| let statementPath = path; | |
| while (statementPath && !j.ExpressionStatement.check(statementPath.value)) { | |
| statementPath = statementPath.parent; | |
| } | |
| if (statementPath) { | |
| j(statementPath).remove(); | |
| } | |
| }; | |
| // ============================================================================ | |
| // RENDER FUNCTION HELPERS | |
| // ============================================================================ | |
| function getUserEventVariantName(baseName) { | |
| return baseName === "renderHook" | |
| ? "renderHookWithUserEvent" | |
| : `${baseName}AndUserEvent`; | |
| } | |
| function isRenderHelper(name) { | |
| return name && ( | |
| RENDER_FUNCTIONS.includes(name) || | |
| name.endsWith("AndUserEvent") || | |
| name.endsWith("WithUserEvent") | |
| ); | |
| } | |
| function hasUserEventSupport(name) { | |
| return name && ( | |
| name.endsWith("AndUserEvent") || | |
| name.endsWith("WithUserEvent") | |
| ); | |
| } | |
| // ============================================================================ | |
| // PATH TRAVERSAL HELPERS | |
| // ============================================================================ | |
| function findEnclosingTestBlock(path) { | |
| let current = path; | |
| while (current) { | |
| if (current.value && isTestBlock(current.value)) { | |
| return current; | |
| } | |
| current = current.parent; | |
| } | |
| return null; | |
| } | |
| function findEnclosingStatement(path) { | |
| let current = path; | |
| while (current) { | |
| if (current.value && ( | |
| j.ExpressionStatement.check(current.value) || | |
| j.VariableDeclaration.check(current.value) | |
| )) { | |
| return current; | |
| } | |
| current = current.parent; | |
| } | |
| return null; | |
| } | |
| function isInImportDeclaration(path) { | |
| return j(path).closest(j.ImportDeclaration).size() > 0; | |
| } | |
| // ============================================================================ | |
| // USEREVENT PROPERTY MANAGEMENT | |
| // ============================================================================ | |
| function ensureUserEventInObjectPattern(objectPattern) { | |
| const hasUserEventProperty = objectPattern.properties.some( | |
| (prop) => prop.key && prop.key.name === "userEvent" | |
| ); | |
| if (!hasUserEventProperty) { | |
| objectPattern.properties.push(createUserEventProperty()); | |
| } | |
| } | |
| function addUserEventDestructuring(parent, path) { | |
| if (j.VariableDeclarator.check(parent.value)) { | |
| if (!j.ObjectPattern.check(parent.value.id)) { | |
| parent.value.id = j.objectPattern([createUserEventProperty()]); | |
| } else { | |
| ensureUserEventInObjectPattern(parent.value.id); | |
| } | |
| } else if (j.ExpressionStatement.check(parent.value)) { | |
| const varDeclaration = j.variableDeclaration("const", [ | |
| j.variableDeclarator( | |
| j.objectPattern([createUserEventProperty()]), | |
| path.value | |
| ) | |
| ]); | |
| j(parent).replaceWith(varDeclaration); | |
| } | |
| } | |
| // ============================================================================ | |
| // FOCUS MANAGEMENT | |
| // ============================================================================ | |
| function isUserEventCall(statement, methods = null) { | |
| if (!j.ExpressionStatement.check(statement)) return false; | |
| const expr = statement.expression; | |
| if (!j.AwaitExpression.check(expr)) return false; | |
| const call = expr.argument; | |
| if (!j.CallExpression.check(call) || | |
| !j.MemberExpression.check(call.callee) || | |
| call.callee.object.name !== "userEvent") { | |
| return false; | |
| } | |
| // If methods is null, match any userEvent method | |
| return methods === null || methods.includes(call.callee.property.name); | |
| } | |
| function isUserFocusCall(statement, targetElement, focusMethods = ["click", "clear"]) { | |
| if (!isUserEventCall(statement, focusMethods)) return false; | |
| const call = statement.expression.argument; | |
| if (call.arguments.length === 0) return false; | |
| return j(call.arguments[0]).toSource() === j(targetElement).toSource(); | |
| } | |
| function hasPriorFocusCall(statementPath, element) { | |
| if (!statementPath || !statementPath.parent) return false; | |
| const parent = statementPath.parent.value; | |
| if (!j.BlockStatement.check(parent)) return false; | |
| const index = parent.body.indexOf(statementPath.value); | |
| const focusMethods = ["click", "clear", "focus"]; | |
| for (let i = index - 1; i >= 0; i--) { | |
| const prevStatement = parent.body[i]; | |
| // If element is provided, check for focus on that specific element | |
| if (element) { | |
| if (isUserFocusCall(prevStatement, element, focusMethods)) { | |
| return true; | |
| } | |
| } else { | |
| // If no element specified, check for any focus call | |
| if (isUserEventCall(prevStatement, focusMethods)) { | |
| return true; | |
| } | |
| // Stop checking if we hit a non-userEvent statement | |
| if (!isUserEventCall(prevStatement)) { | |
| break; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| function insertClickBeforeStatement(statementPath, element) { | |
| // If no element provided, default to screen.getByRole("textbox") | |
| const targetElement = element || j.callExpression( | |
| j.memberExpression(j.identifier("screen"), j.identifier("getByRole")), | |
| [j.literal("textbox")] | |
| ); | |
| const clickCall = j.awaitExpression( | |
| j.callExpression( | |
| j.memberExpression(createUserEventIdentifier(), j.identifier("click")), | |
| [targetElement] | |
| ) | |
| ); | |
| if (statementPath && j.BlockStatement.check(statementPath.parent.value)) { | |
| const parent = statementPath.parent.value; | |
| const index = parent.body.indexOf(statementPath.value); | |
| if (index !== -1) { | |
| parent.body.splice(index, 0, j.expressionStatement(clickCall)); | |
| } | |
| } | |
| } | |
| function ensureFocusBeforeMethod(path, element = null) { | |
| // Generic function to ensure focus before any userEvent method | |
| const statementPath = findEnclosingStatement(path); | |
| if (!statementPath) return; | |
| // Check if there's already a focus call | |
| if (!hasPriorFocusCall(statementPath, element)) { | |
| insertClickBeforeStatement(statementPath, element); | |
| } | |
| } | |
| function hasSpecialKeys(text) { | |
| // Check if string contains keyboard descriptors like {Enter}, {Tab}, etc. | |
| // This is used to determine if keyboard() should be used instead of paste() | |
| if (typeof text !== 'string') return false; | |
| // Match patterns like {Enter}, {Tab}, {Control>}, {/Shift}, [ArrowDown] | |
| // Pattern explanation: [\{\[] = { or [, [\w/>]+ = word chars/slashes/>, [\}\]] = } or ] | |
| return /[\{\[][\w/>]+[\}\]]/.test(text); | |
| } | |
| function transformMethodWithFocus(path, methodName, newMethodName = null) { | |
| const actualNewName = newMethodName || methodName; | |
| let finalMethodName = actualNewName; | |
| // Handle calls with 2+ arguments (element, text) | |
| if (path.value.arguments.length >= 2) { | |
| const element = path.value.arguments[0]; | |
| const text = path.value.arguments[1]; | |
| // Smart conversion for type() → keyboard() vs paste() | |
| if (methodName === "type" && newMethodName === "keyboard") { | |
| if (j.Literal.check(text) && typeof text.value === 'string') { | |
| const textValue = text.value; | |
| const hasSpecial = hasSpecialKeys(textValue); | |
| const isLongString = textValue.length > 20; | |
| // If long string without special keys, use paste() instead | |
| if (isLongString && !hasSpecial) { | |
| finalMethodName = "paste"; | |
| } | |
| } | |
| } | |
| // Update method name | |
| if (newMethodName) { | |
| path.value.callee.property.name = finalMethodName; | |
| } | |
| // Keep only the text argument | |
| path.value.arguments = [text]; | |
| // Ensure focus before the method call | |
| ensureFocusBeforeMethod(path, element); | |
| } else if (newMethodName) { | |
| // No element argument, just update method name if needed | |
| path.value.callee.property.name = finalMethodName; | |
| } | |
| } | |
| function ensurePasteHasFocus() { | |
| // In v14, paste() requires the element to have focus first | |
| // This function ensures all paste() calls have a preceding click | |
| source | |
| .find(j.CallExpression, { | |
| callee: { | |
| type: "MemberExpression", | |
| object: { name: "userEvent" }, | |
| property: { name: "paste" } | |
| } | |
| }) | |
| .forEach((path) => { | |
| // For standalone paste() calls (without element argument), | |
| // ensure there's a focus call before it | |
| if (path.value.arguments.length === 1) { | |
| ensureFocusBeforeMethod(path, null); // null means generic textbox | |
| } | |
| }); | |
| } | |
| // ============================================================================ | |
| // ASYNC FUNCTION MANAGEMENT | |
| // ============================================================================ | |
| function makeArrowFunctionAsync(funcPath) { | |
| if (!funcPath || !funcPath.value) return; | |
| const func = funcPath.value; | |
| if (isFunctionType(func) && !func.async) { | |
| // Check if function body contains await | |
| const hasAwait = j(func).find(j.AwaitExpression).size() > 0; | |
| if (hasAwait) { | |
| func.async = true; | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // KEYBOARD DESCRIPTOR TRANSFORMATION | |
| // ============================================================================ | |
| function updateKeyboardDescriptors(value) { | |
| let newValue = value; | |
| // Replace {selectall} with {Control>}a{/Control} | |
| newValue = newValue.replace(/\{selectall\}/gi, "{Control>}a{/Control}"); | |
| // Handle modifier key combinations | |
| newValue = newValue.replace(/\{(Shift|Control|Alt|Meta)\}\{([^}]+)\}/g, | |
| (match, modifier, key) => `{${modifier}>}{${key}}{/${modifier}}`); | |
| // Handle case-insensitive modifier combinations | |
| newValue = newValue.replace(/\{(shift|control|alt|meta)\}\{([^}]+)\}/gi, | |
| (match, modifier, key) => { | |
| const capitalizedModifier = modifier.charAt(0).toUpperCase() + modifier.slice(1).toLowerCase(); | |
| return `{${capitalizedModifier}>}{${key}}{/${capitalizedModifier}}`; | |
| }); | |
| // Replace keyboard descriptors using the mapping | |
| Object.entries(KEYBOARD_DESCRIPTORS).forEach(([oldKey, newKey]) => { | |
| const regex = new RegExp(`\\{${oldKey}\\}`, 'gi'); | |
| newValue = newValue.replace(regex, `{${newKey}}`); | |
| }); | |
| return newValue; | |
| } | |
| // ============================================================================ | |
| // IMPORT MANAGEMENT | |
| // ============================================================================ | |
| function processUserEventImport() { | |
| source | |
| .find(j.ImportDeclaration, { source: { value: "@testing-library/user-event" }}) | |
| .forEach((path) => { | |
| const userEventSpec = path.value.specifiers.find(spec => | |
| j.ImportDefaultSpecifier.check(spec) || | |
| (spec.local && (spec.local.name === "userEvent" || spec.local.name === "userEvents")) | |
| ); | |
| if (userEventSpec) { | |
| hasUserEventImport = true; | |
| userEventImportName = userEventSpec.local.name; | |
| j(path).remove(); | |
| } | |
| }); | |
| } | |
| function addUserEventVariantImport(specifiers, baseName) { | |
| const userEventName = getUserEventVariantName(baseName); | |
| const hasBase = specifiers.some(spec => spec.imported?.name === baseName); | |
| const hasUserEvent = specifiers.some(spec => spec.imported?.name === userEventName); | |
| if (hasBase && !hasUserEvent) { | |
| specifiers.push(j.importSpecifier(j.identifier(userEventName))); | |
| return true; | |
| } | |
| return false; | |
| } | |
| // ============================================================================ | |
| // MAIN TRANSFORMATION FUNCTIONS | |
| // ============================================================================ | |
| function transformUserEventCalls() { | |
| source | |
| .find(j.MemberExpression, { object: { name: "userEvent" }}) | |
| .forEach((path) => { | |
| const parent = path.parent; | |
| // Add await if not already awaited | |
| if (j.CallExpression.check(parent.value)) { | |
| const grandParent = parent.parent; | |
| if (!j.AwaitExpression.check(grandParent.value)) { | |
| j(parent).replaceWith(j.awaitExpression(parent.value)); | |
| } | |
| } | |
| // Make enclosing test async | |
| const testBlock = findEnclosingTestBlock(path); | |
| if (testBlock && testBlock.value.arguments.length >= 2) { | |
| const testFn = testBlock.value.arguments[1]; | |
| if (isFunctionType(testFn) && !testFn.async) { | |
| testFn.async = true; | |
| } | |
| } | |
| // Make parent functions async | |
| let current = path; | |
| while (current) { | |
| if (current.value && isFunctionType(current.value)) { | |
| makeArrowFunctionAsync(current); | |
| break; // Only make the immediate parent function async | |
| } | |
| current = current.parent; | |
| } | |
| }); | |
| } | |
| function transformRenderFunctionCalls() { | |
| source | |
| .find(j.CallExpression) | |
| .filter(path => RENDER_FUNCTIONS.includes(path.value.callee.name)) | |
| .forEach((path) => { | |
| const calleeName = path.value.callee.name; | |
| // Skip if already has userEvent support | |
| if (hasUserEventSupport(calleeName)) { | |
| const testBlock = findEnclosingTestBlock(path); | |
| if (testBlock) { | |
| const hasUserEventCall = j(testBlock) | |
| .find(j.MemberExpression, { object: { name: "userEvent" }}) | |
| .size() > 0; | |
| if (hasUserEventCall) { | |
| addUserEventDestructuring(path.parent, path); | |
| } | |
| } | |
| return; | |
| } | |
| // Check if userEvent is used | |
| const testBlock = findEnclosingTestBlock(path); | |
| const hasUserEventCall = testBlock | |
| ? j(testBlock).find(j.MemberExpression, { object: { name: "userEvent" }}).size() > 0 | |
| : source.find(j.MemberExpression, { object: { name: "userEvent" }}).size() > 0; | |
| if (hasUserEventCall) { | |
| path.value.callee.name = getUserEventVariantName(calleeName); | |
| if (testBlock) { | |
| addUserEventDestructuring(path.parent, path); | |
| } | |
| } | |
| }); | |
| } | |
| function transformSpecificUserEventMethods() { | |
| // Transform paste() calls | |
| source | |
| .find(j.CallExpression, { | |
| callee: { | |
| type: "MemberExpression", | |
| object: { name: "userEvent" }, | |
| property: { name: "paste" } | |
| } | |
| }) | |
| .forEach((path) => transformMethodWithFocus(path, "paste")); | |
| // Transform type() to keyboard() or paste() (smart conversion) | |
| // - Short strings (<20 chars) without special keys → keyboard() | |
| // - Long strings (>20 chars) without special keys → paste() (much faster!) | |
| // - Any string with special keys ({Enter}, {Tab}, etc.) → keyboard() (required) | |
| // Performance: A 58-char string took 7.6s with keyboard() vs <0.1s with paste() | |
| source | |
| .find(j.CallExpression, { | |
| callee: { | |
| type: "MemberExpression", | |
| object: { name: "userEvent" }, | |
| property: { name: "type" } | |
| } | |
| }) | |
| .forEach((path) => transformMethodWithFocus(path, "type", "keyboard")); | |
| // Update keyboard descriptors | |
| source | |
| .find(j.CallExpression) | |
| .filter(path => { | |
| const node = path.value; | |
| return j.MemberExpression.check(node.callee) && | |
| node.callee.object.name === "userEvent" && | |
| ["type", "keyboard"].includes(node.callee.property.name); | |
| }) | |
| .forEach((path) => { | |
| path.value.arguments.forEach((arg) => { | |
| if (j.Literal.check(arg) && typeof arg.value === "string") { | |
| const newValue = updateKeyboardDescriptors(arg.value); | |
| if (newValue !== arg.value) { | |
| arg.value = newValue; | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| function handleCustomWrapperFunctions() { | |
| source | |
| .find(j.CallExpression) | |
| .filter(path => { | |
| const name = path.value.callee.name; | |
| return name && !RENDER_FUNCTIONS.includes(name) && name !== "userEvent"; | |
| }) | |
| .forEach((path) => { | |
| const calleeName = path.value.callee.name; | |
| const testBlock = findEnclosingTestBlock(path); | |
| if (!testBlock) return; | |
| const hasUserEventCall = j(testBlock) | |
| .find(j.MemberExpression, { object: { name: "userEvent" }}) | |
| .size() > 0; | |
| if (!hasUserEventCall) return; | |
| // Check if this is a locally defined function | |
| const functionDecls = source | |
| .find(j.FunctionDeclaration, { id: { name: calleeName }}) | |
| .paths(); | |
| const arrowFunctions = source | |
| .find(j.VariableDeclarator, { | |
| id: { name: calleeName }, | |
| init: { type: "ArrowFunctionExpression" } | |
| }) | |
| .paths(); | |
| const functionDefs = [...functionDecls, ...arrowFunctions]; | |
| if (functionDefs.length === 0) return; | |
| const funcNode = functionDefs[0]; | |
| // Check if the function calls any render helper | |
| const callsRenderHelper = j(funcNode) | |
| .find(j.CallExpression) | |
| .some(p => isRenderHelper(p.value.callee.name)); | |
| if (callsRenderHelper) { | |
| addUserEventDestructuring(path.parent, path); | |
| // Ensure render calls destructure userEvent | |
| j(funcNode) | |
| .find(j.CallExpression) | |
| .filter(p => isRenderHelper(p.value.callee.name)) | |
| .forEach((renderCallPath) => { | |
| const parent = renderCallPath.parent; | |
| if (j.VariableDeclarator.check(parent.value)) { | |
| if (j.ObjectPattern.check(parent.value.id)) { | |
| ensureUserEventInObjectPattern(parent.value.id); | |
| } else { | |
| parent.value.id = j.objectPattern([createUserEventProperty()]); | |
| } | |
| } else if (j.ExpressionStatement.check(parent.value)) { | |
| const varDeclaration = j.variableDeclaration("const", [ | |
| j.variableDeclarator( | |
| j.objectPattern([createUserEventProperty()]), | |
| renderCallPath.value | |
| ) | |
| ]); | |
| j(parent).replaceWith(varDeclaration); | |
| } | |
| }); | |
| // Ensure userEvent is in return statements | |
| j(funcNode) | |
| .find(j.ReturnStatement) | |
| .filter(p => j.ObjectExpression.check(p.value.argument)) | |
| .forEach((returnPath) => { | |
| const hasUserEvent = returnPath.value.argument.properties | |
| .some(prop => prop.key?.name === "userEvent"); | |
| if (!hasUserEvent) { | |
| returnPath.value.argument.properties.push(createUserEventProperty()); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| function addUserEventRenderImport(path, renderFnName, variantName) { | |
| // Add UserEvent variant import and remove original render function | |
| const testUtilsImport = source | |
| .find(j.ImportDeclaration, { source: { value: "@shared/test/utils" }}); | |
| if (testUtilsImport.size() > 0) { | |
| testUtilsImport.forEach((utilsPath) => { | |
| const specs = utilsPath.value.specifiers; | |
| if (!specs.some(s => s.imported?.name === variantName)) { | |
| specs.push(j.importSpecifier(j.identifier(variantName))); | |
| } | |
| }); | |
| } else { | |
| const newImport = j.importDeclaration( | |
| [j.importSpecifier(j.identifier(variantName))], | |
| j.literal("@shared/test/utils") | |
| ); | |
| j(path).insertAfter(newImport); | |
| } | |
| // Remove original render function from imports | |
| path.value.specifiers = path.value.specifiers.filter( | |
| s => s.imported?.name !== renderFnName | |
| ); | |
| // Replace all calls to use the new variant | |
| source | |
| .find(j.CallExpression, { callee: { name: renderFnName }}) | |
| .forEach(p => p.value.callee.name = variantName); | |
| } | |
| function processImports() { | |
| // Handle @shared/test/utils imports | |
| source | |
| .find(j.ImportDeclaration, { source: { value: "@shared/test/utils" }}) | |
| .forEach((path) => { | |
| const specifiers = path.value.specifiers; | |
| RENDER_FUNCTIONS.forEach((renderFn) => { | |
| addUserEventVariantImport(specifiers, renderFn); | |
| }); | |
| }); | |
| // Handle @testing-library/react imports | |
| source | |
| .find(j.ImportDeclaration, { source: { value: "@testing-library/react" }}) | |
| .forEach((path) => { | |
| const specifiers = path.value.specifiers; | |
| const renderSpec = specifiers.find(spec => spec.imported?.name === "render"); | |
| const renderHookSpec = specifiers.find(spec => spec.imported?.name === "renderHook"); | |
| if (renderSpec) { | |
| addUserEventRenderImport(path, "render", "renderWithUserEvent"); | |
| } | |
| if (renderHookSpec) { | |
| addUserEventRenderImport(path, "renderHook", "renderHookWithUserEvent"); | |
| // Remove the import declaration if no specifiers remain | |
| if (path.value.specifiers.length === 0) { | |
| j(path).remove(); | |
| } | |
| } | |
| }); | |
| } | |
| function cleanupUnusedImports() { | |
| source | |
| .find(j.ImportDeclaration, { source: { value: "@shared/test/utils" }}) | |
| .forEach((path) => { | |
| const specifiers = path.value.specifiers; | |
| const updatedSpecifiers = specifiers.filter(spec => { | |
| const importedName = spec.imported?.name; | |
| // Keep non-render functions | |
| if (!RENDER_FUNCTIONS.includes(importedName) && !hasUserEventSupport(importedName)) { | |
| return true; | |
| } | |
| // Check if the function is still used | |
| return source | |
| .find(j.CallExpression, { callee: { name: importedName }}) | |
| .size() > 0; | |
| }); | |
| path.value.specifiers = updatedSpecifiers; | |
| }); | |
| } | |
| // ============================================================================ | |
| // FOREACH WITH ASYNC CALLBACK TRANSFORMATION | |
| // ============================================================================ | |
| function transformForEachAsyncToForOf() { | |
| // Transform array.forEach(async (item) => ...) to for (const item of array) { ... } | |
| // forEach doesn't wait for async callbacks, causing tests to fail | |
| source | |
| .find(j.CallExpression, { | |
| callee: { | |
| type: "MemberExpression", | |
| property: { name: "forEach" } | |
| } | |
| }) | |
| .forEach((path) => { | |
| const callback = path.value.arguments[0]; | |
| // Check if callback is async | |
| if (!callback || !isFunctionType(callback) || !callback.async) { | |
| return; | |
| } | |
| // Get the array and parameter name | |
| const array = path.value.callee.object; | |
| const param = callback.params[0]; | |
| if (!param) return; | |
| // Get the callback body | |
| let bodyStatements = []; | |
| if (j.BlockStatement.check(callback.body)) { | |
| bodyStatements = callback.body.body; | |
| } else { | |
| // Arrow function with expression body | |
| bodyStatements = [j.expressionStatement(callback.body)]; | |
| } | |
| // Create for...of loop | |
| const forOfLoop = j.forOfStatement( | |
| j.variableDeclaration("const", [ | |
| j.variableDeclarator(param, null) | |
| ]), | |
| array, | |
| j.blockStatement(bodyStatements) | |
| ); | |
| // Find the statement containing this forEach call and replace it | |
| let statementPath = path; | |
| while (statementPath && !j.ExpressionStatement.check(statementPath.value)) { | |
| statementPath = statementPath.parent; | |
| } | |
| if (statementPath) { | |
| j(statementPath).replaceWith(forOfLoop); | |
| } | |
| }); | |
| } | |
| // ============================================================================ | |
| // V14 BEST PRACTICES CLEANUP | |
| // ============================================================================ | |
| function removeNavigatorClipboardMocks() { | |
| // Remove navigator.clipboard mocks from beforeEach and similar hooks | |
| // because user-event v14 simulates clipboard realistically | |
| // Remove direct assignments: navigator.clipboard = ... | |
| source | |
| .find(j.AssignmentExpression, { | |
| left: { | |
| type: "MemberExpression", | |
| object: { name: "navigator" }, | |
| property: { name: "clipboard" } | |
| } | |
| }) | |
| .forEach((path) => { | |
| findAndRemoveEnclosingStatement(path); | |
| }); | |
| // Remove Object.assign(navigator, { clipboard: ... }) | |
| source | |
| .find(j.CallExpression, { | |
| callee: { | |
| type: "MemberExpression", | |
| object: { name: "Object" }, | |
| property: { name: "assign" } | |
| } | |
| }) | |
| .forEach((path) => { | |
| const args = path.value.arguments; | |
| if (args.length >= 2 && | |
| j.Identifier.check(args[0]) && | |
| args[0].name === "navigator") { | |
| // Check if second argument has clipboard property | |
| if (j.ObjectExpression.check(args[1])) { | |
| const hasClipboard = args[1].properties.some(prop => | |
| prop.key?.name === "clipboard" | |
| ); | |
| if (hasClipboard) { | |
| findAndRemoveEnclosingStatement(path); | |
| } | |
| } | |
| } | |
| }); | |
| // Remove Object.defineProperty(navigator, "clipboard", ...) | |
| source | |
| .find(j.CallExpression, { | |
| callee: { | |
| type: "MemberExpression", | |
| object: { name: "Object" }, | |
| property: { name: "defineProperty" } | |
| } | |
| }) | |
| .forEach((path) => { | |
| const args = path.value.arguments; | |
| if (args.length >= 2 && | |
| j.Identifier.check(args[0]) && | |
| args[0].name === "navigator" && | |
| j.Literal.check(args[1]) && | |
| args[1].value === "clipboard") { | |
| findAndRemoveEnclosingStatement(path); | |
| } | |
| }); | |
| // Remove mockClipboard.writeText.mockResolvedValue(...) calls | |
| source | |
| .find(j.CallExpression, { | |
| callee: { | |
| type: "MemberExpression", | |
| property: { name: "mockResolvedValue" } | |
| } | |
| }) | |
| .forEach((path) => { | |
| const callee = path.value.callee; | |
| if (j.MemberExpression.check(callee.object) && | |
| callee.object.property?.name === "writeText") { | |
| findAndRemoveEnclosingStatement(path); | |
| } | |
| }); | |
| } | |
| function transformClipboardExpectation(path, matcherCall, matcher, matcherName) { | |
| if (matcherName === "toHaveBeenCalledWith" || | |
| matcherName === "toHaveBeenLastCalledWith") { | |
| // Get the value being checked | |
| const valueToCheck = matcherCall.value.arguments[0]; | |
| if (!valueToCheck) return; | |
| // Replace with readText().toBe() | |
| path.value.arguments[0] = j.awaitExpression( | |
| j.callExpression( | |
| j.memberExpression( | |
| j.memberExpression( | |
| j.identifier("navigator"), | |
| j.identifier("clipboard") | |
| ), | |
| j.identifier("readText") | |
| ), | |
| [] | |
| ) | |
| ); | |
| // Change matcher to toBe | |
| matcher.property.name = "toBe"; | |
| } else if (matcherName === "toHaveBeenCalled" || | |
| matcherName === "toHaveBeenCalledTimes") { | |
| // Replace with readText().toBeDefined() | |
| path.value.arguments[0] = j.awaitExpression( | |
| j.callExpression( | |
| j.memberExpression( | |
| j.memberExpression( | |
| j.identifier("navigator"), | |
| j.identifier("clipboard") | |
| ), | |
| j.identifier("readText") | |
| ), | |
| [] | |
| ) | |
| ); | |
| // Change matcher to toBeDefined | |
| matcher.property.name = "toBeDefined"; | |
| // Remove any arguments from the matcher call | |
| matcherCall.value.arguments = []; | |
| } | |
| } | |
| function transformClipboardAssertions() { | |
| // Transform clipboard mock assertions to use real clipboard API | |
| // From: expect(navigator.clipboard.writeText).toHaveBeenCalledWith(value) | |
| // To: expect(await navigator.clipboard.readText()).toBe(value) | |
| // Find expect() calls and transform clipboard-related assertions | |
| source | |
| .find(j.CallExpression, { | |
| callee: { name: "expect" } | |
| }) | |
| .forEach((path) => { | |
| const expectArg = path.value.arguments[0]; | |
| if (!expectArg) return; | |
| let isClipboardAssertion = false; | |
| // Check if it's expect(navigator.clipboard.writeText) | |
| if (j.MemberExpression.check(expectArg) && | |
| j.MemberExpression.check(expectArg.object) && | |
| expectArg.object.object?.name === "navigator" && | |
| expectArg.object.property?.name === "clipboard" && | |
| expectArg.property?.name === "writeText") { | |
| isClipboardAssertion = true; | |
| } | |
| // Check for patterns like mockClipboard.writeText or writeTextMock | |
| if (!isClipboardAssertion) { | |
| if (j.MemberExpression.check(expectArg)) { | |
| // mockClipboard.writeText pattern | |
| if (expectArg.property?.name === "writeText") { | |
| isClipboardAssertion = true; | |
| } | |
| } else if (j.Identifier.check(expectArg)) { | |
| // writeTextMock pattern (variable name contains "writeText" or "clipboard") | |
| const varName = expectArg.name.toLowerCase(); | |
| if (varName.includes("writetext") || | |
| (varName.includes("clipboard") && varName.includes("mock"))) { | |
| isClipboardAssertion = true; | |
| } | |
| } | |
| } | |
| if (!isClipboardAssertion) return; | |
| // Find the chained matcher call | |
| let current = path.parent; | |
| while (current && !j.MemberExpression.check(current.value)) { | |
| current = current.parent; | |
| } | |
| if (!current) return; | |
| // Check if there's a chained call after this | |
| let matcherCall = current.parent; | |
| if (!j.CallExpression.check(matcherCall?.value)) return; | |
| const matcher = matcherCall.value.callee; | |
| if (!j.MemberExpression.check(matcher)) return; | |
| const matcherName = matcher.property?.name; | |
| // Transform the assertion using the helper | |
| transformClipboardExpectation(path, matcherCall, matcher, matcherName); | |
| }); | |
| } | |
| // ============================================================================ | |
| // MAIN EXECUTION | |
| // ============================================================================ | |
| // Process userEvent import | |
| processUserEventImport(); | |
| if (!hasUserEventImport || !userEventImportName) { | |
| return source.toSource(); | |
| } | |
| // Rename userEvent imports to consistent name | |
| if (userEventImportName !== "userEvent") { | |
| source | |
| .find(j.Identifier, { name: userEventImportName }) | |
| .filter(path => !isInImportDeclaration(path)) | |
| .forEach(path => path.value.name = "userEvent"); | |
| } | |
| // Execute transformations | |
| processImports(); | |
| transformUserEventCalls(); | |
| transformRenderFunctionCalls(); | |
| transformSpecificUserEventMethods(); | |
| handleCustomWrapperFunctions(); | |
| ensurePasteHasFocus(); | |
| transformForEachAsyncToForOf(); | |
| cleanupUnusedImports(); | |
| removeNavigatorClipboardMocks(); | |
| transformClipboardAssertions(); | |
| return source.toSource(); | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment