feat(html reporter): show log for pw:api and expect steps (#8692)

This commit is contained in:
Dmitry Gozman 2021-09-03 13:08:17 -07:00 committed by GitHub
parent e7d4d61442
commit e2b092c1a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 129 additions and 93 deletions

View file

@ -21,7 +21,7 @@ import { debugLogger } from '../utils/debugLogger';
import { captureStackTrace, ParsedStackTrace } from '../utils/stackTrace';
import { isUnderTest } from '../utils/utils';
import type { Connection } from './connection';
import type { ClientSideInstrumentation, Logger } from './types';
import type { ClientSideInstrumentation, LogContainer, Logger } from './types';
export abstract class ChannelOwner<T extends channels.Channel = channels.Channel, Initializer = {}> extends EventEmitter {
protected _connection: Connection;
@ -72,15 +72,15 @@ 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, logContainer?: LogContainer): T {
const channel = new Proxy(base, {
get: (obj: any, prop) => {
if (prop === 'debugScopeState')
return (params: any) => this._connection.sendMessageToServer(this, prop, params, stackTrace);
return (params: any) => this._connection.sendMessageToServer(this, prop, params, stackTrace, logContainer);
if (typeof prop === 'string') {
const validator = scheme[paramsName(this._type, prop)];
if (validator)
return (params: any) => this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace);
return (params: any) => this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace, logContainer);
}
return obj[prop];
},
@ -93,25 +93,26 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
logger = logger || this._logger;
const stackTrace = captureStackTrace();
const { apiName, frameTexts } = stackTrace;
const channel = this._createChannel({}, stackTrace);
let ancestorWithCSI: ChannelOwner<any> = this;
while (!ancestorWithCSI._csi && ancestorWithCSI._parent)
ancestorWithCSI = ancestorWithCSI._parent;
let csiCallback: ((e?: Error) => void) | undefined;
let csiCallback: ((log: string[], e?: Error) => void) | undefined;
const logContainer = { log: [] };
try {
logApiCall(logger, `=> ${apiName} started`);
csiCallback = ancestorWithCSI._csi?.onApiCall(stackTrace);
const channel = this._createChannel({}, stackTrace, csiCallback ? logContainer : undefined);
const result = await func(channel as any, stackTrace);
csiCallback?.();
csiCallback?.(logContainer.log);
logApiCall(logger, `<= ${apiName} succeeded`);
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);
csiCallback?.(logContainer.log, e);
logApiCall(logger, `<= ${apiName} failed`);
throw e;
}

View file

@ -39,6 +39,7 @@ import { ParsedStackTrace } from '../utils/stackTrace';
import { Artifact } from './artifact';
import { EventEmitter } from 'events';
import { JsonPipe } from './jsonPipe';
import type { LogContainer } from './types';
class Root extends ChannelOwner<channels.RootChannel, {}> {
constructor(connection: Connection) {
@ -57,7 +58,7 @@ export class Connection extends EventEmitter {
private _waitingForObject = new Map<string, any>();
onmessage = (message: object): void => {};
private _lastId = 0;
private _callbacks = new Map<number, { resolve: (a: any) => void, reject: (a: Error) => void, stackTrace: ParsedStackTrace }>();
private _callbacks = new Map<number, { resolve: (a: any) => void, reject: (a: Error) => void, stackTrace: ParsedStackTrace, logContainer: LogContainer | undefined }>();
private _rootObject: Root;
private _disconnectedErrorMessage: string | undefined;
private _onClose?: () => void;
@ -80,7 +81,7 @@ export class Connection extends EventEmitter {
return this._objects.get(guid)!;
}
async sendMessageToServer(object: ChannelOwner, method: string, params: any, maybeStackTrace: ParsedStackTrace | null): Promise<any> {
async sendMessageToServer(object: ChannelOwner, method: string, params: any, maybeStackTrace: ParsedStackTrace | null, logContainer?: LogContainer): Promise<any> {
const guid = object._guid;
const stackTrace = maybeStackTrace || { frameTexts: [], frames: [], apiName: '' };
const { frames, apiName } = stackTrace;
@ -89,12 +90,12 @@ export class Connection extends EventEmitter {
const converted = { id, guid, method, params };
// Do not include metadata in debug logs to avoid noise.
debugLogger.log('channel:command', converted);
const metadata: channels.Metadata = { stack: frames, apiName };
const metadata: channels.Metadata = { stack: frames, apiName, collectLogs: logContainer ? true : undefined };
this.onmessage({ ...converted, metadata });
if (this._disconnectedErrorMessage)
throw new Error(this._disconnectedErrorMessage);
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, stackTrace }));
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, stackTrace, logContainer }));
}
_debugScopeState(): any {
@ -102,13 +103,15 @@ export class Connection extends EventEmitter {
}
dispatch(message: object) {
const { id, guid, method, params, result, error } = message as any;
const { id, guid, method, params, result, error, log } = message as any;
if (id) {
debugLogger.log('channel:response', message);
const callback = this._callbacks.get(id);
if (!callback)
throw new Error(`Cannot find command to respond: ${id}`);
this._callbacks.delete(id);
if (log && callback.logContainer)
callback.logContainer.log.push(...log);
if (error)
callback.reject(parseError(error));
else

View file

@ -27,8 +27,11 @@ export interface Logger {
}
export interface ClientSideInstrumentation {
onApiCall(stackTrace: ParsedStackTrace): (error?: Error) => void;
onApiCall(stackTrace: ParsedStackTrace): ((log: string[], error?: Error) => void) | undefined;
}
export type LogContainer = {
log: string[];
};
export type StrictOptions = { strict?: boolean };
export type Headers = { [key: string]: string };

View file

@ -222,7 +222,8 @@ export class DispatcherConnection {
const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined;
const callMetadata: CallMetadata = {
id: `call@${id}`,
...validMetadata,
stack: validMetadata.stack,
apiName: validMetadata.apiName,
objectId: sdkObject?.guid,
pageId: sdkObject?.attribution?.page?.guid,
frameId: sdkObject?.attribution?.frame?.guid,
@ -277,10 +278,11 @@ export class DispatcherConnection {
await sdkObject?.instrumentation.onAfterCall(sdkObject, callMetadata);
}
const log = validMetadata.collectLogs ? callMetadata.log : undefined;
if (callMetadata.error)
this.onmessage({ id, error: error });
this.onmessage({ id, error: error, log });
else
this.onmessage({ id, result: callMetadata.result });
this.onmessage({ id, result: callMetadata.result, log });
}
private _replaceDispatchersWithGuids(payload: any): any {

View file

@ -18,6 +18,7 @@ import { Request, Response, Route, WebSocket } from '../server/network';
import * as channels from '../protocol/channels';
import { Dispatcher, DispatcherScope, lookupNullableDispatcher, existingDispatcher } from './dispatcher';
import { FrameDispatcher } from './frameDispatcher';
import { CallMetadata } from '../server/instrumentation';
export class RequestDispatcher extends Dispatcher<Request, channels.RequestInitializer, channels.RequestEvents> implements channels.RequestChannel {
@ -84,15 +85,15 @@ export class ResponseDispatcher extends Dispatcher<Response, channels.ResponseIn
return { value: await this._object.serverAddr() || undefined };
}
async rawRequestHeaders(params?: channels.ResponseRawRequestHeadersParams, metadata?: channels.Metadata): Promise<channels.ResponseRawRequestHeadersResult> {
async rawRequestHeaders(params?: channels.ResponseRawRequestHeadersParams): Promise<channels.ResponseRawRequestHeadersResult> {
return { headers: await this._object.rawRequestHeaders() };
}
async rawResponseHeaders(params?: channels.ResponseRawResponseHeadersParams, metadata?: channels.Metadata): Promise<channels.ResponseRawResponseHeadersResult> {
async rawResponseHeaders(params?: channels.ResponseRawResponseHeadersParams): Promise<channels.ResponseRawResponseHeadersResult> {
return { headers: await this._object.rawResponseHeaders() };
}
async sizes(params?: channels.ResponseSizesParams, metadata?: channels.Metadata): Promise<channels.ResponseSizesResult> {
async sizes(params?: channels.ResponseSizesParams): Promise<channels.ResponseSizesResult> {
return { sizes: await this._object.sizes() };
}
}
@ -111,11 +112,11 @@ export class RouteDispatcher extends Dispatcher<Route, channels.RouteInitializer
});
}
async responseBody(params?: channels.RouteResponseBodyParams, metadata?: channels.Metadata): Promise<channels.RouteResponseBodyResult> {
async responseBody(params?: channels.RouteResponseBodyParams): Promise<channels.RouteResponseBodyResult> {
return { binary: (await this._object.responseBody()).toString('base64') };
}
async continue(params: channels.RouteContinueParams, metadata?: channels.Metadata): Promise<channels.RouteContinueResult> {
async continue(params: channels.RouteContinueParams, metadata: CallMetadata): Promise<channels.RouteContinueResult> {
const response = await this._object.continue({
url: params.url,
method: params.method,

View file

@ -52,23 +52,23 @@ export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.Playwr
debugLogger.log('proxy', `Starting socks proxy server on port ${this._object.options.socksProxyPort}`);
}
async socksConnected(params: channels.PlaywrightSocksConnectedParams, metadata?: channels.Metadata): Promise<void> {
async socksConnected(params: channels.PlaywrightSocksConnectedParams): Promise<void> {
this._socksProxy?.socketConnected(params);
}
async socksFailed(params: channels.PlaywrightSocksFailedParams, metadata?: channels.Metadata): Promise<void> {
async socksFailed(params: channels.PlaywrightSocksFailedParams): Promise<void> {
this._socksProxy?.socketFailed(params);
}
async socksData(params: channels.PlaywrightSocksDataParams, metadata?: channels.Metadata): Promise<void> {
async socksData(params: channels.PlaywrightSocksDataParams): Promise<void> {
this._socksProxy?.sendSocketData(params);
}
async socksError(params: channels.PlaywrightSocksErrorParams, metadata?: channels.Metadata): Promise<void> {
async socksError(params: channels.PlaywrightSocksErrorParams): Promise<void> {
this._socksProxy?.sendSocketError(params);
}
async socksEnd(params: channels.PlaywrightSocksEndParams, metadata?: channels.Metadata): Promise<void> {
async socksEnd(params: channels.PlaywrightSocksEndParams): Promise<void> {
this._socksProxy?.sendSocketEnd(params);
}
}

View file

@ -33,6 +33,7 @@ export type StackFrame = {
export type Metadata = {
stack?: StackFrame[],
apiName?: string,
collectLogs?: boolean,
};
export type Point = {

View file

@ -29,6 +29,7 @@ Metadata:
type: array?
items: StackFrame
apiName: string?
collectLogs: boolean?
Point:

View file

@ -42,6 +42,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
scheme.Metadata = tObject({
stack: tOptional(tArray(tType('StackFrame'))),
apiName: tOptional(tString),
collectLogs: tOptional(tBoolean),
});
scheme.Point = tObject({
x: tNumber,

View file

@ -309,7 +309,7 @@ export class Dispatcher {
startTime: new Date(params.wallTime),
duration: 0,
steps: [],
data: params.data,
data: {},
};
steps.set(params.stepId, step);
(parentStep || result).steps.push(step);
@ -331,6 +331,7 @@ export class Dispatcher {
step.duration = params.wallTime - step.startTime.getTime();
if (params.error)
step.error = params.error;
step.data = params.data;
stepStack.delete(step);
steps.delete(params.stepId);
this._reporter.onStepEnd?.(test, result, step);

View file

@ -77,7 +77,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 completeStep = testInfo._addStep('expect', `expect${this.isNot ? '.not' : ''}.${matcherName}`, prepareExpectStepData(stackLines));
const step = testInfo._addStep('expect', `expect${this.isNot ? '.not' : ''}.${matcherName}`, prepareExpectStepData(stackLines));
const reportStepEnd = (result: any) => {
const success = result.pass !== this.isNot;
@ -86,12 +86,12 @@ function wrap(matcherName: string, matcher: any) {
const message = result.message();
error = { message, stack: message + '\n' + stackLines.join('\n') };
}
completeStep?.(error);
step.complete(error);
return result;
};
const reportStepError = (error: Error) => {
completeStep?.(serializeError(error));
step.complete(serializeError(error));
throw error;
};
@ -119,7 +119,7 @@ function prepareExpectStepData(lines: string[]) {
column: parsed.column
};
}).filter(frame => !!frame);
return { stack: frames };
return { stack: frames, log: [] };
}
const wrappedMatchers: any = {};

View file

@ -16,7 +16,7 @@
import { formatLocation, wrapInPromise } from './util';
import * as crypto from 'crypto';
import { FixturesWithLocation, Location, WorkerInfo, TestInfo, CompleteStepCallback } from './types';
import { FixturesWithLocation, Location, WorkerInfo, TestInfo, TestStepInternal } from './types';
type FixtureScope = 'test' | 'worker';
type FixtureRegistration = {
@ -242,7 +242,7 @@ export class FixtureRunner {
throw error;
}
async resolveParametersAndRunHookOrTest(fn: Function, workerInfo: WorkerInfo, testInfo: TestInfo | undefined, paramsStepCallback?: CompleteStepCallback) {
async resolveParametersAndRunHookOrTest(fn: Function, workerInfo: WorkerInfo, testInfo: TestInfo | undefined, paramsStepCallback?: TestStepInternal) {
// Install all automatic fixtures.
for (const registration of this.pool!.registrations.values()) {
const shouldSkip = !testInfo && registration.scope === 'test';
@ -260,7 +260,7 @@ export class FixtureRunner {
}
// Report fixture hooks step as completed.
paramsStepCallback?.();
paramsStepCallback?.complete();
return fn(params, testInfo || workerInfo);
}

View file

@ -204,9 +204,13 @@ export const test = _baseTest.extend<TestFixtures, WorkerAndFileFixtures>({
}
(context as any)._csi = {
onApiCall: (stackTrace: ParsedStackTrace) => {
if ((testInfo as TestInfoImpl)._currentSteps().some(step => step.category === 'pw:api' || step.category === 'expect'))
return () => {};
return (testInfo as TestInfoImpl)._addStep('pw:api', stackTrace.apiName, { stack: stackTrace.frames });
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, { stack: stackTrace.frames, log: [] });
return (log: string[], error?: Error) => {
(existingStep || newStep)?.data.log?.push(...log);
newStep?.complete(error);
};
},
};
};

View file

@ -52,7 +52,6 @@ export type StepBeginPayload = {
title: string;
category: string;
wallTime: number; // milliseconds since unix epoch
data: { [key: string]: any };
};
export type StepEndPayload = {
@ -60,6 +59,7 @@ export type StepEndPayload = {
stepId: string;
wallTime: number; // milliseconds since unix epoch
error?: TestError;
data: { [key: string]: any };
};
export type TestEntry = {

View file

@ -105,6 +105,7 @@ export type JsonTestStep = {
steps: JsonTestStep[];
preview?: string;
stack?: JsonStackFrame[];
log?: string[];
};
class HtmlReporter {
@ -221,6 +222,7 @@ class HtmlReporter {
steps: this._serializeSteps(test, step.steps),
failureSnippet: step.error ? formatError(step.error, test.location.file) : undefined,
...this._sourceProcessor.processStackTrace(step.data.stack),
log: step.data.log || undefined,
};
});
}

View file

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

View file

@ -25,10 +25,14 @@ export type FixturesWithLocation = {
};
export type Annotations = { type: string, description?: string }[];
export type CompleteStepCallback = (error?: Error | TestError) => void;
export interface TestStepInternal {
complete(error?: Error | TestError): void;
category: string;
data: { [key: string]: any };
}
export interface TestInfoImpl extends TestInfo {
_testFinished: Promise<void>;
_addStep: (category: string, title: string, data?: { [key: string]: any }) => CompleteStepCallback;
_currentSteps(): { category: string }[];
_addStep: (category: string, title: string, data?: { [key: string]: any }) => TestStepInternal;
_currentSteps(): TestStepInternal[];
}

View file

@ -25,7 +25,7 @@ import { TestBeginPayload, TestEndPayload, RunPayload, TestEntry, DonePayload, W
import { setCurrentTestInfo } from './globals';
import { Loader } from './loader';
import { Modifier, Suite, TestCase } from './test';
import { Annotations, CompleteStepCallback, TestError, TestInfo, TestInfoImpl, WorkerInfo } from './types';
import { Annotations, TestError, TestInfo, TestInfoImpl, TestStepInternal, WorkerInfo } from './types';
import { ProjectImpl } from './project';
import { FixturePool, FixtureRunner } from './fixtures';
import { DeadlineRunner, raceAgainstDeadline } from '../utils/async';
@ -219,7 +219,7 @@ export class WorkerRunner extends EventEmitter {
let testFinishedCallback = () => {};
let lastStepId = 0;
const stepStack = new Set<{ category: string }>();
const stepStack = new Set<TestStepInternal>();
const testInfo: TestInfoImpl = {
workerIndex: this._params.workerIndex,
project: this._project.config,
@ -270,34 +270,39 @@ export class WorkerRunner extends EventEmitter {
_testFinished: new Promise(f => testFinishedCallback = f),
_addStep: (category: string, title: string, data: { [key: string]: any } = {}) => {
const stepId = `${category}@${title}@${++lastStepId}`;
const step: StepBeginPayload = {
let callbackHandled = false;
const step: TestStepInternal = {
data,
category,
complete: (error?: Error | TestError) => {
if (callbackHandled)
return;
callbackHandled = true;
if (error instanceof Error)
error = serializeError(error);
stepStack.delete(step);
const payload: StepEndPayload = {
testId,
stepId,
wallTime: Date.now(),
error,
data,
};
if (reportEvents)
this.emit('stepEnd', payload);
}
};
stepStack.add(step);
const payload: StepBeginPayload = {
testId,
stepId,
category,
title,
wallTime: Date.now(),
data,
};
stepStack.add(step);
if (reportEvents)
this.emit('stepBegin', step);
let callbackHandled = false;
return (error?: Error | TestError) => {
if (callbackHandled)
return;
callbackHandled = true;
if (error instanceof Error)
error = serializeError(error);
stepStack.delete(step);
const payload: StepEndPayload = {
testId,
stepId,
wallTime: Date.now(),
error
};
if (reportEvents)
this.emit('stepEnd', payload);
};
this.emit('stepBegin', payload);
return step;
},
_currentSteps: () => [...stepStack],
};
@ -422,18 +427,18 @@ export class WorkerRunner extends EventEmitter {
}
private async _runTestWithBeforeHooks(test: TestCase, testInfo: TestInfoImpl) {
const completeStep = testInfo._addStep('hook', 'Before Hooks');
const step = testInfo._addStep('hook', 'Before Hooks');
if (test._type === 'test')
await this._runBeforeHooks(test, testInfo);
// Do not run the test when beforeEach hook fails.
if (testInfo.status === 'failed' || testInfo.status === 'skipped') {
completeStep?.(testInfo.error);
step.complete(testInfo.error);
return;
}
try {
await this._fixtureRunner.resolveParametersAndRunHookOrTest(test.fn, this._workerInfo, testInfo, completeStep);
await this._fixtureRunner.resolveParametersAndRunHookOrTest(test.fn, this._workerInfo, testInfo, step);
} catch (error) {
if (error instanceof SkipError) {
if (testInfo.status === 'passed')
@ -448,15 +453,15 @@ export class WorkerRunner extends EventEmitter {
testInfo.error = serializeError(error);
}
} finally {
completeStep?.(testInfo.error);
step.complete(testInfo.error);
}
}
private async _runAfterHooks(test: TestCase, testInfo: TestInfoImpl) {
let completeStep: CompleteStepCallback | undefined;
let step: TestStepInternal | undefined;
let teardownError: TestError | undefined;
try {
completeStep = testInfo._addStep('hook', 'After Hooks');
step = testInfo._addStep('hook', 'After Hooks');
if (test._type === 'test')
await this._runHooks(test.parent!, 'afterEach', testInfo);
} catch (error) {
@ -480,7 +485,7 @@ export class WorkerRunner extends EventEmitter {
teardownError = testInfo.error;
}
}
completeStep?.(teardownError);
step?.complete(teardownError);
}
private async _runHooks(suite: Suite, type: 'beforeEach' | 'afterEach', testInfo: TestInfo) {

View file

@ -181,3 +181,9 @@
.test-details-column {
overflow-y: auto;
}
.step-log {
line-height: 20px;
white-space: pre;
padding: 8px;
}

View file

@ -272,7 +272,7 @@ const TestStepDetails: React.FC<{
{
id: 'log',
title: 'Log',
render: () => <div></div>
render: () => <div className='step-log'>{step?.log ? step.log.join('\n') : ''}</div>
},
{
id: 'errors',

View file

@ -139,8 +139,9 @@ export const playwrightFixtures: Fixtures<PlaywrightTestOptions & PlaywrightTest
if (trace)
await context.tracing.start({ screenshots: true, snapshots: true });
(context as any)._csi = {
onApiCall: (name: string) => {
return (testInfo as any)._addStep('pw:api', name);
onApiCall: (stackTrace: any) => {
const step = (testInfo as any)._addStep('pw:api', stackTrace.apiName);
return (log, error) => step.complete(error);
},
};
contexts.push(context);

View file

@ -506,20 +506,18 @@ test('should report api step hierarchy', async ({ runInlineTest }) => {
]);
});
test('should report expect and pw:api stacks', async ({ runInlineTest }, testInfo) => {
test('should report expect and pw:api stacks and logs', async ({ runInlineTest }, testInfo) => {
const expectReporterJS = `
class Reporter {
stepDetails(step) {
stepStack(step) {
if (!step.data.stack || !step.data.stack[0])
return step.title + ' <no stack>';
const frame = step.data.stack[0]
return step.title + ' ' + frame.file + ':' + frame.line + ':' + frame.column;
}
onStepBegin(test, result, step) {
console.log('%%%% begin', this.stepDetails(step));
return step.title + ' stack: ' + frame.file + ':' + frame.line + ':' + frame.column;
}
onStepEnd(test, result, step) {
console.log('%%%% end', this.stepDetails(step));
console.log('%%%% ' + this.stepStack(step));
console.log('%%%% ' + step.title + ' log: ' + (step.data.log || []).join(''));
}
}
module.exports = Reporter;
@ -535,20 +533,22 @@ test('should report expect and pw:api stacks', async ({ runInlineTest }, testInf
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<title>hello</title>');
await page.setContent('<title>hello</title><body><div>Click me</div></body>');
await page.click('text=Click me');
expect(1).toBe(1);
await expect(page).toHaveTitle('hello');
await expect(page.locator('div')).toHaveText('Click me');
});
`
}, { reporter: '', workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.output).toContain(`%% begin page.setContent ${testInfo.outputPath('a.test.ts:7:20')}`);
expect(result.output).toContain(`%% end page.setContent ${testInfo.outputPath('a.test.ts:7:20')}`);
expect(result.output).toContain(`%% begin expect.toBe ${testInfo.outputPath('a.test.ts:8:19')}`);
expect(result.output).toContain(`%% end expect.toBe ${testInfo.outputPath('a.test.ts:8:19')}`);
expect(result.output).toContain(`%% begin expect.toHaveTitle ${testInfo.outputPath('a.test.ts:9:28')}`);
expect(result.output).toContain(`%% end expect.toHaveTitle ${testInfo.outputPath('a.test.ts:9:28')}`);
expect(result.output).toContain(`%% page.setContent stack: ${testInfo.outputPath('a.test.ts:7:20')}`);
expect(result.output).toContain(`%% page.setContent log: setting frame content, waiting until "load"`);
expect(result.output).toContain(`%% page.click stack: ${testInfo.outputPath('a.test.ts:8:20')}`);
expect(result.output).toContain(`%% page.click log: waiting for selector "text=Click me"`);
expect(result.output).toContain(`%% expect.toBe stack: ${testInfo.outputPath('a.test.ts:9:19')}`);
expect(result.output).toContain(`%% expect.toHaveText stack: ${testInfo.outputPath('a.test.ts:10:43')}`);
expect(result.output).toContain(`%% expect.toHaveText log: retrieving textContent from "div"`);
});
function stripEscapedAscii(str: string) {