diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index 9f1ec1e4..07216548 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -13,6 +13,7 @@ import type { UserConfig, UserConfigAsyncFn } from '@rspress/core'; import type { RstestConfigExport } from '@rstest/core'; import type { FmtConfigDefinition } from './fmt/types.ts'; import type { StagedConfig } from './staged.ts'; +import { composeConfigLayers } from './configLayers.ts'; export type RslintConfigDefinition = RslintConfig | (() => Promise); @@ -58,6 +59,7 @@ export const normalizeRstackConfig = ({ : { ...configs, lint: normalizeLintConfig(lint) }; export type LoadedRstackConfig = { + /** Effective tool definitions. Configuration factories remain unevaluated. */ configs: Configs; filePath: string | null; dependencies: string[]; @@ -251,7 +253,7 @@ export const loadRstackConfig = async ({ }); return { - configs: session.configs, + configs: await composeConfigLayers([session.configs]), filePath, dependencies, }; diff --git a/packages/rstack/src/configLayers.ts b/packages/rstack/src/configLayers.ts index 8795a918..bc46628e 100644 --- a/packages/rstack/src/configLayers.ts +++ b/packages/rstack/src/configLayers.ts @@ -112,3 +112,79 @@ export const resolveStagedConfig = async ( : { ...merged, ...config }, ); }; + +export const resolveFmtConfigLayers = async ( + layers: readonly Configs[], +): Promise => { + const configs = await resolveConfigLayers(layers, 'fmt'); + return configs.length > 1 + ? (Object.assign({}, ...configs) as FmtConfig) + : configs[0]; +}; + +/** Compose effective definitions without executing any tool or task factories. */ +export const composeConfigLayers = async ( + layers: readonly Configs[], +): Promise => { + if (layers.length <= 1) { + return layers[0] ?? {}; + } + + const configs: Configs = {}; + for (const kind of [ + 'app', + 'lib', + 'doc', + 'test', + 'lint', + 'fmt', + 'staged', + ] as const) { + const matchingLayers = layers.filter((layer) => layer[kind] !== undefined); + if (matchingLayers.length === 0) { + continue; + } + if (matchingLayers.length === 1) { + Object.assign(configs, { [kind]: matchingLayers[0][kind] }); + continue; + } + + switch (kind) { + case 'app': + configs.app = async (params) => { + const { resolveRsbuildConfig } = await import('./rsbuildConfig.ts'); + return resolveRsbuildConfig(matchingLayers, params); + }; + break; + case 'lib': + configs.lib = async (params) => { + const { resolveRslibConfig } = await import('./rslibConfig.ts'); + return resolveRslibConfig(matchingLayers, params); + }; + break; + case 'doc': + configs.doc = async () => { + const { resolveRspressConfig } = await import('./rspressConfig.ts'); + return resolveRspressConfig(matchingLayers); + }; + break; + case 'test': + configs.test = async () => { + const { mergeRstestConfigLayers } = await import('./rstestConfig.ts'); + return mergeRstestConfigLayers(matchingLayers); + }; + break; + case 'lint': + configs.lint = async () => (await resolveRslintConfig(matchingLayers))!; + break; + case 'fmt': + configs.fmt = async () => + (await resolveFmtConfigLayers(matchingLayers))!; + break; + case 'staged': + configs.staged = await resolveStagedConfig(matchingLayers); + break; + } + } + return configs; +}; diff --git a/packages/rstack/src/fmt/config.ts b/packages/rstack/src/fmt/config.ts index 54d735b7..1d9ba7b0 100644 --- a/packages/rstack/src/fmt/config.ts +++ b/packages/rstack/src/fmt/config.ts @@ -1,7 +1,7 @@ import { dirname } from 'node:path'; import micromatch from 'micromatch'; import type { Configs } from '../config.ts'; -import { resolveConfigLayers } from '../configLayers.ts'; +import { resolveFmtConfigLayers } from '../configLayers.ts'; import { createRelativePathResolver } from './pathHelpers.ts'; import type { FmtConfig, @@ -157,11 +157,7 @@ const resolveFmtConfig = async ({ configFilePath, cwd, }: ResolveFmtConfigOptions): Promise => { - const configs = await resolveConfigLayers(layers, 'fmt'); - const config = - configs.length > 1 - ? (Object.assign({}, ...configs) as FmtConfig) - : configs[0]; + const config = await resolveFmtConfigLayers(layers); const rootPath = configFilePath ? dirname(configFilePath) : cwd; return normalizeFmtConfig(config, rootPath); diff --git a/packages/rstack/src/rstestConfig.ts b/packages/rstack/src/rstestConfig.ts index c69bdacb..d02868ba 100644 --- a/packages/rstack/src/rstestConfig.ts +++ b/packages/rstack/src/rstestConfig.ts @@ -87,13 +87,20 @@ const extendsConfig = async ( }; }; +export const mergeRstestConfigLayers = async ( + layers: readonly Configs[], +): Promise => { + const configs = await resolveConfigLayers(layers, 'test'); + return configs.length > 1 + ? mergeRstestConfig(...configs) + : (configs[0] ?? {}); +}; + export const resolveRstestConfig = async ( layers: readonly Configs[], params: ConfigParams, ): Promise => { - const configs = await resolveConfigLayers(layers, 'test'); - const testConfig = - configs.length > 1 ? mergeRstestConfig(...configs) : (configs[0] ?? {}); + const testConfig = await mergeRstestConfigLayers(layers); return extendsConfig(layers, testConfig, params); }; diff --git a/packages/rstack/tests/config/compose.test.ts b/packages/rstack/tests/config/compose.test.ts new file mode 100644 index 00000000..ed52afc0 --- /dev/null +++ b/packages/rstack/tests/config/compose.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { expect, rs, test } from 'rstack/test'; +import { + composeConfigLayers, + resolveConfigLayers, +} from '../../src/configLayers.ts'; +import { resolveFmtConfig } from '../../src/fmt/config.ts'; +import { resolveRstestConfig } from '../../src/rstestConfig.ts'; + +// TODO: Import the source directly after upgrading Rstest to fix +// https://github.com/web-infra-dev/rstest/issues/1891. +rs.mock( + '../../src/rstestConfig.ts', + () => + createRequire(import.meta.url)( + '../../dist/rstestConfig.js', + ) as typeof import('../../src/rstestConfig.ts'), +); + +test('preserves single definitions and leaves missing tools absent', async () => { + const app = rs.fn(() => ({})); + const base = { app }; + const fmt = {}; + + expect(await composeConfigLayers([])).toEqual({}); + expect(await composeConfigLayers([base])).toBe(base); + const configs = await composeConfigLayers([base, { fmt }]); + expect(configs).toEqual({ app, fmt }); + expect(configs.fmt).toBe(fmt); + expect(app).not.toHaveBeenCalled(); +}); + +test('resolves only the requested tool and preserves staged task generators', async () => { + const app = rs.fn(() => ({})); + const fmt = rs.fn(() => ({ semi: false })); + const staged = rs.fn(() => 'rs check'); + const configs = await composeConfigLayers([ + { app, fmt, staged: { '*.ts': 'rs lint' } }, + { app: {}, fmt: { singleQuote: true }, staged }, + ]); + + expect(fmt).not.toHaveBeenCalled(); + expect( + await resolveFmtConfig({ + layers: [configs], + configFilePath: '/project/rstack.config.ts', + cwd: '/other', + }), + ).toEqual({ + rootPath: '/project', + baseOptions: { semi: false, singleQuote: true }, + overrides: [], + ignorePatterns: [], + }); + expect(fmt).toHaveBeenCalledExactlyOnceWith(); + expect(app).not.toHaveBeenCalled(); + expect(configs.staged).toBe(staged); + expect(staged).not.toHaveBeenCalled(); +}); + +test('applies automatic inheritance after merging test definitions', async () => { + const app = rs.fn(() => ({ source: { define: { SHARED: true } } })); + const configs = await composeConfigLayers([ + { app, test: { retry: 1 } }, + { + app: { source: { define: { PROJECT: true } } }, + test: { testTimeout: 5000 }, + }, + ]); + + expect(await resolveConfigLayers([configs], 'test')).toEqual([ + { retry: 1, testTimeout: 5000 }, + ]); + expect(app).not.toHaveBeenCalled(); + + const params = { command: 'build', env: 'production' } as const; + const config = await resolveRstestConfig([configs], params); + assert(typeof config.extends === 'function'); + expect(config).toEqual({ + retry: 1, + testTimeout: 5000, + extends: config.extends, + }); + expect(await config.extends(config)).toMatchObject({ + source: { define: { SHARED: true, PROJECT: true } }, + }); + expect(app).toHaveBeenCalledExactlyOnceWith(params); +});