blob: fe3f06ba374120439a5e69a7029ee2d47bbc9314 [file] [log] [blame]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {createHash} from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {GEN_DIR, SOURCE_ROOT} from './paths.js';
export type ArtifactGroup = Record<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;
static errors: ScreenshotError[] = [];
readonly screenshots: ArtifactGroup;
screenshotPath?: string;
private constructor(screenshots: ArtifactGroup, message?: string, cause?: Error, screenshotPath?: string) {
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;
// To show Diffs mocha Spec reporter expects some properties.
// See node_modules/mocha/lib/reporters/base.js.
// @ts-expect-error forwarding error properties for Mocha.
this.showDiff = cause?.showDiff;
// @ts-expect-error forwarding error properties for Mocha.
this.actual = cause?.actual;
// @ts-expect-error forwarding error properties for Mocha.
this.expected = cause?.expected;
// @ts-expect-error forwarding error properties for Mocha.
this.operator = cause?.operator;
this.screenshotPath = screenshotPath;
}
/**
* Creates a ScreenshotError when a reference golden does not exists.
*/
static fromGeneratedScreenshot(message: string, generatedImgPath: string) {
const screenshots = {
generated: {filePath: this.stashArtifact(generatedImgPath, 'generated')},
};
const screenshotPath =
path.join('test/goldens', path.relative(path.join(GEN_DIR, 'test/.generated'), generatedImgPath));
return new ScreenshotError(screenshots, message, undefined, screenshotPath);
}
/**
* Creates a ScreenshotError when a generated screenshot is different from
* the golden.
*/
static fromScreenshotAssertionError(
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')},
};
const screenshotPath = path.relative(SOURCE_ROOT, goldenImgPath);
return new ScreenshotError(screenshots, undefined, error, screenshotPath);
}
/**
* Creates a ScreenshotError when an unexpected error occurs. Screenshots are
* taken for both the inspected page and the DevTools page.
*/
static fromBase64Images(
error: Error, inspectedPageScreenshot?: string, devToolsPageScreenshot?: string,
collectedScreenshots?: Record<string, string>): Error {
if (!inspectedPageScreenshot || !devToolsPageScreenshot) {
console.error('No artifacts to save.');
return error;
}
const screenshots: ArtifactGroup = {
inspectedPage: {filePath: this.saveArtifact(inspectedPageScreenshot)},
devToolsPage: {filePath: this.saveArtifact(devToolsPageScreenshot)},
...ScreenshotError.saveArtifacts(collectedScreenshots)
};
return new ScreenshotError(screenshots, undefined, error);
}
static saveArtifacts(collectedScreenshots: Record<string, string>|undefined) {
const screenshots: ArtifactGroup = {};
for (const name in collectedScreenshots) {
screenshots[name] = {filePath: this.saveArtifact(collectedScreenshots[name])};
}
return screenshots;
}
/**
* Costructs a summary for Milo at resultdb publication time.
*/
toMiloSummary(): 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 inspected and DevTools pages screenshots below.</p>';
}
return 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;
}
}
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const ScreenshotErrorReporter = function(this: any, baseReporterDecorator: (arg0: unknown) => void) {
const JSON_PATH = path.join(GEN_DIR, 'test', '.generated', 'errors.js');
baseReporterDecorator(this);
this.onRunComplete = () => {
const screenshotErrors = ScreenshotError.errors.splice(0).map(
/* eslint-disable-next-line @typescript-eslint/naming-convention */
({screenshotPath, screenshots: {expected_image, actual_image, image_diff}}) =>
({screenshotPath, expected_image, actual_image, image_diff}));
fs.writeFileSync(JSON_PATH, `window.SCREENSHOT_ERRORS = ${JSON.stringify(screenshotErrors)}`);
};
};
ScreenshotErrorReporter.$inject = ['baseReporterDecorator'];