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: