diff --git a/.github/workflows/test-ubuntu.yml b/.github/workflows/test-ubuntu.yml
index ebfe1edda..0695296fb 100644
--- a/.github/workflows/test-ubuntu.yml
+++ b/.github/workflows/test-ubuntu.yml
@@ -103,11 +103,12 @@ jobs:
if: steps.changes.outputs.changed == 'true'
run: pnpm run test:artifact
- - name: E2E Test (Playwright)
- if: steps.changes.outputs.changed == 'true'
- run: pnpm run test:e2e
-
- name: Examples Test
if: steps.changes.outputs.changed == 'true'
run: |
pnpm run build:examples
+
+ - name: E2E Test (Playwright)
+ if: steps.changes.outputs.changed == 'true'
+ run: pnpm run test:e2e
+
diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml
index f90097f59..dec5bb913 100644
--- a/.github/workflows/test-windows.yml
+++ b/.github/workflows/test-windows.yml
@@ -112,10 +112,10 @@ jobs:
if: steps.changes.outputs.changed == 'true'
run: pnpm run test:artifact
- - name: E2E Test (Playwright)
- if: steps.changes.outputs.changed == 'true'
- run: pnpm run test:e2e
-
- name: Examples Test
if: steps.changes.outputs.changed == 'true'
run: pnpm run build:examples
+
+ - name: E2E Test (Playwright)
+ if: steps.changes.outputs.changed == 'true'
+ run: pnpm run test:e2e
diff --git a/e2e/cases/examples-e2e/react-component/index.pw.test.ts b/e2e/cases/examples-e2e/react-component/index.pw.test.ts
new file mode 100644
index 000000000..ab4062bfd
--- /dev/null
+++ b/e2e/cases/examples-e2e/react-component/index.pw.test.ts
@@ -0,0 +1,26 @@
+import { dev } from '@e2e/helper/rsbuild';
+import { expect, test } from '@playwright/test';
+
+test('should render example "react-component" successfully', async ({
+ page,
+}) => {
+ const rsbuild = await dev({
+ cwd: __dirname,
+ page,
+ });
+
+ const h2El = page.locator('h2');
+ await expect(h2El).toHaveText('Counter: 0');
+
+ const buttonEl = page.locator('#root button');
+
+ const [subtractEl, addEl] = await buttonEl.all();
+
+ await expect(h2El).toHaveText('Counter: 0');
+ addEl?.click();
+ await expect(h2El).toHaveText('Counter: 1');
+ subtractEl?.click();
+ await expect(h2El).toHaveText('Counter: 0');
+
+ await rsbuild.close();
+});
diff --git a/e2e/cases/examples-e2e/react-component/package.json b/e2e/cases/examples-e2e/react-component/package.json
new file mode 100644
index 000000000..534d548d2
--- /dev/null
+++ b/e2e/cases/examples-e2e/react-component/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "react-component-e2e",
+ "version": "1.0.0",
+ "private": true
+}
diff --git a/e2e/cases/examples-e2e/react-component/rsbuild.config.ts b/e2e/cases/examples-e2e/react-component/rsbuild.config.ts
new file mode 100644
index 000000000..c9962d33f
--- /dev/null
+++ b/e2e/cases/examples-e2e/react-component/rsbuild.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from '@rsbuild/core';
+import { pluginReact } from '@rsbuild/plugin-react';
+
+export default defineConfig({
+ plugins: [pluginReact()],
+});
diff --git a/e2e/cases/examples-e2e/react-component/src/App.tsx b/e2e/cases/examples-e2e/react-component/src/App.tsx
new file mode 100644
index 000000000..96c164b16
--- /dev/null
+++ b/e2e/cases/examples-e2e/react-component/src/App.tsx
@@ -0,0 +1,9 @@
+import { Counter } from '@examples/react-component';
+
+const App = () => (
+
+
+
+);
+
+export default App;
diff --git a/e2e/cases/examples-e2e/react-component/src/index.tsx b/e2e/cases/examples-e2e/react-component/src/index.tsx
new file mode 100644
index 000000000..2b875af73
--- /dev/null
+++ b/e2e/cases/examples-e2e/react-component/src/index.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+const root = ReactDOM.createRoot(document.getElementById('root')!);
+root.render(
+
+
+ ,
+);
diff --git a/e2e/cases/examples-e2e/react-component/tsconfig.json b/e2e/cases/examples-e2e/react-component/tsconfig.json
new file mode 100644
index 000000000..e6b9bdf4e
--- /dev/null
+++ b/e2e/cases/examples-e2e/react-component/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "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"]
+}
diff --git a/e2e/package.json b/e2e/package.json
index 5ff635c31..0309281bf 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -6,17 +6,21 @@
"test": "playwright test --pass-with-no-tests"
},
"dependencies": {
- "react": "^18.3.1"
+ "@examples/react-component": "workspace:*",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
},
"devDependencies": {
"@e2e/helper": "workspace:*",
"@playwright/test": "1.47.2",
"@rsbuild/core": "1.0.7",
+ "@rsbuild/plugin-react": "1.0.2",
"@rslib/core": "workspace:*",
"@rslib/tsconfig": "workspace:*",
"@types/fs-extra": "^11.0.4",
"@types/node": "~18.19.39",
"@types/react": "^18.3.9",
+ "@types/react-dom": "^18.3.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
"path-serializer": "0.0.6",
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
index d6da0901f..2440f1147 100644
--- a/e2e/playwright.config.ts
+++ b/e2e/playwright.config.ts
@@ -3,4 +3,8 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
// Playwright test files with `.pw.` to distinguish from Vitest test files
testMatch: /.*pw.(test|spec).(js|ts|mjs)/,
+ // Retry on CI
+ retries: process.env.CI ? 3 : 0,
+ // Print line for each test being run in CI
+ reporter: 'list',
});
diff --git a/e2e/scripts/rsbuild.ts b/e2e/scripts/rsbuild.ts
new file mode 100644
index 000000000..586d6269d
--- /dev/null
+++ b/e2e/scripts/rsbuild.ts
@@ -0,0 +1,138 @@
+/**
+ * The following code is modified based on
+ * https://github.com/web-infra-dev/rsbuild/blob/c21e2130a285177b890fca543f70377b66d1ad73/e2e/scripts/shared.ts
+ */
+import net from 'node:net';
+import type { Page } from '@playwright/test';
+import type {
+ CreateRsbuildOptions,
+ RsbuildConfig,
+ RsbuildPlugins,
+} from '@rsbuild/core';
+
+const getHrefByEntryName = (entryName: string, port: number) => {
+ const htmlRoot = new URL(`http://localhost:${port}`);
+ const homeUrl = new URL(`${entryName}.html`, htmlRoot);
+
+ return homeUrl.href;
+};
+
+const gotoPage = async (
+ page: Page,
+ rsbuild: { port: number },
+ path = 'index',
+) => {
+ const url = getHrefByEntryName(path, rsbuild.port);
+ return page.goto(url);
+};
+
+function isPortAvailable(port: number) {
+ try {
+ const server = net.createServer().listen(port);
+ return new Promise((resolve) => {
+ server.on('listening', () => {
+ server.close();
+ resolve(true);
+ });
+
+ server.on('error', () => {
+ resolve(false);
+ });
+ });
+ } catch (err) {
+ return false;
+ }
+}
+
+const portMap = new Map();
+// Available port ranges: 1024 ~ 65535
+// `10080` is not available in macOS CI, `> 50000` get 'permission denied' in Windows.
+// so we use `15000` ~ `45000`.
+async function getRandomPort(
+ defaultPort = Math.ceil(Math.random() * 30000) + 15000,
+) {
+ let port = defaultPort;
+ while (true) {
+ if (!portMap.get(port) && (await isPortAvailable(port))) {
+ portMap.set(port, 1);
+ return port;
+ }
+ port++;
+ }
+}
+
+const updateConfigForTest = async (
+ originalConfig: RsbuildConfig,
+ cwd: string = process.cwd(),
+) => {
+ const { loadConfig, mergeRsbuildConfig } = await import('@rsbuild/core');
+ const { content: loadedConfig } = await loadConfig({
+ cwd,
+ });
+
+ const baseConfig: RsbuildConfig = {
+ dev: {
+ progressBar: false,
+ },
+ server: {
+ // make port random to avoid conflict
+ port: await getRandomPort(),
+ printUrls: false,
+ },
+ performance: {
+ buildCache: false,
+ printFileSize: false,
+ },
+ };
+
+ return mergeRsbuildConfig(baseConfig, loadedConfig, originalConfig);
+};
+
+const createRsbuild = async (
+ rsbuildOptions: CreateRsbuildOptions,
+ plugins: RsbuildPlugins = [],
+) => {
+ const { createRsbuild: createRsbuildInner } = await import('@rsbuild/core');
+
+ rsbuildOptions.rsbuildConfig ||= {};
+ rsbuildOptions.rsbuildConfig.plugins = [
+ ...(rsbuildOptions.rsbuildConfig.plugins || []),
+ ...(plugins || []),
+ ];
+
+ const rsbuild = await createRsbuildInner(rsbuildOptions);
+ return rsbuild;
+};
+
+export async function dev({
+ plugins,
+ page,
+ ...options
+}: CreateRsbuildOptions & {
+ plugins?: RsbuildPlugins;
+ /**
+ * Playwright Page instance.
+ * This method will automatically goto the page.
+ */
+ page?: Page;
+}) {
+ process.env.NODE_ENV = 'development';
+
+ options.rsbuildConfig = await updateConfigForTest(
+ options.rsbuildConfig || {},
+ options.cwd,
+ );
+
+ const rsbuild = await createRsbuild(options, plugins);
+ const result = await rsbuild.startDevServer();
+
+ if (page) {
+ await gotoPage(page, result);
+ }
+
+ return {
+ ...result,
+ instance: rsbuild,
+ close: async () => result.server.close(),
+ };
+}
diff --git a/examples/react-component/package.json b/examples/react-component/package.json
index 8deaa4aae..23aa6e4e0 100644
--- a/examples/react-component/package.json
+++ b/examples/react-component/package.json
@@ -1,6 +1,9 @@
{
"name": "@examples/react-component",
"private": true,
+ "main": "./dist/cjs/index.js",
+ "module": "./dist/esm/index.mjs",
+ "types": "./dist/cjs/index.d.ts",
"scripts": {
"build": "rslib build"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8c94d4ba6..472ee0875 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,9 +59,15 @@ importers:
e2e:
dependencies:
+ '@examples/react-component':
+ specifier: workspace:*
+ version: link:../examples/react-component
react:
specifier: ^18.3.1
version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
devDependencies:
'@e2e/helper':
specifier: workspace:*
@@ -72,6 +78,9 @@ importers:
'@rsbuild/core':
specifier: 1.0.7
version: 1.0.7
+ '@rsbuild/plugin-react':
+ specifier: 1.0.2
+ version: 1.0.2(@rsbuild/core@1.0.7)
'@rslib/core':
specifier: workspace:*
version: link:../packages/core
@@ -87,6 +96,9 @@ importers:
'@types/react':
specifier: ^18.3.9
version: 18.3.9
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.0
fast-glob:
specifier: ^3.3.2
version: 3.3.2
@@ -213,6 +225,8 @@ importers:
e2e/cases/entry/single: {}
+ e2e/cases/examples-e2e/react-component: {}
+
e2e/cases/extension-alias: {}
e2e/cases/external-helpers/config-override: