diff --git a/e2e/browser-mode/browserReact.test.ts b/e2e/browser-mode/browserReact.test.ts new file mode 100644 index 000000000..0af1b03d0 --- /dev/null +++ b/e2e/browser-mode/browserReact.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from '@rstest/core'; +import { runBrowserCli } from './utils'; + +describe('browser mode - @rstest/browser-react', () => { + it('should work with render, renderHook, cleanup, and @testing-library/dom', async () => { + const { expectExecSuccess, cli } = await runBrowserCli('browser-react'); + await expectExecSuccess(); + + // Verify all test files ran + expect(cli.stdout).toContain('render.test.tsx'); + expect(cli.stdout).toContain('renderHook.test.tsx'); + expect(cli.stdout).toContain('cleanup.test.tsx'); + expect(cli.stdout).toContain('testingLibraryDom.test.tsx'); + + // Verify tests passed + expect(cli.stdout).toMatch(/Tests.*passed/); + }); +}); diff --git a/e2e/browser-mode/fixtures/react/rstest.config.ts b/e2e/browser-mode/fixtures/browser-react/rstest.config.ts similarity index 88% rename from e2e/browser-mode/fixtures/react/rstest.config.ts rename to e2e/browser-mode/fixtures/browser-react/rstest.config.ts index a840612c1..a49fc78ff 100644 --- a/e2e/browser-mode/fixtures/react/rstest.config.ts +++ b/e2e/browser-mode/fixtures/browser-react/rstest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ browser: { enabled: true, headless: true, - port: BROWSER_PORTS.react, + port: BROWSER_PORTS['browser-react'], }, include: ['tests/**/*.test.tsx'], testTimeout: 30000, diff --git a/e2e/browser-mode/fixtures/testing-library/src/App.tsx b/e2e/browser-mode/fixtures/browser-react/src/App.tsx similarity index 73% rename from e2e/browser-mode/fixtures/testing-library/src/App.tsx rename to e2e/browser-mode/fixtures/browser-react/src/App.tsx index 3634cf191..cfe4b86ec 100644 --- a/e2e/browser-mode/fixtures/testing-library/src/App.tsx +++ b/e2e/browser-mode/fixtures/browser-react/src/App.tsx @@ -5,7 +5,7 @@ interface ButtonProps { children: ReactNode; } -export const Button = ({ onClick, children }: ButtonProps) => { +export const Button = ({ onClick, children }: ButtonProps): JSX.Element => { return ( ); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.textContent).toBe('Click me'); + expect(button?.className).toBe('btn'); + }); + + it('should render Counter with initial value', async () => { + const { container } = await render(); + + const countDisplay = container.querySelector('[data-testid="count"]'); + expect(countDisplay?.textContent).toBe('5'); + }); + + it('should handle unmount correctly', async () => { + const { container, unmount } = await render(); + + expect(container.querySelector('h1')).toBeTruthy(); + + await unmount(); + + // After unmount, container should be empty + expect(container.innerHTML).toBe(''); + }); + + it('should handle rerender correctly', async () => { + const { container, rerender } = await render( + , + ); + + expect( + container.querySelector('[data-testid="counter-title"]')?.textContent, + ).toBe('First'); + + await rerender(); + + expect( + container.querySelector('[data-testid="counter-title"]')?.textContent, + ).toBe('Second'); + // Note: initialCount only affects initial state, so count won't change on rerender + }); + + it('should return correct asFragment', async () => { + const { asFragment } = await render(); + + const fragment = asFragment(); + expect(fragment).toBeInstanceOf(DocumentFragment); + expect(fragment.querySelector('button')?.textContent).toBe('Test'); + }); +}); + +describe('@rstest/browser-react render options', () => { + it('should support custom container', async () => { + const customContainer = document.createElement('div'); + customContainer.id = 'custom-container'; + document.body.appendChild(customContainer); + + const { container } = await render(, { container: customContainer }); + + expect(container.id).toBe('custom-container'); + expect(container.querySelector('h1')).toBeTruthy(); + + // Cleanup + document.body.removeChild(customContainer); + }); + + it('should support wrapper option for providers', async () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ); + + const { baseElement } = await render(, { + wrapper: Wrapper, + }); + + expect(baseElement.querySelector('[data-testid="wrapper"]')).toBeTruthy(); + expect( + baseElement.querySelector('[data-testid="wrapper"] button'), + ).toBeTruthy(); + }); +}); diff --git a/e2e/browser-mode/fixtures/browser-react/tests/renderHook.test.tsx b/e2e/browser-mode/fixtures/browser-react/tests/renderHook.test.tsx new file mode 100644 index 000000000..179fcf185 --- /dev/null +++ b/e2e/browser-mode/fixtures/browser-react/tests/renderHook.test.tsx @@ -0,0 +1,70 @@ +import { renderHook } from '@rstest/browser-react'; +import { describe, expect, it } from '@rstest/core'; +import { useCounter } from '../src/useCounter'; + +describe('@rstest/browser-react renderHook', () => { + it('should render hook with initial value', async () => { + const { result } = await renderHook(() => useCounter(5)); + + expect(result.current.count).toBe(5); + }); + + it('should handle hook state updates with act', async () => { + const { result, act } = await renderHook(() => useCounter(0)); + + expect(result.current.count).toBe(0); + + await act(() => { + result.current.increment(); + }); + + expect(result.current.count).toBe(1); + + await act(() => { + result.current.increment(); + result.current.increment(); + }); + + expect(result.current.count).toBe(3); + }); + + it('should handle rerender with new props', async () => { + const { result, rerender } = await renderHook( + (props?: { initial: number }) => useCounter(props?.initial ?? 0), + { initialProps: { initial: 10 } }, + ); + + expect(result.current.count).toBe(10); + + // Rerender with new initial value + // Note: useState only uses initial value on first render + await rerender({ initial: 20 }); + + // Count should still be 10 since useState doesn't reinitialize + expect(result.current.count).toBe(10); + }); + + it('should handle unmount', async () => { + const { result, unmount } = await renderHook(() => useCounter(0)); + + expect(result.current.count).toBe(0); + + await unmount(); + + // After unmount, we can still access the last value + expect(result.current.count).toBe(0); + }); + + it('should support wrapper option', async () => { + let contextValue = 'default'; + + const Wrapper = ({ children }: { children: React.ReactNode }) => { + contextValue = 'wrapped'; + return <>{children}; + }; + + await renderHook(() => useCounter(0), { wrapper: Wrapper }); + + expect(contextValue).toBe('wrapped'); + }); +}); diff --git a/e2e/browser-mode/fixtures/browser-react/tests/testingLibraryDom.test.tsx b/e2e/browser-mode/fixtures/browser-react/tests/testingLibraryDom.test.tsx new file mode 100644 index 000000000..cbec9cb5e --- /dev/null +++ b/e2e/browser-mode/fixtures/browser-react/tests/testingLibraryDom.test.tsx @@ -0,0 +1,85 @@ +/** + * This test demonstrates using @rstest/browser-react with @testing-library/dom + * for DOM queries. This is the recommended approach for users who want + * testing-library-style queries without the full @testing-library/react dependency. + */ +import { act, render } from '@rstest/browser-react'; +import { describe, expect, it } from '@rstest/core'; +import { + getByRole, + getByTestId, + getByText, + queryByTestId, +} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { App, Button, Counter } from '../src/App'; + +describe('@rstest/browser-react + @testing-library/dom', () => { + it('should work with getByRole query', async () => { + const { container } = await render(); + + const heading = getByRole(container, 'heading', { level: 1 }); + expect(heading.textContent).toBe('React Browser Test'); + }); + + it('should work with getByTestId query', async () => { + const { container } = await render(); + + const description = getByTestId(container, 'description'); + expect(description.textContent).toBe('Testing @rstest/browser-react'); + }); + + it('should work with getByText query', async () => { + const { container } = await render(); + + const button = getByText(container, 'Click me'); + expect(button.tagName).toBe('BUTTON'); + expect(button.className).toBe('btn'); + }); + + it('should work with queryByTestId for non-existent elements', async () => { + const { container } = await render(); + + const nonExistent = queryByTestId(container, 'non-existent'); + expect(nonExistent).toBeNull(); + }); + + it('should handle Counter interactions with userEvent', async () => { + const user = userEvent.setup(); + const { container } = await render(); + + const countDisplay = getByTestId(container, 'count'); + expect(countDisplay.textContent).toBe('0'); + + const incrementBtn = getByRole(container, 'button', { name: /increment/i }); + await act(() => user.click(incrementBtn)); + + expect(countDisplay.textContent).toBe('1'); + }); + + it('should handle multiple interactions', async () => { + const user = userEvent.setup(); + const { container } = await render(); + + const countDisplay = getByTestId(container, 'count'); + const incrementBtn = getByRole(container, 'button', { name: /increment/i }); + const decrementBtn = getByRole(container, 'button', { name: /decrement/i }); + + // Increment twice + await act(() => user.click(incrementBtn)); + await act(() => user.click(incrementBtn)); + expect(countDisplay.textContent).toBe('7'); + + // Decrement once + await act(() => user.click(decrementBtn)); + expect(countDisplay.textContent).toBe('6'); + }); + + it('should work with baseElement for queries', async () => { + const { baseElement } = await render(); + + // baseElement is document.body by default + const title = getByTestId(baseElement, 'counter-title'); + expect(title.textContent).toBe('Test Counter'); + }); +}); diff --git a/e2e/browser-mode/fixtures/ports.ts b/e2e/browser-mode/fixtures/ports.ts index f471c1d07..645c40945 100644 --- a/e2e/browser-mode/fixtures/ports.ts +++ b/e2e/browser-mode/fixtures/ports.ts @@ -1,13 +1,12 @@ export const BROWSER_PORTS = { basic: 5180, + 'browser-react': 5202, config: 5184, console: 5192, error: 5182, isolation: 5188, - react: 5194, 'setup-files': 5190, snapshot: 5196, - 'testing-library': 5200, watch: 5186, webkit: 5198, } as const; diff --git a/e2e/browser-mode/fixtures/react/src/App.tsx b/e2e/browser-mode/fixtures/react/src/App.tsx deleted file mode 100644 index d849d3978..000000000 --- a/e2e/browser-mode/fixtures/react/src/App.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { type ReactNode, useState } from 'react'; - -interface ButtonProps { - onClick?: () => void; - children: ReactNode; -} - -export const Button = ({ onClick, children }: ButtonProps) => { - return ( - - ); -}; - -interface CounterProps { - initialCount?: number; - title?: string; -} - -export const Counter = ({ initialCount = 0, title }: CounterProps) => { - const [count, setCount] = useState(initialCount); - - return ( -
- {title &&

{title}

} - {count} - - -
- ); -}; - -export const App = () => { - return ( -
-

React Browser Test

-

Testing React JSX rendering in browser

- -
- ); -}; diff --git a/e2e/browser-mode/fixtures/react/tests/helper.ts b/e2e/browser-mode/fixtures/react/tests/helper.ts deleted file mode 100644 index 2242def7b..000000000 --- a/e2e/browser-mode/fixtures/react/tests/helper.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; - -// Enable React act() in browser environment -// @ts-expect-error React requires this global to be set for act() to work -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -/** - * Creates a clean container element for React rendering. - * Cleans up any previous container before creating a new one. - */ -export const createContainer = (): HTMLDivElement => { - // Clean up previous test's container - cleanupContainer(); - - container = document.createElement('div'); - container.id = 'test-root'; - document.body.appendChild(container); - root = createRoot(container); - return container; -}; - -/** - * Cleans up the container and unmounts the React root. - */ -export const cleanupContainer = (): void => { - if (root) { - const currentRoot = root; - root = null; - act(() => { - currentRoot.unmount(); - }); - } - if (container?.parentNode) { - container.parentNode.removeChild(container); - } - container = null; -}; - -/** - * Renders a React element and waits for the render to complete. - */ -export const render = async (element: React.ReactElement): Promise => { - if (!root) { - throw new Error('Container not created. Call createContainer() first.'); - } - await act(async () => { - root!.render(element); - }); -}; - -/** - * Simulates a click event and waits for React state updates to complete. - */ -export const click = async ( - element: Element | null | undefined, -): Promise => { - if (!element) { - throw new Error('Cannot click on null or undefined element'); - } - await act(async () => { - element.click(); - }); -}; - -/** - * Gets the current container element. - */ -export const getContainer = (): HTMLDivElement => { - if (!container) { - throw new Error('Container not created. Call createContainer() first.'); - } - return container; -}; diff --git a/e2e/browser-mode/fixtures/react/tests/react.test.tsx b/e2e/browser-mode/fixtures/react/tests/react.test.tsx deleted file mode 100644 index 4d6ca191c..000000000 --- a/e2e/browser-mode/fixtures/react/tests/react.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from '@rstest/core'; -import { App, Button, Counter } from '../src/App'; -import { click, createContainer, getContainer, render } from './helper'; - -describe('React JSX rendering', () => { - it('should render App component correctly', async () => { - createContainer(); - await render(); - - const container = getContainer(); - expect(container.querySelector('h1')?.textContent).toBe( - 'React Browser Test', - ); - expect(container.querySelector('[data-testid="description"]')).toBeTruthy(); - expect( - container.querySelector('[data-testid="description"]')?.textContent, - ).toBe('Testing React JSX rendering in browser'); - }); - - it('should render Button with children', async () => { - createContainer(); - await render(); - - const container = getContainer(); - const button = container.querySelector('button'); - expect(button).toBeTruthy(); - expect(button?.textContent).toBe('Click me'); - expect(button?.className).toBe('btn'); - }); - - it('should render Counter with initial value', async () => { - createContainer(); - await render( - , - ); - - const container = getContainer(); - const countDisplay = container.querySelector('[data-testid="count"]'); - expect(countDisplay?.textContent).toBe('5'); - }); - - it('should handle Counter increment interaction', async () => { - createContainer(); - await render( - , - ); - - const container = getContainer(); - const countDisplay = container.querySelector('[data-testid="count"]'); - expect(countDisplay?.textContent).toBe('0'); - - const buttons = container.querySelectorAll('button'); - const incrementBtn = Array.from(buttons).find((btn) => - btn.textContent?.includes('Increment'), - ); - expect(incrementBtn).toBeTruthy(); - - await click(incrementBtn); - expect(countDisplay?.textContent).toBe('1'); - }); - - it('should handle Counter decrement interaction', async () => { - createContainer(); - await render( - , - ); - - const container = getContainer(); - const countDisplay = container.querySelector('[data-testid="count"]'); - expect(countDisplay?.textContent).toBe('10'); - - const buttons = container.querySelectorAll('button'); - const decrementBtn = Array.from(buttons).find((btn) => - btn.textContent?.includes('Decrement'), - ); - expect(decrementBtn).toBeTruthy(); - - await click(decrementBtn); - expect(countDisplay?.textContent).toBe('9'); - }); - - it('should handle multiple Counter interactions', async () => { - createContainer(); - await render( - , - ); - - const container = getContainer(); - const countDisplay = container.querySelector('[data-testid="count"]'); - const buttons = container.querySelectorAll('button'); - const incrementBtn = Array.from(buttons).find((btn) => - btn.textContent?.includes('Increment'), - ); - - await click(incrementBtn); - await click(incrementBtn); - await click(incrementBtn); - - expect(countDisplay?.textContent).toBe('3'); - }); -}); diff --git a/e2e/browser-mode/fixtures/react/tsconfig.json b/e2e/browser-mode/fixtures/react/tsconfig.json deleted file mode 100644 index f76bad90e..000000000 --- a/e2e/browser-mode/fixtures/react/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["DOM", "ES2020"], - "module": "ESNext", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "isolatedModules": true, - "resolveJsonModule": true, - "moduleResolution": "bundler", - "useDefineForClassFields": true - }, - "include": ["src", "tests"] -} diff --git a/e2e/browser-mode/fixtures/testing-library/rstest.config.ts b/e2e/browser-mode/fixtures/testing-library/rstest.config.ts deleted file mode 100644 index 64840a009..000000000 --- a/e2e/browser-mode/fixtures/testing-library/rstest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { pluginReact } from '@rsbuild/plugin-react'; -import { defineConfig } from '@rstest/core'; -import { BROWSER_PORTS } from '../ports'; - -export default defineConfig({ - plugins: [pluginReact()], - browser: { - enabled: true, - headless: true, - port: BROWSER_PORTS['testing-library'], - }, - include: ['tests/**/*.test.tsx'], - testTimeout: 30000, -}); diff --git a/e2e/browser-mode/fixtures/testing-library/tests/testingLibrary.test.tsx b/e2e/browser-mode/fixtures/testing-library/tests/testingLibrary.test.tsx deleted file mode 100644 index 1b6371bae..000000000 --- a/e2e/browser-mode/fixtures/testing-library/tests/testingLibrary.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, describe, expect, it } from '@rstest/core'; -import { cleanup, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { App, Button, Counter } from '../src/App'; - -// Cleanup after each test to avoid DOM pollution -afterEach(() => { - cleanup(); -}); - -describe('@testing-library/react in browser mode', () => { - it('should render App component correctly', () => { - render(); - - const heading = screen.getByRole('heading', { level: 1 }); - expect(heading.textContent).toBe('React Browser Test'); - - const description = screen.getByTestId('description'); - expect(description.textContent).toBe( - 'Testing @testing-library/react in browser mode', - ); - }); - - it('should render Button with children', () => { - render(); - - const button = screen.getByRole('button'); - expect(button).toBeTruthy(); - expect(button.textContent).toBe('Click me'); - expect(button.className).toBe('btn'); - }); - - it('should render Counter with initial value', () => { - render(); - - expect(screen.getByTestId('count').textContent).toBe('5'); - expect(screen.getByTestId('counter-title').textContent).toBe( - 'Test Counter', - ); - }); - - it('should handle Counter increment interaction', async () => { - const user = userEvent.setup(); - render(); - - expect(screen.getByTestId('count').textContent).toBe('0'); - - const incrementBtn = screen.getByRole('button', { name: /increment/i }); - await user.click(incrementBtn); - - expect(screen.getByTestId('count').textContent).toBe('1'); - }); - - it('should handle Counter decrement interaction', async () => { - const user = userEvent.setup(); - render(); - - expect(screen.getByTestId('count').textContent).toBe('10'); - - const decrementBtn = screen.getByRole('button', { name: /decrement/i }); - await user.click(decrementBtn); - - expect(screen.getByTestId('count').textContent).toBe('9'); - }); - - it('should handle multiple Counter interactions', async () => { - const user = userEvent.setup(); - render(); - - const incrementBtn = screen.getByRole('button', { name: /increment/i }); - - await user.click(incrementBtn); - await user.click(incrementBtn); - await user.click(incrementBtn); - - expect(screen.getByTestId('count').textContent).toBe('3'); - }); - - it('should support findBy queries for async elements', async () => { - render(); - - // findBy* returns a promise and waits for the element - const heading = await screen.findByRole('heading', { level: 1 }); - expect(heading.textContent).toBe('React Browser Test'); - }); - - it('should support queryBy queries that return null', () => { - render(); - - // queryBy* returns null if element is not found (instead of throwing) - const nonExistent = screen.queryByTestId('non-existent'); - expect(nonExistent).toBeNull(); - }); - - it('should support getAllBy queries for multiple elements', () => { - render(); - - // getAllBy* returns all matching elements - const buttons = screen.getAllByRole('button'); - expect(buttons).toHaveLength(2); // Increment and Decrement - }); -}); diff --git a/e2e/browser-mode/fixtures/testing-library/tsconfig.json b/e2e/browser-mode/fixtures/testing-library/tsconfig.json deleted file mode 100644 index f76bad90e..000000000 --- a/e2e/browser-mode/fixtures/testing-library/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["DOM", "ES2020"], - "module": "ESNext", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "isolatedModules": true, - "resolveJsonModule": true, - "moduleResolution": "bundler", - "useDefineForClassFields": true - }, - "include": ["src", "tests"] -} diff --git a/e2e/browser-mode/react.test.ts b/e2e/browser-mode/react.test.ts deleted file mode 100644 index 98e99260e..000000000 --- a/e2e/browser-mode/react.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from '@rstest/core'; -import { runBrowserCli } from './utils'; - -describe('browser mode - react', () => { - it('should run React JSX rendering tests correctly', async () => { - const { expectExecSuccess, cli } = await runBrowserCli('react'); - - await expectExecSuccess(); - expect(cli.stdout).toMatch(/Tests.*passed/); - }); - - it('should pass all React component tests', async () => { - const { expectExecSuccess, cli } = await runBrowserCli('react'); - - await expectExecSuccess(); - expect(cli.stdout).toMatch(/Test Files.*passed/); - }); - - it('should exit with code 0 when React tests pass', async () => { - const { cli } = await runBrowserCli('react'); - - await cli.exec; - expect(cli.exec.exitCode).toBe(0); - }); -}); diff --git a/e2e/browser-mode/testingLibrary.test.ts b/e2e/browser-mode/testingLibrary.test.ts deleted file mode 100644 index 768cc2ad8..000000000 --- a/e2e/browser-mode/testingLibrary.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from '@rstest/core'; -import { runBrowserCli } from './utils'; - -describe('browser mode - @testing-library/react', () => { - it('should run @testing-library/react tests in browser mode', async () => { - const { expectExecSuccess, cli } = await runBrowserCli('testing-library'); - - await expectExecSuccess(); - expect(cli.stdout).toMatch(/Tests.*passed/); - }); - - it('should pass all @testing-library/react tests', async () => { - const { expectExecSuccess, cli } = await runBrowserCli('testing-library'); - - await expectExecSuccess(); - expect(cli.stdout).toMatch(/Test Files.*passed/); - }); - - it('should run all 9 tests successfully', async () => { - const { expectExecSuccess, cli } = await runBrowserCli('testing-library'); - - await expectExecSuccess(); - // Verify all 9 tests passed - expect(cli.stdout).toMatch(/Tests\s+9 passed/); - }); - - it('should exit with code 0 when all tests pass', async () => { - const { cli } = await runBrowserCli('testing-library'); - - await cli.exec; - expect(cli.exec.exitCode).toBe(0); - }); -}); diff --git a/e2e/package.json b/e2e/package.json index 576485d67..c1eda1ebb 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -13,8 +13,10 @@ "@rslib/core": "0.19.0", "@rsbuild/plugin-react": "^1.4.2", "@rstest/browser": "workspace:*", + "@rstest/browser-react": "workspace:*", "@rstest/core": "workspace:*", "@rstest/tsconfig": "workspace:*", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", diff --git a/packages/browser-react/AGENTS.md b/packages/browser-react/AGENTS.md new file mode 100644 index 000000000..a6ced6de4 --- /dev/null +++ b/packages/browser-react/AGENTS.md @@ -0,0 +1,114 @@ +# @rstest/browser-react + +React component testing support for Rstest browser mode. Provides `render`, `renderHook`, `cleanup`, and `act` utilities for testing React components in a real browser environment. + +## Do + +- Support React 17, 18, and 19 — ensure code works across all versions +- Use `act()` for all render/unmount/state update operations +- Keep this package focused on React rendering lifecycle only +- Use JSDoc comments for public API functions +- Default to small, focused diffs + +## Don't + +- Don't add DOM query utilities (users should use `@testing-library/dom`) +- Don't add dependencies beyond React peer deps +- Don't break compatibility with older React versions without discussion +- Don't use React version-specific APIs without fallbacks + +## Commands + +```bash +# Build +pnpm --filter @rstest/browser-react build +pnpm --filter @rstest/browser-react dev # Watch mode + +# Type check single file +pnpm tsc --noEmit src/pure.tsx + +# Format single file +pnpm prettier --write src/pure.tsx + +# Run tests +pnpm --filter @rstest/browser-react test +``` + +Note: Prefer file-scoped commands for faster feedback during development. + +## Project structure + +- `src/index.ts` — Default entry with auto-cleanup via `beforeEach` +- `src/pure.tsx` — Core implementation (render, renderHook, cleanup, configure) +- `src/act.ts` — React `act()` wrapper with `IS_REACT_ACT_ENVIRONMENT` management + +## Good and bad examples + +### Handling React version differences + +Good — use feature detection with fallback: + +```typescript +// src/act.ts +const _act = (React as Record).act as + | ((callback: () => unknown) => Promise) + | undefined; + +export const act: ActFunction = + typeof _act !== 'function' + ? async (callback) => { + await callback(); + } // React 17 fallback + : async (callback) => { + await _act(callback); + }; // React 18+ +``` + +Bad — assume specific React version: + +```typescript +import { act } from 'react'; // Breaks React 17 +``` + +## Exports + +### Default entry (`@rstest/browser-react`) + +- `render` — Render a React component, returns `RenderResult` +- `renderHook` — Test React hooks, returns `RenderHookResult` +- `cleanup` — Cleanup mounted components +- `act` — Wrap state updates + +Auto-registers `beforeEach(cleanup)` for automatic cleanup. + +### Pure entry (`@rstest/browser-react/pure`) + +Same exports plus: + +- `configure` — Configure render behavior (e.g., `reactStrictMode`) + +No auto-cleanup — user must call `cleanup()` manually. + +## Key types + +```typescript +interface RenderResult { + container: HTMLElement; + baseElement: HTMLElement; + unmount: () => Promise; + rerender: (ui: ReactNode) => Promise; + asFragment: () => DocumentFragment; +} + +interface RenderOptions { + container?: HTMLElement; + baseElement?: HTMLElement; + wrapper?: JSXElementConstructor<{ children: ReactNode }>; +} +``` + +## When stuck + +- Ask clarifying questions or propose a plan +- Check React version compatibility before making changes +- Reference `@testing-library/react` for API design inspiration diff --git a/packages/browser-react/README.md b/packages/browser-react/README.md new file mode 100644 index 000000000..ce939c149 --- /dev/null +++ b/packages/browser-react/README.md @@ -0,0 +1,166 @@ +# @rstest/browser-react + +React component testing support for [Rstest](https://rstest.dev) browser mode. + +## Installation + +```bash +npm install @rstest/browser-react +# or +pnpm add @rstest/browser-react +``` + +## Usage + +### Basic component testing + +```tsx +import { render, cleanup } from '@rstest/browser-react'; +import { screen } from '@testing-library/dom'; +import { expect, test } from '@rstest/core'; + +test('renders button', async () => { + await render(); + expect(screen.getByRole('button')).toBeTruthy(); +}); +``` + +### Testing with wrapper (providers/context) + +```tsx +import { render } from '@rstest/browser-react'; + +const Wrapper = ({ children }) => ( + {children} +); + +test('renders with theme', async () => { + await render(, { wrapper: Wrapper }); +}); +``` + +### Testing hooks + +```tsx +import { renderHook } from '@rstest/browser-react'; +import { useState } from 'react'; + +test('useState hook', async () => { + const { result, rerender } = await renderHook(() => useState(0)); + + expect(result.current[0]).toBe(0); + + await result.current[1](1); + await rerender(); + + expect(result.current[0]).toBe(1); +}); +``` + +### Manual cleanup with pure entry + +```tsx +import { render, cleanup } from '@rstest/browser-react/pure'; +import { afterEach } from '@rstest/core'; + +// No auto-cleanup, manage it yourself +afterEach(async () => { + await cleanup(); +}); +``` + +### Enabling React strict mode + +```tsx +import { configure } from '@rstest/browser-react/pure'; + +configure({ reactStrictMode: true }); +``` + +## API + +### `render(ui, options?)` + +Renders a React element into the DOM. + +**Returns:** `Promise` + +```typescript +interface RenderResult { + container: HTMLElement; + baseElement: HTMLElement; + unmount: () => Promise; + rerender: (ui: ReactNode) => Promise; + asFragment: () => DocumentFragment; +} + +interface RenderOptions { + container?: HTMLElement; + baseElement?: HTMLElement; + wrapper?: JSXElementConstructor<{ children: ReactNode }>; +} +``` + +### `renderHook(callback, options?)` + +Renders a custom React hook for testing. + +**Returns:** `Promise` + +```typescript +interface RenderHookResult { + result: { current: Result }; + rerender: (props?: Props) => Promise; + unmount: () => Promise; + act: (callback: () => unknown) => Promise; +} +``` + +### `cleanup()` + +Unmounts all mounted components. Called automatically before each test when using the default entry. + +### `act(callback)` + +Wraps a callback in React's `act()` for proper state updates. + +### `configure(options)` (pure entry only) + +Configure render behavior. + +```typescript +interface RenderConfiguration { + reactStrictMode: boolean; +} +``` + +## Using with @testing-library/dom + +This package provides React rendering utilities. For DOM queries (`getByRole`, `getByText`, etc.), install `@testing-library/dom`: + +```bash +npm install @testing-library/dom +``` + +```tsx +import { render } from '@rstest/browser-react'; +import { screen, fireEvent } from '@testing-library/dom'; + +test('button click', async () => { + await render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.getByText('1')).toBeTruthy(); +}); +``` + +## Compatibility + +- React 17, 18, and 19 +- Node.js >= 18.12.0 + +## License + +MIT diff --git a/packages/browser-react/package.json b/packages/browser-react/package.json new file mode 100644 index 000000000..b346fe025 --- /dev/null +++ b/packages/browser-react/package.json @@ -0,0 +1,68 @@ +{ + "name": "@rstest/browser-react", + "version": "0.7.8", + "type": "module", + "description": "React component testing support for Rstest browser mode", + "license": "MIT", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./pure": { + "types": "./dist/pure.d.ts", + "default": "./dist/pure.js" + }, + "./package.json": { + "default": "./package.json" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rslib build", + "dev": "cross-env SOURCEMAP=true rslib build --watch", + "typecheck": "tsc --noEmit", + "test": "rstest" + }, + "peerDependencies": { + "@rstest/core": "workspace:^", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@rslib/core": "^0.19.0", + "@rstest/core": "workspace:*", + "@rstest/tsconfig": "workspace:*", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "typescript": "^5.9.3" + }, + "keywords": [ + "rstest", + "react", + "testing", + "browser", + "component" + ], + "bugs": { + "url": "https://github.com/web-infra-dev/rstest/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/web-infra-dev/rstest", + "directory": "packages/browser-react" + }, + "engines": { + "node": ">=18.12.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/browser-react/rslib.config.ts b/packages/browser-react/rslib.config.ts new file mode 100644 index 000000000..f9ce0777e --- /dev/null +++ b/packages/browser-react/rslib.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from '@rslib/core'; + +export default defineConfig({ + lib: [ + { + format: 'esm', + syntax: ['chrome 100'], + dts: true, + output: { + target: 'web', + sourceMap: process.env.SOURCEMAP === 'true', + externals: { + // Keep react and react-dom as external (provided by user's project) + react: 'react', + 'react-dom': 'react-dom', + 'react-dom/client': 'react-dom/client', + 'react/jsx-runtime': 'react/jsx-runtime', + 'react/jsx-dev-runtime': 'react/jsx-dev-runtime', + // Keep @rstest/core as external + '@rstest/core': '@rstest/core', + }, + }, + }, + ], + source: { + entry: { + index: './src/index.ts', + pure: './src/pure.tsx', + }, + }, +}); diff --git a/packages/browser-react/src/act.ts b/packages/browser-react/src/act.ts new file mode 100644 index 000000000..567f25213 --- /dev/null +++ b/packages/browser-react/src/act.ts @@ -0,0 +1,38 @@ +import * as React from 'react'; + +let activeActs = 0; + +function setActEnvironment(value: boolean | undefined): void { + (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = value; +} + +function updateActEnvironment(): void { + setActEnvironment(activeActs > 0); +} + +// React 18+ exports act, React 17 has unstable_act +const _act = (React as Record).act as + | ((callback: () => unknown) => Promise) + | undefined; + +type ActFunction = (callback: () => unknown) => Promise; + +/** + * Wraps a callback in React's act() for proper state updates. + * Automatically manages IS_REACT_ACT_ENVIRONMENT. + */ +export const act: ActFunction = + typeof _act !== 'function' + ? async (callback: () => unknown): Promise => { + await callback(); + } + : async (callback: () => unknown): Promise => { + activeActs++; + updateActEnvironment(); + try { + await _act(callback); + } finally { + activeActs--; + updateActEnvironment(); + } + }; diff --git a/packages/browser-react/src/index.ts b/packages/browser-react/src/index.ts new file mode 100644 index 000000000..aaf5c85c6 --- /dev/null +++ b/packages/browser-react/src/index.ts @@ -0,0 +1,16 @@ +import { beforeEach } from '@rstest/core'; +import { act, cleanup, render, renderHook } from './pure'; + +// Auto-cleanup before each test +// (before, not after, so we can inspect the DOM after a test failure) +beforeEach(async () => { + await cleanup(); +}); + +export { render, renderHook, cleanup, act }; +export type { + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from './pure'; diff --git a/packages/browser-react/src/pure.tsx b/packages/browser-react/src/pure.tsx new file mode 100644 index 000000000..1ff2bc768 --- /dev/null +++ b/packages/browser-react/src/pure.tsx @@ -0,0 +1,206 @@ +import type { JSXElementConstructor, ReactNode } from 'react'; +import * as React from 'react'; +import * as ReactDOMClient from 'react-dom/client'; +import { act } from './act'; + +// ===== Types ===== + +export interface RenderResult { + /** The container element the component is rendered into */ + container: HTMLElement; + /** The base element for queries, defaults to document.body */ + baseElement: HTMLElement; + /** Unmount the rendered component */ + unmount: () => Promise; + /** Re-render the component with new props/element */ + rerender: (ui: ReactNode) => Promise; + /** Returns the rendered UI as a DocumentFragment (useful for snapshots) */ + asFragment: () => DocumentFragment; +} + +export interface RenderOptions { + /** Custom container element to render into */ + container?: HTMLElement; + /** Base element for queries, defaults to document.body */ + baseElement?: HTMLElement; + /** Wrapper component (e.g., for providers/context) */ + wrapper?: JSXElementConstructor<{ children: ReactNode }>; +} + +export interface RenderHookOptions extends RenderOptions { + /** Initial props passed to the hook */ + initialProps?: Props; +} + +export interface RenderHookResult { + /** Reference to the latest hook return value */ + result: { current: Result }; + /** Re-render the hook with new props */ + rerender: (props?: Props) => Promise; + /** Unmount the hook */ + unmount: () => Promise; + /** Access to act for manual state updates */ + act: (callback: () => unknown) => Promise; +} + +export interface RenderConfiguration { + /** Enable React StrictMode wrapper */ + reactStrictMode: boolean; +} + +// ===== Internal State ===== + +interface ReactRoot { + render: (element: ReactNode) => void; + unmount: () => void; +} + +interface MountedRootEntry { + container: HTMLElement; + root: ReactRoot; +} + +const mountedContainers = new Set(); +const mountedRootEntries: MountedRootEntry[] = []; + +const config: RenderConfiguration = { + reactStrictMode: false, +}; + +// ===== Internal Helpers ===== + +function createRoot(container: HTMLElement): ReactRoot { + const root = ReactDOMClient.createRoot(container); + return { + render: (element: ReactNode) => root.render(element), + unmount: () => root.unmount(), + }; +} + +function strictModeIfNeeded(ui: ReactNode): ReactNode { + return config.reactStrictMode + ? React.createElement(React.StrictMode, null, ui) + : ui; +} + +function wrapUiIfNeeded( + ui: ReactNode, + wrapper?: JSXElementConstructor<{ children: ReactNode }>, +): ReactNode { + return wrapper ? React.createElement(wrapper, null, ui) : ui; +} + +// ===== Public API ===== + +/** + * Render a React element into the DOM. + */ +export async function render( + ui: ReactNode, + options: RenderOptions = {}, +): Promise { + const { wrapper } = options; + let { container, baseElement } = options; + + if (!baseElement) { + baseElement = document.body; + } + + if (!container) { + container = baseElement.appendChild(document.createElement('div')); + } + + let root: ReactRoot; + + if (!mountedContainers.has(container)) { + root = createRoot(container); + + mountedRootEntries.push({ container, root }); + mountedContainers.add(container); + } else { + const entry = mountedRootEntries.find((e) => e.container === container); + if (!entry) { + throw new Error('Container is tracked but root entry not found'); + } + root = entry.root; + } + + const wrappedUi = wrapUiIfNeeded(strictModeIfNeeded(ui), wrapper); + await act(() => root.render(wrappedUi)); + + return { + container, + baseElement, + unmount: async () => { + await act(() => root.unmount()); + }, + rerender: async (newUi: ReactNode) => { + const wrapped = wrapUiIfNeeded(strictModeIfNeeded(newUi), wrapper); + await act(() => root.render(wrapped)); + }, + asFragment: () => { + return document + .createRange() + .createContextualFragment(container.innerHTML); + }, + }; +} + +/** + * Render a custom React hook for testing. + */ +export async function renderHook( + renderCallback: (props?: Props) => Result, + options: RenderHookOptions = {}, +): Promise> { + const { initialProps, ...renderOptions } = options; + const result = { current: undefined as Result }; + + function TestComponent({ hookProps }: { hookProps?: Props }): null { + const value = renderCallback(hookProps); + React.useEffect(() => { + result.current = value; + }); + return null; + } + + const { rerender: baseRerender, unmount } = await render( + React.createElement(TestComponent, { hookProps: initialProps }), + renderOptions, + ); + + return { + result, + rerender: async (props?: Props) => { + await baseRerender( + React.createElement(TestComponent, { hookProps: props }), + ); + }, + unmount, + act, + }; +} + +/** + * Cleanup all mounted components. + * Call this in beforeEach or afterEach to prevent test pollution. + */ +export async function cleanup(): Promise { + for (const { root, container } of mountedRootEntries) { + await act(() => root.unmount()); + if (container.parentNode === document.body) { + document.body.removeChild(container); + } + } + mountedRootEntries.length = 0; + mountedContainers.clear(); +} + +/** + * Configure render behavior. + */ +export function configure(customConfig: Partial): void { + Object.assign(config, customConfig); +} + +export { act }; diff --git a/packages/browser-react/tsconfig.json b/packages/browser-react/tsconfig.json new file mode 100644 index 000000000..fa02f0ca6 --- /dev/null +++ b/packages/browser-react/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@rstest/tsconfig/base", + "compilerOptions": { + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "src", + "composite": true, + "isolatedDeclarations": true, + "declarationMap": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/browser/package.json b/packages/browser/package.json index 0c654d849..64d8df2e3 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -40,13 +40,14 @@ "dev": "rslib build --watch" }, "dependencies": { - "@rsbuild/core": "1.7.1", + "@jridgewell/trace-mapping": "0.3.31", + "convert-source-map": "^2.0.0", "open-editor": "^4.0.0", + "pathe": "^2.0.3", "sirv": "^2.0.4", "ws": "^8.18.3" }, "devDependencies": { - "@jridgewell/trace-mapping": "0.3.31", "@rslib/core": "^0.19.0", "@rstest/browser-ui": "workspace:*", "@rstest/core": "workspace:*", @@ -56,8 +57,6 @@ "@types/ws": "^8.18.1", "@vitest/snapshot": "^3.2.4", "birpc": "2.9.0", - "convert-source-map": "^2.0.0", - "pathe": "^2.0.3", "picocolors": "^1.1.1", "picomatch": "^4.0.3", "playwright": "^1.49.1" diff --git a/packages/browser/src/client/entry.ts b/packages/browser/src/client/entry.ts index 5a04cbdc0..aa222cec0 100644 --- a/packages/browser/src/client/entry.ts +++ b/packages/browser/src/client/entry.ts @@ -26,6 +26,7 @@ import { BrowserSnapshotEnvironment } from './snapshot'; import { findNewScriptUrl, getScriptUrls, + preloadRunnerSourceMap, preloadTestFileSourceMap, } from './sourceMapSupport'; @@ -359,6 +360,11 @@ const run = async () => { setRealTimers(); + // Preload runner.js sourcemap for inline snapshot support. + // The snapshot code runs in runner.js, so we need its sourcemap + // to map stack traces back to original source files. + await preloadRunnerSourceMap(); + // Find the project for this test file const targetTestFile = options.testFile; const currentProject = targetTestFile diff --git a/packages/browser/src/client/sourceMapSupport.ts b/packages/browser/src/client/sourceMapSupport.ts index 1c98abc40..04d6474fa 100644 --- a/packages/browser/src/client/sourceMapSupport.ts +++ b/packages/browser/src/client/sourceMapSupport.ts @@ -110,6 +110,19 @@ export const preloadTestFileSourceMap = async ( await preloadSourceMap(chunkUrl, true); }; +/** + * Preload source map for the runner.js file. + * + * This is essential for inline snapshot support because the snapshot code + * runs in runner.js (which contains @rstest/core/browser-runtime). + * Without this, stack traces from inline snapshots cannot be mapped back + * to the original source files. + */ +export const preloadRunnerSourceMap = async (): Promise => { + const runnerUrl = `${window.location.origin}/static/js/runner.js`; + await preloadSourceMap(runnerUrl); +}; + /** * Clear cache (for testing purposes) */ diff --git a/packages/browser/src/hostController.ts b/packages/browser/src/hostController.ts index 7edcfa6fa..b77bdb76a 100644 --- a/packages/browser/src/hostController.ts +++ b/packages/browser/src/hostController.ts @@ -1,30 +1,26 @@ import { existsSync } from 'node:fs'; import fs from 'node:fs/promises'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; -import type { RsbuildDevServer, RsbuildInstance } from '@rsbuild/core'; -import { createRsbuild, rspack } from '@rsbuild/core'; -import type { - FormattedError, - ListCommandResult, - ProjectContext, - Reporter, - Rstest, - RuntimeConfig, - Test, - TestFileResult, - TestResult, - UserConsoleLog, -} from '@rstest/core/browser'; import { color, + type FormattedError, getSetupFiles, getTestEntries, isDebug, + type ListCommandResult, logger, + type ProjectContext, + type Reporter, + type Rstest, + type RuntimeConfig, + rsbuild, serializableConfig, TEMP_RSTEST_OUTPUT_DIR, + type Test, + type TestFileResult, + type TestResult, + type UserConsoleLog, } from '@rstest/core/browser'; import { type BirpcReturn, createBirpc } from 'birpc'; import openEditor from 'open-editor'; @@ -39,6 +35,10 @@ import type { TestFileInfo, } from './protocol'; +const { createRsbuild, rspack } = rsbuild; +type RsbuildDevServer = rsbuild.RsbuildDevServer; +type RsbuildInstance = rsbuild.RsbuildInstance; + const __dirname = dirname(fileURLToPath(import.meta.url)); // ============================================================================ @@ -490,27 +490,6 @@ const resolveContainerDist = (): string => { ); }; -/** - * Resolve @rstest/core source file path for browser compilation. - * Browser client code needs to import from core's source files (not dist) - * because the dist files contain Node.js-specific code that can't run in browsers. - */ -const resolveCoreSourceFile = (relativePath: string): string => { - const require = createRequire(import.meta.url); - const corePkgPath = require.resolve('@rstest/core/package.json'); - const coreRoot = dirname(corePkgPath); - const srcPath = resolve(coreRoot, 'src', relativePath); - - if (existsSync(srcPath)) { - return srcPath; - } - - throw new Error( - `Unable to resolve @rstest/core source file: ${relativePath}. ` + - `Looked in: ${srcPath}`, - ); -}; - // ============================================================================ // Manifest Generation // ============================================================================ @@ -746,14 +725,17 @@ const createBrowserRuntime = async ({ const userRsbuildConfig = firstProject?.normalizedConfig ?? {}; // Rstest internal aliases that must not be overridden by user config - // These aliases point to source files because dist files contain Node.js code - // that cannot run in the browser environment. + const browserRuntimePath = fileURLToPath( + import.meta.resolve('@rstest/core/browser-runtime'), + ); + const rstestInternalAliases = { '@rstest/browser-manifest': manifestPath, // User test code: import { describe, it } from '@rstest/core' '@rstest/core': resolveBrowserFile('client/public.ts'), // Browser runtime APIs for entry.ts and public.ts - '@rstest/core/browser-runtime': resolveCoreSourceFile('browserRuntime.ts'), + // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots + '@rstest/core/browser-runtime': browserRuntimePath, '@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts'), }; @@ -797,6 +779,10 @@ const createBrowserRuntime = async ({ }, output: { target: 'web', + // Enable source map for inline snapshot support + sourceMap: { + js: 'source-map', + }, }, tools: { rspack: (rspackConfig) => { @@ -807,6 +793,24 @@ const createBrowserRuntime = async ({ }; rspackConfig.plugins = rspackConfig.plugins || []; rspackConfig.plugins.push(virtualManifestPlugin); + + // Extract and merge sourcemaps from pre-built @rstest/core files + // This preserves the sourcemap chain for inline snapshot support + // See: https://rspack.dev/config/module-rules#rulesextractsourcemap + const browserRuntimeDir = dirname(browserRuntimePath); + rspackConfig.module = rspackConfig.module || {}; + rspackConfig.module.rules = rspackConfig.module.rules || []; + rspackConfig.module.rules.unshift({ + test: /\.js$/, + include: browserRuntimeDir, + extractSourceMap: true, + }); + + if (isDebug()) { + logger.log( + `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`, + ); + } }, }, }); diff --git a/packages/core/rslib.config.ts b/packages/core/rslib.config.ts index 405493f79..81350e2e2 100644 --- a/packages/core/rslib.config.ts +++ b/packages/core/rslib.config.ts @@ -133,6 +133,30 @@ export default defineConfig({ output: { target: 'web', distPath: 'dist/browser-runtime', + // Enable sourcemap for browser runtime to support inline snapshot + // When compiled by @rstest/browser, extractSourceMap merges this sourcemap + sourceMap: true, + minify: { + jsOptions: { + minimizerOptions: { + mangle: false, + minify: false, + compress: { + defaults: false, + unused: true, + dead_code: true, + toplevel: true, + // fix `Couldn't infer stack frame for inline snapshot` error + // should keep function name __INLINE_SNAPSHOT__ used to filter stack trace + keep_fnames: true, + }, + format: { + comments: 'some', + preserve_annotations: true, + }, + }, + }, + }, }, plugins: [pluginNodePolyfill()], }, diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index 47c1b524c..03e72ebd0 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -4,6 +4,10 @@ * @rstest/browser must have the same version as @rstest/core. */ +// Re-export @rsbuild/core for @rstest/browser to avoid duplicate dependency +import * as rsbuild from '@rsbuild/core'; +export { rsbuild }; + // Re-export Rstest type for convenience export type { Rstest } from './core/rstest'; // Runtime API diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bbfbe4d3..ab5a98dbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,12 +89,18 @@ importers: '@rstest/browser': specifier: workspace:* version: link:../packages/browser + '@rstest/browser-react': + specifier: workspace:* + version: link:../packages/browser-react '@rstest/core': specifier: workspace:* version: link:../packages/core '@rstest/tsconfig': specifier: workspace:* version: link:../scripts/tsconfig + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -533,12 +539,18 @@ importers: packages/browser: dependencies: - '@rsbuild/core': - specifier: 1.7.1 - version: 1.7.1 + '@jridgewell/trace-mapping': + specifier: 0.3.31 + version: 0.3.31 + convert-source-map: + specifier: ^2.0.0 + version: 2.0.0 open-editor: specifier: ^4.0.0 version: 4.1.1 + pathe: + specifier: ^2.0.3 + version: 2.0.3 sirv: specifier: ^2.0.4 version: 2.0.4 @@ -546,9 +558,6 @@ importers: specifier: ^8.18.3 version: 8.18.3 devDependencies: - '@jridgewell/trace-mapping': - specifier: 0.3.31 - version: 0.3.31 '@rslib/core': specifier: ^0.19.0 version: 0.19.0(@microsoft/api-extractor@7.55.2(@types/node@22.18.6))(typescript@5.9.3) @@ -576,12 +585,6 @@ importers: birpc: specifier: 2.9.0 version: 2.9.0 - convert-source-map: - specifier: ^2.0.0 - version: 2.0.0 - pathe: - specifier: ^2.0.3 - version: 2.0.3 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -592,6 +595,33 @@ importers: specifier: ^1.49.1 version: 1.57.0 + packages/browser-react: + devDependencies: + '@rslib/core': + specifier: ^0.19.0 + version: 0.19.0(@microsoft/api-extractor@7.55.2(@types/node@22.18.6))(typescript@5.9.3) + '@rstest/core': + specifier: workspace:* + version: link:../core + '@rstest/tsconfig': + specifier: workspace:* + version: link:../../scripts/tsconfig + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/browser-ui: dependencies: antd: