Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
da65da5
feat(test runner): --only-changed
Skn0tt Jul 17, 2024
30447b1
support changed files
Skn0tt Jul 17, 2024
9123e1d
extract method
Skn0tt Jul 17, 2024
2d9a299
allow comparison against base commit
Skn0tt Jul 17, 2024
d907883
detect base ref from environment variables
Skn0tt Jul 17, 2024
824bea2
refactor tests
Skn0tt Jul 17, 2024
72d7c86
make it understand dependency structure
Skn0tt Jul 17, 2024
bed4466
refactor
Skn0tt Jul 17, 2024
0cbfd34
test more complex dependency graph
Skn0tt Jul 17, 2024
5cb24de
add test for watch mode
Skn0tt Jul 17, 2024
5b75823
add nice error message
Skn0tt Jul 17, 2024
ce788a6
add docs
Skn0tt Jul 17, 2024
5d2da71
also check CI env var
Skn0tt Jul 17, 2024
9fb7f13
set local commit identity
Skn0tt Jul 17, 2024
822c4cb
remove BASE_REF in CI
Skn0tt Jul 17, 2024
f672d6d
add test about component tests
Skn0tt Jul 17, 2024
0f38048
fix DEPS
Skn0tt Jul 17, 2024
359edf9
treat edge case of calling it in subdirectory
Skn0tt Jul 17, 2024
ab05bbf
untracked and tracked files need to be treated differently
Skn0tt Jul 17, 2024
2251e6a
include onlyChanged in command hash
Skn0tt Jul 17, 2024
7bb2780
propagate --onlyChanged through UI
Skn0tt Jul 17, 2024
6baf0b6
add test
Skn0tt Jul 17, 2024
a5d7ca1
remove CI smartypants
Skn0tt Jul 18, 2024
8de6851
Revert "add test"
Skn0tt Jul 18, 2024
91ba44c
Revert "propagate --onlyChanged through UI"
Skn0tt Jul 18, 2024
6c19e0e
add CI recipe
Skn0tt Jul 18, 2024
5bdeb38
disallow --only-changed in UI mode
Skn0tt Jul 18, 2024
fbcfdb5
show that component dependencies aren't yet understood
Skn0tt Jul 18, 2024
397b66d
fix typo
Skn0tt Jul 18, 2024
a11e7fb
write down plan
Skn0tt Jul 18, 2024
76e63c4
refactor
Skn0tt Jul 18, 2024
b618a61
fix it
Skn0tt Jul 18, 2024
9398347
understand nested dependencies
Skn0tt Jul 18, 2024
2e6ed2f
add note
Skn0tt Jul 18, 2024
226e00b
CRLF on windows
Skn0tt Jul 18, 2024
f530806
try setting it locally
Skn0tt Jul 18, 2024
8493789
fix first windows tests
Skn0tt Jul 19, 2024
1665d8f
tests still seem to pass without `suite`!
Skn0tt Jul 19, 2024
b9fe830
affectedTestFiles needs OS-specific paths
Skn0tt Jul 19, 2024
eb19971
ts fixes
Skn0tt Jul 19, 2024
38a16c5
remove windows crlf change and mark tests as slow
Skn0tt Jul 19, 2024
0b2a74e
fix lint issues
Skn0tt Jul 19, 2024
193977b
mention comment to upvote
Skn0tt Jul 19, 2024
b1c04b1
revert changes to DEPS
Skn0tt Jul 19, 2024
ed43475
add trailing newline back in
Skn0tt Jul 19, 2024
ffc3f7f
revert some more unneeded changes
Skn0tt Jul 19, 2024
6ec936f
address most of the review
Skn0tt Jul 22, 2024
9a32dad
pull writeFiles into tests
Skn0tt Jul 22, 2024
dda6532
trim down test cases
Skn0tt Jul 22, 2024
338790f
move populateDependencies call, only match on filenames
Skn0tt Jul 22, 2024
065f5ce
perform premature optimisation
Skn0tt Jul 22, 2024
805561a
dont filter in "createTaskRunnerForList"
Skn0tt Jul 22, 2024
22977e0
repro @dgozman's example
Skn0tt Jul 22, 2024
92c9253
remove .only 🤦
Skn0tt Jul 22, 2024
55bace0
remove unused GITHUB_BASE_REF rule
Skn0tt Jul 23, 2024
6e2f581
toPosixPath is no longer needed now that were matching on file paths …
Skn0tt Jul 23, 2024
7716b23
use config.configDir as cwd
Skn0tt Jul 23, 2024
961cafc
use configDir for path mapping as well
Skn0tt Jul 23, 2024
acdde57
only return test files
Skn0tt Jul 23, 2024
bc96c8b
add result.passed assertions
Skn0tt Jul 23, 2024
4355c7f
add tests that dont depend on changed files
Skn0tt Jul 23, 2024
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
37 changes: 37 additions & 0 deletions docs/src/ci-intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,43 @@ jobs:
PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }}
```

### Fail-Fast
* langs: js

Even with sharding enabled, large test suites can take very long to execute. Running changed tests first on PRs will give you a faster feedback loop and use less CI resources.

```yml js title=".github/workflows/playwright.yml"
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run changed Playwright tests
run: npx playwright test --only-changed=$GITHUB_BASE_REF
if: github.event_name == 'pull_request'
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
```

## Create a Repo and Push to GitHub

Expand Down
1 change: 1 addition & 0 deletions docs/src/test-cli-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Complete set of Playwright Test options is available in the [configuration file]
| `--max-failures <N>` or `-x`| Stop after the first `N` test failures. Passing `-x` stops after the first failure.|
| `--no-deps` | Ignore the dependencies between projects and behave as if they were not specified. |
| `--output <dir>` | Directory for artifacts produced by tests, defaults to `test-results`. |
| `--only-changed [ref]` | Only run tests that have been changed between working tree and "ref". Defaults to running all uncommitted changes with ref=HEAD. Only supports Git. |
| `--pass-with-no-tests` | Allows the test suite to pass when no files are found. |
| `--project <name>` | Only run tests from the specified [projects](./test-projects.md), supports '*' wildcard. Defaults to running all projects defined in the configuration file.|
| `--quiet` | Whether to suppress stdout and stderr from the tests. |
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright-ct-core/src/vitePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export function createPlugin(): TestRunnerPlugin {
if (stoppableServer)
await new Promise(f => stoppableServer.stop(f));
},

populateDependencies: async () => {
await buildBundle(config, configDir);
},
};
}

Expand Down Expand Up @@ -157,7 +161,7 @@ export async function buildBundle(config: FullConfig, configDir: string): Promis
const viteConfig = await createConfig(dirs, config, frameworkPluginFactory, jsxInJS);

if (sourcesDirty) {
// Only add out own plugin when we actually build / transform.
// Only add our own plugin when we actually build / transform.
log('build');
const depsCollector = new Map<string, string[]>();
const buildConfig = mergeConfig(viteConfig, {
Expand Down
1 change: 1 addition & 0 deletions packages/playwright/src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class FullConfigInternal {
cliArgs: string[] = [];
cliGrep: string | undefined;
cliGrepInvert: string | undefined;
cliOnlyChanged: string | undefined;
cliProjectFilter?: string[];
cliListOnly = false;
cliPassWithNoTests?: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/playwright/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { ReporterV2 } from '../reporters/reporterV2';
export interface TestRunnerPlugin {
name: string;
setup?(config: FullConfig, configDir: string, reporter: ReporterV2): Promise<void>;
populateDependencies?(): Promise<void>;
begin?(suite: Suite): Promise<void>;
end?(): Promise<void>;
teardown?(): Promise<void>;
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,14 @@ Examples:
$ npx playwright merge-reports playwright-report`);
}


