Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/rstack/src/configLayers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type {
ConfigParams as AppConfigParams,
RsbuildConfig,
} from '@rsbuild/core';
import type { ConfigParams as LibConfigParams, RslibConfig } from '@rslib/core';
import type { RslintConfig } from '@rslint/core';
import type { UserConfig as RspressConfig } from '@rspress/core';
import type { RstestConfig } from '@rstest/core';
import type { Configs } from './config.ts';
import type { FmtConfig } from './fmt/types.ts';
import type { StagedConfig } from './staged.ts';

/** Internal definitions, including the deferred lint factory created by define.lint. */
export type ConfigLayer = Readonly<Configs>;

/** Ordered from the lowest to the highest precedence; no tool merge rules here. */
export type ConfigLayers = readonly ConfigLayer[];

type ConfigValues = {
app: RsbuildConfig;
lib: RslibConfig;
doc: RspressConfig;
test: RstestConfig;
lint: RslintConfig;
fmt: FmtConfig;
staged: StagedConfig;
};

type ConfigArgs<K extends keyof Configs> = K extends 'app'
? [params: AppConfigParams]
: K extends 'lib'
? [params: LibConfigParams]
: [];

/** Collect definitions without running factories or modifying the supplied layers. */
export const createConfigLayers = (
project: ConfigLayer,
inherited: ConfigLayers = [],
): ConfigLayers => [...inherited, project];

/**
* Resolve only the requested tool, in layer order. Missing definitions contribute
* nothing; defaults and merging belong to the tool adapter. Each call evaluates
* its factories anew, without caching across native parameters or loads.
*/
export const resolveConfigLayers = async <K extends keyof Configs>(
layers: ConfigLayers,
kind: K,
...args: ConfigArgs<K>
): Promise<ConfigValues[K][]> => {
const configs: ConfigValues[K][] = [];

for (const layer of layers) {
const definition = layer[kind];
if (definition === undefined) {
continue;
}

// A staged function generates tasks from file names; it is not a factory.
if (kind !== 'staged' && typeof definition === 'function') {
const factory = definition as (
...args: ConfigArgs<K>
) => ConfigValues[K] | Promise<ConfigValues[K]>;
configs.push(await factory(...args));
} else {
configs.push(definition as ConfigValues[K]);
}
}

return configs;
};
170 changes: 170 additions & 0 deletions packages/rstack/tests/config/layers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { ConfigParams } from 'rstack/app';
import { expect, rs, test } from 'rstack/test';
import {
createConfigLayers,
resolveConfigLayers,
type ConfigLayer,
} from '../../src/configLayers.ts';

test('collects inherited layers before the project without resolving definitions', () => {
const factory = rs.fn(() => ({}));
const base = Object.freeze({ app: factory });
const team = Object.freeze({ test: factory });
const project = Object.freeze({ app: factory });
const inherited = Object.freeze([base, team]);

const layers = createConfigLayers(project, inherited);

expect(layers).toEqual([base, team, project]);
expect(layers[0]).toBe(base);
expect(layers[2]).toBe(project);
expect(inherited).toEqual([base, team]);
expect(createConfigLayers(project)).toEqual([project]);
expect(factory).not.toHaveBeenCalled();
});

test.each(['app', 'lib'] as const)(
'passes native parameters unchanged to every %s factory',
async (kind) => {
const params: ConfigParams = {
command: 'build',
env: 'production',
envMode: 'staging',
meta: { caller: 'test' },
};
const baseConfig = { root: 'base' };
const projectConfig = { root: 'project' };
const base = rs.fn((_params: ConfigParams) => baseConfig);
const project = rs.fn((_params: ConfigParams) =>
Promise.resolve(projectConfig),
);
const layers = createConfigLayers({ [kind]: project }, [{ [kind]: base }]);

const configs = await resolveConfigLayers(layers, kind, params);

expect(configs).toEqual([baseConfig, projectConfig]);
expect(configs[0]).toBe(baseConfig);
expect(configs[1]).toBe(projectConfig);
expect(base).toHaveBeenCalledExactlyOnceWith(params);
expect(project).toHaveBeenCalledExactlyOnceWith(params);
expect(base.mock.calls[0][0]).toBe(params);
expect(project.mock.calls[0][0]).toBe(params);
},
);

