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
39 changes: 39 additions & 0 deletions docs/src/test-api/class-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,23 @@ You can also add annotations during runtime by manipulating [`property: TestInfo

Learn more about [test annotations](../test-annotations.md).

**Locks**

You can declare named locks to prevent specific tests from running at the same time, while all other tests continue to run in parallel. Tests that share a lock name never run concurrently, even when they are declared in different files or belong to different [projects](../test-projects.md). This is useful when a few tests access a shared resource that does not support concurrent access.

```js
import { test, expect } from '@playwright/test';

test('update user settings', {
lock: 'user-settings',
}, async ({ page }) => {
// This test never runs concurrently with other tests
// that declare the 'user-settings' lock.
});
```

Learn more about [test locks](../test-parallel.md#test-locks).

### param: Test.(call).title
* since: v1.10
- `title` <[string]>
Expand All @@ -98,6 +115,7 @@ Test title.
- `annotation` ?<[Object]|[Array]<[Object]>>
- `type` <[string]> Annotation type, for example `'issue'`.
- `description` ?<[string]> Optional annotation description, for example an issue url.
- `lock` ?<[string]|[Array]<[string]>>

Additional test details.

Expand Down Expand Up @@ -427,6 +445,26 @@ test.describe('two annotated tests', {

Learn more about [test annotations](../test-annotations.md).

**Locks**

You can declare named locks for all tests in a group by providing additional details. Tests that share a lock name never run concurrently. Learn more about [test locks](../test-parallel.md#test-locks).

```js
import { test, expect } from '@playwright/test';

test.describe('two tests with a lock', {
lock: 'user-settings',
}, () => {
test('one', async ({ page }) => {
// ...
});

test('two', async ({ page }) => {
// ...
});
});
```

### param: Test.describe.title
* since: v1.10
- `title` ?<[string]>
Expand All @@ -440,6 +478,7 @@ Group title.
- `annotation` ?<[Object]|[Array]<[Object]>>
- `type` <[string]>
- `description` ?<[string]>
- `lock` ?<[string]|[Array]<[string]>>

Additional details for all tests in the group.

Expand Down
37 changes: 37 additions & 0 deletions docs/src/test-parallel-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,43 @@ test('runs second', async () => {
});
```

## Test locks

A few tests in your suite may access a shared resource that does not support concurrent access, for example an external service or a global account setting. You can declare named locks on such tests, so that tests sharing a lock name never run at the same time, while all other tests continue to run in parallel. Locks work across files, worker processes and [projects](./test-projects.md).

```js title="settings.spec.ts"
import { test, expect } from '@playwright/test';

test('update user settings', { lock: 'user-settings' }, async ({ page }) => {
// ...
});
```

```js title="profile.spec.ts"
import { test, expect } from '@playwright/test';

// Never runs concurrently with 'update user settings' above,
// even in a different project.
test('rename user', { lock: 'user-settings' }, async ({ page }) => {
// ...
});
```

A test can declare multiple locks and will only run when all of them are available. You can also declare locks on a group with [`method: Test.describe`], applying them to every test inside.

```js
test('reset the database', { lock: ['database', 'external-api'] }, async () => {
// ...
});
```

Playwright acquires all the locks of a test before the test starts and releases them when it finishes. A test never waits for a lock while holding another one, so locks cannot deadlock. Waiting for a lock happens before the test is sent to a worker process and does not count towards the [test timeout](./test-timeouts.md).

Note that locks are scoped to a scheduling unit:
* In [fully parallel mode](#parallelize-tests-in-a-single-file), each test acquires its own locks. Tests from the same file that declare an identical set of locks run one after another in the same worker, and `beforeAll` and `afterAll` hooks execute under the lock.
* In the default mode, all tests in a file run together in order, so a lock declared on any test is held for the duration of the whole file. The same applies to [serial mode](#serial-mode) groups.
* When [sharding](#shard-tests-between-multiple-machines), locks are only enforced within each shard, because shards run on different machines.

## Opt out of fully parallel mode

If your configuration applies parallel mode to all tests using [`property: TestConfig.fullyParallel`], you might still want to run some tests with default settings. You can override the mode per describe:
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright/src/common/suiteUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export function bindFileSuiteToProject(project: FullProjectInternal, suite: Suit
for (let parentSuite: Suite | undefined = suite; parentSuite; parentSuite = parentSuite.parent) {
if (parentSuite._staticAnnotations.length)
test.annotations.unshift(...parentSuite._staticAnnotations);
if (parentSuite._locks.length)
test._locks.push(...parentSuite._locks);
if (inheritedRetries === undefined && parentSuite._retries !== undefined)
inheritedRetries = parentSuite._retries;
if (inheritedTimeout === undefined && parentSuite._timeout !== undefined)
Expand Down
9 changes: 9 additions & 0 deletions packages/playwright/src/common/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export class Suite extends Base {
_staticAnnotations: TestAnnotation[] = [];
// Explicitly declared tags that are not a part of the title.
_tags: string[] = [];
// Named locks that apply to all tests in the suite.
_locks: string[] = [];
_modifiers: Modifier[] = [];
_parallelMode: 'none' | 'default' | 'serial' | 'parallel' = 'none';
_fullProject: FullProjectInternal | undefined;
Expand Down Expand Up @@ -224,6 +226,7 @@ export class Suite extends Base {
retries: this._retries,
staticAnnotations: this._staticAnnotations.slice(),
tags: this._tags.slice(),
locks: this._locks.slice(),
modifiers: this._modifiers.slice(),
parallelMode: this._parallelMode,
hooks: this._hooks.map(h => ({ type: h.type, location: h.location, title: h.title })),
Expand All @@ -240,6 +243,7 @@ export class Suite extends Base {
suite._retries = data.retries;
suite._staticAnnotations = data.staticAnnotations;
suite._tags = data.tags;
suite._locks = data.locks;
suite._modifiers = data.modifiers;
suite._parallelMode = data.parallelMode;
suite._hooks = data.hooks.map((h: any) => ({ type: h.type, location: h.location, title: h.title, fn: () => { } }));
Expand Down Expand Up @@ -283,6 +287,9 @@ export class TestCase extends Base implements reporterTypes.TestCase {
_projectId = '';
// Explicitly declared tags that are not a part of the title.
_tags: string[] = [];
// Named locks of the test. Locks declared on parent suites are folded in
// when the file suite is bound to a project.
_locks: string[] = [];
_planAnnotations: TestAnnotation[] = [];

constructor(title: string, fn: Function, testType: TestTypeImpl, location: Location) {
Expand Down Expand Up @@ -342,6 +349,7 @@ export class TestCase extends Base implements reporterTypes.TestCase {
workerHash: this._workerHash,
annotations: this.annotations.slice(),
tags: this._tags.slice(),
locks: this._locks.slice(),
projectId: this._projectId,
};
}
Expand All @@ -358,6 +366,7 @@ export class TestCase extends Base implements reporterTypes.TestCase {
test._workerHash = data.workerHash;
test.annotations = data.annotations;
test._tags = data.tags;
test._locks = data.locks;
test._projectId = data.projectId;
return test;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright/src/common/testType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export class TestTypeImpl {
test._requireFile = suite._requireFile;
test.annotations.push(...validatedDetails.annotations);
test._tags.push(...validatedDetails.tags);
test._locks.push(...validatedDetails.locks);
suite._addTest(test);

if (type === 'only' || type === 'fail.only')
Expand Down Expand Up @@ -152,6 +153,7 @@ export class TestTypeImpl {
child.location = location;
child._staticAnnotations.push(...validatedDetails.annotations);
child._tags.push(...validatedDetails.tags);
child._locks.push(...validatedDetails.locks);
suite._addSuite(child);

if (type === 'only' || type === 'serial.only' || type === 'parallel.only')
Expand Down
11 changes: 11 additions & 0 deletions packages/playwright/src/common/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,19 @@ const testDetailsSchema: JsonSchema = {
{ type: 'array', items: testAnnotationSchema },
]
},
lock: {
oneOf: [
{ type: 'string' },
{ type: 'array', items: { type: 'string' } },
]
},
},
};

type ValidTestDetails = {
tags: string[];
annotations: (TestDetailsAnnotation & { location: Location })[];
locks: string[];
location: Location;
};

Expand All @@ -65,9 +72,13 @@ export function validateTestDetails(details: unknown, location: Location): Valid
const annotation = obj.annotation;
const annotations: TestDetailsAnnotation[] = annotation === undefined ? [] : Array.isArray(annotation) ? annotation : [annotation as TestDetailsAnnotation];

const lock = obj.lock;
const locks: string[] = lock === undefined ? [] : typeof lock === 'string' ? [lock] : lock as string[];

return {
annotations: annotations.map(a => ({ ...a, location })),
tags,
locks,
location,
};
}
46 changes: 36 additions & 10 deletions packages/playwright/src/runner/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,32 @@ export class Dispatcher {
}
}

private _heldLocks(): Set<string> | undefined {
let heldLocks: Set<string> | undefined;
for (const slot of this._workerSlots) {
const locks = slot.jobDispatcher?.job.locks;
if (!locks?.length)
continue;
heldLocks ??= new Set();
for (const lock of locks)
heldLocks.add(lock);
}
return heldLocks;
}

private _findFirstJobToRun() {
const heldLocks = this._heldLocks();
// Always pick the first job that can be run while respecting the project worker limit.
for (let index = 0; index < this._queue.length; index++) {
const job = this._queue[index];
// Isolated retries only run one at a time, after all other jobs have finished.
if (this._isolatedJobs.has(job) && this._workerSlots.some(w => !!w.jobDispatcher))
continue;
// Jobs sharing a lock do not run concurrently. All the locks are acquired
// when the job is scheduled and released when it finishes, so that jobs
// never wait for locks while holding some of them.
if (heldLocks && job.locks.some(lock => heldLocks.has(lock)))
continue;
const projectIdWorkerLimit = this._workerLimitPerProjectId.get(job.projectId);
if (!projectIdWorkerLimit)
return index;
Expand All @@ -75,17 +94,24 @@ export class Dispatcher {
return -1;
}

private _scheduleJob() {
private _scheduleJobs() {
// Schedule as many jobs as possible. Finishing a job releases its locks,
// which may unblock multiple queued jobs at once.
while (this._scheduleJob()) {}
}

private _scheduleJob(): boolean {
// NOTE: keep this method synchronous for easier reasoning.

// 0. No more running jobs after stop.
if (this._isStopped)
return;
// 0. No more running jobs after stop. Bail out early when all workers
// are busy to avoid scanning the queue.
if (this._isStopped || !this._workerSlots.some(w => !w.jobDispatcher))
return false;

// 1. Find a job to run.
const jobIndex = this._findFirstJobToRun();
if (jobIndex === -1)
return;
return false;
const job = this._queue[jobIndex];

// 2. Find a worker with the same hash, or just some free worker.
Expand All @@ -94,7 +120,7 @@ export class Dispatcher {
workerIndex = this._workerSlots.findIndex(w => !w.jobDispatcher);
if (workerIndex === -1) {
// No workers available, bail out.
return;
return false;
}

// 3. Claim both the job and the worker slot.
Expand All @@ -108,10 +134,11 @@ export class Dispatcher {
// 5. Release the worker slot.
this._workerSlots[workerIndex].jobDispatcher = undefined;

// 6. Check whether we are done or should schedule another job.
// 6. Check whether we are done or should schedule other jobs.
this._checkFinished();
this._scheduleJob();
this._scheduleJobs();
});
return true;
}

private async _runJobInWorker(index: number, jobDispatcher: JobDispatcher) {
Expand Down Expand Up @@ -213,8 +240,7 @@ export class Dispatcher {
for (let i = 0; i < this._testRun.config.config.workers; i++)
this._workerSlots.push({});
// 2. Schedule enough jobs.
for (let i = 0; i < this._workerSlots.length; i++)
this._scheduleJob();
this._scheduleJobs();
this._checkFinished();
// 3. More jobs are scheduled when the worker becomes free.
// 4. Wait for all jobs to finish.
Expand Down
25 changes: 22 additions & 3 deletions packages/playwright/src/runner/testGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export type TestGroup = {
repeatEachIndex: number;
projectId: string;
tests: test.TestCase[];
// Named locks held by the tests in the group. The group does not run
// concurrently with any other group that shares a lock.
locks: string[];
};

export function createTestGroups(projectSuite: test.Suite, expectedParallelism: number): TestGroup[] {
Expand All @@ -42,12 +45,15 @@ export function createTestGroups(projectSuite: test.Suite, expectedParallelism:
// There are 3 kinds of parallel tests:
// - Tests belonging to parallel suites, without beforeAll/afterAll hooks.
// These can be run independently, they are put into their own group, key === test.
// Tests with locks are grouped by their lock signature instead, key === signature,
// even with beforeAll/afterAll hooks. Tests sharing all the locks cannot run
// concurrently anyway, and this way a lock is never held for unrelated tests.
// - Tests belonging to parallel suites, with beforeAll/afterAll hooks.
// These should share the worker as much as possible, put into single parallelWithHooks group.
// We'll divide them into equally-sized groups later.
// - Tests belonging to serial suites inside parallel suites.
// These should run as a serial group, each group is independent, key === serial suite.
parallel: Map<test.Suite | test.TestCase, TestGroup>,
parallel: Map<test.Suite | test.TestCase | string, TestGroup>,
parallelWithHooks: TestGroup,
}>>();

Expand All @@ -58,6 +64,7 @@ export function createTestGroups(projectSuite: test.Suite, expectedParallelism:
repeatEachIndex: test.repeatEachIndex,
projectId: test._projectId,
tests: [],
locks: [],
};
};

Expand Down Expand Up @@ -89,10 +96,10 @@ export function createTestGroups(projectSuite: test.Suite, expectedParallelism:
}

if (insideParallel) {
if (hasAllHooks && !outerMostSequentialSuite) {
if (hasAllHooks && !outerMostSequentialSuite && !test._locks.length) {
withRequireFile.parallelWithHooks.tests.push(test);
} else {
const key = outerMostSequentialSuite || test;
const key = outerMostSequentialSuite || (test._locks.length ? [...new Set(test._locks)].sort().join('\x1e') : test);
let group = withRequireFile.parallel.get(key);
if (!group) {
group = createGroup(test);
Expand Down Expand Up @@ -127,6 +134,18 @@ export function createTestGroups(projectSuite: test.Suite, expectedParallelism:
}
}
}

for (const group of result) {
let locks: Set<string> | undefined;
for (const test of group.tests) {
for (const lock of test._locks) {
locks ??= new Set();
locks.add(lock);
}
}
if (locks)
group.locks = [...locks];
}
return result;
}

Expand Down
Loading
Loading