diff --git a/docs/src/test-api/class-test.md b/docs/src/test-api/class-test.md index 4992d168c67dc..6342b409018ab 100644 --- a/docs/src/test-api/class-test.md +++ b/docs/src/test-api/class-test.md @@ -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]> @@ -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. @@ -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]> @@ -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. diff --git a/docs/src/test-parallel-js.md b/docs/src/test-parallel-js.md index 54af7d15bf9fc..eca272bb6cb47 100644 --- a/docs/src/test-parallel-js.md +++ b/docs/src/test-parallel-js.md @@ -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: diff --git a/packages/playwright/src/common/suiteUtils.ts b/packages/playwright/src/common/suiteUtils.ts index 6c7128cca6cf9..5748da9cf1f14 100644 --- a/packages/playwright/src/common/suiteUtils.ts +++ b/packages/playwright/src/common/suiteUtils.ts @@ -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) diff --git a/packages/playwright/src/common/test.ts b/packages/playwright/src/common/test.ts index 60d1fc2e2156f..824d28d21a408 100644 --- a/packages/playwright/src/common/test.ts +++ b/packages/playwright/src/common/test.ts @@ -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; @@ -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 })), @@ -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: () => { } })); @@ -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) { @@ -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, }; } @@ -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; } diff --git a/packages/playwright/src/common/testType.ts b/packages/playwright/src/common/testType.ts index 9911efe09c78d..08adee0f6b99a 100644 --- a/packages/playwright/src/common/testType.ts +++ b/packages/playwright/src/common/testType.ts @@ -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') @@ -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') diff --git a/packages/playwright/src/common/validators.ts b/packages/playwright/src/common/validators.ts index 0dd13523657b1..e85ee11a7c192 100644 --- a/packages/playwright/src/common/validators.ts +++ b/packages/playwright/src/common/validators.ts @@ -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; }; @@ -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, }; } diff --git a/packages/playwright/src/runner/dispatcher.ts b/packages/playwright/src/runner/dispatcher.ts index bec64ec409447..393b02c1d59fd 100644 --- a/packages/playwright/src/runner/dispatcher.ts +++ b/packages/playwright/src/runner/dispatcher.ts @@ -58,13 +58,32 @@ export class Dispatcher { } } + private _heldLocks(): Set | undefined { + let heldLocks: Set | 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; @@ -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. @@ -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. @@ -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) { @@ -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. diff --git a/packages/playwright/src/runner/testGroups.ts b/packages/playwright/src/runner/testGroups.ts index f9d6dd6eb8109..7814e5a1a5e7f 100644 --- a/packages/playwright/src/runner/testGroups.ts +++ b/packages/playwright/src/runner/testGroups.ts @@ -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[] { @@ -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, + parallel: Map, parallelWithHooks: TestGroup, }>>(); @@ -58,6 +64,7 @@ export function createTestGroups(projectSuite: test.Suite, expectedParallelism: repeatEachIndex: test.repeatEachIndex, projectId: test._projectId, tests: [], + locks: [], }; }; @@ -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); @@ -127,6 +134,18 @@ export function createTestGroups(projectSuite: test.Suite, expectedParallelism: } } } + + for (const group of result) { + let locks: Set | 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; } diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 44d6742a8cf60..27531278f28cd 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -2719,6 +2719,7 @@ export type TestAnnotation = TestDetailsAnnotation & { export type TestDetails = { tag?: string | string[]; annotation?: TestDetailsAnnotation | TestDetailsAnnotation[]; + lock?: string | string[]; } type TestBody = (args: TestArgs, testInfo: TestInfo) => Promise | unknown; @@ -2810,6 +2811,26 @@ export interface TestType { * [testInfo.annotations](https://playwright.dev/docs/api/class-testinfo#test-info-annotations). * * Learn more about [test annotations](https://playwright.dev/docs/test-annotations). + * + * **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](https://playwright.dev/docs/test-projects). 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](https://playwright.dev/docs/test-parallel#test-locks). * @param title Test title. * @param details Additional test details. * @param body Test body that takes one or two arguments: an object with fixtures and optional @@ -2887,6 +2908,26 @@ export interface TestType { * [testInfo.annotations](https://playwright.dev/docs/api/class-testinfo#test-info-annotations). * * Learn more about [test annotations](https://playwright.dev/docs/test-annotations). + * + * **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](https://playwright.dev/docs/test-projects). 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](https://playwright.dev/docs/test-parallel#test-locks). * @param title Test title. * @param details Additional test details. * @param body Test body that takes one or two arguments: an object with fixtures and optional @@ -3023,6 +3064,28 @@ export interface TestType { * ``` * * Learn more about [test annotations](https://playwright.dev/docs/test-annotations). + * + * **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](https://playwright.dev/docs/test-parallel#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 title Group title. * @param details Additional details for all tests in the group. * @param callback A callback that is run immediately when calling @@ -3118,6 +3181,28 @@ export interface TestType { * ``` * * Learn more about [test annotations](https://playwright.dev/docs/test-annotations). + * + * **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](https://playwright.dev/docs/test-parallel#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 title Group title. * @param details Additional details for all tests in the group. * @param callback A callback that is run immediately when calling @@ -3213,6 +3298,28 @@ export interface TestType { * ``` * * Learn more about [test annotations](https://playwright.dev/docs/test-annotations). + * + * **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](https://playwright.dev/docs/test-parallel#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 title Group title. * @param details Additional details for all tests in the group. * @param callback A callback that is run immediately when calling @@ -3308,6 +3415,28 @@ export interface TestType { * ``` * * Learn more about [test annotations](https://playwright.dev/docs/test-annotations). + * + * **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](https://playwright.dev/docs/test-parallel#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 title Group title. * @param details Additional details for all tests in the group. * @param callback A callback that is run immediately when calling diff --git a/tests/playwright-test/test-locks.spec.ts b/tests/playwright-test/test-locks.spec.ts new file mode 100644 index 0000000000000..d3cff45c0473e --- /dev/null +++ b/tests/playwright-test/test-locks.spec.ts @@ -0,0 +1,262 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, countTimes } from './playwright-test-fixtures'; + +// Each test prints '%%begin:' and '%%end:' lines. Walks the lines +// and returns pairs of tests from the `conflicts` list that were running +// at the same time. +function conflictingOverlaps(lines: string[], conflicts: [string, string][]): [string, string][] { + const running = new Set(); + const overlaps: [string, string][] = []; + for (const line of lines) { + const [kind, name] = line.split(':'); + if (kind === 'begin') { + for (const [x, y] of conflicts) { + if ((name === x && running.has(y)) || (name === y && running.has(x))) + overlaps.push([x, y]); + } + running.add(name); + } else if (kind === 'end') { + running.delete(name); + } + } + return overlaps; +} + +const lockedTest = (name: string, delay: number, lock?: string | string[]) => ` + test('${name}'${lock !== undefined ? `, { lock: ${JSON.stringify(lock)} }` : ''}, async () => { + console.log('\\n%%begin:${name}'); + await new Promise(f => setTimeout(f, ${delay})); + console.log('\\n%%end:${name}'); + }); +`; + +test('should not run tests with the same lock at the same time', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { fullyParallel: true }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('test1', 1000, 'shared')} + `, + 'b.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('test2', 1000, 'shared')} + `, + }, { workers: 2 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + expect(conflictingOverlaps(result.outputLines, [['test1', 'test2']])).toEqual([]); +}); + +test('should run tests with different locks at the same time', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { fullyParallel: true }; + `, + 'helper.ts': ` + import fs from 'fs'; + import path from 'path'; + export async function signalAndWait(signal: string, waitFor: string) { + fs.mkdirSync(process.env.SIGNAL_DIR, { recursive: true }); + fs.writeFileSync(path.join(process.env.SIGNAL_DIR, signal), ''); + while (!fs.existsSync(path.join(process.env.SIGNAL_DIR, waitFor))) + await new Promise(f => setTimeout(f, 100)); + } + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + import { signalAndWait } from './helper'; + test('test1', { lock: 'lock-a' }, async () => { + // Only finishes when both tests run at the same time. + await signalAndWait('a.txt', 'b.txt'); + }); + `, + 'b.test.ts': ` + import { test } from '@playwright/test'; + import { signalAndWait } from './helper'; + test('test2', { lock: 'lock-b' }, async () => { + await signalAndWait('b.txt', 'a.txt'); + }); + `, + }, { workers: 2 }, { SIGNAL_DIR: test.info().outputDir }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); +}); + +test('should not run tests with the same lock from different projects at the same time', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { + projects: [ + { name: 'project1' }, + { name: 'project2' }, + ], + }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('test1', { lock: 'shared' }, async ({}, testInfo) => { + console.log('\\n%%begin:' + testInfo.project.name); + await new Promise(f => setTimeout(f, 1000)); + console.log('\\n%%end:' + testInfo.project.name); + }); + `, + }, { workers: 2 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + expect(conflictingOverlaps(result.outputLines, [['project1', 'project2']])).toEqual([]); +}); + +test('should support locks declared on a describe group', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { fullyParallel: true }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test.describe('locked suite', { lock: 'shared' }, () => { + ${lockedTest('test1', 1000)} + ${lockedTest('test2', 1000)} + }); + `, + }, { workers: 2 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + expect(conflictingOverlaps(result.outputLines, [['test1', 'test2']])).toEqual([]); +}); + +test('should support multiple locks on a single test', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { fullyParallel: true }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('test1', 1000, ['lock-a', 'lock-b'])} + `, + 'b.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('test2', 1000, 'lock-a')} + `, + 'c.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('test3', 1000, 'lock-b')} + `, + }, { workers: 3 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(3); + expect(conflictingOverlaps(result.outputLines, [['test1', 'test2'], ['test1', 'test3']])).toEqual([]); +}); + +test('should hold the lock for the whole file group in default mode', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('a1', 500, 'shared')} + ${lockedTest('a2', 500)} + `, + 'b.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('b1', 1000, 'shared')} + `, + }, { workers: 2 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(3); + // The lock declared on a1 covers the whole file, including a2. + expect(conflictingOverlaps(result.outputLines, [['a1', 'b1'], ['a2', 'b1']])).toEqual([]); +}); + +test('should run locked tests from a parallel suite with beforeAll hooks in a separate group', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { fullyParallel: true }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test.beforeAll(() => { + console.log('\\n%%beforeAll'); + }); + test('plain1', async () => {}); + test('plain2', async () => {}); + ${lockedTest('test1', 1000, 'shared')} + `, + 'b.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('test2', 1000, 'shared')} + `, + }, { workers: 2 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(4); + expect(result.output).toContain('%%beforeAll'); + expect(conflictingOverlaps(result.outputLines, [['test1', 'test2']])).toEqual([]); +}); + +test('should run tests with the same locks as a single group', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { fullyParallel: true }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test.beforeAll(() => { + console.log('\\n%%beforeAll'); + }); + ${lockedTest('test1', 100, 'shared')} + ${lockedTest('test2', 100, 'shared')} + ${lockedTest('test3', 100, 'shared')} + `, + }, { workers: 2 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(3); + // Tests sharing the same locks run as one group in one worker, + // so beforeAll executes once. + expect(countTimes(result.output, '%%beforeAll')).toBe(1); +}); + +test('should not count waiting for a lock towards the test timeout', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { timeout: 3000 }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('test1', 2000, 'shared')} + `, + 'b.test.ts': ` + import { test } from '@playwright/test'; + ${lockedTest('test2', 2000, 'shared')} + `, + }, { workers: 2 }); + // Each test takes 2000ms, together above the 3000ms timeout. Waiting for + // the lock happens before the test starts and is not a part of the test time. + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + expect(conflictingOverlaps(result.outputLines, [['test1', 'test2']])).toEqual([]); +}); + +test('should validate lock in test details', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('test1', { lock: 42 }, async () => {}); + `, + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('details.lock'); +}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 713979b3bf95e..0560014c3ad8e 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -99,6 +99,7 @@ export type TestAnnotation = TestDetailsAnnotation & { export type TestDetails = { tag?: string | string[]; annotation?: TestDetailsAnnotation | TestDetailsAnnotation[]; + lock?: string | string[]; } type TestBody = (args: TestArgs, testInfo: TestInfo) => Promise | unknown;