test('awaits layers sequentially and only resolves the selected tool', async () => {
const started = Promise.withResolvers<void>();
const ready = Promise.withResolvers<void>();
const baseConfig = Object.freeze({ retry: 2 });
const projectConfig = Object.freeze({ retry: 1 });
const unrelated = rs.fn(() => {
throw new Error('Unselected tool must stay lazy');
});
const project = rs.fn(() => projectConfig);
const layers = createConfigLayers({ test: project, app: unrelated }, [
{
async test() {
started.resolve();
await ready.promise;
return baseConfig;
},
fmt: unrelated,
},
]);

const resolving = resolveConfigLayers(layers, 'test');
await started.promise;
try {
expect(project).not.toHaveBeenCalled();
} finally {
ready.resolve();
}
const configs = await resolving;

expect(configs).toEqual([baseConfig, projectConfig]);
expect(configs[0]).toBe(baseConfig);
expect(configs[1]).toBe(projectConfig);
expect(project).toHaveBeenCalledExactlyOnceWith();
expect(unrelated).not.toHaveBeenCalled();
});

test.each(['doc', 'test', 'lint', 'fmt'] as const)(
'resolves %s values and factories without injecting arguments',
async (kind) => {
const values = {
doc: { title: 'Docs' },
test: { retry: 2 },
lint: [],
fmt: { singleQuote: true },
};
const factory = rs.fn(() => Promise.resolve(values[kind]));
const layers = createConfigLayers({ [kind]: factory }, [
{ [kind]: values[kind] },
]);

const configs = await resolveConfigLayers(layers, kind);

expect(configs).toEqual([values[kind], values[kind]]);
expect(configs[0]).toBe(values[kind]);
expect(configs[1]).toBe(values[kind]);
expect(factory).toHaveBeenCalledExactlyOnceWith();
},
);

test('skips missing definitions while preserving explicitly empty configs', async () => {
const emptyConfig = {};
const emptyLint: ConfigLayer = { lint: [] };
const layers = createConfigLayers({ test: emptyConfig }, [{}, emptyLint]);

expect(await resolveConfigLayers(layers, 'test')).toEqual([emptyConfig]);
expect(await resolveConfigLayers(layers, 'lint')).toEqual([[]]);
expect(await resolveConfigLayers(layers, 'fmt')).toEqual([]);
});

test('preserves staged task generators without invoking them', async () => {
const task = rs.fn((_files: readonly string[]) => 'rs lint');
const generator = rs.fn((_files: readonly string[]) =>
Promise.resolve(['rs fmt']),
);
const tasks = { '*.ts': task };
const layers = createConfigLayers({ staged: generator }, [{ staged: tasks }]);

const configs = await resolveConfigLayers(layers, 'staged');

expect(configs[0]).toBe(tasks);
expect(configs[1]).toBe(generator);
expect(task).not.toHaveBeenCalled();
expect(generator).not.toHaveBeenCalled();
});

test('stops at a failed factory and preserves its error', async () => {
const error = new Error('Invalid shared config');
const project = rs.fn(() => ({}));
const layers = createConfigLayers({ fmt: project }, [
{
fmt: () => Promise.reject(error),
},
]);

await expect(resolveConfigLayers(layers, 'fmt')).rejects.toBe(error);
expect(project).not.toHaveBeenCalled();
});

test('keeps simultaneous resolutions with different parameters independent', async () => {
const factory = rs.fn(async ({ env }: ConfigParams) => {
await Promise.resolve();
return { root: env };
});
const layers = createConfigLayers({ app: factory });

const configs = await Promise.all([
resolveConfigLayers(layers, 'app', { command: 'build', env: 'production' }),
resolveConfigLayers(layers, 'app', { command: 'dev', env: 'development' }),
]);

expect(configs).toEqual([
[{ root: 'production' }],
[{ root: 'development' }],
]);
expect(factory).toHaveBeenCalledTimes(2);
});
Loading