diff --git a/packages/rspack-test-tools/etc/test-tools.api.md b/packages/rspack-test-tools/etc/test-tools.api.md index 6a22f12eb783..9e599255b4ab 100644 --- a/packages/rspack-test-tools/etc/test-tools.api.md +++ b/packages/rspack-test-tools/etc/test-tools.api.md @@ -34,17 +34,31 @@ export class BasicCaseCreator { // (undocumented) create(name: string, src: string, dist: string, temp?: string): ITester | undefined; // (undocumented) + protected createConcurrentEnv(): ITestEnv & IConcurrentTestEnv; + // (undocumented) protected createEnv(testConfig: TTestConfig): ITestEnv; // (undocumented) protected createTester(name: string, src: string, dist: string, temp: string | void, testConfig: TTestConfig): ITester; // (undocumented) + protected currentConcurrent: number; + // (undocumented) protected describe(name: string, tester: ITester, testConfig: TTestConfig): void; // (undocumented) + protected describeConcurrent(name: string, tester: ITester, testConfig: TTestConfig): void; + // (undocumented) + protected getMaxConcurrent(): number; + // (undocumented) protected _options: IBasicCaseCreatorOptions; // (undocumented) protected readTestConfig(src: string): TTestConfig; // (undocumented) + protected registerConcurrentTask(name: string, starter: () => void): () => void; + // (undocumented) protected skip(name: string, reason: string | boolean): void; + // (undocumented) + protected tasks: [string, () => void][]; + // (undocumented) + protected tryRunTask(): void; } // @public (undocumented) @@ -578,6 +592,8 @@ export interface IBasicCaseCreatorOptions { // (undocumented) clean?: boolean; // (undocumented) + concurrent?: boolean | number; + // (undocumented) contextValue?: Record; // (undocumented) describe?: boolean; @@ -688,6 +704,14 @@ export interface ICompareOptions { snapshot?: string; } +// @public (undocumented) +interface IConcurrentTestEnv { + // (undocumented) + clear: () => void; + // (undocumented) + run: () => Promise; +} + // @public (undocumented) export interface IConfigProcessorOptions extends IMultiTaskProcessorOptions { } @@ -1573,12 +1597,13 @@ export type TTestConfig = { beforeExecute?: () => void; afterExecute?: () => void; moduleScope?: (ms: IBasicModuleScope, stats?: TCompilerStatsCompilation) => IBasicModuleScope; - checkStats?: (stepName: string, jsonStats: TCompilerStatsCompilation, stringStats: String) => boolean; + checkStats?: (stepName: string, jsonStats: TCompilerStatsCompilation | undefined, stringStats: String) => boolean; findBundle?: (index: number, options: TCompilerOptions, stepName?: string) => string | string[]; bundlePath?: string[]; nonEsmThis?: (p: string | string[]) => Object; modules?: Record; timeout?: number; + concurrent?: boolean; }; // @public (undocumented) diff --git a/packages/rspack-test-tools/src/case/hot-step.ts b/packages/rspack-test-tools/src/case/hot-step.ts index cb5664396cc4..66a473c4b0fa 100644 --- a/packages/rspack-test-tools/src/case/hot-step.ts +++ b/packages/rspack-test-tools/src/case/hot-step.ts @@ -26,7 +26,8 @@ function getCreator(target: TTarget) { configFiles: ["rspack.config.js", "webpack.config.js"] }) ], - runner: HotStepRunnerFactory + runner: HotStepRunnerFactory, + concurrent: true }) ); } diff --git a/packages/rspack-test-tools/src/case/hot.ts b/packages/rspack-test-tools/src/case/hot.ts index d0eb13ed448b..f2b937080a8a 100644 --- a/packages/rspack-test-tools/src/case/hot.ts +++ b/packages/rspack-test-tools/src/case/hot.ts @@ -26,7 +26,8 @@ function getCreator(target: TTarget) { configFiles: ["rspack.config.js", "webpack.config.js"] }) ], - runner: HotRunnerFactory + runner: HotRunnerFactory, + concurrent: true }) ); } diff --git a/packages/rspack-test-tools/src/case/new-incremental.ts b/packages/rspack-test-tools/src/case/new-incremental.ts index ba162f3bb0ea..e62559e8196e 100644 --- a/packages/rspack-test-tools/src/case/new-incremental.ts +++ b/packages/rspack-test-tools/src/case/new-incremental.ts @@ -37,6 +37,8 @@ function getHotCreator(target: TTarget, documentType: EDocumentType) { }) ], runner: HotRunnerFactory + // TODO: enable concurrent then rspack will be hanged + // concurrent: true }) ); } @@ -101,7 +103,8 @@ const watchCreator = new BasicCaseCreator({ watchState ) ); - } + }, + concurrent: true }); export function createWatchNewIncrementalCase( diff --git a/packages/rspack-test-tools/src/case/watch.ts b/packages/rspack-test-tools/src/case/watch.ts index 7125d8ce93f8..4247f01f0050 100644 --- a/packages/rspack-test-tools/src/case/watch.ts +++ b/packages/rspack-test-tools/src/case/watch.ts @@ -50,7 +50,8 @@ const creator = new BasicCaseCreator({ watchState ) ); - } + }, + concurrent: true }); export function createWatchCase( diff --git a/packages/rspack-test-tools/src/compiler.ts b/packages/rspack-test-tools/src/compiler.ts index 0fd82523761b..cfb0e081609e 100644 --- a/packages/rspack-test-tools/src/compiler.ts +++ b/packages/rspack-test-tools/src/compiler.ts @@ -84,6 +84,10 @@ export class TestCompilerManager throw new Error("Compiler should be created before watch"); this.compilerInstance!.watch( { + // IMPORTANT: + // This is a workaround for the issue that watchpack cannot detect the file change in time + // so we set the poll to 300ms to make it more sensitive to the file change + poll: 300, aggregateTimeout: timeout }, (error, newStats) => { diff --git a/packages/rspack-test-tools/src/helper/util/currentWatchStep.js b/packages/rspack-test-tools/src/helper/util/currentWatchStep.js index 1b18fbcfaf1b..222399d92d50 100644 --- a/packages/rspack-test-tools/src/helper/util/currentWatchStep.js +++ b/packages/rspack-test-tools/src/helper/util/currentWatchStep.js @@ -1 +1 @@ -exports.step = undefined; +exports.step = {}; diff --git a/packages/rspack-test-tools/src/processor/watch.ts b/packages/rspack-test-tools/src/processor/watch.ts index c9c6b11a6523..7836354bef9d 100644 --- a/packages/rspack-test-tools/src/processor/watch.ts +++ b/packages/rspack-test-tools/src/processor/watch.ts @@ -60,7 +60,8 @@ export class WatchProcessor< async build(context: ITestContext) { const compiler = this.getCompiler(context); const currentWatchStepModule = require(currentWatchStepModulePath); - currentWatchStepModule.step = this._watchOptions.stepName; + currentWatchStepModule.step[this._options.name] = + this._watchOptions.stepName; fs.mkdirSync(this._watchOptions.tempDir, { recursive: true }); copyDiff( path.join(context.getSource(), this._watchOptions.stepName), @@ -345,7 +346,8 @@ export class WatchStepProcessor< async build(context: ITestContext) { const compiler = this.getCompiler(context); const currentWatchStepModule = require(currentWatchStepModulePath); - currentWatchStepModule.step = this._watchOptions.stepName; + currentWatchStepModule.step[this._options.name] = + this._watchOptions.stepName; const task = new Promise((resolve, reject) => { compiler.getEmitter().once(ECompilerEvent.Build, (e, stats) => { if (e) return reject(e); diff --git a/packages/rspack-test-tools/src/test/creator.ts b/packages/rspack-test-tools/src/test/creator.ts index 68bf8ebd8ad1..5bf5440c5b35 100644 --- a/packages/rspack-test-tools/src/test/creator.ts +++ b/packages/rspack-test-tools/src/test/creator.ts @@ -14,6 +14,11 @@ import type { } from "../type"; import { Tester } from "./tester"; +interface IConcurrentTestEnv { + clear: () => void; + run: () => Promise; +} + export interface IBasicCaseCreatorOptions { clean?: boolean; describe?: boolean; @@ -34,9 +39,15 @@ export interface IBasicCaseCreatorOptions { context: ITestContext ) => TRunnerFactory; [key: string]: unknown; + concurrent?: boolean | number; } +const DEFAULT_MAX_CONCURRENT = 5; + export class BasicCaseCreator { + protected currentConcurrent = 0; + protected tasks: [string, () => void][] = []; + constructor(protected _options: IBasicCaseCreatorOptions) {} create(name: string, src: string, dist: string, temp?: string) { @@ -55,16 +66,97 @@ export class BasicCaseCreator { } const tester = this.createTester(name, src, dist, temp, testConfig); + const concurrent = + testConfig.concurrent ?? this._options.concurrent ?? false; if (this._options.describe) { - describe(name, () => this.describe(name, tester, testConfig)); + if (concurrent) { + describe(name, () => this.describeConcurrent(name, tester, testConfig)); + } else { + describe(name, () => this.describe(name, tester, testConfig)); + } } else { - this.describe(name, tester, testConfig); + if (concurrent) { + this.describeConcurrent(name, tester, testConfig); + } else { + this.describe(name, tester, testConfig); + } } return tester; } + protected describeConcurrent( + name: string, + tester: ITester, + testConfig: TTestConfig + ) { + beforeAll(async () => { + await tester.prepare(); + }); + + let starter = null; + let chain = new Promise((resolve, reject) => { + starter = resolve; + }); + const ender = this.registerConcurrentTask(name, starter!); + const env = this.createConcurrentEnv(); + let bailout = false; + for (let index = 0; index < tester.total; index++) { + let stepSignalResolve = null; + let stepSignalReject = null; + const stepSignal = new Promise((resolve, reject) => { + stepSignalResolve = resolve; + stepSignalReject = reject; + }); + const description = + typeof this._options.description === "function" + ? this._options.description(name, index) + : `step ${index ? `[${index}]` : ""} should pass`; + it( + description, + async () => { + await stepSignal; + }, + this._options.timeout || 180000 + ); + + chain = chain.then(async () => { + try { + if (bailout) { + throw `Case "${name}" step ${index + 1} bailout because ${tester.step + 1} failed`; + } + env.clear(); + await tester.compile(); + await tester.check(env); + await env.run(); + const context = tester.getContext(); + if (!tester.next() && context.hasError()) { + bailout = true; + const errors = context + .getError() + .map(i => `${i.stack}`.split("\n").join("\t\n")) + .join("\n\n"); + throw new Error( + `Case "${name}" failed at step ${tester.step + 1}:\n${errors}` + ); + } + stepSignalResolve!(); + } catch (e) { + stepSignalReject!(e); + } + }); + } + + chain.finally(() => { + ender(); + }); + + afterAll(async () => { + await tester.resume(); + }); + } + protected describe( name: string, tester: ITester, @@ -74,12 +166,12 @@ export class BasicCaseCreator { await tester.prepare(); }); + let bailout = false; for (let index = 0; index < tester.total; index++) { const description = typeof this._options.description === "function" ? this._options.description(name, index) : `step ${index ? `[${index}]` : ""} should pass`; - let bailout = false; it( description, async () => { @@ -110,6 +202,72 @@ export class BasicCaseCreator { }); } + protected createConcurrentEnv(): ITestEnv & IConcurrentTestEnv { + const tasks: [string, () => Promise | void][] = []; + const beforeTasks: (() => Promise | void)[] = []; + const afterTasks: (() => Promise | void)[] = []; + return { + clear: () => { + tasks.length = 0; + beforeTasks.length = 0; + afterTasks.length = 0; + }, + run: async () => { + const runFn = async ( + fn: (done?: (e?: Error) => void) => Promise | void + ) => { + if (fn.length) { + await new Promise((resolve, reject) => { + fn(e => { + if (e) { + reject(e); + } else { + resolve(); + } + }); + }); + } else { + const res = fn(); + if (typeof res?.then === "function") { + await res; + } + } + }; + + for (const [description, fn] of tasks) { + for (const before of beforeTasks) { + await runFn(before); + } + try { + await runFn(fn); + } catch (e) { + throw new Error( + `Error: ${description} failed\n${(e as Error).stack}` + ); + } + for (const after of afterTasks) { + await runFn(after); + } + } + }, + expect, + it: (description: string, fn: () => Promise | void) => { + expect(typeof description === "string"); + expect(typeof fn === "function"); + tasks.push([description, fn]); + }, + beforeEach: (fn: () => Promise | void) => { + expect(typeof fn === "function"); + beforeTasks.push(fn); + }, + afterEach: (fn: () => Promise | void) => { + expect(typeof fn === "function"); + afterTasks.push(fn); + }, + jest + }; + } + protected createEnv(testConfig: TTestConfig): ITestEnv { if (typeof this._options.runner === "function" && !testConfig.noTest) { return createLazyTestEnv(10000); @@ -178,4 +336,30 @@ export class BasicCaseCreator { }) }); } + + protected tryRunTask() { + while ( + this.tasks.length !== 0 && + this.currentConcurrent < this.getMaxConcurrent() + ) { + const [_name, starter] = this.tasks.shift()!; + this.currentConcurrent++; + starter(); + } + } + + protected getMaxConcurrent() { + return typeof this._options.concurrent === "number" + ? this._options.concurrent + : DEFAULT_MAX_CONCURRENT; + } + + protected registerConcurrentTask(name: string, starter: () => void) { + this.tasks.push([name, starter]); + this.tryRunTask(); + return () => { + this.currentConcurrent--; + this.tryRunTask(); + }; + } } diff --git a/packages/rspack-test-tools/src/type.ts b/packages/rspack-test-tools/src/type.ts index 4b366112b18f..129088dcc5f9 100644 --- a/packages/rspack-test-tools/src/type.ts +++ b/packages/rspack-test-tools/src/type.ts @@ -207,7 +207,7 @@ export type TTestConfig = { ) => IBasicModuleScope; checkStats?: ( stepName: string, - jsonStats: TCompilerStatsCompilation, + jsonStats: TCompilerStatsCompilation | undefined, stringStats: String ) => boolean; findBundle?: ( @@ -219,6 +219,7 @@ export type TTestConfig = { nonEsmThis?: (p: string | string[]) => Object; modules?: Record; timeout?: number; + concurrent?: boolean; }; export type TTestFilter = ( diff --git a/packages/rspack-test-tools/tsconfig.build.json b/packages/rspack-test-tools/tsconfig.build.json index bd25c52bf66a..418e82591500 100644 --- a/packages/rspack-test-tools/tsconfig.build.json +++ b/packages/rspack-test-tools/tsconfig.build.json @@ -9,4 +9,4 @@ "path": "../rspack/tsconfig.build.json" } ] -} +} \ No newline at end of file