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-%% ### option: APIRequestContext.storageState.path = %%-storagestate-option-path-%%
* since: v1.16 * 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. 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 ## async method: BrowserContext.storageState
* since: v1.8 * since: v1.8
* langs: csharp, java * 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-%% ### option: BrowserContext.storageState.path = %%-storagestate-option-path-%%
* since: v1.8 * 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 ## property: BrowserContext.tracing
* since: v1.12 * since: v1.12
- type: <[Tracing]> - 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", "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", "ct": "playwright test tests/components/test-all.spec.js --reporter=list",
"test": "playwright test --config=tests/library/playwright.config.ts", "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/", "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", "build-installer": "babel -s --extensions \".ts\" --out-dir packages/playwright-core/lib/utils/ packages/playwright-core/src/utils",
"doc": "node utils/doclint/cli.js", "doc": "node utils/doclint/cli.js",
@ -62,6 +62,10 @@
"@babel/plugin-transform-optional-chaining": "^7.23.4", "@babel/plugin-transform-optional-chaining": "^7.23.4",
"@babel/plugin-transform-typescript": "^7.23.6", "@babel/plugin-transform-typescript": "^7.23.6",
"@babel/preset-react": "^7.23.3", "@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/babel__core": "^7.20.2",
"@types/codemirror": "^5.60.7", "@types/codemirror": "^5.60.7",
"@types/formidable": "^2.0.4", "@types/formidable": "^2.0.4",
@ -71,9 +75,9 @@
"@types/react-dom": "^18.0.5", "@types/react-dom": "^18.0.5",
"@types/ws": "^8.5.3", "@types/ws": "^8.5.3",
"@types/xml2js": "^0.4.9", "@types/xml2js": "^0.4.9",
"@typescript-eslint/eslint-plugin": "^7.15.0", "@typescript-eslint/eslint-plugin": "^8.23.0",
"@typescript-eslint/parser": "^7.15.0", "@typescript-eslint/parser": "^8.23.0",
"@typescript-eslint/utils": "^7.15.0", "@typescript-eslint/utils": "^8.23.0",
"@vitejs/plugin-basic-ssl": "^1.1.0", "@vitejs/plugin-basic-ssl": "^1.1.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"@zip.js/zip.js": "^2.7.29", "@zip.js/zip.js": "^2.7.29",
@ -86,10 +90,10 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"electron": "^30.1.2", "electron": "^30.1.2",
"esbuild": "^0.18.11", "esbuild": "^0.18.11",
"eslint": "^8.55.0", "eslint": "^9.19.0",
"eslint-plugin-notice": "^0.9.10", "eslint-plugin-notice": "^1.0.0",
"eslint-plugin-react": "^7.35.0", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^5.1.0",
"formidable": "^2.1.1", "formidable": "^2.1.1",
"immutable": "^4.3.7", "immutable": "^4.3.7",
"license-checker": "^25.0.1", "license-checker": "^25.0.1",
@ -98,7 +102,7 @@
"react": "^18.1.0", "react": "^18.1.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",
"ssim.js": "^3.5.0", "ssim.js": "^3.5.0",
"typescript": "^5.7.2", "typescript": "^5.7.3",
"vite": "^5.4.14", "vite": "^5.4.14",
"ws": "^8.17.1", "ws": "^8.17.1",
"xml2js": "^0.5.0", "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='hbox m-2 mt-1'>
<div className='mr-1'>{author}</div> <div className='mr-1'>{author}</div>
<div title={longTimestamp}> on {shortTimestamp}</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>
</div> </div>
{!!info['revision.link'] && <a href={info['revision.link']} target='_blank' rel='noopener noreferrer'> {!!info['revision.link'] && <a href={info['revision.link']} target='_blank' rel='noopener noreferrer'>

View file

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

View file

@ -59,7 +59,7 @@ const testCase: TestCase = {
], ],
tags: [], tags: [],
outcome: 'expected', outcome: 'expected',
duration: 10, duration: 200,
ok: true, ok: true,
results: [result] results: [result]
}; };
@ -215,3 +215,37 @@ test('should correctly render prev and next', async ({ mount }) => {
- text: "My test test.spec.ts:42 10ms" - 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 && <TabbedPane tabs={
test.results.map((result, index) => ({ test.results.map((result, index) => ({
id: String(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} /> render: () => <TestResultView test={test!} result={result} />
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />} })) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
</div>; </div>;

View file

@ -20,11 +20,12 @@
"@protocol/*": ["../protocol/src/*"], "@protocol/*": ["../protocol/src/*"],
"@web/*": ["../web/src/*"], "@web/*": ["../web/src/*"],
"@playwright/*": ["../playwright/src/*"], "@playwright/*": ["../playwright/src/*"],
"@recorder/*": ["../recorder/src/*"],
"@testIsomorphic/*": ["../playwright/src/isomorphic/*"], "@testIsomorphic/*": ["../playwright/src/isomorphic/*"],
"playwright-core/lib/*": ["../playwright-core/src/*"], "playwright-core/lib/*": ["../playwright-core/src/*"],
"playwright/lib/*": ["../playwright/src/*"], "playwright/lib/*": ["../playwright/src/*"],
} }
}, },
"include": ["src"], "include": ["src", "../web/src"],
"references": [{ "path": "./tsconfig.node.json" }] "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, "installByDefault": true,
"browserVersion": "133.0.6943.35" "browserVersion": "133.0.6943.35"
}, },
{
"name": "chromium-headless-shell",
"revision": "1157",
"installByDefault": true,
"browserVersion": "133.0.6943.35"
},
{ {
"name": "chromium-tip-of-tree", "name": "chromium-tip-of-tree",
"revision": "1300", "revision": "1300",
"installByDefault": false, "installByDefault": false,
"browserVersion": "134.0.6998.0" "browserVersion": "134.0.6998.0"
}, },
{
"name": "chromium-tip-of-tree-headless-shell",
"revision": "1300",
"installByDefault": false,
"browserVersion": "134.0.6998.0"
},
{ {
"name": "firefox", "name": "firefox",
"revision": "1474", "revision": "1474",

View file

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

View file

@ -80,7 +80,7 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
} }
async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> { 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 contextOptions = await prepareBrowserContextParams(options, this._browserType);
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions); const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
const context = BrowserContext.from(response.context); 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> { async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState(); const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) { if (options.path) {
await mkdirIfNeeded(options.path); await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); 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 { Browser } from './browser';
import { BrowserContext, prepareBrowserContextParams } from './browserContext'; import { BrowserContext, prepareBrowserContextParams } from './browserContext';
import { ChannelOwner } from './channelOwner'; 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 { Connection } from './connection';
import { Events } from './events'; import { Events } from './events';
import type { ChildProcess } from 'child_process'; import type { ChildProcess } from 'child_process';
@ -45,12 +45,6 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
_contexts = new Set<BrowserContext>(); _contexts = new Set<BrowserContext>();
_playwright!: Playwright; _playwright!: Playwright;
// Instrumentation.
_defaultContextOptions?: BrowserContextOptions;
_defaultContextTimeout?: number;
_defaultContextNavigationTimeout?: number;
private _defaultLaunchOptions?: LaunchOptions;
static from(browserType: channels.BrowserTypeChannel): BrowserType { static from(browserType: channels.BrowserTypeChannel): BrowserType {
return (browserType as any)._object; 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).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.'); assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
const logger = options.logger || this._defaultLaunchOptions?.logger; const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
options = { ...this._defaultLaunchOptions, ...options }; options = { ...this._playwright._defaultLaunchOptions, ...options };
const launchOptions: channels.BrowserTypeLaunchParams = { const launchOptions: channels.BrowserTypeLaunchParams = {
...options, ...options,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : undefined, 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> { async launchServer(options: LaunchServerOptions = {}): Promise<api.BrowserServer> {
if (!this._serverLauncher) if (!this._serverLauncher)
throw new Error('Launching server is not supported'); throw new Error('Launching server is not supported');
options = { ...this._defaultLaunchOptions, ...options }; options = { ...this._playwright._defaultLaunchOptions, ...options };
return await this._serverLauncher.launchServer(options); return await this._serverLauncher.launchServer(options);
} }
async launchPersistentContext(userDataDir: string, options: LaunchPersistentContextOptions = {}): Promise<BrowserContext> { 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.'); 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 contextParams = await prepareBrowserContextParams(options, this);
const persistentParams: channels.BrowserTypeLaunchPersistentContextParams = { const persistentParams: channels.BrowserTypeLaunchPersistentContextParams = {
...contextParams, ...contextParams,
@ -237,11 +231,10 @@ export class BrowserType extends ChannelOwner<channels.BrowserTypeChannel> imple
context._browserType = this; context._browserType = this;
this._contexts.add(context); this._contexts.add(context);
context._setOptions(contextOptions, browserOptions); context._setOptions(contextOptions, browserOptions);
if (this._defaultContextTimeout !== undefined) if (this._playwright._defaultContextTimeout !== undefined)
context.setDefaultTimeout(this._defaultContextTimeout); context.setDefaultTimeout(this._playwright._defaultContextTimeout);
if (this._defaultContextNavigationTimeout !== undefined) if (this._playwright._defaultContextNavigationTimeout !== undefined)
context.setDefaultNavigationTimeout(this._defaultContextNavigationTimeout); context.setDefaultNavigationTimeout(this._playwright._defaultContextNavigationTimeout);
await this._instrumentation.runAfterCreateBrowserContext(context); await this._instrumentation.runAfterCreateBrowserContext(context);
} }

View file

@ -25,7 +25,7 @@ import { assert, headersObjectToArray, isString } from '../utils';
import { mkdirIfNeeded } from '../utils/fileUtils'; import { mkdirIfNeeded } from '../utils/fileUtils';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import { RawHeaders } from './network'; 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 type { Playwright } from './playwright';
import { Tracing } from './tracing'; import { Tracing } from './tracing';
import { TargetClosedError, isTargetClosedError } from './errors'; import { TargetClosedError, isTargetClosedError } from './errors';
@ -47,7 +47,7 @@ export type FetchOptions = {
export type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'tracesDir'> & { export type NewContextOptions = Omit<channels.PlaywrightNewRequestOptions, 'extraHTTPHeaders' | 'clientCertificates' | 'storageState' | 'tracesDir'> & {
extraHTTPHeaders?: Headers, extraHTTPHeaders?: Headers,
storageState?: string | StorageState, storageState?: string | SetStorageState,
clientCertificates?: ClientCertificate[]; clientCertificates?: ClientCertificate[];
}; };
@ -57,9 +57,6 @@ export class APIRequest implements api.APIRequest {
private _playwright: Playwright; private _playwright: Playwright;
readonly _contexts = new Set<APIRequestContext>(); readonly _contexts = new Set<APIRequestContext>();
// Instrumentation.
_defaultContextOptions?: NewContextOptions & { tracesDir?: string };
constructor(playwright: Playwright) { constructor(playwright: Playwright) {
this._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> { 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' ? const storageState = typeof options.storageState === 'string' ?
JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) : JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) :
options.storageState; 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({ const context = APIRequestContext.from((await channel.newRequest({
...options, ...options,
extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined,
storageState, 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), clientCertificates: await toClientCertificatesProtocol(options.clientCertificates),
})).request); })).request);
this._contexts.add(context); this._contexts.add(context);
context._request = this; context._request = this;
context._tracing._tracesDir = tracesDir; context._tracing._tracesDir = this._playwright._defaultLaunchOptions?.tracesDir;
await context._instrumentation.runAfterCreateRequestContext(context); await context._instrumentation.runAfterCreateRequestContext(context);
return context; return context;
} }
@ -264,8 +263,8 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
}); });
} }
async storageState(options: { path?: string } = {}): Promise<StorageState> { async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState(); const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) { if (options.path) {
await mkdirIfNeeded(options.path); await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); 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) if (!map)
return undefined; return undefined;
const result = []; const result = [];
for (const [name, value] of Object.entries(map)) for (const [name, value] of Object.entries(map)) {
result.push({ name, value: String(value) }); if (value !== undefined)
result.push({ name, value: String(value) });
}
return result; return result;
} }

