feat(test-runner): experiemental expect plumbing (#7926)

This commit is contained in:
Pavel Feldman 2021-07-30 16:07:02 -07:00 committed by GitHub
parent 081b8683a3
commit af30d267b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 183 additions and 6 deletions

View file

@ -21,7 +21,9 @@ import { debugLogger } from '../utils/debugLogger';
import { captureStackTrace, ParsedStackTrace } from '../utils/stackTrace'; import { captureStackTrace, ParsedStackTrace } from '../utils/stackTrace';
import { isUnderTest } from '../utils/utils'; import { isUnderTest } from '../utils/utils';
import type { Connection } from './connection'; import type { Connection } from './connection';
import type { Logger } from './types'; import type { ClientSideInstrumentation, Logger } from './types';
let lastCallSeq = 0;
export abstract class ChannelOwner<T extends channels.Channel = channels.Channel, Initializer = {}> extends EventEmitter { export abstract class ChannelOwner<T extends channels.Channel = channels.Channel, Initializer = {}> extends EventEmitter {
protected _connection: Connection; protected _connection: Connection;
@ -33,6 +35,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
readonly _channel: T; readonly _channel: T;
readonly _initializer: Initializer; readonly _initializer: Initializer;
_logger: Logger | undefined; _logger: Logger | undefined;
_csi: ClientSideInstrumentation | undefined;
constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: Initializer) { constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: Initializer) {
super(); super();
@ -46,6 +49,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
if (this._parent) { if (this._parent) {
this._parent._objects.set(guid, this); this._parent._objects.set(guid, this);
this._logger = this._parent._logger; this._logger = this._parent._logger;
this._csi = this._parent._csi;
} }
this._channel = this._createChannel(new EventEmitter(), null); this._channel = this._createChannel(new EventEmitter(), null);
@ -93,15 +97,19 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
const stackTrace = captureStackTrace(); const stackTrace = captureStackTrace();
const { apiName, frameTexts } = stackTrace; const { apiName, frameTexts } = stackTrace;
const channel = this._createChannel({}, stackTrace); const channel = this._createChannel({}, stackTrace);
const seq = ++lastCallSeq;
try { try {
logApiCall(logger, `=> ${apiName} started`); logApiCall(logger, `=> ${apiName} started`);
this._csi?.onApiCall({ phase: 'begin', seq, apiName, frames: stackTrace.frames });
const result = await func(channel as any, stackTrace); const result = await func(channel as any, stackTrace);
this._csi?.onApiCall({ phase: 'end', seq });
logApiCall(logger, `<= ${apiName} succeeded`); logApiCall(logger, `<= ${apiName} succeeded`);
return result; return result;
} catch (e) { } catch (e) {
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : ''; const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
e.message = apiName + ': ' + e.message; e.message = apiName + ': ' + e.message;
e.stack = e.message + '\n' + frameTexts.join('\n') + innerError; e.stack = e.message + '\n' + frameTexts.join('\n') + innerError;
this._csi?.onApiCall({ phase: 'end', seq, error: e.stack });
logApiCall(logger, `<= ${apiName} failed`); logApiCall(logger, `<= ${apiName} failed`);
throw e; throw e;
} }

View file

@ -23,6 +23,10 @@ export interface Logger {
log(name: string, severity: LoggerSeverity, message: string | Error, args: any[], hints: { color?: string }): void; log(name: string, severity: LoggerSeverity, message: string | Error, args: any[], hints: { color?: string }): void;
} }
export interface ClientSideInstrumentation {
onApiCall(data: { phase: 'begin' | 'end', seq: number, apiName?: string, frames?: channels.StackFrame[], error?: string }): void;
}
import { Size } from '../common/types'; import { Size } from '../common/types';
export { Size, Point, Rect, Quad, URLMatch, TimeoutOptions } from '../common/types'; export { Size, Point, Rect, Quad, URLMatch, TimeoutOptions } from '../common/types';
export type StrictOptions = { strict?: boolean }; export type StrictOptions = { strict?: boolean };

View file

@ -17,7 +17,7 @@
import child_process from 'child_process'; import child_process from 'child_process';
import path from 'path'; import path from 'path';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, WorkerInitParams } from './ipc'; import { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, WorkerInitParams, ProgressPayload } from './ipc';
import type { TestResult, Reporter } from '../../types/testReporter'; import type { TestResult, Reporter } from '../../types/testReporter';
import { TestCase } from './test'; import { TestCase } from './test';
import { Loader } from './loader'; import { Loader } from './loader';
@ -235,6 +235,10 @@ export class Dispatcher {
test.timeout = params.timeout; test.timeout = params.timeout;
this._reportTestEnd(test, result); this._reportTestEnd(test, result);
}); });
worker.on('progress', (params: ProgressPayload) => {
const { test } = this._testById.get(params.testId)!;
(this._reporter as any)._onTestProgress?.(test, params.name, params.data);
});
worker.on('stdOut', (params: TestOutputPayload) => { worker.on('stdOut', (params: TestOutputPayload) => {
const chunk = chunkFromParams(params); const chunk = chunkFromParams(params);
const pair = params.testId ? this._testById.get(params.testId) : undefined; const pair = params.testId ? this._testById.get(params.testId) : undefined;

View file

@ -40,10 +40,12 @@ import {
} from './matchers/matchers'; } from './matchers/matchers';
import { toMatchSnapshot } from './matchers/toMatchSnapshot'; import { toMatchSnapshot } from './matchers/toMatchSnapshot';
import type { Expect } from './types'; import type { Expect } from './types';
import matchers from 'expect/build/matchers';
import { currentTestInfo } from './globals';
export const expect: Expect = expectLibrary as any; export const expect: Expect = expectLibrary as any;
expectLibrary.setState({ expand: false }); expectLibrary.setState({ expand: false });
expectLibrary.extend({ const customMatchers = {
toBeChecked, toBeChecked,
toBeDisabled, toBeDisabled,
toBeEditable, toBeEditable,
@ -66,4 +68,51 @@ expectLibrary.extend({
toHaveURL, toHaveURL,
toHaveValue, toHaveValue,
toMatchSnapshot, toMatchSnapshot,
}); };
let lastExpectSeq = 0;
function wrap(matcherName: string, matcher: any) {
return function(this: any, ...args: any[]) {
const testInfo = currentTestInfo();
if (!testInfo)
return matcher.call(this, ...args);
const seq = ++lastExpectSeq;
testInfo._progress('expect', { phase: 'begin', seq, matcherName });
const endPayload: any = { phase: 'end', seq };
let isAsync = false;
try {
const result = matcher.call(this, ...args);
endPayload.pass = result.pass;
if (this.isNot)
endPayload.isNot = this.isNot;
if (result.pass === this.isNot && result.message)
endPayload.message = result.message();
if (result instanceof Promise) {
isAsync = true;
return result.catch(e => {
endPayload.error = e.stack;
throw e;
}).finally(() => {
testInfo._progress('expect', endPayload);
});
}
return result;
} catch (e) {
endPayload.error = e.stack;
throw e;
} finally {
if (!isAsync)
testInfo._progress('expect', endPayload);
}
};
}
const wrappedMatchers: any = {};
for (const matcherName in matchers)
wrappedMatchers[matcherName] = wrap(matcherName, matchers[matcherName]);
for (const matcherName in customMatchers)
wrappedMatchers[matcherName] = wrap(matcherName, (customMatchers as any)[matcherName]);
expectLibrary.extend(wrappedMatchers);

View file

@ -177,13 +177,16 @@ export const test = _baseTest.extend<PlaywrightTestArgs & PlaywrightTestOptions,
recordVideoDir = artifactsFolder; recordVideoDir = artifactsFolder;
} }
const combinedOptions = { const combinedOptions: BrowserContextOptions = {
recordVideo: recordVideoDir ? { dir: recordVideoDir, size: recordVideoSize } : undefined, recordVideo: recordVideoDir ? { dir: recordVideoDir, size: recordVideoSize } : undefined,
...contextOptions, ...contextOptions,
...options, ...options,
...additionalOptions, ...additionalOptions,
}; };
const context = await browser.newContext(combinedOptions); const context = await browser.newContext(combinedOptions);
(context as any)._csi = {
onApiCall: (data: any) => (testInfo as any)._progress('pw:api', data),
};
context.setDefaultTimeout(actionTimeout || 0); context.setDefaultTimeout(actionTimeout || 0);
context.setDefaultNavigationTimeout(navigationTimeout || actionTimeout || 0); context.setDefaultNavigationTimeout(navigationTimeout || actionTimeout || 0);
expectLibrary.setState({ playwrightActionTimeout: actionTimeout } as any); expectLibrary.setState({ playwrightActionTimeout: actionTimeout } as any);

View file

@ -46,6 +46,12 @@ export type TestEndPayload = {
attachments: { name: string, path?: string, body?: string, contentType: string }[]; attachments: { name: string, path?: string, body?: string, contentType: string }[];
}; };
export type ProgressPayload = {
testId: string;
name: string;
data: any;
};
export type TestEntry = { export type TestEntry = {
testId: string; testId: string;
retry: number; retry: number;

View file

@ -57,4 +57,9 @@ export class Multiplexer implements Reporter {
for (const reporter of this._reporters) for (const reporter of this._reporters)
reporter.onError?.(error); reporter.onError?.(error);
} }
_onTestProgress(test: TestCase, name: string, data: any) {
for (const reporter of this._reporters)
(reporter as any)._onTestProgress?.(test, name, data);
}
} }

View file

@ -27,4 +27,5 @@ export type Annotations = { type: string, description?: string }[];
export interface TestInfoImpl extends TestInfo { export interface TestInfoImpl extends TestInfo {
_testFinished: Promise<void>; _testFinished: Promise<void>;
_progress: (name: string, params: any) => void;
} }

View file

@ -74,7 +74,7 @@ process.on('message', async message => {
workerIndex = initParams.workerIndex; workerIndex = initParams.workerIndex;
startProfiling(); startProfiling();
workerRunner = new WorkerRunner(initParams); workerRunner = new WorkerRunner(initParams);
for (const event of ['testBegin', 'testEnd', 'done']) for (const event of ['testBegin', 'testEnd', 'done', 'progress'])
workerRunner.on(event, sendMessageToParent.bind(null, event)); workerRunner.on(event, sendMessageToParent.bind(null, event));
return; return;
} }

View file

@ -267,6 +267,7 @@ export class WorkerRunner extends EventEmitter {
deadlineRunner.setDeadline(deadline()); deadlineRunner.setDeadline(deadline());
}, },
_testFinished: new Promise(f => testFinishedCallback = f), _testFinished: new Promise(f => testFinishedCallback = f),
_progress: (name, data) => this.emit('progress', { testId, name, data }),
}; };
// Inherit test.setTimeout() from parent suites. // Inherit test.setTimeout() from parent suites.

