mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: ui mode first cut (#21291)
This commit is contained in:
parent
c42a1205b1
commit
e222874445
@ -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';
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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[] = [];
|
||||
|
461
packages/playwright-test/src/isomorphic/teleReceiver.ts
Normal file
461
packages/playwright-test/src/isomorphic/teleReceiver.ts
Normal 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);
|
||||
});
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
[*]
|
||||
../common/
|
||||
../common/**
|
||||
../isomorphic/**
|
||||
../util.ts
|
||||
../utilsBundle.ts
|
||||
|
@ -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];
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
|
@ -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)),
|
||||
|
210
packages/playwright-test/src/reporters/teleEmitter.ts
Normal file
210
packages/playwright-test/src/reporters/teleEmitter.ts
Normal 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');
|
@ -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 {
|
||||
|
120
packages/playwright-test/src/runner/uiMode.ts
Normal file
120
packages/playwright-test/src/runner/uiMode.ts
Normal 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 });
|
||||
}
|
22
packages/playwright-test/types/reporterPrivate.ts
Normal file
22
packages/playwright-test/types/reporterPrivate.ts
Normal 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';
|
||||
}
|
@ -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'));
|
||||
})();
|
||||
|
@ -1,6 +1,8 @@
|
||||
[*]
|
||||
@injected/**
|
||||
@isomorphic/**
|
||||
@trace/**
|
||||
@web/**
|
||||
../entries.ts
|
||||
../geometry.ts
|
||||
../../../playwright-test/src/isomorphic/**
|
||||
|
23
packages/trace-viewer/src/ui/watchMode.css
Normal file
23
packages/trace-viewer/src/ui/watchMode.css
Normal 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;
|
||||
}
|
256
packages/trace-viewer/src/ui/watchMode.tsx
Normal file
256
packages/trace-viewer/src/ui/watchMode.tsx
Normal 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 }
|
||||
});
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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'),
|
||||
},
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
};
|
||||
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user