From 8da065a2ea0c18c2b59bcd679e89c409db447dcc Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 23 Oct 2024 12:57:15 -0700 Subject: [PATCH 01/17] Revert "chore: remove unused @babel/parser (#32654)" (#33252) --- packages/playwright/bundles/babel/package-lock.json | 1 + packages/playwright/bundles/babel/package.json | 1 + packages/playwright/bundles/babel/src/babelBundleImpl.ts | 1 + packages/playwright/src/transform/babelBundle.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/playwright/bundles/babel/package-lock.json b/packages/playwright/bundles/babel/package-lock.json index d3531ca0d7..0902f0601e 100644 --- a/packages/playwright/bundles/babel/package-lock.json +++ b/packages/playwright/bundles/babel/package-lock.json @@ -11,6 +11,7 @@ "@babel/code-frame": "^7.24.2", "@babel/core": "^7.24.4", "@babel/helper-plugin-utils": "^7.24.0", + "@babel/parser": "^7.24.4", "@babel/plugin-proposal-decorators": "^7.24.1", "@babel/plugin-proposal-explicit-resource-management": "^7.24.1", "@babel/plugin-syntax-async-generators": "^7.8.4", diff --git a/packages/playwright/bundles/babel/package.json b/packages/playwright/bundles/babel/package.json index 90a0aa85de..27853cf80e 100644 --- a/packages/playwright/bundles/babel/package.json +++ b/packages/playwright/bundles/babel/package.json @@ -12,6 +12,7 @@ "@babel/code-frame": "^7.24.2", "@babel/core": "^7.24.4", "@babel/helper-plugin-utils": "^7.24.0", + "@babel/parser": "^7.24.4", "@babel/plugin-proposal-decorators": "^7.24.1", "@babel/plugin-proposal-explicit-resource-management": "^7.24.1", "@babel/plugin-syntax-async-generators": "^7.8.4", diff --git a/packages/playwright/bundles/babel/src/babelBundleImpl.ts b/packages/playwright/bundles/babel/src/babelBundleImpl.ts index 81e617ef27..5e09be2a0c 100644 --- a/packages/playwright/bundles/babel/src/babelBundleImpl.ts +++ b/packages/playwright/bundles/babel/src/babelBundleImpl.ts @@ -23,6 +23,7 @@ import * as babel from '@babel/core'; export { codeFrameColumns } from '@babel/code-frame'; export { declare } from '@babel/helper-plugin-utils'; export { types } from '@babel/core'; +export { parse } from '@babel/parser'; import traverseFunction from '@babel/traverse'; export const traverse = traverseFunction; diff --git a/packages/playwright/src/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts index faf06b7158..2806a05aec 100644 --- a/packages/playwright/src/transform/babelBundle.ts +++ b/packages/playwright/src/transform/babelBundle.ts @@ -18,6 +18,7 @@ import type { BabelFileResult } from '../../bundles/babel/node_modules/@types/ba export const codeFrameColumns: typeof import('../../bundles/babel/node_modules/@types/babel__code-frame').codeFrameColumns = require('./babelBundleImpl').codeFrameColumns; export const declare: typeof import('../../bundles/babel/node_modules/@types/babel__helper-plugin-utils').declare = require('./babelBundleImpl').declare; export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types; +export const parse: typeof import('../../bundles/babel/node_modules/@babel/parser/typings/babel-parser').parse = require('./babelBundleImpl').parse; export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse; export type BabelPlugin = [string, any?]; export type BabelTransformFunction = (code: string, filename: string, isTypeScript: boolean, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult; From 24cafbc8cb9dac71a38bfe36c9653d883b39bf7a Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 23 Oct 2024 16:04:37 -0700 Subject: [PATCH 02/17] chore: use diff_match_patch with types (#33254) --- .../playwright-core/ThirdPartyNotices.txt | 209 +- .../bundles/utils/package-lock.json | 24 + .../bundles/utils/package.json | 6 +- .../bundles/utils/src/utilsBundleImpl.ts | 3 + .../src/third_party/diff_match_patch.js | 2222 ----------------- packages/playwright-core/src/utils/DEPS.list | 1 - .../playwright-core/src/utils/comparators.ts | 4 +- packages/playwright-core/src/utils/index.ts | 1 + packages/playwright-core/src/utils/patch.ts | 127 + packages/playwright-core/src/utilsBundle.ts | 1 + tests/library/{ => unit}/clock.spec.ts | 4 +- tests/library/unit/patch.spec.ts | 260 ++ tests/library/{ => unit}/sequence.spec.ts | 2 +- 13 files changed, 634 insertions(+), 2230 deletions(-) delete mode 100644 packages/playwright-core/src/third_party/diff_match_patch.js create mode 100644 packages/playwright-core/src/utils/patch.ts rename tests/library/{ => unit}/clock.spec.ts (99%) create mode 100644 tests/library/unit/patch.spec.ts rename tests/library/{ => unit}/sequence.spec.ts (98%) diff --git a/packages/playwright-core/ThirdPartyNotices.txt b/packages/playwright-core/ThirdPartyNotices.txt index 23e3cff257..71ac7d1bed 100644 --- a/packages/playwright-core/ThirdPartyNotices.txt +++ b/packages/playwright-core/ThirdPartyNotices.txt @@ -16,6 +16,7 @@ This project incorporates components from the projects listed below. The origina - concat-map@0.0.1 (https://github.com/substack/node-concat-map) - debug@4.3.4 (https://github.com/debug-js/debug) - define-lazy-prop@2.0.0 (https://github.com/sindresorhus/define-lazy-prop) +- diff-match-patch@1.0.5 (https://github.com/JackuB/diff-match-patch) - dotenv@16.4.5 (https://github.com/motdotla/dotenv) - end-of-stream@1.4.4 (https://github.com/mafintosh/end-of-stream) - escape-string-regexp@2.0.0 (https://github.com/sindresorhus/escape-string-regexp) @@ -351,6 +352,212 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ========================================= END OF define-lazy-prop@2.0.0 AND INFORMATION +%% diff-match-patch@1.0.5 NOTICES AND INFORMATION BEGIN HERE +========================================= +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. +========================================= +END OF diff-match-patch@1.0.5 AND INFORMATION + %% dotenv@16.4.5 NOTICES AND INFORMATION BEGIN HERE ========================================= Copyright (c) 2015, Scott Motte @@ -1194,6 +1401,6 @@ END OF yazl@2.5.1 AND INFORMATION SUMMARY BEGIN HERE ========================================= -Total Packages: 47 +Total Packages: 48 ========================================= END OF SUMMARY \ No newline at end of file diff --git a/packages/playwright-core/bundles/utils/package-lock.json b/packages/playwright-core/bundles/utils/package-lock.json index f786cb4db7..e31dbe6209 100644 --- a/packages/playwright-core/bundles/utils/package-lock.json +++ b/packages/playwright-core/bundles/utils/package-lock.json @@ -11,6 +11,7 @@ "colors": "1.4.0", "commander": "8.3.0", "debug": "^4.3.4", + "diff-match-patch": "^1.0.5", "dotenv": "^16.4.5", "graceful-fs": "4.2.10", "https-proxy-agent": "7.0.5", @@ -30,6 +31,7 @@ }, "devDependencies": { "@types/debug": "^4.1.7", + "@types/diff-match-patch": "^1.0.36", "@types/mime": "^2.0.3", "@types/minimatch": "^3.0.5", "@types/pngjs": "^6.0.1", @@ -49,6 +51,12 @@ "@types/ms": "*" } }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "dev": true + }, "node_modules/@types/mime": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", @@ -201,6 +209,11 @@ "node": ">=8" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -456,6 +469,12 @@ "@types/ms": "*" } }, + "@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "dev": true + }, "@types/mime": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", @@ -587,6 +606,11 @@ "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==" }, + "diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" + }, "dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", diff --git a/packages/playwright-core/bundles/utils/package.json b/packages/playwright-core/bundles/utils/package.json index 005e32fbe6..d8f6b7a481 100644 --- a/packages/playwright-core/bundles/utils/package.json +++ b/packages/playwright-core/bundles/utils/package.json @@ -12,6 +12,7 @@ "colors": "1.4.0", "commander": "8.3.0", "debug": "^4.3.4", + "diff-match-patch": "^1.0.5", "dotenv": "^16.4.5", "graceful-fs": "4.2.10", "https-proxy-agent": "7.0.5", @@ -26,11 +27,12 @@ "signal-exit": "3.0.7", "socks-proxy-agent": "8.0.4", "stack-utils": "2.0.5", - "yaml": "^2.5.1", - "ws": "8.17.1" + "ws": "8.17.1", + "yaml": "^2.5.1" }, "devDependencies": { "@types/debug": "^4.1.7", + "@types/diff-match-patch": "^1.0.36", "@types/mime": "^2.0.3", "@types/minimatch": "^3.0.5", "@types/pngjs": "^6.0.1", diff --git a/packages/playwright-core/bundles/utils/src/utilsBundleImpl.ts b/packages/playwright-core/bundles/utils/src/utilsBundleImpl.ts index 975e291bb5..ba8bc8d90b 100644 --- a/packages/playwright-core/bundles/utils/src/utilsBundleImpl.ts +++ b/packages/playwright-core/bundles/utils/src/utilsBundleImpl.ts @@ -20,6 +20,9 @@ export const colors = colorsLibrary; import debugLibrary from 'debug'; export const debug = debugLibrary; +import diffMatchPatchLibrary from 'diff-match-patch'; +export const diffMatchPatch = diffMatchPatchLibrary; + import dotenvLibrary from 'dotenv'; export const dotenv = dotenvLibrary; diff --git a/packages/playwright-core/src/third_party/diff_match_patch.js b/packages/playwright-core/src/third_party/diff_match_patch.js deleted file mode 100644 index ba0df0f6ab..0000000000 --- a/packages/playwright-core/src/third_party/diff_match_patch.js +++ /dev/null @@ -1,2222 +0,0 @@ -/** - * Diff Match and Patch - * Copyright 2018 The diff-match-patch Authors. - * https://github.com/google/diff-match-patch - * - * 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. - */ - -/** - * @fileoverview Computes the difference between two texts to create a patch. - * Applies the patch onto another text, allowing for errors. - * @author fraser@google.com (Neil Fraser) - */ - -/** - * Class containing the diff, match and patch methods. - * @constructor - */ -var diff_match_patch = function() { - - // Defaults. - // Redefine these in your program to override the defaults. - - // Number of seconds to map a diff before giving up (0 for infinity). - this.Diff_Timeout = 1.0; - // Cost of an empty edit operation in terms of edit characters. - this.Diff_EditCost = 4; - // At what point is no match declared (0.0 = perfection, 1.0 = very loose). - this.Match_Threshold = 0.5; - // How far to search for a match (0 = exact location, 1000+ = broad match). - // A match this many characters away from the expected location will add - // 1.0 to the score (0.0 is a perfect match). - this.Match_Distance = 1000; - // When deleting a large block of text (over ~64 characters), how close do - // the contents have to be to match the expected contents. (0.0 = perfection, - // 1.0 = very loose). Note that Match_Threshold controls how closely the - // end points of a delete need to match. - this.Patch_DeleteThreshold = 0.5; - // Chunk size for context length. - this.Patch_Margin = 4; - - // The number of bits in an int. - this.Match_MaxBits = 32; -}; - - -// DIFF FUNCTIONS - - -/** - * The data structure representing a diff is an array of tuples: - * [[DIFF_DELETE, 'Hello'], [DIFF_INSERT, 'Goodbye'], [DIFF_EQUAL, ' world.']] - * which means: delete 'Hello', add 'Goodbye' and keep ' world.' - */ -var DIFF_DELETE = -1; -var DIFF_INSERT = 1; -var DIFF_EQUAL = 0; - -/** - * Class representing one diff tuple. - * Attempts to look like a two-element array (which is what this used to be). - * @param {number} op Operation, one of: DIFF_DELETE, DIFF_INSERT, DIFF_EQUAL. - * @param {string} text Text to be deleted, inserted, or retained. - * @constructor - */ -diff_match_patch.Diff = function(op, text) { - this[0] = op; - this[1] = text; -}; - -diff_match_patch.Diff.prototype.length = 2; - -/** - * Emulate the output of a two-element array. - * @return {string} Diff operation as a string. - */ -diff_match_patch.Diff.prototype.toString = function() { - return this[0] + ',' + this[1]; -}; - - -/** - * Find the differences between two texts. Simplifies the problem by stripping - * any common prefix or suffix off the texts before diffing. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {boolean=} opt_checklines Optional speedup flag. If present and false, - * then don't run a line-level diff first to identify the changed areas. - * Defaults to true, which does a faster, slightly less optimal diff. - * @param {number=} opt_deadline Optional time when the diff should be complete - * by. Used internally for recursive calls. Users should set DiffTimeout - * instead. - * @return {!Array.} Array of diff tuples. - */ -diff_match_patch.prototype.diff_main = function(text1, text2, opt_checklines, - opt_deadline) { - // Set a deadline by which time the diff must be complete. - if (typeof opt_deadline == 'undefined') { - if (this.Diff_Timeout <= 0) { - opt_deadline = Number.MAX_VALUE; - } else { - opt_deadline = (new Date).getTime() + this.Diff_Timeout * 1000; - } - } - var deadline = opt_deadline; - - // Check for null inputs. - if (text1 == null || text2 == null) { - throw new Error('Null input. (diff_main)'); - } - - // Check for equality (speedup). - if (text1 == text2) { - if (text1) { - return [new diff_match_patch.Diff(DIFF_EQUAL, text1)]; - } - return []; - } - - if (typeof opt_checklines == 'undefined') { - opt_checklines = true; - } - var checklines = opt_checklines; - - // Trim off common prefix (speedup). - var commonlength = this.diff_commonPrefix(text1, text2); - var commonprefix = text1.substring(0, commonlength); - text1 = text1.substring(commonlength); - text2 = text2.substring(commonlength); - - // Trim off common suffix (speedup). - commonlength = this.diff_commonSuffix(text1, text2); - var commonsuffix = text1.substring(text1.length - commonlength); - text1 = text1.substring(0, text1.length - commonlength); - text2 = text2.substring(0, text2.length - commonlength); - - // Compute the diff on the middle block. - var diffs = this.diff_compute_(text1, text2, checklines, deadline); - - // Restore the prefix and suffix. - if (commonprefix) { - diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, commonprefix)); - } - if (commonsuffix) { - diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, commonsuffix)); - } - this.diff_cleanupMerge(diffs); - return diffs; -}; - - -/** - * Find the differences between two texts. Assumes that the texts do not - * have any common prefix or suffix. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {boolean} checklines Speedup flag. If false, then don't run a - * line-level diff first to identify the changed areas. - * If true, then run a faster, slightly less optimal diff. - * @param {number} deadline Time when the diff should be complete by. - * @return {!Array.} Array of diff tuples. - * @private - */ -diff_match_patch.prototype.diff_compute_ = function(text1, text2, checklines, - deadline) { - var diffs; - - if (!text1) { - // Just add some text (speedup). - return [new diff_match_patch.Diff(DIFF_INSERT, text2)]; - } - - if (!text2) { - // Just delete some text (speedup). - return [new diff_match_patch.Diff(DIFF_DELETE, text1)]; - } - - var longtext = text1.length > text2.length ? text1 : text2; - var shorttext = text1.length > text2.length ? text2 : text1; - var i = longtext.indexOf(shorttext); - if (i != -1) { - // Shorter text is inside the longer text (speedup). - diffs = [new diff_match_patch.Diff(DIFF_INSERT, longtext.substring(0, i)), - new diff_match_patch.Diff(DIFF_EQUAL, shorttext), - new diff_match_patch.Diff(DIFF_INSERT, - longtext.substring(i + shorttext.length))]; - // Swap insertions for deletions if diff is reversed. - if (text1.length > text2.length) { - diffs[0][0] = diffs[2][0] = DIFF_DELETE; - } - return diffs; - } - - if (shorttext.length == 1) { - // Single character string. - // After the previous speedup, the character can't be an equality. - return [new diff_match_patch.Diff(DIFF_DELETE, text1), - new diff_match_patch.Diff(DIFF_INSERT, text2)]; - } - - // Check to see if the problem can be split in two. - var hm = this.diff_halfMatch_(text1, text2); - if (hm) { - // A half-match was found, sort out the return data. - var text1_a = hm[0]; - var text1_b = hm[1]; - var text2_a = hm[2]; - var text2_b = hm[3]; - var mid_common = hm[4]; - // Send both pairs off for separate processing. - var diffs_a = this.diff_main(text1_a, text2_a, checklines, deadline); - var diffs_b = this.diff_main(text1_b, text2_b, checklines, deadline); - // Merge the results. - return diffs_a.concat([new diff_match_patch.Diff(DIFF_EQUAL, mid_common)], - diffs_b); - } - - if (checklines && text1.length > 100 && text2.length > 100) { - return this.diff_lineMode_(text1, text2, deadline); - } - - return this.diff_bisect_(text1, text2, deadline); -}; - - -/** - * Do a quick line-level diff on both strings, then rediff the parts for - * greater accuracy. - * This speedup can produce non-minimal diffs. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} deadline Time when the diff should be complete by. - * @return {!Array.} Array of diff tuples. - * @private - */ -diff_match_patch.prototype.diff_lineMode_ = function(text1, text2, deadline) { - // Scan the text on a line-by-line basis first. - var a = this.diff_linesToChars_(text1, text2); - text1 = a.chars1; - text2 = a.chars2; - var linearray = a.lineArray; - - var diffs = this.diff_main(text1, text2, false, deadline); - - // Convert the diff back to original text. - this.diff_charsToLines_(diffs, linearray); - // Eliminate freak matches (e.g. blank lines) - this.diff_cleanupSemantic(diffs); - - // Rediff any replacement blocks, this time character-by-character. - // Add a dummy entry at the end. - diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); - var pointer = 0; - var count_delete = 0; - var count_insert = 0; - var text_delete = ''; - var text_insert = ''; - while (pointer < diffs.length) { - switch (diffs[pointer][0]) { - case DIFF_INSERT: - count_insert++; - text_insert += diffs[pointer][1]; - break; - case DIFF_DELETE: - count_delete++; - text_delete += diffs[pointer][1]; - break; - case DIFF_EQUAL: - // Upon reaching an equality, check for prior redundancies. - if (count_delete >= 1 && count_insert >= 1) { - // Delete the offending records and add the merged ones. - diffs.splice(pointer - count_delete - count_insert, - count_delete + count_insert); - pointer = pointer - count_delete - count_insert; - var subDiff = - this.diff_main(text_delete, text_insert, false, deadline); - for (var j = subDiff.length - 1; j >= 0; j--) { - diffs.splice(pointer, 0, subDiff[j]); - } - pointer = pointer + subDiff.length; - } - count_insert = 0; - count_delete = 0; - text_delete = ''; - text_insert = ''; - break; - } - pointer++; - } - diffs.pop(); // Remove the dummy entry at the end. - - return diffs; -}; - - -/** - * Find the 'middle snake' of a diff, split the problem in two - * and return the recursively constructed diff. - * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} deadline Time at which to bail if not yet complete. - * @return {!Array.} Array of diff tuples. - * @private - */ -diff_match_patch.prototype.diff_bisect_ = function(text1, text2, deadline) { - // Cache the text lengths to prevent multiple calls. - var text1_length = text1.length; - var text2_length = text2.length; - var max_d = Math.ceil((text1_length + text2_length) / 2); - var v_offset = max_d; - var v_length = 2 * max_d; - var v1 = new Array(v_length); - var v2 = new Array(v_length); - // Setting all elements to -1 is faster in Chrome & Firefox than mixing - // integers and undefined. - for (var x = 0; x < v_length; x++) { - v1[x] = -1; - v2[x] = -1; - } - v1[v_offset + 1] = 0; - v2[v_offset + 1] = 0; - var delta = text1_length - text2_length; - // If the total number of characters is odd, then the front path will collide - // with the reverse path. - var front = (delta % 2 != 0); - // Offsets for start and end of k loop. - // Prevents mapping of space beyond the grid. - var k1start = 0; - var k1end = 0; - var k2start = 0; - var k2end = 0; - for (var d = 0; d < max_d; d++) { - // Bail out if deadline is reached. - if ((new Date()).getTime() > deadline) { - break; - } - - // Walk the front path one step. - for (var k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { - var k1_offset = v_offset + k1; - var x1; - if (k1 == -d || (k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1])) { - x1 = v1[k1_offset + 1]; - } else { - x1 = v1[k1_offset - 1] + 1; - } - var y1 = x1 - k1; - while (x1 < text1_length && y1 < text2_length && - text1.charAt(x1) == text2.charAt(y1)) { - x1++; - y1++; - } - v1[k1_offset] = x1; - if (x1 > text1_length) { - // Ran off the right of the graph. - k1end += 2; - } else if (y1 > text2_length) { - // Ran off the bottom of the graph. - k1start += 2; - } else if (front) { - var k2_offset = v_offset + delta - k1; - if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1) { - // Mirror x2 onto top-left coordinate system. - var x2 = text1_length - v2[k2_offset]; - if (x1 >= x2) { - // Overlap detected. - return this.diff_bisectSplit_(text1, text2, x1, y1, deadline); - } - } - } - } - - // Walk the reverse path one step. - for (var k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { - var k2_offset = v_offset + k2; - var x2; - if (k2 == -d || (k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1])) { - x2 = v2[k2_offset + 1]; - } else { - x2 = v2[k2_offset - 1] + 1; - } - var y2 = x2 - k2; - while (x2 < text1_length && y2 < text2_length && - text1.charAt(text1_length - x2 - 1) == - text2.charAt(text2_length - y2 - 1)) { - x2++; - y2++; - } - v2[k2_offset] = x2; - if (x2 > text1_length) { - // Ran off the left of the graph. - k2end += 2; - } else if (y2 > text2_length) { - // Ran off the top of the graph. - k2start += 2; - } else if (!front) { - var k1_offset = v_offset + delta - k2; - if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1) { - var x1 = v1[k1_offset]; - var y1 = v_offset + x1 - k1_offset; - // Mirror x2 onto top-left coordinate system. - x2 = text1_length - x2; - if (x1 >= x2) { - // Overlap detected. - return this.diff_bisectSplit_(text1, text2, x1, y1, deadline); - } - } - } - } - } - // Diff took too long and hit the deadline or - // number of diffs equals number of characters, no commonality at all. - return [new diff_match_patch.Diff(DIFF_DELETE, text1), - new diff_match_patch.Diff(DIFF_INSERT, text2)]; -}; - - -/** - * Given the location of the 'middle snake', split the diff in two parts - * and recurse. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} x Index of split point in text1. - * @param {number} y Index of split point in text2. - * @param {number} deadline Time at which to bail if not yet complete. - * @return {!Array.} Array of diff tuples. - * @private - */ -diff_match_patch.prototype.diff_bisectSplit_ = function(text1, text2, x, y, - deadline) { - var text1a = text1.substring(0, x); - var text2a = text2.substring(0, y); - var text1b = text1.substring(x); - var text2b = text2.substring(y); - - // Compute both diffs serially. - var diffs = this.diff_main(text1a, text2a, false, deadline); - var diffsb = this.diff_main(text1b, text2b, false, deadline); - - return diffs.concat(diffsb); -}; - - -/** - * Split two texts into an array of strings. Reduce the texts to a string of - * hashes where each Unicode character represents one line. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {{chars1: string, chars2: string, lineArray: !Array.}} - * An object containing the encoded text1, the encoded text2 and - * the array of unique strings. - * The zeroth element of the array of unique strings is intentionally blank. - * @private - */ -diff_match_patch.prototype.diff_linesToChars_ = function(text1, text2) { - var lineArray = []; // e.g. lineArray[4] == 'Hello\n' - var lineHash = {}; // e.g. lineHash['Hello\n'] == 4 - - // '\x00' is a valid character, but various debuggers don't like it. - // So we'll insert a junk entry to avoid generating a null character. - lineArray[0] = ''; - - /** - * Split a text into an array of strings. Reduce the texts to a string of - * hashes where each Unicode character represents one line. - * Modifies linearray and linehash through being a closure. - * @param {string} text String to encode. - * @return {string} Encoded string. - * @private - */ - function diff_linesToCharsMunge_(text) { - var chars = ''; - // Walk the text, pulling out a substring for each line. - // text.split('\n') would would temporarily double our memory footprint. - // Modifying text would create many large strings to garbage collect. - var lineStart = 0; - var lineEnd = -1; - // Keeping our own length variable is faster than looking it up. - var lineArrayLength = lineArray.length; - while (lineEnd < text.length - 1) { - lineEnd = text.indexOf('\n', lineStart); - if (lineEnd == -1) { - lineEnd = text.length - 1; - } - var line = text.substring(lineStart, lineEnd + 1); - - if (lineHash.hasOwnProperty ? lineHash.hasOwnProperty(line) : - (lineHash[line] !== undefined)) { - chars += String.fromCharCode(lineHash[line]); - } else { - if (lineArrayLength == maxLines) { - // Bail out at 65535 because - // String.fromCharCode(65536) == String.fromCharCode(0) - line = text.substring(lineStart); - lineEnd = text.length; - } - chars += String.fromCharCode(lineArrayLength); - lineHash[line] = lineArrayLength; - lineArray[lineArrayLength++] = line; - } - lineStart = lineEnd + 1; - } - return chars; - } - // Allocate 2/3rds of the space for text1, the rest for text2. - var maxLines = 40000; - var chars1 = diff_linesToCharsMunge_(text1); - maxLines = 65535; - var chars2 = diff_linesToCharsMunge_(text2); - return {chars1: chars1, chars2: chars2, lineArray: lineArray}; -}; - - -/** - * Rehydrate the text in a diff from a string of line hashes to real lines of - * text. - * @param {!Array.} diffs Array of diff tuples. - * @param {!Array.} lineArray Array of unique strings. - * @private - */ -diff_match_patch.prototype.diff_charsToLines_ = function(diffs, lineArray) { - for (var i = 0; i < diffs.length; i++) { - var chars = diffs[i][1]; - var text = []; - for (var j = 0; j < chars.length; j++) { - text[j] = lineArray[chars.charCodeAt(j)]; - } - diffs[i][1] = text.join(''); - } -}; - - -/** - * Determine the common prefix of two strings. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {number} The number of characters common to the start of each - * string. - */ -diff_match_patch.prototype.diff_commonPrefix = function(text1, text2) { - // Quick check for common null cases. - if (!text1 || !text2 || text1.charAt(0) != text2.charAt(0)) { - return 0; - } - // Binary search. - // Performance analysis: https://neil.fraser.name/news/2007/10/09/ - var pointermin = 0; - var pointermax = Math.min(text1.length, text2.length); - var pointermid = pointermax; - var pointerstart = 0; - while (pointermin < pointermid) { - if (text1.substring(pointerstart, pointermid) == - text2.substring(pointerstart, pointermid)) { - pointermin = pointermid; - pointerstart = pointermin; - } else { - pointermax = pointermid; - } - pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); - } - return pointermid; -}; - - -/** - * Determine the common suffix of two strings. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {number} The number of characters common to the end of each string. - */ -diff_match_patch.prototype.diff_commonSuffix = function(text1, text2) { - // Quick check for common null cases. - if (!text1 || !text2 || - text1.charAt(text1.length - 1) != text2.charAt(text2.length - 1)) { - return 0; - } - // Binary search. - // Performance analysis: https://neil.fraser.name/news/2007/10/09/ - var pointermin = 0; - var pointermax = Math.min(text1.length, text2.length); - var pointermid = pointermax; - var pointerend = 0; - while (pointermin < pointermid) { - if (text1.substring(text1.length - pointermid, text1.length - pointerend) == - text2.substring(text2.length - pointermid, text2.length - pointerend)) { - pointermin = pointermid; - pointerend = pointermin; - } else { - pointermax = pointermid; - } - pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); - } - return pointermid; -}; - - -/** - * Determine if the suffix of one string is the prefix of another. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {number} The number of characters common to the end of the first - * string and the start of the second string. - * @private - */ -diff_match_patch.prototype.diff_commonOverlap_ = function(text1, text2) { - // Cache the text lengths to prevent multiple calls. - var text1_length = text1.length; - var text2_length = text2.length; - // Eliminate the null case. - if (text1_length == 0 || text2_length == 0) { - return 0; - } - // Truncate the longer string. - if (text1_length > text2_length) { - text1 = text1.substring(text1_length - text2_length); - } else if (text1_length < text2_length) { - text2 = text2.substring(0, text1_length); - } - var text_length = Math.min(text1_length, text2_length); - // Quick check for the worst case. - if (text1 == text2) { - return text_length; - } - - // Start by looking for a single character match - // and increase length until no match is found. - // Performance analysis: https://neil.fraser.name/news/2010/11/04/ - var best = 0; - var length = 1; - while (true) { - var pattern = text1.substring(text_length - length); - var found = text2.indexOf(pattern); - if (found == -1) { - return best; - } - length += found; - if (found == 0 || text1.substring(text_length - length) == - text2.substring(0, length)) { - best = length; - length++; - } - } -}; - - -/** - * Do the two texts share a substring which is at least half the length of the - * longer text? - * This speedup can produce non-minimal diffs. - * @param {string} text1 First string. - * @param {string} text2 Second string. - * @return {Array.} Five element Array, containing the prefix of - * text1, the suffix of text1, the prefix of text2, the suffix of - * text2 and the common middle. Or null if there was no match. - * @private - */ -diff_match_patch.prototype.diff_halfMatch_ = function(text1, text2) { - if (this.Diff_Timeout <= 0) { - // Don't risk returning a non-optimal diff if we have unlimited time. - return null; - } - var longtext = text1.length > text2.length ? text1 : text2; - var shorttext = text1.length > text2.length ? text2 : text1; - if (longtext.length < 4 || shorttext.length * 2 < longtext.length) { - return null; // Pointless. - } - var dmp = this; // 'this' becomes 'window' in a closure. - - /** - * Does a substring of shorttext exist within longtext such that the substring - * is at least half the length of longtext? - * Closure, but does not reference any external variables. - * @param {string} longtext Longer string. - * @param {string} shorttext Shorter string. - * @param {number} i Start index of quarter length substring within longtext. - * @return {Array.} Five element Array, containing the prefix of - * longtext, the suffix of longtext, the prefix of shorttext, the suffix - * of shorttext and the common middle. Or null if there was no match. - * @private - */ - function diff_halfMatchI_(longtext, shorttext, i) { - // Start with a 1/4 length substring at position i as a seed. - var seed = longtext.substring(i, i + Math.floor(longtext.length / 4)); - var j = -1; - var best_common = ''; - var best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b; - while ((j = shorttext.indexOf(seed, j + 1)) != -1) { - var prefixLength = dmp.diff_commonPrefix(longtext.substring(i), - shorttext.substring(j)); - var suffixLength = dmp.diff_commonSuffix(longtext.substring(0, i), - shorttext.substring(0, j)); - if (best_common.length < suffixLength + prefixLength) { - best_common = shorttext.substring(j - suffixLength, j) + - shorttext.substring(j, j + prefixLength); - best_longtext_a = longtext.substring(0, i - suffixLength); - best_longtext_b = longtext.substring(i + prefixLength); - best_shorttext_a = shorttext.substring(0, j - suffixLength); - best_shorttext_b = shorttext.substring(j + prefixLength); - } - } - if (best_common.length * 2 >= longtext.length) { - return [best_longtext_a, best_longtext_b, - best_shorttext_a, best_shorttext_b, best_common]; - } else { - return null; - } - } - - // First check if the second quarter is the seed for a half-match. - var hm1 = diff_halfMatchI_(longtext, shorttext, - Math.ceil(longtext.length / 4)); - // Check again based on the third quarter. - var hm2 = diff_halfMatchI_(longtext, shorttext, - Math.ceil(longtext.length / 2)); - var hm; - if (!hm1 && !hm2) { - return null; - } else if (!hm2) { - hm = hm1; - } else if (!hm1) { - hm = hm2; - } else { - // Both matched. Select the longest. - hm = hm1[4].length > hm2[4].length ? hm1 : hm2; - } - - // A half-match was found, sort out the return data. - var text1_a, text1_b, text2_a, text2_b; - if (text1.length > text2.length) { - text1_a = hm[0]; - text1_b = hm[1]; - text2_a = hm[2]; - text2_b = hm[3]; - } else { - text2_a = hm[0]; - text2_b = hm[1]; - text1_a = hm[2]; - text1_b = hm[3]; - } - var mid_common = hm[4]; - return [text1_a, text1_b, text2_a, text2_b, mid_common]; -}; - - -/** - * Reduce the number of edits by eliminating semantically trivial equalities. - * @param {!Array.} diffs Array of diff tuples. - */ -diff_match_patch.prototype.diff_cleanupSemantic = function(diffs) { - var changes = false; - var equalities = []; // Stack of indices where equalities are found. - var equalitiesLength = 0; // Keeping our own length var is faster in JS. - /** @type {?string} */ - var lastEquality = null; - // Always equal to diffs[equalities[equalitiesLength - 1]][1] - var pointer = 0; // Index of current position. - // Number of characters that changed prior to the equality. - var length_insertions1 = 0; - var length_deletions1 = 0; - // Number of characters that changed after the equality. - var length_insertions2 = 0; - var length_deletions2 = 0; - while (pointer < diffs.length) { - if (diffs[pointer][0] == DIFF_EQUAL) { // Equality found. - equalities[equalitiesLength++] = pointer; - length_insertions1 = length_insertions2; - length_deletions1 = length_deletions2; - length_insertions2 = 0; - length_deletions2 = 0; - lastEquality = diffs[pointer][1]; - } else { // An insertion or deletion. - if (diffs[pointer][0] == DIFF_INSERT) { - length_insertions2 += diffs[pointer][1].length; - } else { - length_deletions2 += diffs[pointer][1].length; - } - // Eliminate an equality that is smaller or equal to the edits on both - // sides of it. - if (lastEquality && (lastEquality.length <= - Math.max(length_insertions1, length_deletions1)) && - (lastEquality.length <= Math.max(length_insertions2, - length_deletions2))) { - // Duplicate record. - diffs.splice(equalities[equalitiesLength - 1], 0, - new diff_match_patch.Diff(DIFF_DELETE, lastEquality)); - // Change second copy to insert. - diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; - // Throw away the equality we just deleted. - equalitiesLength--; - // Throw away the previous equality (it needs to be reevaluated). - equalitiesLength--; - pointer = equalitiesLength > 0 ? equalities[equalitiesLength - 1] : -1; - length_insertions1 = 0; // Reset the counters. - length_deletions1 = 0; - length_insertions2 = 0; - length_deletions2 = 0; - lastEquality = null; - changes = true; - } - } - pointer++; - } - - // Normalize the diff. - if (changes) { - this.diff_cleanupMerge(diffs); - } - this.diff_cleanupSemanticLossless(diffs); - - // Find any overlaps between deletions and insertions. - // e.g: abcxxxxxxdef - // -> abcxxxdef - // e.g: xxxabcdefxxx - // -> defxxxabc - // Only extract an overlap if it is as big as the edit ahead or behind it. - pointer = 1; - while (pointer < diffs.length) { - if (diffs[pointer - 1][0] == DIFF_DELETE && - diffs[pointer][0] == DIFF_INSERT) { - var deletion = diffs[pointer - 1][1]; - var insertion = diffs[pointer][1]; - var overlap_length1 = this.diff_commonOverlap_(deletion, insertion); - var overlap_length2 = this.diff_commonOverlap_(insertion, deletion); - if (overlap_length1 >= overlap_length2) { - if (overlap_length1 >= deletion.length / 2 || - overlap_length1 >= insertion.length / 2) { - // Overlap found. Insert an equality and trim the surrounding edits. - diffs.splice(pointer, 0, new diff_match_patch.Diff(DIFF_EQUAL, - insertion.substring(0, overlap_length1))); - diffs[pointer - 1][1] = - deletion.substring(0, deletion.length - overlap_length1); - diffs[pointer + 1][1] = insertion.substring(overlap_length1); - pointer++; - } - } else { - if (overlap_length2 >= deletion.length / 2 || - overlap_length2 >= insertion.length / 2) { - // Reverse overlap found. - // Insert an equality and swap and trim the surrounding edits. - diffs.splice(pointer, 0, new diff_match_patch.Diff(DIFF_EQUAL, - deletion.substring(0, overlap_length2))); - diffs[pointer - 1][0] = DIFF_INSERT; - diffs[pointer - 1][1] = - insertion.substring(0, insertion.length - overlap_length2); - diffs[pointer + 1][0] = DIFF_DELETE; - diffs[pointer + 1][1] = - deletion.substring(overlap_length2); - pointer++; - } - } - pointer++; - } - pointer++; - } -}; - - -/** - * Look for single edits surrounded on both sides by equalities - * which can be shifted sideways to align the edit to a word boundary. - * e.g: The cat came. -> The cat came. - * @param {!Array.} diffs Array of diff tuples. - */ -diff_match_patch.prototype.diff_cleanupSemanticLossless = function(diffs) { - /** - * Given two strings, compute a score representing whether the internal - * boundary falls on logical boundaries. - * Scores range from 6 (best) to 0 (worst). - * Closure, but does not reference any external variables. - * @param {string} one First string. - * @param {string} two Second string. - * @return {number} The score. - * @private - */ - function diff_cleanupSemanticScore_(one, two) { - if (!one || !two) { - // Edges are the best. - return 6; - } - - // Each port of this function behaves slightly differently due to - // subtle differences in each language's definition of things like - // 'whitespace'. Since this function's purpose is largely cosmetic, - // the choice has been made to use each language's native features - // rather than force total conformity. - var char1 = one.charAt(one.length - 1); - var char2 = two.charAt(0); - var nonAlphaNumeric1 = char1.match(diff_match_patch.nonAlphaNumericRegex_); - var nonAlphaNumeric2 = char2.match(diff_match_patch.nonAlphaNumericRegex_); - var whitespace1 = nonAlphaNumeric1 && - char1.match(diff_match_patch.whitespaceRegex_); - var whitespace2 = nonAlphaNumeric2 && - char2.match(diff_match_patch.whitespaceRegex_); - var lineBreak1 = whitespace1 && - char1.match(diff_match_patch.linebreakRegex_); - var lineBreak2 = whitespace2 && - char2.match(diff_match_patch.linebreakRegex_); - var blankLine1 = lineBreak1 && - one.match(diff_match_patch.blanklineEndRegex_); - var blankLine2 = lineBreak2 && - two.match(diff_match_patch.blanklineStartRegex_); - - if (blankLine1 || blankLine2) { - // Five points for blank lines. - return 5; - } else if (lineBreak1 || lineBreak2) { - // Four points for line breaks. - return 4; - } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { - // Three points for end of sentences. - return 3; - } else if (whitespace1 || whitespace2) { - // Two points for whitespace. - return 2; - } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { - // One point for non-alphanumeric. - return 1; - } - return 0; - } - - var pointer = 1; - // Intentionally ignore the first and last element (don't need checking). - while (pointer < diffs.length - 1) { - if (diffs[pointer - 1][0] == DIFF_EQUAL && - diffs[pointer + 1][0] == DIFF_EQUAL) { - // This is a single edit surrounded by equalities. - var equality1 = diffs[pointer - 1][1]; - var edit = diffs[pointer][1]; - var equality2 = diffs[pointer + 1][1]; - - // First, shift the edit as far left as possible. - var commonOffset = this.diff_commonSuffix(equality1, edit); - if (commonOffset) { - var commonString = edit.substring(edit.length - commonOffset); - equality1 = equality1.substring(0, equality1.length - commonOffset); - edit = commonString + edit.substring(0, edit.length - commonOffset); - equality2 = commonString + equality2; - } - - // Second, step character by character right, looking for the best fit. - var bestEquality1 = equality1; - var bestEdit = edit; - var bestEquality2 = equality2; - var bestScore = diff_cleanupSemanticScore_(equality1, edit) + - diff_cleanupSemanticScore_(edit, equality2); - while (edit.charAt(0) === equality2.charAt(0)) { - equality1 += edit.charAt(0); - edit = edit.substring(1) + equality2.charAt(0); - equality2 = equality2.substring(1); - var score = diff_cleanupSemanticScore_(equality1, edit) + - diff_cleanupSemanticScore_(edit, equality2); - // The >= encourages trailing rather than leading whitespace on edits. - if (score >= bestScore) { - bestScore = score; - bestEquality1 = equality1; - bestEdit = edit; - bestEquality2 = equality2; - } - } - - if (diffs[pointer - 1][1] != bestEquality1) { - // We have an improvement, save it back to the diff. - if (bestEquality1) { - diffs[pointer - 1][1] = bestEquality1; - } else { - diffs.splice(pointer - 1, 1); - pointer--; - } - diffs[pointer][1] = bestEdit; - if (bestEquality2) { - diffs[pointer + 1][1] = bestEquality2; - } else { - diffs.splice(pointer + 1, 1); - pointer--; - } - } - } - pointer++; - } -}; - -// Define some regex patterns for matching boundaries. -diff_match_patch.nonAlphaNumericRegex_ = /[^a-zA-Z0-9]/; -diff_match_patch.whitespaceRegex_ = /\s/; -diff_match_patch.linebreakRegex_ = /[\r\n]/; -diff_match_patch.blanklineEndRegex_ = /\n\r?\n$/; -diff_match_patch.blanklineStartRegex_ = /^\r?\n\r?\n/; - -/** - * Reduce the number of edits by eliminating operationally trivial equalities. - * @param {!Array.} diffs Array of diff tuples. - */ -diff_match_patch.prototype.diff_cleanupEfficiency = function(diffs) { - var changes = false; - var equalities = []; // Stack of indices where equalities are found. - var equalitiesLength = 0; // Keeping our own length var is faster in JS. - /** @type {?string} */ - var lastEquality = null; - // Always equal to diffs[equalities[equalitiesLength - 1]][1] - var pointer = 0; // Index of current position. - // Is there an insertion operation before the last equality. - var pre_ins = false; - // Is there a deletion operation before the last equality. - var pre_del = false; - // Is there an insertion operation after the last equality. - var post_ins = false; - // Is there a deletion operation after the last equality. - var post_del = false; - while (pointer < diffs.length) { - if (diffs[pointer][0] == DIFF_EQUAL) { // Equality found. - if (diffs[pointer][1].length < this.Diff_EditCost && - (post_ins || post_del)) { - // Candidate found. - equalities[equalitiesLength++] = pointer; - pre_ins = post_ins; - pre_del = post_del; - lastEquality = diffs[pointer][1]; - } else { - // Not a candidate, and can never become one. - equalitiesLength = 0; - lastEquality = null; - } - post_ins = post_del = false; - } else { // An insertion or deletion. - if (diffs[pointer][0] == DIFF_DELETE) { - post_del = true; - } else { - post_ins = true; - } - /* - * Five types to be split: - * ABXYCD - * AXCD - * ABXC - * AXCD - * ABXC - */ - if (lastEquality && ((pre_ins && pre_del && post_ins && post_del) || - ((lastEquality.length < this.Diff_EditCost / 2) && - (pre_ins + pre_del + post_ins + post_del) == 3))) { - // Duplicate record. - diffs.splice(equalities[equalitiesLength - 1], 0, - new diff_match_patch.Diff(DIFF_DELETE, lastEquality)); - // Change second copy to insert. - diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; - equalitiesLength--; // Throw away the equality we just deleted; - lastEquality = null; - if (pre_ins && pre_del) { - // No changes made which could affect previous entry, keep going. - post_ins = post_del = true; - equalitiesLength = 0; - } else { - equalitiesLength--; // Throw away the previous equality. - pointer = equalitiesLength > 0 ? - equalities[equalitiesLength - 1] : -1; - post_ins = post_del = false; - } - changes = true; - } - } - pointer++; - } - - if (changes) { - this.diff_cleanupMerge(diffs); - } -}; - - -/** - * Reorder and merge like edit sections. Merge equalities. - * Any edit section can move as long as it doesn't cross an equality. - * @param {!Array.} diffs Array of diff tuples. - */ -diff_match_patch.prototype.diff_cleanupMerge = function(diffs) { - // Add a dummy entry at the end. - diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); - var pointer = 0; - var count_delete = 0; - var count_insert = 0; - var text_delete = ''; - var text_insert = ''; - var commonlength; - while (pointer < diffs.length) { - switch (diffs[pointer][0]) { - case DIFF_INSERT: - count_insert++; - text_insert += diffs[pointer][1]; - pointer++; - break; - case DIFF_DELETE: - count_delete++; - text_delete += diffs[pointer][1]; - pointer++; - break; - case DIFF_EQUAL: - // Upon reaching an equality, check for prior redundancies. - if (count_delete + count_insert > 1) { - if (count_delete !== 0 && count_insert !== 0) { - // Factor out any common prefixies. - commonlength = this.diff_commonPrefix(text_insert, text_delete); - if (commonlength !== 0) { - if ((pointer - count_delete - count_insert) > 0 && - diffs[pointer - count_delete - count_insert - 1][0] == - DIFF_EQUAL) { - diffs[pointer - count_delete - count_insert - 1][1] += - text_insert.substring(0, commonlength); - } else { - diffs.splice(0, 0, new diff_match_patch.Diff(DIFF_EQUAL, - text_insert.substring(0, commonlength))); - pointer++; - } - text_insert = text_insert.substring(commonlength); - text_delete = text_delete.substring(commonlength); - } - // Factor out any common suffixies. - commonlength = this.diff_commonSuffix(text_insert, text_delete); - if (commonlength !== 0) { - diffs[pointer][1] = text_insert.substring(text_insert.length - - commonlength) + diffs[pointer][1]; - text_insert = text_insert.substring(0, text_insert.length - - commonlength); - text_delete = text_delete.substring(0, text_delete.length - - commonlength); - } - } - // Delete the offending records and add the merged ones. - pointer -= count_delete + count_insert; - diffs.splice(pointer, count_delete + count_insert); - if (text_delete.length) { - diffs.splice(pointer, 0, - new diff_match_patch.Diff(DIFF_DELETE, text_delete)); - pointer++; - } - if (text_insert.length) { - diffs.splice(pointer, 0, - new diff_match_patch.Diff(DIFF_INSERT, text_insert)); - pointer++; - } - pointer++; - } else if (pointer !== 0 && diffs[pointer - 1][0] == DIFF_EQUAL) { - // Merge this equality with the previous one. - diffs[pointer - 1][1] += diffs[pointer][1]; - diffs.splice(pointer, 1); - } else { - pointer++; - } - count_insert = 0; - count_delete = 0; - text_delete = ''; - text_insert = ''; - break; - } - } - if (diffs[diffs.length - 1][1] === '') { - diffs.pop(); // Remove the dummy entry at the end. - } - - // Second pass: look for single edits surrounded on both sides by equalities - // which can be shifted sideways to eliminate an equality. - // e.g: ABAC -> ABAC - var changes = false; - pointer = 1; - // Intentionally ignore the first and last element (don't need checking). - while (pointer < diffs.length - 1) { - if (diffs[pointer - 1][0] == DIFF_EQUAL && - diffs[pointer + 1][0] == DIFF_EQUAL) { - // This is a single edit surrounded by equalities. - if (diffs[pointer][1].substring(diffs[pointer][1].length - - diffs[pointer - 1][1].length) == diffs[pointer - 1][1]) { - // Shift the edit over the previous equality. - diffs[pointer][1] = diffs[pointer - 1][1] + - diffs[pointer][1].substring(0, diffs[pointer][1].length - - diffs[pointer - 1][1].length); - diffs[pointer + 1][1] = diffs[pointer - 1][1] + diffs[pointer + 1][1]; - diffs.splice(pointer - 1, 1); - changes = true; - } else if (diffs[pointer][1].substring(0, diffs[pointer + 1][1].length) == - diffs[pointer + 1][1]) { - // Shift the edit over the next equality. - diffs[pointer - 1][1] += diffs[pointer + 1][1]; - diffs[pointer][1] = - diffs[pointer][1].substring(diffs[pointer + 1][1].length) + - diffs[pointer + 1][1]; - diffs.splice(pointer + 1, 1); - changes = true; - } - } - pointer++; - } - // If shifts were made, the diff needs reordering and another shift sweep. - if (changes) { - this.diff_cleanupMerge(diffs); - } -}; - - -/** - * loc is a location in text1, compute and return the equivalent location in - * text2. - * e.g. 'The cat' vs 'The big cat', 1->1, 5->8 - * @param {!Array.} diffs Array of diff tuples. - * @param {number} loc Location within text1. - * @return {number} Location within text2. - */ -diff_match_patch.prototype.diff_xIndex = function(diffs, loc) { - var chars1 = 0; - var chars2 = 0; - var last_chars1 = 0; - var last_chars2 = 0; - var x; - for (x = 0; x < diffs.length; x++) { - if (diffs[x][0] !== DIFF_INSERT) { // Equality or deletion. - chars1 += diffs[x][1].length; - } - if (diffs[x][0] !== DIFF_DELETE) { // Equality or insertion. - chars2 += diffs[x][1].length; - } - if (chars1 > loc) { // Overshot the location. - break; - } - last_chars1 = chars1; - last_chars2 = chars2; - } - // Was the location was deleted? - if (diffs.length != x && diffs[x][0] === DIFF_DELETE) { - return last_chars2; - } - // Add the remaining character length. - return last_chars2 + (loc - last_chars1); -}; - - -/** - * Convert a diff array into a pretty HTML report. - * @param {!Array.} diffs Array of diff tuples. - * @return {string} HTML representation. - */ -diff_match_patch.prototype.diff_prettyHtml = function(diffs) { - var html = []; - var pattern_amp = /&/g; - var pattern_lt = //g; - var pattern_para = /\n/g; - for (var x = 0; x < diffs.length; x++) { - var op = diffs[x][0]; // Operation (insert, delete, equal) - var data = diffs[x][1]; // Text of change. - var text = data.replace(pattern_amp, '&').replace(pattern_lt, '<') - .replace(pattern_gt, '>').replace(pattern_para, '¶
'); - switch (op) { - case DIFF_INSERT: - html[x] = '' + text + ''; - break; - case DIFF_DELETE: - html[x] = '' + text + ''; - break; - case DIFF_EQUAL: - html[x] = '' + text + ''; - break; - } - } - return html.join(''); -}; - - -/** - * Compute and return the source text (all equalities and deletions). - * @param {!Array.} diffs Array of diff tuples. - * @return {string} Source text. - */ -diff_match_patch.prototype.diff_text1 = function(diffs) { - var text = []; - for (var x = 0; x < diffs.length; x++) { - if (diffs[x][0] !== DIFF_INSERT) { - text[x] = diffs[x][1]; - } - } - return text.join(''); -}; - - -/** - * Compute and return the destination text (all equalities and insertions). - * @param {!Array.} diffs Array of diff tuples. - * @return {string} Destination text. - */ -diff_match_patch.prototype.diff_text2 = function(diffs) { - var text = []; - for (var x = 0; x < diffs.length; x++) { - if (diffs[x][0] !== DIFF_DELETE) { - text[x] = diffs[x][1]; - } - } - return text.join(''); -}; - - -/** - * Compute the Levenshtein distance; the number of inserted, deleted or - * substituted characters. - * @param {!Array.} diffs Array of diff tuples. - * @return {number} Number of changes. - */ -diff_match_patch.prototype.diff_levenshtein = function(diffs) { - var levenshtein = 0; - var insertions = 0; - var deletions = 0; - for (var x = 0; x < diffs.length; x++) { - var op = diffs[x][0]; - var data = diffs[x][1]; - switch (op) { - case DIFF_INSERT: - insertions += data.length; - break; - case DIFF_DELETE: - deletions += data.length; - break; - case DIFF_EQUAL: - // A deletion and an insertion is one substitution. - levenshtein += Math.max(insertions, deletions); - insertions = 0; - deletions = 0; - break; - } - } - levenshtein += Math.max(insertions, deletions); - return levenshtein; -}; - - -/** - * Crush the diff into an encoded string which describes the operations - * required to transform text1 into text2. - * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. - * Operations are tab-separated. Inserted text is escaped using %xx notation. - * @param {!Array.} diffs Array of diff tuples. - * @return {string} Delta text. - */ -diff_match_patch.prototype.diff_toDelta = function(diffs) { - var text = []; - for (var x = 0; x < diffs.length; x++) { - switch (diffs[x][0]) { - case DIFF_INSERT: - text[x] = '+' + encodeURI(diffs[x][1]); - break; - case DIFF_DELETE: - text[x] = '-' + diffs[x][1].length; - break; - case DIFF_EQUAL: - text[x] = '=' + diffs[x][1].length; - break; - } - } - return text.join('\t').replace(/%20/g, ' '); -}; - - -/** - * Given the original text1, and an encoded string which describes the - * operations required to transform text1 into text2, compute the full diff. - * @param {string} text1 Source string for the diff. - * @param {string} delta Delta text. - * @return {!Array.} Array of diff tuples. - * @throws {!Error} If invalid input. - */ -diff_match_patch.prototype.diff_fromDelta = function(text1, delta) { - var diffs = []; - var diffsLength = 0; // Keeping our own length var is faster in JS. - var pointer = 0; // Cursor in text1 - var tokens = delta.split(/\t/g); - for (var x = 0; x < tokens.length; x++) { - // Each token begins with a one character parameter which specifies the - // operation of this token (delete, insert, equality). - var param = tokens[x].substring(1); - switch (tokens[x].charAt(0)) { - case '+': - try { - diffs[diffsLength++] = - new diff_match_patch.Diff(DIFF_INSERT, decodeURI(param)); - } catch (ex) { - // Malformed URI sequence. - throw new Error('Illegal escape in diff_fromDelta: ' + param); - } - break; - case '-': - // Fall through. - case '=': - var n = parseInt(param, 10); - if (isNaN(n) || n < 0) { - throw new Error('Invalid number in diff_fromDelta: ' + param); - } - var text = text1.substring(pointer, pointer += n); - if (tokens[x].charAt(0) == '=') { - diffs[diffsLength++] = new diff_match_patch.Diff(DIFF_EQUAL, text); - } else { - diffs[diffsLength++] = new diff_match_patch.Diff(DIFF_DELETE, text); - } - break; - default: - // Blank tokens are ok (from a trailing \t). - // Anything else is an error. - if (tokens[x]) { - throw new Error('Invalid diff operation in diff_fromDelta: ' + - tokens[x]); - } - } - } - if (pointer != text1.length) { - throw new Error('Delta length (' + pointer + - ') does not equal source text length (' + text1.length + ').'); - } - return diffs; -}; - - -// MATCH FUNCTIONS - - -/** - * Locate the best instance of 'pattern' in 'text' near 'loc'. - * @param {string} text The text to search. - * @param {string} pattern The pattern to search for. - * @param {number} loc The location to search around. - * @return {number} Best match index or -1. - */ -diff_match_patch.prototype.match_main = function(text, pattern, loc) { - // Check for null inputs. - if (text == null || pattern == null || loc == null) { - throw new Error('Null input. (match_main)'); - } - - loc = Math.max(0, Math.min(loc, text.length)); - if (text == pattern) { - // Shortcut (potentially not guaranteed by the algorithm) - return 0; - } else if (!text.length) { - // Nothing to match. - return -1; - } else if (text.substring(loc, loc + pattern.length) == pattern) { - // Perfect match at the perfect spot! (Includes case of null pattern) - return loc; - } else { - // Do a fuzzy compare. - return this.match_bitap_(text, pattern, loc); - } -}; - - -/** - * Locate the best instance of 'pattern' in 'text' near 'loc' using the - * Bitap algorithm. - * @param {string} text The text to search. - * @param {string} pattern The pattern to search for. - * @param {number} loc The location to search around. - * @return {number} Best match index or -1. - * @private - */ -diff_match_patch.prototype.match_bitap_ = function(text, pattern, loc) { - if (pattern.length > this.Match_MaxBits) { - throw new Error('Pattern too long for this browser.'); - } - - // Initialise the alphabet. - var s = this.match_alphabet_(pattern); - - var dmp = this; // 'this' becomes 'window' in a closure. - - /** - * Compute and return the score for a match with e errors and x location. - * Accesses loc and pattern through being a closure. - * @param {number} e Number of errors in match. - * @param {number} x Location of match. - * @return {number} Overall score for match (0.0 = good, 1.0 = bad). - * @private - */ - function match_bitapScore_(e, x) { - var accuracy = e / pattern.length; - var proximity = Math.abs(loc - x); - if (!dmp.Match_Distance) { - // Dodge divide by zero error. - return proximity ? 1.0 : accuracy; - } - return accuracy + (proximity / dmp.Match_Distance); - } - - // Highest score beyond which we give up. - var score_threshold = this.Match_Threshold; - // Is there a nearby exact match? (speedup) - var best_loc = text.indexOf(pattern, loc); - if (best_loc != -1) { - score_threshold = Math.min(match_bitapScore_(0, best_loc), score_threshold); - // What about in the other direction? (speedup) - best_loc = text.lastIndexOf(pattern, loc + pattern.length); - if (best_loc != -1) { - score_threshold = - Math.min(match_bitapScore_(0, best_loc), score_threshold); - } - } - - // Initialise the bit arrays. - var matchmask = 1 << (pattern.length - 1); - best_loc = -1; - - var bin_min, bin_mid; - var bin_max = pattern.length + text.length; - var last_rd; - for (var d = 0; d < pattern.length; d++) { - // Scan for the best match; each iteration allows for one more error. - // Run a binary search to determine how far from 'loc' we can stray at this - // error level. - bin_min = 0; - bin_mid = bin_max; - while (bin_min < bin_mid) { - if (match_bitapScore_(d, loc + bin_mid) <= score_threshold) { - bin_min = bin_mid; - } else { - bin_max = bin_mid; - } - bin_mid = Math.floor((bin_max - bin_min) / 2 + bin_min); - } - // Use the result from this iteration as the maximum for the next. - bin_max = bin_mid; - var start = Math.max(1, loc - bin_mid + 1); - var finish = Math.min(loc + bin_mid, text.length) + pattern.length; - - var rd = Array(finish + 2); - rd[finish + 1] = (1 << d) - 1; - for (var j = finish; j >= start; j--) { - // The alphabet (s) is a sparse hash, so the following line generates - // warnings. - var charMatch = s[text.charAt(j - 1)]; - if (d === 0) { // First pass: exact match. - rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; - } else { // Subsequent passes: fuzzy match. - rd[j] = (((rd[j + 1] << 1) | 1) & charMatch) | - (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | - last_rd[j + 1]; - } - if (rd[j] & matchmask) { - var score = match_bitapScore_(d, j - 1); - // This match will almost certainly be better than any existing match. - // But check anyway. - if (score <= score_threshold) { - // Told you so. - score_threshold = score; - best_loc = j - 1; - if (best_loc > loc) { - // When passing loc, don't exceed our current distance from loc. - start = Math.max(1, 2 * loc - best_loc); - } else { - // Already passed loc, downhill from here on in. - break; - } - } - } - } - // No hope for a (better) match at greater error levels. - if (match_bitapScore_(d + 1, loc) > score_threshold) { - break; - } - last_rd = rd; - } - return best_loc; -}; - - -/** - * Initialise the alphabet for the Bitap algorithm. - * @param {string} pattern The text to encode. - * @return {!Object} Hash of character locations. - * @private - */ -diff_match_patch.prototype.match_alphabet_ = function(pattern) { - var s = {}; - for (var i = 0; i < pattern.length; i++) { - s[pattern.charAt(i)] = 0; - } - for (var i = 0; i < pattern.length; i++) { - s[pattern.charAt(i)] |= 1 << (pattern.length - i - 1); - } - return s; -}; - - -// PATCH FUNCTIONS - - -/** - * Increase the context until it is unique, - * but don't let the pattern expand beyond Match_MaxBits. - * @param {!diff_match_patch.patch_obj} patch The patch to grow. - * @param {string} text Source text. - * @private - */ -diff_match_patch.prototype.patch_addContext_ = function(patch, text) { - if (text.length == 0) { - return; - } - if (patch.start2 === null) { - throw Error('patch not initialized'); - } - var pattern = text.substring(patch.start2, patch.start2 + patch.length1); - var padding = 0; - - // Look for the first and last matches of pattern in text. If two different - // matches are found, increase the pattern length. - while (text.indexOf(pattern) != text.lastIndexOf(pattern) && - pattern.length < this.Match_MaxBits - this.Patch_Margin - - this.Patch_Margin) { - padding += this.Patch_Margin; - pattern = text.substring(patch.start2 - padding, - patch.start2 + patch.length1 + padding); - } - // Add one chunk for good luck. - padding += this.Patch_Margin; - - // Add the prefix. - var prefix = text.substring(patch.start2 - padding, patch.start2); - if (prefix) { - patch.diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, prefix)); - } - // Add the suffix. - var suffix = text.substring(patch.start2 + patch.length1, - patch.start2 + patch.length1 + padding); - if (suffix) { - patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, suffix)); - } - - // Roll back the start points. - patch.start1 -= prefix.length; - patch.start2 -= prefix.length; - // Extend the lengths. - patch.length1 += prefix.length + suffix.length; - patch.length2 += prefix.length + suffix.length; -}; - - -/** - * Compute a list of patches to turn text1 into text2. - * Use diffs if provided, otherwise compute it ourselves. - * There are four ways to call this function, depending on what data is - * available to the caller: - * Method 1: - * a = text1, b = text2 - * Method 2: - * a = diffs - * Method 3 (optimal): - * a = text1, b = diffs - * Method 4 (deprecated, use method 3): - * a = text1, b = text2, c = diffs - * - * @param {string|!Array.} a text1 (methods 1,3,4) or - * Array of diff tuples for text1 to text2 (method 2). - * @param {string|!Array.=} opt_b text2 (methods 1,4) or - * Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). - * @param {string|!Array.=} opt_c Array of diff tuples - * for text1 to text2 (method 4) or undefined (methods 1,2,3). - * @return {!Array.} Array of Patch objects. - */ -diff_match_patch.prototype.patch_make = function(a, opt_b, opt_c) { - var text1, diffs; - if (typeof a == 'string' && typeof opt_b == 'string' && - typeof opt_c == 'undefined') { - // Method 1: text1, text2 - // Compute diffs from text1 and text2. - text1 = /** @type {string} */(a); - diffs = this.diff_main(text1, /** @type {string} */(opt_b), true); - if (diffs.length > 2) { - this.diff_cleanupSemantic(diffs); - this.diff_cleanupEfficiency(diffs); - } - } else if (a && typeof a == 'object' && typeof opt_b == 'undefined' && - typeof opt_c == 'undefined') { - // Method 2: diffs - // Compute text1 from diffs. - diffs = /** @type {!Array.} */(a); - text1 = this.diff_text1(diffs); - } else if (typeof a == 'string' && opt_b && typeof opt_b == 'object' && - typeof opt_c == 'undefined') { - // Method 3: text1, diffs - text1 = /** @type {string} */(a); - diffs = /** @type {!Array.} */(opt_b); - } else if (typeof a == 'string' && typeof opt_b == 'string' && - opt_c && typeof opt_c == 'object') { - // Method 4: text1, text2, diffs - // text2 is not used. - text1 = /** @type {string} */(a); - diffs = /** @type {!Array.} */(opt_c); - } else { - throw new Error('Unknown call format to patch_make.'); - } - - if (diffs.length === 0) { - return []; // Get rid of the null case. - } - var patches = []; - var patch = new diff_match_patch.patch_obj(); - var patchDiffLength = 0; // Keeping our own length var is faster in JS. - var char_count1 = 0; // Number of characters into the text1 string. - var char_count2 = 0; // Number of characters into the text2 string. - // Start with text1 (prepatch_text) and apply the diffs until we arrive at - // text2 (postpatch_text). We recreate the patches one by one to determine - // context info. - var prepatch_text = text1; - var postpatch_text = text1; - for (var x = 0; x < diffs.length; x++) { - var diff_type = diffs[x][0]; - var diff_text = diffs[x][1]; - - if (!patchDiffLength && diff_type !== DIFF_EQUAL) { - // A new patch starts here. - patch.start1 = char_count1; - patch.start2 = char_count2; - } - - switch (diff_type) { - case DIFF_INSERT: - patch.diffs[patchDiffLength++] = diffs[x]; - patch.length2 += diff_text.length; - postpatch_text = postpatch_text.substring(0, char_count2) + diff_text + - postpatch_text.substring(char_count2); - break; - case DIFF_DELETE: - patch.length1 += diff_text.length; - patch.diffs[patchDiffLength++] = diffs[x]; - postpatch_text = postpatch_text.substring(0, char_count2) + - postpatch_text.substring(char_count2 + - diff_text.length); - break; - case DIFF_EQUAL: - if (diff_text.length <= 2 * this.Patch_Margin && - patchDiffLength && diffs.length != x + 1) { - // Small equality inside a patch. - patch.diffs[patchDiffLength++] = diffs[x]; - patch.length1 += diff_text.length; - patch.length2 += diff_text.length; - } else if (diff_text.length >= 2 * this.Patch_Margin) { - // Time for a new patch. - if (patchDiffLength) { - this.patch_addContext_(patch, prepatch_text); - patches.push(patch); - patch = new diff_match_patch.patch_obj(); - patchDiffLength = 0; - // Unlike Unidiff, our patch lists have a rolling context. - // https://github.com/google/diff-match-patch/wiki/Unidiff - // Update prepatch text & pos to reflect the application of the - // just completed patch. - prepatch_text = postpatch_text; - char_count1 = char_count2; - } - } - break; - } - - // Update the current character count. - if (diff_type !== DIFF_INSERT) { - char_count1 += diff_text.length; - } - if (diff_type !== DIFF_DELETE) { - char_count2 += diff_text.length; - } - } - // Pick up the leftover patch if not empty. - if (patchDiffLength) { - this.patch_addContext_(patch, prepatch_text); - patches.push(patch); - } - - return patches; -}; - - -/** - * Given an array of patches, return another array that is identical. - * @param {!Array.} patches Array of Patch objects. - * @return {!Array.} Array of Patch objects. - */ -diff_match_patch.prototype.patch_deepCopy = function(patches) { - // Making deep copies is hard in JavaScript. - var patchesCopy = []; - for (var x = 0; x < patches.length; x++) { - var patch = patches[x]; - var patchCopy = new diff_match_patch.patch_obj(); - patchCopy.diffs = []; - for (var y = 0; y < patch.diffs.length; y++) { - patchCopy.diffs[y] = - new diff_match_patch.Diff(patch.diffs[y][0], patch.diffs[y][1]); - } - patchCopy.start1 = patch.start1; - patchCopy.start2 = patch.start2; - patchCopy.length1 = patch.length1; - patchCopy.length2 = patch.length2; - patchesCopy[x] = patchCopy; - } - return patchesCopy; -}; - - -/** - * Merge a set of patches onto the text. Return a patched text, as well - * as a list of true/false values indicating which patches were applied. - * @param {!Array.} patches Array of Patch objects. - * @param {string} text Old text. - * @return {!Array.>} Two element Array, containing the - * new text and an array of boolean values. - */ -diff_match_patch.prototype.patch_apply = function(patches, text) { - if (patches.length == 0) { - return [text, []]; - } - - // Deep copy the patches so that no changes are made to originals. - patches = this.patch_deepCopy(patches); - - var nullPadding = this.patch_addPadding(patches); - text = nullPadding + text + nullPadding; - - this.patch_splitMax(patches); - // delta keeps track of the offset between the expected and actual location - // of the previous patch. If there are patches expected at positions 10 and - // 20, but the first patch was found at 12, delta is 2 and the second patch - // has an effective expected position of 22. - var delta = 0; - var results = []; - for (var x = 0; x < patches.length; x++) { - var expected_loc = patches[x].start2 + delta; - var text1 = this.diff_text1(patches[x].diffs); - var start_loc; - var end_loc = -1; - if (text1.length > this.Match_MaxBits) { - // patch_splitMax will only provide an oversized pattern in the case of - // a monster delete. - start_loc = this.match_main(text, text1.substring(0, this.Match_MaxBits), - expected_loc); - if (start_loc != -1) { - end_loc = this.match_main(text, - text1.substring(text1.length - this.Match_MaxBits), - expected_loc + text1.length - this.Match_MaxBits); - if (end_loc == -1 || start_loc >= end_loc) { - // Can't find valid trailing context. Drop this patch. - start_loc = -1; - } - } - } else { - start_loc = this.match_main(text, text1, expected_loc); - } - if (start_loc == -1) { - // No match found. :( - results[x] = false; - // Subtract the delta for this failed patch from subsequent patches. - delta -= patches[x].length2 - patches[x].length1; - } else { - // Found a match. :) - results[x] = true; - delta = start_loc - expected_loc; - var text2; - if (end_loc == -1) { - text2 = text.substring(start_loc, start_loc + text1.length); - } else { - text2 = text.substring(start_loc, end_loc + this.Match_MaxBits); - } - if (text1 == text2) { - // Perfect match, just shove the replacement text in. - text = text.substring(0, start_loc) + - this.diff_text2(patches[x].diffs) + - text.substring(start_loc + text1.length); - } else { - // Imperfect match. Run a diff to get a framework of equivalent - // indices. - var diffs = this.diff_main(text1, text2, false); - if (text1.length > this.Match_MaxBits && - this.diff_levenshtein(diffs) / text1.length > - this.Patch_DeleteThreshold) { - // The end points match, but the content is unacceptably bad. - results[x] = false; - } else { - this.diff_cleanupSemanticLossless(diffs); - var index1 = 0; - var index2; - for (var y = 0; y < patches[x].diffs.length; y++) { - var mod = patches[x].diffs[y]; - if (mod[0] !== DIFF_EQUAL) { - index2 = this.diff_xIndex(diffs, index1); - } - if (mod[0] === DIFF_INSERT) { // Insertion - text = text.substring(0, start_loc + index2) + mod[1] + - text.substring(start_loc + index2); - } else if (mod[0] === DIFF_DELETE) { // Deletion - text = text.substring(0, start_loc + index2) + - text.substring(start_loc + this.diff_xIndex(diffs, - index1 + mod[1].length)); - } - if (mod[0] !== DIFF_DELETE) { - index1 += mod[1].length; - } - } - } - } - } - } - // Strip the padding off. - text = text.substring(nullPadding.length, text.length - nullPadding.length); - return [text, results]; -}; - - -/** - * Add some padding on text start and end so that edges can match something. - * Intended to be called only from within patch_apply. - * @param {!Array.} patches Array of Patch objects. - * @return {string} The padding string added to each side. - */ -diff_match_patch.prototype.patch_addPadding = function(patches) { - var paddingLength = this.Patch_Margin; - var nullPadding = ''; - for (var x = 1; x <= paddingLength; x++) { - nullPadding += String.fromCharCode(x); - } - - // Bump all the patches forward. - for (var x = 0; x < patches.length; x++) { - patches[x].start1 += paddingLength; - patches[x].start2 += paddingLength; - } - - // Add some padding on start of first diff. - var patch = patches[0]; - var diffs = patch.diffs; - if (diffs.length == 0 || diffs[0][0] != DIFF_EQUAL) { - // Add nullPadding equality. - diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); - patch.start1 -= paddingLength; // Should be 0. - patch.start2 -= paddingLength; // Should be 0. - patch.length1 += paddingLength; - patch.length2 += paddingLength; - } else if (paddingLength > diffs[0][1].length) { - // Grow first equality. - var extraLength = paddingLength - diffs[0][1].length; - diffs[0][1] = nullPadding.substring(diffs[0][1].length) + diffs[0][1]; - patch.start1 -= extraLength; - patch.start2 -= extraLength; - patch.length1 += extraLength; - patch.length2 += extraLength; - } - - // Add some padding on end of last diff. - patch = patches[patches.length - 1]; - diffs = patch.diffs; - if (diffs.length == 0 || diffs[diffs.length - 1][0] != DIFF_EQUAL) { - // Add nullPadding equality. - diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); - patch.length1 += paddingLength; - patch.length2 += paddingLength; - } else if (paddingLength > diffs[diffs.length - 1][1].length) { - // Grow last equality. - var extraLength = paddingLength - diffs[diffs.length - 1][1].length; - diffs[diffs.length - 1][1] += nullPadding.substring(0, extraLength); - patch.length1 += extraLength; - patch.length2 += extraLength; - } - - return nullPadding; -}; - - -/** - * Look through the patches and break up any which are longer than the maximum - * limit of the match algorithm. - * Intended to be called only from within patch_apply. - * @param {!Array.} patches Array of Patch objects. - */ -diff_match_patch.prototype.patch_splitMax = function(patches) { - var patch_size = this.Match_MaxBits; - for (var x = 0; x < patches.length; x++) { - if (patches[x].length1 <= patch_size) { - continue; - } - var bigpatch = patches[x]; - // Remove the big old patch. - patches.splice(x--, 1); - var start1 = bigpatch.start1; - var start2 = bigpatch.start2; - var precontext = ''; - while (bigpatch.diffs.length !== 0) { - // Create one of several smaller patches. - var patch = new diff_match_patch.patch_obj(); - var empty = true; - patch.start1 = start1 - precontext.length; - patch.start2 = start2 - precontext.length; - if (precontext !== '') { - patch.length1 = patch.length2 = precontext.length; - patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, precontext)); - } - while (bigpatch.diffs.length !== 0 && - patch.length1 < patch_size - this.Patch_Margin) { - var diff_type = bigpatch.diffs[0][0]; - var diff_text = bigpatch.diffs[0][1]; - if (diff_type === DIFF_INSERT) { - // Insertions are harmless. - patch.length2 += diff_text.length; - start2 += diff_text.length; - patch.diffs.push(bigpatch.diffs.shift()); - empty = false; - } else if (diff_type === DIFF_DELETE && patch.diffs.length == 1 && - patch.diffs[0][0] == DIFF_EQUAL && - diff_text.length > 2 * patch_size) { - // This is a large deletion. Let it pass in one chunk. - patch.length1 += diff_text.length; - start1 += diff_text.length; - empty = false; - patch.diffs.push(new diff_match_patch.Diff(diff_type, diff_text)); - bigpatch.diffs.shift(); - } else { - // Deletion or equality. Only take as much as we can stomach. - diff_text = diff_text.substring(0, - patch_size - patch.length1 - this.Patch_Margin); - patch.length1 += diff_text.length; - start1 += diff_text.length; - if (diff_type === DIFF_EQUAL) { - patch.length2 += diff_text.length; - start2 += diff_text.length; - } else { - empty = false; - } - patch.diffs.push(new diff_match_patch.Diff(diff_type, diff_text)); - if (diff_text == bigpatch.diffs[0][1]) { - bigpatch.diffs.shift(); - } else { - bigpatch.diffs[0][1] = - bigpatch.diffs[0][1].substring(diff_text.length); - } - } - } - // Compute the head context for the next patch. - precontext = this.diff_text2(patch.diffs); - precontext = - precontext.substring(precontext.length - this.Patch_Margin); - // Append the end context for this patch. - var postcontext = this.diff_text1(bigpatch.diffs) - .substring(0, this.Patch_Margin); - if (postcontext !== '') { - patch.length1 += postcontext.length; - patch.length2 += postcontext.length; - if (patch.diffs.length !== 0 && - patch.diffs[patch.diffs.length - 1][0] === DIFF_EQUAL) { - patch.diffs[patch.diffs.length - 1][1] += postcontext; - } else { - patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, postcontext)); - } - } - if (!empty) { - patches.splice(++x, 0, patch); - } - } - } -}; - - -/** - * Take a list of patches and return a textual representation. - * @param {!Array.} patches Array of Patch objects. - * @return {string} Text representation of patches. - */ -diff_match_patch.prototype.patch_toText = function(patches) { - var text = []; - for (var x = 0; x < patches.length; x++) { - text[x] = patches[x]; - } - return text.join(''); -}; - - -/** - * Parse a textual representation of patches and return a list of Patch objects. - * @param {string} textline Text representation of patches. - * @return {!Array.} Array of Patch objects. - * @throws {!Error} If invalid input. - */ -diff_match_patch.prototype.patch_fromText = function(textline) { - var patches = []; - if (!textline) { - return patches; - } - var text = textline.split('\n'); - var textPointer = 0; - var patchHeader = /^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/; - while (textPointer < text.length) { - var m = text[textPointer].match(patchHeader); - if (!m) { - throw new Error('Invalid patch string: ' + text[textPointer]); - } - var patch = new diff_match_patch.patch_obj(); - patches.push(patch); - patch.start1 = parseInt(m[1], 10); - if (m[2] === '') { - patch.start1--; - patch.length1 = 1; - } else if (m[2] == '0') { - patch.length1 = 0; - } else { - patch.start1--; - patch.length1 = parseInt(m[2], 10); - } - - patch.start2 = parseInt(m[3], 10); - if (m[4] === '') { - patch.start2--; - patch.length2 = 1; - } else if (m[4] == '0') { - patch.length2 = 0; - } else { - patch.start2--; - patch.length2 = parseInt(m[4], 10); - } - textPointer++; - - while (textPointer < text.length) { - var sign = text[textPointer].charAt(0); - try { - var line = decodeURI(text[textPointer].substring(1)); - } catch (ex) { - // Malformed URI sequence. - throw new Error('Illegal escape in patch_fromText: ' + line); - } - if (sign == '-') { - // Deletion. - patch.diffs.push(new diff_match_patch.Diff(DIFF_DELETE, line)); - } else if (sign == '+') { - // Insertion. - patch.diffs.push(new diff_match_patch.Diff(DIFF_INSERT, line)); - } else if (sign == ' ') { - // Minor equality. - patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, line)); - } else if (sign == '@') { - // Start of next patch. - break; - } else if (sign === '') { - // Blank line? Whatever. - } else { - // WTF? - throw new Error('Invalid patch mode "' + sign + '" in: ' + line); - } - textPointer++; - } - } - return patches; -}; - - -/** - * Class representing one patch operation. - * @constructor - */ -diff_match_patch.patch_obj = function() { - /** @type {!Array.} */ - this.diffs = []; - /** @type {?number} */ - this.start1 = null; - /** @type {?number} */ - this.start2 = null; - /** @type {number} */ - this.length1 = 0; - /** @type {number} */ - this.length2 = 0; -}; - - -/** - * Emulate GNU diff's format. - * Header: @@ -382,8 +481,9 @@ - * Indices are printed as 1-based, not 0-based. - * @return {string} The GNU diff string. - */ -diff_match_patch.patch_obj.prototype.toString = function() { - var coords1, coords2; - if (this.length1 === 0) { - coords1 = this.start1 + ',0'; - } else if (this.length1 == 1) { - coords1 = this.start1 + 1; - } else { - coords1 = (this.start1 + 1) + ',' + this.length1; - } - if (this.length2 === 0) { - coords2 = this.start2 + ',0'; - } else if (this.length2 == 1) { - coords2 = this.start2 + 1; - } else { - coords2 = (this.start2 + 1) + ',' + this.length2; - } - var text = ['@@ -' + coords1 + ' +' + coords2 + ' @@\n']; - var op; - // Escape the body of the patch with %xx notation. - for (var x = 0; x < this.diffs.length; x++) { - switch (this.diffs[x][0]) { - case DIFF_INSERT: - op = '+'; - break; - case DIFF_DELETE: - op = '-'; - break; - case DIFF_EQUAL: - op = ' '; - break; - } - text[x + 1] = op + encodeURI(this.diffs[x][1]) + '\n'; - } - return text.join('').replace(/%20/g, ' '); -}; - -module.exports = { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL }; diff --git a/packages/playwright-core/src/utils/DEPS.list b/packages/playwright-core/src/utils/DEPS.list index 514f09d407..7cfdafe4a1 100644 --- a/packages/playwright-core/src/utils/DEPS.list +++ b/packages/playwright-core/src/utils/DEPS.list @@ -1,6 +1,5 @@ [*] ./ -../third_party/diff_match_patch ../third_party/pixelmatch ../image_tools/compare.ts ../utilsBundle.ts diff --git a/packages/playwright-core/src/utils/comparators.ts b/packages/playwright-core/src/utils/comparators.ts index ef63b2d376..da707e20c4 100644 --- a/packages/playwright-core/src/utils/comparators.ts +++ b/packages/playwright-core/src/utils/comparators.ts @@ -18,7 +18,7 @@ import { colors, jpegjs } from '../utilsBundle'; const pixelmatch = require('../third_party/pixelmatch'); import { compare } from '../image_tools/compare'; -const { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } = require('../third_party/diff_match_patch'); +const { diffMatchPatch } = require('../utilsBundle'); import { PNG } from '../utilsBundle'; export type ImageComparatorOptions = { threshold?: number, maxDiffPixels?: number, maxDiffPixelRatio?: number, comparator?: string }; @@ -106,6 +106,7 @@ function validateBuffer(buffer: Buffer, mimeType: string): void { } function compareText(actual: Buffer | string, expectedBuffer: Buffer): ComparatorResult { + const { diff_match_patch } = diffMatchPatch; if (typeof actual !== 'string') return { errorMessage: 'Actual result should be a string' }; const expected = expectedBuffer.toString('utf-8'); @@ -120,6 +121,7 @@ function compareText(actual: Buffer | string, expectedBuffer: Buffer): Comparato } function diff_prettyTerminal(diffs: [number, string][]) { + const { DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } = diffMatchPatch; const html = []; for (let x = 0; x < diffs.length; x++) { const op = diffs[x][0]; // Operation (insert, delete, equal) diff --git a/packages/playwright-core/src/utils/index.ts b/packages/playwright-core/src/utils/index.ts index 0bc7a75b08..c570e7b32e 100644 --- a/packages/playwright-core/src/utils/index.ts +++ b/packages/playwright-core/src/utils/index.ts @@ -33,6 +33,7 @@ export * from './isomorphic/stringUtils'; export * from './isomorphic/urlMatch'; export * from './multimap'; export * from './network'; +export * from './patch'; export * from './processLauncher'; export * from './profiler'; export * from './rtti'; diff --git a/packages/playwright-core/src/utils/patch.ts b/packages/playwright-core/src/utils/patch.ts new file mode 100644 index 0000000000..91fa31b8f3 --- /dev/null +++ b/packages/playwright-core/src/utils/patch.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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. + */ + +import { diffMatchPatch } from '../utilsBundle'; + +type Hunk = { + lines: string[]; + startNew: number; + startOld: number; + contextBefore: number; + contextAfter: number; +}; + +export function generateUnifiedDiff(text1: string, text2: string, relativeName: string = 'file'): string { + const { diff_match_patch, DIFF_EQUAL, DIFF_DELETE, DIFF_INSERT } = diffMatchPatch; + const dmp = new diff_match_patch(); + + const a = text1.replace(/\r\n/g, '\n'); + const b = text2.replace(/\r\n/g, '\n'); + + const { chars1, chars2, lineArray } = dmp.diff_linesToChars_(a, b); + const diffs = dmp.diff_main(chars1, chars2, false); + dmp.diff_charsToLines_(diffs, lineArray); + + const contextSize = 3; + const hunks: Hunk[] = []; + let lineOld = 1; + let lineNew = 1; + let hunk: Hunk | null = null; + let contextBuffer: string[] = []; + + for (const diff of diffs) { + const op = diff[0]; + const data = diff[1]; + const lines = data.split('\n'); + + // Remove the last empty line if data ends with '\n' + if (lines[lines.length - 1] === '') + lines.pop(); + + for (const line of lines) { + if (op === DIFF_EQUAL) { + if (hunk) { + hunk.lines.push(' ' + line); + hunk.contextAfter++; + + if (hunk.contextAfter >= contextSize) { + // Close the hunk + hunks.push(hunk); + hunk = null; + contextBuffer = []; + } + } else { + contextBuffer.push(' ' + line); + if (contextBuffer.length > contextSize) + contextBuffer.shift(); + } + lineOld++; + lineNew++; + } else { + if (!hunk) { + // Start a new hunk + const hunkStartOld = lineOld - contextBuffer.length; + const hunkStartNew = lineNew - contextBuffer.length; + hunk = { + startOld: hunkStartOld, + startNew: hunkStartNew, + lines: [...contextBuffer], + contextBefore: contextBuffer.length, + contextAfter: 0, + }; + } + hunk.contextAfter = 0; + + if (op === DIFF_DELETE) { + hunk.lines.push('-' + line); + lineOld++; + } else if (op === DIFF_INSERT) { + hunk.lines.push('+' + line); + lineNew++; + } + } + } + } + + if (hunk) + hunks.push(hunk); + + // Build the unified diff text + let diffText = `--- a/${relativeName}\n+++ b/${relativeName}\n`; + for (const hunk of hunks) { + // Calculate hunk ranges + const oldRangeStart = hunk.startOld; + const newRangeStart = hunk.startNew; + let oldRangeLines = 0; + let newRangeLines = 0; + + for (const line of hunk.lines) { + if (line.startsWith('-') || line.startsWith(' ')) + oldRangeLines++; + if (line.startsWith('+') || line.startsWith(' ')) + newRangeLines++; + } + + // Adjust starting line numbers when range is empty + const oldStartLine = oldRangeLines === 0 ? oldRangeStart - 1 : oldRangeStart; + const newStartLine = newRangeLines === 0 ? newRangeStart - 1 : newRangeStart; + + diffText += `@@ -${oldStartLine},${oldRangeLines} +${newStartLine},${newRangeLines} @@\n`; + diffText += hunk.lines.map(line => line + '\n').join(''); + } + + return diffText; +} diff --git a/packages/playwright-core/src/utilsBundle.ts b/packages/playwright-core/src/utilsBundle.ts index b330491e0c..f9dc8347f7 100644 --- a/packages/playwright-core/src/utilsBundle.ts +++ b/packages/playwright-core/src/utilsBundle.ts @@ -19,6 +19,7 @@ import path from 'path'; export const colors: typeof import('../bundles/utils/node_modules/colors/safe') = require('./utilsBundleImpl').colors; export const debug: typeof import('../bundles/utils/node_modules/@types/debug') = require('./utilsBundleImpl').debug; +export const diffMatchPatch: typeof import('../bundles/utils/node_modules/@types/diff-match-patch') = require('./utilsBundleImpl').diffMatchPatch; export const dotenv: typeof import('../bundles/utils/node_modules/dotenv') = require('./utilsBundleImpl').dotenv; export const getProxyForUrl: typeof import('../bundles/utils/node_modules/@types/proxy-from-env').getProxyForUrl = require('./utilsBundleImpl').getProxyForUrl; export const HttpsProxyAgent: typeof import('../bundles/utils/node_modules/https-proxy-agent').HttpsProxyAgent = require('./utilsBundleImpl').HttpsProxyAgent; diff --git a/tests/library/clock.spec.ts b/tests/library/unit/clock.spec.ts similarity index 99% rename from tests/library/clock.spec.ts rename to tests/library/unit/clock.spec.ts index 3db8e968a2..cb204cef07 100644 --- a/tests/library/clock.spec.ts +++ b/tests/library/unit/clock.spec.ts @@ -15,8 +15,8 @@ */ import { test, expect } from '@playwright/test'; -import { createClock as rawCreateClock, install as rawInstall } from '../../packages/playwright-core/src/server/injected/clock'; -import type { InstallConfig, ClockController, ClockMethods } from '../../packages/playwright-core/src/server/injected/clock'; +import { createClock as rawCreateClock, install as rawInstall } from '../../../packages/playwright-core/src/server/injected/clock'; +import type { InstallConfig, ClockController, ClockMethods } from '../../../packages/playwright-core/src/server/injected/clock'; const createClock = (now?: number): ClockController & ClockMethods => { const { clock, api } = rawCreateClock(globalThis); diff --git a/tests/library/unit/patch.spec.ts b/tests/library/unit/patch.spec.ts new file mode 100644 index 0000000000..1188faff6b --- /dev/null +++ b/tests/library/unit/patch.spec.ts @@ -0,0 +1,260 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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. + */ + + +import { test as it, expect } from '@playwright/test'; +import { generateUnifiedDiff } from '../../../packages/playwright-core/lib/utils/patch'; + +it('Identical texts should produce an empty diff', () => { + const text1 = `line1 +line2 +line3`; + const text2 = `line1 +line2 +line3`; + + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toBe(`--- a/file ++++ b/file +`); +}); + +it('Text with an inserted line', () => { + const text1 = `line1 +line2 +line3`; + const text2 = `line1 +line2 +line2.5 +line3`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,3 +1,4 @@ + line1 + line2 ++line2.5 + line3 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); + +it('Text with a deleted line', () => { + const text1 = `line1 +line2 +line3`; + const text2 = `line1 +line3`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,3 +1,2 @@ + line1 +-line2 + line3 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); + +it('Text with modified line', () => { + const text1 = `line1 +line2 +line3`; + const text2 = `line1 +line2 modified +line3`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,3 +1,3 @@ + line1 +-line2 ++line2 modified + line3 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); + +it('Empty original text', () => { + const text1 = ``; + const text2 = `line1 +line2`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -0,0 +1,2 @@ ++line1 ++line2 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); + +it('Empty modified text', () => { + const text1 = `line1 +line2`; + const text2 = ``; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,2 +0,0 @@ +-line1 +-line2 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); + +it('Handling different line endings (CRLF vs LF)', () => { + const text1 = `line1\r\nline2\r\nline3`; + const text2 = `line1\nline2 modified\nline3`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,3 +1,3 @@ + line1 +-line2 ++line2 modified + line3 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); + +it('Large text diff', () => { + const text1 = Array(1000) + .fill('line') + .join('\n'); + const text2 = Array(1000) + .fill('line') + .map((line, index) => (index === 500 ? 'modified line' : line)) + .join('\n'); + + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain('-line\n+modified line'); +}); + +it('Unicode characters', () => { + const text1 = `こんにちは +世界`; + const text2 = `こんにちは +世界! +さようなら`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,2 +1,3 @@ + こんにちは +-世界 ++世界! ++さようなら +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); + +it('Texts with only whitespace differences', () => { + const text1 = `line1 +line2 +line3`; + const text2 = `line1 +line2 +line3`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,3 +1,3 @@ + line1 +-line2 ++line2 + line3 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toBe(expectedDiff); +}); + +it('Custom file names in diff header', () => { + const text1 = `line1 +line2 +line3`; + const text2 = `line1 +line2 modified +line3`; + + const diff = generateUnifiedDiff(text1, text2, 'original.txt'); + expect(diff.startsWith('--- a/original.txt\n+++ b/original.txt\n')).toBe(true); +}); + +it('Multiple consecutive insertions and deletions', () => { + const text1 = `line1 +line2 +line3 +line4 +line5`; + const text2 = `line1 +line2 modified +line3 +line4 modified +line5`; + + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain('-line2\n+line2 modified'); + expect(diff).toContain('-line4\n+line4 modified'); +}); + +it('Handling tabs and special characters', () => { + const text1 = `line1 +line\t2 +line3`; + const text2 = `line1 +line2 +line3`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,3 +1,3 @@ + line1 +-line\t2 ++line2 + line3 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); + +it('Texts with leading and trailing whitespace differences', () => { + const text1 = ` line1 +line2 +line3`; + const text2 = `line1 +line2 +line3`; + + const expectedDiff = `--- a/file ++++ b/file +@@ -1,3 +1,3 @@ +- line1 +-line2 ++line1 ++line2 + line3 +`; + const diff = generateUnifiedDiff(text1, text2); + expect(diff).toContain(expectedDiff); +}); diff --git a/tests/library/sequence.spec.ts b/tests/library/unit/sequence.spec.ts similarity index 98% rename from tests/library/sequence.spec.ts rename to tests/library/unit/sequence.spec.ts index 6004ce4da5..624722753e 100644 --- a/tests/library/sequence.spec.ts +++ b/tests/library/unit/sequence.spec.ts @@ -16,7 +16,7 @@ import { test as it, expect } from '@playwright/test'; -import { findRepeatedSubsequences } from '../../packages/playwright-core/lib/utils/sequence'; +import { findRepeatedSubsequences } from '../../../packages/playwright-core/lib/utils/sequence'; it('should return an empty array when the input is empty', () => { const input = []; From 9a0a6cec1061f277b43110f442aeee66a52a090f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 23 Oct 2024 17:34:21 -0700 Subject: [PATCH 03/17] chore: remove the leaf node notion (#33249) --- .../src/server/injected/ariaSnapshot.ts | 60 +++++-------------- tests/page/page-aria-snapshot.spec.ts | 42 +++++++++---- 2 files changed, 46 insertions(+), 56 deletions(-) diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 7156d0cce7..d544c0a82c 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -16,7 +16,7 @@ import { escapeWithQuotes } from '@isomorphic/stringUtils'; import * as roleUtils from './roleUtils'; -import { isElementVisible, isElementStyleVisibilityVisible, getElementComputedStyle } from './domUtils'; +import { getElementComputedStyle } from './domUtils'; import type { AriaRole } from './roleUtils'; type AriaProps = { @@ -29,7 +29,7 @@ type AriaProps = { }; type AriaNode = AriaProps & { - role: AriaRole | 'fragment' | 'text'; + role: AriaRole | 'fragment'; name: string; children: (AriaNode | string)[]; }; @@ -56,22 +56,10 @@ export function generateAriaTree(rootElement: Element): AriaNode { if (roleUtils.isElementHiddenForAria(element)) return; - const visible = isElementVisible(element); - const hasVisibleChildren = isElementStyleVisibilityVisible(element); - - if (!hasVisibleChildren) - return; - - if (visible) { - const childAriaNode = toAriaNode(element); - const isHiddenContainer = childAriaNode && hiddenContainerRoles.has(childAriaNode.ariaNode.role); - if (childAriaNode && !isHiddenContainer) - ariaNode.children.push(childAriaNode.ariaNode); - if (isHiddenContainer || !childAriaNode?.isLeaf) - processChildNodes(childAriaNode?.ariaNode || ariaNode, element); - } else { - processChildNodes(ariaNode, element); - } + const childAriaNode = toAriaNode(element); + if (childAriaNode) + ariaNode.children.push(childAriaNode); + processChildNodes(childAriaNode || ariaNode, element); }; function processChildNodes(ariaNode: AriaNode, element: Element) { @@ -101,6 +89,9 @@ export function generateAriaTree(rootElement: Element): AriaNode { if (treatAsBlock) ariaNode.children.push(treatAsBlock); + + if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0]) + ariaNode.children = []; } roleUtils.beginAriaCaches(); @@ -115,19 +106,13 @@ export function generateAriaTree(rootElement: Element): AriaNode { return ariaRoot; } -function toAriaNode(element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null { +function toAriaNode(element: Element): AriaNode | null { const role = roleUtils.getAriaRole(element); if (!role) return null; const name = roleUtils.getElementAccessibleName(element, false) || ''; - const isLeaf = leafRoles.has(role); const result: AriaNode = { role, name, children: [] }; - if (isLeaf && !name) { - const text = roleUtils.accumulatedElementText(element); - if (text) - result.children = [text]; - } if (roleUtils.kAriaCheckedRoles.includes(role)) result.checked = roleUtils.getAriaChecked(element); @@ -147,7 +132,7 @@ function toAriaNode(element: Element): { ariaNode: AriaNode, isLeaf: boolean } | if (roleUtils.kAriaSelectedRoles.includes(role)) result.selected = roleUtils.getAriaSelected(element); - return { isLeaf, ariaNode: result }; + return result; } export function renderedAriaTree(rootElement: Element): string { @@ -178,21 +163,12 @@ function normalizeStringChildren(rootA11yNode: AriaNode) { } flushChildren(buffer, normalizedChildren); ariaNode.children = normalizedChildren.length ? normalizedChildren : []; + if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name) + ariaNode.children = []; }; visit(rootA11yNode); } -const hiddenContainerRoles = new Set(['none', 'presentation']); - -const leafRoles = new Set([ - 'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader', - 'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion', - 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option', - 'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator', - 'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term', - 'textbox', 'time', 'tooltip' -]); - const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\t\r\n]+/g, ' '); function matchesText(text: string | undefined, template: RegExp | string | undefined) { @@ -305,15 +281,7 @@ export function renderAriaTree(ariaNode: AriaNode, options?: { noText?: boolean if (ariaNode.selected === true) line += ` [selected]`; - const stringValue = !ariaNode.children.length || (ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string'); - if (stringValue) { - if (!options?.noText && ariaNode.children.length) - line += ': ' + quoteYamlString(ariaNode.children?.[0] as string); - lines.push(line); - return; - } - - lines.push(line + ':'); + lines.push(line + (ariaNode.children.length ? ':' : '')); for (const child of ariaNode.children || []) visit(child, indent + ' '); }; diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 188808a178..2b8790589a 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -64,8 +64,10 @@ it('should snapshot list with accessible name', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - list "my list": - - listitem: "one" - - listitem: "two" + - listitem: + - text: "one" + - listitem: + - text: "two" `); }); @@ -105,7 +107,8 @@ it('should snapshot details visibility', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - group: "Summary" + - group: + - text: "Summary" `); }); @@ -148,7 +151,8 @@ it('should snapshot integration', async ({ page }) => { - text: "Open source projects and samples from Microsoft" - list: - listitem: - - group: "Verified" + - group: + - text: "Verified" - listitem: - link "Sponsor" `); @@ -164,13 +168,15 @@ it('should support multiline text', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - paragraph: "Line 1 Line 2 Line 3" + - paragraph: + - text: "Line 1 Line 2 Line 3" `); await expect(page.locator('body')).toMatchAriaSnapshot(` - - paragraph: | - Line 1 - Line 2 - Line 3 + - paragraph: + - text: | + Line 1 + Line 2 + Line 3 `); }); @@ -382,6 +388,22 @@ it('should include pseudo codepoints', async ({ page, server }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - paragraph: "\ueab2hello" + - paragraph: + - text: "\ueab2hello" + `); +}); + +it('check aria-hidden text', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` +

+ hello + +

+ `); + + await checkAndMatchSnapshot(page.locator('body'), ` + - paragraph: + - text: "hello" `); }); From 6ae6b4865cf94695b52ce302e5f3ed856ff8904d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 23 Oct 2024 17:36:05 -0700 Subject: [PATCH 04/17] chore: always use TestInfoErrorImpl (impl) in @playwright/test (#33255) --- packages/playwright/src/common/ipc.ts | 16 ++++++--- packages/playwright/src/common/process.ts | 5 ++- .../playwright/src/matchers/matcherHint.ts | 22 ++++++------- packages/playwright/src/util.ts | 4 +-- packages/playwright/src/worker/DEPS.list | 1 + packages/playwright/src/worker/testInfo.ts | 18 +++++----- packages/playwright/src/worker/testTracing.ts | 6 ++-- packages/playwright/src/worker/util.ts | 33 ++++--------------- packages/playwright/src/worker/workerMain.ts | 16 ++++----- 9 files changed, 54 insertions(+), 67 deletions(-) diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index 5ce1991f65..82538bb6ed 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -15,9 +15,11 @@ */ import util from 'util'; -import { type SerializedCompilationCache, serializeCompilationCache } from '../transform/compilationCache'; +import { serializeCompilationCache } from '../transform/compilationCache'; +import type { SerializedCompilationCache } from '../transform/compilationCache'; import type { ConfigLocation, FullConfigInternal } from './config'; import type { ReporterDescription, TestInfoError, TestStatus } from '../../types/test'; +import type { MatcherResultProperty } from '../matchers/matcherHint'; export type ConfigCLIOverrides = { debug?: boolean; @@ -74,11 +76,15 @@ export type AttachmentPayload = { contentType: string; }; +export type TestInfoErrorImpl = TestInfoError & { + matcherResult?: MatcherResultProperty; +}; + export type TestEndPayload = { testId: string; duration: number; status: TestStatus; - errors: TestInfoError[]; + errors: TestInfoErrorImpl[]; hasNonRetriableError: boolean; expectedStatus: TestStatus; annotations: { type: string, description?: string }[]; @@ -99,7 +105,7 @@ export type StepEndPayload = { testId: string; stepId: string; wallTime: number; // milliseconds since unix epoch - error?: TestInfoError; + error?: TestInfoErrorImpl; }; export type TestEntry = { @@ -113,7 +119,7 @@ export type RunPayload = { }; export type DonePayload = { - fatalErrors: TestInfoError[]; + fatalErrors: TestInfoErrorImpl[]; skipTestsDueToSetupFailure: string[]; // test ids fatalUnknownTestIds?: string[]; }; @@ -124,7 +130,7 @@ export type TestOutputPayload = { }; export type TeardownErrorsPayload = { - fatalErrors: TestInfoError[]; + fatalErrors: TestInfoErrorImpl[]; }; export type EnvProducedPayload = [string, string | null][]; diff --git a/packages/playwright/src/common/process.ts b/packages/playwright/src/common/process.ts index 14ad995fed..a372139698 100644 --- a/packages/playwright/src/common/process.ts +++ b/packages/playwright/src/common/process.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import type { EnvProducedPayload, ProcessInitParams } from './ipc'; +import type { EnvProducedPayload, ProcessInitParams, TestInfoErrorImpl } from './ipc'; import { startProfiling, stopProfiling } from 'playwright-core/lib/utils'; -import type { TestInfoError } from '../../types/test'; import { serializeError } from '../util'; import { registerESMLoader } from './esmLoaderHost'; import { execArgvWithoutExperimentalLoaderOptions } from '../transform/esmUtils'; @@ -29,7 +28,7 @@ export type ProtocolRequest = { export type ProtocolResponse = { id?: number; - error?: TestInfoError; + error?: TestInfoErrorImpl; method?: string; params?: any; result?: any; diff --git a/packages/playwright/src/matchers/matcherHint.ts b/packages/playwright/src/matchers/matcherHint.ts index 5ffc745263..200501c1bc 100644 --- a/packages/playwright/src/matchers/matcherHint.ts +++ b/packages/playwright/src/matchers/matcherHint.ts @@ -45,18 +45,18 @@ export type MatcherResult = { printedDiff?: string; }; -export class ExpectError extends Error { - matcherResult: { - message: string; - pass: boolean; - name?: string; - expected?: any; - actual?: any; - log?: string[]; - timeout?: number; - }; +export type MatcherResultProperty = Omit, 'message'> & { + message: string; +}; - constructor(jestError: ExpectError, customMessage: string, stackFrames: StackFrame[]) { +type JestError = Error & { + matcherResult: MatcherResultProperty; +}; + +export class ExpectError extends Error { + matcherResult: MatcherResultProperty; + + constructor(jestError: JestError, customMessage: string, stackFrames: StackFrame[]) { super(''); // Copy to erase the JestMatcherError constructor name from the console.log(error). this.name = jestError.name; diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index 460b3de07e..4046809433 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -21,10 +21,10 @@ import path from 'path'; import url from 'url'; import { debug, mime, minimatch, parseStackTraceLine } from 'playwright-core/lib/utilsBundle'; import { formatCallLog } from 'playwright-core/lib/utils'; -import type { TestInfoError } from './../types/test'; import type { Location } from './../types/testReporter'; import { calculateSha1, isRegExp, isString, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; import type { RawStack } from 'playwright-core/lib/utils'; +import type { TestInfoErrorImpl } from './common/ipc'; const PLAYWRIGHT_TEST_PATH = path.join(__dirname, '..'); const PLAYWRIGHT_CORE_PATH = path.dirname(require.resolve('playwright-core/package.json')); @@ -62,7 +62,7 @@ export function filteredStackTrace(rawStack: RawStack): StackFrame[] { return frames; } -export function serializeError(error: Error | any): TestInfoError { +export function serializeError(error: Error | any): TestInfoErrorImpl { if (error instanceof Error) return filterStackTrace(error); return { diff --git a/packages/playwright/src/worker/DEPS.list b/packages/playwright/src/worker/DEPS.list index fb352ac389..ed3973d1fc 100644 --- a/packages/playwright/src/worker/DEPS.list +++ b/packages/playwright/src/worker/DEPS.list @@ -3,3 +3,4 @@ ../transform/ ../util.ts ../utilBundle.ts +../matchers/** diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index e41f1a9a52..ed71b1a751 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -17,8 +17,8 @@ import fs from 'fs'; import path from 'path'; import { captureRawStack, monotonicTime, zones, sanitizeForFilePath, stringifyStackFrames } from 'playwright-core/lib/utils'; -import type { TestInfoError, TestInfo, TestStatus, FullProject } from '../../types/test'; -import type { AttachmentPayload, StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc'; +import type { TestInfo, TestStatus, FullProject } from '../../types/test'; +import type { AttachmentPayload, StepBeginPayload, StepEndPayload, TestInfoErrorImpl, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; import { TimeoutManager, TimeoutManagerError, kMaxDeadline } from './timeoutManager'; import type { RunnableDescription } from './timeoutManager'; @@ -28,7 +28,7 @@ import { debugTest, filteredStackTrace, formatLocation, getContainedPath, normal import { TestTracing } from './testTracing'; import type { Attachment } from './testTracing'; import type { StackFrame } from '@protocol/channels'; -import { serializeWorkerError } from './util'; +import { testInfoError } from './util'; export interface TestStepInternal { complete(result: { error?: Error | unknown, attachments?: Attachment[] }): void; @@ -41,7 +41,7 @@ export interface TestStepInternal { endWallTime?: number; apiName?: string; params?: Record; - error?: TestInfoError; + error?: TestInfoErrorImpl; infectParentStepsWithError?: boolean; box?: boolean; isStage?: boolean; @@ -97,14 +97,14 @@ export class TestInfoImpl implements TestInfo { snapshotSuffix: string = ''; readonly outputDir: string; readonly snapshotDir: string; - errors: TestInfoError[] = []; + errors: TestInfoErrorImpl[] = []; readonly _attachmentsPush: (...items: TestInfo['attachments']) => number; - get error(): TestInfoError | undefined { + get error(): TestInfoErrorImpl | undefined { return this.errors[0]; } - set error(e: TestInfoError | undefined) { + set error(e: TestInfoErrorImpl | undefined) { if (e === undefined) throw new Error('Cannot assign testInfo.error undefined value!'); this.errors[0] = e; @@ -273,7 +273,7 @@ export class TestInfoImpl implements TestInfo { if (result.error) { if (typeof result.error === 'object' && !(result.error as any)?.[stepSymbol]) (result.error as any)[stepSymbol] = step; - const error = serializeWorkerError(result.error); + const error = testInfoError(result.error); if (data.boxedStack) error.stack = `${error.message}\n${stringifyStackFrames(data.boxedStack).join('\n')}`; step.error = error; @@ -331,7 +331,7 @@ export class TestInfoImpl implements TestInfo { _failWithError(error: Error | unknown) { if (this.status === 'passed' || this.status === 'skipped') this.status = error instanceof TimeoutManagerError ? 'timedOut' : 'failed'; - const serialized = serializeWorkerError(error); + const serialized = testInfoError(error); const step: TestStepInternal | undefined = typeof error === 'object' ? (error as any)?.[stepSymbol] : undefined; if (step && step.boxedStack) serialized.stack = `${(error as Error).name}: ${(error as Error).message}\n${stringifyStackFrames(step.boxedStack).join('\n')}`; diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index fed7fdde7e..5e7a3d80db 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -21,10 +21,10 @@ import fs from 'fs'; import path from 'path'; import { ManualPromise, calculateSha1, monotonicTime, createGuid, SerializedFS } from 'playwright-core/lib/utils'; import { yauzl, yazl } from 'playwright-core/lib/zipBundle'; -import type { TestInfo, TestInfoError } from '../../types/test'; import { filteredStackTrace } from '../util'; -import type { TraceMode, PlaywrightWorkerOptions } from '../../types/test'; +import type { TestInfo, TraceMode, PlaywrightWorkerOptions } from '../../types/test'; import type { TestInfoImpl } from './testInfo'; +import type { TestInfoErrorImpl } from '../common/ipc'; export type Attachment = TestInfo['attachments'][0]; export const testTraceEntryName = 'test.trace'; @@ -219,7 +219,7 @@ export class TestTracing { this._testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' }); } - appendForError(error: TestInfoError) { + appendForError(error: TestInfoErrorImpl) { const rawStack = error.stack?.split('\n') || []; const stack = rawStack ? filteredStackTrace(rawStack) : []; this._appendTraceEvent({ diff --git a/packages/playwright/src/worker/util.ts b/packages/playwright/src/worker/util.ts index d24d337191..a271f62c48 100644 --- a/packages/playwright/src/worker/util.ts +++ b/packages/playwright/src/worker/util.ts @@ -14,32 +14,13 @@ * limitations under the License. */ -import type { TestError } from '../../types/testReporter'; -import type { TestInfoError } from '../../types/test'; -import type { MatcherResult } from '../matchers/matcherHint'; +import type { TestInfoErrorImpl } from '../common/ipc'; +import { ExpectError } from '../matchers/matcherHint'; import { serializeError } from '../util'; - -type MatcherResultDetails = Pick; - -export function serializeWorkerError(error: Error | any): TestInfoError & MatcherResultDetails { - return { - ...serializeError(error), - ...serializeExpectDetails(error), - }; +export function testInfoError(error: Error | any): TestInfoErrorImpl { + const result = serializeError(error); + if (error instanceof ExpectError) + result.matcherResult = error.matcherResult; + return result; } - -function serializeExpectDetails(e: Error): MatcherResultDetails { - const matcherResult = (e as any).matcherResult as MatcherResult; - if (!matcherResult) - return {}; - return { - timeout: matcherResult.timeout, - matcherName: matcherResult.name, - locator: matcherResult.locator, - expected: matcherResult.printedExpected, - received: matcherResult.printedReceived, - log: matcherResult.log, - }; -} - diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 5680c3ddb3..ed323a701b 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -16,7 +16,8 @@ import { colors } from 'playwright-core/lib/utilsBundle'; import { debugTest, relativeFilePath } from '../util'; -import { type TestBeginPayload, type TestEndPayload, type RunPayload, type DonePayload, type WorkerInitParams, type TeardownErrorsPayload, stdioChunkToParams } from '../common/ipc'; +import type { TestBeginPayload, TestEndPayload, RunPayload, DonePayload, WorkerInitParams, TeardownErrorsPayload, TestInfoErrorImpl } from '../common/ipc'; +import { stdioChunkToParams } from '../common/ipc'; import { setCurrentTestInfo, setIsWorkerProcess } from '../common/globals'; import { deserializeConfig } from '../common/configLoader'; import type { Suite, TestCase } from '../common/test'; @@ -28,11 +29,10 @@ import { ProcessRunner } from '../common/process'; import { loadTestFile } from '../common/testLoader'; import { applyRepeatEachIndex, bindFileSuiteToProject, filterTestsRemoveEmptySuites } from '../common/suiteUtils'; import { PoolBuilder } from '../common/poolBuilder'; -import type { TestInfoError } from '../../types/test'; import type { Location } from '../../types/testReporter'; import { inheritFixtureNames } from '../common/fixtures'; import { type TimeSlot } from './timeoutManager'; -import { serializeWorkerError } from './util'; +import { testInfoError } from './util'; export class WorkerMain extends ProcessRunner { private _params: WorkerInitParams; @@ -42,7 +42,7 @@ export class WorkerMain extends ProcessRunner { private _fixtureRunner: FixtureRunner; // Accumulated fatal errors that cannot be attributed to a test. - private _fatalErrors: TestInfoError[] = []; + private _fatalErrors: TestInfoErrorImpl[] = []; // Whether we should skip running remaining tests in this suite because // of a setup error, usually beforeAll hook. private _skipRemainingTestsInSuite: Suite | undefined; @@ -113,7 +113,7 @@ export class WorkerMain extends ProcessRunner { await fakeTestInfo._runAsStage({ title: 'worker cleanup', runnable }, () => gracefullyCloseAll()).catch(() => {}); this._fatalErrors.push(...fakeTestInfo.errors); } catch (e) { - this._fatalErrors.push(serializeWorkerError(e)); + this._fatalErrors.push(testInfoError(e)); } if (this._fatalErrors.length) { @@ -123,7 +123,7 @@ export class WorkerMain extends ProcessRunner { } } - private _appendProcessTeardownDiagnostics(error: TestInfoError) { + private _appendProcessTeardownDiagnostics(error: TestInfoErrorImpl) { if (!this._lastRunningTests.length) return; const count = this._totalRunningTests === 1 ? '1 test' : `${this._totalRunningTests} tests`; @@ -154,7 +154,7 @@ export class WorkerMain extends ProcessRunner { // No current test - fatal error. if (!this._currentTest) { if (!this._fatalErrors.length) - this._fatalErrors.push(serializeWorkerError(error)); + this._fatalErrors.push(testInfoError(error)); void this._stop(); return; } @@ -225,7 +225,7 @@ export class WorkerMain extends ProcessRunner { // In theory, we should run above code without any errors. // However, in the case we screwed up, or loadTestFile failed in the worker // but not in the runner, let's do a fatal error. - this._fatalErrors.push(serializeWorkerError(e)); + this._fatalErrors.push(testInfoError(e)); void this._stop(); } finally { const donePayload: DonePayload = { From 69f56b9f6366858709cb39b5b1054592b029a447 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 24 Oct 2024 10:52:54 +0200 Subject: [PATCH 05/17] fix(locator generator): handle `frameLocator()` and `locator().contentFrame()` (#33208) --- .../src/utils/isomorphic/locatorGenerators.ts | 32 +++++++++++++++---- tests/library/locator-generator.spec.ts | 19 +++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 930abaaba6..04f3040547 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -19,7 +19,7 @@ import { type NestedSelectorBody, parseAttributeSelector, parseSelector, stringi import type { ParsedSelector } from './selectorParser'; export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl'; -export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'and' | 'or' | 'chain'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'and' | 'or' | 'chain'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; export type Quote = '\'' | '"' | '`'; @@ -158,19 +158,29 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram } } if (part.name === 'internal:control' && (part.body as string) === 'enter-frame') { - tokens.push([factory.generateLocator(base, 'frame', '')]); + // transform last tokens from `${selector}` into `${selector}.contentFrame()` and `frameLocator(${selector})` + const lastTokens = tokens[tokens.length - 1]; + const lastPart = parts[index - 1]; + + const transformed = lastTokens.map(token => factory.chainLocators([token, factory.generateLocator(base, 'frame', '')])); + if (['xpath', 'css'].includes(lastPart.name)) { + transformed.push( + factory.generateLocator(base, 'frame-locator', stringifySelector({ parts: [lastPart] })), + factory.generateLocator(base, 'frame-locator', stringifySelector({ parts: [lastPart] }, true)) + ); + } + + lastTokens.splice(0, lastTokens.length, ...transformed); nextBase = 'frame-locator'; continue; } - const locatorType: LocatorType = 'default'; - const nextPart = parts[index + 1]; const selectorPart = stringifySelector({ parts: [part] }); - const locatorPart = factory.generateLocator(base, locatorType, selectorPart); + const locatorPart = factory.generateLocator(base, 'default', selectorPart); - if (locatorType === 'default' && nextPart && ['internal:has-text', 'internal:has-not-text'].includes(nextPart.name)) { + if (nextPart && ['internal:has-text', 'internal:has-not-text'].includes(nextPart.name)) { const { exact, text } = detectExact(nextPart.body as string); // There is no locator equivalent for strict has-text and has-not-text, leave it as is. if (!exact) { @@ -194,7 +204,7 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram let locatorPartWithEngine: string | undefined; if (['xpath', 'css'].includes(part.name)) { const selectorPart = stringifySelector({ parts: [part] }, /* forceEngineName */ true); - locatorPartWithEngine = factory.generateLocator(base, locatorType, selectorPart); + locatorPartWithEngine = factory.generateLocator(base, 'default', selectorPart); } tokens.push([locatorPart, locatorPartWithEngine].filter(Boolean) as string[]); @@ -253,6 +263,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { if (options.hasNotText !== undefined) return `locator(${this.quote(body as string)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`; return `locator(${this.quote(body as string)})`; + case 'frame-locator': + return `frameLocator(${this.quote(body as string)})`; case 'frame': return `contentFrame()`; case 'nth': @@ -345,6 +357,8 @@ export class PythonLocatorFactory implements LocatorFactory { if (options.hasNotText !== undefined) return `locator(${this.quote(body as string)}, has_not_text=${this.toHasText(options.hasNotText)})`; return `locator(${this.quote(body as string)})`; + case 'frame-locator': + return `frame_locator(${this.quote(body as string)})`; case 'frame': return `content_frame`; case 'nth': @@ -450,6 +464,8 @@ export class JavaLocatorFactory implements LocatorFactory { if (options.hasNotText !== undefined) return `locator(${this.quote(body as string)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`; return `locator(${this.quote(body as string)})`; + case 'frame-locator': + return `frameLocator(${this.quote(body as string)})`; case 'frame': return `contentFrame()`; case 'nth': @@ -545,6 +561,8 @@ export class CSharpLocatorFactory implements LocatorFactory { if (options.hasNotText !== undefined) return `Locator(${this.quote(body as string)}, new() { ${this.toHasNotText(options.hasNotText)} })`; return `Locator(${this.quote(body as string)})`; + case 'frame-locator': + return `FrameLocator(${this.quote(body as string)})`; case 'frame': return `ContentFrame`; case 'nth': diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index d177fa7489..4df72977f4 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -584,3 +584,22 @@ it('parse locators strictly', () => { expect.soft(parseLocator('javascript', `locator('div').filter({ hasText: 'Goodbye world' }}).locator('span')`)).not.toBe(selector); expect.soft(parseLocator('python', `locator("div").filter(has_text=="Goodbye world").locator("span")`)).not.toBe(selector); }); + +it('parseLocator frames', async () => { + expect.soft(parseLocator('javascript', `locator('iframe').contentFrame().getByText('foo')`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('javascript', `frameLocator('iframe').getByText('foo')`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('javascript', `frameLocator('css=iframe').getByText('foo')`, '')).toBe(`css=iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('javascript', `getByTitle('iframe title').contentFrame()`)).toBe(`internal:attr=[title=\"iframe title\"i] >> internal:control=enter-frame`); + + expect.soft(asLocators('javascript', 'internal:attr=[title=\"iframe title\"i] >> internal:control=enter-frame')).toEqual([`getByTitle('iframe title').contentFrame()`]); + + expect.soft(parseLocator('python', `locator("iframe").content_frame.get_by_text("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('python', `frame_locator("iframe").get_by_text("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('python', `frame_locator("css=iframe").get_by_text("foo")`, '')).toBe(`css=iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + + expect.soft(parseLocator('csharp', `Locator("iframe").ContentFrame.GetByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('csharp', `FrameLocator("iframe").GetByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + + expect.soft(parseLocator('java', `locator("iframe").contentFrame().getByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); + expect.soft(parseLocator('java', `frameLocator("iframe").getByText("foo")`, '')).toBe(`iframe >> internal:control=enter-frame >> internal:text=\"foo\"i`); +}); From 487134fbafcd8840fd1caf34443578608f0cc92d Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 24 Oct 2024 11:34:41 +0200 Subject: [PATCH 06/17] devops: add chromium-headless-shell bots (#33242) --- .github/workflows/tests_secondary.yml | 21 +++++++++++++++++++++ tests/library/chromium/launcher.spec.ts | 12 ++++++++---- tests/library/headful.spec.ts | 1 + tests/library/launcher.spec.ts | 3 ++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests_secondary.yml b/.github/workflows/tests_secondary.yml index 8b8555eb86..71c7ed105f 100644 --- a/.github/workflows/tests_secondary.yml +++ b/.github/workflows/tests_secondary.yml @@ -284,3 +284,24 @@ jobs: flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} env: PLAYWRIGHT_CHROMIUM_USE_HEADLESS_NEW: 1 + + test_linux_chromium_headless_shell: + name: Chromium Headless Shell + environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }} + strategy: + fail-fast: false + matrix: + runs-on: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.runs-on }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/run-test + with: + browsers-to-install: chromium-headless-shell + command: npm run ctest + bot-name: "headless-shell-${{ matrix.runs-on }}" + flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }} + flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }} + flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }} + env: + PWTEST_CHANNEL: chromium-headless-shell diff --git a/tests/library/chromium/launcher.spec.ts b/tests/library/chromium/launcher.spec.ts index f36a089fa7..8758e63c12 100644 --- a/tests/library/chromium/launcher.spec.ts +++ b/tests/library/chromium/launcher.spec.ts @@ -52,7 +52,8 @@ it('should open devtools when "devtools: true" option is given', async ({ browse await browser.close(); }); -it('should return background pages', async ({ browserType, createUserDataDir, asset }) => { +it('should return background pages', async ({ browserType, createUserDataDir, asset, channel }) => { + it.skip(channel === 'chromium-headless-shell', 'Headless Shell has no support for extensions'); const userDataDir = await createUserDataDir(); const extensionPath = asset('simple-extension'); const extensionOptions = { @@ -75,7 +76,8 @@ it('should return background pages', async ({ browserType, createUserDataDir, as expect(context.backgroundPages().length).toBe(0); }); -it('should return background pages when recording video', async ({ browserType, createUserDataDir, asset }, testInfo) => { +it('should return background pages when recording video', async ({ browserType, createUserDataDir, asset, channel }, testInfo) => { + it.skip(channel === 'chromium-headless-shell', 'Headless Shell has no support for extensions'); const userDataDir = await createUserDataDir(); const extensionPath = asset('simple-extension'); const extensionOptions = { @@ -99,7 +101,8 @@ it('should return background pages when recording video', async ({ browserType, await context.close(); }); -it('should support request/response events when using backgroundPage()', async ({ browserType, createUserDataDir, asset, server }) => { +it('should support request/response events when using backgroundPage()', async ({ browserType, createUserDataDir, asset, server, channel }) => { + it.skip(channel === 'chromium-headless-shell', 'Headless Shell has no support for extensions'); server.setRoute('/empty.html', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/html', 'x-response-foobar': 'BarFoo' }); res.end(`hello world!`); @@ -148,7 +151,8 @@ it('should support request/response events when using backgroundPage()', async ( it('should report console messages from content script', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/32762' } -}, async ({ browserType, createUserDataDir, asset, server }) => { +}, async ({ browserType, createUserDataDir, asset, server, channel }) => { + it.skip(channel === 'chromium-headless-shell', 'Headless Shell has no support for extensions'); const userDataDir = await createUserDataDir(); const extensionPath = asset('extension-with-logging'); const extensionOptions = { diff --git a/tests/library/headful.spec.ts b/tests/library/headful.spec.ts index 5832ef44da..92f2e044e0 100644 --- a/tests/library/headful.spec.ts +++ b/tests/library/headful.spec.ts @@ -19,6 +19,7 @@ import { PNG } from 'playwright-core/lib/utilsBundle'; import { expect, playwrightTest as it } from '../config/browserTest'; it.use({ headless: false }); +it.skip(({ channel }) => channel === 'chromium-headless-shell'); it('should have default url when launching browser @smoke', async ({ launchPersistent }) => { const { context } = await launchPersistent(); diff --git a/tests/library/launcher.spec.ts b/tests/library/launcher.spec.ts index 54b74c6366..e7d71182cb 100644 --- a/tests/library/launcher.spec.ts +++ b/tests/library/launcher.spec.ts @@ -41,9 +41,10 @@ it('should kill browser process on timeout after close', async ({ browserType, m expect(stalled).toBeTruthy(); }); -it('should throw a friendly error if its headed and there is no xserver on linux running', async ({ mode, browserType, platform }) => { +it('should throw a friendly error if its headed and there is no xserver on linux running', async ({ mode, browserType, platform, channel }) => { it.skip(platform !== 'linux'); it.skip(mode.startsWith('service')); + it.skip(channel === 'chromium-headless-shell', 'Headless Shell is always headless'); const error: Error = await browserType.launch({ headless: false, From 1950bbdc6e07c120d590b6aa7b594d4acfe2d3a2 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 24 Oct 2024 02:47:34 -0700 Subject: [PATCH 07/17] test: unflake "should not leak recorders" (#33264) --- tests/library/trace-viewer.spec.ts | 33 ++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index eb81fa8b9c..c13eb778d1 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -1415,26 +1415,37 @@ test('should not leak recorders', { }, async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); - const counts = async () => { + const aliveCount = async () => { return await traceViewer.page.evaluate(() => { const weakSet = (window as any)._weakRecordersForTest || new Set(); const weakList = [...weakSet]; const aliveList = weakList.filter(r => !!r.deref()); - return { total: weakList.length, alive: aliveList.length }; + return aliveList.length; }); }; - await traceViewer.snapshotFrame('page.goto'); - await traceViewer.snapshotFrame('page.evaluate'); - await traceViewer.page.requestGC(); - await expect.poll(() => counts()).toEqual({ total: 4, alive: 1 }); + await expect(traceViewer.snapshotContainer.contentFrame().locator('body')).toContainText(`Hi, I'm frame`); + + const frame1 = await traceViewer.snapshotFrame('page.goto'); + await expect(frame1.locator('body')).toContainText('Hello world'); + + const frame2 = await traceViewer.snapshotFrame('page.evaluate'); + await expect(frame2.locator('button')).toBeVisible(); - await traceViewer.snapshotFrame('page.setContent'); - await traceViewer.snapshotFrame('page.goto'); - await traceViewer.snapshotFrame('page.evaluate'); - await traceViewer.snapshotFrame('page.setContent'); await traceViewer.page.requestGC(); - await expect.poll(() => counts()).toEqual({ total: 8, alive: 1 }); + await expect.poll(() => aliveCount()).toBeLessThanOrEqual(2); // two snapshot iframes + + const frame3 = await traceViewer.snapshotFrame('page.setViewportSize'); + await expect(frame3.locator('body')).toContainText(`Hi, I'm frame`); + + const frame4 = await traceViewer.snapshotFrame('page.goto'); + await expect(frame4.locator('body')).toContainText('Hello world'); + + const frame5 = await traceViewer.snapshotFrame('page.evaluate'); + await expect(frame5.locator('button')).toBeVisible(); + + await traceViewer.page.requestGC(); + await expect.poll(() => aliveCount()).toBeLessThanOrEqual(2); // two snapshot iframes }); test('should serve css without content-type', async ({ page, runAndTrace, server }) => { From 2e01154bb57f5cb34c805092e0119742c46b95d8 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 24 Oct 2024 04:41:35 -0700 Subject: [PATCH 08/17] feat: screenshot:on-first-failure (#33266) --- docs/src/test-api/class-testoptions.md | 5 ++-- packages/playwright/src/index.ts | 13 ++++++--- packages/playwright/types/test.d.ts | 3 ++- .../playwright.artifacts.spec.ts | 27 +++++++++++++++++++ utils/generate_types/overrides-test.d.ts | 2 +- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 47bfd1f377..20dad210e7 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -479,8 +479,8 @@ export default defineConfig({ ## property: TestOptions.screenshot * since: v1.10 -- type: <[Object]|[ScreenshotMode]<"off"|"on"|"only-on-failure">> - - `mode` <[ScreenshotMode]<"off"|"on"|"only-on-failure">> Automatic screenshot mode. +- type: <[Object]|[ScreenshotMode]<"off"|"on"|"only-on-failure"|"on-first-failure">> + - `mode` <[ScreenshotMode]<"off"|"on"|"only-on-failure"|"on-first-failure">> Automatic screenshot mode. - `fullPage` ?<[boolean]> When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to `false`. - `omitBackground` ?<[boolean]> Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images. Defaults to `false`. @@ -488,6 +488,7 @@ Whether to automatically capture a screenshot after each test. Defaults to `'off * `'off'`: Do not capture screenshots. * `'on'`: Capture screenshot after each test. * `'only-on-failure'`: Capture screenshot after each test failure. +* `'on-first-failure'`: Capture screenshot after each test's first failure. **Usage** diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index add1661502..c2c9596b6e 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -571,7 +571,7 @@ class ArtifactsRecorder { if (this._reusedContexts.has(context)) return; await this._stopTracing(context.tracing); - if (this._screenshotMode === 'on' || this._screenshotMode === 'only-on-failure') { + if (this._screenshotMode === 'on' || this._screenshotMode === 'only-on-failure' || (this._screenshotMode === 'on-first-failure' && this._testInfo.retry === 0)) { // Capture screenshot for now. We'll know whether we have to preserve them // after the test finishes. await Promise.all(context.pages().map(page => this._screenshotPage(page, true))); @@ -588,14 +588,19 @@ class ArtifactsRecorder { await this._stopTracing(tracing); } + private _shouldCaptureScreenshotUponFinish() { + return this._screenshotMode === 'on' || + (this._screenshotMode === 'only-on-failure' && this._testInfo._isFailure()) || + (this._screenshotMode === 'on-first-failure' && this._testInfo._isFailure() && this._testInfo.retry === 0); + } + async didFinishTestFunction() { - const captureScreenshots = this._screenshotMode === 'on' || (this._screenshotMode === 'only-on-failure' && this._testInfo._isFailure()); - if (captureScreenshots) + if (this._shouldCaptureScreenshotUponFinish()) await this._screenshotOnTestFailure(); } async didFinishTest() { - const captureScreenshots = this._screenshotMode === 'on' || (this._screenshotMode === 'only-on-failure' && this._testInfo._isFailure()); + const captureScreenshots = this._shouldCaptureScreenshotUponFinish(); if (captureScreenshots) await this._screenshotOnTestFailure(); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index ae02d1506e..5db30f72e2 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -5863,6 +5863,7 @@ export interface PlaywrightWorkerOptions { * - `'off'`: Do not capture screenshots. * - `'on'`: Capture screenshot after each test. * - `'only-on-failure'`: Capture screenshot after each test failure. + * - `'on-first-failure'`: Capture screenshot after each test's first failure. * * **Usage** * @@ -5938,7 +5939,7 @@ export interface PlaywrightWorkerOptions { video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize }; } -export type ScreenshotMode = 'off' | 'on' | 'only-on-failure'; +export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure'; export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure'; export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry'; diff --git a/tests/playwright-test/playwright.artifacts.spec.ts b/tests/playwright-test/playwright.artifacts.spec.ts index b666aa4b70..2e3d99766b 100644 --- a/tests/playwright-test/playwright.artifacts.spec.ts +++ b/tests/playwright-test/playwright.artifacts.spec.ts @@ -192,6 +192,33 @@ test('should work with screenshot: only-on-failure', async ({ runInlineTest }, t ]); }); +test('should work with screenshot: on-first-failure', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', async ({ page }) => { + await page.setContent('I am the page'); + expect(1).toBe(2); + }); + `, + 'playwright.config.ts': ` + module.exports = { + retries: 1, + use: { screenshot: 'on-first-failure' } + }; + `, + }, { workers: 1 }); + + expect(result.exitCode).toBe(1); + expect(result.passed).toBe(0); + expect(result.failed).toBe(1); + expect(listFiles(testInfo.outputPath('test-results'))).toEqual([ + '.last-run.json', + 'a-fails', + ' test-failed-1.png', + ]); +}); + test('should work with screenshot: only-on-failure & fullPage', async ({ runInlineTest, server }, testInfo) => { const result = await runInlineTest({ 'artifacts.spec.ts': ` diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index ff46ba0e5c..49a7093dd3 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -236,7 +236,7 @@ export interface PlaywrightWorkerOptions { video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize }; } -export type ScreenshotMode = 'off' | 'on' | 'only-on-failure'; +export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure'; export type TraceMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-first-failure'; export type VideoMode = 'off' | 'on' | 'retain-on-failure' | 'on-first-retry'; From 3641e5984fa7c371e11b5eac3e95139c03e2512c Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 24 Oct 2024 14:34:59 +0200 Subject: [PATCH 09/17] chore: HMR for Trace Viewer (#33228) --- .../src/server/trace/viewer/traceViewer.ts | 9 ++++++- packages/trace-viewer/.gitignore | 2 ++ packages/trace-viewer/src/embedded.tsx | 2 +- packages/trace-viewer/src/index.tsx | 2 +- packages/trace-viewer/src/recorder.tsx | 2 +- .../trace-viewer/src/sw/traceModelBackends.ts | 27 ++++++++++++------- packages/trace-viewer/src/ui/uiModeView.tsx | 7 +++-- packages/trace-viewer/src/uiMode.tsx | 2 +- packages/trace-viewer/vite.config.ts | 3 +++ packages/trace-viewer/vite.sw.config.ts | 2 +- .../playwright-test/ui-mode-test-run.spec.ts | 11 ++++++++ utils/build/build.js | 26 ++++++++++++++++++ 12 files changed, 77 insertions(+), 18 deletions(-) diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index a49148e061..32cbba0982 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -125,7 +125,14 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[ for (const reporter of options.reporter || []) params.append('reporter', reporter); - const urlPath = `./trace/${options.webApp || 'index.html'}?${params.toString()}`; + let baseUrl = ''; + if (process.env.PW_HMR === '1') { + params.set('testServerPort', '' + server.port()); + baseUrl = 'http://localhost:44223'; // port is hardcoded in build.js + } + + const urlPath = `${baseUrl}/trace/${options.webApp || 'index.html'}?${params.toString()}`; + server.routePath('/', (_, response) => { response.statusCode = 302; response.setHeader('Location', urlPath); diff --git a/packages/trace-viewer/.gitignore b/packages/trace-viewer/.gitignore index a547bf36d8..1e3942879c 100644 --- a/packages/trace-viewer/.gitignore +++ b/packages/trace-viewer/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +public/sw.bundle.js* diff --git a/packages/trace-viewer/src/embedded.tsx b/packages/trace-viewer/src/embedded.tsx index 4f1503dcf2..cc61703baf 100644 --- a/packages/trace-viewer/src/embedded.tsx +++ b/packages/trace-viewer/src/embedded.tsx @@ -45,7 +45,7 @@ import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader'; if (window.location.protocol !== 'file:') { if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`); - navigator.serviceWorker.register('sw.bundle.js'); + navigator.serviceWorker.register('sw.bundle.js' + window.location.search); if (!navigator.serviceWorker.controller) { await new Promise(f => { navigator.serviceWorker.oncontrollerchange = () => f(); diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index a737d9017f..2296cb0090 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -27,7 +27,7 @@ import { WorkbenchLoader } from './ui/workbenchLoader'; await new Promise(f => setTimeout(f, 1000)); if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`); - navigator.serviceWorker.register('sw.bundle.js'); + navigator.serviceWorker.register('sw.bundle.js' + window.location.search); if (!navigator.serviceWorker.controller) { await new Promise(f => { navigator.serviceWorker.oncontrollerchange = () => f(); diff --git a/packages/trace-viewer/src/recorder.tsx b/packages/trace-viewer/src/recorder.tsx index 5e6b9764e3..6239df86ab 100644 --- a/packages/trace-viewer/src/recorder.tsx +++ b/packages/trace-viewer/src/recorder.tsx @@ -26,7 +26,7 @@ import { RecorderView } from './ui/recorder/recorderView'; if (window.location.protocol !== 'file:') { if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`); - navigator.serviceWorker.register('sw.bundle.js'); + navigator.serviceWorker.register('sw.bundle.js' + window.location.search); if (!navigator.serviceWorker.controller) { await new Promise(f => { navigator.serviceWorker.oncontrollerchange = () => f(); diff --git a/packages/trace-viewer/src/sw/traceModelBackends.ts b/packages/trace-viewer/src/sw/traceModelBackends.ts index 19c5fc2dee..4f5bd49624 100644 --- a/packages/trace-viewer/src/sw/traceModelBackends.ts +++ b/packages/trace-viewer/src/sw/traceModelBackends.ts @@ -30,9 +30,8 @@ export class ZipTraceModelBackend implements TraceModelBackend { constructor(traceURL: string, progress: Progress) { this._traceURL = traceURL; - zipjs.configure({ baseURL: self.location.href } as any); this._zipReader = new zipjs.ZipReader( - new zipjs.HttpReader(formatUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any), + new zipjs.HttpReader(formatTraceFileUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any), { useWebWorkers: false }); this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => { const map = new Map(); @@ -87,7 +86,7 @@ export class FetchTraceModelBackend implements TraceModelBackend { constructor(traceURL: string) { this._traceURL = traceURL; - this._entriesPromise = fetch('/trace/file?path=' + encodeURIComponent(traceURL)).then(async response => { + this._entriesPromise = fetch(formatTraceFileUrl(traceURL)).then(async response => { const json = JSON.parse(await response.text()); const entries = new Map(); for (const entry of json.entries) @@ -129,14 +128,22 @@ export class FetchTraceModelBackend implements TraceModelBackend { const fileName = entries.get(entryName); if (!fileName) return; - return fetch('/trace/file?path=' + encodeURIComponent(fileName)); + + return fetch(formatTraceFileUrl(fileName)); } } -function formatUrl(trace: string) { - let url = trace.startsWith('http') || trace.startsWith('blob') ? trace : `file?path=${encodeURIComponent(trace)}`; - // Dropbox does not support cors. - if (url.startsWith('https://www.dropbox.com/')) - url = 'https://dl.dropboxusercontent.com/' + url.substring('https://www.dropbox.com/'.length); - return url; +const baseURL = new URL(self.location.href); +baseURL.port = baseURL.searchParams.get('testServerPort') ?? baseURL.port; + +function formatTraceFileUrl(trace: string) { + if (trace.startsWith('https://www.dropbox.com/')) + return 'https://dl.dropboxusercontent.com/' + trace.substring('https://www.dropbox.com/'.length); + + if (trace.startsWith('http') || trace.startsWith('blob')) + return trace; + + const url = new URL('/trace/file', baseURL); + url.searchParams.set('path', trace); + return url.toString(); } diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 69a5988641..37b6a6bcf6 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -50,6 +50,7 @@ const searchParams = new URLSearchParams(window.location.search); const guid = searchParams.get('ws'); const wsURL = new URL(`../${guid}`, window.location.toString()); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); +wsURL.port = searchParams.get('testServerPort') ?? window.location.port; const queryParams = { args: searchParams.getAll('arg'), grep: searchParams.get('grep') || undefined, @@ -68,6 +69,7 @@ const isMac = navigator.platform === 'MacIntel'; export const UIModeView: React.FC<{}> = ({ }) => { + const isJokesDay = new Date().getMonth() === 3 && new Date().getDate() === 1; const [filterText, setFilterText] = React.useState(''); const [isShowingOutput, setIsShowingOutput] = React.useState(false); const [outputContainsError, setOutputContainsError] = React.useState(false); @@ -440,7 +442,7 @@ export const UIModeView: React.FC<{}> = ({ sidebar={
Playwright logo -
Playwright
+
{isJokesDay ? 'Claywright' : 'Playwright'}
reloadTests()} disabled={isRunningTest || isLoading}>
{ setIsShowingOutput(!isShowingOutput); }} /> @@ -516,10 +518,11 @@ export const UIModeView: React.FC<{}> = ({ style={{ marginLeft: 5 }} title={settingsVisible ? 'Hide Settings' : 'Show Settings'} /> -
Settings
+
{isJokesDay ? 'Schmettings' : 'Settings'}
{settingsVisible && }
} diff --git a/packages/trace-viewer/src/uiMode.tsx b/packages/trace-viewer/src/uiMode.tsx index 5dac2082e8..4702621e5d 100644 --- a/packages/trace-viewer/src/uiMode.tsx +++ b/packages/trace-viewer/src/uiMode.tsx @@ -27,7 +27,7 @@ import { UIModeView } from './ui/uiModeView'; await new Promise(f => setTimeout(f, 1000)); if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the website (${window.location}) via HTTPS or localhost.`); - navigator.serviceWorker.register('sw.bundle.js'); + navigator.serviceWorker.register('sw.bundle.js' + window.location.search); if (!navigator.serviceWorker.controller) { await new Promise(f => { navigator.serviceWorker.oncontrollerchange = () => f(); diff --git a/packages/trace-viewer/vite.config.ts b/packages/trace-viewer/vite.config.ts index 0e2e9cb642..541b1ab6b6 100644 --- a/packages/trace-viewer/vite.config.ts +++ b/packages/trace-viewer/vite.config.ts @@ -26,6 +26,9 @@ export default defineConfig({ react(), bundle() ], + define: { + 'process.env': {}, + }, resolve: { alias: { '@injected': path.resolve(__dirname, '../playwright-core/src/server/injected'), diff --git a/packages/trace-viewer/vite.sw.config.ts b/packages/trace-viewer/vite.sw.config.ts index dc621448b9..a0dd99036a 100644 --- a/packages/trace-viewer/vite.sw.config.ts +++ b/packages/trace-viewer/vite.sw.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ }, }, build: { - outDir: path.resolve(__dirname, '../playwright-core/lib/vite/traceViewer'), + outDir: path.resolve(__dirname, 'public'), // Output dir is shared with vite.config.ts, clearing it here is racy. emptyOutDir: false, rollupOptions: { diff --git a/tests/playwright-test/ui-mode-test-run.spec.ts b/tests/playwright-test/ui-mode-test-run.spec.ts index 3673faab45..0da2940e96 100644 --- a/tests/playwright-test/ui-mode-test-run.spec.ts +++ b/tests/playwright-test/ui-mode-test-run.spec.ts @@ -778,3 +778,14 @@ test('should respect --ignore-snapshots option', { - treeitem ${/\[icon-check\] snapshot \d+ms/} `); }); + +test('should show funny messages', async ({ runUITest }) => { + const { page } = await runUITest(basicTestTree); + await page.clock.setFixedTime('2025-04-01'); + + await expect(page.getByText('Claywright')).toBeVisible(); + const schmettingsHeader = page.getByText('Schmettings'); + await expect(schmettingsHeader).toBeVisible(); + await schmettingsHeader.click(); + await expect(page.getByRole('checkbox', { name: 'Fart mode' })).toBeVisible(); +}); \ No newline at end of file diff --git a/utils/build/build.js b/utils/build/build.js index 7a289bd5ed..a1ba052d51 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -275,6 +275,21 @@ for (const bundle of bundles) { }); } +// initial service worker build. +steps.push({ + command: 'npx', + args: [ + 'vite', + '--config', + 'vite.sw.config.ts', + 'build', + ...(withSourceMaps ? ['--sourcemap=inline'] : []), + ], + shell: true, + cwd: path.join(__dirname, '..', '..', 'packages', 'trace-viewer'), + concurrent: false, +}); + // Build/watch web packages. for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer']) { steps.push({ @@ -290,6 +305,7 @@ for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer']) { concurrent: true, }); } + // Build/watch trace viewer service worker. steps.push({ command: 'npx', @@ -306,6 +322,16 @@ steps.push({ concurrent: true, }); +// web packages dev server +if (watchMode) { + steps.push({ + command: 'npx', + args: ['vite', '--port', '44223', '--base', '/trace/'], + shell: true, + cwd: path.join(__dirname, '..', '..', 'packages', 'trace-viewer'), + concurrent: true, + }); +} // Generate injected. onChanges.push({ From 67471cb3c5fae0e90057a034723e4c0e26779325 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 24 Oct 2024 06:03:36 -0700 Subject: [PATCH 10/17] test: make a few tests headed/headless agnostic (#33268) --- tests/playwright-test/expect.spec.ts | 8 ++++---- tests/playwright-test/playwright.trace.spec.ts | 16 +++++++++++----- tests/playwright-test/ui-mode-test-setup.spec.ts | 2 +- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index 1886651f95..08ce16e7ec 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -511,13 +511,13 @@ test('should support toHaveURL with baseURL from webServer', async ({ runInlineT import { test, expect } from '@playwright/test'; test('pass', async ({ page }) => { - await page.goto('/foobar'); - await expect(page).toHaveURL('/foobar'); - await expect(page).toHaveURL('http://localhost:${port}/foobar'); + await page.goto('/hello'); + await expect(page).toHaveURL('/hello'); + await expect(page).toHaveURL('http://localhost:${port}/hello'); }); test('fail', async ({ page }) => { - await page.goto('/foobar'); + await page.goto('/hello'); await expect(page).toHaveURL('/kek', { timeout: 1000 }); }); `, diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index ba6020fade..5c5d6c304a 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -735,28 +735,34 @@ test('should not throw when attachment is missing', async ({ runInlineTest }, te }); test('should not throw when screenshot on failure fails', async ({ runInlineTest, server }, testInfo) => { + server.setRoute('/download', (req, res) => { + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', 'attachment; filename=file.txt'); + res.end(`Hello world`); + }); + const result = await runInlineTest({ 'playwright.config.ts': ` module.exports = { use: { trace: 'on', screenshot: 'on' } }; `, 'a.spec.ts': ` import { test, expect } from '@playwright/test'; - test('has pdf page', async ({ page }) => { + test('has download page', async ({ page }) => { await page.goto("${server.EMPTY_PAGE}"); - await page.setContent('open me!'); + await page.setContent('open me!'); const downloadPromise = page.waitForEvent('download'); await page.click('a'); const download = await downloadPromise; - expect(download.suggestedFilename()).toBe('empty.pdf'); + expect(download.suggestedFilename()).toBe('file.txt'); }); `, }, { workers: 1 }); expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); - const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-pdf-page', 'trace.zip')); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-download-page', 'trace.zip')); const attachedScreenshots = trace.actionTree.filter(s => s.trim() === `attach "screenshot"`); - // One screenshot for the page, no screenshot for pdf page since it should have failed. + // One screenshot for the page, no screenshot for the download page since it should have failed. expect(attachedScreenshots.length).toBe(1); }); diff --git a/tests/playwright-test/ui-mode-test-setup.spec.ts b/tests/playwright-test/ui-mode-test-setup.spec.ts index 65c6aa2533..9ed1dadb0d 100644 --- a/tests/playwright-test/ui-mode-test-setup.spec.ts +++ b/tests/playwright-test/ui-mode-test-setup.spec.ts @@ -276,7 +276,7 @@ test('should restart webserver on reload', async ({ runUITest }) => { 'a.test.js': ` import { test, expect } from '@playwright/test'; test('should work', async ({ page }) => { - await page.goto('http://localhost:${port}'); + await page.goto('http://localhost:${port}/hello'); }); ` }, { DEBUG: 'pw:webserver' }); From adc38cfd8b6428a1fa063d785513454503789b06 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 24 Oct 2024 16:49:35 +0200 Subject: [PATCH 11/17] test: rebaseline test expectations (#33270) --- tests/library/capabilities.spec.ts | 1 + tests/page/page-screenshot.spec.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/library/capabilities.spec.ts b/tests/library/capabilities.spec.ts index cdc97d1523..72eb73724c 100644 --- a/tests/library/capabilities.spec.ts +++ b/tests/library/capabilities.spec.ts @@ -401,6 +401,7 @@ it('should be able to render avif images', { }, async ({ page, server, browserName, platform }) => { it.fixme(browserName === 'webkit' && platform === 'win32'); it.skip(browserName === 'webkit' && hostPlatform.startsWith('ubuntu20.04'), 'Ubuntu 20.04 is frozen'); + it.skip(browserName === 'webkit' && hostPlatform.startsWith('debian11'), 'Debian 11 is too old'); await page.goto(server.EMPTY_PAGE); await page.setContent(``); await expect.poll(() => page.locator('img').boundingBox()).toEqual(expect.objectContaining({ diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index 879307aa91..7e6856c24c 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -286,7 +286,7 @@ it.describe('page screenshot', () => { await page.goto(server.PREFIX + '/screenshots/canvas.html'); const screenshot = await page.screenshot(); if ((!headless && browserName === 'chromium' && isMac && os.arch() === 'arm64' && macVersion >= 14) || - (browserName === 'webkit' && isLinux)) + (browserName === 'webkit' && isLinux && os.arch() === 'x64')) expect(screenshot).toMatchSnapshot('screenshot-canvas-with-accurate-corners.png'); else expect(screenshot).toMatchSnapshot('screenshot-canvas.png'); From 0509eca9b690aa0efc47fd7bb0b6cd555d1de848 Mon Sep 17 00:00:00 2001 From: Playwright Service <89237858+playwrightmachine@users.noreply.github.com> Date: Thu, 24 Oct 2024 08:47:34 -0700 Subject: [PATCH 12/17] feat(chromium): roll to r1145 (#33274) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 4 +- packages/playwright-core/browsers.json | 4 +- .../src/server/deviceDescriptorsSource.json | 96 +++++++++---------- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index e4f15a6d97..df47df9121 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎭 Playwright -[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-131.0.6778.3-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-131.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) +[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) [![Chromium version](https://img.shields.io/badge/chromium-131.0.6778.13-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-131.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.0-blue.svg?logo=safari)](https://webkit.org/) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://aka.ms/playwright/discord) ## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright) @@ -8,7 +8,7 @@ Playwright is a framework for Web Testing and Automation. It allows testing [Chr | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 131.0.6778.3 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 131.0.6778.13 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 18.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Firefox 131.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 6cd5bc321d..5677f6ec7f 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -3,9 +3,9 @@ "browsers": [ { "name": "chromium", - "revision": "1143", + "revision": "1145", "installByDefault": true, - "browserVersion": "131.0.6778.3" + "browserVersion": "131.0.6778.13" }, { "name": "chromium-tip-of-tree", diff --git a/packages/playwright-core/src/server/deviceDescriptorsSource.json b/packages/playwright-core/src/server/deviceDescriptorsSource.json index aef1b4c62b..90608d052d 100644 --- a/packages/playwright-core/src/server/deviceDescriptorsSource.json +++ b/packages/playwright-core/src/server/deviceDescriptorsSource.json @@ -110,7 +110,7 @@ "defaultBrowserType": "webkit" }, "Galaxy S5": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -121,7 +121,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -132,7 +132,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 360, "height": 740 @@ -143,7 +143,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S8 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 740, "height": 360 @@ -154,7 +154,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 320, "height": 658 @@ -165,7 +165,7 @@ "defaultBrowserType": "chromium" }, "Galaxy S9+ landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 658, "height": 320 @@ -176,7 +176,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36", "viewport": { "width": 712, "height": 1138 @@ -187,7 +187,7 @@ "defaultBrowserType": "chromium" }, "Galaxy Tab S4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36", "viewport": { "width": 1138, "height": 712 @@ -1098,7 +1098,7 @@ "defaultBrowserType": "webkit" }, "LG Optimus L70": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1109,7 +1109,7 @@ "defaultBrowserType": "chromium" }, "LG Optimus L70 landscape": { - "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1120,7 +1120,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1131,7 +1131,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 550 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1142,7 +1142,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 360, "height": 640 @@ -1153,7 +1153,7 @@ "defaultBrowserType": "chromium" }, "Microsoft Lumia 950 landscape": { - "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36 Edge/14.14263", + "userAgent": "Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36 Edge/14.14263", "viewport": { "width": 640, "height": 360 @@ -1164,7 +1164,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36", "viewport": { "width": 800, "height": 1280 @@ -1175,7 +1175,7 @@ "defaultBrowserType": "chromium" }, "Nexus 10 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36", "viewport": { "width": 1280, "height": 800 @@ -1186,7 +1186,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 384, "height": 640 @@ -1197,7 +1197,7 @@ "defaultBrowserType": "chromium" }, "Nexus 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 640, "height": 384 @@ -1208,7 +1208,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1219,7 +1219,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1230,7 +1230,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1241,7 +1241,7 @@ "defaultBrowserType": "chromium" }, "Nexus 5X landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1252,7 +1252,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1263,7 +1263,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1274,7 +1274,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 412, "height": 732 @@ -1285,7 +1285,7 @@ "defaultBrowserType": "chromium" }, "Nexus 6P landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 732, "height": 412 @@ -1296,7 +1296,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36", "viewport": { "width": 600, "height": 960 @@ -1307,7 +1307,7 @@ "defaultBrowserType": "chromium" }, "Nexus 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36", "viewport": { "width": 960, "height": 600 @@ -1362,7 +1362,7 @@ "defaultBrowserType": "webkit" }, "Pixel 2": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 411, "height": 731 @@ -1373,7 +1373,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 731, "height": 411 @@ -1384,7 +1384,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 411, "height": 823 @@ -1395,7 +1395,7 @@ "defaultBrowserType": "chromium" }, "Pixel 2 XL landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 823, "height": 411 @@ -1406,7 +1406,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 393, "height": 786 @@ -1417,7 +1417,7 @@ "defaultBrowserType": "chromium" }, "Pixel 3 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 786, "height": 393 @@ -1428,7 +1428,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 353, "height": 745 @@ -1439,7 +1439,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 745, "height": 353 @@ -1450,7 +1450,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G)": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "screen": { "width": 412, "height": 892 @@ -1465,7 +1465,7 @@ "defaultBrowserType": "chromium" }, "Pixel 4a (5G) landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "screen": { "height": 892, "width": 412 @@ -1480,7 +1480,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "screen": { "width": 393, "height": 851 @@ -1495,7 +1495,7 @@ "defaultBrowserType": "chromium" }, "Pixel 5 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "screen": { "width": 851, "height": 393 @@ -1510,7 +1510,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "screen": { "width": 412, "height": 915 @@ -1525,7 +1525,7 @@ "defaultBrowserType": "chromium" }, "Pixel 7 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "screen": { "width": 915, "height": 412 @@ -1540,7 +1540,7 @@ "defaultBrowserType": "chromium" }, "Moto G4": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 360, "height": 640 @@ -1551,7 +1551,7 @@ "defaultBrowserType": "chromium" }, "Moto G4 landscape": { - "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Mobile Safari/537.36", + "userAgent": "Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Mobile Safari/537.36", "viewport": { "width": 640, "height": 360 @@ -1562,7 +1562,7 @@ "defaultBrowserType": "chromium" }, "Desktop Chrome HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36", "screen": { "width": 1792, "height": 1120 @@ -1577,7 +1577,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge HiDPI": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36 Edg/131.0.6778.3", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36 Edg/131.0.6778.13", "screen": { "width": 1792, "height": 1120 @@ -1622,7 +1622,7 @@ "defaultBrowserType": "webkit" }, "Desktop Chrome": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36", "screen": { "width": 1920, "height": 1080 @@ -1637,7 +1637,7 @@ "defaultBrowserType": "chromium" }, "Desktop Edge": { - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.3 Safari/537.36 Edg/131.0.6778.3", + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.13 Safari/537.36 Edg/131.0.6778.13", "screen": { "width": 1920, "height": 1080 From a2dec8da63b171b75a32ae66a50336b059c90383 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 24 Oct 2024 19:23:19 +0200 Subject: [PATCH 13/17] fix(codegen): SIGINT handling was leading to zombie processes (#33269) --- packages/playwright-core/src/cli/program.ts | 2 ++ packages/playwright-core/src/protocol/validator.ts | 1 + packages/playwright-core/src/server/recorder.ts | 2 ++ packages/playwright-core/src/server/recorder/recorderApp.ts | 2 +- .../playwright-core/src/server/recorder/recorderFrontend.ts | 1 + packages/protocol/src/channels.ts | 2 ++ packages/protocol/src/protocol.yml | 1 + 7 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 1895f2dfcf..59ad0cd9f8 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -554,6 +554,7 @@ async function open(options: Options, url: string | undefined, language: string) contextOptions, device: options.device, saveStorage: options.saveStorage, + handleSIGINT: false, }); await openPage(context, url); } @@ -577,6 +578,7 @@ async function codegen(options: Options & { target: string, output?: string, tes codegenMode: process.env.PW_RECORDER_IS_TRACE_VIEWER ? 'trace-events' : 'actions', testIdAttributeName, outputFile: outputFile ? path.resolve(outputFile) : undefined, + handleSIGINT: false, }); await openPage(context, url); } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 24ddf0014c..7bad26f498 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -976,6 +976,7 @@ scheme.BrowserContextEnableRecorderParams = tObject({ device: tOptional(tString), saveStorage: tOptional(tString), outputFile: tOptional(tString), + handleSIGINT: tOptional(tBoolean), omitCallTracking: tOptional(tBoolean), }); scheme.BrowserContextEnableRecorderResult = tOptional(tObject({})); diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 74ebefee86..a2be2992e6 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -34,6 +34,7 @@ import { buildFullSelector } from '../utils/isomorphic/recorderUtils'; const recorderSymbol = Symbol('recorderSymbol'); export class Recorder implements InstrumentationListener, IRecorder { + readonly handleSIGINT: boolean | undefined; private _context: BrowserContext; private _mode: Mode; private _highlightedSelector = ''; @@ -75,6 +76,7 @@ export class Recorder implements InstrumentationListener, IRecorder { constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) { this._mode = params.mode || 'none'; + this.handleSIGINT = params.handleSIGINT; this._contextRecorder = new ContextRecorder(codegenMode, context, params, {}); this._context = context; this._omitCallTracking = !!params.omitCallTracking; diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index df27a96c6c..6b6b73e241 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -111,7 +111,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { noDefaultViewport: true, headless: !!process.env.PWTEST_CLI_HEADLESS || (isUnderTest() && !headed), useWebSocket: isUnderTest(), - handleSIGINT: false, + handleSIGINT: recorder.handleSIGINT, executablePath: inspectedContext._browser.options.isChromium ? inspectedContext._browser.options.customExecutablePath : undefined, } }); diff --git a/packages/playwright-core/src/server/recorder/recorderFrontend.ts b/packages/playwright-core/src/server/recorder/recorderFrontend.ts index dbba78ceeb..b3dc0daad9 100644 --- a/packages/playwright-core/src/server/recorder/recorderFrontend.ts +++ b/packages/playwright-core/src/server/recorder/recorderFrontend.ts @@ -21,6 +21,7 @@ import type { EventEmitter } from 'events'; export interface IRecorder { setMode(mode: Mode): void; mode(): Mode; + readonly handleSIGINT: boolean | undefined; } export interface IRecorderApp extends EventEmitter { diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 5ecc2f4077..7fcb815468 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1777,6 +1777,7 @@ export type BrowserContextEnableRecorderParams = { device?: string, saveStorage?: string, outputFile?: string, + handleSIGINT?: boolean, omitCallTracking?: boolean, }; export type BrowserContextEnableRecorderOptions = { @@ -1790,6 +1791,7 @@ export type BrowserContextEnableRecorderOptions = { device?: string, saveStorage?: string, outputFile?: string, + handleSIGINT?: boolean, omitCallTracking?: boolean, }; export type BrowserContextEnableRecorderResult = void; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index c91cecbe6c..98428cae2f 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1208,6 +1208,7 @@ BrowserContext: device: string? saveStorage: string? outputFile: string? + handleSIGINT: boolean? omitCallTracking: boolean? newCDPSession: From ff5f1628dc8dbbb0217554adc3105bff4e0a504c Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 24 Oct 2024 16:49:10 -0700 Subject: [PATCH 14/17] chore: allow aria snapshot rebaselines (#33256) --- .../src/server/injected/ariaSnapshot.ts | 23 +++-- packages/playwright/src/common/ipc.ts | 1 + packages/playwright/src/matchers/expect.ts | 11 ++- .../playwright/src/matchers/matcherHint.ts | 3 +- .../src/matchers/toMatchAriaSnapshot.ts | 21 +++- packages/playwright/src/runner/dispatcher.ts | 3 + packages/playwright/src/runner/rebase.ts | 95 +++++++++++++++++++ packages/playwright/src/runner/runner.ts | 3 + packages/playwright/src/worker/testInfo.ts | 3 +- tests/page/page-aria-snapshot.spec.ts | 24 ++--- tests/page/to-match-aria-snapshot.spec.ts | 8 +- .../playwright-test/ui-mode-test-run.spec.ts | 76 +++++++-------- .../update-aria-snapshot.spec.ts | 48 ++++++++++ 13 files changed, 245 insertions(+), 74 deletions(-) create mode 100644 packages/playwright/src/runner/rebase.ts create mode 100644 tests/playwright-test/update-aria-snapshot.spec.ts diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index d544c0a82c..a8d9e81bcd 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { escapeWithQuotes } from '@isomorphic/stringUtils'; import * as roleUtils from './roleUtils'; import { getElementComputedStyle } from './domUtils'; import type { AriaRole } from './roleUtils'; @@ -184,7 +183,7 @@ function matchesText(text: string | undefined, template: RegExp | string | undef export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } { const root = generateAriaTree(rootElement); const matches = matchesNodeDeep(root, template); - return { matches, received: renderAriaTree(root, { noText: true }) }; + return { matches, received: renderAriaTree(root) }; } function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean { @@ -252,17 +251,16 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean { return !!results.length; } -export function renderAriaTree(ariaNode: AriaNode, options?: { noText?: boolean }): string { +export function renderAriaTree(ariaNode: AriaNode): string { const lines: string[] = []; const visit = (ariaNode: AriaNode | string, indent: string) => { if (typeof ariaNode === 'string') { - if (!options?.noText) - lines.push(indent + '- text: ' + quoteYamlString(ariaNode)); + lines.push(indent + '- text: ' + quoteYamlString(ariaNode)); return; } let line = `${indent}- ${ariaNode.role}`; if (ariaNode.name) - line += ` ${escapeWithQuotes(ariaNode.name, '"')}`; + line += ` ${quoteYamlString(ariaNode.name)}`; if (ariaNode.checked === 'mixed') line += ` [checked=mixed]`; @@ -281,9 +279,16 @@ export function renderAriaTree(ariaNode: AriaNode, options?: { noText?: boolean if (ariaNode.selected === true) line += ` [selected]`; - lines.push(line + (ariaNode.children.length ? ':' : '')); - for (const child of ariaNode.children || []) - visit(child, indent + ' '); + if (!ariaNode.children.length) { + lines.push(line); + } else if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string') { + line += ': ' + quoteYamlString(ariaNode.children[0]); + lines.push(line); + } else { + lines.push(line + ':'); + for (const child of ariaNode.children || []) + visit(child, indent + ' '); + } }; if (ariaNode.role === 'fragment') { diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index 82538bb6ed..dcde2b28d4 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -106,6 +106,7 @@ export type StepEndPayload = { stepId: string; wallTime: number; // milliseconds since unix epoch error?: TestInfoErrorImpl; + suggestedRebaseline?: string; }; export type TestEntry = { diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 0d276d4101..0bd116e7a1 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -61,7 +61,7 @@ import { } from '../common/expectBundle'; import { zones } from 'playwright-core/lib/utils'; import { TestInfoImpl } from '../worker/testInfo'; -import { ExpectError, isExpectError } from './matcherHint'; +import { ExpectError, isJestError } from './matcherHint'; import { toMatchAriaSnapshot } from './toMatchAriaSnapshot'; // #region @@ -323,8 +323,13 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const step = testInfo._addStep(stepInfo); - const reportStepError = (jestError: Error | unknown) => { - const error = isExpectError(jestError) ? new ExpectError(jestError, customMessage, stackFrames) : jestError; + const reportStepError = (e: Error | unknown) => { + const jestError = isJestError(e) ? e : null; + const error = jestError ? new ExpectError(jestError, customMessage, stackFrames) : e; + if (jestError?.matcherResult.suggestedRebaseline) { + step.complete({ suggestedRebaseline: jestError?.matcherResult.suggestedRebaseline }); + return; + } step.complete({ error }); if (this._info.isSoft) testInfo._failWithError(error); diff --git a/packages/playwright/src/matchers/matcherHint.ts b/packages/playwright/src/matchers/matcherHint.ts index 200501c1bc..c4f5afd4b4 100644 --- a/packages/playwright/src/matchers/matcherHint.ts +++ b/packages/playwright/src/matchers/matcherHint.ts @@ -43,6 +43,7 @@ export type MatcherResult = { printedReceived?: string; printedExpected?: string; printedDiff?: string; + suggestedRebaseline?: string; }; export type MatcherResultProperty = Omit, 'message'> & { @@ -69,6 +70,6 @@ export class ExpectError extends Error { } } -export function isExpectError(e: unknown): e is ExpectError { +export function isJestError(e: unknown): e is JestError { return e instanceof Error && 'matcherResult' in e; } diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 5b2c204410..2c92d562d6 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -22,6 +22,7 @@ import { colors } from 'playwright-core/lib/utilsBundle'; import { EXPECTED_COLOR } from '../common/expectBundle'; import { callLogText } from '../util'; import { printReceivedStringContainExpectedSubstring } from './expect'; +import { currentTestInfo } from '../common/globals'; export async function toMatchAriaSnapshot( this: ExpectMatcherState, @@ -31,6 +32,15 @@ export async function toMatchAriaSnapshot( ): Promise> { const matcherName = 'toMatchAriaSnapshot'; + const testInfo = currentTestInfo(); + if (!testInfo) + throw new Error(`toMatchSnapshot() must be called during the test`); + + if (testInfo._projectInternal.ignoreSnapshots) + return { pass: !this.isNot, message: () => '', name: 'toMatchSnapshot', expected }; + + const updateSnapshots = testInfo.config.updateSnapshots; + const matcherOptions = { isNot: this.isNot, promise: this.promise, @@ -65,6 +75,12 @@ export async function toMatchAriaSnapshot( } }; + let suggestedRebaseline: string | undefined; + if (!this.isNot && pass === this.isNot) { + if (updateSnapshots === 'all' || (updateSnapshots === 'missing' && !expected.trim())) + suggestedRebaseline = `toMatchAriaSnapshot(\`\n${unshift(received, '${indent} ')}\n\${indent}\`)`; + } + return { name: matcherName, expected, @@ -72,6 +88,7 @@ export async function toMatchAriaSnapshot( pass, actual: received, log, + suggestedRebaseline, timeout: timedOut ? timeout : undefined, }; } @@ -80,7 +97,7 @@ function escapePrivateUsePoints(str: string) { return str.replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`); } -function unshift(snapshot: string): string { +function unshift(snapshot: string, indent: string = ''): string { const lines = snapshot.split('\n'); let whitespacePrefixLength = 100; for (const line of lines) { @@ -91,5 +108,5 @@ function unshift(snapshot: string): string { whitespacePrefixLength = match[1].length; break; } - return lines.filter(t => t.trim()).map(line => line.substring(whitespacePrefixLength)).join('\n'); + return lines.filter(t => t.trim()).map(line => indent + line.substring(whitespacePrefixLength)).join('\n'); } diff --git a/packages/playwright/src/runner/dispatcher.ts b/packages/playwright/src/runner/dispatcher.ts index 4e971f2475..98e0ec1546 100644 --- a/packages/playwright/src/runner/dispatcher.ts +++ b/packages/playwright/src/runner/dispatcher.ts @@ -27,6 +27,7 @@ import type { FullConfigInternal } from '../common/config'; import type { ReporterV2 } from '../reporters/reporterV2'; import type { FailureTracker } from './failureTracker'; import { colors } from 'playwright-core/lib/utilsBundle'; +import { addSuggestedRebaseline } from './rebase'; export type EnvByProjectId = Map>; @@ -341,6 +342,8 @@ class JobDispatcher { step.duration = params.wallTime - step.startTime.getTime(); if (params.error) step.error = params.error; + if (params.suggestedRebaseline) + addSuggestedRebaseline(step.location!, params.suggestedRebaseline); steps.delete(params.stepId); this._reporter.onStepEnd?.(test, result, step); } diff --git a/packages/playwright/src/runner/rebase.ts b/packages/playwright/src/runner/rebase.ts new file mode 100644 index 0000000000..17717e977e --- /dev/null +++ b/packages/playwright/src/runner/rebase.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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. + */ + +import path from 'path'; +import fs from 'fs'; +import type { T } from '../transform/babelBundle'; +import { types, traverse, parse } from '../transform/babelBundle'; +import { MultiMap } from 'playwright-core/lib/utils'; +import { generateUnifiedDiff } from 'playwright-core/lib/utils'; +import type { FullConfigInternal } from '../common/config'; +import { filterProjects } from './projectUtils'; +const t: typeof T = types; + +type Location = { + file: string; + line: number; + column: number; +}; + +type Replacement = { + // Points to the call expression. + location: Location; + code: string; +}; + +const suggestedRebaselines = new MultiMap(); + +export function addSuggestedRebaseline(location: Location, suggestedRebaseline: string) { + suggestedRebaselines.set(location.file, { location, code: suggestedRebaseline }); +} + +export async function applySuggestedRebaselines(config: FullConfigInternal) { + if (config.config.updateSnapshots !== 'all' && config.config.updateSnapshots !== 'missing') + return; + const [project] = filterProjects(config.projects, config.cliProjectFilter); + if (!project) + return; + + for (const fileName of suggestedRebaselines.keys()) { + const source = await fs.promises.readFile(fileName, 'utf8'); + const lines = source.split('\n'); + const replacements = suggestedRebaselines.get(fileName); + const fileNode = parse(source, { sourceType: 'module' }); + const ranges: { start: number, end: number, oldText: string, newText: string }[] = []; + + traverse(fileNode, { + CallExpression: path => { + const node = path.node; + if (node.arguments.length !== 1) + return; + if (!t.isMemberExpression(node.callee)) + return; + const argument = node.arguments[0]; + if (!t.isStringLiteral(argument) && !t.isTemplateLiteral(argument)) + return; + + const matcher = node.callee.property; + for (const replacement of replacements) { + // In Babel, rows are 1-based, columns are 0-based. + if (matcher.loc!.start.line !== replacement.location.line) + continue; + if (matcher.loc!.start.column + 1 !== replacement.location.column) + continue; + const indent = lines[matcher.loc!.start.line - 1].match(/^\s*/)![0]; + const newText = replacement.code.replace(/\$\{indent\}/g, indent); + ranges.push({ start: matcher.start!, end: node.end!, oldText: source.substring(matcher.start!, node.end!), newText }); + } + } + }); + + ranges.sort((a, b) => b.start - a.start); + let result = source; + for (const range of ranges) + result = result.substring(0, range.start) + range.newText + result.substring(range.end); + + const relativeName = path.relative(process.cwd(), fileName); + + const patchFile = path.join(project.project.outputDir, 'rebaselines.patch'); + await fs.promises.mkdir(path.dirname(patchFile), { recursive: true }); + await fs.promises.writeFile(patchFile, generateUnifiedDiff(source, result, relativeName)); + } +} diff --git a/packages/playwright/src/runner/runner.ts b/packages/playwright/src/runner/runner.ts index 923bf36072..966fb13e92 100644 --- a/packages/playwright/src/runner/runner.ts +++ b/packages/playwright/src/runner/runner.ts @@ -24,6 +24,7 @@ import type { FullConfigInternal } from '../common/config'; import { affectedTestFiles } from '../transform/compilationCache'; import { InternalReporter } from '../reporters/internalReporter'; import { LastRunReporter } from './lastRun'; +import { applySuggestedRebaselines } from './rebase'; type ProjectConfigWithFiles = { name: string; @@ -88,6 +89,8 @@ export class Runner { ]; const status = await runTasks(new TestRun(config, reporter), tasks, config.config.globalTimeout); + await applySuggestedRebaselines(config); + // Calling process.exit() might truncate large stdout/stderr output. // See https://github.com/nodejs/node/issues/6456. // See https://github.com/nodejs/node/issues/12921 diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index ed71b1a751..b5b1010ff2 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -31,7 +31,7 @@ import type { StackFrame } from '@protocol/channels'; import { testInfoError } from './util'; export interface TestStepInternal { - complete(result: { error?: Error | unknown, attachments?: Attachment[] }): void; + complete(result: { error?: Error | unknown, attachments?: Attachment[], suggestedRebaseline?: string }): void; stepId: string; title: string; category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string; @@ -297,6 +297,7 @@ export class TestInfoImpl implements TestInfo { stepId, wallTime: step.endWallTime, error: step.error, + suggestedRebaseline: result.suggestedRebaseline, }; this._onStepEnd(payload); const errorForTrace = step.error ? { name: '', message: step.error.message || '', stack: step.error.stack } : undefined; diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 2b8790589a..e4ee122b0c 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -64,10 +64,8 @@ it('should snapshot list with accessible name', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - list "my list": - - listitem: - - text: "one" - - listitem: - - text: "two" + - listitem: "one" + - listitem: "two" `); }); @@ -107,8 +105,7 @@ it('should snapshot details visibility', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - group: - - text: "Summary" + - group: "Summary" `); }); @@ -151,8 +148,7 @@ it('should snapshot integration', async ({ page }) => { - text: "Open source projects and samples from Microsoft" - list: - listitem: - - group: - - text: "Verified" + - group: "Verified" - listitem: - link "Sponsor" `); @@ -168,12 +164,10 @@ it('should support multiline text', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - paragraph: - - text: "Line 1 Line 2 Line 3" + - paragraph: "Line 1 Line 2 Line 3" `); await expect(page.locator('body')).toMatchAriaSnapshot(` - - paragraph: - - text: | + - paragraph: | Line 1 Line 2 Line 3 @@ -388,8 +382,7 @@ it('should include pseudo codepoints', async ({ page, server }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - paragraph: - - text: "\ueab2hello" + - paragraph: "\ueab2hello" `); }); @@ -403,7 +396,6 @@ it('check aria-hidden text', async ({ page, server }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - paragraph: - - text: "hello" + - paragraph: "hello" `); }); diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 1335be5a59..8050c3b569 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -43,8 +43,8 @@ test('should match list with accessible name', async ({ page }) => { `); await expect(page.locator('body')).toMatchAriaSnapshot(` - list "my list": - - listitem: one - - listitem: two + - listitem: "one" + - listitem: "two" `); }); @@ -90,7 +90,7 @@ test('should allow text nodes', async ({ page }) => { await expect(page.locator('body')).toMatchAriaSnapshot(` - heading "Microsoft" - - text: Open source projects and samples from Microsoft + - text: "Open source projects and samples from Microsoft" `); }); @@ -103,7 +103,7 @@ test('details visibility', async ({ page }) => { `); await expect(page.locator('body')).toMatchAriaSnapshot(` - - group: Summary + - group: "Summary" `); }); diff --git a/tests/playwright-test/ui-mode-test-run.spec.ts b/tests/playwright-test/ui-mode-test-run.spec.ts index 0da2940e96..b30f901654 100644 --- a/tests/playwright-test/ui-mode-test-run.spec.ts +++ b/tests/playwright-test/ui-mode-test-run.spec.ts @@ -65,19 +65,19 @@ test('should run visible', async ({ runUITest }) => { - tree: - treeitem "[icon-error] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} - - treeitem ${/\[icon-error\] fails \d+ms/} [selected]: + - treeitem ${/\[icon-check\] passes/} + - treeitem ${/\[icon-error\] fails/} [selected]: - button "Run" - button "Show source" - button "Watch" - treeitem "[icon-error] suite" - treeitem "[icon-error] b.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} - - treeitem ${/\[icon-error\] fails \d+ms/} + - treeitem ${/\[icon-check\] passes/} + - treeitem ${/\[icon-error\] fails/} - treeitem "[icon-check] c.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} + - treeitem ${/\[icon-check\] passes/} - treeitem "[icon-circle-slash] skipped" `); @@ -125,7 +125,7 @@ test('should run on hover', async ({ runUITest }) => { - tree: - treeitem "[icon-circle-outline] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/}: + - treeitem ${/\[icon-check\] passes/}: - button "Run" - button "Show source" - button "Watch" @@ -185,7 +185,7 @@ test('should run on Enter', async ({ runUITest }) => { - treeitem "[icon-error] a.test.ts" [expanded]: - group: - treeitem "[icon-circle-outline] passes" - - treeitem ${/\[icon-error\] fails \d+ms/} [selected]: + - treeitem ${/\[icon-error\] fails/} [selected]: - button "Run" - button "Show source" - button "Watch" @@ -225,19 +225,19 @@ test('should run by project', async ({ runUITest }) => { - tree: - treeitem "[icon-error] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} - - treeitem ${/\[icon-error\] fails \d+ms/} [selected]: + - treeitem ${/\[icon-check\] passes/} + - treeitem ${/\[icon-error\] fails/} [selected]: - button "Run" - button "Show source" - button "Watch" - treeitem "[icon-error] suite" - treeitem "[icon-error] b.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} - - treeitem ${/\[icon-error\] fails \d+ms/} + - treeitem ${/\[icon-check\] passes/} + - treeitem ${/\[icon-error\] fails/} - treeitem "[icon-check] c.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} + - treeitem ${/\[icon-check\] passes/} - treeitem "[icon-circle-slash] skipped" `); @@ -299,14 +299,14 @@ test('should run by project', async ({ runUITest }) => { - tree: - treeitem "[icon-error] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-circle-outline\] passes \d+ms/} [expanded] [selected]: + - treeitem ${/\[icon-circle-outline\] passes/} [expanded] [selected]: - button "Run" - button "Show source" - button "Watch" - group: - - treeitem ${/\[icon-check\] foo \d+ms/} + - treeitem ${/\[icon-check\] foo/} - treeitem ${/\[icon-circle-outline\] bar/} - - treeitem ${/\[icon-error\] fails \d+ms/} + - treeitem ${/\[icon-error\] fails/} `); await expect(page.getByText('Projects: foo bar')).toBeVisible(); @@ -333,17 +333,17 @@ test('should run by project', async ({ runUITest }) => { - tree: - treeitem "[icon-error] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} [expanded]: + - treeitem ${/\[icon-check\] passes/} [expanded]: - group: - - treeitem ${/\[icon-check\] foo \d+ms/} - - treeitem ${/\[icon-check\] bar \d+ms/} - - treeitem ${/\[icon-error\] fails \d+ms/} [expanded]: + - treeitem ${/\[icon-check\] foo/} + - treeitem ${/\[icon-check\] bar/} + - treeitem ${/\[icon-error\] fails/} [expanded]: - group: - - treeitem ${/\[icon-error\] foo \d+ms/} [selected]: + - treeitem ${/\[icon-error\] foo/} [selected]: - button "Run" - button "Show source" - button "Watch" - - treeitem ${/\[icon-error\] bar \d+ms/} + - treeitem ${/\[icon-error\] bar/} - treeitem ${/\[icon-error\] suite/} - treeitem "[icon-error] b.test.ts" [expanded]: - group: @@ -385,7 +385,7 @@ test('should stop', async ({ runUITest }) => { - treeitem "[icon-loading] a.test.ts" [expanded]: - group: - treeitem "[icon-circle-slash] test 0" - - treeitem ${/\[icon-check\] test 1 \d+ms/} + - treeitem ${/\[icon-check\] test 1/} - treeitem ${/\[icon-loading\] test 2/} - treeitem ${/\[icon-clock\] test 3/} `); @@ -408,7 +408,7 @@ test('should stop', async ({ runUITest }) => { - treeitem "[icon-circle-outline] a.test.ts" [expanded]: - group: - treeitem "[icon-circle-slash] test 0" - - treeitem ${/\[icon-check\] test 1 \d+ms/} + - treeitem ${/\[icon-check\] test 1/} - treeitem ${/\[icon-circle-outline\] test 2/} - treeitem ${/\[icon-circle-outline\] test 3/} `); @@ -478,19 +478,19 @@ test('should show time', async ({ runUITest }) => { - tree: - treeitem "[icon-error] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} - - treeitem ${/\[icon-error\] fails \d+ms/} [selected]: + - treeitem ${/\[icon-check\] passes \d+m?s/} + - treeitem ${/\[icon-error\] fails \d+m?s/} [selected]: - button "Run" - button "Show source" - button "Watch" - treeitem "[icon-error] suite" - treeitem "[icon-error] b.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} - - treeitem ${/\[icon-error\] fails \d+ms/} + - treeitem ${/\[icon-check\] passes \d+m?s/} + - treeitem ${/\[icon-error\] fails \d+m?s/} - treeitem "[icon-check] c.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] passes \d+ms/} + - treeitem ${/\[icon-check\] passes \d+m?s/} - treeitem "[icon-circle-slash] skipped" `); @@ -522,7 +522,7 @@ test('should show test.fail as passing', async ({ runUITest }) => { - tree: - treeitem "[icon-check] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] should fail \d+ms/} + - treeitem ${/\[icon-check\] should fail \d+m?s/} `); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); @@ -558,7 +558,7 @@ test('should ignore repeatEach', async ({ runUITest }) => { - tree: - treeitem "[icon-check] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] should pass \d+ms/} + - treeitem ${/\[icon-check\] should pass/} `); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); @@ -593,7 +593,7 @@ test('should remove output folder before test run', async ({ runUITest }) => { - tree: - treeitem "[icon-check] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] should pass \d+ms/} + - treeitem ${/\[icon-check\] should pass/} `); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); @@ -608,7 +608,7 @@ test('should remove output folder before test run', async ({ runUITest }) => { - tree: - treeitem "[icon-check] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] should pass \d+ms/} + - treeitem ${/\[icon-check\] should pass/} `); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); @@ -656,7 +656,7 @@ test('should show proper total when using deps', async ({ runUITest }) => { - tree: - treeitem "[icon-circle-outline] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] run @setup setup \d+ms/} [selected]: + - treeitem ${/\[icon-check\] run @setup setup/} [selected]: - button "Run" - button "Show source" - button "Watch" @@ -676,8 +676,8 @@ test('should show proper total when using deps', async ({ runUITest }) => { - tree: - treeitem "[icon-check] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] run @setup setup \d+ms/} - - treeitem ${/\[icon-check\] run @chromium chromium \d+ms/} [selected]: + - treeitem ${/\[icon-check\] run @setup setup/} + - treeitem ${/\[icon-check\] run @chromium chromium/} [selected]: - button "Run" - button "Show source" - button "Watch" @@ -746,7 +746,7 @@ test('should respect --tsconfig option', { - tree: - treeitem "[icon-check] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] test \d+ms/} + - treeitem ${/\[icon-check\] test/} `); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); @@ -775,7 +775,7 @@ test('should respect --ignore-snapshots option', { - tree: - treeitem "[icon-check] a.test.ts" [expanded]: - group: - - treeitem ${/\[icon-check\] snapshot \d+ms/} + - treeitem ${/\[icon-check\] snapshot/} `); }); @@ -788,4 +788,4 @@ test('should show funny messages', async ({ runUITest }) => { await expect(schmettingsHeader).toBeVisible(); await schmettingsHeader.click(); await expect(page.getByRole('checkbox', { name: 'Fart mode' })).toBeVisible(); -}); \ No newline at end of file +}); diff --git a/tests/playwright-test/update-aria-snapshot.spec.ts b/tests/playwright-test/update-aria-snapshot.spec.ts new file mode 100644 index 0000000000..092d408191 --- /dev/null +++ b/tests/playwright-test/update-aria-snapshot.spec.ts @@ -0,0 +1,48 @@ +/** + * 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. + */ + +import * as fs from 'fs'; +import { test, expect } from './playwright-test-fixtures'; + +test('should update snapshot with the update-snapshots flag', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('test', async ({ page }) => { + await page.setContent(\`

hello

\`); + await expect(page.locator('body')).toMatchAriaSnapshot(\` + - heading "world" + \`); + }); + ` + }, { 'update-snapshots': true }); + + expect(result.exitCode).toBe(0); + const patchPath = testInfo.outputPath('test-results/rebaselines.patch'); + const data = fs.readFileSync(patchPath, 'utf-8'); + expect(data).toBe(`--- a/a.spec.ts ++++ b/a.spec.ts +@@ -3,7 +3,7 @@ + test('test', async ({ page }) => { + await page.setContent(\`

hello

\`); + await expect(page.locator('body')).toMatchAriaSnapshot(\` +- - heading "world" ++ - heading "hello" [level=1] + \`); + }); + +`); +}); From 9707e978671bb77b44537086839610b730eca544 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 25 Oct 2024 00:36:03 -0700 Subject: [PATCH 15/17] chore: revert #33228 - it needs a proper review (#33284) --- .../src/server/trace/viewer/traceViewer.ts | 9 +------ packages/trace-viewer/.gitignore | 2 -- packages/trace-viewer/src/embedded.tsx | 2 +- packages/trace-viewer/src/index.tsx | 2 +- packages/trace-viewer/src/recorder.tsx | 2 +- .../trace-viewer/src/sw/traceModelBackends.ts | 27 +++++++------------ packages/trace-viewer/src/ui/uiModeView.tsx | 7 ++--- packages/trace-viewer/src/uiMode.tsx | 2 +- packages/trace-viewer/vite.config.ts | 3 --- packages/trace-viewer/vite.sw.config.ts | 2 +- .../playwright-test/ui-mode-test-run.spec.ts | 11 -------- utils/build/build.js | 26 ------------------ 12 files changed, 18 insertions(+), 77 deletions(-) diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 32cbba0982..a49148e061 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -125,14 +125,7 @@ export async function installRootRedirect(server: HttpServer, traceUrls: string[ for (const reporter of options.reporter || []) params.append('reporter', reporter); - let baseUrl = ''; - if (process.env.PW_HMR === '1') { - params.set('testServerPort', '' + server.port()); - baseUrl = 'http://localhost:44223'; // port is hardcoded in build.js - } - - const urlPath = `${baseUrl}/trace/${options.webApp || 'index.html'}?${params.toString()}`; - + const urlPath = `./trace/${options.webApp || 'index.html'}?${params.toString()}`; server.routePath('/', (_, response) => { response.statusCode = 302; response.setHeader('Location', urlPath); diff --git a/packages/trace-viewer/.gitignore b/packages/trace-viewer/.gitignore index 1e3942879c..a547bf36d8 100644 --- a/packages/trace-viewer/.gitignore +++ b/packages/trace-viewer/.gitignore @@ -22,5 +22,3 @@ dist-ssr *.njsproj *.sln *.sw? - -public/sw.bundle.js* diff --git a/packages/trace-viewer/src/embedded.tsx b/packages/trace-viewer/src/embedded.tsx index cc61703baf..4f1503dcf2 100644 --- a/packages/trace-viewer/src/embedded.tsx +++ b/packages/trace-viewer/src/embedded.tsx @@ -45,7 +45,7 @@ import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader'; if (window.location.protocol !== 'file:') { if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`); - navigator.serviceWorker.register('sw.bundle.js' + window.location.search); + navigator.serviceWorker.register('sw.bundle.js'); if (!navigator.serviceWorker.controller) { await new Promise(f => { navigator.serviceWorker.oncontrollerchange = () => f(); diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index 2296cb0090..a737d9017f 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -27,7 +27,7 @@ import { WorkbenchLoader } from './ui/workbenchLoader'; await new Promise(f => setTimeout(f, 1000)); if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`); - navigator.serviceWorker.register('sw.bundle.js' + window.location.search); + navigator.serviceWorker.register('sw.bundle.js'); if (!navigator.serviceWorker.controller) { await new Promise(f => { navigator.serviceWorker.oncontrollerchange = () => f(); diff --git a/packages/trace-viewer/src/recorder.tsx b/packages/trace-viewer/src/recorder.tsx index 6239df86ab..5e6b9764e3 100644 --- a/packages/trace-viewer/src/recorder.tsx +++ b/packages/trace-viewer/src/recorder.tsx @@ -26,7 +26,7 @@ import { RecorderView } from './ui/recorder/recorderView'; if (window.location.protocol !== 'file:') { if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the Recorder (${window.location}) via HTTPS or localhost.`); - navigator.serviceWorker.register('sw.bundle.js' + window.location.search); + navigator.serviceWorker.register('sw.bundle.js'); if (!navigator.serviceWorker.controller) { await new Promise(f => { navigator.serviceWorker.oncontrollerchange = () => f(); diff --git a/packages/trace-viewer/src/sw/traceModelBackends.ts b/packages/trace-viewer/src/sw/traceModelBackends.ts index 4f5bd49624..19c5fc2dee 100644 --- a/packages/trace-viewer/src/sw/traceModelBackends.ts +++ b/packages/trace-viewer/src/sw/traceModelBackends.ts @@ -30,8 +30,9 @@ export class ZipTraceModelBackend implements TraceModelBackend { constructor(traceURL: string, progress: Progress) { this._traceURL = traceURL; + zipjs.configure({ baseURL: self.location.href } as any); this._zipReader = new zipjs.ZipReader( - new zipjs.HttpReader(formatTraceFileUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any), + new zipjs.HttpReader(formatUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any), { useWebWorkers: false }); this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => { const map = new Map(); @@ -86,7 +87,7 @@ export class FetchTraceModelBackend implements TraceModelBackend { constructor(traceURL: string) { this._traceURL = traceURL; - this._entriesPromise = fetch(formatTraceFileUrl(traceURL)).then(async response => { + this._entriesPromise = fetch('/trace/file?path=' + encodeURIComponent(traceURL)).then(async response => { const json = JSON.parse(await response.text()); const entries = new Map(); for (const entry of json.entries) @@ -128,22 +129,14 @@ export class FetchTraceModelBackend implements TraceModelBackend { const fileName = entries.get(entryName); if (!fileName) return; - - return fetch(formatTraceFileUrl(fileName)); + return fetch('/trace/file?path=' + encodeURIComponent(fileName)); } } -const baseURL = new URL(self.location.href); -baseURL.port = baseURL.searchParams.get('testServerPort') ?? baseURL.port; - -function formatTraceFileUrl(trace: string) { - if (trace.startsWith('https://www.dropbox.com/')) - return 'https://dl.dropboxusercontent.com/' + trace.substring('https://www.dropbox.com/'.length); - - if (trace.startsWith('http') || trace.startsWith('blob')) - return trace; - - const url = new URL('/trace/file', baseURL); - url.searchParams.set('path', trace); - return url.toString(); +function formatUrl(trace: string) { + let url = trace.startsWith('http') || trace.startsWith('blob') ? trace : `file?path=${encodeURIComponent(trace)}`; + // Dropbox does not support cors. + if (url.startsWith('https://www.dropbox.com/')) + url = 'https://dl.dropboxusercontent.com/' + url.substring('https://www.dropbox.com/'.length); + return url; } diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 37b6a6bcf6..69a5988641 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -50,7 +50,6 @@ const searchParams = new URLSearchParams(window.location.search); const guid = searchParams.get('ws'); const wsURL = new URL(`../${guid}`, window.location.toString()); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); -wsURL.port = searchParams.get('testServerPort') ?? window.location.port; const queryParams = { args: searchParams.getAll('arg'), grep: searchParams.get('grep') || undefined, @@ -69,7 +68,6 @@ const isMac = navigator.platform === 'MacIntel'; export const UIModeView: React.FC<{}> = ({ }) => { - const isJokesDay = new Date().getMonth() === 3 && new Date().getDate() === 1; const [filterText, setFilterText] = React.useState(''); const [isShowingOutput, setIsShowingOutput] = React.useState(false); const [outputContainsError, setOutputContainsError] = React.useState(false); @@ -442,7 +440,7 @@ export const UIModeView: React.FC<{}> = ({ sidebar={
Playwright logo -
{isJokesDay ? 'Claywright' : 'Playwright'}
+
Playwright
reloadTests()} disabled={isRunningTest || isLoading}>
{ setIsShowingOutput(!isShowingOutput); }} /> @@ -518,11 +516,10 @@ export const UIModeView: React.FC<{}> = ({ style={{ marginLeft: 5 }} title={settingsVisible ? 'Hide Settings' : 'Show Settings'} /> -
{isJokesDay ? 'Schmettings' : 'Settings'}
+
Settings
{settingsVisible && }
} diff --git a/packages/trace-viewer/src/uiMode.tsx b/packages/trace-viewer/src/uiMode.tsx index 4702621e5d..5dac2082e8 100644 --- a/packages/trace-viewer/src/uiMode.tsx +++ b/packages/trace-viewer/src/uiMode.tsx @@ -27,7 +27,7 @@ import { UIModeView } from './ui/uiModeView'; await new Promise(f => setTimeout(f, 1000)); if (!navigator.serviceWorker) throw new Error(`Service workers are not supported.\nMake sure to serve the website (${window.location}) via HTTPS or localhost.`); - navigator.serviceWorker.register('sw.bundle.js' + window.location.search); + navigator.serviceWorker.register('sw.bundle.js'); if (!navigator.serviceWorker.controller) { await new Promise(f => { navigator.serviceWorker.oncontrollerchange = () => f(); diff --git a/packages/trace-viewer/vite.config.ts b/packages/trace-viewer/vite.config.ts index 541b1ab6b6..0e2e9cb642 100644 --- a/packages/trace-viewer/vite.config.ts +++ b/packages/trace-viewer/vite.config.ts @@ -26,9 +26,6 @@ export default defineConfig({ react(), bundle() ], - define: { - 'process.env': {}, - }, resolve: { alias: { '@injected': path.resolve(__dirname, '../playwright-core/src/server/injected'), diff --git a/packages/trace-viewer/vite.sw.config.ts b/packages/trace-viewer/vite.sw.config.ts index a0dd99036a..dc621448b9 100644 --- a/packages/trace-viewer/vite.sw.config.ts +++ b/packages/trace-viewer/vite.sw.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ }, }, build: { - outDir: path.resolve(__dirname, 'public'), + outDir: path.resolve(__dirname, '../playwright-core/lib/vite/traceViewer'), // Output dir is shared with vite.config.ts, clearing it here is racy. emptyOutDir: false, rollupOptions: { diff --git a/tests/playwright-test/ui-mode-test-run.spec.ts b/tests/playwright-test/ui-mode-test-run.spec.ts index b30f901654..72eeda4655 100644 --- a/tests/playwright-test/ui-mode-test-run.spec.ts +++ b/tests/playwright-test/ui-mode-test-run.spec.ts @@ -778,14 +778,3 @@ test('should respect --ignore-snapshots option', { - treeitem ${/\[icon-check\] snapshot/} `); }); - -test('should show funny messages', async ({ runUITest }) => { - const { page } = await runUITest(basicTestTree); - await page.clock.setFixedTime('2025-04-01'); - - await expect(page.getByText('Claywright')).toBeVisible(); - const schmettingsHeader = page.getByText('Schmettings'); - await expect(schmettingsHeader).toBeVisible(); - await schmettingsHeader.click(); - await expect(page.getByRole('checkbox', { name: 'Fart mode' })).toBeVisible(); -}); diff --git a/utils/build/build.js b/utils/build/build.js index a1ba052d51..7a289bd5ed 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -275,21 +275,6 @@ for (const bundle of bundles) { }); } -// initial service worker build. -steps.push({ - command: 'npx', - args: [ - 'vite', - '--config', - 'vite.sw.config.ts', - 'build', - ...(withSourceMaps ? ['--sourcemap=inline'] : []), - ], - shell: true, - cwd: path.join(__dirname, '..', '..', 'packages', 'trace-viewer'), - concurrent: false, -}); - // Build/watch web packages. for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer']) { steps.push({ @@ -305,7 +290,6 @@ for (const webPackage of ['html-reporter', 'recorder', 'trace-viewer']) { concurrent: true, }); } - // Build/watch trace viewer service worker. steps.push({ command: 'npx', @@ -322,16 +306,6 @@ steps.push({ concurrent: true, }); -// web packages dev server -if (watchMode) { - steps.push({ - command: 'npx', - args: ['vite', '--port', '44223', '--base', '/trace/'], - shell: true, - cwd: path.join(__dirname, '..', '..', 'packages', 'trace-viewer'), - concurrent: true, - }); -} // Generate injected. onChanges.push({ From 7e00112fec03d4f7581c065d8796159488aa6fb5 Mon Sep 17 00:00:00 2001 From: DetachHead <57028336+DetachHead@users.noreply.github.com> Date: Fri, 25 Oct 2024 23:27:14 +1000 Subject: [PATCH 16/17] docs(python): improve the typing example with the pytest plugin (#33296) --- docs/src/test-runners-python.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/test-runners-python.md b/docs/src/test-runners-python.md index 0ad4043066..d07fa2e53f 100644 --- a/docs/src/test-runners-python.md +++ b/docs/src/test-runners-python.md @@ -99,7 +99,7 @@ See [Running Tests](./running-tests.md) for general information on `pytest` opti ## Examples -### Configure Mypy typings for auto-completion +### Configure typings for auto-completion ```py title="test_my_application.py" from playwright.sync_api import Page @@ -109,6 +109,8 @@ def test_visit_admin_dashboard(page: Page): # ... ``` +If you're using VSCode with Pylance, these types can be inferred by enabling the `python.testing.pytestEnabled` setting so you don't need the type annotation. + ### Configure slow mo Run tests with slow mo with the `--slowmo` argument. From f98531baee31163d1b64a5120c70a0707379c4ed Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 25 Oct 2024 10:33:43 -0700 Subject: [PATCH 17/17] chore: remove check for node < 16 (#33301) --- packages/playwright-core/src/utilsBundle.ts | 4 ---- packages/playwright/src/common/configLoader.ts | 5 ----- tests/config/browserTest.ts | 6 ++++++ tests/library/browsercontext-fetch.spec.ts | 9 ++++----- tests/library/browsercontext-storage-state.spec.ts | 4 ++-- tests/library/browsertype-launch.spec.ts | 5 ++--- tests/library/client-certificates.spec.ts | 4 ++-- 7 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/playwright-core/src/utilsBundle.ts b/packages/playwright-core/src/utilsBundle.ts index f9dc8347f7..f57a6346f7 100644 --- a/packages/playwright-core/src/utilsBundle.ts +++ b/packages/playwright-core/src/utilsBundle.ts @@ -43,12 +43,8 @@ import type { StackFrame } from '@protocol/channels'; const StackUtils: typeof import('../bundles/utils/node_modules/@types/stack-utils') = require('./utilsBundleImpl').StackUtils; const stackUtils = new StackUtils({ internals: StackUtils.nodeInternals() }); -const nodeInternals = StackUtils.nodeInternals(); -const nodeMajorVersion = +process.versions.node.split('.')[0]; export function parseStackTraceLine(line: string): StackFrame | null { - if (!process.env.PWDEBUGIMPL && nodeMajorVersion < 16 && nodeInternals.some(internal => internal.test(line))) - return null; const frame = stackUtils.parseLine(line); if (!frame) return null; diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 37a886d3e8..13d7eac187 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -378,11 +378,6 @@ export function restartWithExperimentalTsEsm(configFile: string | undefined, for // Now check for the newer API presence. if (!require('node:module').register) { - // Older API is experimental, only supported on Node 16+. - const nodeVersion = +process.versions.node.split('.')[0]; - if (nodeVersion < 16) - return false; - // With older API requiring a process restart, do so conditionally on the config. const configIsModule = !!configFile && fileIsModule(configFile); if (!force && !configIsModule) diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index 6a178bbaf2..8e79da1f8e 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -36,6 +36,7 @@ export type BrowserTestWorkerFixtures = PageWorkerFixtures & { browserType: BrowserType; isAndroid: boolean; isElectron: boolean; + nodeVersion: { major: number, minor: number, patch: number }; bidiTestSkipPredicate: (info: TestInfo) => boolean; }; @@ -96,6 +97,11 @@ const test = baseTest.extend await run(Number(browserVersion.split('.')[0])); }, { scope: 'worker' }], + nodeVersion: [async ({}, use) => { + const [major, minor, patch] = process.versions.node.split('.'); + await use({ major: +major, minor: +minor, patch: +patch }); + }, { scope: 'worker' }], + isAndroid: [false, { scope: 'worker' }], isElectron: [false, { scope: 'worker' }], electronMajorVersion: [0, { scope: 'worker' }], diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index ae5cf5e7d5..abbc4f64a4 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -880,9 +880,9 @@ it('should respect timeout after redirects', async function({ context, server }) expect(error.message).toContain(`Request timed out after 100ms`); }); -it('should not hang on a brotli encoded Range request', async ({ context, server }) => { +it('should not hang on a brotli encoded Range request', async ({ context, server, nodeVersion }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/18190' }); - it.skip(+process.versions.node.split('.')[0] < 18); + it.skip(nodeVersion.major < 18); const encodedRequestPayload = zlib.brotliCompressSync(Buffer.from('A')); server.setRoute('/brotli', (req, res) => { @@ -1094,10 +1094,9 @@ it('should support multipart/form-data and keep the order', async function({ con expect(response.status()).toBe(200); }); -it('should support repeating names in multipart/form-data', async function({ context, server }) { +it('should support repeating names in multipart/form-data', async function({ context, server, nodeVersion }) { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28070' }); - const nodeVersion = +process.versions.node.split('.')[0]; - it.skip(nodeVersion < 20, 'File is not available in Node.js < 20. FormData is not available in Node.js < 18'); + it.skip(nodeVersion.major < 20, 'File is not available in Node.js < 20. FormData is not available in Node.js < 18'); const postBodyPromise = new Promise(resolve => { server.setRoute('/empty.html', async (req, res) => { resolve((await req.postBody).toString('utf-8')); diff --git a/tests/library/browsercontext-storage-state.spec.ts b/tests/library/browsercontext-storage-state.spec.ts index 02db42506d..d71657d1be 100644 --- a/tests/library/browsercontext-storage-state.spec.ts +++ b/tests/library/browsercontext-storage-state.spec.ts @@ -204,13 +204,13 @@ it('should handle missing file', async ({ contextFactory }, testInfo) => { expect(error.message).toContain(`Error reading storage state from ${file}:\nENOENT`); }); -it('should handle malformed file', async ({ contextFactory }, testInfo) => { +it('should handle malformed file', async ({ contextFactory, nodeVersion }, testInfo) => { const file = testInfo.outputPath('state.json'); fs.writeFileSync(file, 'not-json', 'utf-8'); const error = await contextFactory({ storageState: file, }).catch(e => e); - if (+process.versions.node.split('.')[0] > 18) + if (nodeVersion.major > 18) expect(error.message).toContain(`Error reading storage state from ${file}:\nUnexpected token 'o', \"not-json\" is not valid JSON`); else expect(error.message).toContain(`Error reading storage state from ${file}:\nUnexpected token o in JSON at position 1`); diff --git a/tests/library/browsertype-launch.spec.ts b/tests/library/browsertype-launch.spec.ts index 011bc8a50d..75198f4f8b 100644 --- a/tests/library/browsertype-launch.spec.ts +++ b/tests/library/browsertype-launch.spec.ts @@ -130,9 +130,8 @@ it('should be callable twice', async ({ browserType }) => { await browser.close(); }); -it('should allow await using', async ({ browserType }) => { - const nodeVersion = +process.versions.node.split('.')[0]; - it.skip(nodeVersion < 18); +it('should allow await using', async ({ browserType, nodeVersion }) => { + it.skip(nodeVersion.major < 18); let b: Browser; let c: BrowserContext; diff --git a/tests/library/client-certificates.spec.ts b/tests/library/client-certificates.spec.ts index 8e0a4cdf47..648fef0d75 100644 --- a/tests/library/client-certificates.spec.ts +++ b/tests/library/client-certificates.spec.ts @@ -727,9 +727,9 @@ test.describe('browser', () => { await browser.close(); }); - test('should return target connection errors when using http2', async ({ browser, startCCServer, asset, browserName, isMac, isLinux }) => { + test('should return target connection errors when using http2', async ({ browser, startCCServer, asset, browserName, isMac, nodeVersion }) => { test.skip(browserName === 'webkit' && isMac, 'WebKit on macOS does not proxy localhost'); - test.skip(+process.versions.node.split('.')[0] < 20, 'http2.performServerHandshake is not supported in older Node.js versions'); + test.skip(nodeVersion.major < 20, 'http2.performServerHandshake is not supported in older Node.js versions'); const serverURL = await startCCServer({ http2: true }); const page = await browser.newPage({