diff --git a/README.md b/README.md index 7284e37..caa6458 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,54 @@ For example, import Rstest APIs without adding `@rstest/core` as a direct depend import { expect, test } from 'rstack/test'; ``` +## Test projects + +`define.test()` accepts an Rstest configuration. For a single project, Rstack automatically applies the `define.app()` configuration, or falls back to `define.lib()`: + +```ts +import { define } from 'rstack'; + +define.app({ + // Rsbuild configuration +}); + +define.test({ + testEnvironment: 'happy-dom', +}); +``` + +For multiple test environments in the same app or library, use inline projects: + +```ts +import { define } from 'rstack'; +import { defineInlineProject } from 'rstack/test'; + +define.app({ + // Shared by all inline projects +}); + +define.test({ + projects: [ + defineInlineProject({ + name: 'node', + include: ['./tests/node/**/*.test.ts'], + testEnvironment: 'node', + }), + defineInlineProject({ + name: 'dom', + include: ['./tests/dom/**/*.test.tsx'], + testEnvironment: 'happy-dom', + }), + ], +}); +``` + +Rstack applies the shared app or library adapter to every inline project without an explicit `extends`. String project entries are passed to Rstest unchanged and load their own configuration. + +Run all projects with `rs test`, or select one with `rs test --project dom`. + +See [`examples/rstest-inline-projects`](./examples/rstest-inline-projects) for a complete React SSR example. + ## Credits Rstack CLI is inspired by: diff --git a/examples/documentation/docs/api/_meta.json b/examples/documentation/docs/api/_meta.json index f0ff0de..3e97dca 100644 --- a/examples/documentation/docs/api/_meta.json +++ b/examples/documentation/docs/api/_meta.json @@ -1 +1 @@ -["index", "commands"] +["index", "commands", "testing"] diff --git a/examples/documentation/docs/api/testing.mdx b/examples/documentation/docs/api/testing.mdx new file mode 100644 index 0000000..99e64aa --- /dev/null +++ b/examples/documentation/docs/api/testing.mdx @@ -0,0 +1,105 @@ +# Testing + +Rstack uses [Rstest](https://rstest.rs/) for testing. The value passed to `define.test()` is an Rstest configuration, similar to using Rstest's `defineConfig()` directly. + +Test APIs and configuration helpers are available from `rstack/test`: + +```ts +import { defineInlineProject, expect, test } from 'rstack/test'; +``` + +## Single project + +For a single test project, pass the Rstest options directly: + +```ts title="rstack.config.ts" +import { define } from 'rstack'; + +define.app({ + // Rsbuild configuration +}); + +define.test({ + testEnvironment: 'happy-dom', + setupFiles: ['./tests/rstest.setup.ts'], +}); +``` + +Unless `extends` is provided explicitly, Rstack automatically extends the test configuration from `define.app()`. If there is no app configuration, it falls back to `define.lib()`. + +## Inline projects + +Use Rstest inline projects when one app or library needs multiple test configurations, such as separate Node.js and DOM test environments: + +```ts title="rstack.config.ts" +import { define } from 'rstack'; +import { defineInlineProject } from 'rstack/test'; + +define.app({ + // Shared by both inline projects +}); + +define.test({ + coverage: { + enabled: true, + }, + projects: [ + defineInlineProject({ + name: 'node', + include: ['./tests/node/**/*.test.ts'], + testEnvironment: 'node', + }), + defineInlineProject({ + name: 'dom', + include: ['./tests/dom/**/*.test.tsx'], + testEnvironment: 'happy-dom', + setupFiles: ['./tests/rstest.setup.ts'], + }), + ], +}); +``` + +Rstack applies the shared `define.app()` or `define.lib()` adapter to every inline project that does not define its own `extends`. Function-based app or library configuration is resolved once and shared by all inline projects. + +Run all projects: + +```bash +rs test +``` + +Run one project by name: + +```bash +rs test --project dom +``` + +See [`examples/rstest-inline-projects`](https://github.com/rstackjs/rstack-cli/tree/main/examples/rstest-inline-projects) for a complete example that tests React server rendering in Node.js and client rendering in happy-dom. + +## Custom `extends` + +An inline project can opt out of automatic app or library inheritance by providing `extends` explicitly: + +```ts +import { defineInlineProject } from 'rstack/test'; + +const customProject = defineInlineProject({ + name: 'custom', + extends: customAdapter(), +}); +``` + +If the root `define.test()` configuration provides `extends`, Rstack leaves the complete test configuration unchanged. + +## External projects + +String project entries are passed to Rstest unchanged: + +```ts +import { define } from 'rstack'; + +define.test({ + projects: ['./legacy/rstest.config.ts'], +}); +``` + +External string projects load their own Rstest configuration and do not inherit the current `define.app()` or `define.lib()` configuration. Use inline projects when projects should share the current Rstack build configuration. diff --git a/examples/rstest-inline-projects/package.json b/examples/rstest-inline-projects/package.json new file mode 100644 index 0000000..4fea5d0 --- /dev/null +++ b/examples/rstest-inline-projects/package.json @@ -0,0 +1,26 @@ +{ + "name": "@examples/rstest-inline-projects", + "private": true, + "type": "module", + "scripts": { + "build": "rs build", + "dev": "rs dev", + "preview": "rs preview", + "test": "rs test" + }, + "dependencies": { + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@rsbuild/plugin-react": "catalog:", + "@testing-library/dom": "catalog:", + "@testing-library/react": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "happy-dom": "catalog:", + "rstack": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/examples/rstest-inline-projects/rstack.config.ts b/examples/rstest-inline-projects/rstack.config.ts new file mode 100644 index 0000000..930ef5b --- /dev/null +++ b/examples/rstest-inline-projects/rstack.config.ts @@ -0,0 +1,25 @@ +import { define } from 'rstack'; +import { defineInlineProject } from 'rstack/test'; + +define.app(async () => { + const { pluginReact } = await import('@rsbuild/plugin-react'); + + return { + plugins: [pluginReact()], + }; +}); + +define.test({ + projects: [ + defineInlineProject({ + name: 'ssr', + include: ['./tests/ssr.test.tsx'], + testEnvironment: 'node', + }), + defineInlineProject({ + name: 'dom', + include: ['./tests/dom.test.tsx'], + testEnvironment: 'happy-dom', + }), + ], +}); diff --git a/examples/rstest-inline-projects/src/App.tsx b/examples/rstest-inline-projects/src/App.tsx new file mode 100644 index 0000000..d8fd7d9 --- /dev/null +++ b/examples/rstest-inline-projects/src/App.tsx @@ -0,0 +1,8 @@ +export default function App() { + return ( +
+

Rstack React SSR

+

Rendered on the server and hydrated in the browser.

+
+ ); +} diff --git a/examples/rstest-inline-projects/src/index.tsx b/examples/rstest-inline-projects/src/index.tsx new file mode 100644 index 0000000..33694e2 --- /dev/null +++ b/examples/rstest-inline-projects/src/index.tsx @@ -0,0 +1,8 @@ +import { hydrateRoot } from 'react-dom/client'; +import App from './App'; + +const root = document.getElementById('root'); + +if (root) { + hydrateRoot(root, ); +} diff --git a/examples/rstest-inline-projects/tests/dom.test.tsx b/examples/rstest-inline-projects/tests/dom.test.tsx new file mode 100644 index 0000000..ed6910f --- /dev/null +++ b/examples/rstest-inline-projects/tests/dom.test.tsx @@ -0,0 +1,9 @@ +import { expect, test } from 'rstack/test'; +import { render, screen } from '@testing-library/react'; +import App from '../src/App'; + +test('renders the app in a DOM environment', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Rstack React SSR' })).toBeTruthy(); +}); diff --git a/examples/rstest-inline-projects/tests/ssr.test.tsx b/examples/rstest-inline-projects/tests/ssr.test.tsx new file mode 100644 index 0000000..8843cf9 --- /dev/null +++ b/examples/rstest-inline-projects/tests/ssr.test.tsx @@ -0,0 +1,10 @@ +import { expect, test } from 'rstack/test'; +import { renderToString } from 'react-dom/server'; +import App from '../src/App'; + +test('renders the app on the server', () => { + const html = renderToString(); + + expect(html).toContain('Rstack React SSR'); + expect(html).toContain('Rendered on the server'); +}); diff --git a/examples/rstest-inline-projects/tsconfig.json b/examples/rstest-inline-projects/tsconfig.json new file mode 100644 index 0000000..4515471 --- /dev/null +++ b/examples/rstest-inline-projects/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "lib": ["DOM", "ES2023"], + "jsx": "react-jsx", + "target": "ES2023", + "noEmit": true, + "skipLibCheck": true, + "types": ["rstack/types", "node"], + "moduleDetection": "force", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "strict": true + }, + "include": ["src", "tests", "rstack.config.ts"] +} diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index dafd575..3b116b6 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -70,7 +70,8 @@ type Define = { * This config is used by the `rs test` command. * * Unless `extends` is set explicitly, Rstest automatically extends `define.app` or - * falls back to `define.lib`. The app config takes precedence when both are defined. + * falls back to `define.lib`. For multi-project configs, this applies to every inline + * project without an explicit `extends`. The app config takes precedence when both are defined. */ test: (config: RstestConfigExport) => void; /** diff --git a/packages/rstack/src/rstestConfig.ts b/packages/rstack/src/rstestConfig.ts index 7c3ed41..c3979d6 100644 --- a/packages/rstack/src/rstestConfig.ts +++ b/packages/rstack/src/rstestConfig.ts @@ -2,11 +2,10 @@ import type { ConfigParams } from '@rsbuild/core'; import type { RstestConfig, RstestConfigExport } from '@rstest/core'; import { loadRstackConfig, type Configs } from './config.js'; -const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: ConfigParams) => { - if ('extends' in testConfig) { - return testConfig; - } - +const resolveAutomaticExtends = async ( + configs: Configs, + params: ConfigParams, +): Promise => { // Prefer the app when both app and lib are defined. Merging both adapters can // introduce conflicting runtime, resolve, and source transform settings. const appConfig = configs.app; @@ -17,12 +16,9 @@ const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: ); const config = typeof appConfig === 'function' ? await appConfig(params) : appConfig; - return { - ...testConfig, - extends: withRsbuildConfig({ - config, - }), - }; + return withRsbuildConfig({ + config, + }); } const libConfig = configs.lib; @@ -33,15 +29,53 @@ const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: ); const config = typeof libConfig === 'function' ? await libConfig(params) : libConfig; - return { - ...testConfig, - extends: withRslibConfig({ - config, - }), - }; + return withRslibConfig({ + config, + }); } - return testConfig; + return undefined; +}; + +const injectExtends = ( + config: T, + automaticExtends: RstestConfig['extends'], +): T => { + if (!automaticExtends || 'extends' in config) { + return config; + } + + return { + ...config, + extends: automaticExtends, + }; +}; + +const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: ConfigParams) => { + if ('extends' in testConfig) { + return testConfig; + } + + if (testConfig.projects === undefined) { + const automaticExtends = await resolveAutomaticExtends(configs, params); + return injectExtends(testConfig, automaticExtends); + } + + const shouldInjectProject = testConfig.projects.some( + (project) => typeof project !== 'string' && !('extends' in project), + ); + if (!shouldInjectProject) { + return testConfig; + } + + const automaticExtends = await resolveAutomaticExtends(configs, params); + + return { + ...testConfig, + projects: testConfig.projects.map((project) => + typeof project === 'string' ? project : injectExtends(project, automaticExtends), + ), + }; }; const resolveRstestConfig = async (configs: Configs) => { diff --git a/packages/rstack/test/config/define-test-projects-app/custom.ts b/packages/rstack/test/config/define-test-projects-app/custom.ts new file mode 100644 index 0000000..64d0d16 --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-app/custom.ts @@ -0,0 +1,7 @@ +import { expect, test } from 'rstack/test'; + +declare const RSTACK_INHERITED_CONFIG: string; + +test('an inline project can provide its own extends config', () => { + expect(RSTACK_INHERITED_CONFIG).toBe('custom'); +}); diff --git a/packages/rstack/test/config/define-test-projects-app/first.ts b/packages/rstack/test/config/define-test-projects-app/first.ts new file mode 100644 index 0000000..08e58b1 --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-app/first.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'rstack/test'; + +declare const RSTACK_APP_CONFIG_CALLS: number; +declare const RSTACK_INHERITED_CONFIG: string; + +test('the first inline project inherits the app config', () => { + expect(RSTACK_INHERITED_CONFIG).toBe('app'); + expect(RSTACK_APP_CONFIG_CALLS).toBe(1); +}); diff --git a/packages/rstack/test/config/define-test-projects-app/index.test.ts b/packages/rstack/test/config/define-test-projects-app/index.test.ts new file mode 100644 index 0000000..74f00e4 --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-app/index.test.ts @@ -0,0 +1,5 @@ +import { test } from '#test-helpers'; + +test('should apply define.app config to every inline test project', ({ execCli }) => { + execCli('test'); +}); diff --git a/packages/rstack/test/config/define-test-projects-app/rstack.config.ts b/packages/rstack/test/config/define-test-projects-app/rstack.config.ts new file mode 100644 index 0000000..33f7928 --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-app/rstack.config.ts @@ -0,0 +1,41 @@ +import { define } from 'rstack'; +import { defineInlineProject } from 'rstack/test'; + +let appConfigCalls = 0; + +define.app(() => { + appConfigCalls += 1; + + return { + source: { + define: { + RSTACK_APP_CONFIG_CALLS: JSON.stringify(appConfigCalls), + RSTACK_INHERITED_CONFIG: JSON.stringify('app'), + }, + }, + }; +}); + +define.test({ + projects: [ + defineInlineProject({ + name: 'app-first', + include: ['./first.ts'], + }), + defineInlineProject({ + name: 'app-second', + include: ['./second.ts'], + }), + defineInlineProject({ + name: 'custom-extends', + include: ['./custom.ts'], + extends: { + source: { + define: { + RSTACK_INHERITED_CONFIG: JSON.stringify('custom'), + }, + }, + }, + }), + ], +}); diff --git a/packages/rstack/test/config/define-test-projects-app/second.ts b/packages/rstack/test/config/define-test-projects-app/second.ts new file mode 100644 index 0000000..b39342b --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-app/second.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'rstack/test'; + +declare const RSTACK_APP_CONFIG_CALLS: number; +declare const RSTACK_INHERITED_CONFIG: string; + +test('the second inline project inherits the app config', () => { + expect(RSTACK_INHERITED_CONFIG).toBe('app'); + expect(RSTACK_APP_CONFIG_CALLS).toBe(1); +}); diff --git a/packages/rstack/test/config/define-test-projects-lib/first.ts b/packages/rstack/test/config/define-test-projects-lib/first.ts new file mode 100644 index 0000000..d0e5b03 --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-lib/first.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'rstack/test'; + +declare const RSTACK_INHERITED_CONFIG: string; +declare const RSTACK_LIB_CONFIG_CALLS: number; + +test('the first inline project inherits the lib config', () => { + expect(RSTACK_INHERITED_CONFIG).toBe('lib'); + expect(RSTACK_LIB_CONFIG_CALLS).toBe(1); +}); diff --git a/packages/rstack/test/config/define-test-projects-lib/index.test.ts b/packages/rstack/test/config/define-test-projects-lib/index.test.ts new file mode 100644 index 0000000..62372a4 --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-lib/index.test.ts @@ -0,0 +1,5 @@ +import { test } from '#test-helpers'; + +test('should apply define.lib config to every inline test project', ({ execCli }) => { + execCli('test'); +}); diff --git a/packages/rstack/test/config/define-test-projects-lib/rstack.config.ts b/packages/rstack/test/config/define-test-projects-lib/rstack.config.ts new file mode 100644 index 0000000..cd94309 --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-lib/rstack.config.ts @@ -0,0 +1,31 @@ +import { define } from 'rstack'; +import { defineInlineProject } from 'rstack/test'; + +let libConfigCalls = 0; + +define.lib(() => { + libConfigCalls += 1; + + return { + lib: [{}], + source: { + define: { + RSTACK_INHERITED_CONFIG: JSON.stringify('lib'), + RSTACK_LIB_CONFIG_CALLS: JSON.stringify(libConfigCalls), + }, + }, + }; +}); + +define.test({ + projects: [ + defineInlineProject({ + name: 'lib-first', + include: ['./first.ts'], + }), + defineInlineProject({ + name: 'lib-second', + include: ['./second.ts'], + }), + ], +}); diff --git a/packages/rstack/test/config/define-test-projects-lib/second.ts b/packages/rstack/test/config/define-test-projects-lib/second.ts new file mode 100644 index 0000000..79966c4 --- /dev/null +++ b/packages/rstack/test/config/define-test-projects-lib/second.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'rstack/test'; + +declare const RSTACK_INHERITED_CONFIG: string; +declare const RSTACK_LIB_CONFIG_CALLS: number; + +test('the second inline project inherits the lib config', () => { + expect(RSTACK_INHERITED_CONFIG).toBe('lib'); + expect(RSTACK_LIB_CONFIG_CALLS).toBe(1); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2be2012..d6893fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,43 @@ importers: specifier: 'catalog:' version: 7.0.2 + examples/rstest-inline-projects: + dependencies: + react: + specifier: 'catalog:' + version: 19.2.7 + react-dom: + specifier: 'catalog:' + version: 19.2.7(react@19.2.7) + devDependencies: + '@rsbuild/plugin-react': + specifier: 'catalog:' + version: 2.1.0(@rsbuild/core@2.1.6)(@rspack/core@2.1.4) + '@testing-library/dom': + specifier: 'catalog:' + version: 10.4.1 + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.7)(react@19.2.7) + '@types/node': + specifier: 'catalog:' + version: 24.13.3 + '@types/react': + specifier: 'catalog:' + version: 19.2.17 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.17) + happy-dom: + specifier: 'catalog:' + version: 20.10.6 + rstack: + specifier: workspace:* + version: link:../../packages/rstack + typescript: + specifier: 'catalog:' + version: 7.0.2 + packages/rstack: dependencies: '@rsbuild/core':