chore: split trace events into phases (#21696)

This commit is contained in:
Pavel Feldman 2023-03-15 22:33:40 -07:00 committed by GitHub
parent 40a6eff8f2
commit c45d8749b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 281 additions and 172 deletions

View File

@ -259,7 +259,6 @@ export class DispatcherConnection {
method,
params: params || {},
log: [],
snapshots: []
};
if (sdkObject && params?.info?.waitId) {

View File

@ -1391,7 +1391,7 @@ export class Frame extends SdkObject {
const injected = await context.injectedScript();
progress.throwIfAborted();
const { log, matches, received, missingRecevied } = await injected.evaluate(async (injected, { info, options, snapshotName }) => {
const { log, matches, received, missingRecevied } = await injected.evaluate(async (injected, { info, options, callId }) => {
const elements = info ? injected.querySelectorAll(info.parsed, document) : [];
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
let log = '';
@ -1401,10 +1401,10 @@ export class Frame extends SdkObject {
throw injected.strictModeViolationError(info!.parsed, elements);
else if (elements.length)
log = ` locator resolved to ${injected.previewNode(elements[0])}`;
if (snapshotName)
injected.markTargetElements(new Set(elements), snapshotName);
if (callId)
injected.markTargetElements(new Set(elements), callId);
return { log, ...(await injected.expect(elements[0], options, elements)) };
}, { info, options, snapshotName: progress.metadata.afterSnapshot });
}, { info, options, callId: metadata.id });
if (log)
progress.log(log);
@ -1552,16 +1552,16 @@ export class Frame extends SdkObject {
progress.throwIfAborted();
if (!resolved)
return continuePolling;
const { log, success, value } = await resolved.injected.evaluate((injected, { info, callbackText, taskData, snapshotName }) => {
const { log, success, value } = await resolved.injected.evaluate((injected, { info, callbackText, taskData, callId }) => {
const callback = injected.eval(callbackText) as ElementCallback<T, R>;
const element = injected.querySelector(info.parsed, document, info.strict);
if (!element)
return { success: false };
const log = ` locator resolved to ${injected.previewNode(element)}`;
if (snapshotName)
injected.markTargetElements(new Set([element]), snapshotName);
if (callId)
injected.markTargetElements(new Set([element]), callId);
return { log, success: true, value: callback(injected, element, taskData as T) };
}, { info: resolved.info, callbackText, taskData, snapshotName: progress.metadata.afterSnapshot });
}, { info: resolved.info, callbackText, taskData, callId: progress.metadata.id });
if (log)
progress.log(log);

View File

@ -1087,14 +1087,14 @@ export class InjectedScript {
}
}
markTargetElements(markedElements: Set<Element>, snapshotName: string) {
markTargetElements(markedElements: Set<Element>, callId: string) {
for (const e of this._markedTargetElements) {
if (!markedElements.has(e))
e.removeAttribute('__playwright_target__');
}
for (const e of markedElements) {
if (!this._markedTargetElements.has(e))
e.setAttribute('__playwright_target__', snapshotName);
e.setAttribute('__playwright_target__', callId);
}
this._markedTargetElements = markedElements;
}

View File

@ -112,7 +112,6 @@ export function serverSideCallMetadata(): CallMetadata {
method: '',
params: {},
log: [],
snapshots: [],
isServerSide: true,
};
}

View File

@ -575,7 +575,6 @@ class ContextRecorder extends EventEmitter {
method: action,
params,
log: [],
snapshots: [],
};
this._generator.willPerformAction(actionInContext);

View File

@ -104,14 +104,14 @@ export class Snapshotter {
eventsHelper.removeEventListeners(this._eventListeners);
}
async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise<void> {
async captureSnapshot(page: Page, callId: string, snapshotName: string, element?: ElementHandle): Promise<void> {
// Prepare expression synchronously.
const expression = `window["${this._snapshotStreamer}"].captureSnapshot(${JSON.stringify(snapshotName)})`;
// In a best-effort manner, without waiting for it, mark target element.
element?.callFunctionNoReply((element: Element, snapshotName: string) => {
element.setAttribute('__playwright_target__', snapshotName);
}, snapshotName);
element?.callFunctionNoReply((element: Element, callId: string) => {
element.setAttribute('__playwright_target__', callId);
}, callId);
// In each frame, in a non-stalling manner, capture the snapshots.
const snapshots = page.frames().map(async frame => {
@ -121,6 +121,7 @@ export class Snapshotter {
return;
const snapshot: FrameSnapshot = {
callId,
snapshotName,
pageId: page.guid,
frameId: frame.guid,

View File

@ -71,7 +71,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
private _snapshotter?: Snapshotter;
private _harTracer: HarTracer;
private _screencastListeners: RegisteredListener[] = [];
private _pendingCalls = new Map<string, { sdkObject: SdkObject, metadata: CallMetadata, beforeSnapshot: Promise<void>, actionSnapshot?: Promise<void>, afterSnapshot?: Promise<void> }>();
private _context: BrowserContext | APIRequestContext;
private _state: RecordingState | undefined;
private _isStopping = false;
@ -249,19 +248,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
if (this._state?.options.screenshots)
this._stopScreencast();
for (const { sdkObject, metadata, beforeSnapshot, actionSnapshot, afterSnapshot } of this._pendingCalls.values()) {
await Promise.all([beforeSnapshot, actionSnapshot, afterSnapshot]);
let callMetadata = metadata;
if (!afterSnapshot) {
// Note: we should not modify metadata here to avoid side-effects in any other place.
callMetadata = {
...metadata,
error: { error: { name: 'Error', message: 'Action was interrupted' } },
};
}
await this.onAfterCall(sdkObject, callMetadata);
}
if (state.options.snapshots)
await this._snapshotter?.stop();
@ -309,7 +295,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return result;
}
async _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) {
async _captureSnapshot(snapshotName: string, sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise<void> {
if (!this._snapshotter)
return;
if (!sdkObject.attribution.page)
@ -318,47 +304,43 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return;
if (!shouldCaptureSnapshot(metadata))
return;
const snapshotName = `${name}@${metadata.id}`;
metadata.snapshots.push({ title: name, snapshotName });
// We have |element| for input actions (page.click and handle.click)
// and |sdkObject| element for accessors like handle.textContent.
if (!element && sdkObject instanceof ElementHandle)
element = sdkObject;
await this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName, element).catch(() => {});
await this._snapshotter.captureSnapshot(sdkObject.attribution.page, metadata.id, snapshotName, element).catch(() => {});
}
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {
// IMPORTANT: no awaits before this._appendTraceEvent in this method.
const event = createBeforeActionTraceEvent(metadata);
if (!event)
return Promise.resolve();
sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling();
// Set afterSnapshot name for all the actions that operate selectors.
// Elements resolved from selectors will be marked on the snapshot.
metadata.afterSnapshot = `after@${metadata.id}`;
const beforeSnapshot = this._captureSnapshot('before', sdkObject, metadata);
this._pendingCalls.set(metadata.id, { sdkObject, metadata, beforeSnapshot });
await beforeSnapshot;
event.beforeSnapshot = `before@${metadata.id}`;
this._appendTraceEvent(event);
return this._captureSnapshot(event.beforeSnapshot, sdkObject, metadata);
}
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle) {
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle) {
// IMPORTANT: no awaits before this._appendTraceEvent in this method.
const event = createInputActionTraceEvent(metadata);
if (!event)
return Promise.resolve();
sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling();
const actionSnapshot = this._captureSnapshot('action', sdkObject, metadata, element);
this._pendingCalls.get(metadata.id)!.actionSnapshot = actionSnapshot;
await actionSnapshot;
event.inputSnapshot = `input@${metadata.id}`;
this._appendTraceEvent(event);
return this._captureSnapshot(event.inputSnapshot, sdkObject, metadata, element);
}
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) {
const event = createAfterActionTraceEvent(metadata);
if (!event)
return Promise.resolve();
sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling();
const pendingCall = this._pendingCalls.get(metadata.id);
if (!pendingCall || pendingCall.afterSnapshot)
return;
if (!sdkObject.attribution.context) {
this._pendingCalls.delete(metadata.id);
return;
}
pendingCall.afterSnapshot = this._captureSnapshot('after', sdkObject, metadata);
await pendingCall.afterSnapshot;
const event = createActionTraceEvent(metadata);
if (event)
this._appendTraceEvent(event);
this._pendingCalls.delete(metadata.id);
event.afterSnapshot = `after@${metadata.id}`;
this._appendTraceEvent(event);
return this._captureSnapshot(event.afterSnapshot, sdkObject, metadata);
}
onEvent(sdkObject: SdkObject, event: trace.EventTraceEvent) {
@ -492,24 +474,41 @@ export function shouldCaptureSnapshot(metadata: CallMetadata): boolean {
return commandsWithTracingSnapshots.has(metadata.type + '.' + metadata.method);
}
function createActionTraceEvent(metadata: CallMetadata): trace.ActionTraceEvent | null {
function createBeforeActionTraceEvent(metadata: CallMetadata): trace.BeforeActionTraceEvent | null {
if (metadata.internal || metadata.method.startsWith('tracing'))
return null;
return {
type: 'action',
type: 'before',
callId: metadata.id,
startTime: metadata.startTime,
endTime: metadata.endTime,
apiName: metadata.apiName || metadata.type + '.' + metadata.method,
class: metadata.type,
method: metadata.method,
params: metadata.params,
wallTime: metadata.wallTime || Date.now(),
log: metadata.log,
snapshots: metadata.snapshots,
error: metadata.error?.error,
result: metadata.result,
point: metadata.point,
pageId: metadata.pageId,
};
}
function createInputActionTraceEvent(metadata: CallMetadata): trace.InputActionTraceEvent | null {
if (metadata.internal || metadata.method.startsWith('tracing'))
return null;
return {
type: 'input',
callId: metadata.id,
point: metadata.point,
};
}
function createAfterActionTraceEvent(metadata: CallMetadata): trace.AfterActionTraceEvent | null {
if (metadata.internal || metadata.method.startsWith('tracing'))
return null;
return {
type: 'after',
callId: metadata.id,
endTime: metadata.endTime,
log: metadata.log,
error: metadata.error?.error,
result: metadata.result,
};
}

View File

@ -56,11 +56,11 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
this._harTracer.stop();
}
async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise<SnapshotRenderer> {
async captureSnapshot(page: Page, callId: string, snapshotName: string, element?: ElementHandle): Promise<SnapshotRenderer> {
if (this._frameSnapshots.has(snapshotName))
throw new Error('Duplicate snapshot name: ' + snapshotName);
this._snapshotter.captureSnapshot(page, snapshotName, element).catch(() => {});
this._snapshotter.captureSnapshot(page, callId, snapshotName, element).catch(() => {});
return new Promise<SnapshotRenderer>(fulfill => {
const disposable = this.onSnapshotEvent((renderer: SnapshotRenderer) => {
if (renderer.snapshotName === snapshotName) {

View File

@ -16,11 +16,11 @@
import fs from 'fs';
import type EventEmitter from 'events';
import type { ClientSideCallMetadata, StackFrame } from '@protocol/channels';
import type { ClientSideCallMetadata, SerializedError, StackFrame } from '@protocol/channels';
import type { SerializedClientSideCallMetadata, SerializedStack, SerializedStackFrame } from './isomorphic/traceUtils';
import { yazl, yauzl } from '../zipBundle';
import { ManualPromise } from './manualPromise';
import type { ActionTraceEvent } from '@trace/trace';
import type { AfterActionTraceEvent, BeforeActionTraceEvent, TraceEvent } from '@trace/trace';
import { calculateSha1 } from './crypto';
import { monotonicTime } from './time';
@ -96,7 +96,7 @@ export async function mergeTraceFiles(fileName: string, temporaryTraceFiles: str
await mergePromise;
}
export async function saveTraceFile(fileName: string, traceEvents: ActionTraceEvent[], saveSources: boolean) {
export async function saveTraceFile(fileName: string, traceEvents: TraceEvent[], saveSources: boolean) {
const lines: string[] = traceEvents.map(e => JSON.stringify(e));
const zipFile = new yazl.ZipFile();
zipFile.addBuffer(Buffer.from(lines.join('\n')), 'trace.trace');
@ -104,8 +104,10 @@ export async function saveTraceFile(fileName: string, traceEvents: ActionTraceEv
if (saveSources) {
const sourceFiles = new Set<string>();
for (const event of traceEvents) {
for (const frame of event.stack || [])
sourceFiles.add(frame.file);
if (event.type === 'before') {
for (const frame of event.stack || [])
sourceFiles.add(frame.file);
}
}
for (const sourceFile of sourceFiles) {
await fs.promises.readFile(sourceFile, 'utf8').then(source => {
@ -120,23 +122,30 @@ export async function saveTraceFile(fileName: string, traceEvents: ActionTraceEv
});
}
export function createTraceEventForExpect(apiName: string, expected: any, stack: StackFrame[], wallTime: number): ActionTraceEvent {
export function createBeforeActionTraceEventForExpect(callId: string, apiName: string, expected: any, stack: StackFrame[]): BeforeActionTraceEvent {
return {
type: 'action',
callId: 'expect@' + wallTime,
wallTime,
type: 'before',
callId,
wallTime: Date.now(),
startTime: monotonicTime(),
endTime: 0,
class: 'Test',
method: 'step',
apiName,
params: { expected: generatePreview(expected) },
snapshots: [],
log: [],
stack,
};
}
export function createAfterActionTraceEventForExpect(callId: string, error?: SerializedError['error']): AfterActionTraceEvent {
return {
type: 'after',
callId,
endTime: monotonicTime(),
log: [],
error,
};
}
function generatePreview(value: any, visited = new Set<any>()): string {
if (visited.has(value))
return '';

View File

@ -14,7 +14,11 @@
* limitations under the License.
*/
import { captureRawStack, createTraceEventForExpect, monotonicTime, pollAgainstTimeout } from 'playwright-core/lib/utils';
import {
captureRawStack,
createAfterActionTraceEventForExpect,
createBeforeActionTraceEventForExpect,
pollAgainstTimeout } from 'playwright-core/lib/utils';
import type { ExpectZone } from 'playwright-core/lib/utils';
import {
toBeChecked,
@ -72,6 +76,8 @@ export type SyncExpectationResult = {
// The replacement is compatible with pretty-format package.
const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&');
let lastCallId = 0;
export const printReceivedStringContainExpectedSubstring = (
received: string,
start: number,
@ -215,9 +221,9 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
testInfo.currentStep = step;
const generateTraceEvent = matcherName !== 'poll' && matcherName !== 'toPass';
const traceEvent = generateTraceEvent ? createTraceEventForExpect(defaultTitle, args[0], stackFrames, wallTime) : undefined;
if (traceEvent)
testInfo._traceEvents.push(traceEvent);
const callId = ++lastCallId;
if (generateTraceEvent)
testInfo._traceEvents.push(createBeforeActionTraceEventForExpect(`expect@${callId}`, defaultTitle, args[0], stackFrames));
const reportStepError = (jestError: Error) => {
const message = jestError.message;
@ -243,11 +249,11 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
}
const serializerError = serializeError(jestError);
if (traceEvent) {
traceEvent.error = { name: jestError.name, message: jestError.message, stack: jestError.stack };
traceEvent.endTime = monotonicTime();
step.complete({ error: serializerError });
if (generateTraceEvent) {
const error = { name: jestError.name, message: jestError.message, stack: jestError.stack };
testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, error));
}
step.complete({ error: serializerError });
if (this._info.isSoft)
testInfo._failWithError(serializerError, false /* isHardError */);
else
@ -255,8 +261,8 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
};
const finalizer = () => {
if (traceEvent)
traceEvent.endTime = monotonicTime();
if (generateTraceEvent)
testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`));
step.complete({});
};

View File

@ -36,8 +36,6 @@ export type CallMetadata = {
wallTime?: number;
location?: { file: string, line?: number, column?: number };
log: string[];
afterSnapshot?: string;
snapshots: { title: string, snapshotName: string }[];
error?: SerializedError;
result?: any;
point?: Point;

View File

@ -22,12 +22,14 @@ export class SnapshotRenderer {
readonly snapshotName: string | undefined;
_resources: ResourceSnapshot[];
private _snapshot: FrameSnapshot;
private _callId: string;
constructor(resources: ResourceSnapshot[], snapshots: FrameSnapshot[], index: number) {
this._resources = resources;
this._snapshots = snapshots;
this._index = index;
this._snapshot = snapshots[index];
this._callId = snapshots[index].callId;
this.snapshotName = snapshots[index].snapshotName;
}
@ -102,7 +104,7 @@ export class SnapshotRenderer {
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
html = prefix + [
'<style>*,*::before,*::after { visibility: hidden }</style>',
`<style>*[__playwright_target__="${this.snapshotName}"] { background-color: #6fa8dc7f; }</style>`,
`<style>*[__playwright_target__="${this._callId}"] { background-color: #6fa8dc7f; }</style>`,
`<script>${snapshotScript()}</script>`
].join('') + html;

View File

@ -37,7 +37,8 @@ export class TraceModel {
}
async load(traceURL: string, progress: (done: number, total: number) => void) {
this._backend = traceURL.endsWith('json') ? new FetchTraceModelBackend(traceURL) : new ZipTraceModelBackend(traceURL, progress);
const isLive = traceURL.endsWith('json');
this._backend = isLive ? new FetchTraceModelBackend(traceURL) : new ZipTraceModelBackend(traceURL, progress);
const ordinals: string[] = [];
let hasSource = false;
@ -55,16 +56,25 @@ export class TraceModel {
for (const ordinal of ordinals) {
const contextEntry = createEmptyContext();
const actionMap = new Map<string, trace.ActionTraceEvent>();
contextEntry.traceUrl = traceURL;
contextEntry.hasSource = hasSource;
const trace = await this._backend.readText(ordinal + 'trace.trace') || '';
for (const line of trace.split('\n'))
this.appendEvent(contextEntry, line);
this.appendEvent(contextEntry, actionMap, line);
const network = await this._backend.readText(ordinal + 'trace.network') || '';
for (const line of network.split('\n'))
this.appendEvent(contextEntry, line);
this.appendEvent(contextEntry, actionMap, line);
contextEntry.actions = [...actionMap.values()].sort((a1, a2) => a1.startTime - a2.startTime);
if (!isLive) {
for (const action of contextEntry.actions) {
if (!action.endTime && !action.error)
action.error = { name: 'Error', message: 'Timed out' };
}
}
const stacks = await this._backend.readText(ordinal + 'trace.stacks');
if (stacks) {
@ -73,7 +83,6 @@ export class TraceModel {
action.stack = action.stack || callMetadata.get(action.callId);
}
contextEntry.actions.sort((a1, a2) => a1.startTime - a2.startTime);
this.contextEntries.push(contextEntry);
}
}
@ -102,7 +111,7 @@ export class TraceModel {
return pageEntry;
}
appendEvent(contextEntry: ContextEntry, line: string) {
appendEvent(contextEntry: ContextEntry, actionMap: Map<string, trace.ActionTraceEvent>, line: string) {
if (!line)
return;
const event = this._modernize(JSON.parse(line));
@ -124,8 +133,27 @@ export class TraceModel {
this._pageEntry(contextEntry, event.pageId).screencastFrames.push(event);
break;
}
case 'before': {
actionMap.set(event.callId, { ...event, type: 'action', endTime: 0, log: [] });
break;
}
case 'input': {
const existing = actionMap.get(event.callId);
existing!.inputSnapshot = event.inputSnapshot;
existing!.point = event.point;
break;
}
case 'after': {
const existing = actionMap.get(event.callId);
existing!.afterSnapshot = event.afterSnapshot;
existing!.endTime = event.endTime;
existing!.log = event.log;
existing!.result = event.result;
existing!.error = event.error;
break;
}
case 'action': {
contextEntry!.actions.push(event);
actionMap.set(event.callId, event);
break;
}
case 'event': {
@ -144,10 +172,10 @@ export class TraceModel {
this._snapshotStorage!.addFrameSnapshot(event.snapshot);
break;
}
if (event.type === 'action') {
if (event.type === 'action' || event.type === 'before')
contextEntry.startTime = Math.min(contextEntry.startTime, event.startTime);
if (event.type === 'action' || event.type === 'after')
contextEntry.endTime = Math.max(contextEntry.endTime, event.endTime);
}
if (event.type === 'event') {
contextEntry.startTime = Math.min(contextEntry.startTime, event.time);
contextEntry.endTime = Math.max(contextEntry.endTime, event.time);
@ -251,7 +279,9 @@ export class TraceModel {
params: metadata.params,
wallTime: metadata.wallTime || Date.now(),
log: metadata.log,
snapshots: metadata.snapshots,
beforeSnapshot: metadata.snapshots.find(s => s.snapshotName === 'before')?.snapshotName,
inputSnapshot: metadata.snapshots.find(s => s.snapshotName === 'input')?.snapshotName,
afterSnapshot: metadata.snapshots.find(s => s.snapshotName === 'after')?.snapshotName,
error: metadata.error?.error,
result: metadata.result,
point: metadata.point,

View File

@ -42,11 +42,13 @@ export const SnapshotTab: React.FunctionComponent<{
const [pickerVisible, setPickerVisible] = React.useState(false);
const { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => {
const snapshotMap = new Map<string, { title: string, snapshotName: string }>();
for (const snapshot of action?.snapshots || [])
snapshotMap.set(snapshot.title, snapshot);
const actionSnapshot = snapshotMap.get('action') || snapshotMap.get('after');
const snapshots = [actionSnapshot ? { ...actionSnapshot, title: 'action' } : undefined, snapshotMap.get('before'), snapshotMap.get('after')].filter(Boolean) as { title: string, snapshotName: string }[];
const actionSnapshot = action?.inputSnapshot || action?.afterSnapshot;
const snapshots = [
actionSnapshot ? { title: 'action', snapshotName: actionSnapshot } : undefined,
action?.beforeSnapshot ? { title: 'before', snapshotName: action?.beforeSnapshot } : undefined,
action?.afterSnapshot ? { title: 'after', snapshotName: action.afterSnapshot } : undefined,
].filter(Boolean) as { title: string, snapshotName: string }[];
let snapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';
let popoutUrl: string | undefined;
let snapshotInfoUrl: string | undefined;
@ -60,7 +62,7 @@ export const SnapshotTab: React.FunctionComponent<{
params.set('name', snapshot.snapshotName);
snapshotUrl = new URL(`snapshot/${action.pageId}?${params.toString()}`, window.location.href).toString();
snapshotInfoUrl = new URL(`snapshotInfo/${action.pageId}?${params.toString()}`, window.location.href).toString();
if (snapshot.snapshotName.includes('action')) {
if (snapshot.title === 'action') {
pointX = action.point?.x;
pointY = action.point?.y;
}

View File

@ -33,6 +33,7 @@ import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper';
import { Expandable } from '@web/components/expandable';
import { toggleTheme } from '@web/theme';
import { artifactsFolderName } from '@testIsomorphic/folders';
let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {};
let runWatchedTests = (fileName: string) => {};
@ -392,30 +393,42 @@ const TraceView: React.FC<{
result: TestResult | undefined,
}> = ({ outputDir, testCase, result }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
const [currentStep, setCurrentStep] = React.useState(0);
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
// Test finished.
const isFinished = result && result.duration >= 0;
if (isFinished) {
const attachment = result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path)
loadSingleTraceFile(attachment.path).then(setModel);
if (!result) {
setModel(undefined);
return;
}
const traceLocation = `${outputDir}/.playwright-artifacts-${result?.workerIndex}/traces/${testCase?.id}.json`;
// Test finished.
const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path) {
loadSingleTraceFile(attachment.path).then(model => setModel(model));
return;
}
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${testCase?.id}.json`;
// Start polling running test.
pollTimer.current = setTimeout(() => {
loadSingleTraceFile(traceLocation).then(setModel).then(() => {
setCurrentStep(currentStep + 1);
});
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(traceLocation);
setModel(model);
} catch {
setModel(undefined);
} finally {
setCounter(counter + 1);
}
}, 250);
}, [result, outputDir, testCase, currentStep, setCurrentStep]);
return () => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [result, outputDir, testCase, setModel, counter, setCounter]);
return <Workbench key='workbench' model={model} hideTimelineBars={true} hideStackFrames={true} showSourcesFirst={true} />;
};

View File

@ -97,6 +97,8 @@ export type ResourceOverride = {
};
export type FrameSnapshot = {
// There was no callId in the original, we are intentionally regressing it.
callId: string;
snapshotName?: string,
pageId: string,
frameId: string,

View File

@ -39,6 +39,7 @@ export type ResourceOverride = {
export type FrameSnapshot = {
snapshotName?: string,
callId: string,
pageId: string,
frameId: string,
frameUrl: string,

View File

@ -51,23 +51,35 @@ export type ScreencastFrameTraceEvent = {
timestamp: number,
};
export type ActionTraceEvent = {
type: 'action',
export type BeforeActionTraceEvent = {
type: 'before',
callId: string;
startTime: number;
endTime: number;
apiName: string;
class: string;
method: string;
params: any;
wallTime: number;
log: string[];
snapshots: { title: string, snapshotName: string }[];
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
};
export type InputActionTraceEvent = {
type: 'input',
callId: string;
inputSnapshot?: string;
point?: Point;
};
export type AfterActionTraceEvent = {
type: 'after',
callId: string;
endTime: number;
afterSnapshot?: string;
log: string[];
error?: SerializedError['error'];
result?: any;
point?: Point;
pageId?: string;
};
export type EventTraceEvent = {
@ -96,10 +108,19 @@ export type FrameSnapshotTraceEvent = {
snapshot: FrameSnapshot,
};
export type ActionTraceEvent = {
type: 'action',
} & Omit<BeforeActionTraceEvent, 'type'>
& Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>;
export type TraceEvent =
ContextCreatedTraceEvent |
ScreencastFrameTraceEvent |
ActionTraceEvent |
BeforeActionTraceEvent |
InputActionTraceEvent |
AfterActionTraceEvent |
EventTraceEvent |
ObjectTraceEvent |
ResourceSnapshotTraceEvent |

View File

@ -18,7 +18,7 @@ import type { Frame, Page } from 'playwright-core';
import { ZipFile } from '../../packages/playwright-core/lib/utils/zipFile';
import type { StackFrame } from '../../packages/protocol/src/channels';
import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils';
import type { ActionTraceEvent } from '../../packages/trace/src/trace';
import type { ActionTraceEvent, TraceEvent } from '../../packages/trace/src/trace';
export async function attachFrame(page: Page, frameId: string, url: string): Promise<Frame> {
const handle = await page.evaluateHandle(async ({ frameId, url }) => {
@ -101,11 +101,36 @@ export async function parseTrace(file: string): Promise<{ events: any[], resourc
resources.set(entry, await zipFS.read(entry));
zipFS.close();
const actionMap = new Map<string, ActionTraceEvent>();
const events: any[] = [];
for (const traceFile of [...resources.keys()].filter(name => name.endsWith('.trace'))) {
for (const line of resources.get(traceFile)!.toString().split('\n')) {
if (line)
events.push(JSON.parse(line));
if (line) {
const event = JSON.parse(line) as TraceEvent;
if (event.type === 'before') {
const action: ActionTraceEvent = {
...event,
type: 'action',
endTime: 0,
log: []
};
events.push(action);
actionMap.set(event.callId, action);
} else if (event.type === 'input') {
const existing = actionMap.get(event.callId);
existing.inputSnapshot = event.inputSnapshot;
existing.point = event.point;
} else if (event.type === 'after') {
const existing = actionMap.get(event.callId);
existing.afterSnapshot = event.afterSnapshot;
existing.endTime = event.endTime;
existing.log = event.log;
existing.error = event.error;
existing.result = event.result;
} else {
events.push(event);
}
}
}
}

View File

@ -29,19 +29,19 @@ const it = contextTest.extend<{ snapshotter: InMemorySnapshotter }>({
it.describe('snapshots', () => {
it('should collect snapshot', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<button>Hello</button>');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot)).toBe('<BUTTON>Hello</BUTTON>');
});
it('should preserve BASE and other content on reset', async ({ page, toImpl, snapshotter, server }) => {
await page.goto(server.EMPTY_PAGE);
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
const html1 = snapshot1.render().html;
expect(html1).toContain(`<BASE href="${server.EMPTY_PAGE}"`);
await snapshotter.reset();
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
const html2 = snapshot2.render().html;
expect(html2.replace(`"snapshot2"`, `"snapshot1"`)).toEqual(html1);
expect(html2.replace(`"call@2"`, `"call@1"`)).toEqual(html1);
});
it('should capture resources', async ({ page, toImpl, server, snapshotter }) => {
@ -50,7 +50,7 @@ it.describe('snapshots', () => {
route.fulfill({ body: 'button { color: red; }', }).catch(() => {});
});
await page.setContent('<link rel="stylesheet" href="style.css"><button>Hello</button>');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
const resource = snapshot.resourceByUrl(`http://localhost:${server.PORT}/style.css`);
expect(resource).toBeTruthy();
});
@ -59,36 +59,36 @@ it.describe('snapshots', () => {
await page.setContent('<button>Hello</button>');
const snapshots = [];
snapshotter.onSnapshotEvent(snapshot => snapshots.push(snapshot));
await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
expect(snapshots.length).toBe(2);
});
it('should respect inline CSSOM change', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<style>button { color: red; }</style><button>Hello</button>');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot1)).toBe('<STYLE>button { color: red; }</STYLE><BUTTON>Hello</BUTTON>');
await page.evaluate(() => { (document.styleSheets[0].cssRules[0] as any).style.color = 'blue'; });
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
expect(distillSnapshot(snapshot2)).toBe('<STYLE>button { color: blue; }</STYLE><BUTTON>Hello</BUTTON>');
});
it('should respect node removal', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<div><button id="button1"></button><button id="button2"></button></div>');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot1)).toBe('<DIV><BUTTON id=\"button1\"></BUTTON><BUTTON id=\"button2\"></BUTTON></DIV>');
await page.evaluate(() => document.getElementById('button2').remove());
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
expect(distillSnapshot(snapshot2)).toBe('<DIV><BUTTON id=\"button1\"></BUTTON></DIV>');
});
it('should respect attr removal', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<div id="div" attr1="1" attr2="2"></div>');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot1)).toBe('<DIV id=\"div\" attr1=\"1\" attr2=\"2\"></DIV>');
await page.evaluate(() => document.getElementById('div').removeAttribute('attr2'));
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot@call@2');
expect(distillSnapshot(snapshot2)).toBe('<DIV id=\"div\" attr1=\"1\"></DIV>');
});
@ -96,21 +96,21 @@ it.describe('snapshots', () => {
await page.goto(server.EMPTY_PAGE);
await page.setContent('<!DOCTYPE foo><body>hi</body>');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot)).toBe('<!DOCTYPE foo>hi');
});
it('should replace meta charset attr that specifies charset', async ({ page, server, toImpl, snapshotter }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent('<meta charset="shift-jis" />');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot)).toBe('<META charset="utf-8">');
});
it('should replace meta content attr that specifies charset', async ({ page, server, toImpl, snapshotter }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent('<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot)).toBe('<META http-equiv="Content-Type" content="text/html; charset=utf-8">');
});
@ -121,11 +121,11 @@ it.describe('snapshots', () => {
});
await page.setContent('<link rel="stylesheet" href="style.css"><button>Hello</button>');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot1)).toBe('<LINK rel=\"stylesheet\" href=\"style.css\"><BUTTON>Hello</BUTTON>');
await page.evaluate(() => { (document.styleSheets[0].cssRules[0] as any).style.color = 'blue'; });
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
const resource = snapshot2.resourceByUrl(`http://localhost:${server.PORT}/style.css`);
expect((await snapshotter.resourceContentForTest(resource.response.content._sha1)).toString()).toBe('button { color: blue; }');
});
@ -146,7 +146,7 @@ it.describe('snapshots', () => {
await page.goto(server.EMPTY_PAGE);
for (let counter = 0; ; ++counter) {
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot' + counter);
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@' + counter, 'snapshot@call@' + counter);
const text = distillSnapshot(snapshot).replace(/frame@[^"]+["]/, '<id>"');
if (text === '<FRAMESET><FRAME __playwright_src__=\"/snapshot/<id>\"></FRAME></FRAMESET>')
break;
@ -191,7 +191,7 @@ it.describe('snapshots', () => {
// Marking iframe hierarchy is racy, do not expect snapshot, wait for it.
for (let counter = 0; ; ++counter) {
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot' + counter);
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@' + counter, 'snapshot@call@' + counter);
const text = distillSnapshot(snapshot).replace(/frame@[^"]+["]/, '<id>"');
if (text === '<IFRAME __playwright_src__=\"/snapshot/<id>\"></IFRAME>')
break;
@ -203,31 +203,31 @@ it.describe('snapshots', () => {
await page.setContent('<button>Hello</button><button>World</button>');
{
const handle = await page.$('text=Hello');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot', toImpl(handle));
expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"snapshot\">Hello</BUTTON><BUTTON>World</BUTTON>');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1', toImpl(handle));
expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"call@1\">Hello</BUTTON><BUTTON>World</BUTTON>');
}
{
const handle = await page.$('text=World');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2', toImpl(handle));
expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"snapshot\">Hello</BUTTON><BUTTON __playwright_target__=\"snapshot2\">World</BUTTON>');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2', toImpl(handle));
expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"call@1\">Hello</BUTTON><BUTTON __playwright_target__=\"call@2\">World</BUTTON>');
}
});
it('should collect on attribute change', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<button>Hello</button>');
{
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot)).toBe('<BUTTON>Hello</BUTTON>');
}
const handle = await page.$('text=Hello')!;
await handle.evaluate(element => element.setAttribute('data', 'one'));
{
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
expect(distillSnapshot(snapshot)).toBe('<BUTTON data="one">Hello</BUTTON>');
}
await handle.evaluate(element => element.setAttribute('data', 'two'));
{
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot@call@2');
expect(distillSnapshot(snapshot)).toBe('<BUTTON data="two">Hello</BUTTON>');
}
});
@ -251,11 +251,11 @@ it.describe('snapshots', () => {
}
});
const renderer1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
const renderer1 = await snapshotter.captureSnapshot(toImpl(page), 'call1', 'snapshot@call@1');
// Expect some adopted style sheets.
expect(distillSnapshot(renderer1)).toContain('__playwright_style_sheet_');
const renderer2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
const renderer2 = await snapshotter.captureSnapshot(toImpl(page), 'call2', 'snapshot@call@2');
const snapshot2 = renderer2.snapshot();
// Second snapshot should be just a copy of the first one.
expect(snapshot2.html).toEqual([[1, 13]]);
@ -263,7 +263,7 @@ it.describe('snapshots', () => {
it('should not navigate on anchor clicks', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<a href="https://example.com">example.com</a>');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
expect(distillSnapshot(snapshot)).toBe('<A href="link://https://example.com">example.com</A>');
});
});

View File

@ -113,7 +113,8 @@ test('should not include buffers in the trace', async ({ context, page, server,
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events } = await parseTrace(testInfo.outputPath('trace.zip'));
const screenshotEvent = events.find(e => e.type === 'action' && e.apiName === 'page.screenshot');
expect(screenshotEvent.snapshots.length).toBe(2);
expect(screenshotEvent.beforeSnapshot).toBeTruthy();
expect(screenshotEvent.afterSnapshot).toBeTruthy();
expect(screenshotEvent.result).toEqual({});
});
@ -405,7 +406,6 @@ test('should include interrupted actions', async ({ context, page, server }, tes
const { events } = await parseTrace(testInfo.outputPath('trace.zip'));
const clickEvent = events.find(e => e.apiName === 'page.click');
expect(clickEvent).toBeTruthy();
expect(clickEvent.error.message).toBe('Action was interrupted');
});
test('should throw when starting with different options', async ({ context }) => {
@ -448,8 +448,6 @@ test('should work with multiple chunks', async ({ context, page, server }, testI
'page.click',
'page.click',
]);
expect(trace1.events.find(e => e.apiName === 'page.click' && !!e.error)).toBeTruthy();
expect(trace1.events.find(e => e.apiName === 'page.click' && e.error?.message === 'Action was interrupted')).toBeTruthy();
expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBeTruthy();
expect(trace1.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).toBeTruthy();

View File

@ -115,15 +115,15 @@ it('should support has:locator', async ({ page, trace }) => {
await expect(page.locator(`div`, {
has: page.locator(`text=world`)
})).toHaveCount(1);
expect(await page.locator(`div`, {
expect(removeHighlight(await page.locator(`div`, {
has: page.locator(`text=world`)
}).evaluate(e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
}).evaluate(e => e.outerHTML))).toBe(`<div><span>world</span></div>`);
await expect(page.locator(`div`, {
has: page.locator(`text="hello"`)
})).toHaveCount(1);
expect(await page.locator(`div`, {
expect(removeHighlight(await page.locator(`div`, {
has: page.locator(`text="hello"`)
}).evaluate(e => e.outerHTML)).toBe(`<div><span>hello</span></div>`);
}).evaluate(e => e.outerHTML))).toBe(`<div><span>hello</span></div>`);
await expect(page.locator(`div`, {
has: page.locator(`xpath=./span`)
})).toHaveCount(2);
@ -133,9 +133,9 @@ it('should support has:locator', async ({ page, trace }) => {
await expect(page.locator(`div`, {
has: page.locator(`span`, { hasText: 'wor' })
})).toHaveCount(1);
expect(await page.locator(`div`, {
expect(removeHighlight(await page.locator(`div`, {
has: page.locator(`span`, { hasText: 'wor' })
}).evaluate(e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
}).evaluate(e => e.outerHTML))).toBe(`<div><span>world</span></div>`);
await expect(page.locator(`div`, {
has: page.locator(`span`),
hasText: 'wor',
@ -180,3 +180,7 @@ it('alias methods coverage', async ({ page }) => {
await expect(page.locator('div').getByRole('button')).toHaveCount(1);
await expect(page.mainFrame().locator('button')).toHaveCount(1);
});
function removeHighlight(markup: string) {
return markup.replace(/\s__playwright_target__="[^"]+"/, '');
}

View File

@ -123,6 +123,7 @@ const testFiles = {
};
test.slow(true, 'Multiple browser launches in each test');
test.describe.configure({ mode: 'parallel' });
test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({