diff --git a/packages/playwright-core/src/server/index.ts b/packages/playwright-core/src/server/index.ts index 3daf49da02..3c65e01ab5 100644 --- a/packages/playwright-core/src/server/index.ts +++ b/packages/playwright-core/src/server/index.ts @@ -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'; \ No newline at end of file diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 07aa9a0af2..6eaa36345b 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -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 { +type Options = { headless?: boolean, host?: string, port?: number, watchMode?: boolean }; + +export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise { + 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; } diff --git a/packages/playwright-test/src/cli.ts b/packages/playwright-test/src/cli.ts index 36233c0476..296077b064 100644 --- a/packages/playwright-test/src/cli.ts +++ b/packages/playwright-test/src/cli.ts @@ -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); diff --git a/packages/playwright-test/src/common/test.ts b/packages/playwright-test/src/common/test.ts index b141a4a558..b8aaa313c4 100644 --- a/packages/playwright-test/src/common/test.ts +++ b/packages/playwright-test/src/common/test.ts @@ -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[] = []; diff --git a/packages/playwright-test/src/isomorphic/teleReceiver.ts b/packages/playwright-test/src/isomorphic/teleReceiver.ts new file mode 100644 index 0000000000..40cccc5f11 --- /dev/null +++ b/packages/playwright-test/src/isomorphic/teleReceiver.ts @@ -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(); + + 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(); + + 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; + 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); + }); +} diff --git a/packages/playwright-test/src/reporters/DEPS.list b/packages/playwright-test/src/reporters/DEPS.list index 5b441c9e4e..472e911a17 100644 --- a/packages/playwright-test/src/reporters/DEPS.list +++ b/packages/playwright-test/src/reporters/DEPS.list @@ -1,4 +1,5 @@ [*] -../common/ +../common/** +../isomorphic/** ../util.ts ../utilsBundle.ts diff --git a/packages/playwright-test/src/reporters/base.ts b/packages/playwright-test/src/reporters/base.ts index 6e95b12edc..4f6e376f33 100644 --- a/packages/playwright-test/src/reporters/base.ts +++ b/packages/playwright-test/src/reporters/base.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]; diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts index 258e13d958..9b7ba809b0 100644 --- a/packages/playwright-test/src/reporters/html.ts +++ b/packages/playwright-test/src/reporters/html.ts @@ -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 = { diff --git a/packages/playwright-test/src/reporters/json.ts b/packages/playwright-test/src/reporters/json.ts index 6f7cb71322..2921760747 100644 --- a/packages/playwright-test/src/reporters/json.ts +++ b/packages/playwright-test/src/reporters/json.ts @@ -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(); 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; diff --git a/packages/playwright-test/src/reporters/raw.ts b/packages/playwright-test/src/reporters/raw.ts index d9c1baa2e6..d366794335 100644 --- a/packages/playwright-test/src/reporters/raw.ts +++ b/packages/playwright-test/src/reporters/raw.ts @@ -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)), diff --git a/packages/playwright-test/src/reporters/teleEmitter.ts b/packages/playwright-test/src/reporters/teleEmitter.ts new file mode 100644 index 0000000000..8888616029 --- /dev/null +++ b/packages/playwright-test/src/reporters/teleEmitter.ts @@ -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'); diff --git a/packages/playwright-test/src/runner/runner.ts b/packages/playwright-test/src/runner/runner.ts index 0cb9656e11..36a9d988c2 100644 --- a/packages/playwright-test/src/runner/runner.ts +++ b/packages/playwright-test/src/runner/runner.ts @@ -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 { + 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 { diff --git a/packages/playwright-test/src/runner/uiMode.ts b/packages/playwright-test/src/runner/uiMode.ts new file mode 100644 index 0000000000..fe217715e1 --- /dev/null +++ b/packages/playwright-test/src/runner/uiMode.ts @@ -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 { + // 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 }); +} diff --git a/packages/playwright-test/types/reporterPrivate.ts b/packages/playwright-test/types/reporterPrivate.ts new file mode 100644 index 0000000000..ca2105c5d8 --- /dev/null +++ b/packages/playwright-test/types/reporterPrivate.ts @@ -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'; +} diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index b3e2b65100..dc586dd785 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -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 ; + else + return ; +}; + (async () => { applyTheme(); if (window.location.protocol !== 'file:') { @@ -37,5 +46,5 @@ import { WorkbenchLoader } from './ui/workbench'; setInterval(function() { fetch('ping'); }, 10000); } - ReactDOM.render(, document.querySelector('#root')); + ReactDOM.render(, document.querySelector('#root')); })(); diff --git a/packages/trace-viewer/src/ui/DEPS.list b/packages/trace-viewer/src/ui/DEPS.list index 8271051e09..515497fd39 100644 --- a/packages/trace-viewer/src/ui/DEPS.list +++ b/packages/trace-viewer/src/ui/DEPS.list @@ -1,6 +1,8 @@ [*] @injected/** @isomorphic/** +@trace/** @web/** ../entries.ts ../geometry.ts +../../../playwright-test/src/isomorphic/** diff --git a/packages/trace-viewer/src/ui/watchMode.css b/packages/trace-viewer/src/ui/watchMode.css new file mode 100644 index 0000000000..2ae6eb88d9 --- /dev/null +++ b/packages/trace-viewer/src/ui/watchMode.css @@ -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; +} diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx new file mode 100644 index 0000000000..b56a3094ae --- /dev/null +++ b/packages/trace-viewer/src/ui/watchMode.tsx @@ -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(); + const [selectedTest, setSelectedTest] = React.useState(); + const [isRunningTest, setIsRunningTest] = React.useState(false); + const [expandedFiles] = React.useState(new Map()); + const [filterText, setFilterText] = React.useState(''); + + const inputRef = React.useRef(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(); + const entries = new Map(); + 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 + +
+
+ { + setFilterText(e.target.value); + }} + onKeyDown={e => { + }}> +
+ 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}> +
+
; +}; + +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 step.title} + itemIcon={(step: TestStep) => step.error ? 'codicon-error' : 'codicon-check'} + >; +}; + +export const TraceView: React.FC<{ + test: TestCase | undefined, + isRunningTest: boolean, +}> = ({ test, isRunningTest }) => { + const [model, setModel] = React.useState(); + + 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 ; + + if (!model) { + return
+
+
Run test to see the trace
+
+
Double click a test or hit Enter
+
+
+
; + } + + return ; + +}; + +declare global { + interface Window { + binding(data: any): Promise; + } +} + +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 { + await (window as any).binding({ + method: 'run', + params: { location } + }); +} diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index b387ee618a..cf4bd5d5ae 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -253,7 +253,7 @@ export const emptyModel = new MultiTraceModel([]); export async function loadSingleTraceFile(url: string): Promise { 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); } diff --git a/packages/trace-viewer/vite.config.ts b/packages/trace-viewer/vite.config.ts index f370bf4d77..79b7dcb962 100644 --- a/packages/trace-viewer/vite.config.ts +++ b/packages/trace-viewer/vite.config.ts @@ -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'), }, }, diff --git a/packages/web/src/common.css b/packages/web/src/common.css index fef540ecd3..30c7cbf0d8 100644 --- a/packages/web/src/common.css +++ b/packages/web/src/common.css @@ -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; +} diff --git a/packages/web/src/components/listView.css b/packages/web/src/components/listView.css index f5692aa898..69f13969d6 100644 --- a/packages/web/src/components/listView.css +++ b/packages/web/src/components/listView.css @@ -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; diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index 3d0ab7cb52..e766e9d752 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -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 = ({ selectedItem, onAccepted, onSelected, + onLeftArrow, + onRightArrow, onHighlighted, + onIconClicked, showNoItemsMessage, dataTestId, }) => { @@ -59,10 +65,21 @@ export const ListView: React.FC = ({ 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 = ({ 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 = ({ setHighlightedItem(undefined); onHighlighted?.(undefined); }} + onIconClicked={() => onIconClicked?.(item)} > {itemRender(item)} )} @@ -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 ?
: undefined} - {hasIcons &&
} + {hasIcons &&
} {typeof children === 'string' ?
{children}
: children} ; }; diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 41848405ce..86bf30faaf 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -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 { - 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);