Skip to content

Instantly share code, notes, and snippets.

@eliOcs
Created November 22, 2025 08:52
Show Gist options
  • Select an option

  • Save eliOcs/61092b2f406f4ebf2144edeed271bb4a to your computer and use it in GitHub Desktop.

Select an option

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
/* 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