Merge branch 'main' into mockingproxy-headers-only

This commit is contained in:
Simon Knott 2025-02-07 15:46:57 +01:00
commit 34991a032f
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
74 changed files with 2104 additions and 1278 deletions

View file

@ -1,18 +0,0 @@
test/assets/modernizr.js
/tests/third_party/
/packages/*/lib/
*.js
/packages/playwright-core/src/generated/*
/packages/playwright-core/src/third_party/
/packages/playwright-core/types/*
/packages/playwright-ct-core/src/generated/*
/index.d.ts
node_modules/
**/*.d.ts
output/
test-results/
/tests/components/
/tests/installation/fixture-scripts/
DEPS
.cache/
/utils/

View file

@ -1,15 +0,0 @@
module.exports = {
extends: "./.eslintrc.js",
parserOptions: {
ecmaVersion: 9,
sourceType: "module",
project: "./tsconfig.json",
},
rules: {
"@typescript-eslint/no-base-to-string": "error",
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
},
parserOptions: {
project: "./tsconfig.json"
},
};

View file

@ -1,137 +0,0 @@
module.exports = {
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "notice"],
parserOptions: {
ecmaVersion: 9,
sourceType: "module",
},
extends: [
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
settings: {
react: { version: "18" }
},
/**
* ESLint rules
*
* All available rules: http://eslint.org/docs/rules/
*
* Rules take the following form:
* "rule-name", [severity, { opts }]
* Severity: 2 == error, 1 == warning, 0 == off.
*/
rules: {
"@typescript-eslint/no-unused-vars": [2, {args: "none"}],
"@typescript-eslint/consistent-type-imports": [2, {disallowTypeAnnotations: false}],
/**
* Enforced rules
*/
// syntax preferences
"object-curly-spacing": ["error", "always"],
"quotes": [2, "single", {
"avoidEscape": true,
"allowTemplateLiterals": true
}],
"jsx-quotes": [2, "prefer-single"],
"no-extra-semi": 2,
"@typescript-eslint/semi": [2],
"comma-style": [2, "last"],
"wrap-iife": [2, "inside"],
"spaced-comment": [2, "always", {
"markers": ["*"]
}],
"eqeqeq": [2],
"accessor-pairs": [2, {
"getWithoutSet": false,
"setWithoutGet": false
}],
"brace-style": [2, "1tbs", {"allowSingleLine": true}],
"curly": [2, "multi-or-nest", "consistent"],
"new-parens": 2,
"arrow-parens": [2, "as-needed"],
"prefer-const": 2,
"quote-props": [2, "consistent"],
"nonblock-statement-body-position": [2, "below"],
// anti-patterns
"no-var": 2,
"no-with": 2,
"no-multi-str": 2,
"no-caller": 2,
"no-implied-eval": 2,
"no-labels": 2,
"no-new-object": 2,
"no-octal-escape": 2,
"no-self-compare": 2,
"no-shadow-restricted-names": 2,
"no-cond-assign": 2,
"no-debugger": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-unreachable": 2,
"no-unsafe-negation": 2,
"radix": 2,
"valid-typeof": 2,
"no-implicit-globals": [2],
"no-unused-expressions": [2, { "allowShortCircuit": true, "allowTernary": true, "allowTaggedTemplates": true}],
"no-proto": 2,
// es2015 features
"require-yield": 2,
"template-curly-spacing": [2, "never"],
// spacing details
"space-infix-ops": 2,
"space-in-parens": [2, "never"],
"array-bracket-spacing": [2, "never"],
"comma-spacing": [2, { "before": false, "after": true }],
"keyword-spacing": [2, "always"],
"space-before-function-paren": [2, {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}],
"no-whitespace-before-property": 2,
"keyword-spacing": [2, {
"overrides": {
"if": {"after": true},
"else": {"after": true},
"for": {"after": true},
"while": {"after": true},
"do": {"after": true},
"switch": {"after": true},
"return": {"after": true}
}
}],
"arrow-spacing": [2, {
"after": true,
"before": true
}],
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/type-annotation-spacing": 2,
// file whitespace
"no-multiple-empty-lines": [2, {"max": 2, "maxEOF": 0}],
"no-mixed-spaces-and-tabs": 2,
"no-trailing-spaces": 2,
"linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ],
"indent": [2, 2, { "SwitchCase": 1, "CallExpression": {"arguments": 2}, "MemberExpression": 2 }],
"key-spacing": [2, {
"beforeColon": false
}],
"eol-last": 2,
// copyright
"notice/notice": [2, {
"mustMatch": "Copyright",
"templateFile": require("path").join(__dirname, "utils", "copyright.js"),
}],
// react
"react/react-in-jsx-scope": 0
}
};

View file

@ -909,3 +909,9 @@ Returns storage state for this request context, contains current cookies and loc
### option: APIRequestContext.storageState.path = %%-storagestate-option-path-%%
* since: v1.16
### option: APIRequestContext.storageState.indexedDB
* since: v1.51
- `indexedDB` ?<boolean>
Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.

View file

@ -1533,6 +1533,10 @@ Whether to emulate network being offline for the browser context.
Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot.
:::note
IndexedDBs with typed arrays are currently not supported.
:::
## async method: BrowserContext.storageState
* since: v1.8
* langs: csharp, java
@ -1541,6 +1545,12 @@ Returns storage state for this browser context, contains current cookies, local
### option: BrowserContext.storageState.path = %%-storagestate-option-path-%%
* since: v1.8
### option: BrowserContext.storageState.indexedDB
* since: v1.51
- `indexedDB` ?<boolean>
Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
## property: BrowserContext.tracing
* since: v1.12
- type: <[Tracing]>

95
eslint-react.config.mjs Normal file
View file

@ -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 { fixupConfigRules } from '@eslint/compat';
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import notice from 'eslint-plugin-notice';
import path from 'path';
import { fileURLToPath } from 'url';
import stylistic from '@stylistic/eslint-plugin';
import { baseRules } from './eslint.config.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
const baseConfig = fixupConfigRules(compat.extends('plugin:react/recommended', 'plugin:react-hooks/recommended'));
const plugins = {
'@stylistic': stylistic,
'@typescript-eslint': typescriptEslint,
notice,
};
const ignores = [
'.github/',
'*.js',
'**/.cache/',
'**/*.d.ts',
'**/dist/**',
'index.d.ts',
'node_modules/',
'output/',
'packages/*/lib/',
'test-results/',
'tests/',
'utils/',
];
export default [
{ ignores },
{
plugins,
settings: {
react: { version: 'detect' },
}
},
...baseConfig,
packageSection('html-reporter'),
packageSection('recorder'),
packageSection('trace-viewer'),
];
function packageSection(packageName) {
return {
files: [
`packages/${packageName}/src/**/*.ts`,
`packages/${packageName}/src/**/*.tsx`,
`packages/web/src/**/*.ts`,
`packages/web/src/**/*.tsx`,
],
languageOptions: {
parser: tsParser,
ecmaVersion: 9,
sourceType: 'module',
parserOptions: {
project: path.join(__dirname, 'packages', packageName, 'tsconfig.json'),
},
},
rules: {
...baseRules,
'no-console': 2,
}
};
}

252
eslint.config.mjs Normal file
View file

@ -0,0 +1,252 @@
/**
* 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 typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import notice from 'eslint-plugin-notice';
import path from 'path';
import { fileURLToPath } from 'url';
import stylistic from '@stylistic/eslint-plugin';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const plugins = {
'@stylistic': stylistic,
'@typescript-eslint': typescriptEslint,
notice,
};
const ignores = [
'.github/',
'*.js',
'**/.cache/',
'**/*.d.ts',
'index.d.ts',
'node_modules/',
'output/',
'packages/*/lib/',
'packages/html-reporter/**',
'packages/playwright-core/src/generated/*',
'packages/playwright-core/src/third_party/',
'packages/playwright-core/types/*',
'packages/playwright-ct-core/src/generated/*',
'packages/recorder/**',
'packages/trace-viewer/**',
'packages/web/**',
'test-results/',
'tests/assets/',
'tests/components/',
'tests/installation/fixture-scripts/',
'tests/third_party/',
'utils/',
];
export const baseRules = {
'@typescript-eslint/no-unused-vars': [2, { args: 'none', caughtErrors: 'none' }],
'@typescript-eslint/consistent-type-imports': [2, { disallowTypeAnnotations: false }],
/**
* Enforced rules
*/
// syntax preferences
'object-curly-spacing': ['error', 'always'],
'quotes': [2, 'single', {
'avoidEscape': true,
'allowTemplateLiterals': true
}],
'jsx-quotes': [2, 'prefer-single'],
'no-extra-semi': 2,
'@stylistic/semi': [2],
'comma-style': [2, 'last'],
'wrap-iife': [2, 'inside'],
'spaced-comment': [2, 'always', {
'markers': ['*']
}],
'eqeqeq': [2],
'accessor-pairs': [2, {
'getWithoutSet': false,
'setWithoutGet': false
}],
'brace-style': [2, '1tbs', { 'allowSingleLine': true }],
'curly': [2, 'multi-or-nest', 'consistent'],
'new-parens': 2,
'arrow-parens': [2, 'as-needed'],
'prefer-const': 2,
'quote-props': [2, 'consistent'],
'nonblock-statement-body-position': [2, 'below'],
// anti-patterns
'no-var': 2,
'no-with': 2,
'no-multi-str': 2,
'no-caller': 2,
'no-implied-eval': 2,
'no-labels': 2,
'no-new-object': 2,
'no-octal-escape': 2,
'no-self-compare': 2,
'no-shadow-restricted-names': 2,
'no-cond-assign': 2,
'no-debugger': 2,
'no-dupe-keys': 2,
'no-duplicate-case': 2,
'no-empty-character-class': 2,
'no-unreachable': 2,
'no-unsafe-negation': 2,
'radix': 2,
'valid-typeof': 2,
'no-implicit-globals': [2],
'no-unused-expressions': [2, { 'allowShortCircuit': true, 'allowTernary': true, 'allowTaggedTemplates': true }],
'no-proto': 2,
// es2015 features
'require-yield': 2,
'template-curly-spacing': [2, 'never'],
// spacing details
'space-infix-ops': 2,
'space-in-parens': [2, 'never'],
'array-bracket-spacing': [2, 'never'],
'comma-spacing': [2, { 'before': false, 'after': true }],
'keyword-spacing': [2, 'always'],
'space-before-function-paren': [2, {
'anonymous': 'never',
'named': 'never',
'asyncArrow': 'always'
}],
'no-whitespace-before-property': 2,
'keyword-spacing': [2, {
'overrides': {
'if': { 'after': true },
'else': { 'after': true },
'for': { 'after': true },
'while': { 'after': true },
'do': { 'after': true },
'switch': { 'after': true },
'return': { 'after': true }
}
}],
'arrow-spacing': [2, {
'after': true,
'before': true
}],
'@stylistic/func-call-spacing': 2,
'@stylistic/type-annotation-spacing': 2,
// file whitespace
'no-multiple-empty-lines': [2, { 'max': 2, 'maxEOF': 0 }],
'no-mixed-spaces-and-tabs': 2,
'no-trailing-spaces': 2,
'linebreak-style': [process.platform === 'win32' ? 0 : 2, 'unix'],
'indent': [2, 2, { 'SwitchCase': 1, 'CallExpression': { 'arguments': 2 }, 'MemberExpression': 2 }],
'key-spacing': [2, {
'beforeColon': false
}],
'eol-last': 2,
// copyright
'notice/notice': [2, {
'mustMatch': 'Copyright',
'templateFile': path.join(__dirname, 'utils', 'copyright.js'),
}],
// react
'react/react-in-jsx-scope': 0
};
const noFloatingPromisesRules = {
'@typescript-eslint/no-floating-promises': 'error',
};
const noBooleanCompareRules = {
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 2,
};
const noRestrictedGlobalsRules = {
'no-restricted-globals': [
'error',
{ 'name': 'window' },
{ 'name': 'document' },
{ 'name': 'globalThis' },
],
};
const languageOptions = {
parser: tsParser,
ecmaVersion: 9,
sourceType: 'module',
};
const languageOptionsWithTsConfig = {
parser: tsParser,
ecmaVersion: 9,
sourceType: 'module',
parserOptions: {
project: path.join(__dirname, 'tsconfig.json'),
},
};
export default [{
ignores,
}, {
files: ['**/*.ts'],
plugins,
languageOptions,
rules: baseRules,
}, {
files: ['packages/**/*.ts'],
languageOptions: languageOptionsWithTsConfig,
rules: {
'no-console': 2,
'no-restricted-properties': [2, {
'object': 'process',
'property': 'exit',
'message': 'Please use gracefullyProcessExitDoNotHang function to exit the process.',
}],
}
}, {
files: ['packages/playwright/**/*.ts'],
rules: {
...noFloatingPromisesRules,
}
}, {
files: ['packages/playwright/src/reporters/**/*.ts'],
languageOptions: languageOptionsWithTsConfig,
rules: {
'no-console': 'off'
}
}, {
files: ['packages/playwright-core/src/server/injected/**/*.ts'],
languageOptions: languageOptionsWithTsConfig,
rules: {
...noRestrictedGlobalsRules,
...noFloatingPromisesRules,
...noBooleanCompareRules,
}
}, {
files: ['tests/**/*.spec.js', 'tests/**/*.ts'],
languageOptions: {
parser: tsParser,
ecmaVersion: 9,
sourceType: 'module',
parserOptions: {
project: path.join(__dirname, 'tests', 'tsconfig.json'),
},
},
rules: {
...noFloatingPromisesRules,
}
}];

