feat(html): bake report zip into the html report, allow opening from fs (#9939)

This commit is contained in:
Pavel Feldman 2021-11-01 15:14:52 -08:00 committed by GitHub
parent 4e52b64619
commit 9ac8829583
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 201 additions and 145 deletions

View File

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

View File

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

View File

@ -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 &amp;&amp; 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;

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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