async function runTests(args: string[], opts: { [key: string]: any }) {
await startProfiling();
const cliOverrides = overridesFromOptions(opts);

if (opts.ui || opts.uiHost || opts.uiPort) {
if (opts.onlyChanged)
throw new Error(`--only-changed is not supported in UI mode. If you'd like that to change, see https://github.com/microsoft/playwright/issues/15075 for more details.`);

const status = await testServer.runUIMode(opts.config, {
host: opts.uiHost,
port: opts.uiPort ? +opts.uiPort : undefined,
Expand Down Expand Up @@ -192,6 +194,7 @@ async function runTests(args: string[], opts: { [key: string]: any }) {

config.cliArgs = args;
config.cliGrep = opts.grep as string | undefined;
config.cliOnlyChanged = opts.onlyChanged === true ? 'HEAD' : opts.onlyChanged;
config.cliGrepInvert = opts.grepInvert as string | undefined;
config.cliListOnly = !!opts.list;
config.cliProjectFilter = opts.project || undefined;
Expand Down Expand Up @@ -352,6 +355,7 @@ const testOptions: [string, string][] = [
['--max-failures <N>', `Stop after the first N failures`],
['--no-deps', 'Do not run project dependencies'],
['--output <dir>', `Folder for output artifacts (default: "test-results")`],
['--only-changed [ref]', `Only run tests that have been changed between 'HEAD' and 'ref'. Defaults to running all uncommitted changes. Only supports Git.`],
['--pass-with-no-tests', `Makes test run succeed even if no tests were found`],
['--project <project-name...>', `Only run tests from the specified list of projects, supports '*' wildcard (default: run all projects)`],
['--quiet', `Suppress stdio`],
Expand Down
6 changes: 4 additions & 2 deletions packages/playwright/src/runner/loadUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { dependenciesForTestFile } from '../transform/compilationCache';
import { sourceMapSupport } from '../utilsBundle';
import type { RawSourceMap } from 'source-map';


export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean, additionalFileMatcher?: Matcher) {
const config = testRun.config;
const fsCache = new Map();
Expand Down Expand Up @@ -118,7 +119,7 @@ export async function loadFileSuites(testRun: TestRun, mode: 'out-of-process' |
}
}

export async function createRootSuite(testRun: TestRun, errors: TestError[], shouldFilterOnly: boolean): Promise<Suite> {
export async function createRootSuite(testRun: TestRun, errors: TestError[], shouldFilterOnly: boolean, additionalFileMatcher?: Matcher): Promise<Suite> {
const config = testRun.config;
// Create root suite, where each child will be a project suite with cloned file suites inside it.
const rootSuite = new Suite('', 'root');
Expand All @@ -135,7 +136,8 @@ export async function createRootSuite(testRun: TestRun, errors: TestError[], sho

// Filter file suites for all projects.
for (const [project, fileSuites] of testRun.projectSuites) {
const projectSuite = createProjectSuite(project, fileSuites);
const filteredFileSuites = additionalFileMatcher ? fileSuites.filter(fileSuite => additionalFileMatcher(fileSuite.location!.file)) : fileSuites;
const projectSuite = createProjectSuite(project, filteredFileSuites);
projectSuites.set(project, projectSuite);
const filteredProjectSuite = filterProjectSuite(projectSuite, { cliFileFilters, cliTitleMatcher, testIdMatcher: config.testIdMatcher });
filteredProjectSuites.set(project, filteredProjectSuite);
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright/src/runner/reporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ function computeCommandHash(config: FullConfigInternal) {
command.cliGrep = config.cliGrep;
if (config.cliGrepInvert)
command.cliGrepInvert = config.cliGrepInvert;
if (config.cliOnlyChanged)
command.cliOnlyChanged = config.cliOnlyChanged;
if (Object.keys(command).length)
parts.push(calculateSha1(JSON.stringify(command)).substring(0, 7));
return parts.join('-');
Expand Down
22 changes: 16 additions & 6 deletions packages/playwright/src/runner/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { Matcher } from '../util';
import { Suite } from '../common/test';
import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils';
import { FailureTracker } from './failureTracker';
import { detectChangedTests } from './vcs';

const readDirAsync = promisify(fs.readdir);

Expand Down Expand Up @@ -64,7 +65,7 @@ export class TestRun {
export function createTaskRunner(config: FullConfigInternal, reporter: ReporterV2): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout);
addGlobalSetupTasks(taskRunner, config);
taskRunner.addTask('load tests', createLoadTask('in-process', { filterOnly: true, failOnLoadErrors: true }));
taskRunner.addTask('load tests', createLoadTask('in-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: true }));
addRunTasks(taskRunner, config);
return taskRunner;
}
Expand All @@ -77,14 +78,14 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report

export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: ReporterV2, additionalFileMatcher?: Matcher): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, 0);
taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true, additionalFileMatcher }));
taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true, additionalFileMatcher }));
addRunTasks(taskRunner, config);
return taskRunner;
}

export function createTaskRunnerForTestServer(config: FullConfigInternal, reporter: ReporterV2): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, 0);
taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true }));
taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, filterOnlyChanged: true, failOnLoadErrors: false, doNotRunDepsOutsideProjectFilter: true }));
addRunTasks(taskRunner, config);
return taskRunner;
}
Expand All @@ -109,7 +110,7 @@ function addRunTasks(taskRunner: TaskRunner<TestRun>, config: FullConfigInternal

export function createTaskRunnerForList(config: FullConfigInternal, reporter: ReporterV2, mode: 'in-process' | 'out-of-process', options: { failOnLoadErrors: boolean }): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout);
taskRunner.addTask('load tests', createLoadTask(mode, { ...options, filterOnly: false }));
taskRunner.addTask('load tests', createLoadTask(mode, { ...options, filterOnly: false, filterOnlyChanged: false }));
taskRunner.addTask('report begin', createReportBeginTask());
return taskRunner;
}
Expand Down Expand Up @@ -223,12 +224,21 @@ function createListFilesTask(): Task<TestRun> {
};
}

