"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.toHaveScreenshot = toHaveScreenshot; exports.toHaveScreenshotStepTitle = toHaveScreenshotStepTitle; exports.toMatchSnapshot = toMatchSnapshot; var _fs = _interopRequireDefault(require("fs")); var _path = _interopRequireDefault(require("path")); var _utils = require("playwright-core/lib/utils"); var _utilsBundle = require("playwright-core/lib/utilsBundle"); var _util = require("../util"); var _matcherHint = require("./matcherHint"); var _globals = require("../common/globals"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } /** * Copyright Microsoft Corporation. All rights reserved. * * 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 snapshotNamesSymbol = Symbol('snapshotNames'); // Keep in sync with above (begin). const NonConfigProperties = ['clip', 'fullPage', 'mask', 'maskColor', 'omitBackground', 'timeout']; // Keep in sync with above (end). class SnapshotHelper { constructor(testInfo, matcherName, locator, anonymousSnapshotExtension, configOptions, nameOrOptions, optOptions) { var _mime$getType; this.testInfo = void 0; this.attachmentBaseName = void 0; this.legacyExpectedPath = void 0; this.previousPath = void 0; this.expectedPath = void 0; this.actualPath = void 0; this.diffPath = void 0; this.mimeType = void 0; this.kind = void 0; this.updateSnapshots = void 0; this.comparator = void 0; this.options = void 0; this.matcherName = void 0; this.locator = void 0; let name; if (Array.isArray(nameOrOptions) || typeof nameOrOptions === 'string') { name = nameOrOptions; this.options = { ...optOptions }; } else { const { name: nameFromOptions, ...options } = nameOrOptions; this.options = options; name = nameFromOptions; } let snapshotNames = testInfo[snapshotNamesSymbol]; if (!testInfo[snapshotNamesSymbol]) { snapshotNames = { anonymousSnapshotIndex: 0, namedSnapshotIndex: {} }; testInfo[snapshotNamesSymbol] = snapshotNames; } let expectedPathSegments; let outputBasePath; if (!name) { // Consider the use case below. We should save actual to different paths. // Therefore we auto-increment |anonymousSnapshotIndex|. // // expect.toMatchSnapshot('a.png') // // noop // expect.toMatchSnapshot('a.png') const fullTitleWithoutSpec = [...testInfo.titlePath.slice(1), ++snapshotNames.anonymousSnapshotIndex].join(' '); // Note: expected path must not ever change for backwards compatibility. expectedPathSegments = [(0, _utils.sanitizeForFilePath)((0, _util.trimLongString)(fullTitleWithoutSpec)) + '.' + anonymousSnapshotExtension]; // Trim the output file paths more aggressively to avoid hitting Windows filesystem limits. const sanitizedName = (0, _utils.sanitizeForFilePath)((0, _util.trimLongString)(fullTitleWithoutSpec, _util.windowsFilesystemFriendlyLength)) + '.' + anonymousSnapshotExtension; outputBasePath = testInfo._getOutputPath(sanitizedName); this.attachmentBaseName = sanitizedName; } else { // We intentionally do not sanitize user-provided array of segments, assuming // it is a file system path. See https://github.com/microsoft/playwright/pull/9156. // Note: expected path must not ever change for backwards compatibility. expectedPathSegments = Array.isArray(name) ? name : [(0, _util.sanitizeFilePathBeforeExtension)(name)]; const joinedName = Array.isArray(name) ? name.join(_path.default.sep) : (0, _util.sanitizeFilePathBeforeExtension)((0, _util.trimLongString)(name, _util.windowsFilesystemFriendlyLength)); snapshotNames.namedSnapshotIndex[joinedName] = (snapshotNames.namedSnapshotIndex[joinedName] || 0) + 1; const index = snapshotNames.namedSnapshotIndex[joinedName]; const sanitizedName = index > 1 ? (0, _util.addSuffixToFilePath)(joinedName, `-${index - 1}`) : joinedName; outputBasePath = testInfo._getOutputPath(sanitizedName); this.attachmentBaseName = sanitizedName; } const defaultTemplate = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}'; this.expectedPath = testInfo._resolveSnapshotPath(configOptions.pathTemplate, defaultTemplate, expectedPathSegments); this.legacyExpectedPath = (0, _util.addSuffixToFilePath)(outputBasePath, '-expected'); this.previousPath = (0, _util.addSuffixToFilePath)(outputBasePath, '-previous'); this.actualPath = (0, _util.addSuffixToFilePath)(outputBasePath, '-actual'); this.diffPath = (0, _util.addSuffixToFilePath)(outputBasePath, '-diff'); const filteredConfigOptions = { ...configOptions }; for (const prop of NonConfigProperties) delete filteredConfigOptions[prop]; this.options = { ...filteredConfigOptions, ...this.options }; // While comparator is not a part of the public API, it is translated here. if (this.options._comparator) { this.options.comparator = this.options._comparator; delete this.options._comparator; } if (this.options.maxDiffPixels !== undefined && this.options.maxDiffPixels < 0) throw new Error('`maxDiffPixels` option value must be non-negative integer'); if (this.options.maxDiffPixelRatio !== undefined && (this.options.maxDiffPixelRatio < 0 || this.options.maxDiffPixelRatio > 1)) throw new Error('`maxDiffPixelRatio` option value must be between 0 and 1'); this.matcherName = matcherName; this.locator = locator; this.updateSnapshots = testInfo.config.updateSnapshots; this.mimeType = (_mime$getType = _utilsBundle.mime.getType(_path.default.basename(this.expectedPath))) !== null && _mime$getType !== void 0 ? _mime$getType : 'application/octet-string'; this.comparator = (0, _utils.getComparator)(this.mimeType); this.testInfo = testInfo; this.kind = this.mimeType.startsWith('image/') ? 'Screenshot' : 'Snapshot'; } createMatcherResult(message, pass, log) { const unfiltered = { name: this.matcherName, expected: this.expectedPath, actual: this.actualPath, diff: this.diffPath, pass, message: () => message, log }; return Object.fromEntries(Object.entries(unfiltered).filter(([_, v]) => v !== undefined)); } handleMissingNegated() { const isWriteMissingMode = this.updateSnapshots !== 'none'; const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? ', matchers using ".not" won\'t write them automatically.' : '.'}`; // NOTE: 'isNot' matcher implies inversed value. return this.createMatcherResult(message, true); } handleDifferentNegated() { // NOTE: 'isNot' matcher implies inversed value. return this.createMatcherResult('', false); } handleMatchingNegated() { const message = [_utils.colors.red(`${this.kind} comparison failed:`), '', indent('Expected result should be different from the actual one.', ' ')].join('\n'); // NOTE: 'isNot' matcher implies inversed value. return this.createMatcherResult(message, true); } handleMissing(actual, step) { const isWriteMissingMode = this.updateSnapshots !== 'none'; if (isWriteMissingMode) writeFileSync(this.expectedPath, actual); step === null || step === void 0 || step._attachToStep({ name: (0, _util.addSuffixToFilePath)(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath }); writeFileSync(this.actualPath, actual); step === null || step === void 0 || step._attachToStep({ name: (0, _util.addSuffixToFilePath)(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? ', writing actual.' : '.'}`; if (this.updateSnapshots === 'all' || this.updateSnapshots === 'changed') { /* eslint-disable no-console */ console.log(message); return this.createMatcherResult(message, true); } if (this.updateSnapshots === 'missing') { this.testInfo._hasNonRetriableError = true; this.testInfo._failWithError(new Error(message)); return this.createMatcherResult('', true); } return this.createMatcherResult(message, false); } handleDifferent(actual, expected, previous, diff, header, diffError, log, step) { const output = [`${header}${indent(diffError, ' ')}`]; if (expected !== undefined) { // Copy the expectation inside the `test-results/` folder for backwards compatibility, // so that one can upload `test-results/` directory and have all the data inside. writeFileSync(this.legacyExpectedPath, expected); step === null || step === void 0 || step._attachToStep({ name: (0, _util.addSuffixToFilePath)(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath }); output.push(`\nExpected: ${_utils.colors.yellow(this.expectedPath)}`); } if (previous !== undefined) { writeFileSync(this.previousPath, previous); step === null || step === void 0 || step._attachToStep({ name: (0, _util.addSuffixToFilePath)(this.attachmentBaseName, '-previous'), contentType: this.mimeType, path: this.previousPath }); output.push(`Previous: ${_utils.colors.yellow(this.previousPath)}`); } if (actual !== undefined) { writeFileSync(this.actualPath, actual); step === null || step === void 0 || step._attachToStep({ name: (0, _util.addSuffixToFilePath)(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); output.push(`Received: ${_utils.colors.yellow(this.actualPath)}`); } if (diff !== undefined) { writeFileSync(this.diffPath, diff); step === null || step === void 0 || step._attachToStep({ name: (0, _util.addSuffixToFilePath)(this.attachmentBaseName, '-diff'), contentType: this.mimeType, path: this.diffPath }); output.push(` Diff: ${_utils.colors.yellow(this.diffPath)}`); } if (log !== null && log !== void 0 && log.length) output.push((0, _util.callLogText)(log));else output.push(''); return this.createMatcherResult(output.join('\n'), false, log); } handleMatching() { return this.createMatcherResult('', true); } } function toMatchSnapshot(received, nameOrOptions = {}, optOptions = {}) { var _testInfo$_projectInt; const testInfo = (0, _globals.currentTestInfo)(); if (!testInfo) throw new Error(`toMatchSnapshot() must be called during the test`); if (received instanceof Promise) throw new Error('An unresolved Promise was passed to toMatchSnapshot(), make sure to resolve it by adding await to it.'); if (testInfo._projectInternal.ignoreSnapshots) return { pass: !this.isNot, message: () => '', name: 'toMatchSnapshot', expected: nameOrOptions }; const configOptions = ((_testInfo$_projectInt = testInfo._projectInternal.expect) === null || _testInfo$_projectInt === void 0 ? void 0 : _testInfo$_projectInt.toMatchSnapshot) || {}; const helper = new SnapshotHelper(testInfo, 'toMatchSnapshot', undefined, determineFileExtension(received), configOptions, nameOrOptions, optOptions); if (this.isNot) { if (!_fs.default.existsSync(helper.expectedPath)) return helper.handleMissingNegated(); const isDifferent = !!helper.comparator(received, _fs.default.readFileSync(helper.expectedPath), helper.options); return isDifferent ? helper.handleDifferentNegated() : helper.handleMatchingNegated(); } if (!_fs.default.existsSync(helper.expectedPath)) return helper.handleMissing(received, this._stepInfo); const expected = _fs.default.readFileSync(helper.expectedPath); if (helper.updateSnapshots === 'all') { if (!(0, _utils.compareBuffersOrStrings)(received, expected)) return helper.handleMatching(); writeFileSync(helper.expectedPath, received); /* eslint-disable no-console */ console.log(helper.expectedPath + ' is not the same, writing actual.'); return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true); } if (helper.updateSnapshots === 'changed') { const result = helper.comparator(received, expected, helper.options); if (!result) return helper.handleMatching(); writeFileSync(helper.expectedPath, received); /* eslint-disable no-console */ console.log(helper.expectedPath + ' does not match, writing actual.'); return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true); } const result = helper.comparator(received, expected, helper.options); if (!result) return helper.handleMatching(); const receiver = (0, _utils.isString)(received) ? 'string' : 'Buffer'; const header = (0, _matcherHint.matcherHint)(this, undefined, 'toMatchSnapshot', receiver, undefined, undefined); return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined, this._stepInfo); } function toHaveScreenshotStepTitle(nameOrOptions = {}, optOptions = {}) { let name; if (typeof nameOrOptions === 'object' && !Array.isArray(nameOrOptions)) name = nameOrOptions.name;else name = nameOrOptions; return Array.isArray(name) ? name.join(_path.default.sep) : name || ''; } async function toHaveScreenshot(pageOrLocator, nameOrOptions = {}, optOptions = {}) { var _testInfo$_projectInt2, _helper$options$timeo, _helper$options$anima, _helper$options$caret, _helper$options$scale; const testInfo = (0, _globals.currentTestInfo)(); if (!testInfo) throw new Error(`toHaveScreenshot() must be called during the test`); if (testInfo._projectInternal.ignoreSnapshots) return { pass: !this.isNot, message: () => '', name: 'toHaveScreenshot', expected: nameOrOptions }; (0, _util.expectTypes)(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); const [page, locator] = pageOrLocator.constructor.name === 'Page' ? [pageOrLocator, undefined] : [pageOrLocator.page(), pageOrLocator]; const configOptions = ((_testInfo$_projectInt2 = testInfo._projectInternal.expect) === null || _testInfo$_projectInt2 === void 0 ? void 0 : _testInfo$_projectInt2.toHaveScreenshot) || {}; const helper = new SnapshotHelper(testInfo, 'toHaveScreenshot', locator, 'png', configOptions, nameOrOptions, optOptions); if (!helper.expectedPath.toLowerCase().endsWith('.png')) throw new Error(`Screenshot name "${_path.default.basename(helper.expectedPath)}" must have '.png' extension`); (0, _util.expectTypes)(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); const style = await loadScreenshotStyles(helper.options.stylePath); const timeout = (_helper$options$timeo = helper.options.timeout) !== null && _helper$options$timeo !== void 0 ? _helper$options$timeo : this.timeout; const expectScreenshotOptions = { locator, animations: (_helper$options$anima = helper.options.animations) !== null && _helper$options$anima !== void 0 ? _helper$options$anima : 'disabled', caret: (_helper$options$caret = helper.options.caret) !== null && _helper$options$caret !== void 0 ? _helper$options$caret : 'hide', clip: helper.options.clip, fullPage: helper.options.fullPage, mask: helper.options.mask, maskColor: helper.options.maskColor, omitBackground: helper.options.omitBackground, scale: (_helper$options$scale = helper.options.scale) !== null && _helper$options$scale !== void 0 ? _helper$options$scale : 'css', style, isNot: !!this.isNot, timeout, comparator: helper.options.comparator, maxDiffPixels: helper.options.maxDiffPixels, maxDiffPixelRatio: helper.options.maxDiffPixelRatio, threshold: helper.options.threshold }; const hasSnapshot = _fs.default.existsSync(helper.expectedPath); if (this.isNot) { if (!hasSnapshot) return helper.handleMissingNegated(); // Having `errorMessage` means we timed out while waiting // for screenshots not to match, so screenshots // are actually the same in the end. expectScreenshotOptions.expected = await _fs.default.promises.readFile(helper.expectedPath); const isDifferent = !(await page._expectScreenshot(expectScreenshotOptions)).errorMessage; return isDifferent ? helper.handleDifferentNegated() : helper.handleMatchingNegated(); } // Fast path: there's no screenshot and we don't intend to update it. if (helper.updateSnapshots === 'none' && !hasSnapshot) return helper.createMatcherResult(`A snapshot doesn't exist at ${helper.expectedPath}.`, false); const receiver = locator ? 'locator' : 'page'; if (!hasSnapshot) { // Regenerate a new screenshot by waiting until two screenshots are the same. const { actual, previous, diff, errorMessage, log, timedOut } = await page._expectScreenshot(expectScreenshotOptions); // We tried re-generating new snapshot but failed. // This can be due to e.g. spinning animation, so we want to show it as a diff. if (errorMessage) { const header = (0, _matcherHint.matcherHint)(this, locator, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined); return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log, this._stepInfo); } // We successfully generated new screenshot. return helper.handleMissing(actual, this._stepInfo); } // General case: // - snapshot exists // - regular matcher (i.e. not a `.not`) const expected = await _fs.default.promises.readFile(helper.expectedPath); expectScreenshotOptions.expected = helper.updateSnapshots === 'all' ? undefined : expected; const { actual, previous, diff, errorMessage, log, timedOut } = await page._expectScreenshot(expectScreenshotOptions); const writeFiles = () => { writeFileSync(helper.expectedPath, actual); writeFileSync(helper.actualPath, actual); /* eslint-disable no-console */ console.log(helper.expectedPath + ' is re-generated, writing actual.'); return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true); }; if (!errorMessage) { // Screenshot is matching, but is not necessarily the same as the expected. if (helper.updateSnapshots === 'all' && actual && (0, _utils.compareBuffersOrStrings)(actual, expected)) { console.log(helper.expectedPath + ' is re-generated, writing actual.'); return writeFiles(); } return helper.handleMatching(); } if (helper.updateSnapshots === 'changed' || helper.updateSnapshots === 'all') return writeFiles(); const header = (0, _matcherHint.matcherHint)(this, undefined, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined); return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log, this._stepInfo); } function writeFileSync(aPath, content) { _fs.default.mkdirSync(_path.default.dirname(aPath), { recursive: true }); _fs.default.writeFileSync(aPath, content); } function indent(lines, tab) { return lines.replace(/^(?=.+$)/gm, tab); } function determineFileExtension(file) { if (typeof file === 'string') return 'txt'; if (compareMagicBytes(file, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'png'; if (compareMagicBytes(file, [0xff, 0xd8, 0xff])) return 'jpg'; return 'dat'; } function compareMagicBytes(file, magicBytes) { return Buffer.compare(Buffer.from(magicBytes), file.slice(0, magicBytes.length)) === 0; } async function loadScreenshotStyles(stylePath) { if (!stylePath) return; const stylePaths = Array.isArray(stylePath) ? stylePath : [stylePath]; const styles = await Promise.all(stylePaths.map(async stylePath => { const text = await _fs.default.promises.readFile(stylePath, 'utf8'); return text.trim(); })); return styles.join('\n').trim() || undefined; }