chore: remove stale html experiments (#8905)

This commit is contained in:
Pavel Feldman 2021-09-13 20:34:46 -07:00 committed by GitHub
parent a1adc15ea3
commit 16baaa317d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 285 additions and 1545 deletions

View File

@ -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.');

View File

@ -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();

View File

@ -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,
};
}
}

View File

@ -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[];
};

View File

@ -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,
};
}
}

View File

@ -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,

View File

@ -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) {

View File

@ -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;
}

View File

@ -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',

View File

@ -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;
}

View File

@ -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 => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
}

View File

@ -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>

View File

@ -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'));
})();

View File

@ -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,
})
]
};

View File

@ -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',
]);
});

View File

@ -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']));
});

View File

@ -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({

View File

@ -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 => {