Merge branch 'main' into refactor-all-pages

This commit is contained in:
Simon Knott 2025-02-07 14:56:37 +01:00
commit baac04f1af
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
63 changed files with 2028 additions and 1192 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,95 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fixupConfigRules } from '@eslint/compat';
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import notice from 'eslint-plugin-notice';
import path from 'path';
import { fileURLToPath } from 'url';
import stylistic from '@stylistic/eslint-plugin';
import { baseRules } from './eslint.config.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
const baseConfig = fixupConfigRules(compat.extends('plugin:react/recommended', 'plugin:react-hooks/recommended'));
const plugins = {
'@stylistic': stylistic,
'@typescript-eslint': typescriptEslint,
notice,
};
const ignores = [
'.github/',
'*.js',
'**/.cache/',
'**/*.d.ts',
'**/dist/**',
'index.d.ts',
'node_modules/',
'output/',
'packages/*/lib/',
'test-results/',
'tests/',
'utils/',
];
export default [
{ ignores },
{
plugins,
settings: {
react: { version: 'detect' },
}
},
...baseConfig,
packageSection('html-reporter'),
packageSection('recorder'),
packageSection('trace-viewer'),
];
function packageSection(packageName) {
return {
files: [
`packages/${packageName}/src/**/*.ts`,
`packages/${packageName}/src/**/*.tsx`,
`packages/web/src/**/*.ts`,
`packages/web/src/**/*.tsx`,
],
languageOptions: {
parser: tsParser,
ecmaVersion: 9,
sourceType: 'module',
parserOptions: {
project: path.join(__dirname, 'packages', packageName, 'tsconfig.json'),
},
},
rules: {
...baseRules,
'no-console': 2,
}
};
}

252
eslint.config.mjs Normal file
View file

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

