diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index ac0714ee91..f7e62b7a1d 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -120,6 +120,10 @@ function dedupeAndSortActions(actions: ActionTraceEvent[]) { return result; } +export function idForAction(action: ActionTraceEvent) { + return `${action.pageId || 'none'}:${action.callId}`; +} + export function context(action: ActionTraceEvent): ContextEntry { return (action as any)[contextSymbol]; } diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx index 4b2d842900..7d71d91165 100644 --- a/packages/trace-viewer/src/ui/watchMode.tsx +++ b/packages/trace-viewer/src/ui/watchMode.tsx @@ -24,7 +24,7 @@ import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; import type { FullConfig, Suite, TestCase, Location, TestError } from '../../../playwright-test/types/testReporter'; import { SplitView } from '@web/components/splitView'; -import { MultiTraceModel } from './modelUtil'; +import { idForAction, MultiTraceModel } from './modelUtil'; import './watchMode.css'; import { ToolbarButton } from '@web/components/toolbarButton'; import { Toolbar } from '@web/components/toolbar'; @@ -35,6 +35,7 @@ import { Expandable } from '@web/components/expandable'; import { toggleTheme } from '@web/theme'; import { artifactsFolderName } from '@testIsomorphic/folders'; import { msToString, settings, useSetting } from '@web/uiUtils'; +import type { ActionTraceEvent } from '@trace/trace'; let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {}; let runWatchedTests = (fileNames: string[]) => {}; @@ -468,6 +469,12 @@ const TraceView: React.FC<{ return { outputDir, result }; }, [item]); + // Preserve user selection upon live-reloading trace model by persisting the action id. + // This avoids auto-selection of the last action every time we reload the model. + const [selectedActionId, setSelectedActionId] = React.useState(); + const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]); + const initialSelection = selectedActionId ? model?.actions.find(a => idForAction(a) === selectedActionId) : undefined; + React.useEffect(() => { if (pollTimer.current) clearTimeout(pollTimer.current); @@ -514,6 +521,8 @@ const TraceView: React.FC<{ hideStackFrames={true} showSourcesFirst={true} rootDir={rootDir} + initialSelection={initialSelection} + onSelectionChanged={onSelectionChanged} defaultSourceLocation={item.location} />; }; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 0c5d754ca6..5eb28f846d 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -39,7 +39,9 @@ export const Workbench: React.FunctionComponent<{ showSourcesFirst?: boolean, rootDir?: string, defaultSourceLocation?: Location, -}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, defaultSourceLocation }) => { + initialSelection?: ActionTraceEvent, + onSelectionChanged?: (action: ActionTraceEvent) => void, +}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, defaultSourceLocation, initialSelection, onSelectionChanged }) => { const [selectedAction, setSelectedAction] = React.useState(undefined); const [highlightedAction, setHighlightedAction] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); @@ -52,11 +54,18 @@ export const Workbench: React.FunctionComponent<{ if (selectedAction && model?.actions.includes(selectedAction)) return; const failedAction = model?.actions.find(a => a.error); - if (failedAction) + if (initialSelection && model?.actions.includes(initialSelection)) + setSelectedAction(initialSelection); + else if (failedAction) setSelectedAction(failedAction); else if (model?.actions.length) setSelectedAction(model.actions[model.actions.length - 1]); - }, [model, selectedAction, setSelectedAction, setSelectedPropertiesTab]); + }, [model, selectedAction, setSelectedAction, setSelectedPropertiesTab, initialSelection]); + + const onActionSelected = React.useCallback((action: ActionTraceEvent) => { + setSelectedAction(action); + onSelectionChanged?.(action); + }, [setSelectedAction, onSelectionChanged]); const { errors, warnings } = activeAction ? modelUtil.stats(activeAction) : { errors: 0, warnings: 0 }; const consoleCount = errors + warnings; @@ -107,7 +116,7 @@ export const Workbench: React.FunctionComponent<{ setSelectedAction(action)} + onSelected={onActionSelected} hideTimelineBars={hideTimelineBars} /> @@ -123,12 +132,8 @@ export const Workbench: React.FunctionComponent<{ sdkLanguage={sdkLanguage} actions={model?.actions || []} selectedAction={model ? selectedAction : undefined} - onSelected={action => { - setSelectedAction(action); - }} - onHighlighted={action => { - setHighlightedAction(action); - }} + onSelected={onActionSelected} + onHighlighted={setHighlightedAction} revealConsole={() => setSelectedPropertiesTab('console')} /> }, diff --git a/tests/playwright-test/ui-mode-test-progress.spec.ts b/tests/playwright-test/ui-mode-test-progress.spec.ts index 6aa7c03018..03f9672486 100644 --- a/tests/playwright-test/ui-mode-test-progress.spec.ts +++ b/tests/playwright-test/ui-mode-test-progress.spec.ts @@ -109,3 +109,104 @@ test('should update trace live', async ({ runUITest, server }) => { /page.gotohttp:\/\/localhost:\d+\/two.html[\d.]+m?s/ ]); }); + +test('should preserve action list selection upon live trace update', async ({ runUITest, server, createLatch }) => { + const latch = createLatch(); + + const { page } = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('live test', async ({ page }) => { + await page.goto('about:blank'); + await page.setContent('hello'); + ${latch.blockingCode} + await page.setContent('world'); + await new Promise(() => {}); + }); + `, + }); + + // Start test. + await page.getByText('live test').dblclick(); + + // It should wait on the latch. + const listItem = page.getByTestId('action-list').getByRole('listitem'); + await expect( + listItem, + 'action list' + ).toHaveText([ + /browserContext.newPage[\d.]+m?s/, + /page.gotoabout:blank[\d.]+m?s/, + /page.setContent[\d.]+m?s/, + ]); + + // Manually select page.goto. + await page.getByTestId('action-list').getByText('page.goto').click(); + + // Generate more actions and check that we are still on the page.goto action. + latch.open(); + await expect( + listItem, + 'action list' + ).toHaveText([ + /browserContext.newPage[\d.]+m?s/, + /page.gotoabout:blank[\d.]+m?s/, + /page.setContent[\d.]+m?s/, + /page.setContent[\d.]+m?s/, + ]); + await expect( + listItem.locator(':scope.selected'), + 'selected action stays the same' + ).toHaveText(/page.goto/); +}); + +test('should update tracing network live', async ({ runUITest, server }) => { + server.setRoute('/style.css', async (req, res) => { + res.end('body { background: red; }'); + }); + + server.setRoute('/one.html', async (req, res) => { + res.end(` + + + + + One + + `); + }); + + const { page } = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('live test', async ({ page }) => { + await page.goto('${server.PREFIX}/one.html'); + await page.setContent('hello'); + await new Promise(() => {}); + }); + `, + }); + + // Start test. + await page.getByText('live test').dblclick(); + + // It should wait on the latch. + const listItem = page.getByTestId('action-list').getByRole('listitem'); + await expect( + listItem, + 'action list' + ).toHaveText([ + /browserContext.newPage[\d.]+m?s/, + /page.gotohttp:\/\/localhost:\d+\/one.html[\d.]+m?s/, + /page.setContent[\d.]+m?s/, + ]); + + // Once page.setContent is visible, we can be sure that page.goto has all required + // resources in the trace. Switch to it and check that everything renders. + await page.getByTestId('action-list').getByText('page.goto').click(); + + await expect( + page.frameLocator('iframe.snapshot-visible[name=snapshot]').locator('body'), + 'verify background' + ).toHaveCSS('background-color', 'rgb(255, 0, 0)', { timeout: 15000 }); +});