mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(html): bake report zip into the html report, allow opening from fs (#9939)
This commit is contained in:
parent
4e52b64619
commit
9ac8829583
@ -0,0 +1,66 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
class BundleJsPlugin {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.compilation.tap('bundle-js-plugin', compilation => {
|
||||
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync('bundle-js-plugin', (htmlPluginData, callback) => {
|
||||
callback(null, this.processTags(compilation, htmlPluginData));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
processTags(compilation, pluginData) {
|
||||
const headTags = pluginData.headTags.map(tag => this.processTag(compilation, tag));
|
||||
const bodyTags = pluginData.bodyTags.map(tag => this.processTag(compilation, tag));
|
||||
return { ...pluginData, headTags, bodyTags };
|
||||
}
|
||||
|
||||
processTag(compilation, tag) {
|
||||
if (tag.tagName !== 'script' || !tag.attributes.src)
|
||||
return tag;
|
||||
|
||||
const asset = getAssetByName(compilation.assets, tag.attributes.src);
|
||||
const innerHTML = asset.source().replace(/(<)(\/script>)/g, '\\x3C$2');
|
||||
return {
|
||||
tagName: 'script',
|
||||
attributes: {
|
||||
type: 'text/javascript'
|
||||
},
|
||||
closeTag: true,
|
||||
innerHTML,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getAssetByName (assets, assetName) {
|
||||
for (var key in assets) {
|
||||
if (assets.hasOwnProperty(key)) {
|
||||
var processedKey = path.posix.relative('', key);
|
||||
if (processedKey === assetName) {
|
||||
return assets[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BundleJsPlugin;
|
||||
@ -449,24 +449,6 @@ a.no-decorations {
|
||||
}
|
||||
}
|
||||
|
||||
.needs-server-message {
|
||||
max-width: 500px;
|
||||
margin: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bash-snippet {
|
||||
margin-top: 10px;
|
||||
font-family: monospace;
|
||||
background: var(--color-fg-default);
|
||||
color: var(--color-canvas-default);
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.d-flex {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
@ -20,12 +20,22 @@ import ansi2html from 'ansi-to-html';
|
||||
import { downArrow, rightArrow, TreeItem } from '../components/treeItem';
|
||||
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
|
||||
import { msToString } from '../uiUtils';
|
||||
import { traceImage } from './images';
|
||||
import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary, TestCaseSummary } from '@playwright/test/src/reporters/html';
|
||||
import type zip from '@zip.js/zip.js';
|
||||
|
||||
const zipjs = (self as any).zip;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
playwrightReportBase64?: string;
|
||||
entries: Map<string, zip.Entry>;
|
||||
}
|
||||
}
|
||||
|
||||
export const Report: React.FC = () => {
|
||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
|
||||
const [fetchError, setFetchError] = React.useState<string | undefined>();
|
||||
const [report, setReport] = React.useState<HTMLReport | undefined>();
|
||||
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
|
||||
const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
|
||||
@ -34,12 +44,11 @@ export const Report: React.FC = () => {
|
||||
if (report)
|
||||
return;
|
||||
(async () => {
|
||||
try {
|
||||
const report = await fetch('data/report.json', { cache: 'no-cache' }).then(r => r.json() as Promise<HTMLReport>);
|
||||
setReport(report);
|
||||
} catch (e) {
|
||||
setFetchError(e.message);
|
||||
}
|
||||
const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader(window.playwrightReportBase64), { useWebWorkers: false }) as zip.ZipReader;
|
||||
window.entries = new Map<string, zip.Entry>();
|
||||
for (const entry of await zipReader.getEntries())
|
||||
window.entries.set(entry.filename, entry);
|
||||
setReport(await readJsonEntry('report.json') as HTMLReport);
|
||||
window.addEventListener('popstate', () => {
|
||||
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||
setFilterText(params.get('q') || '');
|
||||
@ -49,16 +58,8 @@ export const Report: React.FC = () => {
|
||||
|
||||
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
|
||||
|
||||
if (window.location.protocol === 'file:') {
|
||||
return <div className='needs-server-message'>
|
||||
Playwright report needs to be served as a web page. Consider the following options to view it locally:
|
||||
<div className='bash-snippet'>npx node-static playwright-report</div>
|
||||
<div className='bash-snippet'>cd playwright-report && python -m SimpleHTTPServer</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className='vbox columns'>
|
||||
{!fetchError && <div className='flow-container'>
|
||||
{<div className='flow-container'>
|
||||
<Route params=''>
|
||||
<AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} filterText={filterText} setFilterText={setFilterText}></AllTestFilesSummaryView>
|
||||
</Route>
|
||||
@ -176,8 +177,7 @@ const TestCaseView: React.FC<{
|
||||
const fileId = testId.split('-')[0];
|
||||
if (!fileId)
|
||||
return;
|
||||
const result = await fetch(`data/${fileId}.json`, { cache: 'no-cache' });
|
||||
const file = await result.json() as TestFile;
|
||||
const file = await readJsonEntry(`${fileId}.json`) as TestFile;
|
||||
for (const t of file.tests) {
|
||||
if (t.testId === testId) {
|
||||
setTest(t);
|
||||
@ -253,7 +253,7 @@ const TestResultView: React.FC<{
|
||||
{!!traces.length && <Chip header='Traces'>
|
||||
{traces.map((a, i) => <div key={`trace-${i}`}>
|
||||
<a href={`trace/index.html?trace=${new URL(a.path!, window.location.href)}`}>
|
||||
<img src='trace.png' style={{ width: 192, height: 117, marginLeft: 20 }} />
|
||||
<img src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
||||
</a>
|
||||
</div>)}
|
||||
</Chip>}
|
||||
@ -598,6 +598,13 @@ class Filter {
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonEntry(entryName: string): Promise<any> {
|
||||
const reportEntry = window.entries.get(entryName);
|
||||
const writer = new zipjs.TextWriter() as zip.TextWriter;
|
||||
await reportEntry!.getData!(writer);
|
||||
return JSON.parse(await writer.getData());
|
||||
}
|
||||
|
||||
type SearchValues = {
|
||||
text: string;
|
||||
project: string;
|
||||
|
||||
17
packages/playwright-core/src/web/htmlReport/images.ts
Normal file
17
packages/playwright-core/src/web/htmlReport/images.ts
Normal file
File diff suppressed because one or more lines are too long
@ -19,6 +19,6 @@ import * as ReactDOM from 'react-dom';
|
||||
import { Report } from './htmlReport';
|
||||
import './colors.css';
|
||||
|
||||
(async () => {
|
||||
window.onload = () => {
|
||||
ReactDOM.render(<Report />, document.querySelector('#root'));
|
||||
})();
|
||||
};
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
@ -1,12 +1,29 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const HtmlWebPackPlugin = require('html-webpack-plugin');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
const BundleJsPlugin = require('./bundleJsPlugin');
|
||||
|
||||
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
||||
|
||||
module.exports = {
|
||||
mode,
|
||||
entry: {
|
||||
zip: path.resolve(__dirname, '../../../../../node_modules/@zip.js/zip.js/dist/zip-no-worker-inflate.min.js'),
|
||||
app: path.join(__dirname, 'index.tsx'),
|
||||
},
|
||||
resolve: {
|
||||
@ -38,17 +55,11 @@ module.exports = {
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: path.resolve(__dirname, 'static'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
new HtmlWebPackPlugin({
|
||||
title: 'Playwright Test Report',
|
||||
template: path.join(__dirname, 'index.html'),
|
||||
inject: true,
|
||||
})
|
||||
}),
|
||||
new BundleJsPlugin(),
|
||||
]
|
||||
};
|
||||
|
||||
@ -18,11 +18,13 @@ import colors from 'colors/safe';
|
||||
import fs from 'fs';
|
||||
import open from 'open';
|
||||
import path from 'path';
|
||||
import { Transform, TransformCallback } from 'stream';
|
||||
import { FullConfig, Suite } 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, JsonAttachment } from './raw';
|
||||
import assert from 'assert';
|
||||
import yazl from 'yazl';
|
||||
|
||||
export type Stats = {
|
||||
total: number;
|
||||
@ -127,8 +129,8 @@ class HtmlReporter {
|
||||
});
|
||||
const reportFolder = htmlReportFolder(this._outputFolder);
|
||||
await removeFolders([reportFolder]);
|
||||
const builder = new HtmlBuilder(reportFolder, this.config.rootDir);
|
||||
const { ok, singleTestId } = builder.build(reports);
|
||||
const builder = new HtmlBuilder(reportFolder);
|
||||
const { ok, singleTestId } = await builder.build(reports);
|
||||
|
||||
if (process.env.PWTEST_SKIP_TEST_OUTPUT || process.env.CI)
|
||||
return;
|
||||
@ -198,16 +200,16 @@ class HtmlBuilder {
|
||||
private _reportFolder: string;
|
||||
private _tests = new Map<string, JsonTestCase>();
|
||||
private _testPath = new Map<string, string[]>();
|
||||
private _dataFolder: string;
|
||||
private _dataZipFile: yazl.ZipFile;
|
||||
private _hasTraces = false;
|
||||
|
||||
constructor(outputDir: string, rootDir: string) {
|
||||
constructor(outputDir: string) {
|
||||
this._reportFolder = path.resolve(process.cwd(), outputDir);
|
||||
this._dataFolder = path.join(this._reportFolder, 'data');
|
||||
fs.mkdirSync(this._reportFolder, { recursive: true });
|
||||
this._dataZipFile = new yazl.ZipFile();
|
||||
}
|
||||
|
||||
build(rawReports: JsonReport[]): { ok: boolean, singleTestId: string | undefined } {
|
||||
fs.mkdirSync(this._dataFolder, { recursive: true });
|
||||
async build(rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
|
||||
|
||||
const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
|
||||
for (const projectJson of rawReports) {
|
||||
@ -259,7 +261,7 @@ class HtmlBuilder {
|
||||
return t1.location.line - t2.location.line;
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(this._dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2));
|
||||
this._addDataFile(fileId + '.json', testFile);
|
||||
}
|
||||
const htmlReport: HTMLReport = {
|
||||
files: [...data.values()].map(e => e.testFileSummary),
|
||||
@ -272,15 +274,11 @@ class HtmlBuilder {
|
||||
return w2 - w1;
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(this._dataFolder, 'report.json'), JSON.stringify(htmlReport, undefined, 2));
|
||||
this._addDataFile('report.json', htmlReport);
|
||||
|
||||
// Copy app.
|
||||
const appFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'webpack', 'htmlReport');
|
||||
for (const file of fs.readdirSync(appFolder)) {
|
||||
if (file.endsWith('.map'))
|
||||
continue;
|
||||
fs.copyFileSync(path.join(appFolder, file), path.join(this._reportFolder, file));
|
||||
}
|
||||
fs.copyFileSync(path.join(appFolder, 'index.html'), path.join(this._reportFolder, 'index.html'));
|
||||
|
||||
// Copy trace viewer.
|
||||
if (this._hasTraces) {
|
||||
@ -294,14 +292,31 @@ class HtmlBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Inline report data.
|
||||
const indexFile = path.join(this._reportFolder, 'index.html');
|
||||
fs.appendFileSync(indexFile, '<script>\nwindow.playwrightReportBase64 = "data:application/zip;base64,');
|
||||
await new Promise(f => {
|
||||
this._dataZipFile!.end(undefined, () => {
|
||||
this._dataZipFile!.outputStream
|
||||
.pipe(new Base64Encoder())
|
||||
.pipe(fs.createWriteStream(indexFile, { flags: 'a' })).on('close', f);
|
||||
});
|
||||
});
|
||||
fs.appendFileSync(indexFile, '";</script>');
|
||||
|
||||
let singleTestId: string | undefined;
|
||||
if (htmlReport.stats.total === 1) {
|
||||
const testFile: TestFile = data.values().next().value.testFile;
|
||||
singleTestId = testFile.tests[0].testId;
|
||||
}
|
||||
|
||||
return { ok, singleTestId };
|
||||
}
|
||||
|
||||
private _addDataFile(fileName: string, data: any) {
|
||||
this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName);
|
||||
}
|
||||
|
||||
private _processJsonSuite(suite: JsonSuite, fileId: string, projectName: string, path: string[], out: TestEntry[]) {
|
||||
const newPath = [...path, suite.title];
|
||||
suite.suites.map(s => this._processJsonSuite(s, fileId, projectName, newPath, out));
|
||||
@ -358,6 +373,7 @@ class HtmlBuilder {
|
||||
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) {
|
||||
}
|
||||
@ -419,4 +435,30 @@ const addStats = (stats: Stats, delta: Stats): Stats => {
|
||||
return stats;
|
||||
};
|
||||
|
||||
class Base64Encoder extends Transform {
|
||||
private _remainder: Buffer | undefined;
|
||||
|
||||
override _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void {
|
||||
if (this._remainder) {
|
||||
chunk = Buffer.concat([this._remainder, chunk]);
|
||||
this._remainder = undefined;
|
||||
}
|
||||
|
||||
const remaining = chunk.length % 3;
|
||||
if (remaining) {
|
||||
this._remainder = chunk.slice(chunk.length - remaining);
|
||||
chunk = chunk.slice(0, chunk.length - remaining);
|
||||
}
|
||||
chunk = chunk.toString('base64');
|
||||
this.push(Buffer.from(chunk));
|
||||
callback();
|
||||
}
|
||||
|
||||
override _flush(callback: TransformCallback): void {
|
||||
if (this._remainder)
|
||||
this.push(Buffer.from(this._remainder.toString('base64')));
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
export default HtmlReporter;
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { test as baseTest, expect } from './playwright-test-fixtures';
|
||||
import { HttpServer } from 'playwright-core/lib/utils/httpServer';
|
||||
import { startHtmlReportServer } from '../../packages/playwright-test/lib/reporters/html';
|
||||
@ -34,7 +33,7 @@ const test = baseTest.extend<{ showReport: () => Promise<void> }>({
|
||||
|
||||
test.use({ channel: 'chrome' });
|
||||
|
||||
test('should generate report', async ({ runInlineTest }, testInfo) => {
|
||||
test('should generate report', async ({ runInlineTest, showReport, page }) => {
|
||||
await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
module.exports = { name: 'project-name' };
|
||||
@ -45,7 +44,7 @@ test('should generate report', async ({ runInlineTest }, testInfo) => {
|
||||
test('fails', async ({}) => {
|
||||
expect(1).toBe(2);
|
||||
});
|
||||
test('skip', async ({}) => {
|
||||
test('skipped', async ({}) => {
|
||||
test.skip('Does not work')
|
||||
});
|
||||
test('flaky', async ({}, testInfo) => {
|
||||
@ -53,87 +52,19 @@ test('should generate report', async ({ runInlineTest }, testInfo) => {
|
||||
});
|
||||
`,
|
||||
}, { reporter: 'dot,html', retries: 1 });
|
||||
const report = testInfo.outputPath('playwright-report', 'data', 'report.json');
|
||||
const reportObject = JSON.parse(fs.readFileSync(report, 'utf-8'));
|
||||
delete reportObject.testIdToFileId;
|
||||
delete reportObject.files[0].fileId;
|
||||
delete reportObject.files[0].stats.duration;
|
||||
delete reportObject.stats.duration;
|
||||
|
||||
const fileNames = new Set<string>();
|
||||
for (const test of reportObject.files[0].tests) {
|
||||
fileNames.add(testInfo.outputPath('playwright-report', 'data', test.fileId + '.json'));
|
||||
delete test.testId;
|
||||
delete test.fileId;
|
||||
delete test.location.line;
|
||||
delete test.location.column;
|
||||
delete test.duration;
|
||||
delete test.path;
|
||||
}
|
||||
expect(reportObject).toEqual({
|
||||
files: [
|
||||
{
|
||||
fileName: 'a.test.js',
|
||||
tests: [
|
||||
{
|
||||
title: 'fails',
|
||||
projectName: 'project-name',
|
||||
location: {
|
||||
file: 'a.test.js'
|
||||
},
|
||||
outcome: 'unexpected',
|
||||
ok: false
|
||||
},
|
||||
{
|
||||
title: 'flaky',
|
||||
projectName: 'project-name',
|
||||
location: {
|
||||
file: 'a.test.js'
|
||||
},
|
||||
outcome: 'flaky',
|
||||
ok: true
|
||||
},
|
||||
{
|
||||
title: 'passes',
|
||||
projectName: 'project-name',
|
||||
location: {
|
||||
file: 'a.test.js'
|
||||
},
|
||||
outcome: 'expected',
|
||||
ok: true
|
||||
},
|
||||
{
|
||||
title: 'skip',
|
||||
projectName: 'project-name',
|
||||
location: {
|
||||
file: 'a.test.js'
|
||||
},
|
||||
outcome: 'skipped',
|
||||
ok: false
|
||||
}
|
||||
],
|
||||
stats: {
|
||||
total: 4,
|
||||
expected: 1,
|
||||
unexpected: 1,
|
||||
flaky: 1,
|
||||
skipped: 1,
|
||||
ok: false,
|
||||
}
|
||||
}
|
||||
],
|
||||
projectNames: [
|
||||
'project-name'
|
||||
],
|
||||
stats: {
|
||||
expected: 1,
|
||||
flaky: 1,
|
||||
ok: false,
|
||||
skipped: 1,
|
||||
total: 4,
|
||||
unexpected: 1,
|
||||
}
|
||||
});
|
||||
await showReport();
|
||||
|
||||
await expect(page.locator('.subnav-item:has-text("All") .counter')).toHaveText('4');
|
||||
await expect(page.locator('.subnav-item:has-text("Passed") .counter')).toHaveText('1');
|
||||
await expect(page.locator('.subnav-item:has-text("Failed") .counter')).toHaveText('1');
|
||||
await expect(page.locator('.subnav-item:has-text("Flaky") .counter')).toHaveText('1');
|
||||
await expect(page.locator('.subnav-item:has-text("Skipped") .counter')).toHaveText('1');
|
||||
|
||||
await expect(page.locator('.test-summary.outcome-unexpected >> text=fails')).toBeVisible();
|
||||
await expect(page.locator('.test-summary.outcome-flaky >> text=flaky')).toBeVisible();
|
||||
await expect(page.locator('.test-summary.outcome-expected >> text=passes')).toBeVisible();
|
||||
await expect(page.locator('.test-summary.outcome-skipped >> text=skipped')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not throw when attachment is missing', async ({ runInlineTest }) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user