feat: project.teardown that runs after all dependents have finished (#22696)

This replicates globalTeardown in the deps world.

Fixes #21914.
This commit is contained in:
Dmitry Gozman 2023-04-28 14:27:08 -07:00 committed by GitHub
parent 2a675026de
commit dbb218a9d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 373 additions and 10 deletions

View File

@ -57,7 +57,7 @@ behaves as if they were not specified.
Using dependencies allows global setup to produce traces and other artifacts, Using dependencies allows global setup to produce traces and other artifacts,
see the setup steps in the test report, etc. see the setup steps in the test report, etc.
For example: **Usage**
```js ```js
// playwright.config.ts // playwright.config.ts
@ -198,6 +198,52 @@ Use [`method: Test.describe.configure`] to change the number of retries for a sp
Use [`property: TestConfig.retries`] to change this option for all projects. Use [`property: TestConfig.retries`] to change this option for all projects.
## property: TestProject.teardown
* since: v1.34
- type: ?<[string]>
Name of a project that needs to run after this and any dependent projects have finished. Teardown is useful to cleanup any resources acquired by this project.
Passing `--no-deps` argument ignores [`property: TestProject.teardown`] and behaves as if it was not specified.
**Usage**
A common pattern is a "setup" dependency that has a corresponding "teardown":
```js
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /global.setup\.ts/,
teardown: 'teardown',
},
{
name: 'teardown',
testMatch: /global.teardown\.ts/,
},
{
name: 'chromium',
use: devices['Desktop Chrome'],
dependencies: ['setup'],
},
{
name: 'firefox',
use: devices['Desktop Firefox'],
dependencies: ['setup'],
},
{
name: 'webkit',
use: devices['Desktop Safari'],
dependencies: ['setup'],
},
],
});
```
## property: TestProject.testDir ## property: TestProject.testDir
* since: v1.10 * since: v1.10
- type: ?<[string]> - type: ?<[string]>

View File

@ -152,6 +152,7 @@ export class FullProjectInternal {
readonly snapshotPathTemplate: string; readonly snapshotPathTemplate: string;
id = ''; id = '';
deps: FullProjectInternal[] = []; deps: FullProjectInternal[] = [];
teardown: FullProjectInternal | undefined;
constructor(configDir: string, config: Config, fullConfig: FullConfigInternal, projectConfig: Project, configCLIOverrides: ConfigCLIOverrides, throwawayArtifactsPath: string) { constructor(configDir: string, config: Config, fullConfig: FullConfigInternal, projectConfig: Project, configCLIOverrides: ConfigCLIOverrides, throwawayArtifactsPath: string) {
this.fullConfig = fullConfig; this.fullConfig = fullConfig;
@ -174,6 +175,7 @@ export class FullProjectInternal {
timeout: takeFirst(configCLIOverrides.timeout, projectConfig.timeout, config.timeout, defaultTimeout), timeout: takeFirst(configCLIOverrides.timeout, projectConfig.timeout, config.timeout, defaultTimeout),
use: mergeObjects(config.use, projectConfig.use, configCLIOverrides.use), use: mergeObjects(config.use, projectConfig.use, configCLIOverrides.use),
dependencies: projectConfig.dependencies || [], dependencies: projectConfig.dependencies || [],
teardown: projectConfig.teardown,
}; };
(this.project as any)[projectInternalSymbol] = this; (this.project as any)[projectInternalSymbol] = this;
this.fullyParallel = takeFirst(configCLIOverrides.fullyParallel, projectConfig.fullyParallel, config.fullyParallel, undefined); this.fullyParallel = takeFirst(configCLIOverrides.fullyParallel, projectConfig.fullyParallel, config.fullyParallel, undefined);
@ -205,6 +207,7 @@ function resolveReporters(reporters: Config['reporter'], rootDir: string): Repor
} }
function resolveProjectDependencies(projects: FullProjectInternal[]) { function resolveProjectDependencies(projects: FullProjectInternal[]) {
const teardownToSetup = new Map<FullProjectInternal, FullProjectInternal>();
for (const project of projects) { for (const project of projects) {
for (const dependencyName of project.project.dependencies) { for (const dependencyName of project.project.dependencies) {
const dependencies = projects.filter(p => p.project.name === dependencyName); const dependencies = projects.filter(p => p.project.name === dependencyName);
@ -214,6 +217,28 @@ function resolveProjectDependencies(projects: FullProjectInternal[]) {
throw new Error(`Project dependencies should have unique names, reading ${dependencyName}`); throw new Error(`Project dependencies should have unique names, reading ${dependencyName}`);
project.deps.push(...dependencies); project.deps.push(...dependencies);
} }
if (project.project.teardown) {
const teardowns = projects.filter(p => p.project.name === project.project.teardown);
if (!teardowns.length)
throw new Error(`Project '${project.project.name}' has unknown teardown project '${project.project.teardown}'`);
if (teardowns.length > 1)
throw new Error(`Project teardowns should have unique names, reading ${project.project.teardown}`);
const teardown = teardowns[0];
project.teardown = teardown;
if (teardownToSetup.has(teardown))
throw new Error(`Project ${teardown.project.name} can not be designated as teardown to multiple projects (${teardownToSetup.get(teardown)!.project.name} and ${project.project.name})`);
teardownToSetup.set(teardown, project);
}
}
for (const teardown of teardownToSetup.keys()) {
if (teardown.deps.length)
throw new Error(`Teardown project ${teardown.project.name} must not have dependencies`);
}
for (const project of projects) {
for (const dep of project.deps) {
if (teardownToSetup.has(dep))
throw new Error(`Project ${project.project.name} must not depend on a teardown project ${dep.project.name}`);
}
} }
} }

View File

@ -55,8 +55,10 @@ export class ConfigLoader {
const fullConfig = await this._loadConfig(config, path.dirname(file), file); const fullConfig = await this._loadConfig(config, path.dirname(file), file);
setCurrentConfig(fullConfig); setCurrentConfig(fullConfig);
if (ignoreProjectDependencies) { if (ignoreProjectDependencies) {
for (const project of fullConfig.projects) for (const project of fullConfig.projects) {
project.deps = []; project.deps = [];
project.teardown = undefined;
}
} }
this._fullConfig = fullConfig; this._fullConfig = fullConfig;
return fullConfig; return fullConfig;

View File

@ -48,6 +48,7 @@ export type JsonProject = {
repeatEach: number; repeatEach: number;
retries: number; retries: number;
suites: JsonSuite[]; suites: JsonSuite[];
teardown?: string;
testDir: string; testDir: string;
testIgnore: JsonPattern[]; testIgnore: JsonPattern[];
testMatch: JsonPattern[]; testMatch: JsonPattern[];
@ -303,6 +304,7 @@ export class TeleReporterReceiver {
grep: parseRegexPatterns(project.grep) as RegExp[], grep: parseRegexPatterns(project.grep) as RegExp[],
grepInvert: parseRegexPatterns(project.grepInvert) as RegExp[], grepInvert: parseRegexPatterns(project.grepInvert) as RegExp[],
dependencies: project.dependencies, dependencies: project.dependencies,
teardown: project.teardown,
snapshotDir: this._absolutePath(project.snapshotDir), snapshotDir: this._absolutePath(project.snapshotDir),
use: {}, use: {},
}; };

View File

@ -151,6 +151,7 @@ export class TeleReporterEmitter implements Reporter {
grepInvert: serializeRegexPatterns(project.grepInvert || []), grepInvert: serializeRegexPatterns(project.grepInvert || []),
dependencies: project.dependencies, dependencies: project.dependencies,
snapshotDir: this._relativePath(project.snapshotDir), snapshotDir: this._relativePath(project.snapshotDir),
teardown: project.teardown,
}; };
return report; return report;
} }

