Skip to content

Commit 2b6b380

Browse files
committed
feat: support TestProject.workers limit
1 parent 7fe1777 commit 2b6b380

File tree

7 files changed

+222
-40
lines changed

7 files changed

+222
-40
lines changed

docs/src/test-api/class-testproject.md

+32
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,35 @@ export default defineConfig({
392392
```
393393

394394
Use [`property: TestConfig.use`] to change this option for all projects.
395+
396+
## property: TestProject.workers
397+
* since: v1.52
398+
- type: ?<[int]|[string]>
399+
400+
The maximum number of concurrent worker processes to use for parallelizing tests from this project. Can also be set as percentage of logical CPU cores, e.g. `'50%'.`
401+
402+
This could be useful, for example, when all tests from a project share a single resource like a test account, and therefore cannot be executed in parallel. Limiting workers to one for such a project will prevent simultaneous use of the shared resource.
403+
404+
Note that the global [`property: TestConfig.workers`] limit applies to the total number of worker processes. However, Playwright will limit the number of workers used for this project by the value of [`property: TestProject.workers`].
405+
406+
By default, there is no limit per project. See [`property: TestConfig.workers`] for the default of the total worker limit.
407+
408+
**Usage**
409+
410+
```js title="playwright.config.ts"
411+
import { defineConfig } from '@playwright/test';
412+
413+
export default defineConfig({
414+
workers: 10, // total workers limit
415+
416+
projects: [
417+
{
418+
name: 'runs in parallel',
419+
},
420+
{
421+
name: 'one at a time',
422+
workers: 1, // workers limit for this project
423+
},
424+
],
425+
});
426+
```

packages/playwright/src/common/config.ts

+17-19
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export class FullConfigInternal {
108108
updateSnapshots: takeFirst(configCLIOverrides.updateSnapshots, userConfig.updateSnapshots, 'missing'),
109109
updateSourceMethod: takeFirst(configCLIOverrides.updateSourceMethod, userConfig.updateSourceMethod, 'patch'),
110110
version: require('../../package.json').version,
111-
workers: 0,
111+
workers: resolveWorkers(takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.workers, userConfig.workers, '50%')),
112112
webServer: null,
113113
};
114114
for (const key in userConfig) {
@@ -118,18 +118,6 @@ export class FullConfigInternal {
118118

119119
(this.config as any)[configInternalSymbol] = this;
120120

121-
const workers = takeFirst(configCLIOverrides.debug ? 1 : undefined, configCLIOverrides.workers, userConfig.workers, '50%');
122-
if (typeof workers === 'string') {
123-
if (workers.endsWith('%')) {
124-
const cpus = os.cpus().length;
125-
this.config.workers = Math.max(1, Math.floor(cpus * (parseInt(workers, 10) / 100)));
126-
} else {
127-
this.config.workers = parseWorkers(workers);
128-
}
129-
} else {
130-
this.config.workers = workers;
131-
}
132-
133121
const webServers = takeFirst(userConfig.webServer, null);
134122
if (Array.isArray(webServers)) { // multiple web server mode
135123
// Due to previous choices, this value shows up to the user in globalSetup as part of FullConfig. Arrays are not supported by the old type.
@@ -174,6 +162,7 @@ export class FullProjectInternal {
174162
readonly respectGitIgnore: boolean;
175163
readonly snapshotPathTemplate: string | undefined;
176164
readonly ignoreSnapshots: boolean;
165+
readonly workers: number | undefined;
177166
id = '';
178167
deps: FullProjectInternal[] = [];
179168
teardown: FullProjectInternal | undefined;
@@ -210,6 +199,9 @@ export class FullProjectInternal {
210199
}
211200
this.respectGitIgnore = takeFirst(projectConfig.respectGitIgnore, config.respectGitIgnore, !projectConfig.testDir && !config.testDir);
212201
this.ignoreSnapshots = takeFirst(configCLIOverrides.ignoreSnapshots, projectConfig.ignoreSnapshots, config.ignoreSnapshots, false);
202+
this.workers = projectConfig.workers ? resolveWorkers(projectConfig.workers) : undefined;
203+
if (configCLIOverrides.debug && this.workers)
204+
this.workers = 1;
213205
}
214206
}
215207

@@ -235,12 +227,18 @@ function resolveReporters(reporters: Config['reporter'], rootDir: string): Repor
235227
});
236228
}
237229

238-
function parseWorkers(workers: string) {
239-
const parsedWorkers = parseInt(workers, 10);
240-
if (isNaN(parsedWorkers))
241-
throw new Error(`Workers ${workers} must be a number or percentage.`);
242-
243-
return parsedWorkers;
230+
function resolveWorkers(workers: string | number): number {
231+
if (typeof workers === 'string') {
232+
if (workers.endsWith('%')) {
233+
const cpus = os.cpus().length;
234+
return Math.max(1, Math.floor(cpus * (parseInt(workers, 10) / 100)));
235+
}
236+
const parsedWorkers = parseInt(workers, 10);
237+
if (isNaN(parsedWorkers))
238+
throw new Error(`Workers ${workers} must be a number or percentage.`);
239+
return parsedWorkers;
240+
}
241+
return workers;
244242
}
245243

246244
function resolveProjectDependencies(projects: FullProjectInternal[]) {

packages/playwright/src/common/configLoader.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -248,13 +248,6 @@ function validateConfig(file: string, config: Config) {
248248
throw errorWithFile(file, `config.updateSnapshots must be one of "all", "changed", "missing" or "none"`);
249249
}
250250

251-
if ('workers' in config && config.workers !== undefined) {
252-
if (typeof config.workers === 'number' && config.workers <= 0)
253-
throw errorWithFile(file, `config.workers must be a positive number`);
254-
else if (typeof config.workers === 'string' && !config.workers.endsWith('%'))
255-
throw errorWithFile(file, `config.workers must be a number or percentage`);
256-
}
257-
258251
if ('tsconfig' in config && config.tsconfig !== undefined) {
259252
if (typeof config.tsconfig !== 'string')
260253
throw errorWithFile(file, `config.tsconfig must be a string`);
@@ -320,6 +313,13 @@ function validateProject(file: string, project: Project, title: string) {
320313
if (typeof project.ignoreSnapshots !== 'boolean')
321314
throw errorWithFile(file, `${title}.ignoreSnapshots must be a boolean`);
322315
}
316+
317+
if ('workers' in project && project.workers !== undefined) {
318+
if (typeof project.workers === 'number' && project.workers <= 0)
319+
throw errorWithFile(file, `${title}.workers must be a positive number`);
320+
else if (typeof project.workers === 'string' && !project.workers.endsWith('%'))
321+
throw errorWithFile(file, `${title}.workers must be a number or percentage`);
322+
}
323323
}
324324

325325
export function resolveConfigLocation(configFile: string | undefined): ConfigLocation {

packages/playwright/src/runner/dispatcher.ts

+36-14
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type EnvByProjectId = Map<string, Record<string, string | undefined>>;
3838
export class Dispatcher {
3939
private _workerSlots: { busy: boolean, worker?: WorkerHost, jobDispatcher?: JobDispatcher }[] = [];
4040
private _queue: TestGroup[] = [];
41+
private _workerLimitPerProjectId = new Map<string, number>();
4142
private _queuedOrRunningHashCount = new Map<string, number>();
4243
private _finished = new ManualPromise<void>();
4344
private _isStopped = true;
@@ -53,27 +54,48 @@ export class Dispatcher {
5354
this._config = config;
5455
this._reporter = reporter;
5556
this._failureTracker = failureTracker;
57+
for (const project of config.projects) {
58+
if (project.workers)
59+
this._workerLimitPerProjectId.set(project.id, project.workers);
60+
}
5661
}
5762

5863
private async _scheduleJob() {
59-
// 1. Find a job to run.
60-
if (this._isStopped || !this._queue.length)
64+
// 1. Find a job/worker combination to run.
65+
if (this._isStopped)
6166
return;
62-
const job = this._queue[0];
63-
64-
// 2. Find a worker with the same hash, or just some free worker.
65-
let index = this._workerSlots.findIndex(w => !w.busy && w.worker && w.worker.hash() === job.workerHash && !w.worker.didSendStop());
66-
if (index === -1)
67-
index = this._workerSlots.findIndex(w => !w.busy);
68-
// No workers available, bail out.
69-
if (index === -1)
67+
68+
let jobIndex = -1;
69+
let workerIndex = -1;
70+
for (let index = 0; index < this._queue.length; index++) {
71+
const job = this._queue[index];
72+
73+
// 2.1 Respect the project worker limit.
74+
const projectIdWorkerLimit = this._workerLimitPerProjectId.get(job.projectId);
75+
if (projectIdWorkerLimit) {
76+
const runningWorkersWithSameProjectId = this._workerSlots.filter(w => w.busy && w.worker && w.worker.projectId() === job.projectId).length;
77+
if (runningWorkersWithSameProjectId >= projectIdWorkerLimit)
78+
continue;
79+
}
80+
81+
// 2.2. Find a worker with the same hash, or just some free worker.
82+
workerIndex = this._workerSlots.findIndex(w => !w.busy && w.worker && w.worker.hash() === job.workerHash && !w.worker.didSendStop());
83+
if (workerIndex === -1)
84+
workerIndex = this._workerSlots.findIndex(w => !w.busy);
85+
jobIndex = index;
86+
break;
87+
}
88+
89+
// 2.3. No workers available, bail out.
90+
if (jobIndex === -1)
7091
return;
7192

7293
// 3. Claim both the job and the worker, run the job and release the worker.
73-
this._queue.shift();
74-
this._workerSlots[index].busy = true;
75-
await this._startJobInWorker(index, job);
76-
this._workerSlots[index].busy = false;
94+
const job = this._queue[jobIndex];
95+
this._queue.splice(jobIndex, 1);
96+
this._workerSlots[workerIndex].busy = true;
97+
await this._startJobInWorker(workerIndex, job);
98+
this._workerSlots[workerIndex].busy = false;
7799

78100
// 4. Check the "finished" condition.
79101
this._checkFinished();

packages/playwright/src/runner/workerHost.ts

+4
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ export class WorkerHost extends ProcessHost {
8383
return this._hash;
8484
}
8585

86+
projectId() {
87+
return this._params.projectId;
88+
}
89+
8690
didFail() {
8791
return this._didFail;
8892
}

packages/playwright/types/test.d.ts

+41
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,47 @@ interface TestProject<TestArgs = {}, WorkerArgs = {}> {
676676
* option for all projects.
677677
*/
678678
timeout?: number;
679+
680+
/**
681+
* The maximum number of concurrent worker processes to use for parallelizing tests from this project. Can also be set
682+
* as percentage of logical CPU cores, e.g. `'50%'.`
683+
*
684+
* This could be useful, for example, when all tests from a project share a single resource like a test account, and
685+
* therefore cannot be executed in parallel. Limiting workers to one for such a project will prevent simultaneous use
686+
* of the shared resource.
687+
*
688+
* Note that the global [testConfig.workers](https://playwright.dev/docs/api/class-testconfig#test-config-workers)
689+
* limit applies to the total number of worker processes. However, Playwright will limit the number of workers used
690+
* for this project by the value of
691+
* [testProject.workers](https://playwright.dev/docs/api/class-testproject#test-project-workers).
692+
*
693+
* By default, there is no limit per project. See
694+
* [testConfig.workers](https://playwright.dev/docs/api/class-testconfig#test-config-workers) for the default of the
695+
* total worker limit.
696+
*
697+
* **Usage**
698+
*
699+
* ```js
700+
* // playwright.config.ts
701+
* import { defineConfig } from '@playwright/test';
702+
*
703+
* export default defineConfig({
704+
* workers: 10, // total workers limit
705+
*
706+
* projects: [
707+
* {
708+
* name: 'runs in parallel',
709+
* },
710+
* {
711+
* name: 'one at a time',
712+
* workers: 1, // workers limit for this project
713+
* },
714+
* ],
715+
* });
716+
* ```
717+
*
718+
*/
719+
workers?: number|string;
679720
}
680721

681722
export interface Project<TestArgs = {}, WorkerArgs = {}> extends TestProject<TestArgs, WorkerArgs> {

tests/playwright-test/worker-index.spec.ts

+85
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,88 @@ test('should not spawn workers for statically skipped tests', async ({ runInline
239239
expect(result.output).toContain('workerIndex=0');
240240
expect(result.output).not.toContain('workerIndex=1');
241241
});
242+
243+
test('should respect project.workers=1', async ({ runInlineTest }) => {
244+
const result = await runInlineTest({
245+
'playwright.config.ts': `
246+
export default {
247+
workers: 10,
248+
projects: [
249+
{ name: 'project1', workers: 1 },
250+
{ name: 'project2', workers: 1 },
251+
],
252+
};
253+
`,
254+
'a.test.js': `
255+
import { test, expect } from '@playwright/test';
256+
test.describe.configure({ mode: 'parallel' });
257+
test('test1', async ({}, testInfo) => {
258+
console.log('%%test1-begin:' + testInfo.project.name);
259+
await new Promise(f => setTimeout(f, 1000 + (testInfo.project.name === 'project1' ? 2000 : 0)));
260+
console.log('%%test1-end:' + testInfo.project.name);
261+
});
262+
test('test2', async ({}, testInfo) => {
263+
console.log('%%test2:' + testInfo.project.name);
264+
});
265+
`,
266+
}, { workers: 10 });
267+
expect(result.passed).toBe(4);
268+
expect(result.exitCode).toBe(0);
269+
270+
// test1 from both projects start, test2 starts once test1 for that project finishes
271+
expect(result.outputLines.slice(0, 2).sort()).toEqual([
272+
'test1-begin:project1',
273+
'test1-begin:project2',
274+
]);
275+
expect(result.outputLines.slice(2, 6)).toEqual([
276+
'test1-end:project2',
277+
'test2:project2',
278+
'test1-end:project1',
279+
'test2:project1',
280+
]);
281+
});
282+
283+
test('should respect project.workers>1', async ({ runInlineTest }) => {
284+
const result = await runInlineTest({
285+
'playwright.config.ts': `
286+
export default {
287+
workers: 10,
288+
projects: [
289+
{ name: 'project', workers: 2 },
290+
],
291+
};
292+
`,
293+
'a.test.js': `
294+
import { test, expect } from '@playwright/test';
295+
test.describe.configure({ mode: 'parallel' });
296+
test('test1', async ({}, testInfo) => {
297+
console.log('%%test1-begin');
298+
await new Promise(f => setTimeout(f, 2000));
299+
console.log('%%test1-end');
300+
});
301+
test('test2', async ({}, testInfo) => {
302+
console.log('%%test2-begin');
303+
await new Promise(f => setTimeout(f, 1000));
304+
console.log('%%test2-end');
305+
});
306+
test('test3', async ({}, testInfo) => {
307+
console.log('%%test3');
308+
});
309+
`,
310+
}, { workers: 10 });
311+
expect(result.passed).toBe(3);
312+
expect(result.exitCode).toBe(0);
313+
314+
// 1+2 start, 1 finishes => 3 runs, 2 finishes
315+
expect(result.outputLines.slice(0, 2).sort()).toEqual([
316+
'test1-begin',
317+
'test2-begin',
318+
]);
319+
expect(result.outputLines.slice(2, 4)).toEqual([
320+
'test2-end',
321+
'test3',
322+
]);
323+
expect(result.outputLines.slice(4, 5)).toEqual([
324+
'test1-end',
325+
]);
326+
});

0 commit comments

Comments
 (0)