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, method,
params: params || {}, params: params || {},
log: [], log: [],
snapshots: []
}; };
if (sdkObject && params?.info?.waitId) { if (sdkObject && params?.info?.waitId) {

View file

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

View file

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

View file

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

View file

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

View file

@ -71,7 +71,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
private _snapshotter?: Snapshotter; private _snapshotter?: Snapshotter;
private _harTracer: HarTracer; private _harTracer: HarTracer;
private _screencastListeners: RegisteredListener[] = []; 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 _context: BrowserContext | APIRequestContext;
private _state: RecordingState | undefined; private _state: RecordingState | undefined;
private _isStopping = false; private _isStopping = false;
@ -249,19 +248,6 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
if (this._state?.options.screenshots) if (this._state?.options.screenshots)
this._stopScreencast(); 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) if (state.options.snapshots)
await this._snapshotter?.stop(); await this._snapshotter?.stop();
@ -309,7 +295,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return result; 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) if (!this._snapshotter)
return; return;
if (!sdkObject.attribution.page) if (!sdkObject.attribution.page)
@ -318,47 +304,43 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return; return;
if (!shouldCaptureSnapshot(metadata)) if (!shouldCaptureSnapshot(metadata))
return; return;
const snapshotName = `${name}@${metadata.id}`;
metadata.snapshots.push({ title: name, snapshotName });
// We have |element| for input actions (page.click and handle.click) // We have |element| for input actions (page.click and handle.click)
// and |sdkObject| element for accessors like handle.textContent. // and |sdkObject| element for accessors like handle.textContent.
if (!element && sdkObject instanceof ElementHandle) if (!element && sdkObject instanceof ElementHandle)
element = sdkObject; 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(); sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling();
// Set afterSnapshot name for all the actions that operate selectors. event.beforeSnapshot = `before@${metadata.id}`;
// Elements resolved from selectors will be marked on the snapshot. this._appendTraceEvent(event);
metadata.afterSnapshot = `after@${metadata.id}`; return this._captureSnapshot(event.beforeSnapshot, sdkObject, metadata);
const beforeSnapshot = this._captureSnapshot('before', sdkObject, metadata);
this._pendingCalls.set(metadata.id, { sdkObject, metadata, beforeSnapshot });
await beforeSnapshot;
} }
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(); sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling();
const actionSnapshot = this._captureSnapshot('action', sdkObject, metadata, element); event.inputSnapshot = `input@${metadata.id}`;
this._pendingCalls.get(metadata.id)!.actionSnapshot = actionSnapshot; this._appendTraceEvent(event);
await actionSnapshot; return this._captureSnapshot(event.inputSnapshot, sdkObject, metadata, element);
} }
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) {
const event = createAfterActionTraceEvent(metadata);
if (!event)
return Promise.resolve();
sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling(); sdkObject.attribution.page?.temporarlyDisableTracingScreencastThrottling();
const pendingCall = this._pendingCalls.get(metadata.id); event.afterSnapshot = `after@${metadata.id}`;
if (!pendingCall || pendingCall.afterSnapshot) this._appendTraceEvent(event);
return; return this._captureSnapshot(event.afterSnapshot, sdkObject, metadata);
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);
} }
onEvent(sdkObject: SdkObject, event: trace.EventTraceEvent) { onEvent(sdkObject: SdkObject, event: trace.EventTraceEvent) {
@ -492,24 +474,41 @@ export function shouldCaptureSnapshot(metadata: CallMetadata): boolean {
return commandsWithTracingSnapshots.has(metadata.type + '.' + metadata.method); 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')) if (metadata.internal || metadata.method.startsWith('tracing'))
return null; return null;
return { return {
type: 'action', type: 'before',
callId: metadata.id, callId: metadata.id,
startTime: metadata.startTime, startTime: metadata.startTime,
endTime: metadata.endTime,
apiName: metadata.apiName || metadata.type + '.' + metadata.method, apiName: metadata.apiName || metadata.type + '.' + metadata.method,
class: metadata.type, class: metadata.type,
method: metadata.method, method: metadata.method,
params: metadata.params, params: metadata.params,
wallTime: metadata.wallTime || Date.now(), wallTime: metadata.wallTime || Date.now(),
log: metadata.log,
snapshots: metadata.snapshots,
error: metadata.error?.error,
result: metadata.result,
point: metadata.point,
pageId: metadata.pageId, 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(); 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)) if (this._frameSnapshots.has(snapshotName))
throw new Error('Duplicate snapshot name: ' + 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 => { return new Promise<SnapshotRenderer>(fulfill => {
const disposable = this.onSnapshotEvent((renderer: SnapshotRenderer) => { const disposable = this.onSnapshotEvent((renderer: SnapshotRenderer) => {
if (renderer.snapshotName === snapshotName) { if (renderer.snapshotName === snapshotName) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,7 +18,7 @@ import type { Frame, Page } from 'playwright-core';
import { ZipFile } from '../../packages/playwright-core/lib/utils/zipFile'; import { ZipFile } from '../../packages/playwright-core/lib/utils/zipFile';
import type { StackFrame } from '../../packages/protocol/src/channels'; import type { StackFrame } from '../../packages/protocol/src/channels';
import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils'; 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> { export async function attachFrame(page: Page, frameId: string, url: string): Promise<Frame> {
const handle = await page.evaluateHandle(async ({ frameId, url }) => { 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)); resources.set(entry, await zipFS.read(entry));
zipFS.close(); zipFS.close();
const actionMap = new Map<string, ActionTraceEvent>();
const events: any[] = []; const events: any[] = [];
for (const traceFile of [...resources.keys()].filter(name => name.endsWith('.trace'))) { for (const traceFile of [...resources.keys()].filter(name => name.endsWith('.trace'))) {
for (const line of resources.get(traceFile)!.toString().split('\n')) { for (const line of resources.get(traceFile)!.toString().split('\n')) {
if (line) if (line) {
events.push(JSON.parse(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.describe('snapshots', () => {
it('should collect snapshot', async ({ page, toImpl, snapshotter }) => { it('should collect snapshot', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<button>Hello</button>'); 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>'); expect(distillSnapshot(snapshot)).toBe('<BUTTON>Hello</BUTTON>');
}); });
it('should preserve BASE and other content on reset', async ({ page, toImpl, snapshotter, server }) => { it('should preserve BASE and other content on reset', async ({ page, toImpl, snapshotter, server }) => {
await page.goto(server.EMPTY_PAGE); 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; const html1 = snapshot1.render().html;
expect(html1).toContain(`<BASE href="${server.EMPTY_PAGE}"`); expect(html1).toContain(`<BASE href="${server.EMPTY_PAGE}"`);
await snapshotter.reset(); 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; 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 }) => { it('should capture resources', async ({ page, toImpl, server, snapshotter }) => {
@ -50,7 +50,7 @@ it.describe('snapshots', () => {
route.fulfill({ body: 'button { color: red; }', }).catch(() => {}); route.fulfill({ body: 'button { color: red; }', }).catch(() => {});
}); });
await page.setContent('<link rel="stylesheet" href="style.css"><button>Hello</button>'); 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`); const resource = snapshot.resourceByUrl(`http://localhost:${server.PORT}/style.css`);
expect(resource).toBeTruthy(); expect(resource).toBeTruthy();
}); });
@ -59,36 +59,36 @@ it.describe('snapshots', () => {
await page.setContent('<button>Hello</button>'); await page.setContent('<button>Hello</button>');
const snapshots = []; const snapshots = [];
snapshotter.onSnapshotEvent(snapshot => snapshots.push(snapshot)); snapshotter.onSnapshotEvent(snapshot => snapshots.push(snapshot));
await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1');
await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2');
expect(snapshots.length).toBe(2); expect(snapshots.length).toBe(2);
}); });
it('should respect inline CSSOM change', async ({ page, toImpl, snapshotter }) => { it('should respect inline CSSOM change', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<style>button { color: red; }</style><button>Hello</button>'); 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>'); 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'; }); 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>'); expect(distillSnapshot(snapshot2)).toBe('<STYLE>button { color: blue; }</STYLE><BUTTON>Hello</BUTTON>');
}); });
it('should respect node removal', async ({ page, toImpl, snapshotter }) => { it('should respect node removal', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<div><button id="button1"></button><button id="button2"></button></div>'); 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>'); expect(distillSnapshot(snapshot1)).toBe('<DIV><BUTTON id=\"button1\"></BUTTON><BUTTON id=\"button2\"></BUTTON></DIV>');
await page.evaluate(() => document.getElementById('button2').remove()); 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>'); expect(distillSnapshot(snapshot2)).toBe('<DIV><BUTTON id=\"button1\"></BUTTON></DIV>');
}); });
it('should respect attr removal', async ({ page, toImpl, snapshotter }) => { it('should respect attr removal', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<div id="div" attr1="1" attr2="2"></div>'); 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>'); expect(distillSnapshot(snapshot1)).toBe('<DIV id=\"div\" attr1=\"1\" attr2=\"2\"></DIV>');
await page.evaluate(() => document.getElementById('div').removeAttribute('attr2')); 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>'); 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.goto(server.EMPTY_PAGE);
await page.setContent('<!DOCTYPE foo><body>hi</body>'); 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'); expect(distillSnapshot(snapshot)).toBe('<!DOCTYPE foo>hi');
}); });
it('should replace meta charset attr that specifies charset', async ({ page, server, toImpl, snapshotter }) => { it('should replace meta charset attr that specifies charset', async ({ page, server, toImpl, snapshotter }) => {
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
await page.setContent('<meta charset="shift-jis" />'); 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">'); expect(distillSnapshot(snapshot)).toBe('<META charset="utf-8">');
}); });
it('should replace meta content attr that specifies charset', async ({ page, server, toImpl, snapshotter }) => { it('should replace meta content attr that specifies charset', async ({ page, server, toImpl, snapshotter }) => {
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
await page.setContent('<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">'); 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">'); 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>'); 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>'); 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'; }); 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`); const resource = snapshot2.resourceByUrl(`http://localhost:${server.PORT}/style.css`);
expect((await snapshotter.resourceContentForTest(resource.response.content._sha1)).toString()).toBe('button { color: blue; }'); 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); await page.goto(server.EMPTY_PAGE);
for (let counter = 0; ; ++counter) { 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>"'); const text = distillSnapshot(snapshot).replace(/frame@[^"]+["]/, '<id>"');
if (text === '<FRAMESET><FRAME __playwright_src__=\"/snapshot/<id>\"></FRAME></FRAMESET>') if (text === '<FRAMESET><FRAME __playwright_src__=\"/snapshot/<id>\"></FRAME></FRAMESET>')
break; break;
@ -191,7 +191,7 @@ it.describe('snapshots', () => {
// Marking iframe hierarchy is racy, do not expect snapshot, wait for it. // Marking iframe hierarchy is racy, do not expect snapshot, wait for it.
for (let counter = 0; ; ++counter) { 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>"'); const text = distillSnapshot(snapshot).replace(/frame@[^"]+["]/, '<id>"');
if (text === '<IFRAME __playwright_src__=\"/snapshot/<id>\"></IFRAME>') if (text === '<IFRAME __playwright_src__=\"/snapshot/<id>\"></IFRAME>')
break; break;
@ -203,31 +203,31 @@ it.describe('snapshots', () => {
await page.setContent('<button>Hello</button><button>World</button>'); await page.setContent('<button>Hello</button><button>World</button>');
{ {
const handle = await page.$('text=Hello'); const handle = await page.$('text=Hello');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot', toImpl(handle)); const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1', toImpl(handle));
expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"snapshot\">Hello</BUTTON><BUTTON>World</BUTTON>'); expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"call@1\">Hello</BUTTON><BUTTON>World</BUTTON>');
} }
{ {
const handle = await page.$('text=World'); const handle = await page.$('text=World');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2', toImpl(handle)); const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2', toImpl(handle));
expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"snapshot\">Hello</BUTTON><BUTTON __playwright_target__=\"snapshot2\">World</BUTTON>'); 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 }) => { it('should collect on attribute change', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<button>Hello</button>'); 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>'); expect(distillSnapshot(snapshot)).toBe('<BUTTON>Hello</BUTTON>');
} }
const handle = await page.$('text=Hello')!; const handle = await page.$('text=Hello')!;
await handle.evaluate(element => element.setAttribute('data', 'one')); 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>'); expect(distillSnapshot(snapshot)).toBe('<BUTTON data="one">Hello</BUTTON>');
} }
await handle.evaluate(element => element.setAttribute('data', 'two')); 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>'); 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 some adopted style sheets.
expect(distillSnapshot(renderer1)).toContain('__playwright_style_sheet_'); 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(); const snapshot2 = renderer2.snapshot();
// Second snapshot should be just a copy of the first one. // Second snapshot should be just a copy of the first one.
expect(snapshot2.html).toEqual([[1, 13]]); expect(snapshot2.html).toEqual([[1, 13]]);
@ -263,7 +263,7 @@ it.describe('snapshots', () => {
it('should not navigate on anchor clicks', async ({ page, toImpl, snapshotter }) => { it('should not navigate on anchor clicks', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<a href="https://example.com">example.com</a>'); 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>'); 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') }); await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events } = await parseTrace(testInfo.outputPath('trace.zip')); const { events } = await parseTrace(testInfo.outputPath('trace.zip'));
const screenshotEvent = events.find(e => e.type === 'action' && e.apiName === 'page.screenshot'); 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({}); 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 { events } = await parseTrace(testInfo.outputPath('trace.zip'));
const clickEvent = events.find(e => e.apiName === 'page.click'); const clickEvent = events.find(e => e.apiName === 'page.click');
expect(clickEvent).toBeTruthy(); expect(clickEvent).toBeTruthy();
expect(clickEvent.error.message).toBe('Action was interrupted');
}); });
test('should throw when starting with different options', async ({ context }) => { 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',
'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 === 'frame-snapshot')).toBeTruthy();
expect(trace1.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).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`, { await expect(page.locator(`div`, {
has: page.locator(`text=world`) has: page.locator(`text=world`)
})).toHaveCount(1); })).toHaveCount(1);
expect(await page.locator(`div`, { expect(removeHighlight(await page.locator(`div`, {
has: page.locator(`text=world`) 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`, { await expect(page.locator(`div`, {
has: page.locator(`text="hello"`) has: page.locator(`text="hello"`)
})).toHaveCount(1); })).toHaveCount(1);
expect(await page.locator(`div`, { expect(removeHighlight(await page.locator(`div`, {
has: page.locator(`text="hello"`) 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`, { await expect(page.locator(`div`, {
has: page.locator(`xpath=./span`) has: page.locator(`xpath=./span`)
})).toHaveCount(2); })).toHaveCount(2);
@ -133,9 +133,9 @@ it('should support has:locator', async ({ page, trace }) => {
await expect(page.locator(`div`, { await expect(page.locator(`div`, {
has: page.locator(`span`, { hasText: 'wor' }) has: page.locator(`span`, { hasText: 'wor' })
})).toHaveCount(1); })).toHaveCount(1);
expect(await page.locator(`div`, { expect(removeHighlight(await page.locator(`div`, {
has: page.locator(`span`, { hasText: 'wor' }) 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`, { await expect(page.locator(`div`, {
has: page.locator(`span`), has: page.locator(`span`),
hasText: 'wor', hasText: 'wor',
@ -180,3 +180,7 @@ it('alias methods coverage', async ({ page }) => {
await expect(page.locator('div').getByRole('button')).toHaveCount(1); await expect(page.locator('div').getByRole('button')).toHaveCount(1);
await expect(page.mainFrame().locator('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.slow(true, 'Multiple browser launches in each test');
test.describe.configure({ mode: 'parallel' });
test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => { test('should work with screenshot: on', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({ const result = await runInlineTest({