toMatchSnapshot.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. "use strict";
  2. var __create = Object.create;
  3. var __defProp = Object.defineProperty;
  4. var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
  5. var __getOwnPropNames = Object.getOwnPropertyNames;
  6. var __getProtoOf = Object.getPrototypeOf;
  7. var __hasOwnProp = Object.prototype.hasOwnProperty;
  8. var __export = (target, all) => {
  9. for (var name in all)
  10. __defProp(target, name, { get: all[name], enumerable: true });
  11. };
  12. var __copyProps = (to, from, except, desc) => {
  13. if (from && typeof from === "object" || typeof from === "function") {
  14. for (let key of __getOwnPropNames(from))
  15. if (!__hasOwnProp.call(to, key) && key !== except)
  16. __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  17. }
  18. return to;
  19. };
  20. var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
  21. // If the importer is in node compatibility mode or this is not an ESM
  22. // file that has been converted to a CommonJS file using a Babel-
  23. // compatible transform (i.e. "__esModule" has not been set), then set
  24. // "default" to the CommonJS "module.exports" for node compatibility.
  25. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
  26. mod
  27. ));
  28. var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
  29. var toMatchSnapshot_exports = {};
  30. __export(toMatchSnapshot_exports, {
  31. toHaveScreenshot: () => toHaveScreenshot,
  32. toHaveScreenshotStepTitle: () => toHaveScreenshotStepTitle,
  33. toMatchSnapshot: () => toMatchSnapshot
  34. });
  35. module.exports = __toCommonJS(toMatchSnapshot_exports);
  36. var import_fs = __toESM(require("fs"));
  37. var import_path = __toESM(require("path"));
  38. var import_utils = require("playwright-core/lib/utils");
  39. var import_utils2 = require("playwright-core/lib/utils");
  40. var import_utilsBundle = require("playwright-core/lib/utilsBundle");
  41. var import_util = require("../util");
  42. var import_matcherHint = require("./matcherHint");
  43. var import_globals = require("../common/globals");
  44. const NonConfigProperties = [
  45. "clip",
  46. "fullPage",
  47. "mask",
  48. "maskColor",
  49. "omitBackground",
  50. "timeout"
  51. ];
  52. class SnapshotHelper {
  53. constructor(testInfo, matcherName, locator, anonymousSnapshotExtension, configOptions, nameOrOptions, optOptions) {
  54. let name;
  55. if (Array.isArray(nameOrOptions) || typeof nameOrOptions === "string") {
  56. name = nameOrOptions;
  57. this.options = { ...optOptions };
  58. } else {
  59. const { name: nameFromOptions, ...options } = nameOrOptions;
  60. this.options = options;
  61. name = nameFromOptions;
  62. }
  63. this.name = Array.isArray(name) ? name.join(import_path.default.sep) : name || "";
  64. const resolvedPaths = testInfo._resolveSnapshotPaths(matcherName === "toHaveScreenshot" ? "screenshot" : "snapshot", name, "updateSnapshotIndex", anonymousSnapshotExtension);
  65. this.expectedPath = resolvedPaths.absoluteSnapshotPath;
  66. this.attachmentBaseName = resolvedPaths.relativeOutputPath;
  67. const outputBasePath = testInfo._getOutputPath(resolvedPaths.relativeOutputPath);
  68. this.legacyExpectedPath = (0, import_util.addSuffixToFilePath)(outputBasePath, "-expected");
  69. this.previousPath = (0, import_util.addSuffixToFilePath)(outputBasePath, "-previous");
  70. this.actualPath = (0, import_util.addSuffixToFilePath)(outputBasePath, "-actual");
  71. this.diffPath = (0, import_util.addSuffixToFilePath)(outputBasePath, "-diff");
  72. const filteredConfigOptions = { ...configOptions };
  73. for (const prop of NonConfigProperties)
  74. delete filteredConfigOptions[prop];
  75. this.options = {
  76. ...filteredConfigOptions,
  77. ...this.options
  78. };
  79. if (this.options._comparator) {
  80. this.options.comparator = this.options._comparator;
  81. delete this.options._comparator;
  82. }
  83. if (this.options.maxDiffPixels !== void 0 && this.options.maxDiffPixels < 0)
  84. throw new Error("`maxDiffPixels` option value must be non-negative integer");
  85. if (this.options.maxDiffPixelRatio !== void 0 && (this.options.maxDiffPixelRatio < 0 || this.options.maxDiffPixelRatio > 1))
  86. throw new Error("`maxDiffPixelRatio` option value must be between 0 and 1");
  87. this.matcherName = matcherName;
  88. this.locator = locator;
  89. this.updateSnapshots = testInfo.config.updateSnapshots;
  90. this.mimeType = import_utilsBundle.mime.getType(import_path.default.basename(this.expectedPath)) ?? "application/octet-stream";
  91. this.comparator = (0, import_utils.getComparator)(this.mimeType);
  92. this.testInfo = testInfo;
  93. this.kind = this.mimeType.startsWith("image/") ? "Screenshot" : "Snapshot";
  94. }
  95. createMatcherResult(message, pass, log) {
  96. const unfiltered = {
  97. name: this.matcherName,
  98. expected: this.expectedPath,
  99. actual: this.actualPath,
  100. diff: this.diffPath,
  101. pass,
  102. message: () => message,
  103. log
  104. };
  105. return Object.fromEntries(Object.entries(unfiltered).filter(([_, v]) => v !== void 0));
  106. }
  107. handleMissingNegated() {
  108. const isWriteMissingMode = this.updateSnapshots !== "none";
  109. const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? `, matchers using ".not" won't write them automatically.` : "."}`;
  110. return this.createMatcherResult(message, true);
  111. }
  112. handleDifferentNegated() {
  113. return this.createMatcherResult("", false);
  114. }
  115. handleMatchingNegated() {
  116. const message = [
  117. import_utils2.colors.red(`${this.kind} comparison failed:`),
  118. "",
  119. indent("Expected result should be different from the actual one.", " ")
  120. ].join("\n");
  121. return this.createMatcherResult(message, true);
  122. }
  123. handleMissing(actual, step) {
  124. const isWriteMissingMode = this.updateSnapshots !== "none";
  125. if (isWriteMissingMode)
  126. writeFileSync(this.expectedPath, actual);
  127. step?._attachToStep({ name: (0, import_util.addSuffixToFilePath)(this.attachmentBaseName, "-expected"), contentType: this.mimeType, path: this.expectedPath });
  128. writeFileSync(this.actualPath, actual);
  129. step?._attachToStep({ name: (0, import_util.addSuffixToFilePath)(this.attachmentBaseName, "-actual"), contentType: this.mimeType, path: this.actualPath });
  130. const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? ", writing actual." : "."}`;
  131. if (this.updateSnapshots === "all" || this.updateSnapshots === "changed") {
  132. console.log(message);
  133. return this.createMatcherResult(message, true);
  134. }
  135. if (this.updateSnapshots === "missing") {
  136. this.testInfo._hasNonRetriableError = true;
  137. this.testInfo._failWithError(new Error(message));
  138. return this.createMatcherResult("", true);
  139. }
  140. return this.createMatcherResult(message, false);
  141. }
  142. handleDifferent(actual, expected, previous, diff, header, diffError, log, step) {
  143. const output = [`${header}${indent(diffError, " ")}`];
  144. if (this.name) {
  145. output.push("");
  146. output.push(` Snapshot: ${this.name}`);
  147. }
  148. if (expected !== void 0) {
  149. writeFileSync(this.legacyExpectedPath, expected);
  150. step?._attachToStep({ name: (0, import_util.addSuffixToFilePath)(this.attachmentBaseName, "-expected"), contentType: this.mimeType, path: this.expectedPath });
  151. }
  152. if (previous !== void 0) {
  153. writeFileSync(this.previousPath, previous);
  154. step?._attachToStep({ name: (0, import_util.addSuffixToFilePath)(this.attachmentBaseName, "-previous"), contentType: this.mimeType, path: this.previousPath });
  155. }
  156. if (actual !== void 0) {
  157. writeFileSync(this.actualPath, actual);
  158. step?._attachToStep({ name: (0, import_util.addSuffixToFilePath)(this.attachmentBaseName, "-actual"), contentType: this.mimeType, path: this.actualPath });
  159. }
  160. if (diff !== void 0) {
  161. writeFileSync(this.diffPath, diff);
  162. step?._attachToStep({ name: (0, import_util.addSuffixToFilePath)(this.attachmentBaseName, "-diff"), contentType: this.mimeType, path: this.diffPath });
  163. }
  164. if (log?.length)
  165. output.push((0, import_matcherHint.callLogText)(log));
  166. else
  167. output.push("");
  168. return this.createMatcherResult(output.join("\n"), false, log);
  169. }
  170. handleMatching() {
  171. return this.createMatcherResult("", true);
  172. }
  173. }
  174. function toMatchSnapshot(received, nameOrOptions = {}, optOptions = {}) {
  175. const testInfo = (0, import_globals.currentTestInfo)();
  176. if (!testInfo)
  177. throw new Error(`toMatchSnapshot() must be called during the test`);
  178. if (received instanceof Promise)
  179. throw new Error("An unresolved Promise was passed to toMatchSnapshot(), make sure to resolve it by adding await to it.");
  180. if (testInfo._projectInternal.ignoreSnapshots)
  181. return { pass: !this.isNot, message: () => "", name: "toMatchSnapshot", expected: nameOrOptions };
  182. const configOptions = testInfo._projectInternal.expect?.toMatchSnapshot || {};
  183. const helper = new SnapshotHelper(
  184. testInfo,
  185. "toMatchSnapshot",
  186. void 0,
  187. "." + determineFileExtension(received),
  188. configOptions,
  189. nameOrOptions,
  190. optOptions
  191. );
  192. if (this.isNot) {
  193. if (!import_fs.default.existsSync(helper.expectedPath))
  194. return helper.handleMissingNegated();
  195. const isDifferent = !!helper.comparator(received, import_fs.default.readFileSync(helper.expectedPath), helper.options);
  196. return isDifferent ? helper.handleDifferentNegated() : helper.handleMatchingNegated();
  197. }
  198. if (!import_fs.default.existsSync(helper.expectedPath))
  199. return helper.handleMissing(received, this._stepInfo);
  200. const expected = import_fs.default.readFileSync(helper.expectedPath);
  201. if (helper.updateSnapshots === "all") {
  202. if (!(0, import_utils.compareBuffersOrStrings)(received, expected))
  203. return helper.handleMatching();
  204. writeFileSync(helper.expectedPath, received);
  205. console.log(helper.expectedPath + " is not the same, writing actual.");
  206. return helper.createMatcherResult(helper.expectedPath + " running with --update-snapshots, writing actual.", true);
  207. }
  208. if (helper.updateSnapshots === "changed") {
  209. const result2 = helper.comparator(received, expected, helper.options);
  210. if (!result2)
  211. return helper.handleMatching();
  212. writeFileSync(helper.expectedPath, received);
  213. console.log(helper.expectedPath + " does not match, writing actual.");
  214. return helper.createMatcherResult(helper.expectedPath + " running with --update-snapshots, writing actual.", true);
  215. }
  216. const result = helper.comparator(received, expected, helper.options);
  217. if (!result)
  218. return helper.handleMatching();
  219. const header = (0, import_matcherHint.formatMatcherMessage)(this, { matcherName: "toMatchSnapshot", receiver: (0, import_utils.isString)(received) ? "string" : "Buffer", expectation: "expected" });
  220. return helper.handleDifferent(received, expected, void 0, result.diff, header, result.errorMessage, void 0, this._stepInfo);
  221. }
  222. function toHaveScreenshotStepTitle(nameOrOptions = {}, optOptions = {}) {
  223. let name;
  224. if (typeof nameOrOptions === "object" && !Array.isArray(nameOrOptions))
  225. name = nameOrOptions.name;
  226. else
  227. name = nameOrOptions;
  228. return Array.isArray(name) ? name.join(import_path.default.sep) : name || "";
  229. }
  230. async function toHaveScreenshot(pageOrLocator, nameOrOptions = {}, optOptions = {}) {
  231. const testInfo = (0, import_globals.currentTestInfo)();
  232. if (!testInfo)
  233. throw new Error(`toHaveScreenshot() must be called during the test`);
  234. if (testInfo._projectInternal.ignoreSnapshots)
  235. return { pass: !this.isNot, message: () => "", name: "toHaveScreenshot", expected: nameOrOptions };
  236. (0, import_util.expectTypes)(pageOrLocator, ["Page", "Locator"], "toHaveScreenshot");
  237. const [page, locator] = pageOrLocator.constructor.name === "Page" ? [pageOrLocator, void 0] : [pageOrLocator.page(), pageOrLocator];
  238. const configOptions = testInfo._projectInternal.expect?.toHaveScreenshot || {};
  239. const helper = new SnapshotHelper(testInfo, "toHaveScreenshot", locator, void 0, configOptions, nameOrOptions, optOptions);
  240. if (!helper.expectedPath.toLowerCase().endsWith(".png"))
  241. throw new Error(`Screenshot name "${import_path.default.basename(helper.expectedPath)}" must have '.png' extension`);
  242. (0, import_util.expectTypes)(pageOrLocator, ["Page", "Locator"], "toHaveScreenshot");
  243. const style = await loadScreenshotStyles(helper.options.stylePath);
  244. const timeout = helper.options.timeout ?? this.timeout;
  245. const expectScreenshotOptions = {
  246. locator,
  247. animations: helper.options.animations ?? "disabled",
  248. caret: helper.options.caret ?? "hide",
  249. clip: helper.options.clip,
  250. fullPage: helper.options.fullPage,
  251. mask: helper.options.mask,
  252. maskColor: helper.options.maskColor,
  253. omitBackground: helper.options.omitBackground,
  254. scale: helper.options.scale ?? "css",
  255. style,
  256. isNot: !!this.isNot,
  257. timeout,
  258. comparator: helper.options.comparator,
  259. maxDiffPixels: helper.options.maxDiffPixels,
  260. maxDiffPixelRatio: helper.options.maxDiffPixelRatio,
  261. threshold: helper.options.threshold
  262. };
  263. const hasSnapshot = import_fs.default.existsSync(helper.expectedPath);
  264. if (this.isNot) {
  265. if (!hasSnapshot)
  266. return helper.handleMissingNegated();
  267. expectScreenshotOptions.expected = await import_fs.default.promises.readFile(helper.expectedPath);
  268. const isDifferent = !(await page._expectScreenshot(expectScreenshotOptions)).errorMessage;
  269. return isDifferent ? helper.handleDifferentNegated() : helper.handleMatchingNegated();
  270. }
  271. if (helper.updateSnapshots === "none" && !hasSnapshot)
  272. return helper.createMatcherResult(`A snapshot doesn't exist at ${helper.expectedPath}.`, false);
  273. if (!hasSnapshot) {
  274. const { actual: actual2, previous: previous2, diff: diff2, errorMessage: errorMessage2, log: log2, timedOut: timedOut2 } = await page._expectScreenshot(expectScreenshotOptions);
  275. if (errorMessage2) {
  276. const header2 = (0, import_matcherHint.formatMatcherMessage)(this, { matcherName: "toHaveScreenshot", locator, expectation: "expected", timeout, timedOut: timedOut2 });
  277. return helper.handleDifferent(actual2, void 0, previous2, diff2, header2, errorMessage2, log2, this._stepInfo);
  278. }
  279. return helper.handleMissing(actual2, this._stepInfo);
  280. }
  281. const expected = await import_fs.default.promises.readFile(helper.expectedPath);
  282. expectScreenshotOptions.expected = helper.updateSnapshots === "all" ? void 0 : expected;
  283. const { actual, previous, diff, errorMessage, log, timedOut } = await page._expectScreenshot(expectScreenshotOptions);
  284. const writeFiles = (actualBuffer) => {
  285. writeFileSync(helper.expectedPath, actualBuffer);
  286. writeFileSync(helper.actualPath, actualBuffer);
  287. console.log(helper.expectedPath + " is re-generated, writing actual.");
  288. return helper.createMatcherResult(helper.expectedPath + " running with --update-snapshots, writing actual.", true);
  289. };
  290. if (!errorMessage) {
  291. if (helper.updateSnapshots === "all" && actual && (0, import_utils.compareBuffersOrStrings)(actual, expected)) {
  292. console.log(helper.expectedPath + " is re-generated, writing actual.");
  293. return writeFiles(actual);
  294. }
  295. return helper.handleMatching();
  296. }
  297. if (helper.updateSnapshots === "changed" || helper.updateSnapshots === "all") {
  298. if (actual)
  299. return writeFiles(actual);
  300. let header2 = (0, import_matcherHint.formatMatcherMessage)(this, { matcherName: "toHaveScreenshot", locator, expectation: "expected", timeout, timedOut });
  301. header2 += " Failed to re-generate expected.\n";
  302. return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header2, errorMessage, log, this._stepInfo);
  303. }
  304. const header = (0, import_matcherHint.formatMatcherMessage)(this, { matcherName: "toHaveScreenshot", locator, expectation: "expected", timeout, timedOut });
  305. return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log, this._stepInfo);
  306. }
  307. function writeFileSync(aPath, content) {
  308. import_fs.default.mkdirSync(import_path.default.dirname(aPath), { recursive: true });
  309. import_fs.default.writeFileSync(aPath, content);
  310. }
  311. function indent(lines, tab) {
  312. return lines.replace(/^(?=.+$)/gm, tab);
  313. }
  314. function determineFileExtension(file) {
  315. if (typeof file === "string")
  316. return "txt";
  317. if (compareMagicBytes(file, [137, 80, 78, 71, 13, 10, 26, 10]))
  318. return "png";
  319. if (compareMagicBytes(file, [255, 216, 255]))
  320. return "jpg";
  321. return "dat";
  322. }
  323. function compareMagicBytes(file, magicBytes) {
  324. return Buffer.compare(Buffer.from(magicBytes), file.slice(0, magicBytes.length)) === 0;
  325. }
  326. async function loadScreenshotStyles(stylePath) {
  327. if (!stylePath)
  328. return;
  329. const stylePaths = Array.isArray(stylePath) ? stylePath : [stylePath];
  330. const styles = await Promise.all(stylePaths.map(async (stylePath2) => {
  331. const text = await import_fs.default.promises.readFile(stylePath2, "utf8");
  332. return text.trim();
  333. }));
  334. return styles.join("\n").trim() || void 0;
  335. }
  336. // Annotate the CommonJS export names for ESM import in node:
  337. 0 && (module.exports = {
  338. toHaveScreenshot,
  339. toHaveScreenshotStepTitle,
  340. toMatchSnapshot
  341. });