View file

@ -158,3 +158,99 @@ test('should load reporter from node_modules', async ({ runInlineTest }) => {
'%%end', '%%end',
]); ]);
}); });
test('should report expect progress', async ({ runInlineTest }) => {
const expectReporterJS = `
class Reporter {
_onTestProgress(test, name, data) {
if (data.frames)
data.frames = [];
console.log('%%%%', name, JSON.stringify(data));
}
}
module.exports = Reporter;
`;
const result = await runInlineTest({
'reporter.ts': expectReporterJS,
'playwright.config.ts': `
module.exports = {
reporter: './reporter',
};
`,
'a.test.ts': `
const { test } = pwt;
test('fail', async ({}) => {
expect(true).toBeTruthy();
expect(false).toBeTruthy();
});
test('pass', async ({}) => {
expect(false).not.toBeTruthy();
});
test('async', async ({ page }) => {
await expect(page).not.toHaveTitle('False');
});
`
}, { reporter: '', workers: 1 });
expect(result.exitCode).toBe(1);
expect(result.output.split('\n').filter(line => line.startsWith('%%'))).toEqual([
`%% expect {\"phase\":\"begin\",\"seq\":1,\"matcherName\":\"toBeTruthy\"}`,
`%% expect {\"phase\":\"end\",\"seq\":1,\"pass\":true}`,
`%% expect {\"phase\":\"begin\",\"seq\":2,\"matcherName\":\"toBeTruthy\"}`,
`%% expect {\"phase\":\"end\",\"seq\":2,\"pass\":false,\"message\":\"\\u001b[2mexpect(\\u001b[22m\\u001b[31mreceived\\u001b[39m\\u001b[2m).\\u001b[22mtoBeTruthy\\u001b[2m()\\u001b[22m\\n\\nReceived: \\u001b[31mfalse\\u001b[39m\"}`,
`%% expect {\"phase\":\"begin\",\"seq\":1,\"matcherName\":\"toBeTruthy\"}`,
`%% expect {\"phase\":\"end\",\"seq\":1,\"pass\":false,\"isNot\":true}`,
`%% pw:api {\"phase\":\"begin\",\"seq\":3,\"apiName\":\"browserContext.newPage\",\"frames\":[]}`,
`%% pw:api {\"phase\":\"end\",\"seq\":3}`,
`%% expect {\"phase\":\"begin\",\"seq\":2,\"matcherName\":\"toHaveTitle\"}`,
`%% pw:api {\"phase\":\"begin\",\"seq\":4,\"apiName\":\"page.title\",\"frames\":[]}`,
`%% pw:api {\"phase\":\"end\",\"seq\":4}`,
`%% expect {\"phase\":\"end\",\"seq\":2,\"isNot\":true}`,
`%% pw:api {\"phase\":\"begin\",\"seq\":5,\"apiName\":\"browserContext.close\",\"frames\":[]}`,
`%% pw:api {\"phase\":\"end\",\"seq\":5}`,
]);
});
test('should report log progress', async ({ runInlineTest }) => {
const expectReporterJS = `
class Reporter {
_onTestProgress(test, name, data) {
if (data.frames)
data.frames = [];
console.log('%%%%', name, JSON.stringify(data));
}
}
module.exports = Reporter;
`;
const result = await runInlineTest({
'reporter.ts': expectReporterJS,
'playwright.config.ts': `
module.exports = {
reporter: './reporter',
};
`,
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<button></button>');
await page.click('button');
});
`
}, { reporter: '', workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.output.split('\n').filter(line => line.startsWith('%%'))).toEqual([
`%% pw:api {\"phase\":\"begin\",\"seq\":3,\"apiName\":\"browserContext.newPage\",\"frames\":[]}`,
`%% pw:api {\"phase\":\"end\",\"seq\":3}`,
`%% pw:api {\"phase\":\"begin\",\"seq\":4,\"apiName\":\"page.setContent\",\"frames\":[]}`,
`%% pw:api {\"phase\":\"end\",\"seq\":4}`,
`%% pw:api {\"phase\":\"begin\",\"seq\":5,\"apiName\":\"page.click\",\"frames\":[]}`,
`%% pw:api {\"phase\":\"end\",\"seq\":5}`,
`%% pw:api {\"phase\":\"begin\",\"seq\":6,\"apiName\":\"browserContext.close\",\"frames\":[]}`,
`%% pw:api {\"phase\":\"end\",\"seq\":6}`,
]);
});