| // 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. |
| |
| /* eslint-disable no-console */ |
| // no-console disabled here as this is a test runner and expects to output to the console |
| |
| import {assert} from 'chai'; |
| import * as childProcess from 'node:child_process'; |
| import * as fs from 'node:fs'; |
| import * as path from 'node:path'; |
| import type * as puppeteer from 'puppeteer-core'; |
| |
| import {SOURCE_ROOT} from '../conductor/paths.js'; |
| import {platform} from '../conductor/platform.js'; |
| import {ScreenshotError} from '../conductor/screenshot-error.js'; |
| import {TestConfig} from '../conductor/test_config.js'; |
| import {getBrowserAndPages} from '../shared/helper.js'; |
| |
| /** |
| * The goldens screenshot folder is always taken from the source directory (NOT |
| * out/Target/...) because we commit these files to git. Therefore we use the |
| * flags from the test runner config to locate the source directory and read our |
| * goldens from there. |
| */ |
| const testRunnerCWD = SOURCE_ROOT; |
| const GOLDENS_FOLDER = path.join(testRunnerCWD, 'test', 'goldens', platform); |
| |
| /** |
| * It's assumed that the image_diff binaries are in CWD/third_party/image_diff/{platform}/image_diff |
| */ |
| const exeSuffix = platform.startsWith('win') ? '.exe' : ''; |
| const IMAGE_DIFF_BINARY = path.join(testRunnerCWD, 'third_party', 'image_diff', platform, 'image_diff' + exeSuffix); |
| if (!fs.existsSync(IMAGE_DIFF_BINARY)) { |
| throw new Error(`path to image_diff (${IMAGE_DIFF_BINARY}) did not exist.`); |
| } |
| |
| /** |
| * The generated screenshot path is relative, as we put the generated |
| * screenshots into the out/TARGET/... directory. |
| * |
| * If we find it exists ahead of a test run, we remove it, so that when we start |
| * a test run the folder is empty. This ensures no generated files left from |
| * previous runs interfere. |
| */ |
| const generatedScreenshotFolderParts = ['..', '.generated', platform]; |
| const generatedScreenshotFolder = path.join(__dirname, ...generatedScreenshotFolderParts); |
| if (fs.existsSync(generatedScreenshotFolder)) { |
| fs.rmSync(generatedScreenshotFolder, {recursive: true}); |
| } |
| fs.mkdirSync(generatedScreenshotFolder, {recursive: true}); |
| |
| const defaultScreenshotOpts: puppeteer.ScreenshotOptions = { |
| type: 'png', |
| encoding: 'binary', |
| captureBeyondViewport: false, |
| }; |
| |
| const DEFAULT_RETRIES_COUNT = 1; |
| const DEFAULT_MS_BETWEEN_RETRIES = 150; |
| |
| // Percentage difference when comparing golden vs new screenshot that is |
| // acceptable and will not fail the test. |
| const DEFAULT_SCREENSHOT_THRESHOLD_PERCENT = 0.1; |
| |
| export const assertElementScreenshotUnchanged = async ( |
| element: puppeteer.ElementHandle|null, |
| fileName: NonNullable<puppeteer.ScreenshotOptions['path']>, |
| options: Partial<puppeteer.ScreenshotOptions> = {}, |
| ) => { |
| assert.isOk(element, `Given element for test ${fileName} was not found.`); |
| // Only assert screenshots on Linux. We don't observe platform-specific differences enough to justify |
| // the costs of asserting 3 platforms per screenshot. |
| if (platform !== 'linux') { |
| // Extra new line to work with the progress-diff karma reporter that |
| // replaces the previous line. |
| console.warn('Screenshot assertions are only supported on Linux\n'); |
| return; |
| } |
| return await assertScreenshotUnchangedWithRetries( |
| element, fileName, DEFAULT_SCREENSHOT_THRESHOLD_PERCENT, DEFAULT_RETRIES_COUNT, options); |
| }; |
| |
| function getFrontend() { |
| // Outside e2e or interaction tests the frontend can be undefined. |
| try { |
| const {frontend} = getBrowserAndPages(); |
| return frontend; |
| } catch { |
| return; |
| } |
| } |
| |
| const assertScreenshotUnchangedWithRetries = async ( |
| elementOrPage: puppeteer.ElementHandle|puppeteer.Page, fileName: NonNullable<puppeteer.ScreenshotOptions['path']>, |
| maximumDiffThreshold: number, maximumRetries: number, options: Partial<puppeteer.ScreenshotOptions> = {}) => { |
| const frontend = getFrontend(); |
| try { |
| await frontend?.evaluate(() => window.dispatchEvent(new Event('hidecomponentdocsui'))); |
| /** |
| * You can call the helper with a path for the golden - e.g. |
| * accordion/basic.png. So we split on `/` and then join on path.sep to |
| * ensure we calculate the right path regardless of platform. |
| */ |
| const fileNameForPlatform = fileName.split('/').join(path.sep); |
| const goldenScreenshotPath = path.join(GOLDENS_FOLDER, fileNameForPlatform); |
| const generatedScreenshotPath = |
| path.join(generatedScreenshotFolder, fileNameForPlatform) as NonNullable<puppeteer.ScreenshotOptions['path']>; |
| |
| /** |
| * Ensure that the directories for the golden/generated file exist. We need |
| * this because if the user calls this function with `accordion/basic.png`, |
| * we need to make sure that the `accordion` folder exists. |
| */ |
| fs.mkdirSync(path.dirname(generatedScreenshotPath), {recursive: true}); |
| fs.mkdirSync(path.dirname(goldenScreenshotPath), {recursive: true}); |
| |
| await assertScreenshotUnchanged({ |
| elementOrPage, |
| generatedScreenshotPath, |
| goldenScreenshotPath, |
| screenshotOptions: options, |
| fileName, |
| maximumDiffThreshold, |
| maximumRetries, |
| }); |
| } finally { |
| await frontend?.evaluate(() => window.dispatchEvent(new Event('showcomponentdocsui'))); |
| } |
| }; |
| |
| interface ScreenshotAssertionOptions { |
| goldenScreenshotPath: string; |
| generatedScreenshotPath: NonNullable<puppeteer.ScreenshotOptions['path']>; |
| screenshotOptions: Partial<puppeteer.ScreenshotOptions>; |
| elementOrPage: puppeteer.ElementHandle|puppeteer.Page; |
| fileName: string; |
| maximumDiffThreshold: number; |
| maximumRetries: number; |
| retryCount?: number; |
| } |
| |
| const assertScreenshotUnchanged = async (options: ScreenshotAssertionOptions) => { |
| const { |
| elementOrPage, |
| generatedScreenshotPath, |
| goldenScreenshotPath, |
| fileName, |
| maximumDiffThreshold, |
| maximumRetries, |
| retryCount = 1, |
| } = options; |
| const screenshotOptions = {...defaultScreenshotOpts, ...options.screenshotOptions, path: generatedScreenshotPath}; |
| await elementOrPage.screenshot(screenshotOptions); |
| |
| /** |
| * The user can do UPDATE_GOLDEN=accordion/basic.png npm run screenshotstest |
| * to update the golden image. This is useful if work has caused the |
| * screenshot to change and therefore the test goldens need to be updated. |
| */ |
| const shouldUpdate = |
| TestConfig.onDiff.update && (TestConfig.onDiff.update === true || TestConfig.onDiff.update.includes(fileName)); |
| const throwAfterGoldensUpdate = TestConfig.onDiff.throw; |
| |
| let onBotAndImageNotFound = false; |
| |
| // In the event that a golden does not exist, assume the generated screenshot is the new golden. |
| if (!fs.existsSync(goldenScreenshotPath)) { |
| // LUCI_CONTEXT is an environment variable present on the bots. |
| if (process.env.LUCI_CONTEXT !== undefined && !shouldUpdate) { |
| // If the image is missing, there's no point retrying the test N more times. |
| onBotAndImageNotFound = true; |
| throw ScreenshotError.fromGeneratedScreenshot( |
| `Failing test: in an environment with LUCI_CONTEXT and did not find a golden screenshot. |
| |
| Here's the image that this test generated as a base64: |
| |
| data:image/png;base64,${fs.readFileSync(generatedScreenshotPath, { |
| encoding: 'base64', |
| })} |
| `, |
| generatedScreenshotPath); |
| } |
| |
| console.log('Golden does not exist, using generated screenshot.'); |
| setGeneratedFileAsGolden(goldenScreenshotPath, generatedScreenshotPath); |
| if (throwAfterGoldensUpdate) { |
| throw ScreenshotError.fromGeneratedScreenshot( |
| 'Golden does not exist, using generated screenshot.', generatedScreenshotPath); |
| } |
| } |
| |
| try { |
| await compare(goldenScreenshotPath, generatedScreenshotPath, maximumDiffThreshold, shouldUpdate); |
| } catch (compareError) { |
| if (!onBotAndImageNotFound) { |
| console.log(`=> Test failed. Retrying (retry ${retryCount} of ${maximumRetries} maximum).`); |
| } |
| |
| if (retryCount === maximumRetries || onBotAndImageNotFound) { |
| if (shouldUpdate) { |
| console.log(`=> ${fileName} was out of date and failed; updating`); |
| setGeneratedFileAsGolden(goldenScreenshotPath, generatedScreenshotPath); |
| if (throwAfterGoldensUpdate) { |
| throw compareError; |
| } |
| return; |
| } |
| // If we don't want to update, throw the assertion error so we fail the test. |
| throw compareError; |
| } |
| |
| // Wait a little bit before trying again |
| await new Promise(resolve => setTimeout(resolve, DEFAULT_MS_BETWEEN_RETRIES)); |
| |
| await assertScreenshotUnchanged({ |
| elementOrPage, |
| generatedScreenshotPath, |
| goldenScreenshotPath, |
| fileName, |
| maximumDiffThreshold, |
| maximumRetries, |
| retryCount: retryCount + 1, |
| screenshotOptions: options.screenshotOptions, |
| }); |
| } |
| }; |
| |
| interface ImageDiff { |
| rawMisMatchPercentage: number; |
| diffPath: string; |
| } |
| |
| async function imageDiff(golden: string, generated: string) { |
| return await new Promise<ImageDiff>(async (resolve, reject) => { |
| try { |
| const imageDiff: ImageDiff = {rawMisMatchPercentage: 0, diffPath: ''}; |
| const diffText = await execImageDiffCommand(`${IMAGE_DIFF_BINARY} --histogram ${golden} ${generated}`); |
| |
| // Parse out the number from the cmd output, i.e. diff: 48.9% failed => 48.9 |
| imageDiff.rawMisMatchPercentage = Number(diffText.replace(/^diff:\s/, '').replace(/%.*/, '')); |
| |
| if (Number.isNaN(imageDiff.rawMisMatchPercentage)) { |
| reject('Unable to compare images'); |
| } |
| |
| // Only create a diff image if the images are different. |
| if (imageDiff.rawMisMatchPercentage > 0) { |
| imageDiff.diffPath = path.join(path.dirname(generated), `${path.basename(generated, '.png')}-diff.png`); |
| await execImageDiffCommand(`${IMAGE_DIFF_BINARY} --diff ${golden} ${generated} ${imageDiff.diffPath}`); |
| } |
| |
| resolve(imageDiff); |
| } catch (e) { |
| reject(new Error(`Error when running image_diff: ${e.stack}`)); |
| } |
| }); |
| } |
| |
| async function execImageDiffCommand(cmd: string) { |
| return await new Promise<string>((resolve, reject) => { |
| let commandOutput = ''; |
| try { |
| commandOutput = childProcess.execSync(cmd, {encoding: 'utf8'}); |
| resolve(commandOutput); |
| } catch (e) { |
| // image_diff will exit with a status code of 1 if the diff is too big, so |
| // this needs to be caught, but the outcome is the same - we want to send |
| // back the string for processing. |
| if (e.stdout && e.stdout.indexOf('diff') === -1) { |
| reject(new Error(`Comparing diff failed. stdout: "${e.stdout}"`)); |
| return; |
| } |
| |
| resolve(e.stdout); |
| } |
| }); |
| } |
| |
| async function compare(golden: string, generated: string, maximumDiffThreshold: number, isInDiffUpdateMode: boolean) { |
| const isOnBot = process.env.LUCI_CONTEXT !== undefined; |
| if (!isOnBot && process.env.SKIP_SCREENSHOT_COMPARISONS_FOR_FAST_COVERAGE) { |
| // When checking test coverage locally the tests get sped up significantly |
| // if we do not do the actual image comparison. Obviously this makes the |
| // tests all pass, but it is useful to quickly get coverage stats. |
| // Therefore you can pass this flag to skip all screenshot comparisions. We |
| // make sure this is only possible if not on a CQ bot and 99.9% of the time |
| // this should not be used! |
| return; |
| } |
| |
| const {rawMisMatchPercentage, diffPath} = await imageDiff(golden, generated); |
| |
| const base64TestGeneratedImageLog = `Here's the image the test generated as a base64: |
| data:image/png;base64,${fs.readFileSync(generated, { |
| encoding: 'base64', |
| })}`; |
| |
| const base64DiffImageLog = `And here's the diff image as base64:\n |
| data:image/png;base64,${ |
| diffPath ? fs.readFileSync(diffPath, { |
| encoding: 'base64', |
| }) : |
| ''}`; |
| |
| let debugInfo = ''; |
| if (isOnBot) { |
| debugInfo = `${base64TestGeneratedImageLog}\n${base64DiffImageLog}\n`; |
| } else if (!isInDiffUpdateMode) { |
| debugInfo = `Run the tests again with --on-diff=update to update all tests that fail. |
| Only do this if you expected this screenshot to have changed! |
| |
| Diff image generated at: |
| => ${path.relative(testRunnerCWD, diffPath)}\n`; |
| } |
| |
| try { |
| assert.isAtMost( |
| rawMisMatchPercentage, maximumDiffThreshold, |
| `There is a ${rawMisMatchPercentage}% difference between the golden and generated image. |
| |
| ${debugInfo}`); |
| if (rawMisMatchPercentage > 0) { |
| console.log(`test passed with difference of ${rawMisMatchPercentage}%`); |
| } |
| |
| } catch (assertionError) { |
| throw ScreenshotError.fromScreenshotAssertionError(assertionError, golden, generated, diffPath); |
| } |
| } |
| |
| function setGeneratedFileAsGolden(golden: string, generated: string) { |
| console.log(`Setting generated file to golden: |
| ${path.relative(testRunnerCWD, generated)} |
| => ${path.relative(testRunnerCWD, golden)} |
| `); |
| try { |
| fs.copyFileSync(generated, golden); |
| } catch (e) { |
| assert.fail(`Error setting golden, ${e}`); |
| } |
| } |