Files
AI/참고/playwright-main/packages/isomorphic/trace/traceModel.ts

470 lines
17 KiB
TypeScript
Raw Normal View History

2026-05-12 19:40:31 +09:00
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getActionGroup, renderTitleForCall } from '../protocolFormatter';
import type { Language } from '../locatorGenerators';
import type { ResourceSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';
import type { ActionTraceEvent } from '@trace/trace';
import type { ActionEntry, ContextEntry, PageEntry } from './entries';
import type { StackFrame } from '@protocol/channels';
import type { ActionGroup } from '../protocolFormatter';
const contextSymbol = Symbol('context');
const nextInContextSymbol = Symbol('nextInContext');
const prevByEndTimeSymbol = Symbol('prevByEndTime');
const nextByStartTimeSymbol = Symbol('nextByStartTime');
const eventsSymbol = Symbol('events');
export type SourceLocation = {
file: string;
line: number;
column: number;
source?: SourceModel;
};
export type SourceModel = {
errors: { line: number, message: string }[];
content: string | undefined;
};
export type ResourceEntry = ResourceSnapshot & { id: string };
export type ActionTraceEventInContext = ActionEntry & {
context: ContextEntry;
};
export type ActionTreeItem = {
id: string;
children: ActionTreeItem[];
parent: ActionTreeItem | undefined;
action: ActionTraceEventInContext;
};
export type ErrorDescription = {
action?: ActionTraceEventInContext;
stack?: StackFrame[];
message: string;
};
export type Attachment = trace.AfterActionTraceEventAttachment & { callId: string };
export class TraceModel {
readonly startTime: number;
readonly endTime: number;
readonly browserName: string;
readonly channel?: string;
readonly platform?: string;
readonly playwrightVersion?: string;
readonly wallTime?: number;
readonly title?: string;
readonly options: trace.BrowserContextEventOptions;
readonly pages: PageEntry[];
readonly actions: ActionTraceEventInContext[];
readonly attachments: Attachment[];
readonly visibleAttachments: Attachment[];
readonly events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
readonly stdio: trace.StdioTraceEvent[];
readonly errors: trace.ErrorTraceEvent[];
readonly errorDescriptors: ErrorDescription[];
readonly hasSource: boolean;
readonly hasStepData: boolean;
readonly sdkLanguage: Language | undefined;
readonly testIdAttributeName: string | undefined;
readonly sources: Map<string, SourceModel>;
resources: ResourceEntry[];
readonly actionCounters: Map<string, number>;
readonly traceUri: string;
readonly testTimeout?: number;
constructor(traceUri: string, contexts: ContextEntry[]) {
contexts.forEach(contextEntry => indexModel(contextEntry));
const libraryContext = contexts.find(context => context.origin === 'library');
this.traceUri = traceUri;
this.browserName = libraryContext?.browserName || '';
this.sdkLanguage = libraryContext?.sdkLanguage;
this.channel = libraryContext?.channel;
this.testIdAttributeName = libraryContext?.testIdAttributeName;
this.platform = libraryContext?.platform || '';
this.playwrightVersion = contexts.find(c => c.playwrightVersion)?.playwrightVersion;
this.title = libraryContext?.title || '';
this.options = libraryContext?.options || {};
this.testTimeout = contexts.find(c => c.origin === 'testRunner')?.testTimeout;
// Next call updates all timestamps for all events in library contexts, so it must be done first.
this.actions = mergeActionsAndUpdateTiming(contexts);
this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages));
this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE);
this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE);
this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE);
this.events = ([] as (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[]).concat(...contexts.map(c => c.events));
this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio));
this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors));
this.hasSource = contexts.some(c => c.hasSource);
this.hasStepData = contexts.some(context => context.origin === 'testRunner');
this.resources = [...contexts.map(c => c.resources)].flat().map(entry => ({ ...entry, id: `${entry.pageref}-${entry.startedDateTime}-${entry.request.url}` }));
this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []);
this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_'));
this.events.sort((a1, a2) => a1.time - a2.time);
this.resources.sort((a1, a2) => a1._monotonicTime! - a2._monotonicTime!);
this.errorDescriptors = this.hasStepData ? this._errorDescriptorsFromTestRunner() : this._errorDescriptorsFromActions();
this.sources = collectSources(this.actions, this.errorDescriptors);
this.actionCounters = new Map();
for (const action of this.actions) {
action.group = action.group ?? getActionGroup({ type: action.class, method: action.method });
if (action.group)
this.actionCounters.set(action.group, 1 + (this.actionCounters.get(action.group) || 0));
}
}
createRelativeUrl(path: string) {
const url = new URL('http://localhost/' + path);
url.searchParams.set('trace', this.traceUri);
return url.toString().substring('http://localhost/'.length);
}
failedAction() {
// This find innermost action for nested ones.
return this.actions.findLast(a => a.error);
}
filteredActions(actionsFilter: ActionGroup[]) {
const filter = new Set<string>(actionsFilter);
return this.actions.filter(action => !action.group || filter.has(action.group));
}
renderActionTree(filter?: ActionGroup[]) {
const actions = this.filteredActions(filter ?? []);
const { rootItem } = buildActionTree(actions);
const actionTree: string[] = [];
const visit = (actionItem: ActionTreeItem, indent: string) => {
const title = renderTitleForCall({ ...actionItem.action, type: actionItem.action.class });
actionTree.push(`${indent}${title || actionItem.id}`);
for (const child of actionItem.children)
visit(child, indent + ' ');
};
rootItem.children.forEach(a => visit(a, ''));
return actionTree;
}
private _errorDescriptorsFromActions(): ErrorDescription[] {
const errors: ErrorDescription[] = [];
for (const action of this.actions || []) {
if (!action.error?.message)
continue;
errors.push({
action,
stack: action.stack,
message: action.error.message,
});
}
return errors;
}
private _errorDescriptorsFromTestRunner(): ErrorDescription[] {
return this.errors.filter(e => !!e.message).map((error, i) => ({
stack: error.stack,
message: error.message,
}));
}
}
function indexModel(context: ContextEntry) {
for (const page of context.pages)
(page as any)[contextSymbol] = context;
for (let i = 0; i < context.actions.length; ++i) {
const action = context.actions[i] as any;
action[contextSymbol] = context;
}
let lastNonRouteAction = undefined;
for (let i = context.actions.length - 1; i >= 0; i--) {
const action = context.actions[i] as ActionTraceEvent;
(action as any)[nextInContextSymbol] = lastNonRouteAction;
if (action.class !== 'Route')
lastNonRouteAction = action;
}
for (const event of context.events)
(event as any)[contextSymbol] = context;
for (const resource of context.resources)
(resource as any)[contextSymbol] = context;
}
function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) {
const result: ActionTraceEventInContext[] = [];
const actions = mergeActionsAndUpdateTimingSameTrace(contexts);
result.push(...actions);
result.sort((a1, a2) => {
if (a2.parentId === a1.callId)
return 1;
if (a1.parentId === a2.callId)
return -1;
return a1.endTime - a2.endTime;
});
for (let i = 1; i < result.length; ++i)
(result[i] as any)[prevByEndTimeSymbol] = result[i - 1];
result.sort((a1, a2) => {
if (a2.parentId === a1.callId)
return -1;
if (a1.parentId === a2.callId)
return 1;
return a1.startTime - a2.startTime;
});
for (let i = 0; i + 1 < result.length; ++i)
(result[i] as any)[nextByStartTimeSymbol] = result[i + 1];
return result;
}
let lastTmpStepId = 0;
function mergeActionsAndUpdateTimingSameTrace(contexts: ContextEntry[]): ActionTraceEventInContext[] {
const map = new Map<string, ActionTraceEventInContext>();
const libraryContexts = contexts.filter(context => context.origin === 'library');
const testRunnerContexts = contexts.filter(context => context.origin === 'testRunner');
// With library-only or test-runner-only traces there is nothing to match.
if (!testRunnerContexts.length || !libraryContexts.length) {
return contexts.map(context => {
return context.actions.map(action => ({ ...action, context }));
}).flat();
}
for (const context of libraryContexts) {
for (const action of context.actions) {
// Never merge stepless events.
map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context });
}
}
// Protocol call aka library contexts have startTime/endTime as server-side times.
// Step aka test runner contexts have startTime/endTime as client-side times.
// Adjust startTime/endTime on the library contexts to align them with the test
// runner steps.
const delta = monotonicTimeDeltaBetweenLibraryAndRunner(testRunnerContexts, map);
if (delta)
adjustMonotonicTime(libraryContexts, delta);
const nonPrimaryIdToPrimaryId = new Map<string, string>();
for (const context of testRunnerContexts) {
for (const action of context.actions) {
const existing = action.stepId && map.get(action.stepId);
if (existing) {
nonPrimaryIdToPrimaryId.set(action.callId, existing.callId);
if (action.error)
existing.error = action.error;
if (action.attachments)
existing.attachments = action.attachments;
if (action.annotations)
existing.annotations = action.annotations;
if (action.parentId)
existing.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
if (action.group)
existing.group = action.group;
// For the events that are present in the test runner context, always take
// their time from the test runner context to preserve client side order.
existing.startTime = action.startTime;
existing.endTime = action.endTime;
continue;
}
if (action.parentId)
action.parentId = nonPrimaryIdToPrimaryId.get(action.parentId) ?? action.parentId;
map.set(action.stepId || `tmp-step@${++lastTmpStepId}`, { ...action, context });
}
}
return [...map.values()];
}
function adjustMonotonicTime(contexts: ContextEntry[], monotonicTimeDelta: number) {
for (const context of contexts) {
context.startTime += monotonicTimeDelta;
context.endTime += monotonicTimeDelta;
for (const action of context.actions) {
if (action.startTime)
action.startTime += monotonicTimeDelta;
if (action.endTime)
action.endTime += monotonicTimeDelta;
}
for (const event of context.events)
event.time += monotonicTimeDelta;
for (const event of context.stdio)
event.timestamp += monotonicTimeDelta;
for (const page of context.pages) {
for (const frame of page.screencastFrames)
frame.timestamp += monotonicTimeDelta;
}
for (const resource of context.resources) {
if (resource._monotonicTime)
resource._monotonicTime += monotonicTimeDelta;
}
}
}
function monotonicTimeDeltaBetweenLibraryAndRunner(nonPrimaryContexts: ContextEntry[], libraryActions: Map<string, ActionTraceEventInContext>) {
// We cannot rely on wall time or monotonic time to be the in sync
// between library and test runner contexts. So we find first action
// that is present in both runner and library contexts and use it
// to calculate the time delta, assuming the two events happened at the
// same instant.
for (const context of nonPrimaryContexts) {
for (const action of context.actions) {
if (!action.startTime)
continue;
const libraryAction = action.stepId ? libraryActions.get(action.stepId) : undefined;
if (libraryAction)
return action.startTime - libraryAction.startTime;
}
}
return 0;
}
export function buildActionTree(actions: ActionTraceEventInContext[]): { rootItem: ActionTreeItem, itemMap: Map<string, ActionTreeItem> } {
const itemMap = new Map<string, ActionTreeItem>();
for (const action of actions) {
itemMap.set(action.callId, {
id: action.callId,
parent: undefined,
children: [],
action,
});
}
const rootItem: ActionTreeItem = { action: { ...kFakeRootAction }, id: '', parent: undefined, children: [] };
for (const item of itemMap.values()) {
rootItem.action.startTime = Math.min(rootItem.action.startTime, item.action.startTime);
rootItem.action.endTime = Math.max(rootItem.action.endTime, item.action.endTime);
const parent = item.action.parentId ? itemMap.get(item.action.parentId) || rootItem : rootItem;
parent.children.push(item);
item.parent = parent;
}
const inheritStack = (item: ActionTreeItem) => {
for (const child of item.children) {
child.action.stack = child.action.stack ?? item.action.stack;
inheritStack(child);
}
};
inheritStack(rootItem);
return { rootItem, itemMap };
}
export function context(action: ActionTraceEvent | trace.EventTraceEvent | ResourceSnapshot): ContextEntry {
return (action as any)[contextSymbol];
}
function nextInContext(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextInContextSymbol];
}
export function previousActionByEndTime(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[prevByEndTimeSymbol];
}
export function nextActionByStartTime(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextByStartTimeSymbol];
}
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
let errors = 0;
let warnings = 0;
for (const event of eventsForAction(action)) {
if (event.type === 'console') {
const type = event.messageType;
if (type === 'warning')
++warnings;
else if (type === 'error')
++errors;
}
if (event.type === 'event' && event.method === 'pageError')
++errors;
}
return { errors, warnings };
}
export function eventsForAction(action: ActionTraceEvent): (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] {
let result: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[] = (action as any)[eventsSymbol];
if (result)
return result;
const nextAction = nextInContext(action);
result = context(action).events.filter(event => {
return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime);
});
(action as any)[eventsSymbol] = result;
return result;
}
function collectSources(actions: trace.ActionTraceEvent[], errorDescriptors: ErrorDescription[]): Map<string, SourceModel> {
const result = new Map<string, SourceModel>();
for (const action of actions) {
for (const frame of action.stack || []) {
let source = result.get(frame.file);
if (!source) {
source = { errors: [], content: undefined };
result.set(frame.file, source);
}
}
}
for (const error of errorDescriptors) {
const { action, stack, message } = error;
if (!action || !stack)
continue;
result.get(stack[0].file)?.errors.push({
line: stack[0].line || 0,
message
});
}
return result;
}
const kFakeRootAction: ActionTraceEventInContext = {
type: 'action',
callId: '',
startTime: 0,
endTime: 0,
class: '',
method: '',
params: {},
log: [],
context: {
origin: 'library',
startTime: 0,
endTime: 0,
browserName: '',
wallTime: 0,
options: {},
pages: [],
resources: [],
actions: [],
events: [],
stdio: [],
errors: [],
hasSource: false,
contextId: '',
},
};