1875
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,7 @@
"ttest": "node ./tests/playwright-test/stable-test-runner/node_modules/@playwright/test/cli test --config=tests/playwright-test/playwright.config.ts",
"ct": "playwright test tests/components/test-all.spec.js --reporter=list",
"test": "playwright test --config=tests/library/playwright.config.ts",
"eslint": "eslint --cache --report-unused-disable-directives --ext ts,tsx,js,jsx,mjs .",
"eslint": "eslint --cache && eslint -c eslint-react.config.mjs",
"tsc": "tsc -p . && tsc -p packages/html-reporter/",
"build-installer": "babel -s --extensions \".ts\" --out-dir packages/playwright-core/lib/utils/ packages/playwright-core/src/utils",
"doc": "node utils/doclint/cli.js",
@ -62,6 +62,10 @@
"@babel/plugin-transform-optional-chaining": "^7.23.4",
"@babel/plugin-transform-typescript": "^7.23.6",
"@babel/preset-react": "^7.23.3",
"@eslint/compat": "^1.2.6",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/babel__core": "^7.20.2",
"@types/codemirror": "^5.60.7",
"@types/formidable": "^2.0.4",
@ -71,9 +75,9 @@
"@types/react-dom": "^18.0.5",
"@types/ws": "^8.5.3",
"@types/xml2js": "^0.4.9",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@typescript-eslint/utils": "^7.15.0",
"@typescript-eslint/eslint-plugin": "^8.23.0",
"@typescript-eslint/parser": "^8.23.0",
"@typescript-eslint/utils": "^8.23.0",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"@vitejs/plugin-react": "^4.2.1",
"@zip.js/zip.js": "^2.7.29",
@ -86,10 +90,10 @@
"dotenv": "^16.4.5",
"electron": "^30.1.2",
"esbuild": "^0.18.11",
"eslint": "^8.55.0",
"eslint-plugin-notice": "^0.9.10",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint": "^9.19.0",
"eslint-plugin-notice": "^1.0.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"formidable": "^2.1.1",
"immutable": "^4.3.7",
"license-checker": "^25.0.1",
@ -98,7 +102,7 @@
"react": "^18.1.0",
"react-dom": "^18.1.0",
"ssim.js": "^3.5.0",
"typescript": "^5.7.2",
"typescript": "^5.7.3",
"vite": "^5.4.14",
"ws": "^8.17.1",
"xml2js": "^0.5.0",

View file

@ -1,21 +0,0 @@
module.exports = {
"extends": "../.eslintrc.js",
/**
* ESLint rules
*
* All available rules: http://eslint.org/docs/rules/
*
* Rules take the following form:
* "rule-name", [severity, { opts }]
* Severity: 2 == error, 1 == warning, 0 == off.
*/
"rules": {
"no-console": 2,
"no-debugger": 2,
"no-restricted-properties": [2, {
"object": "process",
"property": "exit",
"message": "Please use gracefullyProcessExitDoNotHang function to exit the process.",
}],
}
};

View file

