diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 025e785888..e74a1e99f1 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -237,10 +237,6 @@ if (!process.env.PW_CLI_TARGET_LANG) { if (playwrightTestPackagePath) { require(playwrightTestPackagePath).addTestCommand(program); - if (process.env.PW_EXPERIMENTAL) { - require(playwrightTestPackagePath).addGenerateHtmlCommand(program); - require(playwrightTestPackagePath).addShowHtmlCommand(program); - } } else { const command = program.command('test').allowUnknownOption(true); command.description('Run tests with Playwright Test. Available in @playwright/test package.'); diff --git a/src/test/cli.ts b/src/test/cli.ts index cea0bde0b7..d3264a7d12 100644 --- a/src/test/cli.ts +++ b/src/test/cli.ts @@ -18,15 +18,12 @@ import commander from 'commander'; import fs from 'fs'; -import open from 'open'; import path from 'path'; import type { Config } from './types'; import { Runner, builtInReporters, BuiltInReporter } from './runner'; import { stopProfiling, startProfiling } from './profiler'; import { FilePatternFilter } from './util'; import { Loader } from './loader'; -import { HtmlBuilder } from './html/htmlBuilder'; -import { HttpServer } from '../utils/httpServer'; const defaultTimeout = 30000; const defaultReporter: BuiltInReporter = process.env.CI ? 'dot' : 'list'; @@ -85,61 +82,6 @@ export function addTestCommand(program: commander.CommanderStatic) { }); } -export function addGenerateHtmlCommand(program: commander.CommanderStatic) { - const command = program.command('generate-report'); - command.description('Generate HTML report'); - command.option('-c, --config ', `Configuration file, or a test directory with optional "${tsConfig}"/"${jsConfig}"`); - command.option('--output ', `Folder for output artifacts (default: "playwright-report")`, 'playwright-report'); - command.action(async opts => { - await generateHTMLReport(opts); - }).on('--help', () => { - console.log(''); - console.log('Examples:'); - console.log(''); - console.log(' $ generate-report'); - }); -} - -export function addShowHtmlCommand(program: commander.CommanderStatic) { - const command = program.command('show-report'); - command.description('Show HTML report for last run'); - command.option('-c, --config ', `Configuration file, or a test directory with optional "${tsConfig}"/"${jsConfig}"`); - command.option('--output ', `Folder for output artifacts (default: "playwright-report")`, 'playwright-report'); - command.action(async opts => { - const output = await generateHTMLReport(opts); - const server = new HttpServer(); - server.routePrefix('/', (request, response) => { - let relativePath = request.url!; - if (relativePath === '/') - relativePath = '/index.html'; - const absolutePath = path.join(output, ...relativePath.split('/')); - return server.serveFile(response, absolutePath); - }); - open(await server.start()); - }).on('--help', () => { - console.log(''); - console.log('Examples:'); - console.log(''); - console.log(' $ show-report'); - }); -} - -async function generateHTMLReport(opts: any): Promise { - const output = opts.output; - delete opts.output; - const loader = await createLoader(opts); - const outputFolders = new Set(loader.projects().map(p => p.config.outputDir)); - const reportFiles = new Set(); - for (const outputFolder of outputFolders) { - const reportFolder = path.join(outputFolder, 'report'); - const files = fs.readdirSync(reportFolder).filter(f => f.endsWith('.report')); - for (const file of files) - reportFiles.add(path.join(reportFolder, file)); - } - new HtmlBuilder([...reportFiles], output, loader.fullConfig().rootDir); - return output; -} - async function createLoader(opts: { [key: string]: any }): Promise { if (opts.browser) { const browserOpt = opts.browser.toLowerCase(); diff --git a/src/test/html/htmlBuilder.ts b/src/test/html/htmlBuilder.ts deleted file mode 100644 index 0cf967621b..0000000000 --- a/src/test/html/htmlBuilder.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * 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 fs from 'fs'; -import path from 'path'; -import { ProjectTreeItem, SuiteTreeItem, TestTreeItem, TestCase, TestResult, TestStep, TestFile, Location } from './types'; -import { JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from '../reporters/raw'; -import { calculateSha1 } from '../../utils/utils'; -import { toPosixPath } from '../reporters/json'; - -export class HtmlBuilder { - private _reportFolder: string; - private _tests = new Map(); - private _rootDir: string; - - constructor(rawReports: string[], outputDir: string, rootDir: string) { - this._rootDir = rootDir; - this._reportFolder = path.resolve(process.cwd(), outputDir); - const dataFolder = path.join(this._reportFolder, 'data'); - fs.mkdirSync(dataFolder, { recursive: true }); - const appFolder = path.join(__dirname, '..', '..', 'web', 'htmlReport2'); - for (const file of fs.readdirSync(appFolder)) - fs.copyFileSync(path.join(appFolder, file), path.join(this._reportFolder, file)); - - const projects: ProjectTreeItem[] = []; - for (const projectFile of rawReports) { - const projectJson = JSON.parse(fs.readFileSync(projectFile, 'utf-8')) as JsonReport; - const suites: SuiteTreeItem[] = []; - for (const file of projectJson.suites) { - const relativeFileName = this._relativeLocation(file.location).file; - const fileId = calculateSha1(projectFile + ':' + relativeFileName); - const tests: JsonTestCase[] = []; - suites.push(this._createSuiteTreeItem(file, fileId, tests)); - const testFile: TestFile = { - fileId, - path: relativeFileName, - tests: tests.map(t => this._createTestCase(t)) - }; - fs.writeFileSync(path.join(dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2)); - } - projects.push({ - name: projectJson.project.name, - suites, - failedTests: suites.reduce((a, s) => a + s.failedTests, 0) - }); - } - fs.writeFileSync(path.join(dataFolder, 'projects.json'), JSON.stringify(projects, undefined, 2)); - } - - private _createTestCase(test: JsonTestCase): TestCase { - return { - testId: test.testId, - title: test.title, - location: this._relativeLocation(test.location), - results: test.results.map(r => this._createTestResult(r)) - }; - } - - private _createSuiteTreeItem(suite: JsonSuite, fileId: string, testCollector: JsonTestCase[]): SuiteTreeItem { - const suites = suite.suites.map(s => this._createSuiteTreeItem(s, fileId, testCollector)); - const tests = suite.tests.map(t => this._createTestTreeItem(t, fileId)); - testCollector.push(...suite.tests); - return { - title: suite.title, - location: this._relativeLocation(suite.location), - duration: suites.reduce((a, s) => a + s.duration, 0) + tests.reduce((a, t) => a + t.duration, 0), - failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + tests.reduce((a, t) => t.outcome === 'unexpected' || t.outcome === 'flaky' ? a + 1 : a, 0), - suites, - tests - }; - } - - private _createTestTreeItem(test: JsonTestCase, fileId: string): TestTreeItem { - const duration = test.results.reduce((a, r) => a + r.duration, 0); - this._tests.set(test.testId, test); - return { - testId: test.testId, - fileId: fileId, - location: this._relativeLocation(test.location), - title: test.title, - duration, - outcome: test.outcome - }; - } - - private _createTestResult(result: JsonTestResult): TestResult { - return { - duration: result.duration, - startTime: result.startTime, - retry: result.retry, - steps: result.steps.map(s => this._createTestStep(s)), - error: result.error?.message, - status: result.status, - }; - } - - private _createTestStep(step: JsonTestStep): TestStep { - return { - title: step.title, - startTime: step.startTime, - duration: step.duration, - steps: step.steps.map(s => this._createTestStep(s)), - log: step.log, - error: step.error?.message - }; - } - - private _relativeLocation(location: Location | undefined): Location { - if (!location) - return { file: '', line: 0, column: 0 }; - return { - file: toPosixPath(path.relative(this._rootDir, location.file)), - line: location.line, - column: location.column, - }; - } -} diff --git a/src/test/html/types.ts b/src/test/html/types.ts deleted file mode 100644 index dacadd48f4..0000000000 --- a/src/test/html/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 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. - */ - -export type Location = { - file: string; - line: number; - column: number; -}; - -export type ProjectTreeItem = { - name: string; - suites: SuiteTreeItem[]; - failedTests: number; -}; - -export type SuiteTreeItem = { - title: string; - location?: Location; - duration: number; - suites: SuiteTreeItem[]; - tests: TestTreeItem[]; - failedTests: number; -}; - -export type TestTreeItem = { - testId: string, - fileId: string, - title: string; - location: Location; - duration: number; - outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky'; -}; - -export type TestFile = { - fileId: string; - path: string; - tests: TestCase[]; -}; - -export type TestCase = { - testId: string, - title: string; - location: Location; - results: TestResult[]; -}; - -export type TestResult = { - retry: number; - startTime: string; - duration: number; - steps: TestStep[]; - error?: string; - status: 'passed' | 'failed' | 'timedOut' | 'skipped'; -}; - -export type TestStep = { - title: string; - startTime: string; - duration: number; - log?: string[]; - error?: string; - steps: TestStep[]; -}; diff --git a/src/test/reporters/html.ts b/src/test/reporters/html.ts index 76c3dcfcf9..787bc59da3 100644 --- a/src/test/reporters/html.ts +++ b/src/test/reporters/html.ts @@ -16,460 +16,197 @@ import fs from 'fs'; import path from 'path'; -import { FullConfig, Location, Suite, TestCase, TestError, TestResult, TestStatus, TestStep } from '../../../types/testReporter'; +import { FullConfig, Suite } from '../../../types/testReporter'; import { calculateSha1 } from '../../utils/utils'; -import { formatError, formatResultFailure } from './base'; -import { serializePatterns, toPosixPath } from './json'; +import { toPosixPath } from '../reporters/json'; +import RawReporter, { JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw'; -export type JsonStats = { expected: number, unexpected: number, flaky: number, skipped: number }; -export type JsonLocation = Location & { sha1?: string }; -export type JsonStackFrame = { file: string, line: number, column: number, sha1?: string }; - -export type JsonConfig = Omit & { - projects: { - outputDir: string, - repeatEach: number, - retries: number, - metadata: any, - name: string, - testDir: string, - testIgnore: string[], - testMatch: string[], - timeout: number, - }[], +export type Location = { + file: string; + line: number; + column: number; }; -export type JsonReport = { - config: JsonConfig, - stats: JsonStats, - suites: JsonSuite[], +export type ProjectTreeItem = { + name: string; + suites: SuiteTreeItem[]; + failedTests: number; }; -export type JsonSuite = { +export type SuiteTreeItem = { title: string; - location?: JsonLocation; - suites: JsonSuite[]; - tests: JsonTestCase[]; + location?: Location; + duration: number; + suites: SuiteTreeItem[]; + tests: TestTreeItem[]; + failedTests: number; }; -export type JsonTestCase = { - testId: string; +export type TestTreeItem = { + testId: string, + fileId: string, title: string; - location: JsonLocation; - expectedStatus: TestStatus; - timeout: number; - annotations: { type: string, description?: string }[]; - retries: number; - results: JsonTestResult[]; - ok: boolean; + location: Location; + duration: number; outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky'; }; -export type TestAttachment = { - name: string; - path?: string; - body?: Buffer; - contentType: string; - sha1?: string; +export type TestFile = { + fileId: string; + path: string; + tests: TestCase[]; }; -export type JsonAttachment = { - name: string; - path?: string; - body?: string; - contentType: string; - sha1?: string; -}; - -export type JsonTestResult = { - retry: number; - workerIndex: number; - startTime: string; - duration: number; - status: TestStatus; - error?: TestError; - failureSnippet?: string; - attachments: JsonAttachment[]; - stdout: (string | Buffer)[]; - stderr: (string | Buffer)[]; - steps: JsonTestStep[]; -}; - -export type JsonTestStep = { +export type TestCase = { + testId: string, + title: string; + location: Location; + results: TestResult[]; +}; + +export type TestResult = { + retry: number; + startTime: string; + duration: number; + steps: TestStep[]; + error?: string; + status: 'passed' | 'failed' | 'timedOut' | 'skipped'; +}; + +export type TestStep = { title: string; - category: string, startTime: string; duration: number; - error?: TestError; - failureSnippet?: string; - steps: JsonTestStep[]; - preview?: string; - stack?: JsonStackFrame[]; log?: string[]; + error?: string; + steps: TestStep[]; }; class HtmlReporter { - private _reportFolder: string; - private _resourcesFolder: string; - private _sourceProcessor: SourceProcessor; private config!: FullConfig; private suite!: Suite; - constructor() { - this._reportFolder = path.resolve(process.cwd(), process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report'); - this._resourcesFolder = path.join(this._reportFolder, 'resources'); - this._sourceProcessor = new SourceProcessor(this._resourcesFolder); - fs.mkdirSync(this._resourcesFolder, { recursive: true }); - const appFolder = path.join(__dirname, '..', '..', 'web', 'htmlReport'); - for (const file of fs.readdirSync(appFolder)) - fs.copyFileSync(path.join(appFolder, file), path.join(this._reportFolder, file)); - } - onBegin(config: FullConfig, suite: Suite) { this.config = config; this.suite = suite; } async onEnd() { - const stats: JsonStats = { expected: 0, unexpected: 0, skipped: 0, flaky: 0 }; - this.suite.allTests().forEach(t => { - ++stats[t.outcome()]; + const projectSuites = this.suite.suites; + const reports = projectSuites.map(suite => { + const rawReporter = new RawReporter(); + const report = rawReporter.generateProjectReport(this.config, suite); + return report; }); - const output: JsonReport = { - config: { - ...this.config, - rootDir: toPosixPath(this.config.rootDir), - projects: this.config.projects.map(project => { - return { - outputDir: toPosixPath(project.outputDir), - repeatEach: project.repeatEach, - retries: project.retries, - metadata: project.metadata, - name: project.name, - testDir: toPosixPath(project.testDir), - testIgnore: serializePatterns(project.testIgnore), - testMatch: serializePatterns(project.testMatch), - timeout: project.timeout, - }; - }) - }, - stats, - suites: this.suite.suites.map(s => this._serializeSuite(s)) - }; - fs.writeFileSync(path.join(this._reportFolder, 'report.json'), JSON.stringify(output)); + const reportFolder = path.resolve(process.cwd(), process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report'); + new HtmlBuilder(reports, reportFolder, this.config.rootDir); + } +} + +class HtmlBuilder { + private _reportFolder: string; + private _tests = new Map(); + private _rootDir: string; + + constructor(rawReports: JsonReport[], outputDir: string, rootDir: string) { + this._rootDir = rootDir; + this._reportFolder = path.resolve(process.cwd(), outputDir); + const dataFolder = path.join(this._reportFolder, 'data'); + fs.mkdirSync(dataFolder, { recursive: true }); + const appFolder = path.join(__dirname, '..', '..', 'web', 'htmlReport'); + for (const file of fs.readdirSync(appFolder)) + fs.copyFileSync(path.join(appFolder, file), path.join(this._reportFolder, file)); + + const projects: ProjectTreeItem[] = []; + for (const projectJson of rawReports) { + const suites: SuiteTreeItem[] = []; + for (const file of projectJson.suites) { + const relativeFileName = this._relativeLocation(file.location).file; + const fileId = calculateSha1(projectJson.project.name + ':' + relativeFileName); + const tests: JsonTestCase[] = []; + suites.push(this._createSuiteTreeItem(file, fileId, tests)); + const testFile: TestFile = { + fileId, + path: relativeFileName, + tests: tests.map(t => this._createTestCase(t)) + }; + fs.writeFileSync(path.join(dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2)); + } + projects.push({ + name: projectJson.project.name, + suites, + failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + }); + } + fs.writeFileSync(path.join(dataFolder, 'projects.json'), JSON.stringify(projects, undefined, 2)); } - private _relativeLocation(location: Location | undefined): JsonLocation { - if (!location) - return { file: '', line: 0, column: 0 }; + private _createTestCase(test: JsonTestCase): TestCase { return { - file: toPosixPath(path.relative(this.config.rootDir, location.file)), - line: location.line, - column: location.column, - sha1: this._sourceProcessor.copySourceFile(location.file), + testId: test.testId, + title: test.title, + location: this._relativeLocation(test.location), + results: test.results.map(r => this._createTestResult(r)) }; } - private _serializeSuite(suite: Suite): JsonSuite { + private _createSuiteTreeItem(suite: JsonSuite, fileId: string, testCollector: JsonTestCase[]): SuiteTreeItem { + const suites = suite.suites.map(s => this._createSuiteTreeItem(s, fileId, testCollector)); + const tests = suite.tests.map(t => this._createTestTreeItem(t, fileId)); + testCollector.push(...suite.tests); return { title: suite.title, location: this._relativeLocation(suite.location), - suites: suite.suites.map(s => this._serializeSuite(s)), - tests: suite.tests.map(t => this._serializeTest(t)), + duration: suites.reduce((a, s) => a + s.duration, 0) + tests.reduce((a, t) => a + t.duration, 0), + failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + tests.reduce((a, t) => t.outcome === 'unexpected' || t.outcome === 'flaky' ? a + 1 : a, 0), + suites, + tests }; } - private _serializeTest(test: TestCase): JsonTestCase { - const testId = calculateSha1(test.titlePath().join('|')); + private _createTestTreeItem(test: JsonTestCase, fileId: string): TestTreeItem { + const duration = test.results.reduce((a, r) => a + r.duration, 0); + this._tests.set(test.testId, test); return { - testId, - title: test.title, + testId: test.testId, + fileId: fileId, location: this._relativeLocation(test.location), - expectedStatus: test.expectedStatus, - timeout: test.timeout, - annotations: test.annotations, - retries: test.retries, - ok: test.ok(), - outcome: test.outcome(), - results: test.results.map(r => this._serializeResult(testId, test, r)), + title: test.title, + duration, + outcome: test.outcome }; } - private _serializeResult(testId: string, test: TestCase, result: TestResult): JsonTestResult { + private _createTestResult(result: JsonTestResult): TestResult { return { - retry: result.retry, - workerIndex: result.workerIndex, - startTime: result.startTime.toISOString(), duration: result.duration, + startTime: result.startTime, + retry: result.retry, + steps: result.steps.map(s => this._createTestStep(s)), + error: result.error?.message, status: result.status, - error: result.error, - failureSnippet: formatResultFailure(test, result, '').join('') || undefined, - attachments: this._createAttachments(testId, result), - stdout: result.stdout, - stderr: result.stderr, - steps: this._serializeSteps(test, result.steps) }; } - private _serializeSteps(test: TestCase, steps: TestStep[]): JsonTestStep[] { - return steps.map(step => { - return { - title: step.title, - category: step.category, - startTime: step.startTime.toISOString(), - duration: step.duration, - error: step.error, - steps: this._serializeSteps(test, step.steps), - failureSnippet: step.error ? formatError(step.error, test.location.file) : undefined, - ...this._sourceProcessor.processStackTrace(step.data.stack), - log: step.data.log || undefined, - }; - }); - } - - private _createAttachments(testId: string, result: TestResult): JsonAttachment[] { - const attachments: JsonAttachment[] = []; - for (const attachment of result.attachments) { - if (attachment.path) { - const sha1 = calculateSha1(attachment.path) + path.extname(attachment.path); - try { - fs.copyFileSync(attachment.path, path.join(this._resourcesFolder, sha1)); - attachments.push({ - ...attachment, - body: undefined, - sha1 - }); - } catch (e) { - } - } else if (attachment.body && isTextAttachment(attachment.contentType)) { - attachments.push({ ...attachment, body: attachment.body.toString() }); - } else { - const sha1 = calculateSha1(attachment.body!) + '.dat'; - try { - fs.writeFileSync(path.join(this._resourcesFolder, sha1), attachment.body); - attachments.push({ - ...attachment, - body: undefined, - sha1 - }); - } catch (e) { - } - } - } - - if (result.stdout.length) - attachments.push(this._stdioAttachment(testId, result, 'stdout')); - if (result.stderr.length) - attachments.push(this._stdioAttachment(testId, result, 'stderr')); - return attachments; - } - - private _stdioAttachment(testId: string, result: TestResult, type: 'stdout' | 'stderr'): JsonAttachment { - const sha1 = `${testId}.${result.retry}.${type}`; - const fileName = path.join(this._resourcesFolder, sha1); - for (const chunk of type === 'stdout' ? result.stdout : result.stderr) { - if (typeof chunk === 'string') - fs.appendFileSync(fileName, chunk + '\n'); - else - fs.appendFileSync(fileName, chunk); - } + private _createTestStep(step: JsonTestStep): TestStep { return { - name: type, - contentType: 'application/octet-stream', - sha1 + title: step.title, + startTime: step.startTime, + duration: step.duration, + steps: step.steps.map(s => this._createTestStep(s)), + log: step.log, + error: step.error?.message }; } -} -function isTextAttachment(contentType: string) { - if (contentType.startsWith('text/')) - return true; - if (contentType.includes('json')) - return true; - return false; -} - -type SourceFile = { text: string, lineStart: number[] }; -class SourceProcessor { - private sourceCache = new Map(); - private sha1Cache = new Map(); - private resourcesFolder: string; - - constructor(resourcesFolder: string) { - this.resourcesFolder = resourcesFolder; - } - - processStackTrace(stack: { file?: string, line?: number, column?: number }[] | undefined) { - stack = stack || []; - const frames: JsonStackFrame[] = []; - let preview: string | undefined; - for (const frame of stack) { - if (!frame.file || !frame.line || !frame.column) - continue; - const sha1 = this.copySourceFile(frame.file); - const jsonFrame = { file: frame.file, line: frame.line, column: frame.column, sha1 }; - frames.push(jsonFrame); - if (frame === stack[0]) - preview = this.readPreview(jsonFrame); - } - return { stack: frames, preview }; - } - - copySourceFile(file: string): string | undefined { - let sha1: string | undefined; - if (this.sha1Cache.has(file)) { - sha1 = this.sha1Cache.get(file); - } else { - if (fs.existsSync(file)) { - sha1 = calculateSha1(file) + path.extname(file); - fs.copyFileSync(file, path.join(this.resourcesFolder, sha1)); - } - this.sha1Cache.set(file, sha1); - } - return sha1; - } - - private readSourceFile(file: string): SourceFile | undefined { - let source: { text: string, lineStart: number[] } | undefined; - if (this.sourceCache.has(file)) { - source = this.sourceCache.get(file); - } else { - try { - const text = fs.readFileSync(file, 'utf8'); - const lines = text.split('\n'); - const lineStart = [0]; - for (const line of lines) - lineStart.push(lineStart[lineStart.length - 1] + line.length + 1); - source = { text, lineStart }; - } catch (e) { - } - this.sourceCache.set(file, source); - } - return source; - } - - private readPreview(frame: JsonStackFrame): string | undefined { - const source = this.readSourceFile(frame.file); - if (source === undefined) - return; - - if (frame.line - 1 >= source.lineStart.length) - return; - - const text = source.text; - const pos = source.lineStart[frame.line - 1] + frame.column - 1; - return new SourceParser(text).readPreview(pos); - } -} - -const kMaxPreviewLength = 100; -class SourceParser { - private text: string; - private pos!: number; - - constructor(text: string) { - this.text = text; - } - - readPreview(pos: number) { - let prefix = ''; - - this.pos = pos - 1; - while (true) { - if (this.pos < pos - kMaxPreviewLength) - return; - - this.skipWhiteSpace(-1); - if (this.text[this.pos] !== '.') - break; - - prefix = '.' + prefix; - this.pos--; - this.skipWhiteSpace(-1); - - while (this.text[this.pos] === ')' || this.text[this.pos] === ']') { - const expr = this.readBalancedExpr(-1, this.text[this.pos] === ')' ? '(' : '[', this.text[this.pos]); - if (expr === undefined) - return; - prefix = expr + prefix; - this.skipWhiteSpace(-1); - } - - const id = this.readId(-1); - if (id !== undefined) - prefix = id + prefix; - } - - if (prefix.length > kMaxPreviewLength) - return; - - this.pos = pos; - const suffix = this.readBalancedExpr(+1, ')', '('); - if (suffix === undefined) - return; - return prefix + suffix; - } - - private skipWhiteSpace(dir: number) { - while (this.pos >= 0 && this.pos < this.text.length && /[\s\r\n]/.test(this.text[this.pos])) - this.pos += dir; - } - - private readId(dir: number): string | undefined { - const start = this.pos; - while (this.pos >= 0 && this.pos < this.text.length && /[\p{L}0-9_]/u.test(this.text[this.pos])) - this.pos += dir; - if (this.pos === start) - return; - return dir === 1 ? this.text.substring(start, this.pos) : this.text.substring(this.pos + 1, start + 1); - } - - private readBalancedExpr(dir: number, stopChar: string, stopPair: string): string | undefined { - let result = ''; - let quote = ''; - let lastWhiteSpace = false; - let balance = 0; - const start = this.pos; - while (this.pos >= 0 && this.pos < this.text.length) { - if (this.pos < start - kMaxPreviewLength || this.pos > start + kMaxPreviewLength) - return; - let whiteSpace = false; - if (quote) { - whiteSpace = false; - if (dir === 1 && this.text[this.pos] === '\\') { - result = result + this.text[this.pos] + this.text[this.pos + 1]; - this.pos += 2; - continue; - } - if (dir === -1 && this.text[this.pos - 1] === '\\') { - result = this.text[this.pos - 1] + this.text[this.pos] + result; - this.pos -= 2; - continue; - } - if (this.text[this.pos] === quote) - quote = ''; - } else { - if (this.text[this.pos] === '\'' || this.text[this.pos] === '"' || this.text[this.pos] === '`') { - quote = this.text[this.pos]; - } else if (this.text[this.pos] === stopPair) { - balance++; - } else if (this.text[this.pos] === stopChar) { - balance--; - if (!balance) { - this.pos += dir; - result = dir === 1 ? result + stopChar : stopChar + result; - break; - } - } - whiteSpace = /[\s\r\n]/.test(this.text[this.pos]); - } - const char = whiteSpace ? ' ' : this.text[this.pos]; - if (!lastWhiteSpace || !whiteSpace) - result = dir === 1 ? result + char : char + result; - lastWhiteSpace = whiteSpace; - this.pos += dir; - } - return result; + private _relativeLocation(location: Location | undefined): Location { + if (!location) + return { file: '', line: 0, column: 0 }; + return { + file: toPosixPath(path.relative(this._rootDir, location.file)), + line: location.line, + column: location.column, + }; } } diff --git a/src/test/reporters/raw.ts b/src/test/reporters/raw.ts index 7b5f3cd9dc..631c094ff1 100644 --- a/src/test/reporters/raw.ts +++ b/src/test/reporters/raw.ts @@ -129,25 +129,32 @@ class RawReporter { } if (!reportFile) throw new Error('Internal error, could not create report file'); - const report: JsonReport = { - config: this.config, - project: { - metadata: project.metadata, - name: project.name, - outputDir: project.outputDir, - repeatEach: project.repeatEach, - retries: project.retries, - testDir: project.testDir, - testIgnore: serializePatterns(project.testIgnore), - testMatch: serializePatterns(project.testMatch), - timeout: project.timeout, - }, - suites: suite.suites.map(s => this._serializeSuite(s)) - }; + const report = this.generateProjectReport(this.config, suite); fs.writeFileSync(reportFile, JSON.stringify(report, undefined, 2)); } } + generateProjectReport(config: FullConfig, suite: Suite): JsonReport { + const project = (suite as any)._projectConfig as FullProject; + assert(project, 'Internal Error: Invalid project structure'); + const report: JsonReport = { + config, + project: { + metadata: project.metadata, + name: project.name, + outputDir: project.outputDir, + repeatEach: project.repeatEach, + retries: project.retries, + testDir: project.testDir, + testIgnore: serializePatterns(project.testIgnore), + testMatch: serializePatterns(project.testMatch), + timeout: project.timeout, + }, + suites: suite.suites.map(s => this._serializeSuite(s)) + }; + return report; + } + private _serializeSuite(suite: Suite): JsonSuite { return { title: suite.title, @@ -169,11 +176,11 @@ class RawReporter { retries: test.retries, ok: test.ok(), outcome: test.outcome(), - results: test.results.map(r => this._serializeResult(testId, test, r)), + results: test.results.map(r => this._serializeResult(test, r)), }; } - private _serializeResult(testId: string, test: TestCase, result: TestResult): JsonTestResult { + private _serializeResult(test: TestCase, result: TestResult): JsonTestResult { return { retry: result.retry, workerIndex: result.workerIndex, diff --git a/src/test/runner.ts b/src/test/runner.ts index b2b907bfcb..a32ecd83e2 100644 --- a/src/test/runner.ts +++ b/src/test/runner.ts @@ -32,7 +32,6 @@ import ListReporter from './reporters/list'; import JSONReporter from './reporters/json'; import JUnitReporter from './reporters/junit'; import EmptyReporter from './reporters/empty'; -import RawReporter from './reporters/raw'; import { ProjectImpl } from './project'; import { Minimatch } from 'minimatch'; import { FullConfig } from './types'; @@ -73,7 +72,7 @@ export class Runner { junit: JUnitReporter, null: EmptyReporter, }; - const reporters: Reporter[] = [ new RawReporter() ]; + const reporters: Reporter[] = []; for (const r of this._loader.fullConfig().reporter) { const [name, arg] = r; if (name in defaultReporters) { diff --git a/src/web/htmlReport/htmlReport.css b/src/web/htmlReport/htmlReport.css index 542bf70197..3afea0ad45 100644 --- a/src/web/htmlReport/htmlReport.css +++ b/src/web/htmlReport/htmlReport.css @@ -23,7 +23,7 @@ } .tree-item-title { - padding: 8px 0; + padding: 8px 8px 8px 0; cursor: pointer; } @@ -52,7 +52,7 @@ padding: 5px; overflow: auto; margin: 20px 0; - flex: auto; + flex: none; } .status-icon { @@ -80,7 +80,6 @@ flex: auto; display: flex; flex-direction: column; - padding-right: 8px; } .test-overview-title { @@ -89,20 +88,6 @@ flex: none; } -.awesome { - font-size: 24px; - display: flex; - align-items: center; - justify-content: center; - height: calc(100% - 40px); - flex-direction: column; - line-height: 40px; -} - -.awesome.small { - font-size: 20px; -} - .image-preview img { max-width: 500px; max-height: 500px; @@ -193,3 +178,8 @@ white-space: pre; padding: 8px; } + +.tree-text { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/web/htmlReport/htmlReport.tsx b/src/web/htmlReport/htmlReport.tsx index 8860c12473..5dcab9d023 100644 --- a/src/web/htmlReport/htmlReport.tsx +++ b/src/web/htmlReport/htmlReport.tsx @@ -16,26 +16,30 @@ import './htmlReport.css'; import * as React from 'react'; +import ansi2html from 'ansi-to-html'; import { SplitView } from '../components/splitView'; import { TreeItem } from '../components/treeItem'; import { TabbedPane } from '../traceViewer/ui/tabbedPane'; -import ansi2html from 'ansi-to-html'; -import type { JsonAttachment, JsonLocation, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from '../../test/reporters/html'; import { msToString } from '../uiUtils'; -import { Source, SourceProps } from '../components/source'; +import type { ProjectTreeItem, SuiteTreeItem, TestCase, TestResult, TestStep, TestTreeItem, Location, TestFile } from '../../test/reporters/html'; type Filter = 'Failing' | 'All'; +type TestId = { + fileId: string; + testId: string; +}; + export const Report: React.FC = () => { - const [report, setReport] = React.useState(); + const [report, setReport] = React.useState([]); const [fetchError, setFetchError] = React.useState(); - const [selectedTest, setSelectedTest] = React.useState(); + const [testId, setTestId] = React.useState(); React.useEffect(() => { (async () => { try { - const result = await fetch('report.json', { cache: 'no-cache' }); - const json = (await result.json()) as JsonReport; + const result = await fetch('data/projects.json', { cache: 'no-cache' }); + const json = (await result.json()) as ProjectTreeItem[]; setReport(json); } catch (e) { setFetchError(e.message); @@ -44,20 +48,9 @@ export const Report: React.FC = () => { }, []); const [filter, setFilter] = React.useState('Failing'); - const { unexpectedTests, unexpectedTestCount } = React.useMemo(() => { - const unexpectedTests = new Map(); - let unexpectedTestCount = 0; - for (const project of report?.suites || []) { - const unexpected = computeUnexpectedTests(project); - unexpectedTestCount += unexpected.length; - unexpectedTests.set(project, unexpected); - } - return { unexpectedTests, unexpectedTestCount }; - }, [report]); - return
- +
{ (['Failing', 'All'] as Filter[]).map(item => { @@ -67,310 +60,137 @@ export const Report: React.FC = () => { }}>{item}
; }) }
- {!fetchError && filter === 'All' && report?.suites.map((s, i) => )} - {!fetchError && filter === 'Failing' && !!unexpectedTestCount && report?.suites.map((s, i) => { - const hasUnexpectedOutcomes = !!unexpectedTests.get(s)?.length; - return hasUnexpectedOutcomes && ; - })} - {!fetchError && filter === 'Failing' && !unexpectedTestCount &&
You are awesome!
} - {fetchError &&
Failed to load report
Are you using npx playwright?
} + {!fetchError && filter === 'All' && report?.map((project, i) => )} + {!fetchError && filter === 'Failing' && report?.map((project, i) => )}
; }; -const ProjectTreeItem: React.FC<{ - suite?: JsonSuite; - selectedTest?: JsonTestCase, - setSelectedTest: (test: JsonTestCase) => void; -}> = ({ suite, setSelectedTest, selectedTest }) => { - const location = renderLocation(suite?.location, true); - +const ProjectTreeItemView: React.FC<{ + project: ProjectTreeItem; + testId?: TestId, + setTestId: (id: TestId) => void; + failingOnly?: boolean; +}> = ({ project, testId, setTestId, failingOnly }) => { return -
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title || 'Project'}
- {!!suite?.location?.line && location &&
{location}
} + {statusIconForFailedTests(project.failedTests)}
{project.name || 'Project'}
} loadChildren={() => { - return suite?.suites.map((s, i) => ) || []; + return project.suites.map((s, i) => ) || []; }} depth={0} expandByDefault={true}>
; }; -const ProjectFlatTreeItem: React.FC<{ - suite?: JsonSuite; - unexpectedTests: JsonTestCase[], - selectedTest?: JsonTestCase, - setSelectedTest: (test: JsonTestCase) => void; -}> = ({ suite, setSelectedTest, selectedTest, unexpectedTests }) => { - const location = renderLocation(suite?.location, true); - - return -
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title || 'Project'}
- {!!suite?.location?.line && location &&
{location}
} - - } loadChildren={() => { - return unexpectedTests.map((t, i) => ) || []; - }} depth={0} expandByDefault={true}>
; -}; - -const SuiteTreeItem: React.FC<{ - suite?: JsonSuite; - selectedTest?: JsonTestCase, - setSelectedTest: (test: JsonTestCase) => void; +const SuiteTreeItemView: React.FC<{ + suite: SuiteTreeItem, + testId?: TestId, + setTestId: (id: TestId) => void; depth: number, showFileName: boolean, -}> = ({ suite, setSelectedTest, selectedTest, showFileName, depth }) => { - const location = renderLocation(suite?.location, showFileName); +}> = ({ suite, testId, setTestId, showFileName, depth }) => { + const location = renderLocation(suite.location, showFileName); return -
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title}
- {!!suite?.location?.line && location &&
{location}
} + {statusIconForFailedTests(suite.failedTests)}
{suite.title}
+ {!!suite.location?.line && location &&
{location}
} } loadChildren={() => { - const suiteChildren = suite?.suites.map((s, i) => ) || []; - const suiteCount = suite ? suite.suites.length : 0; - const testChildren = suite?.tests.map((t, i) => ) || []; + const suiteChildren = suite.suites.map((s, i) => ) || []; + const suiteCount = suite.suites.length; + const testChildren = suite.tests.map((t, i) => ) || []; return [...suiteChildren, ...testChildren]; }} depth={depth}>
; }; -const TestTreeItem: React.FC<{ - expandByDefault?: boolean, - test: JsonTestCase; +const TestTreeItemView: React.FC<{ + test: TestTreeItem, showFileName: boolean, - selectedTest?: JsonTestCase, - setSelectedTest: (test: JsonTestCase) => void; + testId?: TestId, + setTestId: (id: TestId) => void; depth: number, -}> = ({ test, setSelectedTest, selectedTest, showFileName, expandByDefault, depth }) => { +}> = ({ test, testId, setTestId, showFileName, depth }) => { const fileName = test.location.file; const name = fileName.substring(fileName.lastIndexOf('/') + 1); return -
{testCaseStatusIcon(test)}
{test.title}
+ {statusIcon(test.outcome)}
{test.title}
{showFileName &&
{name}:{test.location.line}
} - {!showFileName &&
{msToString(test.results.reduce((v, a) => v + a.duration, 0))}
} + {!showFileName &&
{msToString(test.duration)}
} - } selected={test === selectedTest} depth={depth} expandByDefault={expandByDefault} onClick={() => setSelectedTest(test)}>
; + } selected={test.testId === testId?.testId} depth={depth} onClick={() => setTestId({ testId: test.testId, fileId: test.fileId })}>; }; const TestCaseView: React.FC<{ - test: JsonTestCase | undefined, -}> = ({ test }) => { + testId: TestId | undefined, +}> = ({ testId }) => { + const [file, setFile] = React.useState(); + + React.useEffect(() => { + (async () => { + if (!testId || file?.fileId === testId.fileId) + return; + try { + const result = await fetch(`data/${testId.fileId}.json`, { cache: 'no-cache' }); + setFile((await result.json()) as TestFile); + } catch (e) { + } + })(); + }); + + let test: TestCase | undefined; + if (file && testId) { + for (const t of file.tests) { + if (t.testId === testId.testId) { + test = t; + break; + } + } + } + const [selectedResultIndex, setSelectedResultIndex] = React.useState(0); - const [selectedStep, setSelectedStep] = React.useState(undefined); - const result = test?.results[selectedResultIndex]; return
- {!selectedStep && } - {!!selectedStep && }
- { test &&
setSelectedStep(undefined)}>{test?.title}
} - { test &&
setSelectedStep(undefined)}>{renderLocation(test.location, true)}
} + { test &&
{test?.title}
} + { test &&
{renderLocation(test.location, true)}
} { test && ({ + test.results.map((result, index) => ({ id: String(index), title:
{statusIcon(result.status)} {retryLabel(index)}
, - render: () => + render: () => })) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
; }; const TestResultView: React.FC<{ - test: JsonTestCase, - result: JsonTestResult, - selectedStep: JsonTestStep | undefined, - setSelectedStep: (step: JsonTestStep | undefined) => void; -}> = ({ test, result, selectedStep, setSelectedStep }) => { - return
- {result.steps.map((step, i) => )} -
; -}; - -const TestResultDetails: React.FC<{ - test: JsonTestCase | undefined, - result: JsonTestResult | undefined, + test: TestCase, + result: TestResult, }> = ({ test, result }) => { - const [selectedTab, setSelectedTab] = React.useState('errors'); - const [source, setSource] = React.useState({ text: '', language: 'javascript' }); - React.useEffect(() => { - (async () => { - if (!test || !test.location.sha1) - return; - try { - const response = await fetch('resources/' + test.location.sha1); - const text = await response.text(); - setSource({ text, language: 'javascript', highlight: [{ line: test.location.line, type: 'paused' }], revealLine: test.location.line }); - } catch (e) { - setSource({ text: '', language: 'javascript' }); - } - })(); - }, [test]); - const { screenshots, video, attachmentsMap } = React.useMemo(() => { - const attachmentsMap = new Map(); - const attachments = result?.attachments || []; - const screenshots = attachments.filter(a => a.name === 'screenshot'); - const video = attachments.filter(a => a.name === 'video'); - for (const a of attachments) - attachmentsMap.set(a.name, a); - return { attachmentsMap, screenshots, video }; - }, [ result ]); - if (!result) - return
; - return
- { - return
-
- {attachmentsMap.has('expected') && attachmentsMap.has('actual') && } -
; - } - }, - { - id: 'results', - title: 'Results', - render: () => { - return
- {screenshots.map(a =>
)} - {video.map(a =>
- -
)} - {!!result.attachments &&
Attachments
} - {result.attachments.map(a => )} -
; - } - }, - { - id: 'source', - title: 'Source', - render: () => - } - ]}>
-
; -}; - -const TestStepDetails: React.FC<{ - test: JsonTestCase | undefined, - result: JsonTestResult | undefined, - step: JsonTestStep | undefined, -}> = ({ test, result, step }) => { - const [source, setSource] = React.useState({ text: '', language: 'javascript' }); - React.useEffect(() => { - (async () => { - const frame = step?.stack?.[0]; - if (!frame || !frame.sha1) - return; - try { - const response = await fetch('resources/' + frame.sha1); - const text = await response.text(); - setSource({ text, language: 'javascript', highlight: [{ line: frame.line, type: 'paused' }], revealLine: frame.line }); - } catch (e) { - setSource({ text: '', language: 'javascript' }); - } - })(); - }, [step]); - const [selectedTab, setSelectedTab] = React.useState('errors'); - return
-
- }, - { - id: 'source', - title: 'Source', - render: () => - } - ]}>
+ return
+ {result.error && } + {result.steps.map((step, i) => )}
; }; const StepTreeItem: React.FC<{ - step: JsonTestStep; + step: TestStep; depth: number, - selectedStep?: JsonTestStep, - setSelectedStep: (step: JsonTestStep | undefined) => void; -}> = ({ step, depth, selectedStep, setSelectedStep }) => { +}> = ({ step, depth }) => { return - {testStepStatusIcon(step)} - {step.preview || step.title} + {statusIcon(step.error ? 'failed' : 'passed')} + {step.title}
{msToString(step.duration)}
-
} loadChildren={step.steps.length ? () => { - return step.steps.map((s, i) => ); - } : undefined} depth={depth} selected={step === selectedStep} onClick={() => setSelectedStep(step)}>; + } loadChildren={step.steps.length + (step.error ? 1 : 0) ? () => { + const children = step.steps.map((s, i) => ); + if (step.error) + children.unshift(); + return children; + } : undefined} depth={depth}>; }; -export const ImageDiff: React.FunctionComponent<{ - actual: JsonAttachment, - expected: JsonAttachment, - diff?: JsonAttachment, -}> = ({ actual, expected, diff }) => { - const [selectedTab, setSelectedTab] = React.useState('actual'); - const tabs = []; - tabs.push({ - id: 'actual', - title: 'Actual', - render: () =>
- }); - tabs.push({ - id: 'expected', - title: 'Expected', - render: () =>
- }); - if (diff) { - tabs.push({ - id: 'diff', - title: 'Diff', - render: () =>
, - }); - } - return
-
Image mismatch
- -
; -}; - -export const AttachmentLink: React.FunctionComponent<{ - attachment: JsonAttachment, -}> = ({ attachment }) => { - return - - {attachment.sha1 && {attachment.name}} - {attachment.body && {attachment.name}} - } loadChildren={attachment.body ? () => { - return [
${attachment.body}
]; - } : undefined} depth={0}>
; -}; - -function testSuiteErrorStatusIcon(suite?: JsonSuite): JSX.Element | undefined { - if (!suite) - return; - for (const child of suite.suites) { - const icon = testSuiteErrorStatusIcon(child); - if (icon) - return icon; - } - for (const test of suite.tests) { - if (test.outcome !== 'expected' && test.outcome !== 'skipped') - return testCaseStatusIcon(test); - } -} - -function testCaseStatusIcon(test?: JsonTestCase): JSX.Element { - if (!test) - return statusIcon('passed'); - return statusIcon(test.outcome); -} - -function testStepStatusIcon(step: JsonTestStep): JSX.Element { - if (step.category === 'internal') - return ; - return statusIcon(step.error ? 'failed' : 'passed'); +function statusIconForFailedTests(failedTests: number) { + return failedTests ? statusIcon('failed') : statusIcon('passed'); } function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element { @@ -390,21 +210,7 @@ function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expe } } -function computeUnexpectedTests(suite: JsonSuite): JsonTestCase[] { - const failedTests: JsonTestCase[] = []; - const visit = (suite: JsonSuite) => { - for (const child of suite.suites) - visit(child); - for (const test of suite.tests) { - if (test.outcome !== 'expected' && test.outcome !== 'skipped') - failedTests.push(test); - } - }; - visit(suite); - return failedTests; -} - -function renderLocation(location: JsonLocation | undefined, showFileName: boolean) { +function renderLocation(location: Location | undefined, showFileName: boolean) { if (!location) return ''; return (showFileName ? location.file : '') + ':' + location.line; @@ -416,6 +222,15 @@ function retryLabel(index: number) { return `Retry #${index}`; } +const ErrorMessage: React.FC<{ + error: string; +}> = ({ error }) => { + const html = React.useMemo(() => { + return new ansi2html({ colors: ansiColors }).toHtml(escapeHTML(error)); + }, [error]); + return
; +}; + const ansiColors = { 0: '#000', 1: '#C00', diff --git a/src/web/htmlReport2/htmlReport.css b/src/web/htmlReport2/htmlReport.css deleted file mode 100644 index 3afea0ad45..0000000000 --- a/src/web/htmlReport2/htmlReport.css +++ /dev/null @@ -1,185 +0,0 @@ -/* - 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. -*/ - -.suite-tree-column { - line-height: 18px; - flex: auto; - overflow: auto; - color: #616161; - background-color: #f3f3f3; -} - -.tree-item-title { - padding: 8px 8px 8px 0; - cursor: pointer; -} - -.tree-item-body { - min-height: 18px; -} - -.suite-tree-column .tree-item-title:not(.selected):hover { - background-color: #e8e8e8; -} - -.suite-tree-column .tree-item-title.selected { - background-color: #0060c0; - color: white; -} - -.suite-tree-column .tree-item-title.selected * { - color: white !important; -} - -.error-message { - white-space: pre; - font-family: monospace; - background: #000; - color: white; - padding: 5px; - overflow: auto; - margin: 20px 0; - flex: none; -} - -.status-icon { - padding-right: 3px; -} - -.codicon { - padding-right: 3px; -} - -.codicon-clock.status-icon, -.codicon-error.status-icon { - color: red; -} - -.codicon-alert.status-icon { - color: orange; -} - -.codicon-circle-filled.status-icon { - color: green; -} - -.test-result { - flex: auto; - display: flex; - flex-direction: column; -} - -.test-overview-title { - padding: 10px 0; - font-size: 18px; - flex: none; -} - -.image-preview img { - max-width: 500px; - max-height: 500px; -} - -.image-preview { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 550px; - height: 550px; -} - -.test-result .tabbed-pane .tab-content { - display: flex; - align-items: center; - justify-content: center; -} - -.attachment-body { - white-space: pre-wrap; - font-family: monospace; - background-color: #dadada; - border: 1px solid #ccc; - margin-left: 24px; -} - -.test-result .tree-item-title:not(.selected):hover { - background-color: #e8e8e8; -} - -.test-result .tree-item-title.selected { - background-color: #0060c0; - color: white; -} - -.test-result .tree-item-title.selected * { - color: white !important; -} - -.suite-tree-column .tab-strip, -.test-case-column .tab-strip { - border: none; - box-shadow: none; - background-color: transparent; -} - -.suite-tree-column .tab-element, -.test-case-column .tab-element { - border: none; - text-transform: uppercase; - font-weight: bold; - font-size: 11px; - color: #aaa; -} - -.suite-tree-column .tab-element.selected, -.test-case-column .tab-element.selected { - color: #555; -} - -.test-case-title { - flex: none; - display: flex; - align-items: center; - padding: 10px; - font-size: 18px; - cursor: pointer; -} - -.test-case-location { - flex: none; - display: flex; - align-items: center; - padding: 0 10px 10px; - color: var(--blue); - text-decoration: underline; - cursor: pointer; -} - -.test-details-column { - overflow-y: auto; -} - -.step-log { - line-height: 20px; - white-space: pre; - padding: 8px; -} - -.tree-text { - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/src/web/htmlReport2/htmlReport.tsx b/src/web/htmlReport2/htmlReport.tsx deleted file mode 100644 index 5e00758b03..0000000000 --- a/src/web/htmlReport2/htmlReport.tsx +++ /dev/null @@ -1,255 +0,0 @@ -/* - 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 './htmlReport.css'; -import * as React from 'react'; -import ansi2html from 'ansi-to-html'; -import { SplitView } from '../components/splitView'; -import { TreeItem } from '../components/treeItem'; -import { TabbedPane } from '../traceViewer/ui/tabbedPane'; -import { msToString } from '../uiUtils'; -import type { ProjectTreeItem, SuiteTreeItem, TestCase, TestResult, TestStep, TestTreeItem, Location, TestFile } from '../../test/html/types'; - -type Filter = 'Failing' | 'All'; - -type TestId = { - fileId: string; - testId: string; -}; - -export const Report: React.FC = () => { - const [report, setReport] = React.useState([]); - const [fetchError, setFetchError] = React.useState(); - const [testId, setTestId] = React.useState(); - - React.useEffect(() => { - (async () => { - try { - const result = await fetch('data/projects.json', { cache: 'no-cache' }); - const json = (await result.json()) as ProjectTreeItem[]; - setReport(json); - } catch (e) { - setFetchError(e.message); - } - })(); - }, []); - const [filter, setFilter] = React.useState('Failing'); - - return
- - -
-
{ - (['Failing', 'All'] as Filter[]).map(item => { - const selected = item === filter; - return
{ - setFilter(item); - }}>{item}
; - }) - }
- {!fetchError && filter === 'All' && report?.map((project, i) => )} - {!fetchError && filter === 'Failing' && report?.map((project, i) => )} -
-
-
; -}; - -const ProjectTreeItemView: React.FC<{ - project: ProjectTreeItem; - testId?: TestId, - setTestId: (id: TestId) => void; - failingOnly?: boolean; -}> = ({ project, testId, setTestId, failingOnly }) => { - return - {statusIconForFailedTests(project.failedTests)}
{project.name || 'Project'}
- - } loadChildren={() => { - return project.suites.map((s, i) => ) || []; - }} depth={0} expandByDefault={true}>
; -}; - -const SuiteTreeItemView: React.FC<{ - suite: SuiteTreeItem, - testId?: TestId, - setTestId: (id: TestId) => void; - depth: number, - showFileName: boolean, -}> = ({ suite, testId, setTestId, showFileName, depth }) => { - const location = renderLocation(suite.location, showFileName); - return - {statusIconForFailedTests(suite.failedTests)}
{suite.title}
- {!!suite.location?.line && location &&
{location}
} - - } loadChildren={() => { - const suiteChildren = suite.suites.map((s, i) => ) || []; - const suiteCount = suite.suites.length; - const testChildren = suite.tests.map((t, i) => ) || []; - return [...suiteChildren, ...testChildren]; - }} depth={depth}>
; -}; - -const TestTreeItemView: React.FC<{ - test: TestTreeItem, - showFileName: boolean, - testId?: TestId, - setTestId: (id: TestId) => void; - depth: number, -}> = ({ test, testId, setTestId, showFileName, depth }) => { - const fileName = test.location.file; - const name = fileName.substring(fileName.lastIndexOf('/') + 1); - return - {statusIcon(test.outcome)}
{test.title}
- {showFileName &&
{name}:{test.location.line}
} - {!showFileName &&
{msToString(test.duration)}
} - - } selected={test.testId === testId?.testId} depth={depth} onClick={() => setTestId({ testId: test.testId, fileId: test.fileId })}>
; -}; - -const TestCaseView: React.FC<{ - testId: TestId | undefined, -}> = ({ testId }) => { - const [file, setFile] = React.useState(); - - React.useEffect(() => { - (async () => { - if (!testId || file?.fileId === testId.fileId) - return; - try { - const result = await fetch(`data/${testId.fileId}.json`, { cache: 'no-cache' }); - setFile((await result.json()) as TestFile); - } catch (e) { - } - })(); - }); - - let test: TestCase | undefined; - if (file && testId) { - for (const t of file.tests) { - if (t.testId === testId.testId) { - test = t; - break; - } - } - } - - const [selectedResultIndex, setSelectedResultIndex] = React.useState(0); - return -
-
-
- { test &&
{test?.title}
} - { test &&
{renderLocation(test.location, true)}
} - { test && ({ - id: String(index), - title:
{statusIcon(result.status)} {retryLabel(index)}
, - render: () => - })) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />} -
-
; -}; - -const TestResultView: React.FC<{ - test: TestCase, - result: TestResult, -}> = ({ test, result }) => { - return
- {result.error && } - {result.steps.map((step, i) => )} -
; -}; - -const StepTreeItem: React.FC<{ - step: TestStep; - depth: number, -}> = ({ step, depth }) => { - return - {statusIcon(step.error ? 'failed' : 'passed')} - {step.title} -
-
{msToString(step.duration)}
- } loadChildren={step.steps.length + (step.error ? 1 : 0) ? () => { - const children = step.steps.map((s, i) => ); - if (step.error) - children.unshift(); - return children; - } : undefined} depth={depth}>
; -}; - -function statusIconForFailedTests(failedTests: number) { - return failedTests ? statusIcon('failed') : statusIcon('passed'); -} - -function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element { - switch (status) { - case 'failed': - case 'unexpected': - return ; - case 'passed': - case 'expected': - return ; - case 'timedOut': - return ; - case 'flaky': - return ; - case 'skipped': - return ; - } -} - -function renderLocation(location: Location | undefined, showFileName: boolean) { - if (!location) - return ''; - return (showFileName ? location.file : '') + ':' + location.line; -} - -function retryLabel(index: number) { - if (!index) - return 'Run'; - return `Retry #${index}`; -} - -const ErrorMessage: React.FC<{ - error: string; -}> = ({ error }) => { - const html = React.useMemo(() => { - return new ansi2html({ colors: ansiColors }).toHtml(escapeHTML(error)); - }, [error]); - return
; -}; - -const ansiColors = { - 0: '#000', - 1: '#C00', - 2: '#0C0', - 3: '#C50', - 4: '#00C', - 5: '#C0C', - 6: '#0CC', - 7: '#CCC', - 8: '#555', - 9: '#F55', - 10: '#5F5', - 11: '#FF5', - 12: '#55F', - 13: '#F5F', - 14: '#5FF', - 15: '#FFF' -}; - -function escapeHTML(text: string): string { - return text.replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!)); -} diff --git a/src/web/htmlReport2/index.html b/src/web/htmlReport2/index.html deleted file mode 100644 index f79a89f37d..0000000000 --- a/src/web/htmlReport2/index.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - Playwright Test Report - - -
- - diff --git a/src/web/htmlReport2/index.tsx b/src/web/htmlReport2/index.tsx deleted file mode 100644 index 7fbcea2a55..0000000000 --- a/src/web/htmlReport2/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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 '../third_party/vscode/codicon.css'; -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -import { applyTheme } from '../theme'; -import '../common.css'; -import { Report } from './htmlReport'; - -(async () => { - applyTheme(); - ReactDOM.render(, document.querySelector('#root')); -})(); diff --git a/src/web/htmlReport2/webpack.config.js b/src/web/htmlReport2/webpack.config.js deleted file mode 100644 index 279710dfca..0000000000 --- a/src/web/htmlReport2/webpack.config.js +++ /dev/null @@ -1,50 +0,0 @@ -const path = require('path'); -const HtmlWebPackPlugin = require('html-webpack-plugin'); - -const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development'; - -module.exports = { - mode, - entry: { - app: path.join(__dirname, 'index.tsx'), - }, - resolve: { - extensions: ['.ts', '.js', '.tsx', '.jsx'] - }, - devtool: mode === 'production' ? false : 'source-map', - output: { - globalObject: 'self', - filename: '[name].bundle.js', - path: path.resolve(__dirname, '../../../lib/web/htmlReport2') - }, - module: { - rules: [ - { - test: /\.(j|t)sx?$/, - loader: 'babel-loader', - options: { - presets: [ - "@babel/preset-typescript", - "@babel/preset-react" - ] - }, - exclude: /node_modules/ - }, - { - test: /\.css$/, - use: ['style-loader', 'css-loader'] - }, - { - test: /\.ttf$/, - use: ['file-loader'] - } - ] - }, - plugins: [ - new HtmlWebPackPlugin({ - title: 'Playwright Test Report', - template: path.join(__dirname, 'index.html'), - inject: true, - }) - ] -}; diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index a2e42cbb43..c0d67a68d9 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -155,8 +155,6 @@ test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => { 'artifacts-two-contexts-failing', ' test-failed-1.png', ' test-failed-2.png', - 'report', - ' project.report', 'report.json', ]); }); @@ -184,8 +182,6 @@ test('should work with screenshot: only-on-failure', async ({ runInlineTest }, t 'artifacts-two-contexts-failing', ' test-failed-1.png', ' test-failed-2.png', - 'report', - ' project.report', 'report.json', ]); }); @@ -224,8 +220,6 @@ test('should work with trace: on', async ({ runInlineTest }, testInfo) => { 'artifacts-two-contexts-failing', ' trace-1.zip', ' trace.zip', - 'report', - ' project.report', 'report.json', ]); }); @@ -253,8 +247,6 @@ test('should work with trace: retain-on-failure', async ({ runInlineTest }, test 'artifacts-two-contexts-failing', ' trace-1.zip', ' trace.zip', - 'report', - ' project.report', 'report.json', ]); }); @@ -282,8 +274,6 @@ test('should work with trace: on-first-retry', async ({ runInlineTest }, testInf 'artifacts-two-contexts-failing-retry1', ' trace-1.zip', ' trace.zip', - 'report', - ' project.report', 'report.json', ]); }); @@ -324,8 +314,6 @@ test('should stop tracing with trace: on-first-retry, when not retrying', async expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ 'a-shared-flaky-retry1', ' trace.zip', - 'report', - ' project.report', 'report.json', ]); }); diff --git a/tests/playwright-test/raw-reporter.spec.ts b/tests/playwright-test/raw-reporter.spec.ts index 84b6b71a2d..fdfce38e16 100644 --- a/tests/playwright-test/raw-reporter.spec.ts +++ b/tests/playwright-test/raw-reporter.spec.ts @@ -15,15 +15,21 @@ */ import fs from 'fs'; +import path from 'path'; import { test, expect } from './playwright-test-fixtures'; +const kRawReporterPath = path.join(__dirname, '..', '..', 'lib', 'test', 'reporters', 'raw.js'); + test('should generate raw report', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ 'a.test.js': ` const { test } = pwt; test('passes', async ({ page }, testInfo) => {}); `, - }, { usesCustomOutputDir: true }); + }, { + usesCustomOutputDir: true, + reporter: 'dot,' + kRawReporterPath + }); const json = JSON.parse(fs.readFileSync(testInfo.outputPath('test-results', 'report', 'project.report'), 'utf-8')); expect(json.config).toBeTruthy(); expect(json.project).toBeTruthy(); @@ -44,7 +50,10 @@ test('should use project name', async ({ runInlineTest }, testInfo) => { const { test } = pwt; test('passes', async ({ page }, testInfo) => {}); `, - }, { usesCustomOutputDir: true }); + }, { + usesCustomOutputDir: true, + reporter: 'dot,' + kRawReporterPath + }); const json = JSON.parse(fs.readFileSync(testInfo.outputPath('output', 'report', 'project-name.report'), 'utf-8')); expect(json.project.name).toBe('project-name'); expect(result.exitCode).toBe(0); @@ -61,7 +70,10 @@ test('should save stdio', async ({ runInlineTest }, testInfo) => { process.stderr.write(Buffer.from([4, 5, 6])); }); `, - }, { usesCustomOutputDir: true }); + }, { + usesCustomOutputDir: true, + reporter: 'dot,' + kRawReporterPath + }); const json = JSON.parse(fs.readFileSync(testInfo.outputPath('test-results', 'report', 'project.report'), 'utf-8')); const result = json.suites[0].tests[0].results[0]; expect(result.attachments).toEqual([ @@ -97,7 +109,10 @@ test('should save attachments', async ({ runInlineTest }, testInfo) => { }); }); `, - }, { usesCustomOutputDir: true }); + }, { + usesCustomOutputDir: true, + reporter: 'dot,' + kRawReporterPath + }); const json = JSON.parse(fs.readFileSync(testInfo.outputPath('test-results', 'report', 'project.report'), 'utf-8')); const result = json.suites[0].tests[0].results[0]; expect(result.attachments[0].name).toBe('binary'); @@ -122,7 +137,10 @@ test('dupe project names', async ({ runInlineTest }, testInfo) => { const { test } = pwt; test('passes', async ({ page }, testInfo) => {}); `, - }, { usesCustomOutputDir: true }); + }, { + usesCustomOutputDir: true, + reporter: 'dot,' + kRawReporterPath + }); const files = fs.readdirSync(testInfo.outputPath('test-results', 'report')); expect(new Set(files)).toEqual(new Set(['project-name.report', 'project-name-1.report', 'project-name-2.report'])); }); diff --git a/utils/build/build.js b/utils/build/build.js index 7e8eb7e3b5..8587b919df 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -115,7 +115,6 @@ const webPackFiles = [ 'src/web/traceViewer/webpack.config.js', 'src/web/recorder/webpack.config.js', 'src/web/htmlReport/webpack.config.js', - 'src/web/htmlReport2/webpack.config.js', ]; for (const file of webPackFiles) { steps.push({ diff --git a/utils/check_deps.js b/utils/check_deps.js index cfd2422272..f453a00ff1 100644 --- a/utils/check_deps.js +++ b/utils/check_deps.js @@ -195,7 +195,6 @@ DEPS['src/test/'] = ['src/test/**', 'src/utils/utils.ts', 'src/utils/**']; // HTML report DEPS['src/web/htmlReport/'] = ['src/test/**', 'src/web/']; -DEPS['src/web/htmlReport2/'] = ['src/test/**', 'src/web/']; checkDeps().catch(e => {