blob: 0e947c1a01a925c113a760b56e5ba1cd034c0fe8 [file]
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {createHash} from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
type ArtifactGroup = {
[key: string]: {
filePath: string,
},
};
export class ScreenshotError extends Error {
// The max length of the summary is 4000, but we need to leave some room for
// the rest of the HTML formatting (e.g. <pre> and </pre>).
static readonly SUMMARY_LENGTH_CUTOFF = 3900;
readonly screenshots: ArtifactGroup;
private constructor(screenshots: ArtifactGroup, message?: string, cause?: Error) {
message = [message, cause?.message, (cause?.cause as Error)?.message].filter(x => x).join('\n\n');
super(message);
this.cause = cause;
this.stack = cause?.stack ?? '';
this.screenshots = screenshots;
}
/**
* Creates a ScreenshotError when a reference golden does not exists.
*/
static fromMessage(message: string, generatedImgPath: string) {
const screenshots = {
'generated': {filePath: this.stashArtifact(generatedImgPath, 'generated')},
};
return new ScreenshotError(screenshots, message, undefined);
}
/**
* Creates a ScreenshotError when a generated screenshot is different from
* the golden.
*/
static fromError(error: Error, goldenImgPath: string, generatedImgPath: string, diffImgPath: string) {
const screenshots = {
'expected_image': {filePath: this.stashArtifact(goldenImgPath, 'expected')},
'actual_image': {filePath: this.stashArtifact(generatedImgPath, 'actual')},
'image_diff': {filePath: this.stashArtifact(diffImgPath, 'diff')},
};
return new ScreenshotError(screenshots, undefined, error);
}
/**
* Creates a ScreenshotError an unexpected error occurs. Screenshots are
* were taken for both the target and the frontend.
*/
static fromBase64Images(error: unknown, targetScreenshot?: string, frontendScreenshot?: string) {
if (!targetScreenshot || !frontendScreenshot) {
console.error('No artifacts to save.');
return error;
}
const screenshots = {
'target': {filePath: this.saveArtifact(targetScreenshot)},
'frontend': {filePath: this.saveArtifact(frontendScreenshot)},
};
return new ScreenshotError(screenshots, undefined, error as Error);
}
/**
* Costructs artifact group and summary for Milo
* at resultdb publication time.
*/
toMiloArtifacts(): [ArtifactGroup, string] {
let summary: string;
if ('expected_image' in this.screenshots) {
// no summary; autogenerated by Milo based on artifact name convention
summary = '';
} else if ('generated' in this.screenshots) {
// TODO(liviurau): embed image once Milo supports it
summary = '<pre>' + this.message.slice(0, ScreenshotError.SUMMARY_LENGTH_CUTOFF) +
'</pre><p>Screenshot generated (see below)</p>';
} else {
// TODO(liviurau): embed images once Milo supports it
const message = (this.message + '\n\n' + this.stack).slice(0, ScreenshotError.SUMMARY_LENGTH_CUTOFF);
summary = '<pre>' + message + '</pre><p>Unexpected error. See target and frontend screenshots ' +
'below.</p>';
}
return [this.screenshots, summary];
}
/**
* Copy artifacts in tmp folder so they remain available
* at resultdb publication time.
*/
private static stashArtifact(originalFile: string, tag: string): string {
const stashedFileName = tag + '-' + path.basename(originalFile);
const artifactPath = path.join(os.tmpdir(), stashedFileName);
fs.copyFileSync(originalFile, artifactPath);
return artifactPath;
}
/**
* Save base64 image in tmp folder to make it available at resultdb
* publication time.
*/
private static saveArtifact(base64Image: string): string {
base64Image = base64Image.replace(/^data:image\/png;base64,/, '');
const fileName = createHash('sha256').update(base64Image).digest('hex');
const artifactPath = path.join(os.tmpdir(), fileName);
fs.writeFileSync(artifactPath, base64Image, {encoding: 'base64'});
return artifactPath;
}
}