@ -95,7 +95,18 @@ const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
<div className='hbox m-2 mt-1'>
<div className='mr-1'>{author}</div>
<div title={longTimestamp}> on {shortTimestamp}</div>
{info['ci.link'] && <><span className='mx-2'>·</span><a href={info['ci.link']} target='_blank' rel='noopener noreferrer' title='CI/CD logs'>logs</a></>}
{info['ci.link'] && (
<>
<span className='mx-2'>·</span>
<a href={info['ci.link']} target='_blank' rel='noopener noreferrer' title='CI/CD logs'>Logs</a>
</>
)}
{info['pull.link'] && (
<>
<span className='mx-2'>·</span>
<a href={info['pull.link']} target='_blank' rel='noopener noreferrer'>Pull Request</a>
</>
)}
</div>
</div>
{!!info['revision.link'] && <a href={info['revision.link']} target='_blank' rel='noopener noreferrer'>

View file

@ -50,6 +50,11 @@
line-height: 24px;
}
.test-case-run-duration {
color: var(--color-fg-subtle);
padding-left: 8px;
}
.test-case-path {
flex: none;
align-items: center;

View file

@ -59,7 +59,7 @@ const testCase: TestCase = {
],
tags: [],
outcome: 'expected',
duration: 10,
duration: 200,
ok: true,
results: [result]
};
@ -215,3 +215,37 @@ test('should correctly render prev and next', async ({ mount }) => {
- text: "My test test.spec.ts:42 10ms"
`);
});
const testCaseWithTwoAttempts: TestCase = {
...testCase,
results: [
{
...result,
errors: ['Error message'],
status: 'failed',
duration: 50,
},
{
...result,
duration: 150,
status: 'passed',
},
],
};
test('total duration is selected run duration', async ({ mount, page }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCaseWithTwoAttempts} prev={undefined} next={undefined} run={0}></TestCaseView>);
await expect(component).toMatchAriaSnapshot(`
- text: "My test test.spec.ts:42 200ms"
- text: "Run 50ms Retry #1 150ms"
`);
await page.locator('.tabbed-pane-tab-label', { hasText: 'Run50ms' }).click();
await expect(component).toMatchAriaSnapshot(`
- text: "My test test.spec.ts:42 200ms"
`);
await page.locator('.tabbed-pane-tab-label', { hasText: 'Retry #1150ms' }).click();
await expect(component).toMatchAriaSnapshot(`
- text: "My test test.spec.ts:42 200ms"
`);
});

View file

@ -77,7 +77,10 @@ export const TestCaseView: React.FC<{
{test && <TabbedPane tabs={
test.results.map((result, index) => ({
id: String(index),
title: <div style={{ display: 'flex', alignItems: 'center' }}>{statusIcon(result.status)} {retryLabel(index)}</div>,
title: <div style={{ display: 'flex', alignItems: 'center' }}>
{statusIcon(result.status)} {retryLabel(index)}
{(test.results.length > 1) && <span className='test-case-run-duration'>{msToString(result.duration)}</span>}
</div>,
render: () => <TestResultView test={test!} result={result} />
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
</div>;

View file

@ -20,11 +20,12 @@
"@protocol/*": ["../protocol/src/*"],
"@web/*": ["../web/src/*"],
"@playwright/*": ["../playwright/src/*"],
"@recorder/*": ["../recorder/src/*"],
"@testIsomorphic/*": ["../playwright/src/isomorphic/*"],
"playwright-core/lib/*": ["../playwright-core/src/*"],
"playwright/lib/*": ["../playwright/src/*"],
}
},
"include": ["src"],
"include": ["src", "../web/src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -1,3 +0,0 @@
module.exports = {
extends: "../../.eslintrc-with-ts-config.js",
};

View file

@ -7,12 +7,24 @@
"installByDefault": true,
"browserVersion": "133.0.6943.35"
},
{
"name": "chromium-headless-shell",
"revision": "1157",
"installByDefault": true,
"browserVersion": "133.0.6943.35"
},
{
"name": "chromium-tip-of-tree",
"revision": "1300",
"installByDefault": false,
"browserVersion": "134.0.6998.0"
},
{
"name": "chromium-tip-of-tree-headless-shell",
"revision": "1300",
"installByDefault": false,
"browserVersion": "134.0.6998.0"
},
{
"name": "firefox",
"revision": "1474",

View file

@ -19,7 +19,6 @@ const semver = currentNodeVersion.split('.');
const [major] = [+semver[0]];
if (major < minimumMajorNodeVersion) {
// eslint-disable-next-line no-console
console.error(
'You are running Node.js ' +
currentNodeVersion +

View file

@ -80,7 +80,7 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
}
async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> {
options = { ...this._browserType._defaultContextOptions, ...options };
options = { ...this._browserType._playwright._defaultContextOptions, ...options };
const contextOptions = await prepareBrowserContextParams(options, this._browserType);
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
const context = BrowserContext.from(response.context);

View file

@ -424,8 +424,8 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
});
}
async storageState(options: { path?: string } = {}): Promise<StorageState> {
const state = await this._channel.storageState();
async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) {
await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');

View file

@ -18,7 +18,7 @@ import type * as channels from '@protocol/channels';
import { Browser } from './browser';
import { BrowserContext, prepareBrowserContextParams } from './browserContext';
import { ChannelOwner } from './channelOwner';
import type { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions, BrowserContextOptions, Logger } from './types';
import type { LaunchOptions, LaunchServerOptions, ConnectOptions, LaunchPersistentContextOptions, Logger } from './types';
import { Connection } from './connection';
import { Events } from './events';
import type { ChildProcess } from 'child_process';
@ -45,12 +45,6 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
_contexts = new Set<BrowserContext>();
_playwright!: Playwright;
// Instrumentation.
_defaultContextOptions?: BrowserContextOptions;
_defaultContextTimeout?: number;
_defaultContextNavigationTimeout?: number;
private _defaultLaunchOptions?: LaunchOptions;
static from(browserType: channels.BrowserTypeChannel): BrowserType {
return (browserType as any)._object;
}
@ -69,8 +63,8 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead');
assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
const logger = options.logger || this._defaultLaunchOptions?.logger;
options = { ...this._defaultLaunchOptions, ...options };
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
options = { ...this._playwright._defaultLaunchOptions, ...options };
const launchOptions: channels.BrowserTypeLaunchParams = {
...options,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined,
@ -87,14 +81,14 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
async launchServer(options: LaunchServerOptions = {}): Promise<api.BrowserServer> {
if (!this._serverLauncher)
throw new Error('Launching server is not supported');
options = { ...this._defaultLaunchOptions, ...options };
options = { ...this._playwright._defaultLaunchOptions, ...options };
return await this._serverLauncher.launchServer(options);
}
async launchPersistentContext(userDataDir: string, options: LaunchPersistentContextOptions = {}): Promise<BrowserContext> {
const logger = options.logger || this._defaultLaunchOptions?.logger;
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
options = { ...this._defaultLaunchOptions, ...this._defaultContextOptions, ...options };
options = { ...this._playwright._defaultLaunchOptions, ...this._playwright._defaultContextOptions, ...options };
const contextParams = await prepareBrowserContextParams(options, this);
const persistentParams: channels.BrowserTypeLaunchPersistentContextParams = {
...contextParams,
@ -237,11 +231,10 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
context._browserType = this;
this._contexts.add(context);
context._setOptions(contextOptions, browserOptions);
if (this._defaultContextTimeout !== undefined)
context.setDefaultTimeout(this._defaultContextTimeout);
if (this._defaultContextNavigationTimeout !== undefined)
context.setDefaultNavigationTimeout(this._defaultContextNavigationTimeout);
if (this._playwright._defaultContextTimeout !== undefined)
context.setDefaultTimeout(this._playwright._defaultContextTimeout);
if (this._playwright._defaultContextNavigationTimeout !== undefined)
context.setDefaultNavigationTimeout(this._playwright._defaultContextNavigationTimeout);
await this._instrumentation.runAfterCreateBrowserContext(context);
}

View file

@ -25,7 +25,7 @@ import { assert, headersObjectToArray, isString } from '../utils';
import { mkdirIfNeeded } from '../utils/fileUtils';
import { ChannelOwner } from './channelOwner';
import { RawHeaders } from './network';
import type { ClientCertificate, FilePayload, Headers, StorageState } from './types';
import type { ClientCertificate, FilePayload, Headers, SetStorageState, StorageState } from './types';
import type { Playwright } from './playwright';
import { Tracing } from './tracing';
import { TargetClosedError, isTargetClosedError } from './errors';
@ -47,7 +47,7 @@ export type FetchOptions = {
export type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'tracesDir'> & {
extraHTTPHeaders?: Headers,
storageState?: string | StorageState,
storageState?: string | SetStorageState,
clientCertificates?: ClientCertificate[];
};
@ -57,9 +57,6 @@ export class APIRequest implements api.APIRequest {
private _playwright: Playwright;
readonly _contexts = new Set<APIRequestContext>();
// Instrumentation.
_defaultContextOptions?: NewContextOptions & { tracesDir?: string };
constructor(playwright: Playwright) {
this._playwright = playwright;
}
@ -69,22 +66,24 @@ export class APIRequest implements api.APIRequest {
}
async _newContext(options: NewContextOptions = {}, channel: channels.PlaywrightChannel | channels.LocalUtilsChannel): Promise<APIRequestContext> {
options = { ...this._defaultContextOptions, ...options };
options = {
...this._playwright._defaultContextOptions,
timeout: this._playwright._defaultContextTimeout,
...options,
};
const storageState = typeof options.storageState === 'string' ?
JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) :
options.storageState;
// We do not expose tracesDir in the API, so do not allow options to accidentally override it.
const tracesDir = this._defaultContextOptions?.tracesDir;
const context = APIRequestContext.from((await channel.newRequest({
...options,
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
storageState,
tracesDir,
tracesDir: this._playwright._defaultLaunchOptions?.tracesDir, // We do not expose tracesDir in the API, so do not allow options to accidentally override it.
clientCertificates: await toClientCertificatesProtocol(options.clientCertificates),
})).request);
this._contexts.add(context);
context._request = this;
context._tracing._tracesDir = tracesDir;
context._tracing._tracesDir = this._playwright._defaultLaunchOptions?.tracesDir;
await context._instrumentation.runAfterCreateRequestContext(context);
return context;
}
@ -264,8 +263,8 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
});
}
async storageState(options: { path?: string } = {}): Promise<StorageState> {
const state = await this._channel.storageState();
async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) {
await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');
@ -420,8 +419,10 @@ function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined {
if (!map)
return undefined;
const result = [];
for (const [name, value] of Object.entries(map))
result.push({ name, value: String(value) });
for (const [name, value] of Object.entries(map)) {
if (value !== undefined)
result.push({ name, value: String(value) });
}
return result;
}

View file

@ -23,6 +23,7 @@ import { Electron } from './electron';
import { APIRequest } from './fetch';
import { Selectors, SelectorsOwner } from './selectors';
import { MockingProxy } from './mockingProxy';
import type { BrowserContextOptions, LaunchOptions } from 'playwright-core';
export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
readonly _android: Android;
@ -38,6 +39,12 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
readonly errors: { TimeoutError: typeof TimeoutError };
_mockingProxy?: MockingProxy;
// Instrumentation.
_defaultLaunchOptions?: LaunchOptions;
_defaultContextOptions?: BrowserContextOptions;
_defaultContextTimeout?: number;
_defaultContextNavigationTimeout?: number;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.PlaywrightInitializer) {
super(parent, type, guid, initializer);
this.request = new APIRequest(this);
@ -76,6 +83,19 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
return (channel as any)._object;
}
private _browserTypes(): BrowserType[] {
return [this.chromium, this.firefox, this.webkit, this._bidiChromium, this._bidiFirefox];
}
_allContexts() {
return this._browserTypes().flatMap(type => [...type._contexts]);
}
_allPages() {
return this._allContexts().flatMap(context => context.pages());
}
async _startMockingProxy() {
const requestContext = await this.request._newContext(undefined, this._connection.localUtils()._channel);
const result = await this._connection.localUtils()._channel.newMockingProxy({ requestContext: requestContext._channel });

View file

@ -234,7 +234,9 @@ scheme.APIRequestContextFetchLogParams = tObject({
scheme.APIRequestContextFetchLogResult = tObject({
log: tArray(tString),
});
scheme.APIRequestContextStorageStateParams = tOptional(tObject({}));
scheme.APIRequestContextStorageStateParams = tObject({
indexedDB: tOptional(tBoolean),
});
scheme.APIRequestContextStorageStateResult = tObject({
cookies: tArray(tType('NetworkCookie')),
origins: tArray(tType('OriginStorage')),
@ -1058,7 +1060,9 @@ scheme.BrowserContextSetOfflineParams = tObject({
offline: tBoolean,
});
scheme.BrowserContextSetOfflineResult = tOptional(tObject({}));
scheme.BrowserContextStorageStateParams = tOptional(tObject({}));
scheme.BrowserContextStorageStateParams = tObject({
indexedDB: tOptional(tBoolean),
});
scheme.BrowserContextStorageStateResult = tObject({
cookies: tArray(tType('NetworkCookie')),
origins: tArray(tType('OriginStorage')),

View file

@ -79,8 +79,8 @@ export class RawMouseImpl implements input.RawMouse {
async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, forClick: boolean): Promise<void> {
// Bidi throws when x/y are not integers.
x = Math.round(x);
y = Math.round(y);
x = Math.floor(x);
y = Math.floor(y);
await this._performActions([{ type: 'pointerMove', x, y }]);
}

View file

@ -508,14 +508,14 @@ export abstract class BrowserContext extends SdkObject implements network.Reques
this._origins.add(origin);
}
async storageState(): Promise<channels.BrowserContextStorageStateResult> {
async storageState(indexedDB = true): Promise<channels.BrowserContextStorageStateResult> {
const result: channels.BrowserContextStorageStateResult = {
cookies: await this.cookies(),
origins: []
};
const originsToSave = new Set(this._origins);
const collectScript = `(${storageScript.collect})((${utilityScriptSerializers.source})(), ${this._browser.options.name === 'firefox'})`;
const collectScript = `(${storageScript.collect})((${utilityScriptSerializers.source})(), ${this._browser.options.name === 'firefox'}, ${indexedDB})`;
// First try collecting storage stage from existing pages.
for (const page of this.pages()) {

View file

@ -291,7 +291,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
}
async storageState(params: channels.BrowserContextStorageStateParams, metadata: CallMetadata): Promise<channels.BrowserContextStorageStateResult> {
return await this._context.storageState();
return await this._context.storageState(params.indexedDB);
}
async close(params: channels.BrowserContextCloseParams, metadata: CallMetadata): Promise<void> {

View file

@ -197,8 +197,8 @@ export class APIRequestContextDispatcher extends Dispatcher<APIRequestContext, c
this.adopt(tracing);
}
async storageState(): Promise<channels.APIRequestContextStorageStateResult> {
return this._object.storageState();
async storageState(params: channels.APIRequestContextStorageStateParams): Promise<channels.APIRequestContextStorageStateResult> {
return this._object.storageState(params.indexedDB);
}
async dispose(params: channels.APIRequestContextDisposeParams, metadata: CallMetadata): Promise<void> {

View file

@ -133,7 +133,7 @@ export abstract class APIRequestContext extends SdkObject {
abstract _defaultOptions(): FetchRequestOptions;
abstract _addCookies(cookies: channels.NetworkCookie[]): Promise<void>;
abstract _cookies(url: URL): Promise<channels.NetworkCookie[]>;
abstract storageState(): Promise<channels.APIRequestContextStorageStateResult>;
abstract storageState(indexedDB?: boolean): Promise<channels.APIRequestContextStorageStateResult>;
private _storeResponseBody(body: Buffer): string {
const uid = createGuid();
@ -618,8 +618,8 @@ export class BrowserContextAPIRequestContext extends APIRequestContext {
return await this._context.cookies(url.toString());
}
override async storageState(): Promise<channels.APIRequestContextStorageStateResult> {
return this._context.storageState();
override async storageState(indexedDB?: boolean): Promise<channels.APIRequestContextStorageStateResult> {
return this._context.storageState(indexedDB);
}
}
@ -684,10 +684,10 @@ export class GlobalAPIRequestContext extends APIRequestContext {
return this._cookieStore.cookies(url);
}
override async storageState(): Promise<channels.APIRequestContextStorageStateResult> {
override async storageState(indexedDB = true): Promise<channels.APIRequestContextStorageStateResult> {
return {
cookies: this._cookieStore.allCookies(),
origins: this._origins || []
origins: (this._origins || []).map(origin => ({ ...origin, indexedDB: indexedDB ? origin.indexedDB : [] })),
};
}
}

View file

@ -1,21 +0,0 @@
const path = require('path');
module.exports = {
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "notice"],
parserOptions: {
ecmaVersion: 9,
sourceType: "module",
project: path.join(__dirname, '../../../../../tsconfig.json'),
},
rules: {
"no-restricted-globals": [
"error",
{ "name": "window" },
{ "name": "document" },
{ "name": "globalThis" },
],
'@typescript-eslint/no-floating-promises': 'error',
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
},
};

View file

@ -445,14 +445,7 @@ type BrowsersJSONDescriptor = {
};
function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] {
const headlessShells: BrowsersJSON['browsers'] = [];
for (const browserName of ['chromium', 'chromium-tip-of-tree']) {
headlessShells.push({
...browsersJSON.browsers.find(browser => browser.name === browserName)!,
name: `${browserName}-headless-shell`,
});
}
return [...browsersJSON.browsers, ...headlessShells].map(obj => {
return (browsersJSON['browsers']).map(obj => {
const name = obj.name;
const revisionOverride = (obj.revisionOverrides || {})[hostPlatform];
const revision = revisionOverride || obj.revision;

View file

@ -19,8 +19,8 @@ import type { source } from './isomorphic/utilityScriptSerializers';
export type Storage = Omit<channels.OriginStorage, 'origin'>;
export async function collect(serializers: ReturnType<typeof source>, isFirefox: boolean): Promise<Storage> {
const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => {
export async function collect(serializers: ReturnType<typeof source>, isFirefox: boolean, recordIndexedDB: boolean): Promise<Storage> {
async function collectDB(dbInfo: IDBDatabaseInfo) {
if (!dbInfo.name)
throw new Error('Database name is empty');
if (!dbInfo.version)
@ -119,13 +119,13 @@ export async function collect(serializers: ReturnType<typeof source>, isFirefox:
version: dbInfo.version,
stores,
};
})).catch(e => {
throw new Error('Unable to serialize IndexedDB: ' + e.message);
});
}
return {
localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name)! })),
indexedDB: idbResult,
indexedDB: recordIndexedDB ? await Promise.all((await indexedDB.databases()).map(collectDB)).catch(e => {
throw new Error('Unable to serialize IndexedDB: ' + e.message);
}) : [],
};
}

View file

@ -103,7 +103,8 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
wallTime: 0,
monotonicTime: 0,
sdkLanguage: context.attribution.playwright.options.sdkLanguage,
testIdAttributeName
testIdAttributeName,
contextId: context.guid,
};
if (context instanceof BrowserContext) {
this._snapshotter = new Snapshotter(context, this);

View file

@ -72,7 +72,7 @@ export class InMemorySnapshotter implements SnapshotterDelegate, HarTracerDelega
}
onEntryFinished(entry: har.Entry) {
this._storage.addResource(entry);
this._storage.addResource('', entry);
}
onContentBlob(sha1: string, buffer: Buffer) {
@ -85,7 +85,7 @@ export class InMemorySnapshotter implements SnapshotterDelegate, HarTracerDelega
onFrameSnapshot(snapshot: FrameSnapshot): void {
++this._snapshotCount;
const renderer = this._storage.addFrameSnapshot(snapshot, []);
const renderer = this._storage.addFrameSnapshot('', snapshot, []);
this._snapshotReadyPromises.get(snapshot.snapshotName || '')?.resolve(renderer);
}

View file

@ -9268,9 +9268,17 @@ export interface BrowserContext {
/**
* Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
* snapshot.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
* @param options
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
*/
indexedDB?: boolean;
/**
* The file path to save the storage state to. If
* [`path`](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state-option-path) is a
@ -18531,6 +18539,11 @@ export interface APIRequestContext {
* @param options
*/
storageState(options?: {
/**
* Defaults to `true`. Set to `false` to omit IndexedDB from snapshot.
*/
indexedDB?: boolean;
/**
* The file path to save the storage state to. If
* [`path`](https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-storage-state-option-path) is

View file

@ -1,3 +0,0 @@
module.exports = {
extends: "../../.eslintrc-with-ts-config.js",
};

View file

@ -1,6 +0,0 @@
module.exports = {
extends: '../../.eslintrc-with-ts-config.js',
rules: {
'@typescript-eslint/no-floating-promises': 'error',
},
};

View file

@ -24,10 +24,9 @@ import type { TestInfoImpl, TestStepInternal } from './worker/testInfo';
import { rootTestType } from './common/testType';
import type { ContextReuseMode } from './common/config';
import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation';
import type { MockingProxy } from '../../playwright-core/src/client/mockingProxy';
import type { BrowserContext as BrowserContextImpl } from '../../playwright-core/src/client/browserContext';
import type { Playwright as PlaywrightImpl } from '../../playwright-core/src/client/playwright';
import { currentTestInfo } from './common/globals';
import type { Playwright as PlaywrightImpl } from 'playwright-core/lib/client/playwright';
export { expect } from './matchers/expect';
export const _baseTest: TestType<{}, {}> = rootTestType.test;
@ -53,11 +52,13 @@ type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
};
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
playwright: PlaywrightImpl;
_browserOptions: LaunchOptions;
_optionContextReuseMode: ContextReuseMode,
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
_reuseContext: boolean,
_mockingProxy?: void,
_pageSnapshot: PageSnapshotOption,
};
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
@ -76,23 +77,22 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
video: ['off', { scope: 'worker', option: true }],
trace: ['off', { scope: 'worker', option: true }],
mockingProxy: [undefined, { scope: 'worker', option: true }],
_pageSnapshot: ['off', { scope: 'worker', option: true }],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
const options: LaunchOptions = {
handleSIGINT: false,
...launchOptions,
tracesDir: tracing().tracesDir(),
};
if (headless !== undefined)
options.headless = headless;
if (channel !== undefined)
options.channel = channel;
options.tracesDir = tracing().tracesDir();
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit, playwright._bidiChromium, playwright._bidiFirefox])
(browserType as any)._defaultLaunchOptions = options;
playwright._defaultLaunchOptions = options;
await use(options);
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit, playwright._bidiChromium, playwright._bidiFirefox])
(browserType as any)._defaultLaunchOptions = undefined;
playwright._defaultLaunchOptions = undefined;
}, { scope: 'worker', auto: true, box: true }],
browser: [async ({ playwright, browserName, _browserOptions, connectOptions, _reuseContext }, use, testInfo) => {
@ -241,30 +241,23 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
testInfo.snapshotSuffix = process.platform;
if (debugMode())
(testInfo as TestInfoImpl)._setDebugMode();
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) {
(browserType as any)._defaultContextOptions = _combinedContextOptions;
(browserType as any)._defaultContextTimeout = actionTimeout || 0;
(browserType as any)._defaultContextNavigationTimeout = navigationTimeout || 0;
}
(playwright.request as any)._defaultContextOptions = { ..._combinedContextOptions };
(playwright.request as any)._defaultContextOptions.tracesDir = tracing().tracesDir();
(playwright.request as any)._defaultContextOptions.timeout = actionTimeout || 0;
playwright._defaultContextOptions = _combinedContextOptions;
playwright._defaultContextTimeout = actionTimeout || 0;
playwright._defaultContextNavigationTimeout = navigationTimeout || 0;
await use();
(playwright.request as any)._defaultContextOptions = undefined;
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) {
(browserType as any)._defaultContextOptions = undefined;
(browserType as any)._defaultContextTimeout = undefined;
(browserType as any)._defaultContextNavigationTimeout = undefined;
}
playwright._defaultContextOptions = undefined;
playwright._defaultContextTimeout = undefined;
playwright._defaultContextNavigationTimeout = undefined;
}, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any],
_setupArtifacts: [async ({ playwright, screenshot }, use, testInfo) => {
_setupArtifacts: [async ({ playwright, screenshot, _pageSnapshot }, use, testInfo) => {
// This fixture has a separate zero-timeout slot to ensure that artifact collection
// happens even after some fixtures or hooks time out.
// Now that default test timeout is known, we can replace zero with an actual value.
testInfo.setTimeout(testInfo.project.timeout);
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot);
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot, _pageSnapshot);
await artifactsRecorder.willStartTest(testInfo as TestInfoImpl);
const tracingGroupSteps: TestStepInternal[] = [];
@ -462,7 +455,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
});
type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined;
type Playwright = PlaywrightWorkerArgs['playwright'];
type PageSnapshotOption = 'off' | 'on' | 'only-on-failure';
function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode {
if (!video)
@ -534,54 +527,135 @@ function connectOptionsFromEnv() {
};
}
class ArtifactsRecorder {
private _testInfo!: TestInfoImpl;
private _playwright: Playwright;
private _artifactsDir: string;
private _screenshotMode: ScreenshotMode;
private _screenshotOptions: { mode: ScreenshotMode } & Pick<playwrightLibrary.PageScreenshotOptions, 'fullPage' | 'omitBackground'> | undefined;
private _temporaryScreenshots: string[] = [];
private _temporaryArtifacts: string[] = [];
private _reusedContexts = new Set<BrowserContext>();
private _screenshotOrdinal = 0;
private _screenshottedSymbol: symbol;
private _startedCollectingArtifacts: symbol;
class SnapshotRecorder {
private _ordinal = 0;
private _temporary: string[] = [];
private _snapshottedSymbol = Symbol('snapshotted');
constructor(playwright: Playwright, artifactsDir: string, screenshot: ScreenshotOption) {
this._playwright = playwright;
this._artifactsDir = artifactsDir;
this._screenshotMode = normalizeScreenshotMode(screenshot);
this._screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot;
this._screenshottedSymbol = Symbol('screenshotted');
this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts');
constructor(
private _artifactsRecorder: ArtifactsRecorder,
private _mode: ScreenshotMode | PageSnapshotOption,
private _name: string,
private _contentType: string,
private _extension: string,
private _doSnapshot: (page: Page, path: string) => Promise<void>) {
}
fixOrdinal() {
// Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not
// overwrite previous screenshots.
this._ordinal = this.testInfo.attachments.filter(a => a.name === this._name).length;
}
private shouldCaptureUponFinish() {
return this._mode === 'on' ||
(this._mode === 'only-on-failure' && this.testInfo._isFailure()) ||
(this._mode === 'on-first-failure' && this.testInfo._isFailure() && this.testInfo.retry === 0);
}
async maybeCapture() {
if (!this.shouldCaptureUponFinish())
return;
await Promise.all(this._artifactsRecorder._playwright._allPages().map(page => this._snapshotPage(page, false)));
}
async persistTemporary() {
if (this.shouldCaptureUponFinish()) {
await Promise.all(this._temporary.map(async file => {
try {
const path = this._createAttachmentPath();
await fs.promises.rename(file, path);
this._attach(path);
} catch {
}
}));
}
}
async captureTemporary(context: BrowserContext) {
if (this._mode === 'on' || this._mode === 'only-on-failure' || (this._mode === 'on-first-failure' && this.testInfo.retry === 0))
await Promise.all(context.pages().map(page => this._snapshotPage(page, true)));
}
private _attach(screenshotPath: string) {
this.testInfo.attachments.push({ name: this._name, path: screenshotPath, contentType: this._contentType });
}
private _createAttachmentPath() {
const testFailed = this.testInfo._isFailure();
const index = this._ordinal + 1;
++this._ordinal;
const path = this.testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${index}${this._extension}`);
return path;
}
private _createTemporaryArtifact(...name: string[]) {
const file = path.join(this._artifactsDir, ...name);
this._temporaryArtifacts.push(file);
const file = path.join(this._artifactsRecorder._artifactsDir, ...name);
return file;
}
private async _snapshotPage(page: Page, temporary: boolean) {
if ((page as any)[this._snapshottedSymbol])
return;
(page as any)[this._snapshottedSymbol] = true;
try {
const path = temporary ? this._createTemporaryArtifact(createGuid() + this._extension) : this._createAttachmentPath();
await this._doSnapshot(page, path);
if (temporary)
this._temporary.push(path);
else
this._attach(path);
} catch {
// snapshot may fail, just ignore.
}
}
private get testInfo(): TestInfoImpl {
return this._artifactsRecorder._testInfo;
}
}
class ArtifactsRecorder {
_testInfo!: TestInfoImpl;
_playwright: PlaywrightImpl;
_artifactsDir: string;
private _reusedContexts = new Set<BrowserContext>();
private _startedCollectingArtifacts: symbol;
private _pageSnapshotRecorder: SnapshotRecorder;
private _screenshotRecorder: SnapshotRecorder;
constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption, pageSnapshot: PageSnapshotOption) {
this._playwright = playwright;
this._artifactsDir = artifactsDir;
const screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot;
this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts');
this._screenshotRecorder = new SnapshotRecorder(this, normalizeScreenshotMode(screenshot), 'screenshot', 'image/png', '.png', async (page, path) => {
await page.screenshot({ ...screenshotOptions, timeout: 5000, path, caret: 'initial' });
});
this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/plain', '.ariasnapshot', async (page, path) => {
const ariaSnapshot = await page.locator('body').ariaSnapshot();
await fs.promises.writeFile(path, ariaSnapshot);
});
}
async willStartTest(testInfo: TestInfoImpl) {
this._testInfo = testInfo;
testInfo._onDidFinishTestFunction = () => this.didFinishTestFunction();
// Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not
// overwrite previous screenshots.
this._screenshotOrdinal = testInfo.attachments.filter(a => a.name === 'screenshot').length;
this._screenshotRecorder.fixOrdinal();
this._pageSnapshotRecorder.fixOrdinal();
// Process existing contexts.
for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) {
const promises: (Promise<void> | undefined)[] = [];
const existingContexts = Array.from((browserType as any)._contexts) as BrowserContext[];
for (const context of existingContexts) {
if ((context as any)[kIsReusedContext])
this._reusedContexts.add(context);
else
promises.push(this.didCreateBrowserContext(context));
}
await Promise.all(promises);
}
await Promise.all(this._playwright._allContexts().map(async context => {
if ((context as any)[kIsReusedContext])
this._reusedContexts.add(context);
else
await this.didCreateBrowserContext(context);
}));
{
const existingApiRequests: APIRequestContext[] = Array.from((this._playwright.request as any)._contexts as Set<APIRequestContext>);
await Promise.all(existingApiRequests.map(c => this.didCreateRequestContext(c)));
@ -598,11 +672,9 @@ class ArtifactsRecorder {
if (this._reusedContexts.has(context))
return;
await this._stopTracing(context.tracing);
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)));
}
await this._screenshotRecorder.captureTemporary(context);
await this._pageSnapshotRecorder.captureTemporary(context);
}
async didCreateRequestContext(context: APIRequestContext) {
@ -615,26 +687,15 @@ 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() {
if (this._shouldCaptureScreenshotUponFinish())
await this._screenshotOnTestFailure();
await this._screenshotRecorder.maybeCapture();
await this._pageSnapshotRecorder.maybeCapture();
}
async didFinishTest() {
const captureScreenshots = this._shouldCaptureScreenshotUponFinish();
if (captureScreenshots)
await this._screenshotOnTestFailure();
await this.didFinishTestFunction();
let leftoverContexts: BrowserContext[] = [];
for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit])
leftoverContexts.push(...(browserType as any)._contexts);
leftoverContexts = leftoverContexts.filter(context => !this._reusedContexts.has(context));
const leftoverContexts = this._playwright._allContexts().filter(context => !this._reusedContexts.has(context));
const leftoverApiRequests: APIRequestContext[] = Array.from((this._playwright.request as any)._contexts as Set<APIRequestContext>);
// Collect traces/screenshots for remaining contexts.
@ -645,55 +706,8 @@ class ArtifactsRecorder {
await this._stopTracing(tracing);
})));
// Attach temporary screenshots for contexts closed before collecting the test trace.
if (captureScreenshots) {
for (const file of this._temporaryScreenshots) {
try {
const screenshotPath = this._createScreenshotAttachmentPath();
await fs.promises.rename(file, screenshotPath);
this._attachScreenshot(screenshotPath);
} catch {
}
}
}
}
private _createScreenshotAttachmentPath() {
const testFailed = this._testInfo._isFailure();
const index = this._screenshotOrdinal + 1;
++this._screenshotOrdinal;
const screenshotPath = this._testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${index}.png`);
return screenshotPath;
}
private async _screenshotPage(page: Page, temporary: boolean) {
if ((page as any)[this._screenshottedSymbol])
return;
(page as any)[this._screenshottedSymbol] = true;
try {
const screenshotPath = temporary ? this._createTemporaryArtifact(createGuid() + '.png') : this._createScreenshotAttachmentPath();
// Pass caret=initial to avoid any evaluations that might slow down the screenshot
// and let the page modify itself from the problematic state it had at the moment of failure.
await page.screenshot({ ...this._screenshotOptions, timeout: 5000, path: screenshotPath, caret: 'initial' });
if (temporary)
this._temporaryScreenshots.push(screenshotPath);
else
this._attachScreenshot(screenshotPath);
} catch {
// Screenshot may fail, just ignore.
}
}
private _attachScreenshot(screenshotPath: string) {
this._testInfo.attachments.push({ name: 'screenshot', path: screenshotPath, contentType: 'image/png' });
}
private async _screenshotOnTestFailure() {
const contexts: BrowserContext[] = [];
for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit])
contexts.push(...(browserType as any)._contexts);
const pages = contexts.map(ctx => ctx.pages()).flat();
await Promise.all(pages.map(page => this._screenshotPage(page, false)));
await this._screenshotRecorder.persistTemporary();
await this._pageSnapshotRecorder.persistTemporary();
}
private async _startTraceChunkOnContextCreation(tracing: Tracing) {

View file

@ -96,6 +96,7 @@ export interface TestServerInterface {
workers?: number | string;
updateSnapshots?: 'all' | 'changed' | 'missing' | 'none';
updateSourceMethod?: 'overwrite' | 'patch' | '3way';
pageSnapshot?: 'off' | 'on' | 'only-on-failure';
reporters?: string[],
trace?: 'on' | 'off';
video?: 'on' | 'off';

View file

@ -21,5 +21,9 @@ export interface GitCommitInfo {
'revision.subject'?: string;
'revision.timestamp'?: number | Date;
'revision.link'?: string;
'revision.diff'?: string;
'pull.link'?: string;
'pull.diff'?: string;
'pull.base'?: string;
'ci.link'?: string;
}

View file

@ -33,13 +33,9 @@ export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerP
setup: async (config: FullConfig, configDir: string) => {
const fromEnv = linksFromEnv();
const fromCLI = await gitStatusFromCLI(options?.directory || configDir);
const info = { ...fromEnv, ...fromCLI };
if (info['revision.timestamp'] instanceof Date)
info['revision.timestamp'] = info['revision.timestamp'].getTime();
const fromCLI = await gitStatusFromCLI(options?.directory || configDir, fromEnv);
config.metadata = config.metadata || {};
config.metadata['git.commit.info'] = info;
config.metadata['git.commit.info'] = { ...fromEnv, ...fromCLI };
},
};
};
@ -48,8 +44,8 @@ interface GitCommitInfoPluginOptions {
directory?: string;
}
function linksFromEnv(): Pick<GitCommitInfo, 'revision.link' | 'ci.link'> {
const out: { 'revision.link'?: string; 'ci.link'?: string; } = {};
function linksFromEnv() {
const out: Partial<GitCommitInfo> = {};
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
if (process.env.BUILD_URL)
out['ci.link'] = process.env.BUILD_URL;
@ -63,28 +59,54 @@ function linksFromEnv(): Pick<GitCommitInfo, 'revision.link' | 'ci.link'> {
out['revision.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`;
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID)
out['ci.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
if (process.env.GITHUB_REF_NAME && process.env.GITHUB_REF_NAME.endsWith('/merge')) {
const pullId = process.env.GITHUB_REF_NAME.substring(0, process.env.GITHUB_REF_NAME.indexOf('/merge'));
out['pull.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${pullId}`;
out['pull.base'] = process.env.GITHUB_BASE_REF;
}
return out;
}
async function gitStatusFromCLI(gitDir: string): Promise<GitCommitInfo | undefined> {
async function gitStatusFromCLI(gitDir: string, envInfo: Pick<GitCommitInfo, 'pull.base'>): Promise<GitCommitInfo | undefined> {
const separator = `:${createGuid().slice(0, 4)}:`;
const { code, stdout } = await spawnAsync(
const commitInfoResult = await spawnAsync(
'git',
['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (code)
if (commitInfoResult.code)
return;
const showOutput = stdout.trim();
const showOutput = commitInfoResult.stdout.trim();
const [id, subject, author, email, rawTimestamp] = showOutput.split(separator);
let timestamp: number = Number.parseInt(rawTimestamp, 10);
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
return {
const result: GitCommitInfo = {
'revision.id': id,
'revision.author': author,
'revision.email': email,
'revision.subject': subject,
'revision.timestamp': timestamp,
};
const diffLimit = 1_000_000; // 1MB
if (envInfo['pull.base']) {
const pullDiffResult = await spawnAsync(
'git',
['diff', envInfo['pull.base']],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!pullDiffResult.code)
result['pull.diff'] = pullDiffResult.stdout.substring(0, diffLimit);
} else {
const diffResult = await spawnAsync(
'git',
['diff', 'HEAD~1'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!diffResult.code)
result['revision.diff'] = diffResult.stdout.substring(0, diffLimit);
}
return result;
}

View file

@ -1,5 +0,0 @@
{
"rules": {
"no-console": "off"
}
}

View file

@ -311,6 +311,7 @@ export class TestServerDispatcher implements TestServerInterface {
...(params.headed !== undefined ? { headless: !params.headed } : {}),
_optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
_pageSnapshot: params.pageSnapshot,
},
...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}),
...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}),

View file

@ -349,7 +349,7 @@ export interface APIRequestContextChannel extends APIRequestContextEventTarget,
fetch(params: APIRequestContextFetchParams, metadata?: CallMetadata): Promise<APIRequestContextFetchResult>;
fetchResponseBody(params: APIRequestContextFetchResponseBodyParams, metadata?: CallMetadata): Promise<APIRequestContextFetchResponseBodyResult>;
fetchLog(params: APIRequestContextFetchLogParams, metadata?: CallMetadata): Promise<APIRequestContextFetchLogResult>;
storageState(params?: APIRequestContextStorageStateParams, metadata?: CallMetadata): Promise<APIRequestContextStorageStateResult>;
storageState(params: APIRequestContextStorageStateParams, metadata?: CallMetadata): Promise<APIRequestContextStorageStateResult>;
disposeAPIResponse(params: APIRequestContextDisposeAPIResponseParams, metadata?: CallMetadata): Promise<APIRequestContextDisposeAPIResponseResult>;
dispose(params: APIRequestContextDisposeParams, metadata?: CallMetadata): Promise<APIRequestContextDisposeResult>;
}
@ -405,8 +405,12 @@ export type APIRequestContextFetchLogOptions = {
export type APIRequestContextFetchLogResult = {
log: string[],
};
export type APIRequestContextStorageStateParams = {};
export type APIRequestContextStorageStateOptions = {};
export type APIRequestContextStorageStateParams = {
indexedDB?: boolean,
};
export type APIRequestContextStorageStateOptions = {
indexedDB?: boolean,
};
export type APIRequestContextStorageStateResult = {
cookies: NetworkCookie[],
origins: OriginStorage[],
@ -1688,7 +1692,7 @@ export interface BrowserContextChannel extends BrowserContextEventTarget, EventT
setNetworkInterceptionPatterns(params: BrowserContextSetNetworkInterceptionPatternsParams, metadata?: CallMetadata): Promise<BrowserContextSetNetworkInterceptionPatternsResult>;
setWebSocketInterceptionPatterns(params: BrowserContextSetWebSocketInterceptionPatternsParams, metadata?: CallMetadata): Promise<BrowserContextSetWebSocketInterceptionPatternsResult>;
setOffline(params: BrowserContextSetOfflineParams, metadata?: CallMetadata): Promise<BrowserContextSetOfflineResult>;
storageState(params?: BrowserContextStorageStateParams, metadata?: CallMetadata): Promise<BrowserContextStorageStateResult>;
storageState(params: BrowserContextStorageStateParams, metadata?: CallMetadata): Promise<BrowserContextStorageStateResult>;
pause(params?: BrowserContextPauseParams, metadata?: CallMetadata): Promise<BrowserContextPauseResult>;
enableRecorder(params: BrowserContextEnableRecorderParams, metadata?: CallMetadata): Promise<BrowserContextEnableRecorderResult>;
newCDPSession(params: BrowserContextNewCDPSessionParams, metadata?: CallMetadata): Promise<BrowserContextNewCDPSessionResult>;
@ -1921,8 +1925,12 @@ export type BrowserContextSetOfflineOptions = {
};
export type BrowserContextSetOfflineResult = void;
export type BrowserContextStorageStateParams = {};
export type BrowserContextStorageStateOptions = {};
export type BrowserContextStorageStateParams = {
indexedDB?: boolean,
};
export type BrowserContextStorageStateOptions = {
indexedDB?: boolean,
};
export type BrowserContextStorageStateResult = {
cookies: NetworkCookie[],
origins: OriginStorage[],

View file

@ -376,6 +376,8 @@ APIRequestContext:
items: string
storageState:
parameters:
indexedDB: boolean?
returns:
cookies:
type: array
@ -1285,6 +1287,8 @@ BrowserContext:
offline: boolean
storageState:
parameters:
indexedDB: boolean?
returns:
cookies:
type: array

View file

@ -23,6 +23,6 @@
"@web/*": ["../web/src/*"],
}
},
"include": ["src"],
"include": ["src", "../web/src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -120,14 +120,16 @@ async function doFetch(event: FetchEvent): Promise<Response> {
const { snapshotServer } = loadedTraces.get(traceUrl!) || {};
if (!snapshotServer)
return new Response(null, { status: 404 });
return snapshotServer.serveSnapshotInfo(relativePath, url.searchParams);
const pageOrFrameId = relativePath.substring('/snapshotInfo/'.length);
return snapshotServer.serveSnapshotInfo(pageOrFrameId, url.searchParams);
}
if (relativePath.startsWith('/snapshot/')) {
const { snapshotServer } = loadedTraces.get(traceUrl!) || {};
if (!snapshotServer)
return new Response(null, { status: 404 });
const response = snapshotServer.serveSnapshot(relativePath, url.searchParams, url.href);
const pageOrFrameId = relativePath.substring('/snapshot/'.length);
const response = snapshotServer.serveSnapshot(pageOrFrameId, url.searchParams, url.href);
if (isDeployedAsHttps)
response.headers.set('Content-Security-Policy', 'upgrade-insecure-requests');
return response;
@ -137,7 +139,8 @@ async function doFetch(event: FetchEvent): Promise<Response> {
const { snapshotServer } = loadedTraces.get(traceUrl!) || {};
if (!snapshotServer)
return new Response(null, { status: 404 });
return snapshotServer.serveClosestScreenshot(relativePath, url.searchParams);
const pageOrFrameId = relativePath.substring('/closest-screenshot/'.length);
return snapshotServer.serveClosestScreenshot(pageOrFrameId, url.searchParams);
}
if (relativePath.startsWith('/sha1/')) {

View file

@ -31,8 +31,8 @@ export class SnapshotServer {
this._resourceLoader = resourceLoader;
}
serveSnapshot(pathname: string, searchParams: URLSearchParams, snapshotUrl: string): Response {
const snapshot = this._snapshot(pathname.substring('/snapshot'.length), searchParams);
serveSnapshot(pageOrFrameId: string, searchParams: URLSearchParams, snapshotUrl: string): Response {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
if (!snapshot)
return new Response(null, { status: 404 });
@ -41,16 +41,16 @@ export class SnapshotServer {
return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
async serveClosestScreenshot(pathname: string, searchParams: URLSearchParams): Promise<Response> {
const snapshot = this._snapshot(pathname.substring('/closest-screenshot'.length), searchParams);
async serveClosestScreenshot(pageOrFrameId: string, searchParams: URLSearchParams): Promise<Response> {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
const sha1 = snapshot?.closestScreenshot();
if (!sha1)
return new Response(null, { status: 404 });
return new Response(await this._resourceLoader(sha1));
}
serveSnapshotInfo(pathname: string, searchParams: URLSearchParams): Response {
const snapshot = this._snapshot(pathname.substring('/snapshotInfo'.length), searchParams);
serveSnapshotInfo(pageOrFrameId: string, searchParams: URLSearchParams): Response {
const snapshot = this._snapshot(pageOrFrameId, searchParams);
return this._respondWithJson(snapshot ? {
viewport: snapshot.viewport(),
url: snapshot.snapshot().frameUrl,
@ -61,9 +61,9 @@ export class SnapshotServer {
});
}
private _snapshot(pathname: string, params: URLSearchParams) {
private _snapshot(pageOrFrameId: string, params: URLSearchParams) {
const name = params.get('name')!;
return this._snapshotStorage.snapshotByName(pathname.slice(1), name);
return this._snapshotStorage.snapshotByName(pageOrFrameId, name);
}
private _respondWithJson(object: any): Response {

View file

@ -20,19 +20,19 @@ import type { PageEntry } from '../types/entries';
import { LRUCache } from './lruCache';
export class SnapshotStorage {
private _resources: ResourceSnapshot[] = [];
private _frameSnapshots = new Map<string, {
raw: FrameSnapshot[],
renderers: SnapshotRenderer[]
renderers: SnapshotRenderer[],
}>();
private _cache = new LRUCache<SnapshotRenderer, string>(100_000_000); // 100MB per each trace
private _contextToResources = new Map<string, ResourceSnapshot[]>();
addResource(resource: ResourceSnapshot): void {
addResource(contextId: string, resource: ResourceSnapshot): void {
resource.request.url = rewriteURLForCustomProtocol(resource.request.url);
this._resources.push(resource);
this._ensureResourcesForContext(contextId).push(resource);
}
addFrameSnapshot(snapshot: FrameSnapshot, screencastFrames: PageEntry['screencastFrames']) {
addFrameSnapshot(contextId: string, snapshot: FrameSnapshot, screencastFrames: PageEntry['screencastFrames']) {
for (const override of snapshot.resourceOverrides)
override.url = rewriteURLForCustomProtocol(override.url);
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
@ -46,7 +46,8 @@ export class SnapshotStorage {
this._frameSnapshots.set(snapshot.pageId, frameSnapshots);
}
frameSnapshots.raw.push(snapshot);
const renderer = new SnapshotRenderer(this._cache, this._resources, frameSnapshots.raw, screencastFrames, frameSnapshots.raw.length - 1);
const resources = this._ensureResourcesForContext(contextId);
const renderer = new SnapshotRenderer(this._cache, resources, frameSnapshots.raw, screencastFrames, frameSnapshots.raw.length - 1);
frameSnapshots.renderers.push(renderer);
return renderer;
}
@ -62,6 +63,16 @@ export class SnapshotStorage {
finalize() {
// Resources are not necessarily sorted in the trace file, so sort them now.
this._resources.sort((a, b) => (a._monotonicTime || 0) - (b._monotonicTime || 0));
for (const resources of this._contextToResources.values())
resources.sort((a, b) => (a._monotonicTime || 0) - (b._monotonicTime || 0));
}
private _ensureResourcesForContext(contextId: string): ResourceSnapshot[] {
let resources = this._contextToResources.get(contextId);
if (!resources) {
resources = [];
this._contextToResources.set(contextId, resources);
}
return resources;
}
}

View file

@ -105,7 +105,7 @@ export class TraceModel {
this.contextEntries.push(contextEntry);
}
this._snapshotStorage!.finalize();
this._snapshotStorage.finalize();
}
async hasEntry(filename: string): Promise<boolean> {
@ -153,5 +153,6 @@ function createEmptyContext(): ContextEntry {
errors: [],
stdio: [],
hasSource: false,
contextId: '',
};
}

View file

@ -92,6 +92,7 @@ export class TraceModernizer {
contextEntry.sdkLanguage = event.sdkLanguage;
contextEntry.options = event.options;
contextEntry.testIdAttributeName = event.testIdAttributeName;
contextEntry.contextId = event.contextId ?? '';
break;
}
case 'screencast-frame': {
@ -156,11 +157,11 @@ export class TraceModernizer {
break;
}
case 'resource-snapshot':
this._snapshotStorage.addResource(event.snapshot);
this._snapshotStorage.addResource(this._contextEntry.contextId, event.snapshot);
contextEntry.resources.push(event.snapshot);
break;
case 'frame-snapshot':
this._snapshotStorage.addFrameSnapshot(event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames);
this._snapshotStorage.addFrameSnapshot(this._contextEntry.contextId, event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames);
break;
}
// Make sure there is a page entry for each page, even without screencast frames,
@ -388,12 +389,13 @@ export class TraceModernizer {
wallTime: 0,
monotonicTime: 0,
sdkLanguage: 'javascript',
contextId: '',
};
result.push(event);
}
for (const event of events) {
if (event.type === 'context-options') {
result.push({ ...event, monotonicTime: 0, origin: 'library' });
result.push({ ...event, monotonicTime: 0, origin: 'library', contextId: '' });
continue;
}
// Take wall and monotonic time from the first event.

View file

@ -40,6 +40,7 @@ export type ContextEntry = {
stdio: trace.StdioTraceEvent[];
errors: trace.ErrorTraceEvent[];
hasSource: boolean;
contextId: string;
};
export type PageEntry = {

View file

@ -30,6 +30,6 @@
},
"useUnknownInCatchVariables": false,
},
"include": ["src"],
"include": ["src", "../web/src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -44,6 +44,7 @@ export type ContextCreatedTraceEvent = {
options: BrowserContextEventOptions,
sdkLanguage?: Language,
testIdAttributeName?: string,
contextId?: string,
};
export type ScreencastFrameTraceEvent = {

View file

@ -1,16 +0,0 @@
const path = require('path');
module.exports = {
extends: '../.eslintrc.js',
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "notice"],
parserOptions: {
ecmaVersion: 9,
sourceType: "module",
project: path.join(__dirname, 'tsconfig.json'),
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
},
};

View file

@ -1,5 +0,0 @@
{
"parserOptions": {
"sourceType": "module"
}
}

View file

@ -2,6 +2,7 @@
"name": "ct-react-vite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",

View file

@ -15,7 +15,8 @@
*/
import { defineConfig, devices } from '@playwright/experimental-ct-react';
import { resolve } from 'path';
import path from 'path';
import { fileURLToPath } from 'url';
export default defineConfig({
testDir: 'tests',
@ -30,7 +31,7 @@ export default defineConfig({
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@': path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src'),
}
}
}

View file

@ -1,6 +1,7 @@
{
"name": "ct-vue-vite",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",

View file

@ -15,7 +15,8 @@
*/
import { defineConfig, devices } from '@playwright/experimental-ct-vue';
import { resolve } from 'path';
import path from 'path';
import { fileURLToPath } from 'url';
export default defineConfig({
testDir: 'tests',
@ -27,7 +28,7 @@ export default defineConfig({
ctViteConfig: {
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@': path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src'),
}
}
}

View file

@ -82,7 +82,7 @@ export class RemoteServer implements PlaywrightServer {
async _start(childProcess: CommonFixtures['childProcess'], browserType: BrowserType, channel: string, remoteServerOptions: RemoteServerOptions = {}) {
this._browserType = browserType;
const browserOptions = (browserType as any)._defaultLaunchOptions;
const browserOptions = (browserType as any)._playwright._defaultLaunchOptions;
// Copy options to prevent a large JSON string when launching subprocess.
// Otherwise, we get `Error: spawn ENAMETOOLONG` on Windows.
const launchOptions: Parameters<BrowserType['launchServer']>[0] = {

View file

@ -1183,7 +1183,7 @@ it('should send secure cookie over http for localhost', async ({ page, server })
expect(serverRequest.headers.cookie).toBe('a=v');
});
it('should accept bool and numeric params', async ({ page, server }) => {
it('should accept bool and numeric params and filter out undefined', async ({ page, server }) => {
let request;
const url = new URL(server.EMPTY_PAGE);
url.searchParams.set('str', 's');
@ -1200,6 +1200,7 @@ it('should accept bool and numeric params', async ({ page, server }) => {
'num': 10,
'bool': true,
'bool2': false,
'none': undefined,
}
});
const params = new URLSearchParams(request!.url.substr(request!.url.indexOf('?')));
@ -1207,6 +1208,7 @@ it('should accept bool and numeric params', async ({ page, server }) => {
expect(params.get('num')).toEqual('10');
expect(params.get('bool')).toEqual('true');
expect(params.get('bool2')).toEqual('false');
expect(params.has('none')).toBe(false);
});
it('should abort requests when browser context closes', async ({ contextFactory, server }) => {

View file

@ -20,7 +20,7 @@ import type { BrowserContext, Page } from '@playwright/test';
const test = browserTest.extend<{ reusedContext: () => Promise<BrowserContext> }>({
reusedContext: async ({ browserType, browser }, use) => {
await use(async () => {
const defaultContextOptions = (browserType as any)._defaultContextOptions;
const defaultContextOptions = (browserType as any)._playwright._defaultContextOptions;
const context = await (browser as any)._newContextForReuse(defaultContextOptions);
return context;
});

View file

@ -90,15 +90,19 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) =>
await new Promise((resolve, reject) => {
const openRequest = indexedDB.open('db', 42);
openRequest.onupgradeneeded = () => {
openRequest.result.createObjectStore('store');
openRequest.result.createObjectStore('store', { keyPath: 'name' });
openRequest.result.createObjectStore('store2');
};
openRequest.onsuccess = () => {
const request = openRequest.result.transaction('store', 'readwrite')
const transaction = openRequest.result.transaction(['store', 'store2'], 'readwrite');
transaction
.objectStore('store')
.put({ name: 'foo', date: new Date(0) }, 'bar');
request.addEventListener('success', resolve);
request.addEventListener('error', reject);
.put({ name: 'foo', date: new Date(0) });
transaction
.objectStore('store2')
.put('bar', 'foo');
transaction.addEventListener('complete', resolve);
transaction.addEventListener('error', reject);
};
});
@ -120,18 +124,25 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) =>
expect(localStorage).toEqual({ name1: 'value1' });
const cookie = await page2.evaluate('document.cookie');
expect(cookie).toEqual('username=John Doe');
const idbValue = await page2.evaluate(() => new Promise<string>((resolve, reject) => {
const idbValues = await page2.evaluate(() => new Promise((resolve, reject) => {
const openRequest = indexedDB.open('db', 42);
openRequest.addEventListener('success', () => {
const db = openRequest.result;
const transaction = db.transaction('store', 'readonly');
const getRequest = transaction.objectStore('store').get('bar');
getRequest.addEventListener('success', () => resolve(getRequest.result));
getRequest.addEventListener('error', () => reject(getRequest.error));
const transaction = db.transaction(['store', 'store2'], 'readonly');
const request1 = transaction.objectStore('store').get('foo');
const request2 = transaction.objectStore('store2').get('foo');
Promise.all([request1, request2].map(request => new Promise((resolve, reject) => {
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
}))).then(resolve, reject);
});
openRequest.addEventListener('error', () => reject(openRequest.error));
}));
expect(idbValue).toEqual({ name: 'foo', date: new Date(0) });
expect(idbValues).toEqual([
{ name: 'foo', date: new Date(0) },
'bar'
]);
await context2.close();
});
@ -436,4 +447,6 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => {
- listitem:
- text: /Pet the cat/
`);
expect(await context.storageState({ indexedDB: false })).toEqual({ cookies: [], origins: [] });
});

View file

@ -21,7 +21,7 @@ import { playwrightTest as test, expect } from '../config/browserTest';
test('browserType.executablePath should work', async ({ browserType, channel, mode }) => {
test.skip(!!channel, 'We skip browser download when testing a channel');
test.skip(mode.startsWith('service'));
test.skip(!!(browserType as any)._defaultLaunchOptions.executablePath, 'Skip with custom executable path');
test.skip(!!(browserType as any)._playwright._defaultLaunchOptions.executablePath, 'Skip with custom executable path');
const executablePath = browserType.executablePath();
expect(fs.existsSync(executablePath)).toBe(true);

View file

@ -40,7 +40,7 @@ const test = playwrightTest.extend<ExtraFixtures>({
await use(async (wsEndpoint, options = {}, redirectPortForTest): Promise<Browser> => {
(options as any).__testHookRedirectPortForwarding = redirectPortForTest;
options.headers = {
'x-playwright-launch-options': JSON.stringify((browserType as any)._defaultLaunchOptions || {}),
'x-playwright-launch-options': JSON.stringify((browserType as any)._playwright._defaultLaunchOptions || {}),
...options.headers,
};
browser = await browserType.connect(wsEndpoint, options);
@ -173,8 +173,8 @@ for (const kind of ['launchServer', 'run-server'] as const) {
test('should ignore page.pause when headed', async ({ connect, startRemoteServer, browserType, channel }) => {
test.skip(channel === 'chromium-headless-shell', 'shell is never headed');
const headless = (browserType as any)._defaultLaunchOptions.headless;
(browserType as any)._defaultLaunchOptions.headless = false;
const headless = (browserType as any)._playwright._defaultLaunchOptions.headless;
(browserType as any)._playwright._defaultLaunchOptions.headless = false;
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint());
const browserContext = await browser.newContext();
@ -182,7 +182,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
// @ts-ignore
await page.pause({ __testHookKeepTestTimeout: true });
await browser.close();
(browserType as any)._defaultLaunchOptions.headless = headless;
(browserType as any)._playwright._defaultLaunchOptions.headless = headless;
});
test('should be able to visit ipv6 through localhost', async ({ connect, startRemoteServer, ipV6ServerPort }) => {
@ -599,7 +599,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
const browser = await browserType.connect({
wsEndpoint: remoteServer.wsEndpoint(),
headers: {
'x-playwright-launch-options': JSON.stringify((browserType as any)._defaultLaunchOptions || {}),
'x-playwright-launch-options': JSON.stringify((browserType as any)._playwright._defaultLaunchOptions || {}),
},
});
const page = await browser.newPage();
@ -630,14 +630,14 @@ for (const kind of ['launchServer', 'run-server'] as const) {
test('should filter launch options', async ({ connect, startRemoteServer, server, browserType }, testInfo) => {
const tracesDir = testInfo.outputPath('traces');
const oldTracesDir = (browserType as any)._defaultLaunchOptions.tracesDir;
(browserType as any)._defaultLaunchOptions.tracesDir = tracesDir;
const oldTracesDir = (browserType as any)._playwright._defaultTracesDir;
(browserType as any)._playwright._defaultTracesDir = tracesDir;
const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint());
const page = await browser.newPage();
await page.goto(server.EMPTY_PAGE);
await browser.close();
(browserType as any)._defaultLaunchOptions.tracesDir = oldTracesDir;
(browserType as any)._playwright._defaultTracesDir = oldTracesDir;
expect(fs.existsSync(tracesDir)).toBe(false);
});

View file

@ -51,7 +51,7 @@ const test = baseTest.extend<Fixtures>({
await use(async () => {
const browser = await browserType.connect(wsEndpoint, {
headers: {
'x-playwright-launch-options': JSON.stringify((browserType as any)._defaultLaunchOptions),
'x-playwright-launch-options': JSON.stringify((browserType as any)._playwright._defaultLaunchOptions),
'x-playwright-reuse-context': '1',
},
}) as BrowserWithReuse;

View file

@ -150,7 +150,7 @@ it('should have passed URL when launching with ignoreDefaultArgs: true', async (
it.skip(mode !== 'default');
const userDataDir = await createUserDataDir();
const args = toImpl(browserType).defaultArgs((browserType as any)._defaultLaunchOptions, 'persistent', userDataDir, 0).filter(a => a !== 'about:blank');
const args = toImpl(browserType).defaultArgs((browserType as any)._playwright._defaultLaunchOptions, 'persistent', userDataDir, 0).filter(a => a !== 'about:blank');
const options = {
args: browserName === 'firefox' ? [...args, '-new-tab', server.EMPTY_PAGE] : [...args, server.EMPTY_PAGE],
ignoreDefaultArgs: true,

View file

@ -255,7 +255,7 @@ it('should set playwright as user-agent', async ({ playwright, server, isWindows
});
it('should be able to construct with context options', async ({ playwright, browserType, server }) => {
const request = await playwright.request.newContext((browserType as any)._defaultContextOptions);
const request = await playwright.request.newContext((browserType as any)._playwright._defaultContextOptions);
const response = await request.get(server.EMPTY_PAGE);
expect(response.ok()).toBeTruthy();
await request.dispose();

View file

@ -368,8 +368,8 @@ test('should not crash when browser closes mid-trace', async ({ browserType, ser
});
test('should survive browser.close with auto-created traces dir', async ({ browserType }, testInfo) => {
const oldTracesDir = (browserType as any)._defaultLaunchOptions.tracesDir;
(browserType as any)._defaultLaunchOptions.tracesDir = undefined;
const oldTracesDir = (browserType as any)._playwright._defaultTracesDir;
(browserType as any)._playwright._defaultTracesDir = undefined;
const browser = await browserType.launch();
const page = await browser.newPage();
await page.context().tracing.start();
@ -394,7 +394,7 @@ test('should survive browser.close with auto-created traces dir', async ({ brows
]);
done.value = true;
(browserType as any)._defaultLaunchOptions.tracesDir = oldTracesDir;
(browserType as any)._playwright._defaultTracesDir = oldTracesDir;
});
test('should not stall on dialogs', async ({ page, context, server }) => {

View file

@ -420,3 +420,71 @@ test('should take screenshot when page is closed in afterEach', async ({ runInli
expect(result.failed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-fails', 'test-failed-1.png'))).toBeTruthy();
});
test('should work with _pageSnapshot: on', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...testFiles,
'playwright.config.ts': `
module.exports = { use: { _pageSnapshot: 'on' } };
`,
}, { workers: 1 });
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(5);
expect(result.failed).toBe(5);
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.ariasnapshot',
'artifacts-own-context-failing',
' test-failed-1.ariasnapshot',
'artifacts-own-context-passing',
' test-finished-1.ariasnapshot',
'artifacts-passing',
' test-finished-1.ariasnapshot',
'artifacts-persistent-failing',
' test-failed-1.ariasnapshot',
'artifacts-persistent-passing',
' test-finished-1.ariasnapshot',
'artifacts-shared-shared-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
'artifacts-shared-shared-passing',
' test-finished-1.ariasnapshot',
' test-finished-2.ariasnapshot',
'artifacts-two-contexts',
' test-finished-1.ariasnapshot',
' test-finished-2.ariasnapshot',
'artifacts-two-contexts-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
]);
});
test('should work with _pageSnapshot: only-on-failure', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
...testFiles,
'playwright.config.ts': `
module.exports = { use: { _pageSnapshot: 'only-on-failure' } };
`,
}, { workers: 1 });
expect(result.exitCode).toBe(1);
expect(result.passed).toBe(5);
expect(result.failed).toBe(5);
expect(listFiles(testInfo.outputPath('test-results'))).toEqual([
'.last-run.json',
'artifacts-failing',
' test-failed-1.ariasnapshot',
'artifacts-own-context-failing',
' test-failed-1.ariasnapshot',
'artifacts-persistent-failing',
' test-failed-1.ariasnapshot',
'artifacts-shared-shared-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
'artifacts-two-contexts-failing',
' test-failed-1.ariasnapshot',
' test-failed-2.ariasnapshot',
]);
});

View file

@ -1213,6 +1213,8 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await execGit(['init']);
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']);
await execGit(['config', '--local', 'user.name', 'William']);
await execGit(['add', 'playwright.config.ts']);
await execGit(['commit', '-m', 'init']);
await execGit(['add', '*.ts']);
await execGit(['commit', '-m', 'chore(html): make this test look nice']);
@ -1222,6 +1224,8 @@ for (const useIntermediateMergeReport of [true, false] as const) {
GITHUB_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev',
GITHUB_SHA: 'example-sha',
GITHUB_REF_NAME: '42/merge',
GITHUB_BASE_REF: 'HEAD~1',
});
await showReport();
@ -1231,7 +1235,8 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
- 'link "chore(html): make this test look nice"'
- text: /^William <shakespeare@example.local> on/
- link "logs"
- link "Logs"
- link "Pull Request"
- link /^[a-f0-9]{7}$/
- text: 'foo: value1 bar: {"prop":"value2"} baz: ["value3",123]'
`);

View file

@ -137,29 +137,55 @@ class JSLintingService extends LintingService {
'vue-router',
'experimental-ct',
];
constructor() {
super();
this.eslint = new ESLint({
overrideConfigFile: path.join(PROJECT_DIR, '.eslintrc.js'),
useEslintrc: false,
async _init() {
if (this._eslint)
return this._eslint;
const { fixupConfigRules } = await import('@eslint/compat');
const { FlatCompat } = await import('@eslint/eslintrc');
// @ts-ignore
const js = (await import('@eslint/js')).default;
const compat = new FlatCompat({
baseDirectory: __dirname,
// @ts-ignore
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
const baseConfig = fixupConfigRules(compat.extends('plugin:react/recommended', 'plugin:@typescript-eslint/disable-type-checked'));
const { baseRules }= await import('../../../eslint.config.mjs');
this._eslint = new ESLint({
baseConfig,
plugins: /** @type {any}*/({
'@stylistic': (await import('@stylistic/eslint-plugin')).default,
'notice': await import('eslint-plugin-notice'),
}),
ignore: false,
overrideConfig: {
plugins: ['react'],
files: ['**/*.ts', '**/*.tsx'],
settings: {
react: { version: 'detect', }
react: { version: 'detect' },
},
extends: [
'plugin:react/recommended',
],
rules: {
languageOptions: {
// @ts-ignore
parser: await import('@typescript-eslint/parser'),
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: /** @type {any}*/({
...baseRules,
'notice/notice': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'max-len': ['error', { code: 100 }],
'react/react-in-jsx-scope': 'off',
'eol-last': 'off',
},
'@typescript-eslint/consistent-type-imports': 'off',
}),
}
});
return this._eslint;
}
supports(codeLang) {
@ -171,9 +197,10 @@ class JSLintingService extends LintingService {
* @returns {Promise<LintResult>}
*/
async _lintSnippet(snippet) {
const eslint = await this._init();
if (this._knownBadSnippets.some(s => snippet.code.includes(s)))
return { status: 'ok' };
const results = await this.eslint.lintText(snippet.code);
const results = await eslint.lintText(snippet.code, { filePath: path.join(__dirname, 'file.tsx') });
if (!results || !results.length || !results[0].messages.length)
return { status: 'ok' };
const result = results[0];

View file

@ -94,6 +94,18 @@ Example:
console.log('\nUpdating browser version in browsers.json...');
for (const descriptor of descriptors)
descriptor.browserVersion = browserVersion;
// 4.1 chromium-headless-shell is equal to chromium version.
if (browserName === 'chromium') {
const headlessShellBrowser = await browsersJSON.browsers.find(b => b.name === 'chromium-headless-shell');
headlessShellBrowser.revision = revision;
headlessShellBrowser.browserVersion = browserVersion;
} else if (browserName === 'chromium-tip-of-tree') {
const tipOfTreeBrowser = await browsersJSON.browsers.find(b => b.name === 'chromium-tip-of-tree-headless-shell');
tipOfTreeBrowser.revision = revision;
tipOfTreeBrowser.browserVersion = browserVersion;
}
fs.writeFileSync(path.join(CORE_PATH, 'browsers.json'), JSON.stringify(browsersJSON, null, 2) + '\n');
}