chore: make find-related-test-files work through plugins (#32465)

Also switches it to the task runner.
This commit is contained in:
Dmitry Gozman 2024-09-05 06:52:11 -07:00 committed by GitHub
parent 5127efdc2a
commit d4c77ce260
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 136 additions and 84 deletions

View file

@ -16,7 +16,7 @@
const { test: baseTest, expect, devices, defineConfig: originalDefineConfig } = require('playwright/test'); const { test: baseTest, expect, devices, defineConfig: originalDefineConfig } = require('playwright/test');
const { fixtures } = require('./lib/mount'); const { fixtures } = require('./lib/mount');
const { clearCacheCommand, findRelatedTestFilesCommand } = require('./lib/cliOverrides'); const { clearCacheCommand } = require('./lib/cliOverrides');
const { createPlugin } = require('./lib/vitePlugin'); const { createPlugin } = require('./lib/vitePlugin');
const defineConfig = (...configs) => { const defineConfig = (...configs) => {
@ -31,7 +31,6 @@ const defineConfig = (...configs) => {
], ],
cli: { cli: {
'clear-cache': clearCacheCommand, 'clear-cache': clearCacheCommand,
'find-related-test-files': findRelatedTestFilesCommand,
}, },
} }
}; };

View file

@ -15,8 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { affectedTestFiles, cacheDir } from 'playwright/lib/transform/compilationCache'; import { cacheDir } from 'playwright/lib/transform/compilationCache';
import { buildBundle } from './vitePlugin';
import { resolveDirs } from './viteUtils'; import { resolveDirs } from './viteUtils';
import type { FullConfigInternal } from 'playwright/lib/common/config'; import type { FullConfigInternal } from 'playwright/lib/common/config';
import { removeFolderAndLogToConsole } from 'playwright/lib/runner/testServer'; import { removeFolderAndLogToConsole } from 'playwright/lib/runner/testServer';
@ -27,8 +26,3 @@ export async function clearCacheCommand(config: FullConfigInternal) {
await removeFolderAndLogToConsole(dirs.outDir); await removeFolderAndLogToConsole(dirs.outDir);
await removeFolderAndLogToConsole(cacheDir); await removeFolderAndLogToConsole(cacheDir);
} }
export async function findRelatedTestFilesCommand(files: string[], config: FullConfigInternal) {
await buildBundle(config.config, config.configDir);
return { testFiles: affectedTestFiles(files) };
}

View file