function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task<TestRun> {
function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, filterOnlyChanged: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, additionalFileMatcher?: Matcher }): Task<TestRun> {
return {
setup: async (testRun, errors, softErrors) => {
await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter, options.additionalFileMatcher);
await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors);
testRun.rootSuite = await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly);

let cliOnlyChangedMatcher: Matcher | undefined = undefined;
if (testRun.config.cliOnlyChanged && options.filterOnlyChanged) {
for (const plugin of testRun.config.plugins)
await plugin.instance?.populateDependencies?.();
const changedFiles = await detectChangedTests(testRun.config.cliOnlyChanged, testRun.config.configDir);
cliOnlyChangedMatcher = file => changedFiles.has(file);
}

testRun.rootSuite = await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly, cliOnlyChangedMatcher);
testRun.failureTracker.onRootSuite(testRun.rootSuite);
// Fail when no tests.
if (options.failOnLoadErrors && !testRun.rootSuite.allTests().length && !testRun.config.cliPassWithNoTests && !testRun.config.config.shard) {
Expand Down
45 changes: 45 additions & 0 deletions packages/playwright/src/runner/vcs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* 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 childProcess from 'child_process';
import { affectedTestFiles } from '../transform/compilationCache';
import path from 'path';

export async function detectChangedTests(baseCommit: string, configDir: string): Promise<Set<string>> {
function gitFileList(command: string) {
try {
return childProcess.execSync(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to pass config.configDir as cwd here, wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smart! done in 7716b23. I'll still keep the path mapping in line 42, because we the configDir is not necessarily the git root

`git ${command}`,
{ encoding: 'utf-8', stdio: 'pipe', cwd: configDir }
).split('\n').filter(Boolean);
} catch (_error) {
const error = _error as childProcess.SpawnSyncReturns<string>;
throw new Error([
`Cannot detect changed files for --only-changed mode:`,
`git ${command}`,
'',
...error.output,
].join('\n'));
}
}

const untrackedFiles = gitFileList(`ls-files --others --exclude-standard`).map(file => path.join(configDir, file));

const [gitRoot] = gitFileList('rev-parse --show-toplevel');
const trackedFilesWithChanges = gitFileList(`diff ${baseCommit} --name-only`).map(file => path.join(gitRoot, file));

return new Set(affectedTestFiles([...untrackedFiles, ...trackedFilesWithChanges]));
}
Loading