mirror of
https://github.com/github/awesome-copilot.git
synced 2026-04-14 04:05:58 +00:00
feat: Adds React 18 and 19 migration plugin (#1339)
- Adds React 18 and 19 migration orchestration plugins - Introduces comprehensive upgrade toolkits for migrating legacy React 16/17 and 18 codebases to React 18.3.1 and 19, respectively. Each plugin bundles specialized agents and skills for exhaustive audit, dependency management, class/component API migration, test suite transformation, and batching regression fixes. - The React 18 toolkit targets class-component-heavy apps, ensures safe lifecycle and context transitions, resolves dependency blockers, and fully automates test migrations including Enzyme removal. The React 19 toolkit addresses breaking changes such as removal of legacy APIs, defaultProps on function components, and forwardRef, while enforcing a gated, memory-resumable migration pipeline. - Both plugins update documentation, plugin registries, and skill references to support reliable, repeatable enterprise-scale React migrations.
This commit is contained in:
committed by
GitHub
parent
f4909cd581
commit
7f7b1b9b46
260
skills/react18-enzyme-to-rtl/references/async-patterns.md
Normal file
260
skills/react18-enzyme-to-rtl/references/async-patterns.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Async Test Patterns - Enzyme → RTL Migration
|
||||
|
||||
Reference for rewriting Enzyme async tests to React Testing Library with React 18 compatible patterns.
|
||||
|
||||
## The Core Problem
|
||||
|
||||
Enzyme's async tests typically used one of these approaches:
|
||||
|
||||
- `wrapper.update()` after state changes
|
||||
- `setTimeout` / `Promise.resolve()` to flush microtasks
|
||||
- `setImmediate` to flush async queues
|
||||
- Direct instance method calls followed by `wrapper.update()`
|
||||
|
||||
None of these work in RTL. RTL provides `waitFor`, `findBy*`, and `act` instead.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1 - wrapper.update() After State Change
|
||||
|
||||
Enzyme required `wrapper.update()` to force a re-render after async state changes.
|
||||
|
||||
```jsx
|
||||
// Enzyme:
|
||||
it('loads data', async () => {
|
||||
const wrapper = mount(<UserList />);
|
||||
await Promise.resolve(); // flush microtasks
|
||||
wrapper.update(); // force Enzyme to sync with DOM
|
||||
expect(wrapper.find('li')).toHaveLength(3);
|
||||
});
|
||||
```
|
||||
|
||||
```jsx
|
||||
// RTL - waitFor handles re-renders automatically:
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
it('loads data', async () => {
|
||||
render(<UserList />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2 - Async Action Triggered by User Interaction
|
||||
|
||||
```jsx
|
||||
// Enzyme:
|
||||
it('fetches user on button click', async () => {
|
||||
const wrapper = mount(<UserCard />);
|
||||
wrapper.find('button').simulate('click');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
expect(wrapper.find('.user-name').text()).toBe('John Doe');
|
||||
});
|
||||
```
|
||||
|
||||
```jsx
|
||||
// RTL:
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
it('fetches user on button click', async () => {
|
||||
render(<UserCard />);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /load/i }));
|
||||
// findBy* auto-waits up to 1000ms (configurable)
|
||||
expect(await screen.findByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3 - Loading State Assertion
|
||||
|
||||
```jsx
|
||||
// Enzyme - asserted loading state synchronously then final state after flush:
|
||||
it('shows loading then result', async () => {
|
||||
const wrapper = mount(<SearchResults query="react" />);
|
||||
expect(wrapper.find('.spinner').exists()).toBe(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
wrapper.update();
|
||||
expect(wrapper.find('.spinner').exists()).toBe(false);
|
||||
expect(wrapper.find('.result')).toHaveLength(5);
|
||||
});
|
||||
```
|
||||
|
||||
```jsx
|
||||
// RTL:
|
||||
it('shows loading then result', async () => {
|
||||
render(<SearchResults query="react" />);
|
||||
// Loading state - check it appears
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
// Or if loading is text:
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
|
||||
// Wait for results to appear (loading disappears, results show)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(5);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4 - Apollo MockedProvider Async Tests
|
||||
|
||||
```jsx
|
||||
// Enzyme with Apollo - used to flush with multiple ticks:
|
||||
it('renders user from query', async () => {
|
||||
const wrapper = mount(
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
<UserProfile id="1" />
|
||||
</MockedProvider>
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 0)); // flush Apollo queue
|
||||
wrapper.update();
|
||||
expect(wrapper.find('.username').text()).toBe('Alice');
|
||||
});
|
||||
```
|
||||
|
||||
```jsx
|
||||
// RTL with Apollo:
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
|
||||
it('renders user from query', async () => {
|
||||
render(
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
<UserProfile id="1" />
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
// Wait for Apollo to resolve the query
|
||||
expect(await screen.findByText('Alice')).toBeInTheDocument();
|
||||
// OR:
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Apollo loading state in RTL:**
|
||||
|
||||
```jsx
|
||||
it('shows loading then data', async () => {
|
||||
render(
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
<UserProfile id="1" />
|
||||
</MockedProvider>
|
||||
);
|
||||
// Apollo loading state - check immediately after render
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
// Then wait for data
|
||||
expect(await screen.findByText('Alice')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5 - Error State from Async Operation
|
||||
|
||||
```jsx
|
||||
// Enzyme:
|
||||
it('shows error on failed fetch', async () => {
|
||||
server.use(rest.get('/api/user', (req, res, ctx) => res(ctx.status(500))));
|
||||
const wrapper = mount(<UserCard />);
|
||||
wrapper.find('button').simulate('click');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
wrapper.update();
|
||||
expect(wrapper.find('.error-message').text()).toContain('Something went wrong');
|
||||
});
|
||||
```
|
||||
|
||||
```jsx
|
||||
// RTL:
|
||||
it('shows error on failed fetch', async () => {
|
||||
// (assuming MSW or jest.mock for fetch)
|
||||
render(<UserCard />);
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /load/i }));
|
||||
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6 - act() for Manual Async Control
|
||||
|
||||
When you need explicit control over async timing (rare with RTL but occasionally needed for class component tests):
|
||||
|
||||
```jsx
|
||||
// RTL with act() for fine-grained async control:
|
||||
import { act } from 'react';
|
||||
|
||||
it('handles sequential state updates', async () => {
|
||||
render(<MultiStepForm />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /next/i }));
|
||||
await Promise.resolve(); // flush microtask queue
|
||||
});
|
||||
|
||||
expect(screen.getByText('Step 2')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RTL Async Query Guide
|
||||
|
||||
| Method | Behavior | Use when |
|
||||
|---|---|---|
|
||||
| `getBy*` | Synchronous - throws if not found | Element is always present immediately |
|
||||
| `queryBy*` | Synchronous - returns null if not found | Checking element does NOT exist |
|
||||
| `findBy*` | Async - waits up to 1000ms, rejects if not found | Element appears asynchronously |
|
||||
| `getAllBy*` | Synchronous - throws if 0 found | Multiple elements always present |
|
||||
| `queryAllBy*` | Synchronous - returns [] if none found | Checking count or non-existence |
|
||||
| `findAllBy*` | Async - waits for elements to appear | Multiple elements appear asynchronously |
|
||||
| `waitFor(fn)` | Retries fn until no error or timeout | Custom assertion that needs polling |
|
||||
| `waitForElementToBeRemoved(el)` | Waits until element disappears | Loading states, removals |
|
||||
|
||||
**Default timeout:** 1000ms. Configure globally in `jest.config.js`:
|
||||
|
||||
```js
|
||||
// Increase timeout for slow CI environments
|
||||
// jest.config.js
|
||||
module.exports = {
|
||||
testEnvironmentOptions: {
|
||||
asyncUtilTimeout: 3000,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Migration Mistakes
|
||||
|
||||
```jsx
|
||||
// WRONG - mixing async query with sync assertion:
|
||||
const el = await screen.findByText('Result');
|
||||
// el is already resolved here - findBy returns the element, not a promise
|
||||
expect(await el).toBeInTheDocument(); // unnecessary second await
|
||||
|
||||
// CORRECT:
|
||||
const el = await screen.findByText('Result');
|
||||
expect(el).toBeInTheDocument();
|
||||
// OR simply:
|
||||
expect(await screen.findByText('Result')).toBeInTheDocument();
|
||||
```
|
||||
|
||||
```jsx
|
||||
// WRONG - using getBy* for elements that appear asynchronously:
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Loaded!')).toBeInTheDocument(); // throws before data loads
|
||||
|
||||
// CORRECT:
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByText('Loaded!')).toBeInTheDocument(); // waits
|
||||
```
|
||||
224
skills/react18-enzyme-to-rtl/references/enzyme-api-map.md
Normal file
224
skills/react18-enzyme-to-rtl/references/enzyme-api-map.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Enzyme API Map - Complete Before/After
|
||||
|
||||
## Setup / Configure
|
||||
|
||||
```jsx
|
||||
// Enzyme:
|
||||
import Enzyme from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
// RTL: delete this entirely - no setup needed
|
||||
// (jest.config.js setupFilesAfterFramework handles @testing-library/jest-dom matchers)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rendering
|
||||
|
||||
```jsx
|
||||
// Enzyme - shallow (no children rendered):
|
||||
import { shallow } from 'enzyme';
|
||||
const wrapper = shallow(<MyComponent prop="value" />);
|
||||
|
||||
// RTL - render (full render, children included):
|
||||
import { render } from '@testing-library/react';
|
||||
render(<MyComponent prop="value" />);
|
||||
// No wrapper variable needed - query via screen
|
||||
```
|
||||
|
||||
```jsx
|
||||
// Enzyme - mount (full render with DOM):
|
||||
import { mount } from 'enzyme';
|
||||
const wrapper = mount(<MyComponent />);
|
||||
|
||||
// RTL - same render() call handles this
|
||||
render(<MyComponent />);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Querying
|
||||
|
||||
```jsx
|
||||
// Enzyme - find by component type:
|
||||
const button = wrapper.find('button');
|
||||
const comp = wrapper.find(ChildComponent);
|
||||
const items = wrapper.find('.list-item');
|
||||
|
||||
// RTL - query by accessible attributes:
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /submit/i });
|
||||
const heading = screen.getByRole('heading', { name: /title/i });
|
||||
const input = screen.getByLabelText('Email');
|
||||
const items = screen.getAllByRole('listitem');
|
||||
```
|
||||
|
||||
```jsx
|
||||
// Enzyme - find by text:
|
||||
wrapper.find('.message').text() === 'Hello'
|
||||
|
||||
// RTL:
|
||||
screen.getByText('Hello')
|
||||
screen.getByText(/hello/i) // case-insensitive regex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Interaction
|
||||
|
||||
```jsx
|
||||
// Enzyme:
|
||||
wrapper.find('button').simulate('click');
|
||||
wrapper.find('input').simulate('change', { target: { value: 'hello' } });
|
||||
wrapper.find('form').simulate('submit');
|
||||
|
||||
// RTL - fireEvent (synchronous, low-level):
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } });
|
||||
fireEvent.submit(screen.getByRole('form'));
|
||||
|
||||
// RTL - userEvent (preferred, simulates real user behavior):
|
||||
import userEvent from '@testing-library/user-event';
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole('button'));
|
||||
await user.type(screen.getByRole('textbox'), 'hello');
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'option1');
|
||||
```
|
||||
|
||||
**Use `userEvent` for most interactions** - it fires the full event sequence (pointerdown, mousedown, focus, click, etc.) like a real user. Use `fireEvent` only when testing specific event properties.
|
||||
|
||||
---
|
||||
|
||||
## Assertions on Props and State
|
||||
|
||||
```jsx
|
||||
// Enzyme - prop assertion:
|
||||
expect(wrapper.find('input').prop('disabled')).toBe(true);
|
||||
expect(wrapper.prop('className')).toContain('active');
|
||||
|
||||
// RTL - assert on visible attributes:
|
||||
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
|
||||
expect(screen.getByRole('listitem')).toHaveClass('active');
|
||||
```
|
||||
|
||||
```jsx
|
||||
// Enzyme - state assertion (NO RTL EQUIVALENT):
|
||||
expect(wrapper.state('count')).toBe(3);
|
||||
expect(wrapper.state('loading')).toBe(false);
|
||||
|
||||
// RTL - assert on what the state renders:
|
||||
expect(screen.getByText('Count: 3')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
```
|
||||
|
||||
**Key principle:** Don't test state values - test what the state produces in the UI. If the component renders `<span>Count: {this.state.count}</span>`, test that span.
|
||||
|
||||
---
|
||||
|
||||
## Instance Methods
|
||||
|
||||
```jsx
|
||||
// Enzyme - direct method call (NO RTL EQUIVALENT):
|
||||
wrapper.instance().handleSubmit();
|
||||
wrapper.instance().loadData();
|
||||
|
||||
// RTL - trigger through the UI:
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /submit/i }));
|
||||
// Or if no UI trigger exists, reconsider: should internal methods be tested directly?
|
||||
// Usually the answer is no - test the rendered outcome instead.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Existence Checks
|
||||
|
||||
```jsx
|
||||
// Enzyme:
|
||||
expect(wrapper.find('.error')).toHaveLength(1);
|
||||
expect(wrapper.find('.error')).toHaveLength(0);
|
||||
expect(wrapper.exists('.error')).toBe(true);
|
||||
|
||||
// RTL:
|
||||
expect(screen.getByText('Error message')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Error message')).not.toBeInTheDocument();
|
||||
// queryBy returns null instead of throwing when not found
|
||||
// getBy throws if not found - use in positive assertions
|
||||
// findBy returns a promise - use for async elements
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multiple Elements
|
||||
|
||||
```jsx
|
||||
// Enzyme:
|
||||
expect(wrapper.find('li')).toHaveLength(5);
|
||||
wrapper.find('li').forEach((item, i) => {
|
||||
expect(item.text()).toBe(expectedItems[i]);
|
||||
});
|
||||
|
||||
// RTL:
|
||||
const items = screen.getAllByRole('listitem');
|
||||
expect(items).toHaveLength(5);
|
||||
items.forEach((item, i) => {
|
||||
expect(item).toHaveTextContent(expectedItems[i]);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Before/After: Complete Component Test
|
||||
|
||||
```jsx
|
||||
// Enzyme version:
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
describe('LoginForm', () => {
|
||||
it('submits with credentials', () => {
|
||||
const mockSubmit = jest.fn();
|
||||
const wrapper = shallow(<LoginForm onSubmit={mockSubmit} />);
|
||||
|
||||
wrapper.find('input[name="email"]').simulate('change', {
|
||||
target: { value: 'user@example.com' }
|
||||
});
|
||||
wrapper.find('input[name="password"]').simulate('change', {
|
||||
target: { value: 'password123' }
|
||||
});
|
||||
wrapper.find('button[type="submit"]').simulate('click');
|
||||
|
||||
expect(wrapper.state('loading')).toBe(true);
|
||||
expect(mockSubmit).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```jsx
|
||||
// RTL version:
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('LoginForm', () => {
|
||||
it('submits with credentials', async () => {
|
||||
const mockSubmit = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={mockSubmit} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123');
|
||||
await user.click(screen.getByRole('button', { name: /submit/i }));
|
||||
|
||||
// Assert on visible output - not on state
|
||||
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled(); // loading state
|
||||
expect(mockSubmit).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user