@ -86,7 +86,8 @@ function addFindRelatedTestFilesCommand(program: Command) {
command.description('Returns the list of related tests to the given files'); command.description('Returns the list of related tests to the given files');
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`);
command.action(async (files, options) => { command.action(async (files, options) => {
await withRunnerAndMutedWrite(options.config, runner => runner.findRelatedTestFiles('in-process', files)); const resolvedFiles = (files as string[]).map(file => path.resolve(process.cwd(), file));
await withRunnerAndMutedWrite(options.config, runner => runner.findRelatedTestFiles(resolvedFiles));
}); });
} }

View file

@ -86,12 +86,22 @@ export async function createReporterForTestServer(file: string, messageSink: (me
})); }));
} }
export function createConsoleReporter() { interface ErrorCollectingReporter extends ReporterV2 {
return wrapReporterAsV2({ errors(): TestError[];
}
export function createErrorCollectingReporter(writeToConsole?: boolean): ErrorCollectingReporter {
const errors: TestError[] = [];
const reporterV2 = wrapReporterAsV2({
onError(error: TestError) { onError(error: TestError) {
process.stdout.write(formatError(error, colors.enabled).message + '\n'); errors.push(error);
if (writeToConsole)
process.stdout.write(formatError(error, colors.enabled).message + '\n');
} }
}); });
const reporter = reporterV2 as ErrorCollectingReporter;
reporter.errors = () => errors;
return reporter;
} }
function reporterOptions(config: FullConfigInternal, mode: 'list' | 'test' | 'merge', isTestServer: boolean) { function reporterOptions(config: FullConfigInternal, mode: 'list' | 'test' | 'merge', isTestServer: boolean) {

View file

@ -21,11 +21,9 @@ import { monotonicTime } from 'playwright-core/lib/utils';
import type { FullResult, TestError } from '../../types/testReporter'; import type { FullResult, TestError } from '../../types/testReporter';
import { webServerPluginsForConfig } from '../plugins/webServerPlugin'; import { webServerPluginsForConfig } from '../plugins/webServerPlugin';
import { collectFilesForProject, filterProjects } from './projectUtils'; import { collectFilesForProject, filterProjects } from './projectUtils';
import { createConsoleReporter, createReporters } from './reporters'; import { createErrorCollectingReporter, createReporters } from './reporters';
import { TestRun, createTaskRunner, createTaskRunnerForDevServer, createTaskRunnerForList } from './tasks'; import { TestRun, createTaskRunner, createTaskRunnerForDevServer, createTaskRunnerForList, createTaskRunnerForRelatedTestFiles } from './tasks';
import type { FullConfigInternal } from '../common/config'; import type { FullConfigInternal } from '../common/config';
import type { Suite } from '../common/test';
import { wrapReporterAsV2 } from '../reporters/reporterV2';
import { affectedTestFiles } from '../transform/compilationCache'; import { affectedTestFiles } from '../transform/compilationCache';
import { InternalReporter } from '../reporters/internalReporter'; import { InternalReporter } from '../reporters/internalReporter';
@ -109,43 +107,22 @@ export class Runner {
return status; return status;
} }
async loadAllTests(mode: 'in-process' | 'out-of-process' = 'in-process'): Promise<{ status: FullResult['status'], suite?: Suite, errors: TestError[] }> { async findRelatedTestFiles(files: string[]): Promise<FindRelatedTestFilesReport> {
const config = this._config; const errorReporter = createErrorCollectingReporter();
const errors: TestError[] = []; const reporter = new InternalReporter([errorReporter]);
const reporter = new InternalReporter([wrapReporterAsV2({ const taskRunner = createTaskRunnerForRelatedTestFiles(this._config, reporter, 'in-process', true);
onError(error: TestError) { const testRun = new TestRun(this._config);
errors.push(error); reporter.onConfigure(this._config.config);
} const status = await taskRunner.run(testRun, 0);
})]); await reporter.onEnd({ status });
const taskRunner = createTaskRunnerForList(config, reporter, mode, { failOnLoadErrors: true });
const testRun = new TestRun(config);
reporter.onConfigure(config.config);
const taskStatus = await taskRunner.run(testRun, 0);
let status: FullResult['status'] = testRun.failureTracker.result();
if (status === 'passed' && taskStatus !== 'passed')
status = taskStatus;
const modifiedResult = await reporter.onEnd({ status });
if (modifiedResult && modifiedResult.status)
status = modifiedResult.status;
await reporter.onExit(); await reporter.onExit();
return { status, suite: testRun.rootSuite, errors }; if (status !== 'passed')
} return { errors: errorReporter.errors(), testFiles: [] };
return { testFiles: affectedTestFiles(files) };
async findRelatedTestFiles(mode: 'in-process' | 'out-of-process', files: string[]): Promise<FindRelatedTestFilesReport> {
const result = await this.loadAllTests(mode);
if (result.status !== 'passed' || !result.suite)
return { errors: result.errors, testFiles: [] };
const resolvedFiles = (files as string[]).map(file => path.resolve(process.cwd(), file));
const override = (this._config.config as any)['@playwright/test']?.['cli']?.['find-related-test-files'];
if (override)
return await override(resolvedFiles, this._config);
return { testFiles: affectedTestFiles(resolvedFiles) };
} }
async runDevServer() { async runDevServer() {
const reporter = new InternalReporter([createConsoleReporter()]); const reporter = new InternalReporter([createErrorCollectingReporter(true)]);
const taskRunner = createTaskRunnerForDevServer(this._config, reporter, 'in-process', true); const taskRunner = createTaskRunnerForDevServer(this._config, reporter, 'in-process', true);
const testRun = new TestRun(this._config); const testRun = new TestRun(this._config);
reporter.onConfigure(this._config.config); reporter.onConfigure(this._config.config);

View file

@ -129,6 +129,16 @@ export function createTaskRunnerForDevServer(config: FullConfigInternal, reporte
return taskRunner; return taskRunner;
} }
export function createTaskRunnerForRelatedTestFiles(config: FullConfigInternal, reporter: InternalReporter, mode: 'in-process' | 'out-of-process', setupPlugins: boolean): TaskRunner<TestRun> {
const taskRunner = TaskRunner.create<TestRun>(reporter, config.config.globalTimeout);
if (setupPlugins) {
for (const plugin of config.plugins)
taskRunner.addTask('plugin setup', createPluginSetupTask(plugin));
}
taskRunner.addTask('load tests', createLoadTask(mode, { failOnLoadErrors: true, filterOnly: false, populateDependencies: true }));
return taskRunner;
}
function createReportBeginTask(): Task<TestRun> { function createReportBeginTask(): Task<TestRun> {
return { return {
setup: async (reporter, { rootSuite }) => { setup: async (reporter, { rootSuite }) => {
@ -231,16 +241,19 @@ function createListFilesTask(): Task<TestRun> {
}; };
} }
function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean }): Task<TestRun> { function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, doNotRunDepsOutsideProjectFilter?: boolean, populateDependencies?: boolean }): Task<TestRun> {
return { return {
setup: async (reporter, testRun, errors, softErrors) => { setup: async (reporter, testRun, errors, softErrors) => {
await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter); await collectProjectsAndTestFiles(testRun, !!options.doNotRunDepsOutsideProjectFilter);
await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors); await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors);
let cliOnlyChangedMatcher: Matcher | undefined = undefined; if (testRun.config.cliOnlyChanged || options.populateDependencies) {
if (testRun.config.cliOnlyChanged) {
for (const plugin of testRun.config.plugins) for (const plugin of testRun.config.plugins)
await plugin.instance?.populateDependencies?.(); await plugin.instance?.populateDependencies?.();
}
let cliOnlyChangedMatcher: Matcher | undefined = undefined;
if (testRun.config.cliOnlyChanged) {
const changedFiles = await detectChangedTestFiles(testRun.config.cliOnlyChanged, testRun.config.configDir); const changedFiles = await detectChangedTestFiles(testRun.config.cliOnlyChanged, testRun.config.configDir);
cliOnlyChangedMatcher = file => changedFiles.has(file); cliOnlyChangedMatcher = file => changedFiles.has(file);
} }

View file

@ -20,16 +20,15 @@ import { installRootRedirect, openTraceInBrowser, openTraceViewerApp, registry,
import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils'; import { ManualPromise, gracefullyProcessExitDoNotHang, isUnderTest } from 'playwright-core/lib/utils';
import type { Transport, HttpServer } from 'playwright-core/lib/utils'; import type { Transport, HttpServer } from 'playwright-core/lib/utils';
import type * as reporterTypes from '../../types/testReporter'; import type * as reporterTypes from '../../types/testReporter';
import { collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache'; import { affectedTestFiles, collectAffectedTestFiles, dependenciesForTestFile } from '../transform/compilationCache';
import type { ConfigLocation, FullConfigInternal } from '../common/config'; import type { ConfigLocation, FullConfigInternal } from '../common/config';
import { createReporterForTestServer, createReporters } from './reporters'; import { createErrorCollectingReporter, createReporterForTestServer, createReporters } from './reporters';
import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer, createTaskRunnerForWatchSetup, createTaskRunnerForListFiles, createTaskRunnerForDevServer } from './tasks'; import { TestRun, createTaskRunnerForList, createTaskRunnerForTestServer, createTaskRunnerForWatchSetup, createTaskRunnerForListFiles, createTaskRunnerForDevServer, createTaskRunnerForRelatedTestFiles } from './tasks';
import { open } from 'playwright-core/lib/utilsBundle'; import { open } from 'playwright-core/lib/utilsBundle';
import ListReporter from '../reporters/list'; import ListReporter from '../reporters/list';
import { SigIntWatcher } from './sigIntWatcher'; import { SigIntWatcher } from './sigIntWatcher';
import { Watcher } from '../fsWatcher'; import { Watcher } from '../fsWatcher';
import type { ReportEntry, TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface'; import type { ReportEntry, TestServerInterface, TestServerInterfaceEventEmitters } from '../isomorphic/testServerInterface';
import { Runner } from './runner';
import type { ConfigCLIOverrides } from '../common/ipc'; import type { ConfigCLIOverrides } from '../common/ipc';
import { loadConfig, resolveConfigLocation, restartWithExperimentalTsEsm } from '../common/configLoader'; import { loadConfig, resolveConfigLocation, restartWithExperimentalTsEsm } from '../common/configLoader';
import { webServerPluginsForConfig } from '../plugins/webServerPlugin'; import { webServerPluginsForConfig } from '../plugins/webServerPlugin';
@ -362,11 +361,21 @@ export class TestServerDispatcher implements TestServerInterface {
} }
async findRelatedTestFiles(params: Parameters<TestServerInterface['findRelatedTestFiles']>[0]): ReturnType<TestServerInterface['findRelatedTestFiles']> { async findRelatedTestFiles(params: Parameters<TestServerInterface['findRelatedTestFiles']>[0]): ReturnType<TestServerInterface['findRelatedTestFiles']> {
const { config, error } = await this._loadConfig(); const errorReporter = createErrorCollectingReporter();
if (error) const reporter = new InternalReporter([errorReporter]);
return { testFiles: [], errors: [error] }; const config = await this._loadConfigOrReportError(reporter);
const runner = new Runner(config!); if (!config)
return runner.findRelatedTestFiles('out-of-process', params.files); return { errors: errorReporter.errors(), testFiles: [] };
const taskRunner = createTaskRunnerForRelatedTestFiles(config, reporter, 'out-of-process', false);
const testRun = new TestRun(config);
reporter.onConfigure(config.config);
const status = await taskRunner.run(testRun, 0);
await reporter.onEnd({ status });
await reporter.onExit();
if (status !== 'passed')
return { errors: errorReporter.errors(), testFiles: [] };
return { testFiles: affectedTestFiles(params.files) };
} }
async stopTests() { async stopTests() {

View file

@ -15,9 +15,6 @@
*/ */
import { test, expect } from './playwright-test-fixtures'; import { test, expect } from './playwright-test-fixtures';
import path from 'path';
export const ctReactCliEntrypoint = path.join(__dirname, '../../packages/playwright-ct-react/cli.js');
test('should list related tests', async ({ runCLICommand }) => { test('should list related tests', async ({ runCLICommand }) => {
const result = await runCLICommand({ const result = await runCLICommand({
@ -77,7 +74,7 @@ test('should list related tests for ct', async ({ runCLICommand }) => {
await mount(<Button />); await mount(<Button />);
}); });
`, `,
}, 'find-related-test-files', ['helper.tsx'], ctReactCliEntrypoint); }, 'find-related-test-files', ['helper.tsx']);
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
const data = JSON.parse(result.stdout); const data = JSON.parse(result.stdout);
expect(data).toEqual({ expect(data).toEqual({
@ -86,3 +83,18 @@ test('should list related tests for ct', async ({ runCLICommand }) => {
] ]
}); });
}); });
test('should return errors', async ({ runCLICommand }) => {
const result = await runCLICommand({
'a.spec.ts': `
const a = 1;
const a = 2;
`,
}, 'find-related-test-files', ['a.spec.ts']);
expect(result.exitCode).toBe(0);
const data = JSON.parse(result.stdout);
expect(data).toEqual({ testFiles: [], errors: [
expect.objectContaining({ message: expect.stringContaining(`Identifier 'a' has already been declared`) }),
expect.objectContaining({ message: expect.stringContaining(`No tests found`) }),
] });
});

View file

@ -18,7 +18,7 @@ import { test as baseTest, expect } from './ui-mode-fixtures';
import { TestServerConnection } from '../../packages/playwright/lib/isomorphic/testServerConnection'; import { TestServerConnection } from '../../packages/playwright/lib/isomorphic/testServerConnection';
import { playwrightCtConfigText } from './playwright-test-fixtures'; import { playwrightCtConfigText } from './playwright-test-fixtures';
import ws from 'ws'; import ws from 'ws';
import type { TestChildProcess } from 'tests/config/commonFixtures'; import type { TestChildProcess } from '../config/commonFixtures';
class WSTransport { class WSTransport {
private _ws: ws.WebSocket; private _ws: ws.WebSocket;
@ -70,6 +70,24 @@ const test = baseTest.extend<{ startTestServer: () => Promise<TestServerConnecti
} }
}); });
const ctFiles = {
'playwright.config.ts': playwrightCtConfigText,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = () => <button>Button</button>;
`,
'src/button.test.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './button';
test('pass', async ({ mount }) => {
const component = await mount(<Button></Button>);
await expect(component).toHaveText('Button', { timeout: 1 });
});
`,
};
test('file watching', async ({ startTestServer, writeFiles }, testInfo) => { test('file watching', async ({ startTestServer, writeFiles }, testInfo) => {
await writeFiles({ await writeFiles({
'utils.ts': ` 'utils.ts': `
@ -125,23 +143,7 @@ test('stdio interception', async ({ startTestServer, writeFiles }) => {
}); });
test('start dev server', async ({ startTestServer, writeFiles, runInlineTest }) => { test('start dev server', async ({ startTestServer, writeFiles, runInlineTest }) => {
await writeFiles({ await writeFiles(ctFiles);
'playwright.config.ts': playwrightCtConfigText,
'playwright/index.html': `<script type="module" src="./index.ts"></script>`,
'playwright/index.ts': ``,
'src/button.tsx': `
export const Button = () => <button>Button</button>;
`,
'src/button.test.tsx': `
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './button';
test('pass', async ({ mount }) => {
const component = await mount(<Button></Button>);
await expect(component).toHaveText('Button', { timeout: 1 });
});
`,
});
const testServerConnection = await startTestServer(); const testServerConnection = await startTestServer();
await testServerConnection.initialize({ interceptStdio: true }); await testServerConnection.initialize({ interceptStdio: true });
@ -156,3 +158,38 @@ test('start dev server', async ({ startTestServer, writeFiles, runInlineTest })
expect((await testServerConnection.stopDevServer({})).status).toBe('passed'); expect((await testServerConnection.stopDevServer({})).status).toBe('passed');
expect((await testServerConnection.runGlobalTeardown({})).status).toBe('passed'); expect((await testServerConnection.runGlobalTeardown({})).status).toBe('passed');
}); });
test('find related test files errors', async ({ startTestServer, writeFiles }) => {
await writeFiles({
'a.spec.ts': `
const a = 1;
const a = 2;
`,
});
const testServerConnection = await startTestServer();
await testServerConnection.initialize({ interceptStdio: true });
expect((await testServerConnection.runGlobalSetup({})).status).toBe('passed');
const aSpecTs = test.info().outputPath('a.spec.ts');
const result = await testServerConnection.findRelatedTestFiles({ files: [aSpecTs] });
expect(result).toEqual({ testFiles: [], errors: [
expect.objectContaining({ message: expect.stringContaining(`Identifier 'a' has already been declared`) }),
expect.objectContaining({ message: expect.stringContaining(`No tests found`) }),
] });
expect((await testServerConnection.runGlobalTeardown({})).status).toBe('passed');
});
test('find related test files', async ({ startTestServer, writeFiles }) => {
await writeFiles(ctFiles);
const testServerConnection = await startTestServer();
await testServerConnection.initialize({ interceptStdio: true });
expect((await testServerConnection.runGlobalSetup({})).status).toBe('passed');
const buttonTsx = test.info().outputPath('src/button.tsx');
const buttonTestTsx = test.info().outputPath('src/button.test.tsx');
const result = await testServerConnection.findRelatedTestFiles({ files: [buttonTsx] });
expect(result).toEqual({ testFiles: [buttonTestTsx] });
expect((await testServerConnection.runGlobalTeardown({})).status).toBe('passed');
});