View file

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

View file

@ -234,7 +234,9 @@ scheme.APIRequestContextFetchLogParams = tObject({
scheme.APIRequestContextFetchLogResult = tObject({ scheme.APIRequestContextFetchLogResult = tObject({
log: tArray(tString), log: tArray(tString),
}); });
scheme.APIRequestContextStorageStateParams = tOptional(tObject({})); scheme.APIRequestContextStorageStateParams = tObject({
indexedDB: tOptional(tBoolean),
});
scheme.APIRequestContextStorageStateResult = tObject({ scheme.APIRequestContextStorageStateResult = tObject({
cookies: tArray(tType('NetworkCookie')), cookies: tArray(tType('NetworkCookie')),
origins: tArray(tType('OriginStorage')), origins: tArray(tType('OriginStorage')),
@ -1058,7 +1060,9 @@ scheme.BrowserContextSetOfflineParams = tObject({
offline: tBoolean, offline: tBoolean,
}); });
scheme.BrowserContextSetOfflineResult = tOptional(tObject({})); scheme.BrowserContextSetOfflineResult = tOptional(tObject({}));
scheme.BrowserContextStorageStateParams = tOptional(tObject({})); scheme.BrowserContextStorageStateParams = tObject({
indexedDB: tOptional(tBoolean),
});
scheme.BrowserContextStorageStateResult = tObject({ scheme.BrowserContextStorageStateResult = tObject({
cookies: tArray(tType('NetworkCookie')), cookies: tArray(tType('NetworkCookie')),
origins: tArray(tType('OriginStorage')), 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> { 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. // Bidi throws when x/y are not integers.
x = Math.round(x); x = Math.floor(x);
y = Math.round(y); y = Math.floor(y);
await this._performActions([{ type: 'pointerMove', x, 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); this._origins.add(origin);
} }
async storageState(): Promise<channels.BrowserContextStorageStateResult> { async storageState(indexedDB = true): Promise<channels.BrowserContextStorageStateResult> {
const result: channels.BrowserContextStorageStateResult = { const result: channels.BrowserContextStorageStateResult = {
cookies: await this.cookies(), cookies: await this.cookies(),
origins: [] origins: []
}; };
const originsToSave = new Set(this._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. // First try collecting storage stage from existing pages.
for (const page of this.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> { 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> { 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); this.adopt(tracing);
} }
async storageState(): Promise<channels.APIRequestContextStorageStateResult> { async storageState(params: channels.APIRequestContextStorageStateParams): Promise<channels.APIRequestContextStorageStateResult> {
return this._object.storageState(); return this._object.storageState(params.indexedDB);
} }
async dispose(params: channels.APIRequestContextDisposeParams, metadata: CallMetadata): Promise<void> { 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 _defaultOptions(): FetchRequestOptions;
abstract _addCookies(cookies: channels.NetworkCookie[]): Promise<void>; abstract _addCookies(cookies: channels.NetworkCookie[]): Promise<void>;
abstract _cookies(url: URL): Promise<channels.NetworkCookie[]>; abstract _cookies(url: URL): Promise<channels.NetworkCookie[]>;
abstract storageState(): Promise<channels.APIRequestContextStorageStateResult>; abstract storageState(indexedDB?: boolean): Promise<channels.APIRequestContextStorageStateResult>;
private _storeResponseBody(body: Buffer): string { private _storeResponseBody(body: Buffer): string {
const uid = createGuid(); const uid = createGuid();
@ -618,8 +618,8 @@ export class BrowserContextAPIRequestContext extends APIRequestContext {
return await this._context.cookies(url.toString()); return await this._context.cookies(url.toString());
} }
override async storageState(): Promise<channels.APIRequestContextStorageStateResult> { override async storageState(indexedDB?: boolean): Promise<channels.APIRequestContextStorageStateResult> {
return this._context.storageState(); return this._context.storageState(indexedDB);
} }
} }
@ -684,10 +684,10 @@ export class GlobalAPIRequestContext extends APIRequestContext {
return this._cookieStore.cookies(url); return this._cookieStore.cookies(url);
} }
override async storageState(): Promise<channels.APIRequestContextStorageStateResult> { override async storageState(indexedDB = true): Promise<channels.APIRequestContextStorageStateResult> {
return { return {
cookies: this._cookieStore.allCookies(), 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[] { function readDescriptors(browsersJSON: BrowsersJSON): BrowsersJSONDescriptor[] {
const headlessShells: BrowsersJSON['browsers'] = []; return (browsersJSON['browsers']).map(obj => {
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 => {
const name = obj.name; const name = obj.name;
const revisionOverride = (obj.revisionOverrides || {})[hostPlatform]; const revisionOverride = (obj.revisionOverrides || {})[hostPlatform];
const revision = revisionOverride || obj.revision; 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 type Storage = Omit<channels.OriginStorage, 'origin'>;
export async function collect(serializers: ReturnType<typeof source>, isFirefox: boolean): Promise<Storage> { export async function collect(serializers: ReturnType<typeof source>, isFirefox: boolean, recordIndexedDB: boolean): Promise<Storage> {
const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => { async function collectDB(dbInfo: IDBDatabaseInfo) {
if (!dbInfo.name) if (!dbInfo.name)
throw new Error('Database name is empty'); throw new Error('Database name is empty');
if (!dbInfo.version) if (!dbInfo.version)
@ -119,13 +119,13 @@ export async function collect(serializers: ReturnType<typeof source>, isFirefox:
version: dbInfo.version, version: dbInfo.version,
stores, stores,
}; };
})).catch(e => { }
throw new Error('Unable to serialize IndexedDB: ' + e.message);
});
return { return {
localStorage: Object.keys(localStorage).map(name => ({ name, value: localStorage.getItem(name)! })), 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, wallTime: 0,
monotonicTime: 0, monotonicTime: 0,
sdkLanguage: context.attribution.playwright.options.sdkLanguage, sdkLanguage: context.attribution.playwright.options.sdkLanguage,
testIdAttributeName testIdAttributeName,
contextId: context.guid,
}; };
if (context instanceof BrowserContext) { if (context instanceof BrowserContext) {
this._snapshotter = new Snapshotter(context, this); this._snapshotter = new Snapshotter(context, this);

View file

@ -72,7 +72,7 @@ export class InMemorySnapshotter implements SnapshotterDelegate, HarTracerDelega
} }
onEntryFinished(entry: har.Entry) { onEntryFinished(entry: har.Entry) {
this._storage.addResource(entry); this._storage.addResource('', entry);
} }
onContentBlob(sha1: string, buffer: Buffer) { onContentBlob(sha1: string, buffer: Buffer) {
@ -85,7 +85,7 @@ export class InMemorySnapshotter implements SnapshotterDelegate, HarTracerDelega
onFrameSnapshot(snapshot: FrameSnapshot): void { onFrameSnapshot(snapshot: FrameSnapshot): void {
++this._snapshotCount; ++this._snapshotCount;
const renderer = this._storage.addFrameSnapshot(snapshot, []); const renderer = this._storage.addFrameSnapshot('', snapshot, []);
this._snapshotReadyPromises.get(snapshot.snapshotName || '')?.resolve(renderer); 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 * Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB
* snapshot. * snapshot.
*
* **NOTE** IndexedDBs with typed arrays are currently not supported.
*
* @param options * @param options
*/ */
storageState(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 * 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 * [`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 * @param options
*/ */
storageState(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 * 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 * [`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 { rootTestType } from './common/testType';
import type { ContextReuseMode } from './common/config'; import type { ContextReuseMode } from './common/config';
import type { ApiCallData, ClientInstrumentation, ClientInstrumentationListener } from '../../playwright-core/src/client/clientInstrumentation'; 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 { 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 { currentTestInfo } from './common/globals';
import type { Playwright as PlaywrightImpl } from 'playwright-core/lib/client/playwright';
export { expect } from './matchers/expect'; export { expect } from './matchers/expect';
export const _baseTest: TestType<{}, {}> = rootTestType.test; export const _baseTest: TestType<{}, {}> = rootTestType.test;
@ -53,11 +52,13 @@ type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
}; };
type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
playwright: PlaywrightImpl;
_browserOptions: LaunchOptions; _browserOptions: LaunchOptions;
_optionContextReuseMode: ContextReuseMode, _optionContextReuseMode: ContextReuseMode,
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'], _optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
_reuseContext: boolean, _reuseContext: boolean,
_mockingProxy?: void, _mockingProxy?: void,
_pageSnapshot: PageSnapshotOption,
}; };
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
@ -76,23 +77,22 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
video: ['off', { scope: 'worker', option: true }], video: ['off', { scope: 'worker', option: true }],
trace: ['off', { scope: 'worker', option: true }], trace: ['off', { scope: 'worker', option: true }],
mockingProxy: [undefined, { scope: 'worker', option: true }], mockingProxy: [undefined, { scope: 'worker', option: true }],
_pageSnapshot: ['off', { scope: 'worker', option: true }],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => { _browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
const options: LaunchOptions = { const options: LaunchOptions = {
handleSIGINT: false, handleSIGINT: false,
...launchOptions, ...launchOptions,
tracesDir: tracing().tracesDir(),
}; };
if (headless !== undefined) if (headless !== undefined)
options.headless = headless; options.headless = headless;
if (channel !== undefined) if (channel !== undefined)
options.channel = channel; options.channel = channel;
options.tracesDir = tracing().tracesDir();
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit, playwright._bidiChromium, playwright._bidiFirefox]) playwright._defaultLaunchOptions = options;
(browserType as any)._defaultLaunchOptions = options;
await use(options); await use(options);
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit, playwright._bidiChromium, playwright._bidiFirefox]) playwright._defaultLaunchOptions = undefined;
(browserType as any)._defaultLaunchOptions = undefined;
}, { scope: 'worker', auto: true, box: true }], }, { scope: 'worker', auto: true, box: true }],
browser: [async ({ playwright, browserName, _browserOptions, connectOptions, _reuseContext }, use, testInfo) => { browser: [async ({ playwright, browserName, _browserOptions, connectOptions, _reuseContext }, use, testInfo) => {
@ -241,30 +241,23 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
testInfo.snapshotSuffix = process.platform; testInfo.snapshotSuffix = process.platform;
if (debugMode()) if (debugMode())
(testInfo as TestInfoImpl)._setDebugMode(); (testInfo as TestInfoImpl)._setDebugMode();
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) {
(browserType as any)._defaultContextOptions = _combinedContextOptions; playwright._defaultContextOptions = _combinedContextOptions;
(browserType as any)._defaultContextTimeout = actionTimeout || 0; playwright._defaultContextTimeout = actionTimeout || 0;
(browserType as any)._defaultContextNavigationTimeout = navigationTimeout || 0; playwright._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;
await use(); await use();
(playwright.request as any)._defaultContextOptions = undefined; playwright._defaultContextOptions = undefined;
for (const browserType of [playwright.chromium, playwright.firefox, playwright.webkit]) { playwright._defaultContextTimeout = undefined;
(browserType as any)._defaultContextOptions = undefined; playwright._defaultContextNavigationTimeout = undefined;
(browserType as any)._defaultContextTimeout = undefined;
(browserType as any)._defaultContextNavigationTimeout = undefined;
}
}, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any], }, { 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 // This fixture has a separate zero-timeout slot to ensure that artifact collection
// happens even after some fixtures or hooks time out. // happens even after some fixtures or hooks time out.
// Now that default test timeout is known, we can replace zero with an actual value. // Now that default test timeout is known, we can replace zero with an actual value.
testInfo.setTimeout(testInfo.project.timeout); 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); await artifactsRecorder.willStartTest(testInfo as TestInfoImpl);
const tracingGroupSteps: TestStepInternal[] = []; const tracingGroupSteps: TestStepInternal[] = [];
@ -462,7 +455,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
}); });
type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined; 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 { function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode {
if (!video) if (!video)
@ -534,54 +527,135 @@ function connectOptionsFromEnv() {
}; };
} }
class ArtifactsRecorder { class SnapshotRecorder {
private _testInfo!: TestInfoImpl; private _ordinal = 0;
private _playwright: Playwright; private _temporary: string[] = [];
private _artifactsDir: string; private _snapshottedSymbol = Symbol('snapshotted');
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;
constructor(playwright: Playwright, artifactsDir: string, screenshot: ScreenshotOption) { constructor(
this._playwright = playwright; private _artifactsRecorder: ArtifactsRecorder,
this._artifactsDir = artifactsDir; private _mode: ScreenshotMode | PageSnapshotOption,
this._screenshotMode = normalizeScreenshotMode(screenshot); private _name: string,
this._screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot; private _contentType: string,
this._screenshottedSymbol = Symbol('screenshotted'); private _extension: string,
this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts'); 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[]) { private _createTemporaryArtifact(...name: string[]) {
const file = path.join(this._artifactsDir, ...name); const file = path.join(this._artifactsRecorder._artifactsDir, ...name);
this._temporaryArtifacts.push(file);
return file; 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) { async willStartTest(testInfo: TestInfoImpl) {
this._testInfo = testInfo; this._testInfo = testInfo;
testInfo._onDidFinishTestFunction = () => this.didFinishTestFunction(); testInfo._onDidFinishTestFunction = () => this.didFinishTestFunction();
// Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not this._screenshotRecorder.fixOrdinal();
// overwrite previous screenshots. this._pageSnapshotRecorder.fixOrdinal();
this._screenshotOrdinal = testInfo.attachments.filter(a => a.name === 'screenshot').length;
// Process existing contexts. // Process existing contexts.
for (const browserType of [this._playwright.chromium, this._playwright.firefox, this._playwright.webkit]) { await Promise.all(this._playwright._allContexts().map(async context => {
const promises: (Promise<void> | undefined)[] = []; if ((context as any)[kIsReusedContext])
const existingContexts = Array.from((browserType as any)._contexts) as BrowserContext[]; this._reusedContexts.add(context);
for (const context of existingContexts) { else
if ((context as any)[kIsReusedContext]) await this.didCreateBrowserContext(context);
this._reusedContexts.add(context); }));
else
promises.push(this.didCreateBrowserContext(context));
}
await Promise.all(promises);
}
{ {
const existingApiRequests: APIRequestContext[] = Array.from((this._playwright.request as any)._contexts as Set<APIRequestContext>); const existingApiRequests: APIRequestContext[] = Array.from((this._playwright.request as any)._contexts as Set<APIRequestContext>);
await Promise.all(existingApiRequests.map(c => this.didCreateRequestContext(c))); await Promise.all(existingApiRequests.map(c => this.didCreateRequestContext(c)));
@ -598,11 +672,9 @@ class ArtifactsRecorder {
if (this._reusedContexts.has(context)) if (this._reusedContexts.has(context))
return; return;
await this._stopTracing(context.tracing); 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 await this._screenshotRecorder.captureTemporary(context);
// after the test finishes. await this._pageSnapshotRecorder.captureTemporary(context);
await Promise.all(context.pages().map(page => this._screenshotPage(page, true)));
}
} }
async didCreateRequestContext(context: APIRequestContext) { async didCreateRequestContext(context: APIRequestContext) {
@ -615,26 +687,15 @@ class ArtifactsRecorder {
await this._stopTracing(tracing); 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() { async didFinishTestFunction() {
if (this._shouldCaptureScreenshotUponFinish()) await this._screenshotRecorder.maybeCapture();
await this._screenshotOnTestFailure(); await this._pageSnapshotRecorder.maybeCapture();
} }
async didFinishTest() { async didFinishTest() {
const captureScreenshots = this._shouldCaptureScreenshotUponFinish(); await this.didFinishTestFunction();
if (captureScreenshots)
await this._screenshotOnTestFailure();
let leftoverContexts: BrowserContext[] = []; const leftoverContexts = this._playwright._allContexts().filter(context => !this._reusedContexts.has(context));
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 leftoverApiRequests: APIRequestContext[] = Array.from((this._playwright.request as any)._contexts as Set<APIRequestContext>); const leftoverApiRequests: APIRequestContext[] = Array.from((this._playwright.request as any)._contexts as Set<APIRequestContext>);
// Collect traces/screenshots for remaining contexts. // Collect traces/screenshots for remaining contexts.
@ -645,55 +706,8 @@ class ArtifactsRecorder {
await this._stopTracing(tracing); await this._stopTracing(tracing);
}))); })));
// Attach temporary screenshots for contexts closed before collecting the test trace. await this._screenshotRecorder.persistTemporary();
if (captureScreenshots) { await this._pageSnapshotRecorder.persistTemporary();
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)));
} }
private async _startTraceChunkOnContextCreation(tracing: Tracing) { private async _startTraceChunkOnContextCreation(tracing: Tracing) {

View file

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

View file

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

View file

@ -33,13 +33,9 @@ export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerP
setup: async (config: FullConfig, configDir: string) => { setup: async (config: FullConfig, configDir: string) => {
const fromEnv = linksFromEnv(); const fromEnv = linksFromEnv();
const fromCLI = await gitStatusFromCLI(options?.directory || configDir); const fromCLI = await gitStatusFromCLI(options?.directory || configDir, fromEnv);
const info = { ...fromEnv, ...fromCLI };
if (info['revision.timestamp'] instanceof Date)
info['revision.timestamp'] = info['revision.timestamp'].getTime();
config.metadata = config.metadata || {}; 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; directory?: string;
} }
function linksFromEnv(): Pick<GitCommitInfo, 'revision.link' | 'ci.link'> { function linksFromEnv() {
const out: { 'revision.link'?: string; 'ci.link'?: string; } = {}; const out: Partial<GitCommitInfo> = {};
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables // Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
if (process.env.BUILD_URL) if (process.env.BUILD_URL)
out['ci.link'] = 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}`; 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) 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}`; 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; 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 separator = `:${createGuid().slice(0, 4)}:`;
const { code, stdout } = await spawnAsync( const commitInfoResult = await spawnAsync(
'git', 'git',
['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'], ['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
); );
if (code) if (commitInfoResult.code)
return; return;
const showOutput = stdout.trim(); const showOutput = commitInfoResult.stdout.trim();
const [id, subject, author, email, rawTimestamp] = showOutput.split(separator); const [id, subject, author, email, rawTimestamp] = showOutput.split(separator);
let timestamp: number = Number.parseInt(rawTimestamp, 10); let timestamp: number = Number.parseInt(rawTimestamp, 10);
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0; timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
return { const result: GitCommitInfo = {
'revision.id': id, 'revision.id': id,
'revision.author': author, 'revision.author': author,
'revision.email': email, 'revision.email': email,
'revision.subject': subject, 'revision.subject': subject,
'revision.timestamp': timestamp, '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 } : {}), ...(params.headed !== undefined ? { headless: !params.headed } : {}),
_optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined, _optionContextReuseMode: params.reuseContext ? 'when-possible' : undefined,
_optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined, _optionConnectOptions: params.connectWsEndpoint ? { wsEndpoint: params.connectWsEndpoint } : undefined,
_pageSnapshot: params.pageSnapshot,
}, },
...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}), ...(params.updateSnapshots ? { updateSnapshots: params.updateSnapshots } : {}),
...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}), ...(params.updateSourceMethod ? { updateSourceMethod: params.updateSourceMethod } : {}),

View file

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

View file

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

View file

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

View file

@ -120,14 +120,16 @@ async function doFetch(event: FetchEvent): Promise<Response> {
const { snapshotServer } = loadedTraces.get(traceUrl!) || {}; const { snapshotServer } = loadedTraces.get(traceUrl!) || {};
if (!snapshotServer) if (!snapshotServer)
return new Response(null, { status: 404 }); 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/')) { if (relativePath.startsWith('/snapshot/')) {
const { snapshotServer } = loadedTraces.get(traceUrl!) || {}; const { snapshotServer } = loadedTraces.get(traceUrl!) || {};
if (!snapshotServer) if (!snapshotServer)
return new Response(null, { status: 404 }); 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) if (isDeployedAsHttps)
response.headers.set('Content-Security-Policy', 'upgrade-insecure-requests'); response.headers.set('Content-Security-Policy', 'upgrade-insecure-requests');
return response; return response;
@ -137,7 +139,8 @@ async function doFetch(event: FetchEvent): Promise<Response> {
const { snapshotServer } = loadedTraces.get(traceUrl!) || {}; const { snapshotServer } = loadedTraces.get(traceUrl!) || {};
if (!snapshotServer) if (!snapshotServer)
return new Response(null, { status: 404 }); 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/')) { if (relativePath.startsWith('/sha1/')) {

View file

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

View file

@ -20,19 +20,19 @@ import type { PageEntry } from '../types/entries';
import { LRUCache } from './lruCache'; import { LRUCache } from './lruCache';
export class SnapshotStorage { export class SnapshotStorage {
private _resources: ResourceSnapshot[] = [];
private _frameSnapshots = new Map<string, { private _frameSnapshots = new Map<string, {
raw: FrameSnapshot[], raw: FrameSnapshot[],
renderers: SnapshotRenderer[] renderers: SnapshotRenderer[],
}>(); }>();
private _cache = new LRUCache<SnapshotRenderer, string>(100_000_000); // 100MB per each trace 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); 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) for (const override of snapshot.resourceOverrides)
override.url = rewriteURLForCustomProtocol(override.url); override.url = rewriteURLForCustomProtocol(override.url);
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId); let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
@ -46,7 +46,8 @@ export class SnapshotStorage {
this._frameSnapshots.set(snapshot.pageId, frameSnapshots); this._frameSnapshots.set(snapshot.pageId, frameSnapshots);
} }
frameSnapshots.raw.push(snapshot); 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); frameSnapshots.renderers.push(renderer);
return renderer; return renderer;
} }
@ -62,6 +63,16 @@ export class SnapshotStorage {
finalize() { finalize() {
// Resources are not necessarily sorted in the trace file, so sort them now. // 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.contextEntries.push(contextEntry);
} }
this._snapshotStorage!.finalize(); this._snapshotStorage.finalize();
} }
async hasEntry(filename: string): Promise<boolean> { async hasEntry(filename: string): Promise<boolean> {
@ -153,5 +153,6 @@ function createEmptyContext(): ContextEntry {
errors: [], errors: [],
stdio: [], stdio: [],
hasSource: false, hasSource: false,
contextId: '',
}; };
} }

View file

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

View file

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

View file

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

View file

@ -44,6 +44,7 @@ export type ContextCreatedTraceEvent = {
options: BrowserContextEventOptions, options: BrowserContextEventOptions,
sdkLanguage?: Language, sdkLanguage?: Language,
testIdAttributeName?: string, testIdAttributeName?: string,
contextId?: string,
}; };
export type ScreencastFrameTraceEvent = { 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", "name": "ct-react-vite",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",

View file

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

View file

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

View file

@ -15,7 +15,8 @@
*/ */
import { defineConfig, devices } from '@playwright/experimental-ct-vue'; import { defineConfig, devices } from '@playwright/experimental-ct-vue';
import { resolve } from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
export default defineConfig({ export default defineConfig({
testDir: 'tests', testDir: 'tests',
@ -27,7 +28,7 @@ export default defineConfig({
ctViteConfig: { ctViteConfig: {
resolve: { resolve: {
alias: { 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 = {}) { async _start(childProcess: CommonFixtures['childProcess'], browserType: BrowserType, channel: string, remoteServerOptions: RemoteServerOptions = {}) {
this._browserType = browserType; 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. // Copy options to prevent a large JSON string when launching subprocess.
// Otherwise, we get `Error: spawn ENAMETOOLONG` on Windows. // Otherwise, we get `Error: spawn ENAMETOOLONG` on Windows.
const launchOptions: Parameters<BrowserType['launchServer']>[0] = { 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'); 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; let request;
const url = new URL(server.EMPTY_PAGE); const url = new URL(server.EMPTY_PAGE);
url.searchParams.set('str', 's'); url.searchParams.set('str', 's');
@ -1200,6 +1200,7 @@ it('should accept bool and numeric params', async ({ page, server }) => {
'num': 10, 'num': 10,
'bool': true, 'bool': true,
'bool2': false, 'bool2': false,
'none': undefined,
} }
}); });
const params = new URLSearchParams(request!.url.substr(request!.url.indexOf('?'))); 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('num')).toEqual('10');
expect(params.get('bool')).toEqual('true'); expect(params.get('bool')).toEqual('true');
expect(params.get('bool2')).toEqual('false'); expect(params.get('bool2')).toEqual('false');
expect(params.has('none')).toBe(false);
}); });
it('should abort requests when browser context closes', async ({ contextFactory, server }) => { 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> }>({ const test = browserTest.extend<{ reusedContext: () => Promise<BrowserContext> }>({
reusedContext: async ({ browserType, browser }, use) => { reusedContext: async ({ browserType, browser }, use) => {
await use(async () => { await use(async () => {
const defaultContextOptions = (browserType as any)._defaultContextOptions; const defaultContextOptions = (browserType as any)._playwright._defaultContextOptions;
const context = await (browser as any)._newContextForReuse(defaultContextOptions); const context = await (browser as any)._newContextForReuse(defaultContextOptions);
return context; return context;
}); });

View file

@ -90,15 +90,19 @@ it('should round-trip through the file', async ({ contextFactory }, testInfo) =>
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const openRequest = indexedDB.open('db', 42); const openRequest = indexedDB.open('db', 42);
openRequest.onupgradeneeded = () => { openRequest.onupgradeneeded = () => {
openRequest.result.createObjectStore('store'); openRequest.result.createObjectStore('store', { keyPath: 'name' });
openRequest.result.createObjectStore('store2');
}; };
openRequest.onsuccess = () => { openRequest.onsuccess = () => {
const request = openRequest.result.transaction('store', 'readwrite') const transaction = openRequest.result.transaction(['store', 'store2'], 'readwrite');
transaction
.objectStore('store') .objectStore('store')
.put({ name: 'foo', date: new Date(0) }, 'bar'); .put({ name: 'foo', date: new Date(0) });
request.addEventListener('success', resolve); transaction
request.addEventListener('error', reject); .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' }); expect(localStorage).toEqual({ name1: 'value1' });
const cookie = await page2.evaluate('document.cookie'); const cookie = await page2.evaluate('document.cookie');
expect(cookie).toEqual('username=John Doe'); 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); const openRequest = indexedDB.open('db', 42);
openRequest.addEventListener('success', () => { openRequest.addEventListener('success', () => {
const db = openRequest.result; const db = openRequest.result;
const transaction = db.transaction('store', 'readonly'); const transaction = db.transaction(['store', 'store2'], 'readonly');
const getRequest = transaction.objectStore('store').get('bar'); const request1 = transaction.objectStore('store').get('foo');
getRequest.addEventListener('success', () => resolve(getRequest.result)); const request2 = transaction.objectStore('store2').get('foo');
getRequest.addEventListener('error', () => reject(getRequest.error));
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)); 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(); await context2.close();
}); });
@ -436,4 +447,6 @@ it('should support IndexedDB', async ({ page, server, contextFactory }) => {
- listitem: - listitem:
- text: /Pet the cat/ - 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('browserType.executablePath should work', async ({ browserType, channel, mode }) => {
test.skip(!!channel, 'We skip browser download when testing a channel'); test.skip(!!channel, 'We skip browser download when testing a channel');
test.skip(mode.startsWith('service')); 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(); const executablePath = browserType.executablePath();
expect(fs.existsSync(executablePath)).toBe(true); 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> => { await use(async (wsEndpoint, options = {}, redirectPortForTest): Promise<Browser> => {
(options as any).__testHookRedirectPortForwarding = redirectPortForTest; (options as any).__testHookRedirectPortForwarding = redirectPortForTest;
options.headers = { 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, ...options.headers,
}; };
browser = await browserType.connect(wsEndpoint, options); 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('should ignore page.pause when headed', async ({ connect, startRemoteServer, browserType, channel }) => {
test.skip(channel === 'chromium-headless-shell', 'shell is never headed'); test.skip(channel === 'chromium-headless-shell', 'shell is never headed');
const headless = (browserType as any)._defaultLaunchOptions.headless; const headless = (browserType as any)._playwright._defaultLaunchOptions.headless;
(browserType as any)._defaultLaunchOptions.headless = false; (browserType as any)._playwright._defaultLaunchOptions.headless = false;
const remoteServer = await startRemoteServer(kind); const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint()); const browser = await connect(remoteServer.wsEndpoint());
const browserContext = await browser.newContext(); const browserContext = await browser.newContext();
@ -182,7 +182,7 @@ for (const kind of ['launchServer', 'run-server'] as const) {
// @ts-ignore // @ts-ignore
await page.pause({ __testHookKeepTestTimeout: true }); await page.pause({ __testHookKeepTestTimeout: true });
await browser.close(); 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 }) => { 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({ const browser = await browserType.connect({
wsEndpoint: remoteServer.wsEndpoint(), wsEndpoint: remoteServer.wsEndpoint(),
headers: { 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(); 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) => { test('should filter launch options', async ({ connect, startRemoteServer, server, browserType }, testInfo) => {
const tracesDir = testInfo.outputPath('traces'); const tracesDir = testInfo.outputPath('traces');
const oldTracesDir = (browserType as any)._defaultLaunchOptions.tracesDir; const oldTracesDir = (browserType as any)._playwright._defaultTracesDir;
(browserType as any)._defaultLaunchOptions.tracesDir = tracesDir; (browserType as any)._playwright._defaultTracesDir = tracesDir;
const remoteServer = await startRemoteServer(kind); const remoteServer = await startRemoteServer(kind);
const browser = await connect(remoteServer.wsEndpoint()); const browser = await connect(remoteServer.wsEndpoint());
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
await browser.close(); await browser.close();
(browserType as any)._defaultLaunchOptions.tracesDir = oldTracesDir; (browserType as any)._playwright._defaultTracesDir = oldTracesDir;
expect(fs.existsSync(tracesDir)).toBe(false); expect(fs.existsSync(tracesDir)).toBe(false);
}); });

View file

@ -51,7 +51,7 @@ const test = baseTest.extend<Fixtures>({
await use(async () => { await use(async () => {
const browser = await browserType.connect(wsEndpoint, { const browser = await browserType.connect(wsEndpoint, {
headers: { 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', 'x-playwright-reuse-context': '1',
}, },
}) as BrowserWithReuse; }) as BrowserWithReuse;

View file

@ -150,7 +150,7 @@ it('should have passed URL when launching with ignoreDefaultArgs: true', async (
it.skip(mode !== 'default'); it.skip(mode !== 'default');
const userDataDir = await createUserDataDir(); 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 = { const options = {
args: browserName === 'firefox' ? [...args, '-new-tab', server.EMPTY_PAGE] : [...args, server.EMPTY_PAGE], args: browserName === 'firefox' ? [...args, '-new-tab', server.EMPTY_PAGE] : [...args, server.EMPTY_PAGE],
ignoreDefaultArgs: true, 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 }) => { 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); const response = await request.get(server.EMPTY_PAGE);
expect(response.ok()).toBeTruthy(); expect(response.ok()).toBeTruthy();
await request.dispose(); 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) => { test('should survive browser.close with auto-created traces dir', async ({ browserType }, testInfo) => {
const oldTracesDir = (browserType as any)._defaultLaunchOptions.tracesDir; const oldTracesDir = (browserType as any)._playwright._defaultTracesDir;
(browserType as any)._defaultLaunchOptions.tracesDir = undefined; (browserType as any)._playwright._defaultTracesDir = undefined;
const browser = await browserType.launch(); const browser = await browserType.launch();
const page = await browser.newPage(); const page = await browser.newPage();
await page.context().tracing.start(); await page.context().tracing.start();
@ -394,7 +394,7 @@ test('should survive browser.close with auto-created traces dir', async ({ brows
]); ]);
done.value = true; 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 }) => { 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(result.failed).toBe(1);
expect(fs.existsSync(testInfo.outputPath('test-results', 'a-fails', 'test-failed-1.png'))).toBeTruthy(); 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(['init']);
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']); await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']);
await execGit(['config', '--local', 'user.name', 'William']); await execGit(['config', '--local', 'user.name', 'William']);
await execGit(['add', 'playwright.config.ts']);
await execGit(['commit', '-m', 'init']);
await execGit(['add', '*.ts']); await execGit(['add', '*.ts']);
await execGit(['commit', '-m', 'chore(html): make this test look nice']); 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_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SERVER_URL: 'https://playwright.dev',
GITHUB_SHA: 'example-sha', GITHUB_SHA: 'example-sha',
GITHUB_REF_NAME: '42/merge',
GITHUB_BASE_REF: 'HEAD~1',
}); });
await showReport(); await showReport();
@ -1231,7 +1235,8 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(` await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
- 'link "chore(html): make this test look nice"' - 'link "chore(html): make this test look nice"'
- text: /^William <shakespeare@example.local> on/ - text: /^William <shakespeare@example.local> on/
- link "logs" - link "Logs"
- link "Pull Request"
- link /^[a-f0-9]{7}$/ - link /^[a-f0-9]{7}$/
- text: 'foo: value1 bar: {"prop":"value2"} baz: ["value3",123]' - text: 'foo: value1 bar: {"prop":"value2"} baz: ["value3",123]'
`); `);

View file

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

View file

@ -94,6 +94,18 @@ Example:
console.log('\nUpdating browser version in browsers.json...'); console.log('\nUpdating browser version in browsers.json...');
for (const descriptor of descriptors) for (const descriptor of descriptors)
descriptor.browserVersion = browserVersion; 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'); fs.writeFileSync(path.join(CORE_PATH, 'browsers.json'), JSON.stringify(browsersJSON, null, 2) + '\n');
} }