diff --git a/packages/playwright-test/src/matchers/expect.ts b/packages/playwright-test/src/matchers/expect.ts index ac54bdd9e4..8f4f034e26 100644 --- a/packages/playwright-test/src/matchers/expect.ts +++ b/packages/playwright-test/src/matchers/expect.ts @@ -296,7 +296,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { }; // Process the async matchers separately to preserve the zones in the stacks. - if (this._info.isPoll || matcherName in customAsyncMatchers) { + if (this._info.isPoll || (matcherName in customAsyncMatchers && matcherName !== 'toPass')) { return (async () => { try { const expectZone: ExpectZone = { title: defaultTitle, wallTime }; diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 2fc95fe9ec..f6b5fcaf39 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -23,7 +23,7 @@ import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; import type { TreeState } from '@web/components/treeView'; import { TreeView } from '@web/components/treeView'; -import type { ActionTraceEventInContext } from './modelUtil'; +import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil'; export interface ActionListProps { actions: ActionTraceEventInContext[], @@ -35,13 +35,6 @@ export interface ActionListProps { isLive?: boolean, } -type ActionTreeItem = { - id: string; - children: ActionTreeItem[]; - parent: ActionTreeItem | undefined; - action?: ActionTraceEventInContext; -}; - const ActionTreeView = TreeView; export const ActionList: React.FC = ({ @@ -54,26 +47,7 @@ export const ActionList: React.FC = ({ isLive, }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); - const { rootItem, itemMap } = React.useMemo(() => { - const itemMap = new Map(); - - for (const action of actions) { - itemMap.set(action.callId, { - id: action.callId, - parent: undefined, - children: [], - action, - }); - } - - const rootItem: ActionTreeItem = { id: '', parent: undefined, children: [] }; - for (const item of itemMap.values()) { - const parent = item.action!.parentId ? itemMap.get(item.action!.parentId) || rootItem : rootItem; - parent.children.push(item); - item.parent = parent; - } - return { rootItem, itemMap }; - }, [actions]); + const { rootItem, itemMap } = React.useMemo(() => modelUtil.buildActionTree(actions), [actions]); const { selectedItem } = React.useMemo(() => { const selectedItem = selectedAction ? itemMap.get(selectedAction.callId) : undefined; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 7b6b0e9926..6793c51fae 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -41,6 +41,13 @@ export type ActionTraceEventInContext = ActionTraceEvent & { context: ContextEntry; }; +export type ActionTreeItem = { + id: string; + children: ActionTreeItem[]; + parent: ActionTreeItem | undefined; + action?: ActionTraceEventInContext; +}; + export class MultiTraceModel { readonly startTime: number; readonly endTime: number; @@ -159,6 +166,27 @@ function mergeActions(contexts: ContextEntry[]) { return result; } +export function buildActionTree(actions: ActionTraceEventInContext[]): { rootItem: ActionTreeItem, itemMap: Map } { + const itemMap = new Map(); + + for (const action of actions) { + itemMap.set(action.callId, { + id: action.callId, + parent: undefined, + children: [], + action, + }); + } + + const rootItem: ActionTreeItem = { id: '', parent: undefined, children: [] }; + for (const item of itemMap.values()) { + const parent = item.action!.parentId ? itemMap.get(item.action!.parentId) || rootItem : rootItem; + parent.children.push(item); + item.parent = parent; + } + return { rootItem, itemMap }; +} + export function idForAction(action: ActionTraceEvent) { return `${action.pageId || 'none'}:${action.callId}`; } diff --git a/tests/config/utils.ts b/tests/config/utils.ts index 0603b669ab..f4ac07a0cd 100644 --- a/tests/config/utils.ts +++ b/tests/config/utils.ts @@ -20,7 +20,8 @@ import type { TraceModelBackend } from '../../packages/trace-viewer/src/traceMod import type { StackFrame } from '../../packages/protocol/src/channels'; import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils'; import { TraceModel } from '../../packages/trace-viewer/src/traceModel'; -import { MultiTraceModel } from '../../packages/trace-viewer/src/ui/modelUtil'; +import type { ActionTreeItem } from '../../packages/trace-viewer/src/ui/modelUtil'; +import { buildActionTree, MultiTraceModel } from '../../packages/trace-viewer/src/ui/modelUtil'; import type { ActionTraceEvent, EventTraceEvent, TraceEvent } from '@trace/trace'; export async function attachFrame(page: Page, frameId: string, url: string): Promise { @@ -165,11 +166,19 @@ function eventsToActions(events: ActionTraceEvent[]): string[] { .map(e => e.apiName); } -export async function parseTrace(file: string): Promise<{ resources: Map, events: EventTraceEvent[], actions: ActionTraceEvent[], apiNames: string[], traceModel: TraceModel, model: MultiTraceModel }> { +export async function parseTrace(file: string): Promise<{ resources: Map, events: EventTraceEvent[], actions: ActionTraceEvent[], apiNames: string[], traceModel: TraceModel, model: MultiTraceModel, actionTree: string[] }> { const backend = new TraceBackend(file); const traceModel = new TraceModel(); await traceModel.load(backend, () => {}); const model = new MultiTraceModel(traceModel.contextEntries); + const { rootItem } = buildActionTree(model.actions); + const actionTree: string[] = []; + const visit = (actionItem: ActionTreeItem, indent: string) => { + actionTree.push(`${indent}${actionItem.action?.apiName || actionItem.id}`); + for (const child of actionItem.children) + visit(child, indent + ' '); + }; + rootItem.children.forEach(a => visit(a, '')); return { apiNames: model.actions.map(a => a.apiName), resources: backend.entries, @@ -177,6 +186,7 @@ export async function parseTrace(file: string): Promise<{ resources: Map { expect(result.failed).toBe(1); // One trace file for request context and one for each APIRequestContext const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); - expect(trace1.apiNames).toEqual([ + expect(trace1.actionTree).toEqual([ 'Before Hooks', - 'fixture: request', - 'apiRequest.newContext', - 'tracing.start', - 'fixture: browser', - 'browserType.launch', - 'fixture: context', - 'browser.newContext', - 'tracing.start', - 'fixture: page', - 'browserContext.newPage', + ' fixture: request', + ' apiRequest.newContext', + ' tracing.start', + ' fixture: browser', + ' browserType.launch', + ' fixture: context', + ' browser.newContext', + ' tracing.start', + ' fixture: page', + ' browserContext.newPage', 'page.goto', 'apiRequestContext.get', 'After Hooks', - 'fixture: page', - 'fixture: context', - 'fixture: request', - 'tracing.stopChunk', - 'apiRequestContext.dispose', + ' fixture: page', + ' fixture: context', + ' fixture: request', + ' tracing.stopChunk', + ' apiRequestContext.dispose', ]); const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.zip')); - expect(trace2.apiNames).toEqual([ + expect(trace2.actionTree).toEqual([ 'Before Hooks', 'apiRequest.newContext', 'tracing.start', @@ -117,25 +117,25 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { 'After Hooks', ]); const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); - expect(trace3.apiNames).toEqual([ + expect(trace3.actionTree).toEqual([ 'Before Hooks', - 'fixture: request', - 'apiRequest.newContext', - 'tracing.start', - 'fixture: context', - 'browser.newContext', - 'tracing.start', - 'fixture: page', - 'browserContext.newPage', + ' fixture: request', + ' apiRequest.newContext', + ' tracing.start', + ' fixture: context', + ' browser.newContext', + ' tracing.start', + ' fixture: page', + ' browserContext.newPage', 'page.goto', 'apiRequestContext.get', 'expect.toBe', 'After Hooks', - 'fixture: page', - 'fixture: context', - 'fixture: request', - 'tracing.stopChunk', - 'apiRequestContext.dispose', + ' fixture: page', + ' fixture: context', + ' fixture: request', + ' tracing.stopChunk', + ' apiRequestContext.dispose', ]); }); @@ -321,28 +321,28 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve expect(result.failed).toBe(1); const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip')); - expect(trace1.apiNames).toEqual([ + expect(trace1.actionTree).toEqual([ 'Before Hooks', - 'fixture: browser', - 'browserType.launch', - 'fixture: context', - 'browser.newContext', - 'tracing.start', - 'fixture: page', - 'browserContext.newPage', + ' fixture: browser', + ' browserType.launch', + ' fixture: context', + ' browser.newContext', + ' tracing.start', + ' fixture: page', + ' browserContext.newPage', 'page.goto', 'After Hooks', - 'fixture: page', - 'fixture: context', - 'attach \"trace\"', - 'afterAll hook', - 'fixture: request', - 'apiRequest.newContext', - 'tracing.start', - 'apiRequestContext.get', - 'fixture: request', - 'tracing.stopChunk', - 'apiRequestContext.dispose', + ' fixture: page', + ' fixture: context', + ' attach \"trace\"', + ' afterAll hook', + ' fixture: request', + ' apiRequest.newContext', + ' tracing.start', + ' apiRequestContext.get', + ' fixture: request', + ' tracing.stopChunk', + ' apiRequestContext.dispose', ]); const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.zip')).catch(e => e); @@ -608,3 +608,45 @@ test('should record with custom page fixture', async ({ runInlineTest }, testInf type: 'frame-snapshot', })); }); + +test('should expand expect.toPass', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { use: { trace: { mode: 'on' } } }; + `, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ page }) => { + let i = 0; + await expect(async () => { + await page.goto('data:text/html,Hello world'); + expect(i++).toBe(2); + }).toPass(); + }); + `, + }, { workers: 1 }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + const trace = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); + expect(trace.actionTree).toEqual([ + 'Before Hooks', + ' fixture: browser', + ' browserType.launch', + ' fixture: context', + ' browser.newContext', + ' tracing.start', + ' fixture: page', + ' browserContext.newPage', + 'expect.toPass', + ' page.goto', + ' expect.toBe', + ' page.goto', + ' expect.toBe', + ' page.goto', + ' expect.toBe', + 'After Hooks', + ' fixture: page', + ' fixture: context', + ]); +});