=
- T extends new (...angs: any) => { $props: infer P; } ? NonNullable :
- T extends (props: infer P, ...args: any) => any ? P :
- {};
-
-export interface MountOptions {
- props?: ComponentProps;
- slots?: ComponentSlots;
- on?: ComponentEvents;
- hooksConfig?: HooksConfig;
-}
-
-export interface MountOptionsJsx {
- hooksConfig?: HooksConfig;
-}
-
-export interface MountResult extends Locator {
- unmount(): Promise;
- update(options: {
- props?: Partial>;
- slots?: Partial;
- on?: Partial;
- }): Promise;
-}
-
-export interface MountResultJsx extends Locator {
- unmount(): Promise;
- update(component: JSX.Element): Promise;
-}
-
-export const test: TestType<{
- mount(
- component: JSX.Element,
- options?: MountOptionsJsx
- ): Promise;
- mount(
- component: Component,
- options?: MountOptions
- ): Promise>;
-}>;
-
-export { defineConfig, PlaywrightTestConfig, expect, devices } from '@playwright/experimental-ct-core';
diff --git a/packages/playwright-ct-vue2/index.js b/packages/playwright-ct-vue2/index.js
deleted file mode 100644
index 2eeabb0d08..0000000000
--- a/packages/playwright-ct-vue2/index.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * 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.
- */
-
-const { test, expect, devices, defineConfig: originalDefineConfig } = require('@playwright/experimental-ct-core');
-const path = require('path');
-
-const defineConfig = (config, ...configs) => {
- return originalDefineConfig({
- ...config,
- '@playwright/test': {
- packageJSON: require.resolve('./package.json'),
- },
- '@playwright/experimental-ct-core': {
- registerSourceFile: path.join(__dirname, 'registerSource.mjs'),
- frameworkPluginFactory: () => import('@vitejs/plugin-vue2').then(plugin => plugin.default()),
- },
- }, ...configs);
-};
-
-module.exports = { test, expect, devices, defineConfig };
diff --git a/packages/playwright-ct-vue2/package.json b/packages/playwright-ct-vue2/package.json
deleted file mode 100644
index 9ec30191ad..0000000000
--- a/packages/playwright-ct-vue2/package.json
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "name": "@playwright/experimental-ct-vue2",
- "version": "1.49.0-next",
- "description": "Playwright Component Testing for Vue2",
- "repository": {
- "type": "git",
- "url": "git+https://github.com/microsoft/playwright.git"
- },
- "homepage": "https://playwright.dev",
- "engines": {
- "node": ">=18"
- },
- "author": {
- "name": "Microsoft Corporation"
- },
- "license": "Apache-2.0",
- "exports": {
- ".": {
- "types": "./index.d.ts",
- "default": "./index.js"
- },
- "./register": {
- "types": "./register.d.ts",
- "default": "./register.mjs"
- },
- "./hooks": {
- "types": "./hooks.d.ts",
- "default": "./hooks.mjs"
- },
- "./package.json": "./package.json"
- },
- "dependencies": {
- "@playwright/experimental-ct-core": "1.49.0-next",
- "@vitejs/plugin-vue2": "^2.2.0"
- },
- "devDependencies": {
- "vue": "^2.7.14"
- },
- "bin": {
- "playwright": "cli.js"
- }
-}
diff --git a/packages/playwright-ct-vue2/register.d.ts b/packages/playwright-ct-vue2/register.d.ts
deleted file mode 100644
index f88e9be59d..0000000000
--- a/packages/playwright-ct-vue2/register.d.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * 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.
- */
-
-export default function pwRegister(
- components: Record,
- options?: {
- createApp: any,
- setDevtoolsHook: any,
- h: any,
- }
-): void;
diff --git a/packages/playwright-ct-vue2/register.mjs b/packages/playwright-ct-vue2/register.mjs
deleted file mode 100644
index ca6a6a12d9..0000000000
--- a/packages/playwright-ct-vue2/register.mjs
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * 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 { pwRegister } from './registerSource.mjs';
-
-export default components => {
- pwRegister(components);
-};
diff --git a/packages/playwright-ct-vue2/registerSource.mjs b/packages/playwright-ct-vue2/registerSource.mjs
deleted file mode 100644
index 19b4d41c08..0000000000
--- a/packages/playwright-ct-vue2/registerSource.mjs
+++ /dev/null
@@ -1,212 +0,0 @@
-/**
- * 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.
- */
-
-// @ts-check
-
-// This file is injected into the registry as text, no dependencies are allowed.
-
-import __pwVue, { h as __pwH } from 'vue';
-
-/** @typedef {import('../playwright-ct-core/types/component').Component} Component */
-/** @typedef {import('../playwright-ct-core/types/component').JsxComponent} JsxComponent */
-/** @typedef {import('../playwright-ct-core/types/component').ObjectComponent} ObjectComponent */
-/** @typedef {import('vue').Component} FrameworkComponent */
-
-/**
- * @param {any} component
- * @returns {component is ObjectComponent}
- */
-function isObjectComponent(component) {
- return typeof component === 'object' && component && component.__pw_type === 'object-component';
-}
-
-/**
- * @param {any} component
- * @returns {component is JsxComponent}
- */
-function isJsxComponent(component) {
- return typeof component === 'object' && component && component.__pw_type === 'jsx';
-}
-
-/**
- * @param {any} child
- */
-function __pwCreateChild(child) {
- if (Array.isArray(child))
- return child.map(grandChild => __pwCreateChild(grandChild));
- if (isJsxComponent(child) || isObjectComponent(child))
- return __pwCreateWrapper(child);
- return child;
-}
-
-/**
- * Exists to support fallthrough attributes:
- * https://vuejs.org/guide/components/attrs.html#fallthrough-attributes
- * @param {any} Component
- * @param {string} key
- * @return {boolean}
- */
-function __pwComponentHasKeyInProps(Component, key) {
- return typeof Component.props === 'object' && Component.props && key in Component.props;
-}
-
-/**
- * @param {JsxComponent} component
- * @returns {any[] | undefined}
- */
-function __pwJsxChildArray(component) {
- if (!component.props.children)
- return;
- if (Array.isArray(component.props.children))
- return component.props.children;
- return [component.props.children];
-}
-
-/**
- * @param {Component} component
- */
-function __pwCreateComponent(component) {
- const isVueComponent = typeof component.type !== 'string';
-
- /**
- * @type {(import('vue').VNode | string)[]}
- */
- const children = [];
-
- /** @type {import('vue').VNodeData} */
- const nodeData = {};
- nodeData.attrs = {};
- nodeData.props = {};
- nodeData.scopedSlots = {};
- nodeData.on = {};
-
- if (component.__pw_type === 'jsx') {
- for (const child of __pwJsxChildArray(component) || []) {
- if (isJsxComponent(child) && child.type === 'template') {
- const slotProperty = Object.keys(child.props).find(k => k.startsWith('v-slot:'));
- const slot = slotProperty ? slotProperty.substring('v-slot:'.length) : 'default';
- nodeData.scopedSlots[slot] = () => __pwJsxChildArray(child)?.map(c => __pwCreateChild(c));
- } else {
- children.push(__pwCreateChild(child));
- }
- }
-
- for (const [key, value] of Object.entries(component.props)) {
- if (key.startsWith('v-on:')) {
- const event = key.substring('v-on:'.length);
- nodeData.on[event] = value;
- } else {
- if (isVueComponent && __pwComponentHasKeyInProps(component.type, key))
- nodeData.props[key] = value;
- else
- nodeData.attrs[key] = value;
- }
- }
- }
-
- if (component.__pw_type === 'object-component') {
- // Vue test util syntax.
- for (const [key, value] of Object.entries(component.slots || {})) {
- const list = (Array.isArray(value) ? value : [value]).map(v => __pwCreateChild(v));
- if (key === 'default')
- children.push(...list);
- else
- nodeData.scopedSlots[key] = () => list;
- }
- nodeData.props = component.props || {};
- for (const [key, value] of Object.entries(component.on || {}))
- nodeData.on[key] = value;
- }
-
- /** @type {(string|import('vue').VNode)[] | undefined} */
- let lastArg;
- if (Object.entries(nodeData.scopedSlots).length) {
- if (children.length)
- nodeData.scopedSlots.default = () => children;
- } else if (children.length) {
- lastArg = children;
- }
-
- return { Component: component.type, nodeData, slots: lastArg };
-}
-
-/**
- * @param {Component} component
- * @returns {import('vue').VNode}
- */
-function __pwCreateWrapper(component) {
- const { Component, nodeData, slots } = __pwCreateComponent(component);
- const wrapper = __pwH(Component, nodeData, slots);
- return wrapper;
-}
-
-const instanceKey = Symbol('instanceKey');
-const wrapperKey = Symbol('wrapperKey');
-
-window.playwrightMount = async (component, rootElement, hooksConfig) => {
- let options = {};
- for (const hook of window.__pw_hooks_before_mount || [])
- options = await hook({ hooksConfig, Vue: __pwVue });
-
- const instance = new __pwVue({
- ...options,
- render: () => {
- const wrapper = __pwCreateWrapper(component);
- /** @type {any} */ (rootElement)[wrapperKey] = wrapper;
- return wrapper;
- },
- }).$mount();
- rootElement.appendChild(instance.$el);
- /** @type {any} */ (rootElement)[instanceKey] = instance;
-
- for (const hook of window.__pw_hooks_after_mount || [])
- await hook({ hooksConfig, instance });
-};
-
-window.playwrightUnmount = async rootElement => {
- const component = rootElement[instanceKey];
- if (!component)
- throw new Error('Component was not mounted');
- component.$destroy();
- component.$el.remove();
- delete rootElement[instanceKey];
-};
-
-window.playwrightUpdate = async (element, options) => {
- const wrapper = /** @type {any} */(element)[wrapperKey];
- if (!wrapper)
- throw new Error('Component was not mounted');
-
- const component = wrapper.componentInstance;
- if (!component)
- throw new Error('Updating a native HTML element is not supported');
-
- const { nodeData, slots } = __pwCreateComponent(options);
-
- for (const [name, value] of Object.entries(nodeData.on || {})) {
- component.$on(name, value);
- component.$listeners[name] = value;
- }
-
- Object.assign(component.$scopedSlots, nodeData.scopedSlots);
- component.$slots.default = slots;
-
- for (const [key, value] of Object.entries(nodeData.props || {}))
- component[key] = value;
-
- if (!Object.keys(nodeData.props || {}).length)
- component.$forceUpdate();
-};
diff --git a/packages/playwright/ThirdPartyNotices.txt b/packages/playwright/ThirdPartyNotices.txt
index f2bb64d661..2931da55c9 100644
--- a/packages/playwright/ThirdPartyNotices.txt
+++ b/packages/playwright/ThirdPartyNotices.txt
@@ -112,6 +112,7 @@ This project incorporates components from the projects listed below. The origina
- escape-string-regexp@2.0.0 (https://github.com/sindresorhus/escape-string-regexp)
- fill-range@7.1.1 (https://github.com/jonschlinkert/fill-range)
- gensync@1.0.0-beta.2 (https://github.com/loganfsmyth/gensync)
+- get-east-asian-width@1.3.0 (https://github.com/sindresorhus/get-east-asian-width)
- glob-parent@5.1.2 (https://github.com/gulpjs/glob-parent)
- globals@11.12.0 (https://github.com/sindresorhus/globals)
- graceful-fs@4.2.11 (https://github.com/isaacs/node-graceful-fs)
@@ -3410,6 +3411,20 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
=========================================
END OF gensync@1.0.0-beta.2 AND INFORMATION
+%% get-east-asian-width@1.3.0 NOTICES AND INFORMATION BEGIN HERE
+=========================================
+MIT License
+
+Copyright (c) Sindre Sorhus (https://sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+=========================================
+END OF get-east-asian-width@1.3.0 AND INFORMATION
+
%% glob-parent@5.1.2 NOTICES AND INFORMATION BEGIN HERE
=========================================
The ISC License
@@ -4399,6 +4414,6 @@ END OF yallist@3.1.1 AND INFORMATION
SUMMARY BEGIN HERE
=========================================
-Total Packages: 151
+Total Packages: 152
=========================================
END OF SUMMARY
\ No newline at end of file
diff --git a/packages/playwright/bundles/babel/src/babelBundleImpl.ts b/packages/playwright/bundles/babel/src/babelBundleImpl.ts
index 5e09be2a0c..78c3c0403e 100644
--- a/packages/playwright/bundles/babel/src/babelBundleImpl.ts
+++ b/packages/playwright/bundles/babel/src/babelBundleImpl.ts
@@ -23,7 +23,6 @@ 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;
@@ -114,16 +113,25 @@ function babelTransformOptions(isTypeScript: boolean, isModule: boolean, plugins
let isTransforming = false;
-export function babelTransform(code: string, filename: string, isTypeScript: boolean, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult {
+function isTypeScript(filename: string) {
+ return filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts');
+}
+
+export function babelTransform(code: string, filename: string, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult {
if (isTransforming)
return {};
// Prevent reentry while requiring plugins lazily.
isTransforming = true;
try {
- const options = babelTransformOptions(isTypeScript, isModule, pluginsPrologue, pluginsEpilogue);
+ const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue);
return babel.transform(code, { filename, ...options })!;
} finally {
isTransforming = false;
}
}
+
+export function babelParse(code: string, filename: string, isModule: boolean): babel.ParseResult {
+ const options = babelTransformOptions(isTypeScript(filename), isModule, [], []);
+ return babel.parse(code, { filename, ...options })!;
+}
diff --git a/packages/playwright/bundles/utils/build.js b/packages/playwright/bundles/utils/build.js
index 8c6bd89cf2..6fca8d3e0a 100644
--- a/packages/playwright/bundles/utils/build.js
+++ b/packages/playwright/bundles/utils/build.js
@@ -16,35 +16,14 @@
// @ts-check
const path = require('path');
-const fs = require('fs');
const esbuild = require('esbuild');
-// Can be removed once source-map-support was is fixed.
-/** @type{import('esbuild').Plugin} */
-let patchSource = {
- name: 'patch-source-map-support-deprecation',
- setup(build) {
- build.onResolve({ filter: /^source-map-support$/ }, () => {
- const originalPath = require.resolve('source-map-support');
- const patchedPath = path.join(path.dirname(originalPath), path.basename(originalPath, '.js') + '.pw-patched.js');
- let sourceFileContent = fs.readFileSync(originalPath, 'utf8');
- // source-map-support is overwriting __PW_ZONE__ with func in core if source maps are present.
- const original = `return state.nextPosition.name || originalFunctionName();`;
- const insertedLine = `if (state.nextPosition.name === 'func') return originalFunctionName() || 'func';`;
- sourceFileContent = sourceFileContent.replace(original, insertedLine + original);
- fs.writeFileSync(patchedPath, sourceFileContent);
- return { path: patchedPath }
- });
- },
-};
-
(async () => {
const ctx = await esbuild.context({
entryPoints: [path.join(__dirname, 'src/utilsBundleImpl.ts')],
external: ['fsevents'],
bundle: true,
outdir: path.join(__dirname, '../../lib'),
- plugins: [patchSource],
format: 'cjs',
platform: 'node',
target: 'ES2019',
diff --git a/packages/playwright/bundles/utils/package-lock.json b/packages/playwright/bundles/utils/package-lock.json
index fcf9f972fe..5fd1392d33 100644
--- a/packages/playwright/bundles/utils/package-lock.json
+++ b/packages/playwright/bundles/utils/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"chokidar": "3.6.0",
"enquirer": "2.3.6",
+ "get-east-asian-width": "1.3.0",
"json5": "2.2.3",
"pirates": "4.0.4",
"source-map-support": "0.5.21",
@@ -146,6 +147,18 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/get-east-asian-width": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
+ "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -376,6 +389,11 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
+ "get-east-asian-width": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
+ "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="
+ },
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
diff --git a/packages/playwright/bundles/utils/package.json b/packages/playwright/bundles/utils/package.json
index 69477909c5..3fb09c1d17 100644
--- a/packages/playwright/bundles/utils/package.json
+++ b/packages/playwright/bundles/utils/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"chokidar": "3.6.0",
"enquirer": "2.3.6",
+ "get-east-asian-width": "1.3.0",
"json5": "2.2.3",
"pirates": "4.0.4",
"source-map-support": "0.5.21",
diff --git a/packages/playwright/bundles/utils/src/utilsBundleImpl.ts b/packages/playwright/bundles/utils/src/utilsBundleImpl.ts
index 7c29c301a8..6cd35e2885 100644
--- a/packages/playwright/bundles/utils/src/utilsBundleImpl.ts
+++ b/packages/playwright/bundles/utils/src/utilsBundleImpl.ts
@@ -31,3 +31,6 @@ export const enquirer = enquirerLibrary;
import chokidarLibrary from 'chokidar';
export const chokidar = chokidarLibrary;
+
+import * as getEastAsianWidthLibrary from 'get-east-asian-width';
+export const getEastAsianWidth = getEastAsianWidthLibrary;
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/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..dc4264d56b 100644
--- a/packages/playwright/src/matchers/matcherHint.ts
+++ b/packages/playwright/src/matchers/matcherHint.ts
@@ -33,16 +33,13 @@ export function matcherHint(state: ExpectMatcherState, locator: Locator | undefi
export type MatcherResult = {
name: string;
- expected: E;
+ expected?: E;
message: () => string;
pass: boolean;
actual?: A;
log?: string[];
timeout?: number;
- locator?: string;
- printedReceived?: string;
- printedExpected?: string;
- printedDiff?: string;
+ suggestedRebaseline?: string;
};
export type MatcherResultProperty = Omit, 'message'> & {
@@ -69,6 +66,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/matchers.ts b/packages/playwright/src/matchers/matchers.ts
index c0319e371f..f7c68b4544 100644
--- a/packages/playwright/src/matchers/matchers.ts
+++ b/packages/playwright/src/matchers/matchers.ts
@@ -15,7 +15,7 @@
*/
import type { Locator, Page, APIResponse } from 'playwright-core';
-import type { FrameExpectOptions } from 'playwright-core/lib/client/types';
+import type { FrameExpectParams } from 'playwright-core/lib/client/types';
import { colors } from 'playwright-core/lib/utilsBundle';
import { expectTypes, callLogText } from '../util';
import { toBeTruthy } from './toBeTruthy';
@@ -28,7 +28,7 @@ import type { ExpectMatcherState } from '../../types/test';
import { takeFirst } from '../common/config';
export interface LocatorEx extends Locator {
- _expect(expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
+ _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
}
interface APIResponseEx extends APIResponse {
diff --git a/packages/playwright/src/matchers/toBeTruthy.ts b/packages/playwright/src/matchers/toBeTruthy.ts
index 8902a14eea..abe00a8852 100644
--- a/packages/playwright/src/matchers/toBeTruthy.ts
+++ b/packages/playwright/src/matchers/toBeTruthy.ts
@@ -73,7 +73,5 @@ export async function toBeTruthy(
expected,
log,
timeout: timedOut ? timeout : undefined,
- ...(printedReceived ? { printedReceived } : {}),
- ...(printedExpected ? { printedExpected } : {}),
};
}
diff --git a/packages/playwright/src/matchers/toEqual.ts b/packages/playwright/src/matchers/toEqual.ts
index f75caf87f5..bb0f4e147e 100644
--- a/packages/playwright/src/matchers/toEqual.ts
+++ b/packages/playwright/src/matchers/toEqual.ts
@@ -83,8 +83,5 @@ export async function toEqual(
pass,
log,
timeout: timedOut ? timeout : undefined,
- ...(printedReceived ? { printedReceived } : {}),
- ...(printedExpected ? { printedExpected } : {}),
- ...(printedDiff ? { printedDiff } : {}),
};
}
diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts
index 5b2c204410..a475ec39d8 100644
--- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts
+++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts
@@ -22,6 +22,9 @@ 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';
+import type { MatcherReceived } from '@injected/ariaSnapshot';
+import { escapeTemplateString } from 'playwright-core/lib/utils';
export async function toMatchAriaSnapshot(
this: ExpectMatcherState,
@@ -31,6 +34,15 @@ export async function toMatchAriaSnapshot(
): Promise> {
const matcherName = 'toMatchAriaSnapshot';
+ const testInfo = currentTestInfo();
+ if (!testInfo)
+ throw new Error(`toMatchAriaSnapshot() must be called during the test`);
+
+ if (testInfo._projectInternal.ignoreSnapshots)
+ return { pass: !this.isNot, message: () => '', name: 'toMatchAriaSnapshot', expected };
+
+ const updateSnapshots = testInfo.config.updateSnapshots;
+
const matcherOptions = {
isNot: this.isNot,
promise: this.promise,
@@ -44,27 +56,57 @@ export async function toMatchAriaSnapshot(
].join('\n\n'));
}
+ const generateMissingBaseline = updateSnapshots === 'missing' && !expected;
+ const generateNewBaseline = updateSnapshots === 'all' || generateMissingBaseline;
+
+ if (generateMissingBaseline) {
+ if (this.isNot) {
+ const message = `Matchers using ".not" can't generate new baselines`;
+ return { pass: this.isNot, message: () => message, name: 'toMatchAriaSnapshot' };
+ } else {
+ // When generating new baseline, run entire pipeline against impossible match.
+ expected = `- none "Generating new baseline"`;
+ }
+ }
+
const timeout = options.timeout ?? this.timeout;
+ expected = unshift(expected);
const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: expected, isNot: this.isNot, timeout });
+ const typedReceived = received as MatcherReceived | typeof kNoElementsFoundError;
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
- const notFound = received === kNoElementsFoundError;
- const escapedExpected = unshift(escapePrivateUsePoints(expected));
- const escapedReceived = unshift(escapePrivateUsePoints(received));
+ const notFound = typedReceived === kNoElementsFoundError;
+ if (notFound) {
+ return {
+ pass: this.isNot,
+ message: () => messagePrefix + `Expected: ${this.utils.printExpected(expected)}\nReceived: ${EXPECTED_COLOR('not found')}` + callLogText(log),
+ name: 'toMatchAriaSnapshot',
+ expected,
+ };
+ }
+
+ const escapedExpected = escapePrivateUsePoints(expected);
+ const escapedReceived = escapePrivateUsePoints(typedReceived.raw);
const message = () => {
if (pass) {
if (notFound)
return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
const printedReceived = printReceivedStringContainExpectedSubstring(escapedReceived, escapedReceived.indexOf(escapedExpected), escapedExpected.length);
- return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived string: ${printedReceived}` + callLogText(log);
+ return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${printedReceived}` + callLogText(log);
} else {
const labelExpected = `Expected`;
if (notFound)
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
- return messagePrefix + this.utils.printDiffOrStringify(escapedExpected, escapedReceived, labelExpected, 'Received string', false) + callLogText(log);
+ return messagePrefix + this.utils.printDiffOrStringify(escapedExpected, escapedReceived, labelExpected, 'Received', false) + callLogText(log);
}
};
+ if (!this.isNot && pass === this.isNot && generateNewBaseline) {
+ // Only rebaseline failed snapshots.
+ const suggestedRebaseline = `toMatchAriaSnapshot(\`\n${escapeTemplateString(indent(typedReceived.regex, '{indent} '))}\n{indent}\`)`;
+ return { pass: this.isNot, message: () => '', name: 'toMatchAriaSnapshot', suggestedRebaseline };
+ }
+
return {
name: matcherName,
expected,
@@ -77,7 +119,7 @@ export async function toMatchAriaSnapshot(
}
function escapePrivateUsePoints(str: string) {
- return str.replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
+ return escapeTemplateString(str).replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
}
function unshift(snapshot: string): string {
@@ -93,3 +135,7 @@ function unshift(snapshot: string): string {
}
return lines.filter(t => t.trim()).map(line => line.substring(whitespacePrefixLength)).join('\n');
}
+
+function indent(snapshot: string, indent: string): string {
+ return snapshot.split('\n').map(line => indent + line).join('\n');
+}
diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts
index 86504062d3..374fba3db5 100644
--- a/packages/playwright/src/matchers/toMatchSnapshot.ts
+++ b/packages/playwright/src/matchers/toMatchSnapshot.ts
@@ -18,7 +18,7 @@ import type { Locator, Page } from 'playwright-core';
import type { ExpectScreenshotOptions, Page as PageEx } from 'playwright-core/lib/client/page';
import { currentTestInfo } from '../common/globals';
import type { ImageComparatorOptions, Comparator } from 'playwright-core/lib/utils';
-import { getComparator, sanitizeForFilePath } from 'playwright-core/lib/utils';
+import { getComparator, isString, sanitizeForFilePath } from 'playwright-core/lib/utils';
import {
addSuffixToFilePath,
trimLongString, callLogText,
@@ -31,7 +31,7 @@ import path from 'path';
import { mime } from 'playwright-core/lib/utilsBundle';
import type { TestInfoImpl } from '../worker/testInfo';
import type { ExpectMatcherState } from '../../types/test';
-import type { MatcherResult } from './matcherHint';
+import { matcherHint, type MatcherResult } from './matcherHint';
import type { FullProjectInternal } from '../common/config';
type NameOrSegments = string | string[];
@@ -194,10 +194,6 @@ class SnapshotHelper {
pass,
message: () => message,
log,
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- ...(this.locator ? { locator: this.locator.toString() } : {}),
- printedExpected: this.expectedPath,
- printedReceived: this.actualPath,
};
return Object.fromEntries(Object.entries(unfiltered).filter(([_, v]) => v !== undefined)) as ImageMatcherResult;
}
@@ -250,16 +246,10 @@ class SnapshotHelper {
expected: Buffer | string | undefined,
previous: Buffer | string | undefined,
diff: Buffer | string | undefined,
- diffError: string | undefined,
- log: string[] | undefined,
- title = `${this.kind} comparison failed:`): ImageMatcherResult {
- const output = [
- colors.red(title),
- '',
- ];
- if (diffError)
- output.push(indent(diffError, ' '));
-
+ header: string,
+ diffError: string,
+ log: string[] | undefined): ImageMatcherResult {
+ const output = [`${header}${indent(diffError, ' ')}`];
if (expected !== undefined) {
// Copy the expectation inside the `test-results/` folder for backwards compatibility,
// so that one can upload `test-results/` directory and have all the data inside.
@@ -338,7 +328,9 @@ export function toMatchSnapshot(
return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true);
}
- return helper.handleDifferent(received, expected, undefined, result.diff, result.errorMessage, undefined);
+ const receiver = isString(received) ? 'string' : 'Buffer';
+ const header = matcherHint(this, undefined, 'toMatchSnapshot', receiver, undefined, undefined);
+ return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined);
}
export function toHaveScreenshotStepTitle(
@@ -374,6 +366,7 @@ export async function toHaveScreenshot(
throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have '.png' extension`);
expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot');
const style = await loadScreenshotStyles(helper.options.stylePath);
+ const timeout = helper.options.timeout ?? this.timeout;
const expectScreenshotOptions: ExpectScreenshotOptions = {
locator,
animations: helper.options.animations ?? 'disabled',
@@ -386,7 +379,7 @@ export async function toHaveScreenshot(
scale: helper.options.scale ?? 'css',
style,
isNot: !!this.isNot,
- timeout: helper.options.timeout ?? this.timeout,
+ timeout,
comparator: helper.options.comparator,
maxDiffPixels: helper.options.maxDiffPixels,
maxDiffPixelRatio: helper.options.maxDiffPixelRatio,
@@ -410,13 +403,16 @@ export async function toHaveScreenshot(
if (helper.updateSnapshots === 'none' && !hasSnapshot)
return helper.createMatcherResult(`A snapshot doesn't exist at ${helper.expectedPath}.`, false);
+ const receiver = locator ? 'locator' : 'page';
if (!hasSnapshot) {
// Regenerate a new screenshot by waiting until two screenshots are the same.
- const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions);
+ const { actual, previous, diff, errorMessage, log, timedOut } = await page._expectScreenshot(expectScreenshotOptions);
// We tried re-generating new snapshot but failed.
// This can be due to e.g. spinning animation, so we want to show it as a diff.
- if (errorMessage)
- return helper.handleDifferent(actual, undefined, previous, diff, undefined, log, errorMessage);
+ if (errorMessage) {
+ const header = matcherHint(this, locator, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined);
+ return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log);
+ }
// We successfully generated new screenshot.
return helper.handleMissing(actual!);
@@ -427,7 +423,7 @@ export async function toHaveScreenshot(
// - regular matcher (i.e. not a `.not`)
// - perhaps an 'all' flag to update non-matching screenshots
expectScreenshotOptions.expected = await fs.promises.readFile(helper.expectedPath);
- const { actual, previous, diff, errorMessage, log } = await page._expectScreenshot(expectScreenshotOptions);
+ const { actual, previous, diff, errorMessage, log, timedOut } = await page._expectScreenshot(expectScreenshotOptions);
if (!errorMessage)
return helper.handleMatching();
@@ -440,7 +436,8 @@ export async function toHaveScreenshot(
return helper.createMatcherResult(helper.expectedPath + ' running with --update-snapshots, writing actual.', true);
}
- return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, errorMessage, log);
+ const header = matcherHint(this, undefined, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined);
+ return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log);
}
function writeFileSync(aPath: string, content: Buffer | string) {
diff --git a/packages/playwright/src/matchers/toMatchText.ts b/packages/playwright/src/matchers/toMatchText.ts
index 76ed48af1e..2f8bc34b21 100644
--- a/packages/playwright/src/matchers/toMatchText.ts
+++ b/packages/playwright/src/matchers/toMatchText.ts
@@ -118,10 +118,5 @@ export async function toMatchText(
actual: received,
log,
timeout: timedOut ? timeout : undefined,
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- locator: receiver.toString(),
- ...(printedReceived ? { printedReceived } : {}),
- ...(printedExpected ? { printedExpected } : {}),
- ...(printedDiff ? { printedDiff } : {}),
};
}
diff --git a/packages/playwright/src/reporters/base.ts b/packages/playwright/src/reporters/base.ts
index 66f78bfdc9..3e452d6c44 100644
--- a/packages/playwright/src/reporters/base.ts
+++ b/packages/playwright/src/reporters/base.ts
@@ -18,6 +18,7 @@ import { colors as realColors, ms as milliseconds, parseStackTraceLine } from 'p
import path from 'path';
import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter';
import { getPackageManagerExecCommand } from 'playwright-core/lib/utils';
+import { getEastAsianWidth } from '../utilsBundle';
import type { ReporterV2 } from './reporterV2';
import { resolveReporterOutputPath } from '../util';
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
@@ -26,13 +27,6 @@ export const kOutputSymbol = Symbol('output');
type ErrorDetails = {
message: string;
location?: Location;
- timeout?: number;
- matcherName?: string;
- locator?: string;
- expected?: string;
- received?: string;
- log?: string[];
- snippet?: string;
};
type TestSummary = {
@@ -362,13 +356,6 @@ export function formatResultFailure(test: TestCase, result: TestResult, initialI
errorDetails.push({
message: indent(formattedError.message, initialIndent),
location: formattedError.location,
- timeout: error.timeout,
- matcherName: error.matcherName,
- locator: error.locator,
- expected: error.expected,
- received: error.received,
- log: error.log,
- snippet: error.snippet,
});
}
return errorDetails;
@@ -448,15 +435,16 @@ export function formatError(error: TestError, highlightCode: boolean): ErrorDeta
tokens.push(snippet);
}
- if (parsedStack && parsedStack.stackLines.length) {
- tokens.push('');
+ if (parsedStack && parsedStack.stackLines.length)
tokens.push(colors.dim(parsedStack.stackLines.join('\n')));
- }
let location = error.location;
if (parsedStack && !location)
location = parsedStack.location;
+ if (error.cause)
+ tokens.push(colors.dim('[cause]: ') + formatError(error.cause, highlightCode).message);
+
return {
location,
message: tokens.join('\n'),
@@ -503,11 +491,35 @@ export function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, '');
}
+function characterWidth(c: string) {
+ return getEastAsianWidth.eastAsianWidth(c.codePointAt(0)!);
+}
+
+function stringWidth(v: string) {
+ let width = 0;
+ for (const { segment } of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(v))
+ width += characterWidth(segment);
+ return width;
+}
+
+function suffixOfWidth(v: string, width: number) {
+ const segments = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(v)];
+ let suffixBegin = v.length;
+ for (const { segment, index } of segments.reverse()) {
+ const segmentWidth = stringWidth(segment);
+ if (segmentWidth > width)
+ break;
+ width -= segmentWidth;
+ suffixBegin = index;
+ }
+ return v.substring(suffixBegin);
+}
+
// Leaves enough space for the "prefix" to also fit.
-function fitToWidth(line: string, width: number, prefix?: string): string {
+export function fitToWidth(line: string, width: number, prefix?: string): string {
const prefixLength = prefix ? stripAnsiEscapes(prefix).length : 0;
width -= prefixLength;
- if (line.length <= width)
+ if (stringWidth(line) <= width)
return line;
// Even items are plain text, odd items are control sequences.
@@ -518,13 +530,14 @@ function fitToWidth(line: string, width: number, prefix?: string): string {
// Include all control sequences to preserve formatting.
taken.push(parts[i]);
} else {
- let part = parts[i].substring(parts[i].length - width);
- if (part.length < parts[i].length && part.length > 0) {
+ let part = suffixOfWidth(parts[i], width);
+ const wasTruncated = part.length < parts[i].length;
+ if (wasTruncated && parts[i].length > 0) {
// Add ellipsis if we are truncating.
- part = '\u2026' + part.substring(1);
+ part = '\u2026' + suffixOfWidth(parts[i], width - 1);
}
taken.push(part);
- width -= part.length;
+ width -= stringWidth(part);
}
}
return taken.reverse().join('');
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/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts
index e2c6d0c530..1315eea6e4 100644
--- a/packages/playwright/src/runner/loadUtils.ts
+++ b/packages/playwright/src/runner/loadUtils.ts
@@ -30,7 +30,7 @@ import { applyRepeatEachIndex, bindFileSuiteToProject, filterByFocusedLine, filt
import { createTestGroups, filterForShard, type TestGroup } from './testGroups';
import { dependenciesForTestFile } from '../transform/compilationCache';
import { sourceMapSupport } from '../utilsBundle';
-import type { RawSourceMap } from 'source-map';
+import type { RawSourceMap } from '../utilsBundle';
export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean) {
diff --git a/packages/playwright/src/runner/rebase.ts b/packages/playwright/src/runner/rebase.ts
new file mode 100644
index 0000000000..7558ea0800
--- /dev/null
+++ b/packages/playwright/src/runner/rebase.ts
@@ -0,0 +1,107 @@
+/**
+ * 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, babelParse } from '../transform/babelBundle';
+import { MultiMap } from 'playwright-core/lib/utils';
+import { generateUnifiedDiff } from 'playwright-core/lib/utils';
+import { colors } from 'playwright-core/lib/utilsBundle';
+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;
+ if (!suggestedRebaselines.size)
+ return;
+ const [project] = filterProjects(config.projects, config.cliProjectFilter);
+ if (!project)
+ return;
+
+ const patches: string[] = [];
+ const files: string[] = [];
+
+ for (const fileName of [...suggestedRebaselines.keys()].sort()) {
+ const source = await fs.promises.readFile(fileName, 'utf8');
+ const lines = source.split('\n');
+ const replacements = suggestedRebaselines.get(fileName);
+ const fileNode = babelParse(source, fileName, true);
+ 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);
+ files.push(relativeName);
+ patches.push(generateUnifiedDiff(source, result, relativeName.replace(/\\/g, '/')));
+ }
+
+ const patchFile = path.join(project.project.outputDir, 'rebaselines.patch');
+ await fs.promises.mkdir(path.dirname(patchFile), { recursive: true });
+ await fs.promises.writeFile(patchFile, patches.join('\n'));
+
+ const fileList = files.map(file => ' ' + colors.dim(file)).join('\n');
+ // eslint-disable-next-line no-console
+ console.log(`New baselines created for:\n\n${fileList}\n\n ` + colors.cyan('git apply ' + path.relative(process.cwd(), patchFile)) + '\n');
+}
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/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts
index 2806a05aec..d2f8b5919a 100644
--- a/packages/playwright/src/transform/babelBundle.ts
+++ b/packages/playwright/src/transform/babelBundle.ts
@@ -14,14 +14,15 @@
* limitations under the License.
*/
-import type { BabelFileResult } from '../../bundles/babel/node_modules/@types/babel__core';
+import type { BabelFileResult, ParseResult } from '../../bundles/babel/node_modules/@types/babel__core';
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;
+export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult;
export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform;
+export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult;
+export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse;
export type { NodePath, types as T, PluginObj } from '../../bundles/babel/node_modules/@types/babel__core';
export type { BabelAPI } from '../../bundles/babel/node_modules/@types/babel__helper-plugin-utils';
diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts
index f70f385b5b..549d83a168 100644
--- a/packages/playwright/src/transform/transform.ts
+++ b/packages/playwright/src/transform/transform.ts
@@ -215,7 +215,6 @@ export function setTransformData(pluginName: string, value: any) {
}
export function transformHook(originalCode: string, filename: string, moduleUrl?: string): { code: string, serializedCache?: any } {
- const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts');
const hasPreprocessor =
process.env.PW_TEST_SOURCE_TRANSFORM &&
process.env.PW_TEST_SOURCE_TRANSFORM_SCOPE &&
@@ -233,7 +232,7 @@ export function transformHook(originalCode: string, filename: string, moduleUrl?
const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle');
transformData = new Map();
- const { code, map } = babelTransform(originalCode, filename, isTypeScript, !!moduleUrl, pluginsPrologue, pluginsEpilogue);
+ const { code, map } = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue);
if (!code)
return { code: '', serializedCache };
const added = addToCache!(code, map, transformData);
diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts
index 4046809433..f7f91d3198 100644
--- a/packages/playwright/src/util.ts
+++ b/packages/playwright/src/util.ts
@@ -29,15 +29,17 @@ 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'));
-export function filterStackTrace(e: Error): { message: string, stack: string } {
+export function filterStackTrace(e: Error): { message: string, stack: string, cause?: ReturnType } {
const name = e.name ? e.name + ': ' : '';
+ const cause = e.cause instanceof Error ? filterStackTrace(e.cause) : undefined;
if (process.env.PWDEBUGIMPL)
- return { message: name + e.message, stack: e.stack || '' };
+ return { message: name + e.message, stack: e.stack || '', cause };
const stackLines = stringifyStackFrames(filteredStackTrace(e.stack?.split('\n') || []));
return {
message: name + e.message,
- stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}`
+ stack: `${name}${e.message}${stackLines.map(line => '\n' + line).join('')}`,
+ cause,
};
}
diff --git a/packages/playwright/src/utilsBundle.ts b/packages/playwright/src/utilsBundle.ts
index 072e16bb03..d0e2a37e77 100644
--- a/packages/playwright/src/utilsBundle.ts
+++ b/packages/playwright/src/utilsBundle.ts
@@ -20,3 +20,5 @@ export const sourceMapSupport: typeof import('../bundles/utils/node_modules/@typ
export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable;
export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer;
export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar;
+export const getEastAsianWidth: typeof import('../bundles/utils/node_modules/get-east-asian-width') = require('./utilsBundleImpl').getEastAsianWidth;
+export type { RawSourceMap } from '../bundles/utils/node_modules/source-map';
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/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts
index 5e7a3d80db..eb0ce9d807 100644
--- a/packages/playwright/src/worker/testTracing.ts
+++ b/packages/playwright/src/worker/testTracing.ts
@@ -224,11 +224,18 @@ export class TestTracing {
const stack = rawStack ? filteredStackTrace(rawStack) : [];
this._appendTraceEvent({
type: 'error',
- message: error.message || String(error.value),
+ message: this._formatError(error),
stack,
});
}
+ _formatError(error: TestInfoErrorImpl) {
+ const parts: string[] = [error.message || String(error.value)];
+ if (error.cause)
+ parts.push('[cause]: ' + this._formatError(error.cause));
+ return parts.join('\n');
+ }
+
appendStdioToTrace(type: 'stdout' | 'stderr', chunk: string | Buffer) {
this._appendTraceEvent({
type,
diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts
index 5db30f72e2..706d567dcb 100644
--- a/packages/playwright/types/test.d.ts
+++ b/packages/playwright/types/test.d.ts
@@ -9152,6 +9152,13 @@ export interface TestInfo {
* Information about an error thrown during test execution.
*/
export interface TestInfoError {
+ /**
+ * Error cause. Set when there is a
+ * [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the
+ * error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error].
+ */
+ cause?: TestInfoError;
+
/**
* Error message. Set when [Error] (or its subclass) has been thrown.
*/
diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts
index 5663f92f98..04cf03287f 100644
--- a/packages/playwright/types/testReporter.d.ts
+++ b/packages/playwright/types/testReporter.d.ts
@@ -555,40 +555,22 @@ export interface TestCase {
*/
export interface TestError {
/**
- * Expected value formatted as a human-readable string.
+ * Error cause. Set when there is a
+ * [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) for the
+ * error. Will be `undefined` if there is no cause or if the cause is not an instance of [Error].
*/
- expected?: string;
+ cause?: TestError;
/**
* Error location in the source code.
*/
location?: Location;
- /**
- * Receiver's locator.
- */
- locator?: string;
-
- /**
- * Call log.
- */
- log?: Array;
-
- /**
- * Expect matcher name.
- */
- matcherName?: string;
-
/**
* Error message. Set when [Error] (or its subclass) has been thrown.
*/
message?: string;
- /**
- * Received value formatted as a human-readable string.
- */
- received?: string;
-
/**
* Source code snippet with highlighted error.
*/
@@ -599,11 +581,6 @@ export interface TestError {
*/
stack?: string;
- /**
- * Timeout in milliseconds, if the error was caused by a timeout.
- */
- timeout?: number;
-
/**
* The value that was thrown. Set when anything except the [Error] (or its subclass) has been thrown.
*/
diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts
index 5ecc2f4077..5b37b33bcf 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;
@@ -2139,7 +2141,7 @@ export type PageReloadResult = {
};
export type PageExpectScreenshotParams = {
expected?: Binary,
- timeout?: number,
+ timeout: number,
isNot: boolean,
locator?: {
frame: FrameChannel,
@@ -2164,7 +2166,6 @@ export type PageExpectScreenshotParams = {
};
export type PageExpectScreenshotOptions = {
expected?: Binary,
- timeout?: number,
locator?: {
frame: FrameChannel,
selector: string,
@@ -2191,6 +2192,7 @@ export type PageExpectScreenshotResult = {
errorMessage?: string,
actual?: Binary,
previous?: Binary,
+ timedOut?: boolean,
log?: string[],
};
export type PageScreenshotParams = {
@@ -3160,7 +3162,7 @@ export type FrameExpectParams = {
expectedValue?: SerializedArgument,
useInnerText?: boolean,
isNot: boolean,
- timeout?: number,
+ timeout: number,
};
export type FrameExpectOptions = {
expressionArg?: any,
@@ -3168,7 +3170,6 @@ export type FrameExpectOptions = {
expectedNumber?: number,
expectedValue?: SerializedArgument,
useInnerText?: boolean,
- timeout?: number,
};
export type FrameExpectResult = {
matches: boolean,
diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml
index c91cecbe6c..82de53be24 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:
@@ -1481,7 +1482,7 @@ Page:
expectScreenshot:
parameters:
expected: binary?
- timeout: number?
+ timeout: number
isNot: boolean
locator:
type: object?
@@ -1500,6 +1501,7 @@ Page:
errorMessage: string?
actual: binary?
previous: binary?
+ timedOut: boolean?
log:
type: array?
items: string
@@ -2386,7 +2388,7 @@ Frame:
expectedValue: SerializedArgument?
useInnerText: boolean?
isNot: boolean
- timeout: number?
+ timeout: number
returns:
matches: boolean
received: SerializedValue?
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/sourceTab.tsx b/packages/trace-viewer/src/ui/sourceTab.tsx
index d130499207..1dd9170f67 100644
--- a/packages/trace-viewer/src/ui/sourceTab.tsx
+++ b/packages/trace-viewer/src/ui/sourceTab.tsx
@@ -111,7 +111,7 @@ export const SourceTab: React.FunctionComponent<{
{location && }
}
-
+