chrome: don't nest pw:api steps (#8932)

This commit is contained in:
Pavel Feldman 2021-09-15 11:34:23 -07:00 committed by GitHub
parent 2ec82b9a5c
commit 14bc663742
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 85 additions and 57 deletions

View File

@ -72,15 +72,22 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
};
}
private _createChannel(base: Object, stackTrace: ParsedStackTrace | null): T {
private _createChannel(base: Object, stackTrace: ParsedStackTrace | null, csi?: ClientSideInstrumentation, callCookie?: { userObject: any }): T {
const channel = new Proxy(base, {
get: (obj: any, prop) => {
if (prop === 'debugScopeState')
return (params: any) => this._connection.sendMessageToServer(this, prop, params, stackTrace);
if (typeof prop === 'string') {
const validator = scheme[paramsName(this._type, prop)];
if (validator)
return (params: any) => this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace);
if (validator) {
return (params: any) => {
if (callCookie && csi) {
callCookie.userObject = csi.onApiCallBegin(renderCallWithParams(stackTrace!.apiName, params)).userObject;
csi = undefined;
}
return this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace);
};
}
}
return obj[prop];
},
@ -97,22 +104,25 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
let ancestorWithCSI: ChannelOwner<any> = this;
while (!ancestorWithCSI._csi && ancestorWithCSI._parent)
ancestorWithCSI = ancestorWithCSI._parent;
let csiCallback: ((e?: Error) => void) | undefined;
// Do not report nested async calls to _wrapApiCall.
const isNested = stackTrace.allFrames.filter(f => f.function?.includes('_wrapApiCall')).length > 1;
const csi = isNested ? undefined : ancestorWithCSI._csi;
const callCookie: { userObject: any } = { userObject: null };
try {
logApiCall(logger, `=> ${apiName} started`);
csiCallback = ancestorWithCSI._csi?.onApiCall(stackTrace);
const channel = this._createChannel({}, stackTrace);
logApiCall(logger, `=> ${apiName} started`, isNested);
const channel = this._createChannel({}, stackTrace, csi, callCookie);
const result = await func(channel as any, stackTrace);
csiCallback?.();
logApiCall(logger, `<= ${apiName} succeeded`);
csi?.onApiCallEnd(callCookie);
logApiCall(logger, `<= ${apiName} succeeded`, isNested);
return result;
} catch (e) {
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
e.message = apiName + ': ' + e.message;
e.stack = e.message + '\n' + frameTexts.join('\n') + innerError;
csiCallback?.(e);
logApiCall(logger, `<= ${apiName} failed`);
csi?.onApiCallEnd(callCookie, e);
logApiCall(logger, `<= ${apiName} failed`, isNested);
throw e;
}
}
@ -129,7 +139,9 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
}
}
function logApiCall(logger: Logger | undefined, message: string) {
function logApiCall(logger: Logger | undefined, message: string, isNested: boolean) {
if (isNested)
return;
if (logger && logger.isEnabled('api', 'info'))
logger.log('api', 'info', message, [], { color: 'cyan' });
debugLogger.log('api', message);
@ -139,6 +151,19 @@ function paramsName(type: string, method: string) {
return type + method[0].toUpperCase() + method.substring(1) + 'Params';
}
const paramsToRender = ['url', 'selector', 'text', 'key'];
export function renderCallWithParams(apiName: string, params: any) {
const paramsArray = [];
if (params) {
for (const name of paramsToRender) {
if (params[name])
paramsArray.push(params[name]);
}
}
const paramsText = paramsArray.length ? '(' + paramsArray.join(', ') + ')' : '';
return apiName + paramsText;
}
const tChannel = (name: string): Validator => {
return (arg: any, path: string) => {
if (arg._object instanceof ChannelOwner && (name === '*' || arg._object._type === name))

View File

@ -83,7 +83,7 @@ export class Connection extends EventEmitter {
async sendMessageToServer(object: ChannelOwner, method: string, params: any, maybeStackTrace: ParsedStackTrace | null): Promise<any> {
const guid = object._guid;
const stackTrace = maybeStackTrace || { frameTexts: [], frames: [], apiName: '' };
const stackTrace: ParsedStackTrace = maybeStackTrace || { frameTexts: [], frames: [], apiName: '', allFrames: [] };
const { frames, apiName } = stackTrace;
const id = ++this._lastId;

View File

@ -383,9 +383,7 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
}
async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> {
return this._wrapApiCall(async (channel: channels.PageChannel) => {
return this._waitForEvent(event, optionsOrPredicate, `waiting for event "${event}"`);
});
return this._waitForEvent(event, optionsOrPredicate, `waiting for event "${event}"`);
}
private async _waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions, logLine?: string): Promise<any> {

View File

@ -17,7 +17,6 @@
import * as channels from '../protocol/channels';
import type { Size } from '../common/types';
import type { ParsedStackTrace } from '../utils/stackTrace';
export { Size, Point, Rect, Quad, URLMatch, TimeoutOptions, HeadersArray } from '../common/types';
type LoggerSeverity = 'verbose' | 'info' | 'warning' | 'error';
@ -27,7 +26,8 @@ export interface Logger {
}
export interface ClientSideInstrumentation {
onApiCall(stackTrace: ParsedStackTrace): ((error?: Error) => void) | undefined;
onApiCallBegin(apiCall: string): { userObject: any };
onApiCallEnd(userData: { userObject: any }, error?: Error): any;
}
export type StrictOptions = { strict?: boolean };

View File

@ -313,7 +313,8 @@ export class Dispatcher {
};
steps.set(params.stepId, step);
(parentStep || result).steps.push(step);
stepStack.add(step);
if (params.canHaveChildren)
stepStack.add(step);
this._reporter.onStepBegin?.(test, result, step);
});
worker.on('stepEnd', (params: StepEndPayload) => {

View File

@ -75,7 +75,7 @@ function wrap(matcherName: string, matcher: any) {
const INTERNAL_STACK_LENGTH = 3;
const stackLines = new Error().stack!.split('\n').slice(INTERNAL_STACK_LENGTH + 1);
const step = testInfo._addStep('expect', `expect${this.isNot ? '.not' : ''}.${matcherName}`);
const step = testInfo._addStep('expect', `expect${this.isNot ? '.not' : ''}.${matcherName}`, true);
const reportStepEnd = (result: any) => {
const success = result.pass !== this.isNot;

View File

@ -20,7 +20,6 @@ import type { LaunchOptions, BrowserContextOptions, Page, BrowserContext, Browse
import type { TestType, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestInfo } from '../../types/test';
import { rootTestType } from './testType';
import { createGuid, removeFolders } from '../utils/utils';
import { TestInfoImpl } from './types';
export { expect } from './expect';
export const _baseTest: TestType<{}, {}> = rootTestType.test;
@ -203,13 +202,14 @@ export const test = _baseTest.extend<TestFixtures, WorkerAndFileFixtures>({
await context.tracing.stop();
}
(context as any)._csi = {
onApiCall: (stackTrace: ParsedStackTrace) => {
const testInfoImpl = testInfo as TestInfoImpl;
const existingStep = testInfoImpl._currentSteps().find(step => step.category === 'pw:api' || step.category === 'expect');
const newStep = existingStep ? undefined : testInfoImpl._addStep('pw:api', stackTrace.apiName);
return (error?: Error) => {
newStep?.complete(error);
};
onApiCallBegin: (apiCall: string) => {
const testInfoImpl = testInfo as any;
const step = testInfoImpl._addStep('pw:api', apiCall, false);
return { userObject: step };
},
onApiCallEnd: (data: { userObject: any }, error?: Error) => {
const step = data.userObject;
step?.complete(error);
},
};
};

View File

@ -51,6 +51,7 @@ export type StepBeginPayload = {
stepId: string;
title: string;
category: string;
canHaveChildren: boolean;
wallTime: number; // milliseconds since unix epoch
};

View File

@ -186,7 +186,7 @@ export class TestTypeImpl {
const testInfo = currentTestInfo();
if (!testInfo)
throw errorWithLocation(location, `test.step() can only be called from a test`);
const step = testInfo._addStep('test.step', title);
const step = testInfo._addStep('test.step', title, true);
try {
await body();
step.complete();

View File

@ -28,10 +28,10 @@ export type Annotations = { type: string, description?: string }[];
export interface TestStepInternal {
complete(error?: Error | TestError): void;
category: string;
canHaveChildren: boolean;
}
export interface TestInfoImpl extends TestInfo {
_testFinished: Promise<void>;
_addStep: (category: string, title: string) => TestStepInternal;
_currentSteps(): TestStepInternal[];
_addStep: (category: string, title: string, canHaveChildren: boolean) => TestStepInternal;
}

View File

@ -219,7 +219,6 @@ export class WorkerRunner extends EventEmitter {
let testFinishedCallback = () => {};
let lastStepId = 0;
const stepStack = new Set<TestStepInternal>();
const testInfo: TestInfoImpl = {
workerIndex: this._params.workerIndex,
project: this._project.config,
@ -268,18 +267,18 @@ export class WorkerRunner extends EventEmitter {
deadlineRunner.updateDeadline(deadline());
},
_testFinished: new Promise(f => testFinishedCallback = f),
_addStep: (category: string, title: string) => {
_addStep: (category: string, title: string, canHaveChildren: boolean) => {
const stepId = `${category}@${title}@${++lastStepId}`;
let callbackHandled = false;
const step: TestStepInternal = {
category,
canHaveChildren,
complete: (error?: Error | TestError) => {
if (callbackHandled)
return;
callbackHandled = true;
if (error instanceof Error)
error = serializeError(error);
stepStack.delete(step);
const payload: StepEndPayload = {
testId,
stepId,
@ -290,11 +289,11 @@ export class WorkerRunner extends EventEmitter {
this.emit('stepEnd', payload);
}
};
stepStack.add(step);
const payload: StepBeginPayload = {
testId,
stepId,
category,
canHaveChildren,
title,
wallTime: Date.now(),
};
@ -302,7 +301,6 @@ export class WorkerRunner extends EventEmitter {
this.emit('stepBegin', payload);
return step;
},
_currentSteps: () => [...stepStack],
};
// Inherit test.setTimeout() from parent suites.
@ -425,7 +423,7 @@ export class WorkerRunner extends EventEmitter {
}
private async _runTestWithBeforeHooks(test: TestCase, testInfo: TestInfoImpl) {
const step = testInfo._addStep('hook', 'Before Hooks');
const step = testInfo._addStep('hook', 'Before Hooks', true);
if (test._type === 'test')
await this._runBeforeHooks(test, testInfo);
@ -459,7 +457,7 @@ export class WorkerRunner extends EventEmitter {
let step: TestStepInternal | undefined;
let teardownError: TestError | undefined;
try {
step = testInfo._addStep('hook', 'After Hooks');
step = testInfo._addStep('hook', 'After Hooks', true);
if (test._type === 'test')
await this._runHooks(test.parent!, 'afterEach', testInfo);
} catch (error) {

View File

@ -35,6 +35,7 @@ const CLIENT_LIB = path.join(ROOT_DIR, 'lib', 'client');
const CLIENT_SRC = path.join(ROOT_DIR, 'src', 'client');
export type ParsedStackTrace = {
allFrames: StackFrame[];
frames: StackFrame[];
frameTexts: string[];
apiName: string;
@ -78,6 +79,7 @@ export function captureStackTrace(): ParsedStackTrace {
let apiName = '';
// Deepest transition between non-client code calling into client code
// is the api entry.
const allFrames = parsedFrames;
for (let i = 0; i < parsedFrames.length - 1; i++) {
if (parsedFrames[i].inClient && !parsedFrames[i + 1].inClient) {
const frame = parsedFrames[i].frame;
@ -88,6 +90,7 @@ export function captureStackTrace(): ParsedStackTrace {
}
return {
allFrames: allFrames.map(p => p.frame),
frames: parsedFrames.map(p => p.frame),
frameTexts: parsedFrames.map(p => p.frameText),
apiName

View File

@ -80,7 +80,7 @@ const ProjectTreeItemView: React.FC<{
<StatsView stats={project.stats}></StatsView>
</div>
} loadChildren={hasChildren ? () => {
return project.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={1} showFileName={true} failingOnly={failingOnly}></SuiteTreeItemView>) || [];
return project.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={1} failingOnly={failingOnly}></SuiteTreeItemView>) || [];
} : undefined} depth={0} expandByDefault={true}></TreeItem>;
};
@ -90,17 +90,14 @@ const SuiteTreeItemView: React.FC<{
setTestId: (id: TestId) => void;
failingOnly: boolean;
depth: number,
showFileName: boolean,
}> = ({ suite, testId, setTestId, showFileName, failingOnly, depth }) => {
const location = renderLocation(suite.location, showFileName);
}> = ({ suite, testId, setTestId, failingOnly, depth }) => {
return <TreeItem title={<div className='hbox'>
<div className='tree-text' title={suite.title}>{suite.title}</div>
<div style={{ flex: 'auto' }}></div>
<StatsView stats={suite.stats}></StatsView>
{!!suite.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
</div>
} loadChildren={() => {
const suiteChildren = suite.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={depth + 1} showFileName={false} failingOnly={failingOnly}></SuiteTreeItemView>) || [];
const suiteChildren = suite.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={depth + 1} failingOnly={failingOnly}></SuiteTreeItemView>) || [];
const suiteCount = suite.suites.length;
const testChildren = suite.tests.filter(t => !(failingOnly && t.ok)).map((t, i) => <TestTreeItemView key={i + suiteCount} test={t} setTestId={setTestId} testId={testId} depth={depth + 1}></TestTreeItemView>) || [];
return [...suiteChildren, ...testChildren];

View File

@ -141,11 +141,14 @@ export const playwrightFixtures: Fixtures<PlaywrightTestOptions & PlaywrightTest
if (trace)
await context.tracing.start({ screenshots: true, snapshots: true });
(context as any)._csi = {
onApiCall: (stackTrace: any) => {
onApiCallBegin: (apiCall: string) => {
const testInfoImpl = testInfo as any;
const existingStep = testInfoImpl._currentSteps().find(step => step.category === 'pw:api' || step.category === 'expect');
const newStep = existingStep ? undefined : testInfoImpl._addStep('pw:api', stackTrace.apiName);
return (error?: Error) => newStep?.complete(error);
const step = testInfoImpl._addStep('pw:api', apiCall, false);
return { userObject: step };
},
onApiCallEnd: (data: { userObject: any }, error?: Error) => {
const step = data.userObject;
step?.complete(error);
},
};
return context;

View File

@ -229,7 +229,9 @@ test('should report expect steps', async ({ runInlineTest }) => {
`%% end {\"title\":\"browserContext.newPage\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"Before Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"browserContext.newPage\",\"category\":\"pw:api\"}]}`,
`%% begin {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\"}`,
`%% end {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\"}`,
`%% begin {\"title\":\"page.title\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.title\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\",\"steps\":[{\"title\":\"page.title\",\"category\":\"pw:api\"}]}`,
`%% begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`,
`%% begin {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`,
@ -281,22 +283,22 @@ test('should report api steps', async ({ runInlineTest }) => {
`%% end {\"title\":\"Before Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"browserContext.newPage\",\"category\":\"pw:api\"}]}`,
`%% begin {\"title\":\"page.setContent\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.setContent\",\"category\":\"pw:api\"}`,
`%% begin {\"title\":\"page.click\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.click\",\"category\":\"pw:api\"}`,
`%% begin {\"title\":\"page.click(button)\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.click(button)\",\"category\":\"pw:api\"}`,
`%% begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`,
`%% begin {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"After Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"browserContext.close\",\"category\":\"pw:api\"}]}`,
`%% begin {\"title\":\"Before Hooks\",\"category\":\"hook\"}`,
`%% end {\"title\":\"Before Hooks\",\"category\":\"hook\"}`,
`%% begin {\"title\":\"page.click\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.click\",\"category\":\"pw:api\"}`,
`%% begin {\"title\":\"page.click(button)\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.click(button)\",\"category\":\"pw:api\"}`,
`%% begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`,
`%% end {\"title\":\"After Hooks\",\"category\":\"hook\"}`,
`%% begin {\"title\":\"Before Hooks\",\"category\":\"hook\"}`,
`%% end {\"title\":\"Before Hooks\",\"category\":\"hook\"}`,
`%% begin {\"title\":\"page.click\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.click\",\"category\":\"pw:api\"}`,
`%% begin {\"title\":\"page.click(button)\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.click(button)\",\"category\":\"pw:api\"}`,
`%% begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`,
`%% end {\"title\":\"After Hooks\",\"category\":\"hook\"}`,
]);
@ -328,8 +330,8 @@ test('should report api step failure', async ({ runInlineTest }) => {
`%% end {\"title\":\"Before Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"browserContext.newPage\",\"category\":\"pw:api\"}]}`,
`%% begin {\"title\":\"page.setContent\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.setContent\",\"category\":\"pw:api\"}`,
`%% begin {\"title\":\"page.click\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.click\",\"category\":\"pw:api\",\"error\":{\"message\":\"page.click: Timeout 1ms exceeded.\\n=========================== logs ===========================\\nwaiting for selector \\\"input\\\"\\n============================================================\",\"stack\":\"<stack>\"}}`,
`%% begin {\"title\":\"page.click(input)\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"page.click(input)\",\"category\":\"pw:api\",\"error\":{\"message\":\"page.click: Timeout 1ms exceeded.\\n=========================== logs ===========================\\nwaiting for selector \\\"input\\\"\\n============================================================\",\"stack\":\"<stack>\"}}`,
`%% begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`,
`%% begin {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`,