Merge branch 'main' into sharding-algorithm

This commit is contained in:
Mathias Leppich 2024-09-10 08:43:12 +02:00
commit 4f758a1e9e
18 changed files with 618 additions and 376 deletions

View file

@ -26,8 +26,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
# TODO: add Firefox channel: [bidi-chromium, bidi-firefox-beta]
channel: [bidi-chrome-stable]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@ -38,5 +37,8 @@ jobs:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- run: npm run build - run: npm run build
- run: npx playwright install --with-deps chromium - run: npx playwright install --with-deps chromium
if: matrix.channel == 'bidi-chromium'
- run: npx -y @puppeteer/browsers install firefox@beta
if: matrix.channel == 'bidi-firefox-beta'
- name: Run tests - name: Run tests
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}* run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --project=${{ matrix.channel }}*

View file

@ -1,35 +0,0 @@
# class: TestInfoErrorMatcherResult
* since: v1.48
* langs: js
Matcher-specific details for the error thrown during the `expect` call.
## property: TestInfoErrorMatcherResult.actual
* since: v1.48
- type: ?<[unknown]>
Actual value.
## property: TestInfoErrorMatcherResult.expected
* since: v1.48
- type: ?<[unknown]>
Expected value.
## property: TestInfoErrorMatcherResult.name
* since: v1.48
- type: ?<[string]>
Matcher name.
## property: TestInfoErrorMatcherResult.pass
* since: v1.48
- type: <[string]>
Whether the matcher passed.
## property: TestInfoErrorMatcherResult.timeout
* since: v1.48
- type: ?<[int]>
Timeout that was used during matching.

View file

@ -4,12 +4,6 @@
Information about an error thrown during test execution. Information about an error thrown during test execution.
## property: TestInfoError.matcherResult
* since: v1.48
- type: ?<[TestInfoErrorMatcherResult]>
Matcher result details.
## property: TestInfoError.message ## property: TestInfoError.message
* since: v1.10 * since: v1.10
- type: ?<[string]> - type: ?<[string]>

View file

@ -1,35 +0,0 @@
# class: TestErrorMatcherResult
* since: v1.48
* langs: js
Matcher-specific details for the error thrown during the `expect` call.
## property: TestErrorMatcherResult.actual
* since: v1.48
- type: ?<[unknown]>
Actual value.
## property: TestErrorMatcherResult.expected
* since: v1.48
- type: ?<[unknown]>
Expected value.
## property: TestErrorMatcherResult.name
* since: v1.48
- type: ?<[string]>
Matcher name.
## property: TestErrorMatcherResult.pass
* since: v1.48
- type: <[string]>
Whether the matcher passed.
## property: TestErrorMatcherResult.timeout
* since: v1.48
- type: ?<[int]>
Timeout that was used during matching.

View file

@ -4,12 +4,6 @@
Information about an error thrown during test execution. Information about an error thrown during test execution.
## property: TestError.matcherResult
* since: v1.48
- type: ?<[TestErrorMatcherResult]>
Matcher result details.
## property: TestError.message ## property: TestError.message
* since: v1.10 * since: v1.10
- type: ?<[string]> - type: ?<[string]>

View file