View File

@ -49,6 +49,15 @@ export function filterProjects(projects: FullProjectInternal[], projectNames?: s
return result; return result;
} }
export function buildTeardownToSetupMap(projects: FullProjectInternal[]): Map<FullProjectInternal, FullProjectInternal> {
const result = new Map<FullProjectInternal, FullProjectInternal>();
for (const project of projects) {
if (project.teardown)
result.set(project.teardown, project);
}
return result;
}
export function buildProjectsClosure(projects: FullProjectInternal[]): Map<FullProjectInternal, 'top-level' | 'dependency'> { export function buildProjectsClosure(projects: FullProjectInternal[]): Map<FullProjectInternal, 'top-level' | 'dependency'> {
const result = new Map<FullProjectInternal, 'top-level' | 'dependency'>(); const result = new Map<FullProjectInternal, 'top-level' | 'dependency'>();
const visit = (depth: number, project: FullProjectInternal) => { const visit = (depth: number, project: FullProjectInternal) => {
@ -59,6 +68,8 @@ export function buildProjectsClosure(projects: FullProjectInternal[]): Map<FullP
} }
result.set(project, depth ? 'dependency' : 'top-level'); result.set(project, depth ? 'dependency' : 'top-level');
project.deps.map(visit.bind(undefined, depth + 1)); project.deps.map(visit.bind(undefined, depth + 1));
if (project.teardown)
visit(depth + 1, project.teardown);
}; };
for (const p of projects) for (const p of projects)
result.set(p, 'top-level'); result.set(p, 'top-level');
@ -67,6 +78,29 @@ export function buildProjectsClosure(projects: FullProjectInternal[]): Map<FullP
return result; return result;
} }
export function buildDependentProjects(forProject: FullProjectInternal, projects: FullProjectInternal[]): Set<FullProjectInternal> {
const reverseDeps = new Map<FullProjectInternal, FullProjectInternal[]>(projects.map(p => ([p, []])));
for (const project of projects) {
for (const dep of project.deps)
reverseDeps.get(dep)!.push(project);
}
const result = new Set<FullProjectInternal>();
const visit = (depth: number, project: FullProjectInternal) => {
if (depth > 100) {
const error = new Error('Circular dependency detected between projects.');
error.stack = '';
throw error;
}
result.add(project);
for (const reverseDep of reverseDeps.get(project)!)
visit(depth + 1, reverseDep);
if (project.teardown)
visit(depth + 1, project.teardown);
};
visit(0, forProject);
return result;
}
export async function collectFilesForProject(project: FullProjectInternal, fsCache = new Map<string, string[]>()): Promise<string[]> { export async function collectFilesForProject(project: FullProjectInternal, fsCache = new Map<string, string[]>()): Promise<string[]> {
const extensions = ['.js', '.ts', '.mjs', '.tsx', '.jsx']; const extensions = ['.js', '.ts', '.mjs', '.tsx', '.jsx'];
const testFileExtension = (file: string) => extensions.includes(path.extname(file)); const testFileExtension = (file: string) => extensions.includes(path.extname(file));

View File

@ -28,6 +28,7 @@ import type { FullConfigInternal, FullProjectInternal } from '../common/config';
import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook } from './loadUtils'; import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook } from './loadUtils';
import type { Matcher } from '../util'; import type { Matcher } from '../util';
import type { Suite } from '../common/test'; import type { Suite } from '../common/test';
import { buildDependentProjects, buildTeardownToSetupMap } from './projectUtils';
const removeFolderAsync = promisify(rimraf); const removeFolderAsync = promisify(rimraf);
const readDirAsync = promisify(fs.readdir); const readDirAsync = promisify(fs.readdir);
@ -182,13 +183,23 @@ function createPhasesTask(): Task<TestRun> {
const processed = new Set<FullProjectInternal>(); const processed = new Set<FullProjectInternal>();
const projectToSuite = new Map(testRun.rootSuite!.suites.map(suite => [suite._fullProject!, suite])); const projectToSuite = new Map(testRun.rootSuite!.suites.map(suite => [suite._fullProject!, suite]));
const allProjects = [...projectToSuite.keys()];
const teardownToSetup = buildTeardownToSetupMap(allProjects);
const teardownToSetupDependents = new Map<FullProjectInternal, FullProjectInternal[]>();
for (const [teardown, setup] of teardownToSetup) {
const closure = buildDependentProjects(setup, allProjects);
closure.delete(teardown);
teardownToSetupDependents.set(teardown, [...closure]);
}
for (let i = 0; i < projectToSuite.size; i++) { for (let i = 0; i < projectToSuite.size; i++) {
// Find all projects that have all their dependencies processed by previous phases. // Find all projects that have all their dependencies processed by previous phases.
const phaseProjects: FullProjectInternal[] = []; const phaseProjects: FullProjectInternal[] = [];
for (const project of projectToSuite.keys()) { for (const project of projectToSuite.keys()) {
if (processed.has(project)) if (processed.has(project))
continue; continue;
if (project.deps.find(p => !processed.has(p))) const projectsThatShouldFinishFirst = [...project.deps, ...(teardownToSetupDependents.get(project) || [])];
if (projectsThatShouldFinishFirst.find(p => !processed.has(p)))
continue; continue;
phaseProjects.push(project); phaseProjects.push(project);
} }
@ -229,6 +240,7 @@ function createRunTestsTask(): Task<TestRun> {
const { phases } = testRun; const { phases } = testRun;
const successfulProjects = new Set<FullProjectInternal>(); const successfulProjects = new Set<FullProjectInternal>();
const extraEnvByProjectId: EnvByProjectId = new Map(); const extraEnvByProjectId: EnvByProjectId = new Map();
const teardownToSetup = buildTeardownToSetupMap(phases.map(phase => phase.projects.map(p => p.project)).flat());
for (const { dispatcher, projects } of phases) { for (const { dispatcher, projects } of phases) {
// Each phase contains dispatcher and a set of test groups. // Each phase contains dispatcher and a set of test groups.
@ -240,6 +252,9 @@ function createRunTestsTask(): Task<TestRun> {
let extraEnv: Record<string, string | undefined> = {}; let extraEnv: Record<string, string | undefined> = {};
for (const dep of project.deps) for (const dep of project.deps)
extraEnv = { ...extraEnv, ...extraEnvByProjectId.get(dep.id) }; extraEnv = { ...extraEnv, ...extraEnvByProjectId.get(dep.id) };
const setupForTeardown = teardownToSetup.get(project);
if (setupForTeardown)
extraEnv = { ...extraEnv, ...extraEnvByProjectId.get(setupForTeardown.id) };
extraEnvByProjectId.set(project.id, extraEnv); extraEnvByProjectId.set(project.id, extraEnv);
const hasFailedDeps = project.deps.some(p => !successfulProjects.has(p)); const hasFailedDeps = project.deps.some(p => !successfulProjects.has(p));

View File

@ -44,8 +44,10 @@ class UIMode {
process.env.PW_LIVE_TRACE_STACKS = '1'; process.env.PW_LIVE_TRACE_STACKS = '1';
config.cliListOnly = false; config.cliListOnly = false;
config.cliPassWithNoTests = true; config.cliPassWithNoTests = true;
for (const project of config.projects) for (const project of config.projects) {
project.deps = []; project.deps = [];
project.teardown = undefined;
}
for (const p of config.projects) { for (const p of config.projects) {
p.project.retries = 0; p.project.retries = 0;

View File

@ -308,6 +308,8 @@ function affectedProjectsClosure(projectClosure: FullProjectInternal[], affected
if (result.has(dep)) if (result.has(dep))
result.add(p); result.add(p);
} }
if (p.teardown && result.has(p.teardown))
result.add(p);
} }
} }
return result; return result;

View File

@ -193,7 +193,7 @@ export interface FullProject<TestArgs = {}, WorkerArgs = {}> {
* Using dependencies allows global setup to produce traces and other artifacts, see the setup steps in the test * Using dependencies allows global setup to produce traces and other artifacts, see the setup steps in the test
* report, etc. * report, etc.
* *
* For example: * **Usage**
* *
* ```js * ```js
* // playwright.config.ts * // playwright.config.ts
@ -284,6 +284,54 @@ export interface FullProject<TestArgs = {}, WorkerArgs = {}> {
* option for all projects. * option for all projects.
*/ */
retries: number; retries: number;
/**
* Name of a project that needs to run after this and any dependent projects have finished. Teardown is useful to
* cleanup any resources acquired by this project.
*
* Passing `--no-deps` argument ignores
* [testProject.teardown](https://playwright.dev/docs/api/class-testproject#test-project-teardown) and behaves as if
* it was not specified.
*
* **Usage**
*
* A common pattern is a "setup" dependency that has a corresponding "teardown":
*
* ```js
* // playwright.config.ts
* import { defineConfig } from '@playwright/test';
*
* export default defineConfig({
* projects: [
* {
* name: 'setup',
* testMatch: /global.setup\.ts/,
* teardown: 'teardown',
* },
* {
* name: 'teardown',
* testMatch: /global.teardown\.ts/,
* },
* {
* name: 'chromium',
* use: devices['Desktop Chrome'],
* dependencies: ['setup'],
* },
* {
* name: 'firefox',
* use: devices['Desktop Firefox'],
* dependencies: ['setup'],
* },
* {
* name: 'webkit',
* use: devices['Desktop Safari'],
* dependencies: ['setup'],
* },
* ],
* });
* ```
*
*/
teardown?: string;
/** /**
* Directory that will be recursively scanned for test files. Defaults to the directory of the configuration file. * Directory that will be recursively scanned for test files. Defaults to the directory of the configuration file.
* *
@ -5932,7 +5980,7 @@ interface TestProject {
* Using dependencies allows global setup to produce traces and other artifacts, see the setup steps in the test * Using dependencies allows global setup to produce traces and other artifacts, see the setup steps in the test
* report, etc. * report, etc.
* *
* For example: * **Usage**
* *
* ```js * ```js
* // playwright.config.ts * // playwright.config.ts
@ -6245,6 +6293,55 @@ interface TestProject {
*/ */
snapshotPathTemplate?: string; snapshotPathTemplate?: string;
/**
* Name of a project that needs to run after this and any dependent projects have finished. Teardown is useful to
* cleanup any resources acquired by this project.
*
* Passing `--no-deps` argument ignores
* [testProject.teardown](https://playwright.dev/docs/api/class-testproject#test-project-teardown) and behaves as if
* it was not specified.
*
* **Usage**
*
* A common pattern is a "setup" dependency that has a corresponding "teardown":
*
* ```js
* // playwright.config.ts
* import { defineConfig } from '@playwright/test';
*
* export default defineConfig({
* projects: [
* {
* name: 'setup',
* testMatch: /global.setup\.ts/,
* teardown: 'teardown',
* },
* {
* name: 'teardown',
* testMatch: /global.teardown\.ts/,
* },
* {
* name: 'chromium',
* use: devices['Desktop Chrome'],
* dependencies: ['setup'],
* },
* {
* name: 'firefox',
* use: devices['Desktop Firefox'],
* dependencies: ['setup'],
* },
* {
* name: 'webkit',
* use: devices['Desktop Safari'],
* dependencies: ['setup'],
* },
* ],
* });
* ```
*
*/
teardown?: string;
/** /**
* Directory that will be recursively scanned for test files. Defaults to the directory of the configuration file. * Directory that will be recursively scanned for test files. Defaults to the directory of the configuration file.
* *

View File

@ -43,9 +43,10 @@ test('should inherit env changes from dependencies', async ({ runInlineTest }) =
'playwright.config.ts': ` 'playwright.config.ts': `
module.exports = { projects: [ module.exports = { projects: [
{ name: 'A', testMatch: '**/a.spec.ts' }, { name: 'A', testMatch: '**/a.spec.ts' },
{ name: 'B', testMatch: '**/b.spec.ts' }, { name: 'B', testMatch: '**/b.spec.ts', teardown: 'E' },
{ name: 'C', testMatch: '**/c.spec.ts', dependencies: ['A'] }, { name: 'C', testMatch: '**/c.spec.ts', dependencies: ['A'] },
{ name: 'D', testMatch: '**/d.spec.ts', dependencies: ['B'] }, { name: 'D', testMatch: '**/d.spec.ts', dependencies: ['B'] },
{ name: 'E', testMatch: '**/e.spec.ts' },
] }; ] };
`, `,
'a.spec.ts': ` 'a.spec.ts': `
@ -75,11 +76,17 @@ test('should inherit env changes from dependencies', async ({ runInlineTest }) =
console.log('\\n%%D-' + process.env.SET_IN_A + '-' + process.env.SET_IN_B + '-' + process.env.SET_OUTSIDE); console.log('\\n%%D-' + process.env.SET_IN_A + '-' + process.env.SET_IN_B + '-' + process.env.SET_OUTSIDE);
}); });
`, `,
'e.spec.ts': `
import { test, expect } from '@playwright/test';
test('pass', async ({}, testInfo) => {
console.log('\\n%%E-' + process.env.SET_IN_A + '-' + process.env.SET_IN_B + '-' + process.env.SET_OUTSIDE);
});
`,
}, {}, { SET_OUTSIDE: 'outside' }); }, {}, { SET_OUTSIDE: 'outside' });
expect(result.passed).toBe(4); expect(result.passed).toBe(5);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(result.skipped).toBe(0); expect(result.skipped).toBe(0);
expect(result.outputLines.sort()).toEqual(['A', 'B', 'C-valuea-undefined-undefined', 'D-undefined-valueb-outside']); expect(result.outputLines.sort()).toEqual(['A', 'B', 'C-valuea-undefined-undefined', 'D-undefined-valueb-outside', 'E-undefined-valueb-outside']);
}); });
test('should not run projects with dependencies when --no-deps is passed', async ({ runInlineTest }) => { test('should not run projects with dependencies when --no-deps is passed', async ({ runInlineTest }) => {
@ -423,3 +430,132 @@ test('should run setup project with zero tests recursively', async ({ runInlineT
expect(result.passed).toBe(2); expect(result.passed).toBe(2);
expect(result.outputLines).toEqual(['A', 'C']); expect(result.outputLines).toEqual(['A', 'C']);
}); });
test('should run project with teardown', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
projects: [
{ name: 'A', teardown: 'B' },
{ name: 'B' },
],
};`,
'test.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({}, testInfo) => {
console.log('\\n%%' + testInfo.project.name);
});
`,
}, { workers: 1 }, undefined, { additionalArgs: ['--project=A'] });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(2);
expect(result.outputLines).toEqual(['A', 'B']);
});
test('should run teardown after depedents', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
projects: [
{ name: 'A', teardown: 'E' },
{ name: 'B', dependencies: ['A'] },
{ name: 'C', dependencies: ['B'], teardown: 'D' },
{ name: 'D' },
{ name: 'E' },
],
};`,
'test.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({}, testInfo) => {
console.log('\\n%%' + testInfo.project.name);
});
`,
}, { workers: 1 }, undefined, { additionalArgs: ['--project=C'] });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(5);
expect(result.outputLines).toEqual(['A', 'B', 'C', 'D', 'E']);
});
test('should run teardown after failure', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
projects: [
{ name: 'A', teardown: 'D' },
{ name: 'B', dependencies: ['A'] },
{ name: 'C', dependencies: ['B'] },
{ name: 'D' },
],
};`,
'test.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', async ({}, testInfo) => {
console.log('\\n%%' + testInfo.project.name);
if (testInfo.project.name === 'A')
throw new Error('ouch');
});
`,
}, { workers: 1 }, undefined, { additionalArgs: ['--project=C'] });
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.skipped).toBe(2);
expect(result.outputLines).toEqual(['A', 'D']);
});
test('should complain about teardown being a dependency', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
projects: [
{ name: 'A', teardown: 'B' },
{ name: 'B' },
{ name: 'C', dependencies: ['B'] },
],
};`,
'test.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', () => {});
`,
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`Project C must not depend on a teardown project B`);
});
test('should complain about teardown having a dependency', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
projects: [
{ name: 'A', teardown: 'B' },
{ name: 'B', dependencies: ['C'] },
{ name: 'C' },
],
};`,
'test.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', () => {});
`,
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`Teardown project B must not have dependencies`);
});
test('should complain about teardown used multiple times', async ({ runInlineTest }) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
projects: [
{ name: 'A', teardown: 'C' },
{ name: 'B', teardown: 'C' },
{ name: 'C' },
],
};`,
'test.spec.ts': `
import { test, expect } from '@playwright/test';
test('test', () => {});
`,
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`Project C can not be designated as teardown to multiple projects (A and B)`);
});

View File

@ -36,7 +36,7 @@ export interface Project<TestArgs = {}, WorkerArgs = {}> extends TestProject {
// [internal] !!! DO NOT ADD TO THIS !!! // [internal] !!! DO NOT ADD TO THIS !!!
// [internal] It is part of the public API and is computed from the user's config. // [internal] It is part of the public API and is computed from the user's config.
// [internal] If you need new fields internally, add them to FullConfigInternal instead. // [internal] If you need new fields internally, add them to FullProjectInternal instead.
export interface FullProject<TestArgs = {}, WorkerArgs = {}> { export interface FullProject<TestArgs = {}, WorkerArgs = {}> {
grep: RegExp | RegExp[]; grep: RegExp | RegExp[];
grepInvert: RegExp | RegExp[] | null; grepInvert: RegExp | RegExp[] | null;
@ -47,6 +47,7 @@ export interface FullProject<TestArgs = {}, WorkerArgs = {}> {
outputDir: string; outputDir: string;
repeatEach: number; repeatEach: number;
retries: number; retries: number;
teardown?: string;
testDir: string; testDir: string;
testIgnore: string | RegExp | (string | RegExp)[]; testIgnore: string | RegExp | (string | RegExp)[];
testMatch: string | RegExp | (string | RegExp)[]; testMatch: string | RegExp | (string | RegExp)[];