chore: ui mode first cut (#21291)

This commit is contained in:
Pavel Feldman 2023-03-01 15:27:23 -08:00 committed by GitHub
parent c42a1205b1
commit e222874445
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1186 additions and 24 deletions

View File

@ -29,3 +29,5 @@ export { createPlaywright } from './playwright';
export type { DispatcherScope } from './dispatchers/dispatcher';
export type { Playwright } from './playwright';
export { showTraceViewer } from './trace/viewer/traceViewer';
export { serverSideCallMetadata } from './instrumentation';

View File

@ -20,13 +20,16 @@ import * as consoleApiSource from '../../../generated/consoleApiSource';
import { HttpServer } from '../../../utils/httpServer';
import { findChromiumChannel } from '../../registry';
import { isUnderTest } from '../../../utils';
import type { BrowserContext } from '../../browserContext';
import { installAppIcon, syncLocalStorageWithSettings } from '../../chromium/crApp';
import { serverSideCallMetadata } from '../../instrumentation';
import { createPlaywright } from '../../playwright';
import { ProgressController } from '../../progress';
import type { Page } from '../../page';
export async function showTraceViewer(traceUrls: string[], browserName: string, { headless = false, host, port }: { headless?: boolean, host?: string, port?: number }): Promise<BrowserContext | undefined> {
type Options = { headless?: boolean, host?: string, port?: number, watchMode?: boolean };
export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise<Page> {
const { headless = false, host, port, watchMode } = options || {};
for (const traceUrl of traceUrls) {
if (!traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceUrl)) {
// eslint-disable-next-line no-console
@ -86,6 +89,8 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
await syncLocalStorageWithSettings(page, 'traceviewer');
const params = traceUrls.map(t => `trace=${t}`);
if (watchMode)
params.push('watchMode=true');
if (isUnderTest()) {
params.push('isUnderTest=true');
page.on('close', () => context.close(serverSideCallMetadata()).catch(() => {}));
@ -95,5 +100,5 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
const searchQuery = params.length ? '?' + params.join('&') : '';
await page.mainFrame().goto(serverSideCallMetadata(), urlPrefix + `/trace/index.html${searchQuery}`);
return context;
return page;
}

View File

@ -26,6 +26,7 @@ import { showHTMLReport } from './reporters/html';
import { baseFullConfig, builtInReporters, ConfigLoader, defaultTimeout, kDefaultConfigFiles, resolveConfigFile } from './common/configLoader';
import type { TraceMode } from './common/types';
import type { ConfigCLIOverrides } from './common/ipc';
import type { FullResult } from '../reporter';
export function addTestCommands(program: Command) {
addTestCommand(program);
@ -166,7 +167,13 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
config._internal.passWithNoTests = !!opts.passWithNoTests;
const runner = new Runner(config);
const status = process.env.PWTEST_WATCH ? await runner.watchAllTests() : await runner.runAllTests();
let status: FullResult['status'];
if (process.env.PWTEST_UI)
status = await runner.uiAllTests();
else if (process.env.PWTEST_WATCH)
status = await runner.watchAllTests();
else
status = await runner.runAllTests();
await stopProfiling(undefined);
if (status === 'interrupted')
process.exit(130);

View File

@ -16,6 +16,7 @@
import type { FixturePool } from './fixtures';
import type * as reporterTypes from '../../types/testReporter';
import type { SuitePrivate } from '../../types/reporterPrivate';
import type { TestTypeImpl } from './testType';
import { rootTestType } from './testType';
import type { Annotation, FixturesWithLocation, FullProject, FullProjectInternal, Location } from './types';
@ -37,7 +38,7 @@ export type Modifier = {
description: string | undefined
};
export class Suite extends Base implements reporterTypes.Suite {
export class Suite extends Base implements SuitePrivate {
location?: Location;
parent?: Suite;
_use: FixturesWithLocation[] = [];

View File

@ -0,0 +1,461 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 type { FullConfig, FullResult, Location, Reporter, TestError, TestResult, TestStatus, TestStep } from '../../types/testReporter';
import type { Annotation, FullProject, Metadata } from '../common/types';
import type * as reporterTypes from '../../types/testReporter';
import type { SuitePrivate } from '../../types/reporterPrivate';
export type JsonLocation = Location;
export type JsonError = string;
export type JsonStackFrame = { file: string, line: number, column: number };
export type JsonConfig = {
rootDir: string;
configFile: string | undefined;
};
export type JsonPattern = {
s?: string;
r?: { source: string, flags: string };
};
export type JsonProject = {
id: string;
grep: JsonPattern[];
grepInvert: JsonPattern[];
metadata: Metadata;
name: string;
dependencies: string[];
snapshotDir: string;
outputDir: string;
repeatEach: number;
retries: number;
suites: JsonSuite[];
testDir: string;
testIgnore: JsonPattern[];
testMatch: JsonPattern[];
timeout: number;
};
export type JsonSuite = {
type: 'root' | 'project' | 'file' | 'describe';
title: string;
location?: JsonLocation;
suites: JsonSuite[];
tests: JsonTestCase[];
fileId: string | undefined;
parallelMode: 'default' | 'serial' | 'parallel';
};
export type JsonTestCase = {
testId: string;
title: string;
location: JsonLocation;
expectedStatus: TestStatus;
timeout: number;
annotations: { type: string, description?: string }[];
retries: number;
};
export type JsonTestResultStart = {
id: string;
retry: number;
workerIndex: number;
parallelIndex: number;
startTime: string;
};
export type JsonTestResultEnd = {
id: string;
duration: number;
status: TestStatus;
errors: TestError[];
attachments: TestResult['attachments'];
};
export type JsonTestStepStart = {
id: string;
title: string;
category: string,
startTime: string;
location?: Location;
};
export type JsonTestStepEnd = {
id: string;
duration: number;
error?: TestError;
};
export class TeleReporterReceiver {
private _rootSuite: TeleSuite;
private _reporter: Reporter;
private _tests = new Map<string, TeleTestCase>();
constructor(reporter: Reporter) {
this._rootSuite = new TeleSuite('', 'root');
this._reporter = reporter;
}
dispatch(message: any) {
const { method, params }: { method: string, params: any } = message;
if (method === 'onBegin') {
this._onBegin(params.config, params.projects);
return;
}
if (method === 'onTestBegin') {
this._onTestBegin(params.testId, params.result);
return;
}
if (method === 'onTestEnd') {
this._onTestEnd(params.testId, params.result);
return;
}
if (method === 'onStepBegin') {
this._onStepBegin(params.testId, params.resultId, params.step);
return;
}
if (method === 'onStepEnd') {
this._onStepEnd(params.testId, params.resultId, params.step);
return;
}
if (method === 'onError') {
this._onError(params.error);
return;
}
if (method === 'onStdIO') {
this._onStdIO(params.type, params.testId, params.resultId, params.data, params.isBase64);
return;
}
if (method === 'onEnd') {
this._onEnd(params.result);
return;
}
}
private _onBegin(config: JsonConfig, projects: JsonProject[]) {
for (const project of projects) {
let projectSuite = this._rootSuite.suites.find(suite => suite.project()!.id === project.id);
if (!projectSuite) {
projectSuite = new TeleSuite(project.name, 'project');
this._rootSuite.suites.push(projectSuite);
projectSuite.parent = this._rootSuite;
}
const p = this._parseProject(project);
projectSuite.project = () => p;
this._mergeSuitesInto(project.suites, projectSuite);
}
this._reporter.onBegin?.(this._parseConfig(config), this._rootSuite);
}
private _onTestBegin(testId: string, payload: JsonTestResultStart) {
const test = this._tests.get(testId)!;
test.results = [];
test.resultsMap.clear();
const testResult = test._appendTestResult(payload.id);
testResult.retry = payload.retry;
testResult.workerIndex = payload.workerIndex;
testResult.parallelIndex = payload.parallelIndex;
testResult.startTime = new Date(payload.startTime);
this._reporter.onTestBegin?.(test, testResult);
}
private _onTestEnd(testId: string, payload: JsonTestResultEnd) {
const test = this._tests.get(testId)!;
const result = test.resultsMap.get(payload.id)!;
result.duration = payload.duration;
result.status = payload.status;
result.errors = payload.errors;
result.attachments = payload.attachments;
this._reporter.onTestEnd?.(test, result);
}
private _onStepBegin(testId: string, resultId: string, payload: JsonTestStepStart) {
const test = this._tests.get(testId)!;
const result = test.resultsMap.get(resultId)!;
const step: TestStep = {
titlePath: () => [],
title: payload.title,
category: payload.category,
location: payload.location,
startTime: new Date(payload.startTime),
duration: 0,
steps: [],
};
// TODO: implement nested steps.
result.stepMap.set(payload.id, step);
result.stepStack[result.stepStack.length - 1].steps.push(step);
result.stepStack.push(step);
this._reporter.onStepBegin?.(test, result, step);
}
private _onStepEnd(testId: string, resultId: string, payload: JsonTestStepEnd) {
const test = this._tests.get(testId)!;
const result = test.resultsMap.get(resultId)!;
const step = result.stepMap.get(payload.id)!;
const i = result.stepStack.indexOf(step);
if (i !== -1)
result.stepStack.splice(i, 1);
step.duration = payload.duration;
step.error = payload.error;
this._reporter.onStepEnd?.(test, result, step);
}
private _onError(error: TestError) {
this._reporter.onError?.(error);
}
private _onStdIO(type: 'stdout' | 'stderr', testId: string | undefined, resultId: string | undefined, data: string, isBase64: boolean) {
const chunk = isBase64 ? Buffer.from(data, 'base64') : data;
const test = testId ? this._tests.get(testId) : undefined;
const result = test && resultId ? test.resultsMap.get(resultId) : undefined;
if (type === 'stdout')
this._reporter.onStdOut?.(chunk, test, result);
else
this._reporter.onStdErr?.(chunk, test, result);
}
private _onEnd(result: FullResult) {
this._reporter.onEnd?.(result);
}
private _parseConfig(config: JsonConfig): FullConfig {
const fullConfig = baseFullConfig;
fullConfig.rootDir = config.rootDir;
fullConfig.configFile = config.configFile;
return fullConfig;
}
private _parseProject(project: JsonProject): TeleFullProject {
return {
id: project.id,
metadata: project.metadata,
name: project.name,
outputDir: project.outputDir,
repeatEach: project.repeatEach,
retries: project.retries,
testDir: project.testDir,
testIgnore: parseRegexPatterns(project.testIgnore),
testMatch: parseRegexPatterns(project.testMatch),
timeout: project.timeout,
grep: parseRegexPatterns(project.grep) as RegExp[],
grepInvert: parseRegexPatterns(project.grepInvert) as RegExp[],
dependencies: project.dependencies,
snapshotDir: project.snapshotDir,
use: {},
};
}
private _mergeSuitesInto(jsonSuites: JsonSuite[], parent: TeleSuite) {
for (const jsonSuite of jsonSuites) {
let targetSuite = parent.suites.find(s => s.title === jsonSuite.title);
if (!targetSuite) {
targetSuite = new TeleSuite(jsonSuite.title, jsonSuite.type);
targetSuite.parent = parent;
parent.suites.push(targetSuite);
}
targetSuite.location = jsonSuite.location;
targetSuite._fileId = jsonSuite.fileId;
targetSuite._parallelMode = jsonSuite.parallelMode;
this._mergeSuitesInto(jsonSuite.suites, targetSuite);
this._mergeTestsInto(jsonSuite.tests, targetSuite);
}
}
private _mergeTestsInto(jsonTests: JsonTestCase[], parent: TeleSuite) {
for (const jsonTest of jsonTests) {
let targetTest = parent.tests.find(s => s.title === jsonTest.title);
if (!targetTest) {
targetTest = new TeleTestCase(jsonTest.testId, jsonTest.title, jsonTest.location);
targetTest.parent = parent;
parent.tests.push(targetTest);
this._tests.set(targetTest.id, targetTest);
}
this._updateTest(jsonTest, targetTest);
}
}
private _updateTest(payload: JsonTestCase, test: TeleTestCase): TeleTestCase {
test.id = payload.testId;
test.expectedStatus = payload.expectedStatus;
test.timeout = payload.timeout;
test.annotations = payload.annotations;
test.retries = payload.retries;
return test;
}
}
export class TeleSuite implements SuitePrivate {
title: string;
location?: Location;
parent?: TeleSuite;
_requireFile: string = '';
suites: TeleSuite[] = [];
tests: TeleTestCase[] = [];
_timeout: number | undefined;
_retries: number | undefined;
_fileId: string | undefined;
_parallelMode: 'default' | 'serial' | 'parallel' = 'default';
readonly _type: 'root' | 'project' | 'file' | 'describe';
constructor(title: string, type: 'root' | 'project' | 'file' | 'describe') {
this.title = title;
this._type = type;
}
allTests(): TeleTestCase[] {
const result: TeleTestCase[] = [];
const visit = (suite: TeleSuite) => {
for (const entry of [...suite.suites, ...suite.tests]) {
if (entry instanceof TeleSuite)
visit(entry);
else
result.push(entry);
}
};
visit(this);
return result;
}
titlePath(): string[] {
const titlePath = this.parent ? this.parent.titlePath() : [];
// Ignore anonymous describe blocks.
if (this.title || this._type !== 'describe')
titlePath.push(this.title);
return titlePath;
}
project(): TeleFullProject | undefined {
return undefined;
}
}
export class TeleTestCase implements reporterTypes.TestCase {
title: string;
fn = () => {};
results: reporterTypes.TestResult[] = [];
location: Location;
parent!: TeleSuite;
expectedStatus: reporterTypes.TestStatus = 'passed';
timeout = 0;
annotations: Annotation[] = [];
retries = 0;
repeatEachIndex = 0;
id: string;
resultsMap = new Map<string, TeleTestResult>();
constructor(id: string, title: string, location: Location) {
this.id = id;
this.title = title;
this.location = location;
}
titlePath(): string[] {
const titlePath = this.parent ? this.parent.titlePath() : [];
titlePath.push(this.title);
return titlePath;
}
outcome(): 'skipped' | 'expected' | 'unexpected' | 'flaky' {
const nonSkipped = this.results.filter(result => result.status !== 'skipped' && result.status !== 'interrupted');
if (!nonSkipped.length)
return 'skipped';
if (nonSkipped.every(result => result.status === this.expectedStatus))
return 'expected';
if (nonSkipped.some(result => result.status === this.expectedStatus))
return 'flaky';
return 'unexpected';
}
ok(): boolean {
const status = this.outcome();
return status === 'expected' || status === 'flaky' || status === 'skipped';
}
_appendTestResult(id: string): reporterTypes.TestResult {
const result: TeleTestResult = {
retry: this.results.length,
parallelIndex: -1,
workerIndex: -1,
duration: 0,
startTime: new Date(),
stdout: [],
stderr: [],
attachments: [],
status: 'skipped',
steps: [],
errors: [],
stepMap: new Map(),
stepStack: [],
};
result.stepStack.push(result);
this.results.push(result);
this.resultsMap.set(id, result);
return result;
}
}
export type TeleTestResult = reporterTypes.TestResult & {
stepMap: Map<string, reporterTypes.TestStep>;
stepStack: (reporterTypes.TestStep | reporterTypes.TestResult)[];
};
export type TeleFullProject = FullProject & { id: string };
export const baseFullConfig: FullConfig = {
forbidOnly: false,
fullyParallel: false,
globalSetup: null,
globalTeardown: null,
globalTimeout: 0,
grep: /.*/,
grepInvert: null,
maxFailures: 0,
metadata: {},
preserveOutput: 'always',
projects: [],
reporter: [[process.env.CI ? 'dot' : 'list']],
reportSlowTests: { max: 5, threshold: 15000 },
configFile: '',
rootDir: '',
quiet: false,
shard: null,
updateSnapshots: 'missing',
version: '',
workers: 0,
webServer: null,
};
export function serializeRegexPatterns(patterns: string | RegExp | (string | RegExp)[]): JsonPattern[] {
if (!Array.isArray(patterns))
patterns = [patterns];
return patterns.map(s => {
if (typeof s === 'string')
return { s };
return { r: { source: s.source, flags: s.flags } };
});
}
export function parseRegexPatterns(patterns: JsonPattern[]): (string | RegExp)[] {
return patterns.map(p => {
if (p.s)
return p.s;
return new RegExp(p.r!.source, p.r!.flags);
});
}

View File

@ -1,4 +1,5 @@
[*]
../common/
../common/**
../isomorphic/**
../util.ts
../utilsBundle.ts

View File

@ -18,6 +18,7 @@ import { colors, ms as milliseconds, parseStackTraceLine } from 'playwright-core
import fs from 'fs';
import path from 'path';
import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location, Reporter } from '../../types/testReporter';
import type { SuitePrivate } from '../../types/reporterPrivate';
import type { FullConfigInternal } from '../common/types';
import { codeFrameColumns } from '../common/babelBundle';
import { monotonicTime } from 'playwright-core/lib/utils';
@ -88,7 +89,7 @@ export class BaseReporter implements Reporter {
onTestEnd(test: TestCase, result: TestResult) {
// Ignore any tests that are run in parallel.
for (let suite: Suite | undefined = test.parent; suite; suite = suite.parent) {
if ((suite as any)._parallelMode === 'parallel')
if ((suite as SuitePrivate)._parallelMode === 'parallel')
return;
}
const projectName = test.titlePath()[1];

View File

@ -210,7 +210,7 @@ class HtmlBuilder {
for (const projectJson of rawReports) {
for (const file of projectJson.suites) {
const fileName = file.location!.file;
const fileId = file.fileId;
const fileId = file.fileId!;
let fileEntry = data.get(fileId);
if (!fileEntry) {
fileEntry = {

View File

@ -20,6 +20,7 @@ import type { FullConfig, TestCase, Suite, TestResult, TestError, TestStep, Full
import { formatError, prepareErrorStack } from './base';
import { MultiMap } from 'playwright-core/lib/utils';
import { assert } from 'playwright-core/lib/utils';
import type { FullProjectInternal } from '../common/types';
export function toPosixPath(aPath: string): string {
return aPath.split(path.sep).join(path.posix.sep);
@ -63,7 +64,7 @@ class JSONReporter implements Reporter {
repeatEach: project.repeatEach,
retries: project.retries,
metadata: project.metadata,
id: (project as any)._id,
id: (project as FullProjectInternal)._internal.id,
name: project.name,
testDir: toPosixPath(project.testDir),
testIgnore: serializePatterns(project.testIgnore),
@ -80,7 +81,7 @@ class JSONReporter implements Reporter {
private _mergeSuites(suites: Suite[]): JSONReportSuite[] {
const fileSuites = new MultiMap<string, JSONReportSuite>();
for (const projectSuite of suites) {
const projectId = (projectSuite.project() as any)._id;
const projectId = (projectSuite.project() as FullProjectInternal)._internal.id;
const projectName = projectSuite.project()!.name;
for (const fileSuite of projectSuite.suites) {
const file = fileSuite.location!.file;

View File

@ -24,6 +24,7 @@ import { toPosixPath, serializePatterns } from './json';
import { MultiMap } from 'playwright-core/lib/utils';
import { codeFrameColumns } from '../common/babelBundle';
import type { Metadata } from '../common/types';
import type { SuitePrivate } from '../../types/reporterPrivate';
export type JsonLocation = Location;
export type JsonError = string;
@ -50,7 +51,7 @@ export type JsonProject = {
};
export type JsonSuite = {
fileId: string;
fileId?: string;
title: string;
location?: JsonLocation;
suites: JsonSuite[];
@ -215,7 +216,7 @@ class RawReporter {
const location = this._relativeLocation(suite.location);
const result = {
title: suite.title,
fileId: (suite as any)._fileId,
fileId: (suite as SuitePrivate)._fileId,
location,
suites: suite.suites.map(s => this._serializeSuite(s)),
tests: suite.tests.map(t => this._serializeTest(t)),

View File

@ -0,0 +1,210 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 type { FullConfig, FullResult, Reporter, TestError, TestResult, TestStep } from '../../types/testReporter';
import type { Suite, TestCase } from '../common/test';
import type { JsonConfig, JsonProject, JsonSuite, JsonTestCase, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver';
import type { SuitePrivate } from '../../types/reporterPrivate';
import type { FullProjectInternal } from '../common/types';
import { createGuid } from 'playwright-core/lib/utils';
import { serializeRegexPatterns } from '../isomorphic/teleReceiver';
export class TeleReporterEmitter implements Reporter {
private config!: FullConfig;
private _messageSink: (message: any) => void;
constructor(messageSink: (message: any) => void) {
this._messageSink = messageSink;
}
onBegin(config: FullConfig, suite: Suite) {
this.config = config;
const projects: any[] = [];
for (const projectSuite of suite.suites) {
const report = this._serializeProject(projectSuite);
projects.push(report);
}
this._messageSink({ method: 'onBegin', params: { config: this._serializeConfig(config), projects } });
}
onTestBegin(test: TestCase, result: TestResult): void {
(result as any)[idSymbol] = createGuid();
this._messageSink({
method: 'onTestBegin',
params: {
testId: test.id,
result: this._serializeResultStart(result)
}
});
}
onTestEnd(test: TestCase, result: TestResult): void {
this._messageSink({
method: 'onTestEnd',
params: {
testId: test.id,
result: this._serializeResultEnd(result),
}
});
}
onStepBegin(test: TestCase, result: TestResult, step: TestStep): void {
(step as any)[idSymbol] = createGuid();
this._messageSink({
method: 'onStepBegin',
params: {
testId: test.id,
resultId: (result as any)[idSymbol],
step: this._serializeStepStart(step)
}
});
}
onStepEnd(test: TestCase, result: TestResult, step: TestStep): void {
this._messageSink({
method: 'onStepEnd',
params: {
testId: test.id,
resultId: (result as any)[idSymbol],
step: this._serializeStepEnd(step)
}
});
}
onError(error: TestError): void {
this._messageSink({
method: 'onError',
params: { error }
});
}
onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void {
this._onStdIO('stdio', chunk, test, result);
}
onStdErr(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void {
this._onStdIO('stderr', chunk, test, result);
}
private _onStdIO(type: 'stdio' | 'stderr', chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void {
const isBase64 = typeof chunk !== 'string';
const data = isBase64 ? chunk.toString('base64') : chunk;
this._messageSink({
method: 'onStdIO',
params: { testId: test?.id, resultId: result ? (result as any)[idSymbol] : undefined, type, data, isBase64 }
});
}
async onEnd(result: FullResult) {
this._messageSink({ method: 'onEnd', params: { result } });
}
private _serializeConfig(config: FullConfig): JsonConfig {
return {
rootDir: config.rootDir,
configFile: config.configFile,
};
}
private _serializeProject(suite: Suite): JsonProject {
const project = suite.project()!;
const report: JsonProject = {
id: (project as FullProjectInternal)._internal.id,
metadata: project.metadata,
name: project.name,
outputDir: project.outputDir,
repeatEach: project.repeatEach,
retries: project.retries,
testDir: project.testDir,
testIgnore: serializeRegexPatterns(project.testIgnore),
testMatch: serializeRegexPatterns(project.testMatch),
timeout: project.timeout,
suites: suite.suites.map(fileSuite => {
return this._serializeSuite(fileSuite);
}),
grep: serializeRegexPatterns(project.grep),
grepInvert: serializeRegexPatterns(project.grepInvert || []),
dependencies: project.dependencies,
snapshotDir: project.snapshotDir,
};
return report;
}
private _serializeSuite(suite: Suite): JsonSuite {
const result = {
type: suite._type,
title: suite.title,
fileId: (suite as SuitePrivate)._fileId,
parallelMode: (suite as SuitePrivate)._parallelMode,
location: suite.location,
suites: suite.suites.map(s => this._serializeSuite(s)),
tests: suite.tests.map(t => this._serializeTest(t)),
};
return result;
}
private _serializeTest(test: TestCase): JsonTestCase {
return {
testId: test.id,
title: test.title,
location: test.location,
expectedStatus: test.expectedStatus,
timeout: test.timeout,
annotations: test.annotations,
retries: test.retries,
};
}
private _serializeResultStart(result: TestResult): JsonTestResultStart {
return {
id: (result as any)[idSymbol],
retry: result.retry,
workerIndex: result.workerIndex,
parallelIndex: result.parallelIndex,
startTime: result.startTime.toISOString(),
};
}
private _serializeResultEnd(result: TestResult): JsonTestResultEnd {
return {
id: (result as any)[idSymbol],
duration: result.duration,
status: result.status,
errors: result.errors,
attachments: result.attachments,
};
}
private _serializeStepStart(step: TestStep): JsonTestStepStart {
return {
id: (step as any)[idSymbol],
title: step.title,
category: step.category,
startTime: step.startTime.toISOString(),
location: step.location,
};
}
private _serializeStepEnd(step: TestStep): JsonTestStepEnd {
return {
id: (step as any)[idSymbol],
duration: step.duration,
error: step.error,
};
}
}
const idSymbol = Symbol('id');

View File

@ -25,6 +25,7 @@ import type { TaskRunnerState } from './tasks';
import type { FullConfigInternal } from '../common/types';
import { colors } from 'playwright-core/lib/utilsBundle';
import { runWatchModeLoop } from './watchMode';
import { runUIMode } from './uiMode';
export class Runner {
private _config: FullConfigInternal;
@ -97,6 +98,12 @@ export class Runner {
webServerPluginsForConfig(config).forEach(p => config._internal.plugins.push({ factory: p }));
return await runWatchModeLoop(config);
}
async uiAllTests(): Promise<FullResult['status']> {
const config = this._config;
webServerPluginsForConfig(config).forEach(p => config._internal.plugins.push({ factory: p }));
return await runUIMode(config);
}
}
function sanitizeConfigForJSON(object: any, visited: Set<any>): any {

View File

@ -0,0 +1,120 @@
/**
* 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 type { FullResult } from 'packages/playwright-test/reporter';
import type { Page } from 'playwright-core/lib/server/page';
import { showTraceViewer, serverSideCallMetadata } from 'playwright-core/lib/server';
import { clearCompilationCache } from '../common/compilationCache';
import type { FullConfigInternal } from '../common/types';
import ListReporter from '../reporters/list';
import { Multiplexer } from '../reporters/multiplexer';
import { TeleReporterEmitter } from '../reporters/teleEmitter';
import { createTaskRunnerForList, createTaskRunnerForWatch, createTaskRunnerForWatchSetup } from './tasks';
import type { TaskRunnerState } from './tasks';
import { createReporter } from './reporters';
export async function runUIMode(config: FullConfigInternal): Promise<FullResult['status']> {
// Reset the settings that don't apply to watch.
config._internal.passWithNoTests = true;
for (const p of config.projects)
p.retries = 0;
{
// Global setup.
const reporter = await createReporter(config, 'watch');
const taskRunner = createTaskRunnerForWatchSetup(config, reporter);
reporter.onConfigure(config);
const context: TaskRunnerState = {
config,
reporter,
phases: [],
};
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0);
if (status !== 'passed')
return await globalCleanup();
}
// Show trace viewer.
const page = await showTraceViewer([], 'chromium', { watchMode: true });
await page.mainFrame()._waitForFunctionExpression(serverSideCallMetadata(), '!!window.dispatch', false, undefined, { timeout: 0 });
{
// List
const controller = new Controller(config, page);
const listReporter = new TeleReporterEmitter(message => controller!.send(message));
const reporter = new Multiplexer([listReporter]);
const taskRunner = createTaskRunnerForList(config, reporter);
const context: TaskRunnerState = {
config,
reporter,
phases: [],
};
reporter.onConfigure(config);
const { status, cleanup: globalCleanup } = await taskRunner.runDeferCleanup(context, 0);
if (status !== 'passed')
return await globalCleanup();
await taskRunner.run(context, 0);
}
await new Promise(() => {});
// TODO: implement watch queue with the sigint watcher and global teardown.
return 'passed';
}
class Controller {
private _page: Page;
private _queue = Promise.resolve();
private _runReporter: TeleReporterEmitter;
constructor(config: FullConfigInternal, page: Page) {
this._page = page;
this._runReporter = new TeleReporterEmitter(message => this!.send(message));
this._page.exposeBinding('binding', false, (source, data) => {
const { method, params } = data;
if (method === 'run') {
const { location } = params;
config._internal.cliArgs = [location];
this._queue = this._queue.then(() => runTests(config, this._runReporter));
return this._queue;
}
});
}
send(message: any) {
const func = (message: any) => {
(window as any).dispatch(message);
};
// eslint-disable-next-line no-console
this._page.mainFrame().evaluateExpression(String(func), true, message).catch(e => console.log(e));
}
}
async function runTests(config: FullConfigInternal, teleReporter: TeleReporterEmitter) {
const reporter = new Multiplexer([new ListReporter(), teleReporter]);
config._internal.configCLIOverrides.use = config._internal.configCLIOverrides.use || {};
config._internal.configCLIOverrides.use.trace = 'on';
const taskRunner = createTaskRunnerForWatch(config, reporter);
const context: TaskRunnerState = {
config,
reporter,
phases: [],
};
clearCompilationCache();
reporter.onConfigure(config);
const status = await taskRunner.run(context, 0);
await reporter.onExit({ status });
}

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 type { Suite } from './testReporter';
export interface SuitePrivate extends Suite {
_fileId: string | undefined;
_parallelMode: 'default' | 'serial' | 'parallel';
}

View File

@ -14,13 +14,22 @@
* limitations under the License.
*/
import '@web/common.css';
import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css';
import React from 'react';
import * as ReactDOM from 'react-dom';
import { applyTheme } from '@web/theme';
import '@web/common.css';
import { WatchModeView } from './ui/watchMode';
import { WorkbenchLoader } from './ui/workbench';
export const RootView: React.FC<{}> = ({
}) => {
if (window.location.href.includes('watchMode=true'))
return <WatchModeView />;
else
return <WorkbenchLoader/>;
};
(async () => {
applyTheme();
if (window.location.protocol !== 'file:') {
@ -37,5 +46,5 @@ import { WorkbenchLoader } from './ui/workbench';
setInterval(function() { fetch('ping'); }, 10000);
}
ReactDOM.render(<WorkbenchLoader></WorkbenchLoader>, document.querySelector('#root'));
ReactDOM.render(<RootView></RootView>, document.querySelector('#root'));
})();

View File

@ -1,6 +1,8 @@
[*]
@injected/**
@isomorphic/**
@trace/**
@web/**
../entries.ts
../geometry.ts
../../../playwright-test/src/isomorphic/**

View File

@ -0,0 +1,23 @@
/*
Copyright (c) Microsoft Corporation.
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.
*/
.watch-mode-sidebar {
background-color: var(--vscode-sideBar-background);
}
.watch-mode-sidebar input {
flex: auto;
}

View File

@ -0,0 +1,256 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 '@web/third_party/vscode/codicon.css';
import { loadSingleTraceFile, Workbench } from './workbench';
import '@web/common.css';
import React from 'react';
import { ListView } from '@web/components/listView';
import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
import type { FullConfig, Suite, TestCase, TestStep } from '../../../playwright-test/types/testReporter';
import { SplitView } from '@web/components/splitView';
import type { MultiTraceModel } from './modelUtil';
import './watchMode.css';
let rootSuite: Suite | undefined;
let updateList: () => void = () => {};
let updateProgress: () => void = () => {};
type Entry = { test?: TestCase, fileSuite: Suite };
export const WatchModeView: React.FC<{}> = ({
}) => {
const [updateCounter, setUpdateCounter] = React.useState(0);
updateList = () => setUpdateCounter(updateCounter + 1);
const [selectedFileSuite, setSelectedFileSuite] = React.useState<Suite | undefined>();
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>();
const [isRunningTest, setIsRunningTest] = React.useState<boolean>(false);
const [expandedFiles] = React.useState(new Map<Suite, boolean | undefined>());
const [filterText, setFilterText] = React.useState<string>('');
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus();
}, []);
const selectedOrDefaultFileSuite = selectedFileSuite || rootSuite?.suites?.[0]?.suites?.[0];
const tests: TestCase[] = [];
const fileSuites: Suite[] = [];
for (const projectSuite of rootSuite?.suites || []) {
for (const fileSuite of projectSuite.suites) {
if (fileSuite === selectedOrDefaultFileSuite)
tests.push(...fileSuite.allTests());
fileSuites.push(fileSuite);
}
}
const explicitlyOrAutoExpandedFiles = new Set<Suite>();
const entries = new Map<TestCase | Suite, Entry>();
const trimmedFilterText = filterText.trim();
const filterTokens = trimmedFilterText.split(' ');
for (const fileSuite of fileSuites) {
const hasMatch = !trimmedFilterText || fileSuite.allTests().some(test => {
const fullTitle = test.titlePath().join(' ');
return !filterTokens.some(token => !fullTitle.includes(token));
});
if (hasMatch)
entries.set(fileSuite, { fileSuite });
const expandState = expandedFiles.get(fileSuite);
const autoExpandMatches = entries.size < 100 && (trimmedFilterText && hasMatch && expandState !== false);
if (expandState === true || autoExpandMatches) {
explicitlyOrAutoExpandedFiles.add(fileSuite);
for (const test of fileSuite.allTests()) {
const fullTitle = test.titlePath().join(' ');
if (!filterTokens.some(token => !fullTitle.includes(token)))
entries.set(test, { test, fileSuite });
}
}
}
const selectedEntry = selectedTest ? entries.get(selectedTest) : selectedOrDefaultFileSuite ? entries.get(selectedOrDefaultFileSuite) : undefined;
return <SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
<TraceView test={selectedTest} isRunningTest={isRunningTest}></TraceView>
<div className='vbox watch-mode-sidebar'>
<div style={{ flex: 'none', display: 'flex', padding: 4 }}>
<input ref={inputRef} type='search' placeholder='Filter tests' spellCheck={false} value={filterText}
onChange={e => {
setFilterText(e.target.value);
}}
onKeyDown={e => {
}}></input>
</div>
<ListView
items={[...entries.values()]}
itemKey={(entry: Entry) => entry.test ? entry.test!.id : entry.fileSuite.title }
itemRender={(entry: Entry) => entry.test ? entry.test!.titlePath().slice(3).join(' ') : entry.fileSuite.title }
itemIcon={(entry: Entry) => {
if (entry.test) {
if (entry.test.results.length && entry.test.results[0].duration)
return entry.test.ok() ? 'codicon-check' : 'codicon-error';
if (entry.test.results.length)
return 'codicon-loading';
} else {
if (explicitlyOrAutoExpandedFiles.has(entry.fileSuite))
return 'codicon-chevron-down';
return 'codicon-chevron-right';
}
}}
itemIndent={(entry: Entry) => entry.test ? 1 : 0}
selectedItem={selectedEntry}
onAccepted={(entry: Entry) => {
if (entry.test) {
setSelectedTest(entry.test);
setIsRunningTest(true);
runTests(entry.test ? entry.test.location.file + ':' + entry.test.location.line : entry.fileSuite.title).then(() => {
setIsRunningTest(false);
});
}
}}
onLeftArrow={(entry: Entry) => {
expandedFiles.set(entry.fileSuite, false);
setSelectedTest(undefined);
setSelectedFileSuite(entry.fileSuite);
updateList();
}}
onRightArrow={(entry: Entry) => {
expandedFiles.set(entry.fileSuite, true);
updateList();
}}
onSelected={(entry: Entry) => {
if (entry.test) {
setSelectedFileSuite(undefined);
setSelectedTest(entry.test!);
} else {
setSelectedTest(undefined);
setSelectedFileSuite(entry.fileSuite);
}
}}
onIconClicked={(entry: Entry) => {
if (explicitlyOrAutoExpandedFiles.has(entry.fileSuite))
expandedFiles.set(entry.fileSuite, false);
else
expandedFiles.set(entry.fileSuite, true);
updateList();
}}
showNoItemsMessage={true}></ListView>
</div>
</SplitView>;
};
export const ProgressView: React.FC<{
test: TestCase | undefined,
}> = ({
test,
}) => {
const [updateCounter, setUpdateCounter] = React.useState(0);
updateProgress = () => setUpdateCounter(updateCounter + 1);
const steps: (TestCase | TestStep)[] = [];
for (const result of test?.results || [])
steps.push(...result.steps);
return <ListView
items={steps}
itemRender={(step: TestStep) => step.title}
itemIcon={(step: TestStep) => step.error ? 'codicon-error' : 'codicon-check'}
></ListView>;
};
export const TraceView: React.FC<{
test: TestCase | undefined,
isRunningTest: boolean,
}> = ({ test, isRunningTest }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
React.useEffect(() => {
(async () => {
if (!test) {
setModel(undefined);
return;
}
for (const result of test.results) {
const attachment = result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path) {
setModel(await loadSingleTraceFile(attachment.path));
return;
}
}
setModel(undefined);
})();
}, [test, isRunningTest]);
if (isRunningTest)
return <ProgressView test={test}></ProgressView>;
if (!model) {
return <div className='vbox'>
<div className='drop-target'>
<div>Run test to see the trace</div>
<div style={{ paddingTop: 20 }}>
<div>Double click a test or hit Enter</div>
</div>
</div>
</div>;
}
return <Workbench model={model} view='embedded'></Workbench>;
};
declare global {
interface Window {
binding(data: any): Promise<void>;
}
}
const receiver = new TeleReporterReceiver({
onBegin: (config: FullConfig, suite: Suite) => {
if (!rootSuite)
rootSuite = suite;
updateList();
},
onTestBegin: () => {
updateList();
},
onTestEnd: () => {
updateList();
},
onStepBegin: () => {
updateProgress();
},
onStepEnd: () => {
updateProgress();
},
});
(window as any).dispatch = (message: any) => {
receiver.dispatch(message);
};
async function runTests(location: string): Promise<void> {
await (window as any).binding({
method: 'run',
params: { location }
});
}

View File

@ -253,7 +253,7 @@ export const emptyModel = new MultiTraceModel([]);
export async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`context?${params.toString()}`);
const contextEntry = await response.json() as ContextEntry;
return new MultiTraceModel([contextEntry]);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}

View File

@ -32,6 +32,7 @@ export default defineConfig({
'@injected': path.resolve(__dirname, '../playwright-core/src/server/injected'),
'@isomorphic': path.resolve(__dirname, '../playwright-core/src/server/isomorphic'),
'@protocol': path.resolve(__dirname, '../protocol/src'),
'@trace': path.resolve(__dirname, '../trace/src'),
'@web': path.resolve(__dirname, '../web/src'),
},
},

View File

@ -105,3 +105,10 @@ svg {
.codicon-error {
color: var(--red);
}
input[type=text], input[type=search] {
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);
border: none;
outline: none;
}

View File

@ -21,7 +21,7 @@
position: relative;
user-select: none;
overflow: auto;
outline: 1 px solid transparent;
outline: 1px solid transparent;
}
.list-view-entry {
@ -57,6 +57,10 @@
outline: 1px solid var(--vscode-inputValidation-errorBorder);
}
.list-view-content:focus .list-view-entry.selected .codicon {
color: var(--vscode-list-activeSelectionForeground) !important;
}
.list-view-empty {
flex: auto;
display: flex;

View File

@ -27,7 +27,10 @@ export type ListViewProps = {
selectedItem?: any,
onAccepted?: (item: any) => void,
onSelected?: (item: any) => void,
onLeftArrow?: (item: any) => void,
onRightArrow?: (item: any) => void,
onHighlighted?: (item: any | undefined) => void,
onIconClicked?: (item: any) => void,
showNoItemsMessage?: boolean,
dataTestId?: string,
};
@ -42,7 +45,10 @@ export const ListView: React.FC<ListViewProps> = ({
selectedItem,
onAccepted,
onSelected,
onLeftArrow,
onRightArrow,
onHighlighted,
onIconClicked,
showNoItemsMessage,
dataTestId,
}) => {
@ -59,10 +65,21 @@ export const ListView: React.FC<ListViewProps> = ({
onAccepted?.(selectedItem);
return;
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')
return;
event.stopPropagation();
event.preventDefault();
if (event.key === 'ArrowLeft') {
onLeftArrow?.(selectedItem);
return;
}
if (event.key === 'ArrowRight') {
onRightArrow?.(selectedItem);
return;
}
const index = selectedItem ? items.indexOf(selectedItem) : -1;
let newIndex = index;
if (event.key === 'ArrowDown') {
@ -77,6 +94,7 @@ export const ListView: React.FC<ListViewProps> = ({
else
newIndex = Math.max(index - 1, 0);
}
const element = itemListRef.current?.children.item(newIndex);
scrollIntoViewIfNeeded(element);
onHighlighted?.(undefined);
@ -102,6 +120,7 @@ export const ListView: React.FC<ListViewProps> = ({
setHighlightedItem(undefined);
onHighlighted?.(undefined);
}}
onIconClicked={() => onIconClicked?.(item)}
>
{itemRender(item)}
</ListItemView>)}
@ -120,8 +139,9 @@ const ListItemView: React.FC<{
onSelected: () => void,
onMouseEnter: () => void,
onMouseLeave: () => void,
onIconClicked: () => void,
children: React.ReactNode | React.ReactNode[],
}> = ({ key, hasIcons, icon, type, indent, onSelected, onMouseEnter, onMouseLeave, isHighlighted, isSelected, children }) => {
}> = ({ key, hasIcons, icon, type, indent, onSelected, onMouseEnter, onMouseLeave, onIconClicked, isHighlighted, isSelected, children }) => {
const selectedSuffix = isSelected ? ' selected' : '';
const highlightedSuffix = isHighlighted ? ' highlighted' : '';
const errorSuffix = type === 'error' ? ' error' : '';
@ -141,7 +161,7 @@ const ListItemView: React.FC<{
ref={divRef}
>
{indent ? <div style={{ minWidth: indent * 16 }}></div> : undefined}
{hasIcons && <div className={'codicon ' + (icon || 'blank')} style={{ minWidth: 16, marginRight: 4 }}></div>}
{hasIcons && <div className={'codicon ' + (icon || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={onIconClicked}></div>}
{typeof children === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{children}</div> : children}
</div>;
};

View File

@ -15,7 +15,7 @@
*/
import type { Fixtures, Frame, Locator, Page, Browser, BrowserContext } from '@playwright/test';
import { showTraceViewer } from '../../packages/playwright-core/lib/server/trace/viewer/traceViewer';
import { showTraceViewer } from '../../packages/playwright-core/lib/server';
type BaseTestFixtures = {
context: BrowserContext;
@ -113,7 +113,8 @@ export const traceViewerFixtures: Fixtures<TraceViewerFixtures, {}, BaseTestFixt
const browsers: Browser[] = [];
const contextImpls: any[] = [];
await use(async (traces: string[], { host, port } = {}) => {
const contextImpl = await showTraceViewer(traces, browserName, { headless, host, port });
const pageImpl = await showTraceViewer(traces, browserName, { headless, host, port });
const contextImpl = pageImpl.context();
const browser = await playwright.chromium.connectOverCDP(contextImpl._browser.options.wsEndpoint);
browsers.push(browser);
contextImpls.push(contextImpl);