mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-04 16:44:11 +03:00
chore: remove stale html experiments (#8905)
This commit is contained in:
parent
a1adc15ea3
commit
16baaa317d
@ -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.');
|
||||
|
@ -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 <file>', `Configuration file, or a test directory with optional "${tsConfig}"/"${jsConfig}"`);
|
||||
command.option('--output <dir>', `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 <file>', `Configuration file, or a test directory with optional "${tsConfig}"/"${jsConfig}"`);
|
||||
command.option('--output <dir>', `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<string> {
|
||||
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<string>();
|
||||
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<Loader> {
|
||||
if (opts.browser) {
|
||||
const browserOpt = opts.browser.toLowerCase();
|
||||
|
@ -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<string, JsonTestCase>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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[];
|
||||
};
|
@ -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<FullConfig, 'projects'> & {
|
||||
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<string, JsonTestCase>();
|
||||
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<string, SourceFile | undefined>();
|
||||
private sha1Cache = new Map<string, string | undefined>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<JsonReport | undefined>();
|
||||
const [report, setReport] = React.useState<ProjectTreeItem[]>([]);
|
||||
const [fetchError, setFetchError] = React.useState<string | undefined>();
|
||||
const [selectedTest, setSelectedTest] = React.useState<JsonTestCase | undefined>();
|
||||
const [testId, setTestId] = React.useState<TestId | undefined>();
|
||||
|
||||
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<Filter>('Failing');
|
||||
|
||||
const { unexpectedTests, unexpectedTestCount } = React.useMemo(() => {
|
||||
const unexpectedTests = new Map<JsonSuite, JsonTestCase[]>();
|
||||
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 <div className='hbox'>
|
||||
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<TestCaseView test={selectedTest}></TestCaseView>
|
||||
<TestCaseView key={testId?.testId} testId={testId}></TestCaseView>
|
||||
<div className='suite-tree-column'>
|
||||
<div className='tab-strip'>{
|
||||
(['Failing', 'All'] as Filter[]).map(item => {
|
||||
@ -67,310 +60,137 @@ export const Report: React.FC = () => {
|
||||
}}>{item}</div>;
|
||||
})
|
||||
}</div>
|
||||
{!fetchError && filter === 'All' && report?.suites.map((s, i) => <ProjectTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest}></ProjectTreeItem>)}
|
||||
{!fetchError && filter === 'Failing' && !!unexpectedTestCount && report?.suites.map((s, i) => {
|
||||
const hasUnexpectedOutcomes = !!unexpectedTests.get(s)?.length;
|
||||
return hasUnexpectedOutcomes && <ProjectFlatTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} unexpectedTests={unexpectedTests.get(s)!}></ProjectFlatTreeItem>;
|
||||
})}
|
||||
{!fetchError && filter === 'Failing' && !unexpectedTestCount && <div className='awesome'>You are awesome!</div>}
|
||||
{fetchError && <div className='awesome small'><div>Failed to load report</div><div>Are you using npx playwright?</div></div>}
|
||||
{!fetchError && filter === 'All' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId}></ProjectTreeItemView>)}
|
||||
{!fetchError && filter === 'Failing' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={true}></ProjectTreeItemView>)}
|
||||
</div>
|
||||
</SplitView>
|
||||
</div>;
|
||||
};
|
||||
|
||||
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 <TreeItem title={<div className='hbox'>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title || 'Project'}</div></div>
|
||||
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
{statusIconForFailedTests(project.failedTests)}<div className='tree-text'>{project.name || 'Project'}</div>
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
return suite?.suites.map((s, i) => <SuiteTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} depth={1} showFileName={true}></SuiteTreeItem>) || [];
|
||||
return project.suites.map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={1} showFileName={true}></SuiteTreeItemView>) || [];
|
||||
}} depth={0} expandByDefault={true}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <TreeItem title={<div className='hbox'>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title || 'Project'}</div></div>
|
||||
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
return unexpectedTests.map((t, i) => <TestTreeItem key={i} test={t} setSelectedTest={setSelectedTest} selectedTest={selectedTest} showFileName={false} depth={1}></TestTreeItem>) || [];
|
||||
}} depth={0} expandByDefault={true}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <TreeItem title={<div className='hbox'>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title}</div></div>
|
||||
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
{statusIconForFailedTests(suite.failedTests)}<div className='tree-text'>{suite.title}</div>
|
||||
{!!suite.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
const suiteChildren = suite?.suites.map((s, i) => <SuiteTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} depth={depth + 1} showFileName={false}></SuiteTreeItem>) || [];
|
||||
const suiteCount = suite ? suite.suites.length : 0;
|
||||
const testChildren = suite?.tests.map((t, i) => <TestTreeItem key={i + suiteCount} test={t} setSelectedTest={setSelectedTest} selectedTest={selectedTest} showFileName={false} depth={depth + 1}></TestTreeItem>) || [];
|
||||
const suiteChildren = suite.suites.map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={depth + 1} showFileName={false}></SuiteTreeItemView>) || [];
|
||||
const suiteCount = suite.suites.length;
|
||||
const testChildren = suite.tests.map((t, i) => <TestTreeItemView key={i + suiteCount} test={t} setTestId={setTestId} testId={testId} showFileName={false} depth={depth + 1}></TestTreeItemView>) || [];
|
||||
return [...suiteChildren, ...testChildren];
|
||||
}} depth={depth}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <TreeItem title={<div className='hbox'>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testCaseStatusIcon(test)}<div style={{overflow: 'hidden', textOverflow: 'ellipsis'}}>{test.title}</div></div>
|
||||
{statusIcon(test.outcome)}<div className='tree-text'>{test.title}</div>
|
||||
{showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{name}:{test.location.line}</div>}
|
||||
{!showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{msToString(test.results.reduce((v, a) => v + a.duration, 0))}</div>}
|
||||
{!showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{msToString(test.duration)}</div>}
|
||||
</div>
|
||||
} selected={test === selectedTest} depth={depth} expandByDefault={expandByDefault} onClick={() => setSelectedTest(test)}></TreeItem>;
|
||||
} selected={test.testId === testId?.testId} depth={depth} onClick={() => setTestId({ testId: test.testId, fileId: test.fileId })}></TreeItem>;
|
||||
};
|
||||
|
||||
const TestCaseView: React.FC<{
|
||||
test: JsonTestCase | undefined,
|
||||
}> = ({ test }) => {
|
||||
testId: TestId | undefined,
|
||||
}> = ({ testId }) => {
|
||||
const [file, setFile] = React.useState<TestFile | undefined>();
|
||||
|
||||
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<JsonTestStep | undefined>(undefined);
|
||||
const result = test?.results[selectedResultIndex];
|
||||
return <SplitView sidebarSize={500} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<div className='test-details-column vbox'>
|
||||
{!selectedStep && <TestResultDetails test={test} result={result} />}
|
||||
{!!selectedStep && <TestStepDetails test={test} result={result} step={selectedStep}/>}
|
||||
</div>
|
||||
<div className='test-case-column vbox'>
|
||||
{ test && <div className='test-case-title' onClick={() => setSelectedStep(undefined)}>{test?.title}</div> }
|
||||
{ test && <div className='test-case-location' onClick={() => setSelectedStep(undefined)}>{renderLocation(test.location, true)}</div> }
|
||||
{ test && <div className='test-case-title'>{test?.title}</div> }
|
||||
{ test && <div className='test-case-location'>{renderLocation(test.location, true)}</div> }
|
||||
{ test && <TabbedPane tabs={
|
||||
test?.results.map((result, index) => ({
|
||||
test.results.map((result, index) => ({
|
||||
id: String(index),
|
||||
title: <div style={{ display: 'flex', alignItems: 'center' }}>{statusIcon(result.status)} {retryLabel(index)}</div>,
|
||||
render: () => <TestResultView test={test} result={result} selectedStep={selectedStep} setSelectedStep={setSelectedStep}></TestResultView>
|
||||
render: () => <TestResultView test={test!} result={result}></TestResultView>
|
||||
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
|
||||
</div>
|
||||
</SplitView>;
|
||||
};
|
||||
|
||||
const TestResultView: React.FC<{
|
||||
test: JsonTestCase,
|
||||
result: JsonTestResult,
|
||||
selectedStep: JsonTestStep | undefined,
|
||||
setSelectedStep: (step: JsonTestStep | undefined) => void;
|
||||
}> = ({ test, result, selectedStep, setSelectedStep }) => {
|
||||
return <div className='test-result'>
|
||||
{result.steps.map((step, i) => <StepTreeItem key={i} step={step} depth={0} selectedStep={selectedStep} setSelectedStep={setSelectedStep}></StepTreeItem>)}
|
||||
</div>;
|
||||
};
|
||||
|
||||
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<SourceProps>({ 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<string, JsonAttachment>();
|
||||
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 <div></div>;
|
||||
return <div className='vbox'>
|
||||
<TabbedPane selectedTab={selectedTab} setSelectedTab={setSelectedTab} tabs={[
|
||||
{
|
||||
id: 'errors',
|
||||
title: 'Errors',
|
||||
render: () => {
|
||||
return <div style={{ overflow: 'auto' }}>
|
||||
<div className='error-message' dangerouslySetInnerHTML={{ __html: new ansi2html({ colors: ansiColors }).toHtml(escapeHTML(result.failureSnippet?.trim() || '')) }}></div>
|
||||
{attachmentsMap.has('expected') && attachmentsMap.has('actual') && <ImageDiff actual={attachmentsMap.get('actual')!} expected={attachmentsMap.get('expected')!} diff={attachmentsMap.get('diff')}></ImageDiff>}
|
||||
</div>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
title: 'Results',
|
||||
render: () => {
|
||||
return <div style={{ overflow: 'auto' }}>
|
||||
{screenshots.map(a => <div className='image-preview'><img src={'resources/' + a.sha1} /></div>)}
|
||||
{video.map(a => <div className='image-preview'>
|
||||
<video controls>
|
||||
<source src={'resources/' + a.sha1} type={a.contentType}/>
|
||||
</video>
|
||||
</div>)}
|
||||
{!!result.attachments && <div className='test-overview-title'>Attachments</div>}
|
||||
{result.attachments.map(a => <AttachmentLink attachment={a}></AttachmentLink>)}
|
||||
</div>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
title: 'Source',
|
||||
render: () => <Source text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine}></Source>
|
||||
}
|
||||
]}></TabbedPane>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const TestStepDetails: React.FC<{
|
||||
test: JsonTestCase | undefined,
|
||||
result: JsonTestResult | undefined,
|
||||
step: JsonTestStep | undefined,
|
||||
}> = ({ test, result, step }) => {
|
||||
const [source, setSource] = React.useState<SourceProps>({ 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 <div className='vbox'>
|
||||
<TabbedPane selectedTab={selectedTab} setSelectedTab={setSelectedTab} tabs={[
|
||||
{
|
||||
id: 'errors',
|
||||
title: 'Errors',
|
||||
render: () => <div className='error-message' dangerouslySetInnerHTML={{ __html: new ansi2html({ colors: ansiColors }).toHtml(escapeHTML(step?.failureSnippet?.trim() || '')) }}></div>
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
title: 'Source',
|
||||
render: () => <Source text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine}></Source>
|
||||
}
|
||||
]}></TabbedPane>
|
||||
return <div className='test-result'>
|
||||
{result.error && <ErrorMessage key={-1} error={result.error}></ErrorMessage>}
|
||||
{result.steps.map((step, i) => <StepTreeItem key={i} step={step} depth={0}></StepTreeItem>)}
|
||||
</div>;
|
||||
};
|
||||
|
||||
const StepTreeItem: React.FC<{
|
||||
step: JsonTestStep;
|
||||
step: TestStep;
|
||||
depth: number,
|
||||
selectedStep?: JsonTestStep,
|
||||
setSelectedStep: (step: JsonTestStep | undefined) => void;
|
||||
}> = ({ step, depth, selectedStep, setSelectedStep }) => {
|
||||
}> = ({ step, depth }) => {
|
||||
return <TreeItem title={<div style={{ display: 'flex', alignItems: 'center', flex: 'auto' }}>
|
||||
{testStepStatusIcon(step)}
|
||||
<span style={{ whiteSpace: 'pre' }}>{step.preview || step.title}</span>
|
||||
{statusIcon(step.error ? 'failed' : 'passed')}
|
||||
<span style={{ whiteSpace: 'pre' }}>{step.title}</span>
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>{msToString(step.duration)}</div>
|
||||
</div>} loadChildren={step.steps.length ? () => {
|
||||
return step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} selectedStep={selectedStep} setSelectedStep={setSelectedStep}></StepTreeItem>);
|
||||
} : undefined} depth={depth} selected={step === selectedStep} onClick={() => setSelectedStep(step)}></TreeItem>;
|
||||
</div>} loadChildren={step.steps.length + (step.error ? 1 : 0) ? () => {
|
||||
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
||||
if (step.error)
|
||||
children.unshift(<ErrorMessage key={-1} error={step.error}></ErrorMessage>);
|
||||
return children;
|
||||
} : undefined} depth={depth}></TreeItem>;
|
||||
};
|
||||
|
||||
export const ImageDiff: React.FunctionComponent<{
|
||||
actual: JsonAttachment,
|
||||
expected: JsonAttachment,
|
||||
diff?: JsonAttachment,
|
||||
}> = ({ actual, expected, diff }) => {
|
||||
const [selectedTab, setSelectedTab] = React.useState<string>('actual');
|
||||
const tabs = [];
|
||||
tabs.push({
|
||||
id: 'actual',
|
||||
title: 'Actual',
|
||||
render: () => <div className='image-preview'><img src={'resources/' + actual.sha1}/></div>
|
||||
});
|
||||
tabs.push({
|
||||
id: 'expected',
|
||||
title: 'Expected',
|
||||
render: () => <div className='image-preview'><img src={'resources/' + expected.sha1}/></div>
|
||||
});
|
||||
if (diff) {
|
||||
tabs.push({
|
||||
id: 'diff',
|
||||
title: 'Diff',
|
||||
render: () => <div className='image-preview'><img src={'resources/' + diff.sha1}/></div>,
|
||||
});
|
||||
}
|
||||
return <div className='vbox test-image-mismatch'>
|
||||
<div className='test-overview-title'>Image mismatch</div>
|
||||
<TabbedPane tabs={tabs} selectedTab={selectedTab} setSelectedTab={setSelectedTab} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const AttachmentLink: React.FunctionComponent<{
|
||||
attachment: JsonAttachment,
|
||||
}> = ({ attachment }) => {
|
||||
return <TreeItem title={<div style={{ display: 'flex', alignItems: 'center', flex: 'auto' }}>
|
||||
<span className={'codicon codicon-cloud-download'}></span>
|
||||
{attachment.sha1 && <a href={'resources/' + attachment.sha1} target='_blank'>{attachment.name}</a>}
|
||||
{attachment.body && <span>{attachment.name}</span>}
|
||||
</div>} loadChildren={attachment.body ? () => {
|
||||
return [<div className='attachment-body'>${attachment.body}</div>];
|
||||
} : undefined} depth={0}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <span></span>;
|
||||
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 <div className='error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
|
||||
};
|
||||
|
||||
const ansiColors = {
|
||||
0: '#000',
|
||||
1: '#C00',
|
||||
|
@ -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;
|
||||
}
|
@ -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<ProjectTreeItem[]>([]);
|
||||
const [fetchError, setFetchError] = React.useState<string | undefined>();
|
||||
const [testId, setTestId] = React.useState<TestId | undefined>();
|
||||
|
||||
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<Filter>('Failing');
|
||||
|
||||
return <div className='hbox'>
|
||||
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<TestCaseView key={testId?.testId} testId={testId}></TestCaseView>
|
||||
<div className='suite-tree-column'>
|
||||
<div className='tab-strip'>{
|
||||
(['Failing', 'All'] as Filter[]).map(item => {
|
||||
const selected = item === filter;
|
||||
return <div key={item} className={'tab-element' + (selected ? ' selected' : '')} onClick={e => {
|
||||
setFilter(item);
|
||||
}}>{item}</div>;
|
||||
})
|
||||
}</div>
|
||||
{!fetchError && filter === 'All' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId}></ProjectTreeItemView>)}
|
||||
{!fetchError && filter === 'Failing' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={true}></ProjectTreeItemView>)}
|
||||
</div>
|
||||
</SplitView>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const ProjectTreeItemView: React.FC<{
|
||||
project: ProjectTreeItem;
|
||||
testId?: TestId,
|
||||
setTestId: (id: TestId) => void;
|
||||
failingOnly?: boolean;
|
||||
}> = ({ project, testId, setTestId, failingOnly }) => {
|
||||
return <TreeItem title={<div className='hbox'>
|
||||
{statusIconForFailedTests(project.failedTests)}<div className='tree-text'>{project.name || 'Project'}</div>
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
return project.suites.map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={1} showFileName={true}></SuiteTreeItemView>) || [];
|
||||
}} depth={0} expandByDefault={true}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <TreeItem title={<div className='hbox'>
|
||||
{statusIconForFailedTests(suite.failedTests)}<div className='tree-text'>{suite.title}</div>
|
||||
{!!suite.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
const suiteChildren = suite.suites.map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={depth + 1} showFileName={false}></SuiteTreeItemView>) || [];
|
||||
const suiteCount = suite.suites.length;
|
||||
const testChildren = suite.tests.map((t, i) => <TestTreeItemView key={i + suiteCount} test={t} setTestId={setTestId} testId={testId} showFileName={false} depth={depth + 1}></TestTreeItemView>) || [];
|
||||
return [...suiteChildren, ...testChildren];
|
||||
}} depth={depth}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <TreeItem title={<div className='hbox'>
|
||||
{statusIcon(test.outcome)}<div className='tree-text'>{test.title}</div>
|
||||
{showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{name}:{test.location.line}</div>}
|
||||
{!showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{msToString(test.duration)}</div>}
|
||||
</div>
|
||||
} selected={test.testId === testId?.testId} depth={depth} onClick={() => setTestId({ testId: test.testId, fileId: test.fileId })}></TreeItem>;
|
||||
};
|
||||
|
||||
const TestCaseView: React.FC<{
|
||||
testId: TestId | undefined,
|
||||
}> = ({ testId }) => {
|
||||
const [file, setFile] = React.useState<TestFile | undefined>();
|
||||
|
||||
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 <SplitView sidebarSize={500} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<div className='test-details-column vbox'>
|
||||
</div>
|
||||
<div className='test-case-column vbox'>
|
||||
{ test && <div className='test-case-title'>{test?.title}</div> }
|
||||
{ test && <div className='test-case-location'>{renderLocation(test.location, true)}</div> }
|
||||
{ test && <TabbedPane tabs={
|
||||
test.results.map((result, index) => ({
|
||||
id: String(index),
|
||||
title: <div style={{ display: 'flex', alignItems: 'center' }}>{statusIcon(result.status)} {retryLabel(index)}</div>,
|
||||
render: () => <TestResultView test={test!} result={result}></TestResultView>
|
||||
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
|
||||
</div>
|
||||
</SplitView>;
|
||||
};
|
||||
|
||||
const TestResultView: React.FC<{
|
||||
test: TestCase,
|
||||
result: TestResult,
|
||||
}> = ({ test, result }) => {
|
||||
return <div className='test-result'>
|
||||
{result.error && <ErrorMessage error={result.error}></ErrorMessage>}
|
||||
{result.steps.map((step, i) => <StepTreeItem key={i} step={step} depth={0}></StepTreeItem>)}
|
||||
</div>;
|
||||
};
|
||||
|
||||
const StepTreeItem: React.FC<{
|
||||
step: TestStep;
|
||||
depth: number,
|
||||
}> = ({ step, depth }) => {
|
||||
return <TreeItem title={<div style={{ display: 'flex', alignItems: 'center', flex: 'auto' }}>
|
||||
{statusIcon(step.error ? 'failed' : 'passed')}
|
||||
<span style={{ whiteSpace: 'pre' }}>{step.title}</span>
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>{msToString(step.duration)}</div>
|
||||
</div>} loadChildren={step.steps.length + (step.error ? 1 : 0) ? () => {
|
||||
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
||||
if (step.error)
|
||||
children.unshift(<ErrorMessage error={step.error}></ErrorMessage>);
|
||||
return children;
|
||||
} : undefined} depth={depth}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <span className={'codicon codicon-error status-icon'}></span>;
|
||||
case 'passed':
|
||||
case 'expected':
|
||||
return <span className={'codicon codicon-circle-filled status-icon'}></span>;
|
||||
case 'timedOut':
|
||||
return <span className={'codicon codicon-clock status-icon'}></span>;
|
||||
case 'flaky':
|
||||
return <span className={'codicon codicon-alert status-icon'}></span>;
|
||||
case 'skipped':
|
||||
return <span className={'codicon codicon-tag status-icon'}></span>;
|
||||
}
|
||||
}
|
||||
|
||||
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 <div className='error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
|
||||
};
|
||||
|
||||
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]!));
|
||||
}
|
@ -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.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playwright Test Report</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id=root></div>
|
||||
</body>
|
||||
</html>
|
@ -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(<Report />, document.querySelector('#root'));
|
||||
})();
|
@ -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,
|
||||
})
|
||||
]
|
||||
};
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
@ -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']));
|
||||
});
|
||||
|
@ -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({
|
||||
|
@ -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 => {
|
||||
|
Loading…
Reference in New Issue
Block a user