Skip to content

Commit

Permalink
Add experimental screencasts capturing for failing tests
Browse files Browse the repository at this point in the history
  • Loading branch information
kevin940726 committed Jul 16, 2021
1 parent b66e4c5 commit e2af388
Show file tree
Hide file tree
Showing 3 changed files with 219 additions and 8 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/end2end-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ jobs:
- name: Archive debug artifacts (screenshots, HTML snapshots)
uses: actions/upload-artifact@e448a9b857ee2131e752b06002bf0e093c65e571 # v2.2.2
if: always()
if: failure()
with:
name: failures-artifacts
path: artifacts
if-no-files-found: ignore
61 changes: 54 additions & 7 deletions packages/scripts/config/jest-environment-puppeteer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const chalk = require( 'chalk' );
* Internal dependencies
*/
const { readConfig, getPuppeteer } = require( './config' );
const { startScreencast } = require( './screencast' );

const handleError = ( error ) => {
// To match the same behavior in jest-jasmine2:
Expand All @@ -44,6 +45,8 @@ const KEYS = {

const root = process.env.GITHUB_WORKSPACE || process.cwd();
const ARTIFACTS_PATH = path.join( root, 'artifacts' );
const CAPTURE_SCREENCASTS = process.env.CAPTURE_SCREENCASTS !== 'false';
const screencasts = new Map();

class PuppeteerEnvironment extends NodeEnvironment {
// Jest is not available here, so we have to reverse engineer
Expand Down Expand Up @@ -199,11 +202,7 @@ class PuppeteerEnvironment extends NodeEnvironment {
}
}

async storeArtifacts( testName ) {
const datetime = new Date().toISOString().split( '.' )[ 0 ];
const fileName = filenamify( `${ testName } ${ datetime }`, {
replacement: '-',
} );
async storeArtifacts( fileName ) {
await writeFile(
path.join( ARTIFACTS_PATH, `${ fileName }-snapshot.html` ),
await this.global.page.content()
Expand All @@ -214,11 +213,59 @@ class PuppeteerEnvironment extends NodeEnvironment {
}

async handleTestEvent( event, state ) {
if ( event.name === 'test_fn_failure' ) {
if ( state.currentlyRunningTest ) {
const testName = state.currentlyRunningTest.name;
await this.storeArtifacts( testName );
const fileName = getFileName( testName );

if ( CAPTURE_SCREENCASTS && event.name === 'test_fn_start' ) {
screencasts.set(
testName,
await startScreencast( {
page: this.global.page,
browser: this.global.browser,
downloadPath: ARTIFACTS_PATH,
fileName,
} ).catch( ( err ) => {
// Ignore error to prevent it from failing the test,
// instead just log it for debugging.
// eslint-disable-next-line no-console
console.error( err );
} )
);
}

if (
CAPTURE_SCREENCASTS &&
( event.name === 'test_fn_success' ||
event.name === 'test_fn_failure' ) &&
screencasts.has( testName )
) {
const stopScreencast = screencasts.get( testName );
screencasts.delete( testName );
await stopScreencast( {
// Only save the screencast if the test failed.
save: event.name === 'test_fn_failure',
} ).catch( ( err ) => {
// Ignore error to prevent it from failing the test,
// instead just log it for debugging.
// eslint-disable-next-line no-console
console.error( err );
} );
}

if ( event.name === 'test_fn_failure' ) {
await this.storeArtifacts( fileName );
}
}
}
}

function getFileName( testName ) {
const datetime = new Date().toISOString().split( '.' )[ 0 ];
const fileName = filenamify( `${ testName } ${ datetime }`, {
replacement: '-',
} );
return fileName;
}

module.exports = PuppeteerEnvironment;
163 changes: 163 additions & 0 deletions packages/scripts/config/jest-environment-puppeteer/screencast.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Parts of this source were derived and modified from browserless/chrome,
* released under the GPL-3.0-or-later license.
*
* https://github.com/browserless/chrome/blob/master/src/apis/screencast.ts
*/
const path = require( 'path' );
const fs = require( 'fs/promises' );

function getScreencastAPI( downloadName ) {
class ScreencastAPI {
constructor() {
this.canvas = document.createElement( 'canvas' );
this.downloadAnchor = document.createElement( 'a' );

document.body.appendChild( this.canvas );
document.body.appendChild( this.downloadAnchor );

this.ctx = this.canvas.getContext( '2d' );
this.downloadAnchor.href = '#';
this.downloadAnchor.textContent = 'Download video';
this.downloadAnchor.id = 'download';
this.chunks = [];
}

async beginRecording( stream ) {
return new Promise( ( resolve, reject ) => {
this.recorder = new window.MediaRecorder( stream, {
mimeType: 'video/webm',
} );
this.recorder.ondataavailable = ( event ) =>
this.chunks.push( event.data );
this.recorder.onerror = reject;
this.recorder.onstop = resolve;
this.recorder.start();
} );
}

async download() {
await this.recordingFinish;
const blob = new window.Blob( this.chunks, {
type: 'video/webm',
} );

this.downloadAnchor.onclick = () => {
this.downloadAnchor.href = URL.createObjectURL( blob );
this.downloadAnchor.download = downloadName;
};

this.downloadAnchor.click();
}

async start( { width, height } ) {
this.canvas.width = width;
this.canvas.height = height;
this.recordingFinish = this.beginRecording(
this.canvas.captureStream()
);
}

async draw( pngData ) {
const data = await window
.fetch( `data:image/png;base64,${ pngData }` )
.then( ( res ) => res.blob() )
.then( ( blob ) => window.createImageBitmap( blob ) );

this.ctx.clearRect( 0, 0, this.canvas.width, this.canvas.height );
this.ctx.drawImage( data, 0, 0 );

return this;
}

stop() {
this.recorder.stop();
return this;
}
}

return new ScreencastAPI();
}

async function startScreencast( { page, browser, downloadPath, fileName } ) {
const client = await page.target().createCDPSession();
const renderer = await browser.newPage();
const rendererClient = await renderer.target().createCDPSession();
const downloadName = fileName + '.webm';
const fullDownloadPath = path.join( downloadPath, downloadName );

await rendererClient.send( 'Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath,
} );

const viewport = page.viewport();
const screencastAPI = await renderer.evaluateHandle(
getScreencastAPI,
downloadName
);
await page.bringToFront();

await renderer.evaluate(
( _screencastAPI, width, height ) =>
_screencastAPI.start( { width, height } ),
screencastAPI,
viewport.width,
viewport.height
);

await client.send( 'Page.startScreencast', {
format: 'png',
maxWidth: viewport.width,
maxHeight: viewport.height,
everyNthFrame: 1,
} );

async function onScreencastFrame( { data, sessionId } ) {
renderer
.evaluate(
( _screencastAPI, _data ) => _screencastAPI.draw( _data ),
screencastAPI,
data
)
.catch( () => {} );
client
.send( 'Page.screencastFrameAck', { sessionId } )
.catch( () => {} );
}

client.on( 'Page.screencastFrame', onScreencastFrame );

return async function stopScreencast( { save = false } = {} ) {
await client.send( 'Page.stopScreencast' );
client.off( 'Page.screencastFrame', onScreencastFrame );
await renderer.bringToFront();
await renderer.evaluate(
async ( _screencastAPI, _save ) => {
_screencastAPI.stop();
if ( _save ) {
await _screencastAPI.download();
}
},
screencastAPI,
save
);
if ( save ) {
await waitUntilFileDownloaded( fullDownloadPath );
}
await renderer.close();
};
}

async function waitUntilFileDownloaded( filePath ) {
while ( true ) {
try {
await fs.access( filePath );
break;
} catch {}

await new Promise( ( resolve ) => setTimeout( resolve, 500 ) );
}
}

module.exports = { startScreencast };

0 comments on commit e2af388

Please sign in to comment.