1875
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -259,8 +259,8 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
});
}
async storageState(options: { path?: string } = {}): Promise<StorageState> {
const state = await this._channel.storageState();
async storageState(options: { path?: string, indexedDB?: boolean } = {}): Promise<StorageState> {
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) {
await mkdirIfNeeded(options.path);
await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8');
@ -415,8 +415,10 @@ function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined {
if (!map)
return undefined;
const result = [];
for (const [name, value] of Object.entries(map))
result.push({ name, value: String(value) });
for (const [name, value] of Object.entries(map)) {
if (value !== undefined)
result.push({ name, value: String(value) });
}
return result;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -56,6 +56,7 @@ type WorkerFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
_optionContextReuseMode: ContextReuseMode,
_optionConnectOptions: PlaywrightWorkerOptions['connectOptions'],
_reuseContext: boolean,
_pageSnapshot: PageSnapshotOption,
};
const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
@ -73,6 +74,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
screenshot: ['off', { scope: 'worker', option: true }],
video: ['off', { scope: 'worker', option: true }],
trace: ['off', { scope: 'worker', option: true }],
_pageSnapshot: ['off', { scope: 'worker', option: true }],
_browserOptions: [async ({ playwright, headless, channel, launchOptions }, use) => {
const options: LaunchOptions = {
@ -240,13 +242,13 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
playwright._defaultContextNavigationTimeout = undefined;
}, { auto: 'all-hooks-included', title: 'context configuration', box: true } as any],
_setupArtifacts: [async ({ playwright, screenshot }, use, testInfo) => {
_setupArtifacts: [async ({ playwright, screenshot, _pageSnapshot }, use, testInfo) => {
// This fixture has a separate zero-timeout slot to ensure that artifact collection
// happens even after some fixtures or hooks time out.
// Now that default test timeout is known, we can replace zero with an actual value.
testInfo.setTimeout(testInfo.project.timeout);
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot);
const artifactsRecorder = new ArtifactsRecorder(playwright, tracing().artifactsDir(), screenshot, _pageSnapshot);
await artifactsRecorder.willStartTest(testInfo as TestInfoImpl);
const tracingGroupSteps: TestStepInternal[] = [];
@ -444,6 +446,7 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
});
type ScreenshotOption = PlaywrightWorkerOptions['screenshot'] | undefined;
type PageSnapshotOption = 'off' | 'on' | 'only-on-failure';
function normalizeVideoMode(video: VideoMode | 'retry-with-video' | { mode: VideoMode } | undefined): VideoMode {
if (!video)
@ -515,41 +518,127 @@ function connectOptionsFromEnv() {
};
}
class ArtifactsRecorder {
private _testInfo!: TestInfoImpl;
private _playwright: PlaywrightImpl;
private _artifactsDir: string;
private _screenshotMode: ScreenshotMode;
private _screenshotOptions: { mode: ScreenshotMode } & Pick<playwrightLibrary.PageScreenshotOptions, 'fullPage' | 'omitBackground'> | undefined;
private _temporaryScreenshots: string[] = [];
private _temporaryArtifacts: string[] = [];
private _reusedContexts = new Set<BrowserContext>();
private _screenshotOrdinal = 0;
private _screenshottedSymbol: symbol;
private _startedCollectingArtifacts: symbol;
class SnapshotRecorder {
private _ordinal = 0;
private _temporary: string[] = [];
private _snapshottedSymbol = Symbol('snapshotted');
constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption) {
this._playwright = playwright;
this._artifactsDir = artifactsDir;
this._screenshotMode = normalizeScreenshotMode(screenshot);
this._screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot;
this._screenshottedSymbol = Symbol('screenshotted');
this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts');
constructor(
private _artifactsRecorder: ArtifactsRecorder,
private _mode: ScreenshotMode | PageSnapshotOption,
private _name: string,
private _contentType: string,
private _extension: string,
private _doSnapshot: (page: Page, path: string) => Promise<void>) {
}
fixOrdinal() {
// Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not
// overwrite previous screenshots.
this._ordinal = this.testInfo.attachments.filter(a => a.name === this._name).length;
}
private shouldCaptureUponFinish() {
return this._mode === 'on' ||
(this._mode === 'only-on-failure' && this.testInfo._isFailure()) ||
(this._mode === 'on-first-failure' && this.testInfo._isFailure() && this.testInfo.retry === 0);
}
async maybeCapture() {
if (!this.shouldCaptureUponFinish())
return;
await Promise.all(this._artifactsRecorder._playwright._allPages().map(page => this._snapshotPage(page, false)));
}
async persistTemporary() {
if (this.shouldCaptureUponFinish()) {
await Promise.all(this._temporary.map(async file => {
try {
const path = this._createAttachmentPath();
await fs.promises.rename(file, path);
this._attach(path);
} catch {
}
}));
}
}
async captureTemporary(context: BrowserContext) {
if (this._mode === 'on' || this._mode === 'only-on-failure' || (this._mode === 'on-first-failure' && this.testInfo.retry === 0))
await Promise.all(context.pages().map(page => this._snapshotPage(page, true)));
}
private _attach(screenshotPath: string) {
this.testInfo.attachments.push({ name: this._name, path: screenshotPath, contentType: this._contentType });
}
private _createAttachmentPath() {
const testFailed = this.testInfo._isFailure();
const index = this._ordinal + 1;
++this._ordinal;
const path = this.testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${index}${this._extension}`);
return path;
}
private _createTemporaryArtifact(...name: string[]) {
const file = path.join(this._artifactsDir, ...name);
this._temporaryArtifacts.push(file);
const file = path.join(this._artifactsRecorder._artifactsDir, ...name);
return file;
}
private async _snapshotPage(page: Page, temporary: boolean) {
if ((page as any)[this._snapshottedSymbol])
return;
(page as any)[this._snapshottedSymbol] = true;
try {
const path = temporary ? this._createTemporaryArtifact(createGuid() + this._extension) : this._createAttachmentPath();
await this._doSnapshot(page, path);
if (temporary)
this._temporary.push(path);
else
this._attach(path);
} catch {
// snapshot may fail, just ignore.
}
}
private get testInfo(): TestInfoImpl {
return this._artifactsRecorder._testInfo;
}
}
class ArtifactsRecorder {
_testInfo!: TestInfoImpl;
_playwright: PlaywrightImpl;
_artifactsDir: string;
private _reusedContexts = new Set<BrowserContext>();
private _startedCollectingArtifacts: symbol;
private _pageSnapshotRecorder: SnapshotRecorder;
private _screenshotRecorder: SnapshotRecorder;
constructor(playwright: PlaywrightImpl, artifactsDir: string, screenshot: ScreenshotOption, pageSnapshot: PageSnapshotOption) {
this._playwright = playwright;
this._artifactsDir = artifactsDir;
const screenshotOptions = typeof screenshot === 'string' ? undefined : screenshot;
this._startedCollectingArtifacts = Symbol('startedCollectingArtifacts');
this._screenshotRecorder = new SnapshotRecorder(this, normalizeScreenshotMode(screenshot), 'screenshot', 'image/png', '.png', async (page, path) => {
await page.screenshot({ ...screenshotOptions, timeout: 5000, path, caret: 'initial' });
});
this._pageSnapshotRecorder = new SnapshotRecorder(this, pageSnapshot, 'pageSnapshot', 'text/plain', '.ariasnapshot', async (page, path) => {
const ariaSnapshot = await page.locator('body').ariaSnapshot();
await fs.promises.writeFile(path, ariaSnapshot);
});
}
async willStartTest(testInfo: TestInfoImpl) {
this._testInfo = testInfo;
testInfo._onDidFinishTestFunction = () => this.didFinishTestFunction();
// Since beforeAll(s), test and afterAll(s) reuse the same TestInfo, make sure we do not
// overwrite previous screenshots.
this._screenshotOrdinal = testInfo.attachments.filter(a => a.name === 'screenshot').length;
this._screenshotRecorder.fixOrdinal();
this._pageSnapshotRecorder.fixOrdinal();
// Process existing contexts.
await Promise.all(this._playwright._allContexts().map(async context => {
@ -574,11 +663,9 @@ class ArtifactsRecorder {
if (this._reusedContexts.has(context))
return;
await this._stopTracing(context.tracing);
if (this._screenshotMode === 'on' || this._screenshotMode === 'only-on-failure' || (this._screenshotMode === 'on-first-failure' && this._testInfo.retry === 0)) {
// Capture screenshot for now. We'll know whether we have to preserve them
// after the test finishes.
await Promise.all(context.pages().map(page => this._screenshotPage(page, true)));
}
await this._screenshotRecorder.captureTemporary(context);
await this._pageSnapshotRecorder.captureTemporary(context);
}
async didCreateRequestContext(context: APIRequestContext) {
@ -591,21 +678,13 @@ class ArtifactsRecorder {
await this._stopTracing(tracing);
}
private _shouldCaptureScreenshotUponFinish() {
return this._screenshotMode === 'on' ||
(this._screenshotMode === 'only-on-failure' && this._testInfo._isFailure()) ||
(this._screenshotMode === 'on-first-failure' && this._testInfo._isFailure() && this._testInfo.retry === 0);
}
async didFinishTestFunction() {
if (this._shouldCaptureScreenshotUponFinish())
await this._screenshotOnTestFailure();
await this._screenshotRecorder.maybeCapture();
await this._pageSnapshotRecorder.maybeCapture();
}
async didFinishTest() {
const captureScreenshots = this._shouldCaptureScreenshotUponFinish();
if (captureScreenshots)
await this._screenshotOnTestFailure();
await this.didFinishTestFunction();
const leftoverContexts = this._playwright._allContexts().filter(context => !this._reusedContexts.has(context));
const leftoverApiRequests: APIRequestContext[] = Array.from((this._playwright.request as any)._contexts as Set<APIRequestContext>);
@ -618,51 +697,8 @@ class ArtifactsRecorder {
await this._stopTracing(tracing);
})));
// Attach temporary screenshots for contexts closed before collecting the test trace.
if (captureScreenshots) {
for (const file of this._temporaryScreenshots) {
try {
const screenshotPath = this._createScreenshotAttachmentPath();
await fs.promises.rename(file, screenshotPath);
this._attachScreenshot(screenshotPath);
} catch {
}
}
}
}
private _createScreenshotAttachmentPath() {
const testFailed = this._testInfo._isFailure();
const index = this._screenshotOrdinal + 1;
++this._screenshotOrdinal;
const screenshotPath = this._testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${index}.png`);
return screenshotPath;
}
private async _screenshotPage(page: Page, temporary: boolean) {
if ((page as any)[this._screenshottedSymbol])
return;
(page as any)[this._screenshottedSymbol] = true;
try {
const screenshotPath = temporary ? this._createTemporaryArtifact(createGuid() + '.png') : this._createScreenshotAttachmentPath();
// Pass caret=initial to avoid any evaluations that might slow down the screenshot
// and let the page modify itself from the problematic state it had at the moment of failure.
await page.screenshot({ ...this._screenshotOptions, timeout: 5000, path: screenshotPath, caret: 'initial' });
if (temporary)
this._temporaryScreenshots.push(screenshotPath);
else
this._attachScreenshot(screenshotPath);
} catch {
// Screenshot may fail, just ignore.
}
}
private _attachScreenshot(screenshotPath: string) {
this._testInfo.attachments.push({ name: 'screenshot', path: screenshotPath, contentType: 'image/png' });
}
private async _screenshotOnTestFailure() {
await Promise.all(this._playwright._allPages().map(page => this._screenshotPage(page, false)));
await this._screenshotRecorder.persistTemporary();
await this._pageSnapshotRecorder.persistTemporary();
}
private async _startTraceChunkOnContextCreation(tracing: Tracing) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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