feat(html-reporter): add report context header (#12734)
Resolves #11318. * Adds `TestConfig.attachments` public API. (We opted to not implement an analog to the async `TestInfo.attach(…)` API.) * Adds `TestConfig.attachments` to common reporters. * Dogfoods some git and CI-info inference to generate useful atttachments * Updates HTML Reporter to include a side bar to present a pre-defined set of attachments (a.k.a git/commit context sidebar) Here's what it looks like: <img width="1738" alt="Screen Shot 2022-03-21 at 3 23 28 PM" src="https://user-images.githubusercontent.com/11915034/159373291-8b937d30-fba3-472a-853a-766018f6b3e2.png"> See `tests/playwright-test/reporter-html.spec.ts` for an example of usage (for dogfood-ing only). In the future, if this becomes user-facing, there the Global Setup bit would likely become unnecessary (as would interaction with attachments array); there would likely just be a nice top-level config and/or CLI flag to enable collecting of info.
This commit is contained in:
Родитель
8c29803542
Коммит
541fb39a51
|
@ -719,3 +719,16 @@ const config: PlaywrightTestConfig = {
|
|||
};
|
||||
export default config;
|
||||
```
|
||||
|
||||
## property: TestConfig.attachments
|
||||
- type: <[Array]<[Object]>>
|
||||
- `name` <[string]> Attachment name.
|
||||
- `contentType` <[string]> Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
|
||||
- `path` <[void]|[string]> Optional path on the filesystem to the attached file.
|
||||
- `body` <[void]|[Buffer]> Optional attachment body used instead of a file.
|
||||
|
||||
:::note
|
||||
This does not include test-level attachments. See [`method: TestInfo.attach`] and [`property: TestInfo.attachments`] for working with test-level attachments.
|
||||
:::
|
||||
|
||||
The list of files or buffers attached for the overall Playwright Test run. Some reporters show attachments.
|
||||
|
|
|
@ -69,3 +69,19 @@ export const clock = () => {
|
|||
export const blank = () => {
|
||||
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
|
||||
};
|
||||
|
||||
export const externalLink = () => {
|
||||
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M10.604 1h4.146a.25.25 0 01.25.25v4.146a.25.25 0 01-.427.177L13.03 4.03 9.28 7.78a.75.75 0 01-1.06-1.06l3.75-3.75-1.543-1.543A.25.25 0 0110.604 1zM3.75 2A1.75 1.75 0 002 3.75v8.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 12.25v-3.5a.75.75 0 00-1.5 0v3.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25h3.5a.75.75 0 000-1.5h-3.5z"></path></svg>;
|
||||
};
|
||||
|
||||
export const calendar = () => {
|
||||
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4.75 0a.75.75 0 01.75.75V2h5V.75a.75.75 0 011.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0113.25 16H2.75A1.75 1.75 0 011 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 014.75 0zm0 3.5h8.5a.25.25 0 01.25.25V6h-11V3.75a.25.25 0 01.25-.25h2zm-2.25 4v6.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V7.5h-11z"></path></svg>;
|
||||
};
|
||||
|
||||
export const person = () => {
|
||||
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M10.5 5a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm.061 3.073a4 4 0 10-5.123 0 6.004 6.004 0 00-3.431 5.142.75.75 0 001.498.07 4.5 4.5 0 018.99 0 .75.75 0 101.498-.07 6.005 6.005 0 00-3.432-5.142z"></path></svg>;
|
||||
};
|
||||
|
||||
export const commit = () => {
|
||||
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M10.5 7.75a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm1.43.75a4.002 4.002 0 01-7.86 0H.75a.75.75 0 110-1.5h3.32a4.001 4.001 0 017.86 0h3.32a.75.75 0 110 1.5h-3.32z"></path></svg>;
|
||||
};
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { HTMLReport } from '@playwright/test/src/reporters/html';
|
||||
import type { HTMLReport, TestAttachment } from '@playwright/test/src/reporters/html';
|
||||
import type zip from '@zip.js/zip.js';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
@ -22,6 +22,59 @@ import './colors.css';
|
|||
import { LoadedReport } from './loadedReport';
|
||||
import { ReportView } from './reportView';
|
||||
|
||||
export type Metadata = Partial<{
|
||||
'generatedAt': number;
|
||||
'revision.id': string;
|
||||
'revision.author': string;
|
||||
'revision.email': string;
|
||||
'revision.subject': string;
|
||||
'revision.timestamp': number;
|
||||
'revision.link': string;
|
||||
'revision.localPendingChanges': boolean;
|
||||
'ci.link': string;
|
||||
}>;
|
||||
|
||||
const extractMetadata = (attachments: TestAttachment[]): Metadata | undefined => {
|
||||
// The last plugin to register for a given key will take precedence
|
||||
attachments = [...attachments];
|
||||
attachments.reverse();
|
||||
const field = (name: string) => attachments.find(({ name: n }) => n === name)?.body;
|
||||
const fieldAsJSON = (name: string) => {
|
||||
const raw = field(name);
|
||||
if (raw !== undefined)
|
||||
return JSON.parse(raw);
|
||||
};
|
||||
const fieldAsNumber = (name: string) => {
|
||||
const v = fieldAsJSON(name);
|
||||
if (v !== undefined && typeof v !== 'number')
|
||||
throw new Error(`Invalid value for field '${name}'. Expected type 'number', but got ${typeof v}.`);
|
||||
|
||||
return v;
|
||||
};
|
||||
const fieldAsBool = (name: string) => {
|
||||
const v = fieldAsJSON(name);
|
||||
if (v !== undefined && typeof v !== 'boolean')
|
||||
throw new Error(`Invalid value for field '${name}'. Expected type 'boolean', but got ${typeof v}.`);
|
||||
|
||||
return v;
|
||||
};
|
||||
|
||||
const out = {
|
||||
'generatedAt': fieldAsNumber('generatedAt'),
|
||||
'revision.id': field('revision.id'),
|
||||
'revision.author': field('revision.author'),
|
||||
'revision.email': field('revision.email'),
|
||||
'revision.subject': field('revision.subject'),
|
||||
'revision.timestamp': fieldAsNumber('revision.timestamp'),
|
||||
'revision.link': field('revision.link'),
|
||||
'revision.localPendingChanges': fieldAsBool('revision.localPendingChanges'),
|
||||
'ci.link': field('ci.link'),
|
||||
};
|
||||
|
||||
if (Object.entries(out).filter(([_, v]) => v !== undefined).length)
|
||||
return out;
|
||||
};
|
||||
|
||||
const zipjs = (self as any).zip;
|
||||
|
||||
const ReportLoader: React.FC = () => {
|
||||
|
@ -41,13 +94,14 @@ window.onload = () => {
|
|||
|
||||
class ZipReport implements LoadedReport {
|
||||
private _entries = new Map<string, zip.Entry>();
|
||||
private _json!: HTMLReport;
|
||||
private _json!: HTMLReport & { metadata?: Metadata };
|
||||
|
||||
async load() {
|
||||
const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader(window.playwrightReportBase64), { useWebWorkers: false }) as zip.ZipReader;
|
||||
for (const entry of await zipReader.getEntries())
|
||||
this._entries.set(entry.filename, entry);
|
||||
this._json = await this.entry('report.json') as HTMLReport;
|
||||
this._json.metadata = extractMetadata(this._json.attachments);
|
||||
}
|
||||
|
||||
json(): HTMLReport {
|
||||
|
|
|
@ -15,8 +15,9 @@
|
|||
*/
|
||||
|
||||
import { HTMLReport } from '@playwright/test/src/reporters/html';
|
||||
import { Metadata } from '.';
|
||||
|
||||
export interface LoadedReport {
|
||||
json(): HTMLReport;
|
||||
json(): HTMLReport & { metadata?: Metadata };
|
||||
entry(name: string): Promise<Object | undefined>;
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ html, body {
|
|||
|
||||
body {
|
||||
overflow: auto;
|
||||
max-width: 1024px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -33,8 +32,58 @@ body {
|
|||
border-top: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.htmlreport {
|
||||
gap: 24px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.htmlreport header {
|
||||
width: 300px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.htmlreport main {
|
||||
max-width: 1024px;
|
||||
width: 100%;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.metadata-view a {
|
||||
color: var(--color-accent-fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.metadata-view a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.metadata-view h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.report {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 900px) {
|
||||
.htmlreport {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.htmlreport header {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.htmlreport main {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,8 @@ import './reportView.css';
|
|||
import { TestCaseView } from './testCaseView';
|
||||
import { TestFilesView } from './testFilesView';
|
||||
import './theme.css';
|
||||
import * as icons from './icons';
|
||||
import { Metadata } from '.';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
@ -43,8 +45,10 @@ export const ReportView: React.FC<{
|
|||
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
|
||||
|
||||
return <div className='htmlreport vbox px-4 pb-4'>
|
||||
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
||||
{<>
|
||||
|
||||
{report?.json().metadata && <MetadataView {...report?.json().metadata!} />}
|
||||
<main>
|
||||
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
||||
<Route params=''>
|
||||
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
|
||||
</Route>
|
||||
|
@ -54,10 +58,75 @@ export const ReportView: React.FC<{
|
|||
<Route params='testId'>
|
||||
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
|
||||
</Route>
|
||||
</>}
|
||||
</main>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const MetadataView: React.FC<Metadata> = metadata => {
|
||||
return (
|
||||
<header className='metadata-view pt-3'>
|
||||
<h1>{metadata['revision.subject'] || 'Playwright Test Report'}</h1>
|
||||
{metadata['revision.id'] &&
|
||||
<MetadatViewItem
|
||||
testId='revision.id'
|
||||
content={<span style={{ fontFamily: 'monospace' }}>{metadata['revision.id'].slice(0, 7)}</span>}
|
||||
href={metadata['revision.link']}
|
||||
icon='commit'
|
||||
/>
|
||||
}
|
||||
{(metadata['revision.author'] || metadata['revision.email']) &&
|
||||
<MetadatViewItem
|
||||
content={(
|
||||
metadata['revision.author'] && metadata['revision.email']
|
||||
? <>{metadata['revision.author']}<br/>{metadata['revision.email']}</>
|
||||
: (metadata['revision.author'] || metadata['revision.email'])
|
||||
)!}
|
||||
icon='person'
|
||||
/>
|
||||
}
|
||||
{metadata['revision.timestamp'] &&
|
||||
<MetadatViewItem
|
||||
testId='revision.timestamp'
|
||||
content={
|
||||
<>
|
||||
{Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).format(metadata['revision.timestamp'])}
|
||||
<br />
|
||||
{Intl.DateTimeFormat(undefined, { timeStyle: 'long' }).format(metadata['revision.timestamp'])}
|
||||
</>
|
||||
}
|
||||
icon='calendar'
|
||||
/>
|
||||
}
|
||||
{metadata['ci.link'] &&
|
||||
<MetadatViewItem
|
||||
content='CI/CD Logs'
|
||||
href={metadata['ci.link']}
|
||||
icon='externalLink'
|
||||
/>
|
||||
}
|
||||
{metadata['revision.localPendingChanges'] &&
|
||||
<p style={{ fontStyle: 'italic', color: 'var(--color-fg-subtle)' }}>This report was generated with <strong>uncommitted changes</strong>.</p>
|
||||
}
|
||||
{metadata['generatedAt'] &&
|
||||
<p style={{ fontStyle: 'italic', color: 'var(--color-fg-subtle)' }}>Report generated on {Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(metadata['generatedAt'])}</p>
|
||||
}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const MetadatViewItem: React.FC<{ content: JSX.Element | string; icon: keyof typeof icons, href?: string, testId?: string }> = ({ content, icon, href, testId }) => {
|
||||
return (
|
||||
<div className='mt-2 hbox' data-test-id={testId} >
|
||||
<div className='mr-2'>
|
||||
{icons[icon]()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{href ? <a href={href} target='_blank' rel='noopener noreferrer'>{content}</a> : content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TestCaseViewLoader: React.FC<{
|
||||
report: LoadedReport,
|
||||
}> = ({ report }) => {
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
"./lib/cli": "./lib/cli.js",
|
||||
"./lib/experimentalLoader": "./lib/experimentalLoader.js",
|
||||
"./lib/mount": "./lib/mount.js",
|
||||
"./lib/ci": "./lib/ci.js",
|
||||
"./reporter": "./reporter.js"
|
||||
},
|
||||
"bin": {
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* 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 { spawnAsync } from 'playwright-core/lib/utils/utils';
|
||||
|
||||
const GIT_OPERATIONS_TIMEOUT_MS = 1500;
|
||||
const kContentTypePlainText = 'text/plain';
|
||||
const kContentTypeJSON = 'application/json';
|
||||
export interface Attachment {
|
||||
name: string;
|
||||
contentType: string;
|
||||
path?: string;
|
||||
body?: Buffer;
|
||||
}
|
||||
|
||||
export const gitStatusFromCLI = async (gitDir: string): Promise<Attachment[]> => {
|
||||
const execGit = async (args: string[]) => {
|
||||
const { code, stdout } = await spawnAsync('git', args, { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS });
|
||||
if (!!code)
|
||||
throw new Error('Exited with non-zero code.');
|
||||
|
||||
return stdout.trim();
|
||||
};
|
||||
|
||||
await execGit(['--help']).catch(() => { throw new Error('git --help failed; is git installed?');});
|
||||
const [ status, sha, subject, authorName, authorEmail, rawTimestamp ] = await Promise.all([
|
||||
execGit(['status', '--porcelain=v1']),
|
||||
execGit(['rev-parse', 'HEAD']),
|
||||
execGit(['show', '-s', '--format=%s', 'HEAD']),
|
||||
execGit(['show', '-s', '--format=%an', 'HEAD']),
|
||||
execGit(['show', '-s', '--format=%ae', 'HEAD']),
|
||||
execGit(['show', '-s', '--format=%ct', 'HEAD']),
|
||||
]).catch(() => { throw new Error('one or more git commands failed');});
|
||||
|
||||
let timestamp: number = Number.parseInt(rawTimestamp, 10);
|
||||
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
|
||||
|
||||
return [
|
||||
{ name: 'revision.id', body: Buffer.from(sha), contentType: kContentTypePlainText },
|
||||
{ name: 'revision.author', body: Buffer.from(authorName), contentType: kContentTypePlainText },
|
||||
{ name: 'revision.email', body: Buffer.from(authorEmail), contentType: kContentTypePlainText },
|
||||
{ name: 'revision.subject', body: Buffer.from(subject), contentType: kContentTypePlainText },
|
||||
{ name: 'revision.timestamp', body: Buffer.from(JSON.stringify(timestamp)), contentType: kContentTypeJSON },
|
||||
{ name: 'revision.localPendingChanges', body: Buffer.from(!!status + ''), contentType: kContentTypePlainText },
|
||||
];
|
||||
};
|
||||
|
||||
export const githubEnv = async (): Promise<Attachment[]> => {
|
||||
const out: Attachment[] = [];
|
||||
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA)
|
||||
out.push({ name: 'revision.link', body: Buffer.from(`${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`), contentType: kContentTypePlainText });
|
||||
|
||||
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID)
|
||||
out.push({ name: 'ci.link', body: Buffer.from(`${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`), contentType: kContentTypePlainText });
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
export const gitlabEnv = async (): Promise<Attachment[]> => {
|
||||
// GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
|
||||
const out: Attachment[] = [];
|
||||
if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA)
|
||||
out.push({ name: 'revision.link', body: Buffer.from(`${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`), contentType: kContentTypePlainText });
|
||||
|
||||
if (process.env.CI_JOB_URL)
|
||||
out.push({ name: 'ci.link', body: Buffer.from(process.env.CI_JOB_URL), contentType: kContentTypePlainText });
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
export const jenkinsEnv = async (): Promise<Attachment[]> => {
|
||||
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
|
||||
const out: Attachment[] = [];
|
||||
if (process.env.BUILD_URL)
|
||||
out.push({ name: 'ci.link', body: Buffer.from(process.env.BUILD_URL), contentType: kContentTypePlainText });
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
export const generationTimestamp = async (): Promise<Attachment[]> => {
|
||||
return [{ name: 'generatedAt', body: Buffer.from(JSON.stringify(Date.now())), contentType: kContentTypeJSON }];
|
||||
};
|
|
@ -456,6 +456,7 @@ const baseFullConfig: FullConfig = {
|
|||
version: require('../package.json').version,
|
||||
workers: 1,
|
||||
webServer: null,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
function resolveReporters(reporters: Config['reporter'], rootDir: string): ReporterDescription[]|undefined {
|
||||
|
|
|
@ -22,7 +22,7 @@ import { Transform, TransformCallback } from 'stream';
|
|||
import { FullConfig, Suite, Reporter } from '../../types/testReporter';
|
||||
import { HttpServer } from 'playwright-core/lib/utils/httpServer';
|
||||
import { calculateSha1, removeFolders } from 'playwright-core/lib/utils/utils';
|
||||
import RawReporter, { JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw';
|
||||
import RawReporter, { JsonAttachment, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw';
|
||||
import assert from 'assert';
|
||||
import yazl from 'yazl';
|
||||
import { stripAnsiEscapes } from './base';
|
||||
|
@ -44,6 +44,7 @@ export type Location = {
|
|||
};
|
||||
|
||||
export type HTMLReport = {
|
||||
attachments: TestAttachment[];
|
||||
files: TestFileSummary[];
|
||||
stats: Stats;
|
||||
projectNames: string[];
|
||||
|
@ -145,7 +146,7 @@ class HtmlReporter implements Reporter {
|
|||
const reportFolder = htmlReportFolder(this._outputFolder);
|
||||
await removeFolders([reportFolder]);
|
||||
const builder = new HtmlBuilder(reportFolder);
|
||||
const { ok, singleTestId } = await builder.build(reports);
|
||||
const { ok, singleTestId } = await builder.build(new RawReporter().generateAttachments(this.config), reports);
|
||||
|
||||
if (process.env.CI)
|
||||
return;
|
||||
|
@ -228,7 +229,7 @@ class HtmlBuilder {
|
|||
this._dataZipFile = new yazl.ZipFile();
|
||||
}
|
||||
|
||||
async build(rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
|
||||
async build(testReportAttachments: JsonAttachment[], rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
|
||||
|
||||
const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
|
||||
for (const projectJson of rawReports) {
|
||||
|
@ -284,6 +285,7 @@ class HtmlBuilder {
|
|||
this._addDataFile(fileId + '.json', testFile);
|
||||
}
|
||||
const htmlReport: HTMLReport = {
|
||||
attachments: this._serializeAttachments(testReportAttachments),
|
||||
files: [...data.values()].map(e => e.testFileSummary),
|
||||
projectNames: rawReports.map(r => r.project.name),
|
||||
stats: [...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats())
|
||||
|
@ -377,8 +379,84 @@ class HtmlBuilder {
|
|||
};
|
||||
}
|
||||
|
||||
private _createTestResult(result: JsonTestResult): TestResult {
|
||||
private _serializeAttachments(attachments: JsonAttachment[]) {
|
||||
let lastAttachment: TestAttachment | undefined;
|
||||
return attachments.map(a => {
|
||||
if (a.name === 'trace')
|
||||
this._hasTraces = true;
|
||||
|
||||
if ((a.name === 'stdout' || a.name === 'stderr') && a.contentType === 'text/plain') {
|
||||
if (lastAttachment &&
|
||||
lastAttachment.name === a.name &&
|
||||
lastAttachment.contentType === a.contentType) {
|
||||
lastAttachment.body += stripAnsiEscapes(a.body as string);
|
||||
return null;
|
||||
}
|
||||
a.body = stripAnsiEscapes(a.body as string);
|
||||
lastAttachment = a as TestAttachment;
|
||||
return a;
|
||||
}
|
||||
|
||||
if (a.path) {
|
||||
let fileName = a.path;
|
||||
try {
|
||||
const buffer = fs.readFileSync(a.path);
|
||||
const sha1 = calculateSha1(buffer) + path.extname(a.path);
|
||||
fileName = 'data/' + sha1;
|
||||
fs.mkdirSync(path.join(this._reportFolder, 'data'), { recursive: true });
|
||||
fs.writeFileSync(path.join(this._reportFolder, 'data', sha1), buffer);
|
||||
} catch (e) {
|
||||
return {
|
||||
name: `Missing attachment "${a.name}"`,
|
||||
contentType: kMissingContentType,
|
||||
body: `Attachment file ${fileName} is missing`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
path: fileName,
|
||||
body: a.body,
|
||||
};
|
||||
}
|
||||
|
||||
if (a.body instanceof Buffer) {
|
||||
if (isTextContentType(a.contentType)) {
|
||||
// Content type is like this: "text/html; charset=UTF-8"
|
||||
const charset = a.contentType.match(/charset=(.*)/)?.[1];
|
||||
try {
|
||||
const body = a.body.toString(charset as any || 'utf-8');
|
||||
return {
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
body,
|
||||
};
|
||||
} catch (e) {
|
||||
// Invalid encoding, fall through and save to file.
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(this._reportFolder, 'data'), { recursive: true });
|
||||
const sha1 = calculateSha1(a.body) + '.dat';
|
||||
fs.writeFileSync(path.join(this._reportFolder, 'data', sha1), a.body);
|
||||
return {
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
path: 'data/' + sha1,
|
||||
body: a.body,
|
||||
};
|
||||
}
|
||||
|
||||
// string
|
||||
return {
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
body: a.body,
|
||||
};
|
||||
}).filter(Boolean) as TestAttachment[];
|
||||
}
|
||||
|
||||
private _createTestResult(result: JsonTestResult): TestResult {
|
||||
return {
|
||||
duration: result.duration,
|
||||
startTime: result.startTime,
|
||||
|
@ -386,79 +464,7 @@ class HtmlBuilder {
|
|||
steps: result.steps.map(s => this._createTestStep(s)),
|
||||
errors: result.errors,
|
||||
status: result.status,
|
||||
attachments: result.attachments.map(a => {
|
||||
if (a.name === 'trace')
|
||||
this._hasTraces = true;
|
||||
|
||||
if ((a.name === 'stdout' || a.name === 'stderr') && a.contentType === 'text/plain') {
|
||||
if (lastAttachment &&
|
||||
lastAttachment.name === a.name &&
|
||||
lastAttachment.contentType === a.contentType) {
|
||||
lastAttachment.body += stripAnsiEscapes(a.body as string);
|
||||
return null;
|
||||
}
|
||||
a.body = stripAnsiEscapes(a.body as string);
|
||||
lastAttachment = a as TestAttachment;
|
||||
return a;
|
||||
}
|
||||
|
||||
if (a.path) {
|
||||
let fileName = a.path;
|
||||
try {
|
||||
const buffer = fs.readFileSync(a.path);
|
||||
const sha1 = calculateSha1(buffer) + path.extname(a.path);
|
||||
fileName = 'data/' + sha1;
|
||||
fs.mkdirSync(path.join(this._reportFolder, 'data'), { recursive: true });
|
||||
fs.writeFileSync(path.join(this._reportFolder, 'data', sha1), buffer);
|
||||
} catch (e) {
|
||||
return {
|
||||
name: `Missing attachment "${a.name}"`,
|
||||
contentType: kMissingContentType,
|
||||
body: `Attachment file ${fileName} is missing`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
path: fileName,
|
||||
body: a.body,
|
||||
};
|
||||
}
|
||||
|
||||
if (a.body instanceof Buffer) {
|
||||
if (isTextContentType(a.contentType)) {
|
||||
// Content type is like this: "text/html; charset=UTF-8"
|
||||
const charset = a.contentType.match(/charset=(.*)/)?.[1];
|
||||
try {
|
||||
const body = a.body.toString(charset as any || 'utf-8');
|
||||
return {
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
body,
|
||||
};
|
||||
} catch (e) {
|
||||
// Invalid encoding, fall through and save to file.
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.join(this._reportFolder, 'data'), { recursive: true });
|
||||
const sha1 = calculateSha1(a.body) + '.dat';
|
||||
fs.writeFileSync(path.join(this._reportFolder, 'data', sha1), a.body);
|
||||
return {
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
path: 'data/' + sha1,
|
||||
body: a.body,
|
||||
};
|
||||
}
|
||||
|
||||
// string
|
||||
return {
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
body: a.body,
|
||||
};
|
||||
}).filter(Boolean) as TestAttachment[]
|
||||
attachments: this._serializeAttachments(result.attachments),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -20,7 +20,13 @@ import { FullConfig, TestCase, Suite, TestResult, TestError, TestStep, FullResul
|
|||
import { prepareErrorStack } from './base';
|
||||
|
||||
export interface JSONReport {
|
||||
config: Omit<FullConfig, 'projects'> & {
|
||||
config: Omit<FullConfig, 'projects' | 'attachments'> & {
|
||||
attachments: {
|
||||
name: string;
|
||||
path?: string;
|
||||
body?: string;
|
||||
contentType: string;
|
||||
}[];
|
||||
projects: {
|
||||
outputDir: string,
|
||||
repeatEach: number,
|
||||
|
@ -121,6 +127,12 @@ class JSONReporter implements Reporter {
|
|||
return {
|
||||
config: {
|
||||
...this.config,
|
||||
attachments: this.config.attachments.map(a => ({
|
||||
name: a.name,
|
||||
contentType: a.contentType,
|
||||
path: a.path,
|
||||
body: a.body?.toString('base64')
|
||||
})),
|
||||
rootDir: toPosixPath(this.config.rootDir),
|
||||
projects: this.config.projects.map(project => {
|
||||
return {
|
||||
|
|
|
@ -34,7 +34,7 @@ export type JsonReport = {
|
|||
suites: JsonSuite[],
|
||||
};
|
||||
|
||||
export type JsonConfig = Omit<FullConfig, 'projects'>;
|
||||
export type JsonConfig = Omit<FullConfig, 'projects' | 'attachments'>;
|
||||
|
||||
export type JsonProject = {
|
||||
metadata: any,
|
||||
|
@ -133,6 +133,10 @@ class RawReporter {
|
|||
}
|
||||
}
|
||||
|
||||
generateAttachments(config: FullConfig): JsonAttachment[] {
|
||||
return this._createAttachments(config.attachments);
|
||||
}
|
||||
|
||||
generateProjectReport(config: FullConfig, suite: Suite): JsonReport {
|
||||
this.config = config;
|
||||
const project = suite.project();
|
||||
|
@ -223,7 +227,7 @@ class RawReporter {
|
|||
duration: result.duration,
|
||||
status: result.status,
|
||||
errors: formatResultFailure(this.config, test, result, '', true).map(error => error.message),
|
||||
attachments: this._createAttachments(result),
|
||||
attachments: this._createAttachments(result.attachments, result),
|
||||
steps: dedupeSteps(result.steps.map(step => this._serializeStep(test, step)))
|
||||
};
|
||||
}
|
||||
|
@ -245,17 +249,17 @@ class RawReporter {
|
|||
return result;
|
||||
}
|
||||
|
||||
private _createAttachments(result: TestResult): JsonAttachment[] {
|
||||
const attachments: JsonAttachment[] = [];
|
||||
for (const attachment of result.attachments) {
|
||||
private _createAttachments(attachments: TestResult['attachments'], ioStreams?: Pick<TestResult, 'stdout' | 'stderr'>): JsonAttachment[] {
|
||||
const out: JsonAttachment[] = [];
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.body) {
|
||||
attachments.push({
|
||||
out.push({
|
||||
name: attachment.name,
|
||||
contentType: attachment.contentType,
|
||||
body: attachment.body
|
||||
});
|
||||
} else if (attachment.path) {
|
||||
attachments.push({
|
||||
out.push({
|
||||
name: attachment.name,
|
||||
contentType: attachment.contentType,
|
||||
path: attachment.path
|
||||
|
@ -263,11 +267,14 @@ class RawReporter {
|
|||
}
|
||||
}
|
||||
|
||||
for (const chunk of result.stdout)
|
||||
attachments.push(this._stdioAttachment(chunk, 'stdout'));
|
||||
for (const chunk of result.stderr)
|
||||
attachments.push(this._stdioAttachment(chunk, 'stderr'));
|
||||
return attachments;
|
||||
if (ioStreams) {
|
||||
for (const chunk of ioStreams.stdout)
|
||||
out.push(this._stdioAttachment(chunk, 'stdout'));
|
||||
for (const chunk of ioStreams.stderr)
|
||||
out.push(this._stdioAttachment(chunk, 'stderr'));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private _stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): JsonAttachment {
|
||||
|
|
|
@ -1268,6 +1268,15 @@ export interface FullConfig<TestArgs = {}, WorkerArgs = {}> {
|
|||
*
|
||||
*/
|
||||
webServer: WebServerConfig | null;
|
||||
/**
|
||||
* > NOTE: This does not include test-level attachments. See
|
||||
* [testInfo.attach(name[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach) and
|
||||
* [testInfo.attachments](https://playwright.dev/docs/api/class-testinfo#test-info-attachments) for working with test-level
|
||||
* attachments.
|
||||
*
|
||||
* The list of files or buffers attached for the overall Playwright Test run. Some reporters show attachments.
|
||||
*/
|
||||
attachments: { name: string, path?: string, body?: Buffer, contentType: string }[];
|
||||
}
|
||||
|
||||
export type TestStatus = 'passed' | 'failed' | 'timedOut' | 'skipped';
|
||||
|
|
|
@ -26,6 +26,7 @@ process.env.PWPAGE_IMPL = 'android';
|
|||
const outputDir = path.join(__dirname, '..', '..', 'test-results');
|
||||
const testDir = path.join(__dirname, '..');
|
||||
const config: Config<ServerWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions> = {
|
||||
globalSetup: path.join(__dirname, './globalSetup'),
|
||||
testDir,
|
||||
outputDir,
|
||||
timeout: 120000,
|
||||
|
|
|
@ -44,6 +44,7 @@ const trace = !!process.env.PWTEST_TRACE;
|
|||
const outputDir = path.join(__dirname, '..', '..', 'test-results');
|
||||
const testDir = path.join(__dirname, '..');
|
||||
const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions & TestModeWorkerOptions> = {
|
||||
globalSetup: path.join(__dirname, './globalSetup'),
|
||||
testDir,
|
||||
outputDir,
|
||||
expect: {
|
||||
|
|
|
@ -26,6 +26,7 @@ process.env.PWPAGE_IMPL = 'electron';
|
|||
const outputDir = path.join(__dirname, '..', '..', 'test-results');
|
||||
const testDir = path.join(__dirname, '..');
|
||||
const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions> = {
|
||||
globalSetup: path.join(__dirname, './globalSetup'),
|
||||
testDir,
|
||||
outputDir,
|
||||
timeout: 30000,
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* 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 { FullConfig } from '@playwright/test';
|
||||
|
||||
// We're dogfooding this, so the …/lib/… import is acceptable
|
||||
import * as ci from '@playwright/test/lib/ci';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
config.attachments = [
|
||||
...await ci.generationTimestamp(),
|
||||
...await ci.gitStatusFromCLI(config.rootDir).catch(() => []),
|
||||
...await ci.githubEnv(),
|
||||
// In the future, we would add some additional plugins like:
|
||||
// ...await ci.azurePipelinePlugin(),
|
||||
// (and these would likley all get bundled into one call and controlled with one config instead
|
||||
// of manually manipulating the attachments array)
|
||||
];
|
||||
}
|
||||
|
||||
export default globalSetup;
|
|
@ -120,7 +120,17 @@ async function runPlaywrightTest(childProcess: CommonFixtures['childProcess'], b
|
|||
...process.env,
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: reportFile,
|
||||
PWTEST_CACHE_DIR: cacheDir,
|
||||
// BEGIN: Reserved CI
|
||||
CI: undefined,
|
||||
BUILD_URL: undefined,
|
||||
CI_COMMIT_SHA: undefined,
|
||||
CI_JOB_URL: undefined,
|
||||
CI_PROJECT_URL: undefined,
|
||||
GITHUB_REPOSITORY: undefined,
|
||||
GITHUB_RUN_ID: undefined,
|
||||
GITHUB_SERVER_URL: undefined,
|
||||
GITHUB_SHA: undefined,
|
||||
// END: Reserved CI
|
||||
PW_TEST_HTML_REPORT_OPEN: undefined,
|
||||
PLAYWRIGHT_DOCKER: undefined,
|
||||
PW_GRID: undefined,
|
||||
|
@ -199,7 +209,7 @@ type RunOptions = {
|
|||
};
|
||||
type Fixtures = {
|
||||
writeFiles: (files: Files) => Promise<string>;
|
||||
runInlineTest: (files: Files, params?: Params, env?: Env, options?: RunOptions) => Promise<RunResult>;
|
||||
runInlineTest: (files: Files, params?: Params, env?: Env, options?: RunOptions, beforeRunPlaywrightTest?: ({ baseDir }: { baseDir: string }) => Promise<void>) => Promise<RunResult>;
|
||||
runTSC: (files: Files) => Promise<TSCResult>;
|
||||
};
|
||||
|
||||
|
@ -212,8 +222,10 @@ export const test = base
|
|||
},
|
||||
|
||||
runInlineTest: async ({ childProcess }, use, testInfo: TestInfo) => {
|
||||
await use(async (files: Files, params: Params = {}, env: Env = {}, options: RunOptions = {}) => {
|
||||
await use(async (files: Files, params: Params = {}, env: Env = {}, options: RunOptions = {}, beforeRunPlaywrightTest?: ({ baseDir: string }) => Promise<void>) => {
|
||||
const baseDir = await writeFiles(testInfo, files);
|
||||
if (beforeRunPlaywrightTest)
|
||||
await beforeRunPlaywrightTest({ baseDir });
|
||||
return await runPlaywrightTest(childProcess, baseDir, params, env, options);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -186,3 +186,34 @@ test(`testInfo.attach allow empty buffer body`, async ({ runInlineTest }) => {
|
|||
expect(result.failed).toBe(1);
|
||||
expect(stripAnsi(result.output)).toMatch(/^.*attachment #1: name \(text\/plain\).*\n.*\n.*------/gm);
|
||||
});
|
||||
|
||||
test(`TestConfig.attachments works`, async ({ runInlineTest }) => {
|
||||
const result = await runInlineTest({
|
||||
'globalSetup.ts': `
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
config.attachments = [{ contentType: 'text/plain', body: Buffer.from('example data'), name: 'my-attachment.txt' }];
|
||||
};
|
||||
|
||||
export default globalSetup;
|
||||
`,
|
||||
'playwright.config.ts': `
|
||||
import path from 'path';
|
||||
const config = {
|
||||
globalSetup: path.join(__dirname, './globalSetup'),
|
||||
}
|
||||
|
||||
export default config;
|
||||
`,
|
||||
'example.spec.ts': `
|
||||
const { test } = pwt;
|
||||
test('sample', async ({}) => { expect(2).toBe(2); });
|
||||
`,
|
||||
}, { reporter: 'json' });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.report.config.attachments).toHaveLength(1);
|
||||
expect(result.report.config.attachments[0].name).toBe('my-attachment.txt');
|
||||
expect(Buffer.from(result.report.config.attachments[0].body, 'base64').toString()).toBe('example data');
|
||||
});
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
import { test as baseTest, expect, createImage } from './playwright-test-fixtures';
|
||||
import { HttpServer } from '../../packages/playwright-core/lib/utils/httpServer';
|
||||
import { startHtmlReportServer } from '../../packages/playwright-test/lib/reporters/html';
|
||||
import { spawnAsync } from 'playwright-core/lib/utils/utils';
|
||||
|
||||
const test = baseTest.extend<{ showReport: () => Promise<void> }>({
|
||||
showReport: async ({ page }, use, testInfo) => {
|
||||
|
@ -65,6 +66,8 @@ test('should generate report', async ({ runInlineTest, showReport, page }) => {
|
|||
await expect(page.locator('.test-file-test-outcome-flaky >> text=flaky')).toBeVisible();
|
||||
await expect(page.locator('.test-file-test-outcome-expected >> text=passes')).toBeVisible();
|
||||
await expect(page.locator('.test-file-test-outcome-skipped >> text=skipped')).toBeVisible();
|
||||
|
||||
await expect(page.locator('.metadata-view')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should not throw when attachment is missing', async ({ runInlineTest, page, showReport }, testInfo) => {
|
||||
|
@ -624,3 +627,64 @@ test('open tests from required file', async ({ runInlineTest, showReport, page }
|
|||
/expect\.toBe/,
|
||||
]);
|
||||
});
|
||||
|
||||
test('should include metadata', async ({ runInlineTest, showReport, page }) => {
|
||||
const beforeRunPlaywrightTest = async ({ baseDir }: { baseDir: string }) => {
|
||||
const execGit = async (args: string[]) => {
|
||||
const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir });
|
||||
if (!!code)
|
||||
throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`);
|
||||
return;
|
||||
};
|
||||
|
||||
await execGit(['init']);
|
||||
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']);
|
||||
await execGit(['config', '--local', 'user.name', 'William']);
|
||||
await execGit(['add', '*.ts']);
|
||||
await execGit(['commit', '-m', 'awesome commit message']);
|
||||
};
|
||||
|
||||
const result = await runInlineTest({
|
||||
'uncommitted.txt': `uncommitted file`,
|
||||
'globalSetup.ts': `
|
||||
import * as ci from '@playwright/test/lib/ci';
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
config.attachments = [
|
||||
...await ci.generationTimestamp(),
|
||||
...await ci.gitStatusFromCLI(config.rootDir).catch(() => []),
|
||||
...await ci.githubEnv(),
|
||||
];
|
||||
};
|
||||
|
||||
export default globalSetup;
|
||||
`,
|
||||
'playwright.config.ts': `
|
||||
import path from 'path';
|
||||
const config = {
|
||||
globalSetup: path.join(__dirname, './globalSetup'),
|
||||
}
|
||||
|
||||
export default config;
|
||||
`,
|
||||
'example.spec.ts': `
|
||||
const { test } = pwt;
|
||||
test('sample', async ({}) => { expect(2).toBe(2); });
|
||||
`,
|
||||
}, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_OPEN: 'never', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_RUN_ID: 'example-run-id', GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SHA: 'example-sha' }, undefined, beforeRunPlaywrightTest);
|
||||
|
||||
await showReport();
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const metadata = page.locator('.metadata-view');
|
||||
await expect.soft(metadata.locator('data-test-id=revision.id')).toContainText(/^[a-f\d]{7}$/i);
|
||||
await expect.soft(metadata.locator('data-test-id=revision.id >> a')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha');
|
||||
await expect.soft(metadata.locator('data-test-id=revision.timestamp')).toContainText(/AM|PM/);
|
||||
await expect.soft(metadata).toContainText('awesome commit message');
|
||||
await expect.soft(metadata).toContainText('William');
|
||||
await expect.soft(metadata).toContainText('shakespeare@example.local');
|
||||
await expect.soft(metadata.locator('text=CI/CD Logs')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/actions/runs/example-run-id');
|
||||
await expect.soft(metadata).toContainText('uncommitted changes');
|
||||
await expect.soft(metadata.locator('text=Report generated on')).toContainText(/AM|PM/);
|
||||
});
|
||||
|
|
|
@ -211,6 +211,7 @@ export interface FullConfig<TestArgs = {}, WorkerArgs = {}> {
|
|||
updateSnapshots: UpdateSnapshots;
|
||||
workers: number;
|
||||
webServer: WebServerConfig | null;
|
||||
attachments: { name: string, path?: string, body?: Buffer, contentType: string }[];
|
||||
}
|
||||
|
||||
export type TestStatus = 'passed' | 'failed' | 'timedOut' | 'skipped';
|
||||
|
|
Загрузка…
Ссылка в новой задаче