@ -354,7 +354,7 @@ function readDescriptors(browsersJSON: BrowsersJSON) {
export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi'; export type BrowserName = 'chromium' | 'firefox' | 'webkit' | 'bidi';
type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'android'; type InternalTool = 'ffmpeg' | 'firefox-beta' | 'chromium-tip-of-tree' | 'android';
type BidiChannel = 'bidi-firefox-stable' | 'bidi-chrome-canary' | 'bidi-chrome-stable'; type BidiChannel = 'bidi-firefox-stable' | 'bidi-firefox-beta' | 'bidi-firefox-nightly' | 'bidi-chrome-canary' | 'bidi-chrome-stable' | 'bidi-chromium';
type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary'; type ChromiumChannel = 'chrome' | 'chrome-beta' | 'chrome-dev' | 'chrome-canary' | 'msedge' | 'msedge-beta' | 'msedge-dev' | 'msedge-canary';
const allDownloadable = ['chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree']; const allDownloadable = ['chromium', 'firefox', 'webkit', 'ffmpeg', 'firefox-beta', 'chromium-tip-of-tree'];
@ -525,11 +525,22 @@ export class Registry {
'win32': `\\Microsoft\\Edge SxS\\Application\\msedge.exe`, 'win32': `\\Microsoft\\Edge SxS\\Application\\msedge.exe`,
})); }));
this._executables.push(this._createBidiChannel('bidi-firefox-stable', { this._executables.push(this._createBidiFirefoxChannel('bidi-firefox-stable', {
'linux': '/usr/bin/firefox', 'linux': '/firefox/firefox',
'darwin': '/Applications/Firefox.app/Contents/MacOS/firefox', 'darwin': '/Firefox.app/Contents/MacOS/firefox',
'win32': '\\Mozilla Firefox\\firefox.exe', 'win32': '\\core\\firefox.exe',
})); }));
this._executables.push(this._createBidiFirefoxChannel('bidi-firefox-beta', {
'linux': '/firefox/firefox',
'darwin': '/Firefox.app/Contents/MacOS/firefox',
'win32': '\\core\\firefox.exe',
}));
this._executables.push(this._createBidiFirefoxChannel('bidi-firefox-nightly', {
'linux': '/firefox/firefox',
'darwin': '/Firefox Nightly.app/Contents/MacOS/firefox',
'win32': '\\firefox\\firefox.exe',
}));
this._executables.push(this._createBidiChannel('bidi-chrome-stable', { this._executables.push(this._createBidiChannel('bidi-chrome-stable', {
'linux': '/opt/google/chrome/chrome', 'linux': '/opt/google/chrome/chrome',
'darwin': '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 'darwin': '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
@ -540,6 +551,21 @@ export class Registry {
'darwin': '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', 'darwin': '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'win32': `\\Google\\Chrome SxS\\Application\\chrome.exe`, 'win32': `\\Google\\Chrome SxS\\Application\\chrome.exe`,
})); }));
this._executables.push({
type: 'browser',
name: 'bidi-chromium',
browserName: 'bidi',
directory: chromium.dir,
executablePath: () => chromiumExecutable,
executablePathOrDie: (sdkLanguage: string) => executablePathOrDie('chromium', chromiumExecutable, chromium.installByDefault, sdkLanguage),
installType: 'download-on-demand',
_validateHostRequirements: (sdkLanguage: string) => this._validateHostRequirements(sdkLanguage, 'chromium', chromium.dir, ['chrome-linux'], [], ['chrome-win']),
downloadURLs: this._downloadURLs(chromium),
browserVersion: chromium.browserVersion,
_install: () => this._downloadExecutable(chromium, chromiumExecutable),
_dependencyGroup: 'chromium',
_isHermeticInstallation: true,
});
const firefox = descriptors.find(d => d.name === 'firefox')!; const firefox = descriptors.find(d => d.name === 'firefox')!;
const firefoxExecutable = findExecutablePath(firefox.dir, 'firefox'); const firefoxExecutable = findExecutablePath(firefox.dir, 'firefox');
@ -691,6 +717,48 @@ export class Registry {
}; };
} }
private _createBidiFirefoxChannel(name: BidiChannel, lookAt: Record<'linux' | 'darwin' | 'win32', string>, install?: () => Promise<void>): ExecutableImpl {
const executablePath = (sdkLanguage: string, shouldThrow: boolean) => {
const suffix = lookAt[process.platform as 'linux' | 'darwin' | 'win32'];
if (!suffix) {
if (shouldThrow)
throw new Error(`Firefox distribution '${name}' is not supported on ${process.platform}`);
return undefined;
}
const folder = path.resolve('firefox');
let channelName = 'stable';
if (name.includes('beta'))
channelName = 'beta';
else if (name.includes('nightly'))
channelName = 'nightly';
const installedVersions = fs.readdirSync(folder);
const found = installedVersions.filter(e => e.includes(channelName));
if (found.length === 1)
return path.join(folder, found[0], suffix);
if (found.length > 1) {
if (shouldThrow)
throw new Error(`Multiple Firefox installations found for channel '${name}': ${found.join(', ')}`);
else
return undefined;
}
if (shouldThrow)
throw new Error(`Cannot find Firefox installation for channel '${name}' under ${folder}`);
return undefined;
};
return {
type: 'channel',
name,
browserName: 'bidi',
directory: undefined,
executablePath: (sdkLanguage: string) => executablePath(sdkLanguage, false),
executablePathOrDie: (sdkLanguage: string) => executablePath(sdkLanguage, true)!,
installType: 'none',
_validateHostRequirements: () => Promise.resolve(),
_isHermeticInstallation: true,
_install: install,
};
}
private _createBidiChannel(name: BidiChannel, lookAt: Record<'linux' | 'darwin' | 'win32', string>, install?: () => Promise<void>): ExecutableImpl { private _createBidiChannel(name: BidiChannel, lookAt: Record<'linux' | 'darwin' | 'win32', string>, install?: () => Promise<void>): ExecutableImpl {
const executablePath = (sdkLanguage: string, shouldThrow: boolean) => { const executablePath = (sdkLanguage: string, shouldThrow: boolean) => {
const suffix = lookAt[process.platform as 'linux' | 'darwin' | 'win32']; const suffix = lookAt[process.platform as 'linux' | 'darwin' | 'win32'];

View file

@ -32,7 +32,6 @@ type Annotation = {
type ErrorDetails = { type ErrorDetails = {
message: string; message: string;
location?: Location; location?: Location;
matcherResult?: TestError['matcherResult'];
}; };
type TestSummary = { type TestSummary = {
@ -400,7 +399,6 @@ export function formatResultFailure(test: TestCase, result: TestResult, initialI
errorDetails.push({ errorDetails.push({
message: indent(formattedError.message, initialIndent), message: indent(formattedError.message, initialIndent),
location: formattedError.location, location: formattedError.location,
matcherResult: formattedError.matcherResult
}); });
} }
return errorDetails; return errorDetails;
@ -491,7 +489,6 @@ export function formatError(error: TestError, highlightCode: boolean): ErrorDeta
return { return {
location, location,
message: tokens.join('\n'), message: tokens.join('\n'),
matcherResult: error.matcherResult
}; };
} }

View file

@ -53,9 +53,13 @@ export interface LoadedTsConfig {
} }
export function loadTsConfig(configPath: string): LoadedTsConfig[] { export function loadTsConfig(configPath: string): LoadedTsConfig[] {
const references: LoadedTsConfig[] = []; try {
const config = innerLoadTsConfig(configPath, references); const references: LoadedTsConfig[] = [];
return [config, ...references]; const config = innerLoadTsConfig(configPath, references);
return [config, ...references];
} catch (e) {
throw new Error(`Failed to load tsconfig file at ${configPath}:\n${e.message}`);
}
} }
function resolveConfigFile(baseConfigFile: string, referencedConfigFile: string) { function resolveConfigFile(baseConfigFile: string, referencedConfigFile: string) {

View file

@ -24,7 +24,7 @@ import type { LoadedTsConfig } from '../third_party/tsconfig-loader';
import { loadTsConfig } from '../third_party/tsconfig-loader'; import { loadTsConfig } from '../third_party/tsconfig-loader';
import Module from 'module'; import Module from 'module';
import type { BabelPlugin, BabelTransformFunction } from './babelBundle'; import type { BabelPlugin, BabelTransformFunction } from './babelBundle';
import { createFileMatcher, fileIsModule, resolveImportSpecifierExtension } from '../util'; import { createFileMatcher, fileIsModule, resolveImportSpecifierAfterMapping } from '../util';
import type { Matcher } from '../util'; import type { Matcher } from '../util';
import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules, installSourceMapSupport } from './compilationCache'; import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules, installSourceMapSupport } from './compilationCache';
@ -136,8 +136,13 @@ export function resolveHook(filename: string, specifier: string): string | undef
return; return;
if (isRelativeSpecifier(specifier)) if (isRelativeSpecifier(specifier))
return resolveImportSpecifierExtension(path.resolve(path.dirname(filename), specifier)); return resolveImportSpecifierAfterMapping(path.resolve(path.dirname(filename), specifier), false);
/**
* TypeScript discourages path-mapping into node_modules:
* https://www.typescriptlang.org/docs/handbook/modules/reference.html#paths-should-not-point-to-monorepo-packages-or-node_modules-packages
* However, if path-mapping doesn't yield a result, TypeScript falls back to the default resolution through node_modules.
*/
const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx'); const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx');
const tsconfigs = loadAndValidateTsconfigsForFile(filename); const tsconfigs = loadAndValidateTsconfigsForFile(filename);
for (const tsconfig of tsconfigs) { for (const tsconfig of tsconfigs) {
@ -179,7 +184,7 @@ export function resolveHook(filename: string, specifier: string): string | undef
if (value.includes('*')) if (value.includes('*'))
candidate = candidate.replace('*', matchedPartOfSpecifier); candidate = candidate.replace('*', matchedPartOfSpecifier);
candidate = path.resolve(tsconfig.pathsBase!, candidate); candidate = path.resolve(tsconfig.pathsBase!, candidate);
const existing = resolveImportSpecifierExtension(candidate); const existing = resolveImportSpecifierAfterMapping(candidate, true);
if (existing) { if (existing) {
longestPrefixLength = keyPrefix.length; longestPrefixLength = keyPrefix.length;
pathMatchedByLongestPrefix = existing; pathMatchedByLongestPrefix = existing;
@ -193,7 +198,7 @@ export function resolveHook(filename: string, specifier: string): string | undef
if (path.isAbsolute(specifier)) { if (path.isAbsolute(specifier)) {
// Handle absolute file paths like `import '/path/to/file'` // Handle absolute file paths like `import '/path/to/file'`
// Do not handle module imports like `import 'fs'` // Do not handle module imports like `import 'fs'`
return resolveImportSpecifierExtension(specifier); return resolveImportSpecifierAfterMapping(specifier, false);
} }
} }

View file

@ -63,20 +63,8 @@ export function filteredStackTrace(rawStack: RawStack): StackFrame[] {
} }
export function serializeError(error: Error | any): TestInfoError { export function serializeError(error: Error | any): TestInfoError {
if (error instanceof Error) { if (error instanceof Error)
const result: TestInfoError = filterStackTrace(error); return filterStackTrace(error);
if ('matcherResult' in error && error.matcherResult) {
const matcherResult = (error.matcherResult as TestInfoError['matcherResult'])!;
result.matcherResult = {
pass: matcherResult.pass,
name: matcherResult.name,
expected: matcherResult.expected,
actual: matcherResult.actual,
timeout: matcherResult.timeout,
};
}
return result;
}
return { return {
value: util.inspect(error) value: util.inspect(error)
}; };
@ -307,8 +295,23 @@ function folderIsModule(folder: string): boolean {
return require(packageJsonPath).type === 'module'; return require(packageJsonPath).type === 'module';
} }
// This follows the --moduleResolution=bundler strategy from tsc. const packageJsonMainFieldCache = new Map<string, string | undefined>();
// https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#moduleresolution-bundler
function getMainFieldFromPackageJson(packageJsonPath: string) {
if (!packageJsonMainFieldCache.has(packageJsonPath)) {
let mainField: string | undefined;
try {
mainField = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).main;
} catch {
}
packageJsonMainFieldCache.set(packageJsonPath, mainField);
}
return packageJsonMainFieldCache.get(packageJsonPath);
}
// This method performs "file extension subsitution" to find the ts, js or similar source file
// based on the import specifier, which might or might not have an extension. See TypeScript docs:
// https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution.
const kExtLookups = new Map([ const kExtLookups = new Map([
['.js', ['.jsx', '.ts', '.tsx']], ['.js', ['.jsx', '.ts', '.tsx']],
['.jsx', ['.tsx']], ['.jsx', ['.tsx']],
@ -316,7 +319,7 @@ const kExtLookups = new Map([
['.mjs', ['.mts']], ['.mjs', ['.mts']],
['', ['.js', '.ts', '.jsx', '.tsx', '.cjs', '.mjs', '.cts', '.mts']], ['', ['.js', '.ts', '.jsx', '.tsx', '.cjs', '.mjs', '.cts', '.mts']],
]); ]);
export function resolveImportSpecifierExtension(resolved: string): string | undefined { function resolveImportSpecifierExtension(resolved: string): string | undefined {
if (fileExists(resolved)) if (fileExists(resolved))
return resolved; return resolved;
@ -330,13 +333,45 @@ export function resolveImportSpecifierExtension(resolved: string): string | unde
} }
break; // Do not try '' when a more specific extension like '.jsx' matched. break; // Do not try '' when a more specific extension like '.jsx' matched.
} }
}
// This method resolves directory imports and performs "file extension subsitution".
// It is intended to be called after the path mapping resolution.
//
// Directory imports follow the --moduleResolution=bundler strategy from tsc.
// https://www.typescriptlang.org/docs/handbook/modules/reference.html#directory-modules-index-file-resolution
// https://www.typescriptlang.org/docs/handbook/modules/reference.html#bundler
//
// See also Node.js "folder as module" behavior:
// https://nodejs.org/dist/latest-v20.x/docs/api/modules.html#folders-as-modules.
export function resolveImportSpecifierAfterMapping(resolved: string, afterPathMapping: boolean): string | undefined {
const resolvedFile = resolveImportSpecifierExtension(resolved);
if (resolvedFile)
return resolvedFile;
if (dirExists(resolved)) { if (dirExists(resolved)) {
const packageJsonPath = path.join(resolved, 'package.json');
if (afterPathMapping) {
// Most notably, the module resolution algorithm is not performed after the path mapping.
// This means no node_modules lookup or package.json#exports.
//
// Only the "folder as module" Node.js behavior is respected:
// - consult `package.json#main`;
// - look for `index.js` or similar.
const mainField = getMainFieldFromPackageJson(packageJsonPath);
const mainFieldResolved = mainField ? resolveImportSpecifierExtension(path.resolve(resolved, mainField)) : undefined;
return mainFieldResolved || resolveImportSpecifierExtension(path.join(resolved, 'index'));
}
// If we import a package, let Node.js figure out the correct import based on package.json. // If we import a package, let Node.js figure out the correct import based on package.json.
if (fileExists(path.join(resolved, 'package.json'))) // This also covers the "main" field for "folder as module".
if (fileExists(packageJsonPath))
return resolved; return resolved;
// Otherwise, try to find a corresponding index file. // Implement the "folder as module" Node.js behavior.
// Note that we do not delegate to Node.js, because we support this for ESM as well,
// following the TypeScript "bundler" mode.
const dirImport = path.join(resolved, 'index'); const dirImport = path.join(resolved, 'index');
return resolveImportSpecifierExtension(dirImport); return resolveImportSpecifierExtension(dirImport);
} }

View file

@ -8238,45 +8238,10 @@ export interface TestInfo {
workerIndex: number; workerIndex: number;
} }
/**
* Matcher-specific details for the error thrown during the `expect` call.
*/
export interface TestInfoErrorMatcherResult {
/**
* Actual value.
*/
actual?: unknown;
/**
* Expected value.
*/
expected?: unknown;
/**
* Matcher name.
*/
name?: string;
/**
* Whether the matcher passed.
*/
pass: string;
/**
* Timeout that was used during matching.
*/
timeout?: number;
}
/** /**
* Information about an error thrown during test execution. * Information about an error thrown during test execution.
*/ */
export interface TestInfoError { export interface TestInfoError {
/**
* Matcher result details.
*/
matcherResult?: TestInfoErrorMatcherResult;
/** /**
* Error message. Set when [Error] (or its subclass) has been thrown. * Error message. Set when [Error] (or its subclass) has been thrown.
*/ */

View file

@ -284,7 +284,6 @@ export interface JSONReportTest {
export interface JSONReportError { export interface JSONReportError {
message: string; message: string;
location?: Location; location?: Location;
matcherResult?: TestErrorMatcherResult;
} }
export interface JSONReportTestResult { export interface JSONReportTestResult {
@ -568,36 +567,6 @@ export interface TestCase {
type: "test"; type: "test";
} }
/**
* Matcher-specific details for the error thrown during the `expect` call.
*/
export interface TestErrorMatcherResult {
/**
* Actual value.
*/
actual?: unknown;
/**
* Expected value.
*/
expected?: unknown;
/**
* Matcher name.
*/
name?: string;
/**
* Whether the matcher passed.
*/
pass: string;
/**
* Timeout that was used during matching.
*/
timeout?: number;
}
/** /**
* Information about an error thrown during test execution. * Information about an error thrown during test execution.
*/ */
@ -607,11 +576,6 @@ export interface TestError {
*/ */
location?: Location; location?: Location;
/**
* Matcher result details.
*/
matcherResult?: TestErrorMatcherResult;
/** /**
* Error message. Set when [Error] (or its subclass) has been thrown. * Error message. Set when [Error] (or its subclass) has been thrown.
*/ */

23
tests/bidi/README.md Normal file
View file

@ -0,0 +1,23 @@
## Running Bidi tests
To run Playwright tests with Bidi:
```sh
git clone https://github.com/microsoft/playwright.git
cd playwright
npm run build # call `npm run watch` for watch mode
npx playwright install chromium
npm run biditest -- --project='bidi-firefox-beta-*'
```
To install beta channel of Firefox, run the following command in the project root:
```sh
npx -y @puppeteer/browsers install firefox@beta
```
You can also pass custom binary path via `BIDIPATH`:
```sh
BIDIPATH='/Users/myself/Downloads/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
```

View file

@ -63,8 +63,8 @@ if (executablePath && !process.env.TEST_WORKER_INDEX)
console.error(`Using executable at ${executablePath}`); console.error(`Using executable at ${executablePath}`);
const testIgnore: RegExp[] = []; const testIgnore: RegExp[] = [];
const browserToChannels = { const browserToChannels = {
'_bidiChromium': ['bidi-chrome-stable'], '_bidiChromium': ['bidi-chromium', 'bidi-chrome-canary', 'bidi-chrome-stable'],
'_bidiFirefox': ['bidi-firefox-stable'], '_bidiFirefox': ['bidi-firefox-nightly', 'bidi-firefox-beta', 'bidi-firefox-stable'],
}; };
for (const [key, channels] of Object.entries(browserToChannels)) { for (const [key, channels] of Object.entries(browserToChannels)) {
const browserName: any = key; const browserName: any = key;

View file

@ -2040,12 +2040,15 @@ test('project filter in report name', async ({ runInlineTest }) => {
const reportDir = test.info().outputPath('blob-report'); const reportDir = test.info().outputPath('blob-report');
{ {
await runInlineTest(files, { shard: `2/2`, project: 'foo' }); const result = await runInlineTest(files, { shard: `2/2`, project: 'foo' });
expect(result.exitCode).toBe(0);
const reportFiles = await fs.promises.readdir(reportDir); const reportFiles = await fs.promises.readdir(reportDir);
expect(reportFiles.sort()).toEqual(['report-foo-2.zip']); expect(reportFiles.sort()).toEqual(['report-foo-2.zip']);
} }
{ {
await runInlineTest(files, { shard: `1/2`, project: 'foo,b*r', grep: 'smoke' }); const result = await runInlineTest(files, { shard: `1/2`, project: ['foo', 'b*r'], grep: 'smoke' });
expect(result.exitCode).toBe(0);
const reportFiles = await fs.promises.readdir(reportDir); const reportFiles = await fs.promises.readdir(reportDir);
expect(reportFiles.sort()).toEqual(['report-foo-b-r-6d9d49e-1.zip']); expect(reportFiles.sort()).toEqual(['report-foo-b-r-6d9d49e-1.zip']);
} }

View file

@ -1,145 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './playwright-test-fixtures';
test('should report matcherResults for generic matchers', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.js': `
import { test, expect as baseExpect } from '@playwright/test';
const expect = baseExpect.soft;
test('fail', ({}) => {
expect(1).toBe(2);
expect(1).toBeCloseTo(2);
expect(undefined).toBeDefined();
expect(1).toBeFalsy();
expect(1).toBeGreaterThan(2);
expect(1).toBeGreaterThanOrEqual(2);
expect('a').toBeInstanceOf(Number);
expect(2).toBeLessThan(1);
expect(2).toBeLessThanOrEqual(1);
expect(1).toBeNaN();
expect(1).toBeNull();
expect(0).toBeTruthy();
expect(1).toBeUndefined();
expect([1]).toContain(2);
expect([1]).toContainEqual(2);
expect([1]).toEqual([2]);
expect([1]).toHaveLength(2);
expect({ a: 1 }).toHaveProperty('b');
expect('a').toMatch(/b/);
expect({ a: 1 }).toMatchObject({ b: 2 });
expect({ a: 1 }).toStrictEqual({ b: 2 });
expect(() => {}).toThrow();
expect(() => {}).toThrowError('a');
});
`
}, { });
expect(result.exitCode).toBe(1);
const { errors } = result.report.suites[0].specs[0].tests[0].results[0];
const matcherResults = errors.map(e => e.matcherResult);
expect(matcherResults).toEqual([
{ name: 'toBe', pass: false, expected: 2, actual: 1 },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ name: 'toEqual', pass: false, expected: [2], actual: [1] },
{ pass: false },
{ pass: false },
{ pass: false },
{ pass: false },
{ name: 'toStrictEqual', pass: false, expected: { b: 2 }, actual: { a: 1 } },
{ pass: false },
{ pass: false },
]);
});
test('should report matcherResults for web matchers', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.js': `
import { test, expect as baseExpect } from '@playwright/test';
const expect = baseExpect.configure({ soft: true, timeout: 1 });
test('fail', async ({ page }) => {
await page.setContent('<span>Hello</span><div display="none">World</div><input type="checkbox"><textarea></textarea><button>Submit</button><select multiple><option value="value">Text</option></select>');
await expect(page.locator('input')).toBeChecked();
await expect(page.locator('input')).toBeDisabled();
await expect(page.locator('textarea')).not.toBeEditable();
await expect(page.locator('span')).toBeEmpty();
await expect(page.locator('button')).not.toBeEnabled();
await expect(page.locator('button')).toBeFocused();
await expect(page.locator('span')).toBeHidden();
await expect(page.locator('div')).not.toBeInViewport();
await expect(page.locator('div')).not.toBeVisible();
await expect(page.locator('span')).toContainText('World');
await expect(page.locator('span')).toHaveAccessibleDescription('World');
await expect(page.locator('span')).toHaveAccessibleName('World');
await expect(page.locator('span')).toHaveAttribute('name', 'value');
await expect(page.locator('span')).toHaveAttribute('name');
await expect(page.locator('span')).toHaveClass('name');
await expect(page.locator('span')).toHaveCount(2);
await expect(page.locator('span')).toHaveCSS('width', '10');
await expect(page.locator('span')).toHaveId('id');
await expect(page.locator('span')).toHaveJSProperty('name', 'value');
await expect(page.locator('span')).toHaveRole('role');
await expect(page.locator('span')).toHaveText('World');
await expect(page.locator('textarea')).toHaveValue('value');
await expect(page.locator('select')).toHaveValues(['value']);
});
`
}, { });
expect(result.exitCode).toBe(1);
const { errors } = result.report.suites[0].specs[0].tests[0].results[0];
const matcherResults = errors.map(e => e.matcherResult);
expect(matcherResults).toEqual([
{ name: 'toBeChecked', pass: false, expected: 'checked', actual: 'unchecked', timeout: 1 },
{ name: 'toBeDisabled', pass: false, expected: 'disabled', actual: 'enabled', timeout: 1 },
{ name: 'toBeEditable', pass: true, expected: 'editable', actual: 'editable', timeout: 1 },
{ name: 'toBeEmpty', pass: false, expected: 'empty', actual: 'notEmpty', timeout: 1 },
{ name: 'toBeEnabled', pass: true, expected: 'enabled', actual: 'enabled', timeout: 1 },
{ name: 'toBeFocused', pass: false, expected: 'focused', actual: 'inactive', timeout: 1 },
{ name: 'toBeHidden', pass: false, expected: 'hidden', actual: 'visible', timeout: 1 },
{ name: 'toBeInViewport', pass: true, expected: 'in viewport', actual: 'in viewport', timeout: 1 },
{ name: 'toBeVisible', pass: true, expected: 'visible', actual: 'visible', timeout: 1 },
{ name: 'toContainText', pass: false, expected: 'World', actual: 'Hello', timeout: 1 },
{ name: 'toHaveAccessibleDescription', pass: false, expected: 'World', actual: '', timeout: 1 },
{ name: 'toHaveAccessibleName', pass: false, expected: 'World', actual: '', timeout: 1 },
{ name: 'toHaveAttribute', pass: false, expected: 'value', actual: null, timeout: 1 },
{ name: 'toHaveAttribute', pass: false, expected: 'have attribute', actual: 'not have attribute', timeout: 1 },
{ name: 'toHaveClass', pass: false, expected: 'name', actual: '', timeout: 1 },
{ name: 'toHaveCount', pass: false, expected: 2, actual: 1, timeout: 1 },
{ name: 'toHaveCSS', pass: false, expected: '10', actual: 'auto', timeout: 1 },
{ name: 'toHaveId', pass: false, expected: 'id', actual: '', timeout: 1 },
{ name: 'toHaveJSProperty', pass: false, expected: 'value', timeout: 1 },
{ name: 'toHaveRole', pass: false, expected: 'role', actual: '', timeout: 1 },
{ name: 'toHaveText', pass: false, expected: 'World', actual: 'Hello', timeout: 1 },
{ name: 'toHaveValue', pass: false, expected: 'value', actual: '', timeout: 1 },
{ name: 'toHaveValues', pass: false, expected: ['value'], actual: [], timeout: 1 },
]);
});

View file

@ -16,6 +16,24 @@
import { test, expect } from './playwright-test-fixtures'; import { test, expect } from './playwright-test-fixtures';
test('should print tsconfig parsing error', async ({ runInlineTest }) => {
const files = {
'a.spec.ts': `
import { test } from '@playwright/test';
test('pass', async () => {});
`,
'tsconfig.json': `
"foo": "bar"
`,
};
const result = await runInlineTest(files);
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`Failed to load tsconfig file at`);
expect(result.output).toContain(`tsconfig.json`);
expect(result.output).toContain(`JSON5: invalid character ':' at 2:12`);
});
test('should respect path resolver', async ({ runInlineTest }) => { test('should respect path resolver', async ({ runInlineTest }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/11656' }); test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/11656' });
@ -569,43 +587,6 @@ test('should resolve paths relative to the originating config when extending and
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
}); });
test('should import packages with non-index main script through path resolver', async ({ runInlineTest }) => {
const result = await runInlineTest({
'app/pkg/main.ts': `
export const foo = 42;
`,
'app/pkg/package.json': `
{ "main": "main.ts" }
`,
'package.json': `
{ "name": "example-project" }
`,
'playwright.config.ts': `
export default {};
`,
'tsconfig.json': `{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"app/*": ["app/*"],
},
},
}`,
'example.spec.ts': `
import { foo } from 'app/pkg';
import { test, expect } from '@playwright/test';
test('test', ({}) => {
console.log('foo=' + foo);
});
`,
});
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(result.output).not.toContain(`find module`);
expect(result.output).toContain(`foo=42`);
});
test('should respect tsconfig project references', async ({ runInlineTest }) => { test('should respect tsconfig project references', async ({ runInlineTest }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29256' }); test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/29256' });
@ -693,3 +674,426 @@ test('should respect --tsconfig option', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.output).not.toContain(`Could not`); expect(result.output).not.toContain(`Could not`);
}); });
test.describe('directory imports', () => {
test('should resolve index.js without path mapping in CJS', async ({ runInlineTest, runTSC }) => {
const files = {
'foo-pkg/index.js': `
exports.foo = 'bar';
`,
'foo-pkg/index.d.ts': `
export const foo: 'bar';
`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
import { foo } from './foo-pkg';
test('pass', async () => {
const bar: 'bar' = foo;
expect(bar).toBe('bar');
});
`,
};
const result = await runInlineTest(files);
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should resolve index.js without path mapping in ESM', async ({ runInlineTest, runTSC }) => {
const files = {
'foo-pkg/index.js': `
export const foo = 'bar';
`,
'foo-pkg/index.d.ts': `
export const foo: 'bar';
`,
'package.json': `
{ "type": "module" }
`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
import { foo } from './foo-pkg';
test('pass', async () => {
const bar: 'bar' = foo;
expect(bar).toBe('bar');
});
`,
};
const result = await runInlineTest(files);
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should resolve index.js after path mapping in CJS', async ({ runInlineTest, runTSC }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31811' });
const files = {
'@acme/lib/index.js': `
exports.greet = () => 2;
`,
'@acme/lib/index.d.ts': `
export const greet: () => number;
`,
'tests/hello.test.ts': `
import { greet } from '@acme/lib';
import { test, expect } from '@playwright/test';
test('hello', async ({}) => {
const foo: number = greet();
expect(foo).toBe(2);
});
`,
'tsconfig.json': `
{
"compilerOptions": {
"paths": {
"@acme/*": ["./@acme/*"]
},
"moduleResolution": "bundler",
"module": "preserve",
"noEmit": true,
"noImplicitAny": true
}
}
`,
};
const result = await runInlineTest(files);
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should resolve index.js after path mapping in ESM', async ({ runInlineTest, runTSC }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/31811' });
const files = {
'@acme/lib/index.js': `
export const greet = () => 2;
`,
'@acme/lib/index.d.ts': `
export const greet: () => number;
`,
'package.json': `
{ "type": "module" }
`,
'tests/hello.test.ts': `
import { greet } from '@acme/lib';
import { test, expect } from '@playwright/test';
test('hello', async ({}) => {
const foo: number = greet();
expect(foo).toBe(2);
});
`,
'tsconfig.json': `
{
"compilerOptions": {
"paths": {
"@acme/*": ["./@acme/*"]
},
"moduleResolution": "bundler",
"module": "preserve",
"noEmit": true,
"noImplicitAny": true
}
}
`,
};
const result = await runInlineTest(files);
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should respect package.json#main after path mapping in CJS', async ({ runInlineTest, runTSC }) => {
const files = {
'app/pkg/main.ts': `
export const foo = 42;
`,
'app/pkg/package.json': `
{ "main": "main.ts" }
`,
'package.json': `
{ "name": "example-project" }
`,
'playwright.config.ts': `
export default {};
`,
'tsconfig.json': `
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"app/*": ["app/*"]
},
"moduleResolution": "bundler",
"module": "preserve",
"noEmit": true,
"noImplicitAny": true
}
}
`,
'example.spec.ts': `
import { foo } from 'app/pkg';
import { test, expect } from '@playwright/test';
test('test', ({}) => {
const bar: number = foo;
expect(bar).toBe(42);
});
`,
};
const result = await runInlineTest(files);
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(result.output).not.toContain(`find module`);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should respect package.json#main after path mapping in ESM', async ({ runInlineTest, runTSC }) => {
const files = {
'app/pkg/main.ts': `
export const foo = 42;
`,
'app/pkg/package.json': `
{ "main": "main.ts", "type": "module" }
`,
'package.json': `
{ "name": "example-project", "type": "module" }
`,
'playwright.config.ts': `
export default {};
`,
'tsconfig.json': `
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"app/*": ["app/*"]
},
"moduleResolution": "bundler",
"module": "preserve",
"noEmit": true,
"noImplicitAny": true
},
}
`,
'example.spec.ts': `
import { foo } from 'app/pkg';
import { test, expect } from '@playwright/test';
test('test', ({}) => {
const bar: number = foo;
expect(bar).toBe(42);
});
`,
};
const result = await runInlineTest(files);
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should respect package.json#exports without path mapping in CJS', async ({ runInlineTest, runTSC }) => {
const files = {
'node_modules/foo-pkg/package.json': `
{ "name": "foo-pkg", "exports": { ".": "./foo.js" } }
`,
'node_modules/foo-pkg/foo.js': `
exports.foo = 'bar';
`,
'node_modules/foo-pkg/foo.d.ts': `
export const foo: 'bar';
`,
'package.json': `
{ "name": "test-project" }
`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
import { foo } from 'foo-pkg';
test('pass', async () => {
const bar: 'bar' = foo;
expect(bar).toBe('bar');
});
`,
'tsconfig.json': `
{
"compilerOptions": {
"moduleResolution": "bundler",
"module": "preserve",
"noEmit": true,
"noImplicitAny": true
},
}
`,
};
const result = await runInlineTest(files);
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should respect package.json#exports without path mapping in ESM', async ({ runInlineTest, runTSC }) => {
const files = {
'node_modules/foo-pkg/package.json': `
{ "name": "foo-pkg", "type": "module", "exports": { "default": "./foo.js" } }
`,
'node_modules/foo-pkg/foo.js': `
export const foo = 'bar';
`,
'node_modules/foo-pkg/foo.d.ts': `
export const foo: 'bar';
`,
'package.json': `
{ "name": "test-project", "type": "module" }
`,
'a.test.ts': `
import { test, expect } from '@playwright/test';
import { foo } from 'foo-pkg';
test('pass', async () => {
const bar: 'bar' = foo;
expect(bar).toBe('bar');
});
`,
'tsconfig.json': `
{
"compilerOptions": {
"moduleResolution": "bundler",
"module": "preserve",
"noEmit": true,
"noImplicitAny": true
},
}
`,
};
const result = await runInlineTest(files);
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should not respect package.json#exports after type mapping in CJS', async ({ runInlineTest, runTSC }) => {
const files = {
'app/pkg/main.ts': `
export const filename: 'main.ts' = 'main.ts';
`,
'app/pkg/index.js': `
export const filename = 'index.js';
`,
'app/pkg/index.d.ts': `
export const filename: 'index.js';
`,
'app/pkg/package.json': `
{ "exports": { ".": "./main.ts" } }
`,
'package.json': `
{ "name": "example-project" }
`,
'playwright.config.ts': `
export default {};
`,
'tsconfig.json': `
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"app/*": ["app/*"]
},
"moduleResolution": "bundler",
"module": "preserve",
"noEmit": true,
"noImplicitAny": true
}
}
`,
'example.spec.ts': `
import { filename } from 'app/pkg';
import { test, expect } from '@playwright/test';
test('test', ({}) => {
const foo: 'index.js' = filename;
expect(foo).toBe('index.js');
});
`,
};
const result = await runInlineTest(files);
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
test('should not respect package.json#exports after type mapping in ESM', async ({ runInlineTest, runTSC }) => {
const files = {
'app/pkg/main.ts': `
export const filename: 'main.ts' = 'main.ts';
`,
'app/pkg/index.js': `
export const filename = 'index.js';
`,
'app/pkg/index.d.ts': `
export const filename: 'index.js';
`,
'app/pkg/package.json': `
{ "exports": { ".": "./main.ts" }, "type": "module" }
`,
'package.json': `
{ "name": "example-project", "type": "module" }
`,
'playwright.config.ts': `
export default {};
`,
'tsconfig.json': `
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"app/*": ["app/*"]
},
"moduleResolution": "bundler",
"module": "preserve",
"noEmit": true,
"noImplicitAny": true
}
}
`,
'example.spec.ts': `
import { filename } from 'app/pkg';
import { test, expect } from '@playwright/test';
test('test', ({}) => {
const foo: 'index.js' = filename;
expect(foo).toBe('index.js');
});
`,
};
const result = await runInlineTest(files);
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const tscResult = await runTSC(files);
expect(tscResult.exitCode).toBe(0);
});
});

View file

@ -105,7 +105,6 @@ export interface JSONReportTest {
export interface JSONReportError { export interface JSONReportError {
message: string; message: string;
location?: Location; location?: Location;
matcherResult?: TestErrorMatcherResult;
} }
export interface JSONReportTestResult { export interface JSONReportTestResult {