diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 7a636a83b0..4536fd4325 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -165,7 +165,7 @@ function isEqualAttachment(a: Attachment, b: AfterActionTraceEventAttachment): b return a.name === b.name && a.path === b.path && a.sha1 === b.sha1; } -function attachmentURL(attachment: Attachment, queryParams: Record = {}) { +export function attachmentURL(attachment: Attachment, queryParams: Record = {}) { const params = new URLSearchParams(queryParams); if (attachment.sha1) { params.set('trace', attachment.traceUrl); diff --git a/packages/trace-viewer/src/ui/copyToClipboard.tsx b/packages/trace-viewer/src/ui/copyToClipboard.tsx index 1eb989d08e..8f3f8cb448 100644 --- a/packages/trace-viewer/src/ui/copyToClipboard.tsx +++ b/packages/trace-viewer/src/ui/copyToClipboard.tsx @@ -46,11 +46,16 @@ export const CopyToClipboard: React.FunctionComponent<{ export const CopyToClipboardTextButton: React.FunctionComponent<{ value: string | (() => Promise), description: string, -}> = ({ value, description }) => { + copiedDescription?: React.ReactNode, + style?: React.CSSProperties, +}> = ({ value, description, copiedDescription = description, style }) => { + const [copied, setCopied] = React.useState(false); const handleCopy = React.useCallback(async () => { const valueToCopy = typeof value === 'function' ? await value() : value; await navigator.clipboard.writeText(valueToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 3000); }, [value]); - return {description}; + return {copied ? copiedDescription : description}; }; diff --git a/packages/trace-viewer/src/ui/errorsTab.tsx b/packages/trace-viewer/src/ui/errorsTab.tsx index acf5bf838e..3d8651f74d 100644 --- a/packages/trace-viewer/src/ui/errorsTab.tsx +++ b/packages/trace-viewer/src/ui/errorsTab.tsx @@ -21,6 +21,59 @@ import { PlaceholderPanel } from './placeholderPanel'; import { renderAction } from './actionList'; import type { Language } from '@isomorphic/locatorGenerators'; import type { StackFrame } from '@protocol/channels'; +import { CopyToClipboardTextButton } from './copyToClipboard'; +import { attachmentURL } from './attachmentsTab'; +import { fixTestPrompt } from '@web/components/prompts'; +import type { GitCommitInfo } from '@testIsomorphic/types'; + +const GitCommitInfoContext = React.createContext(undefined); + +export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) { + return {children}; +} + +export function useGitCommitInfo() { + return React.useContext(GitCommitInfoContext); +} + +const PromptButton: React.FC<{ + error: string; + actions: modelUtil.ActionTraceEventInContext[]; +}> = ({ error, actions }) => { + const [pageSnapshot, setPageSnapshot] = React.useState(); + + React.useEffect(() => { + for (const action of actions) { + for (const attachment of action.attachments ?? []) { + if (attachment.name === 'pageSnapshot') { + fetch(attachmentURL({ ...attachment, traceUrl: action.context.traceUrl })).then(async response => { + setPageSnapshot(await response.text()); + }); + return; + } + } + } + }, [actions]); + + const gitCommitInfo = useGitCommitInfo(); + const prompt = React.useMemo( + () => fixTestPrompt( + error, + gitCommitInfo?.['pull.diff'] ?? gitCommitInfo?.['revision.diff'], + pageSnapshot + ), + [error, gitCommitInfo, pageSnapshot] + ); + + return ( + Copied } + style={{ width: '90px', justifyContent: 'center' }} + /> + ); +}; export type ErrorDescription = { action?: modelUtil.ActionTraceEventInContext; @@ -44,9 +97,10 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): export const ErrorsTab: React.FunctionComponent<{ errorsModel: ErrorsTabModel, + actions: modelUtil.ActionTraceEventInContext[], sdkLanguage: Language, revealInSource: (error: ErrorDescription) => void, -}> = ({ errorsModel, sdkLanguage, revealInSource }) => { +}> = ({ errorsModel, sdkLanguage, revealInSource, actions }) => { if (!errorsModel.errors.size) return ; @@ -72,6 +126,9 @@ export const ErrorsTab: React.FunctionComponent<{ {location &&
@ revealInSource(error)}>{location}
} + + + ; diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 4375018765..8e27ed0137 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -37,6 +37,7 @@ import { TestListView } from './uiModeTestListView'; import { TraceView } from './uiModeTraceView'; import { SettingsView } from './settingsView'; import { DefaultSettingsView } from './defaultSettingsView'; +import { GitCommitInfoProvider } from './errorsTab'; let xtermSize = { cols: 80, rows: 24 }; const xtermDataSource: XtermDataSource = { @@ -430,13 +431,15 @@ export const UIModeView: React.FC<{}> = ({
- testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} - /> + + testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} + /> +
} sidebar={
diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index de59892772..25d01098ed 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -199,7 +199,7 @@ export const Workbench: React.FunctionComponent<{ else setRevealedError(error); selectPropertiesTab('source'); - }} /> + }} actions={model?.actions ?? []} /> }; // Fallback location w/o action stands for file / test. diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 8001623721..e7479cab1a 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -499,3 +499,22 @@ test('skipped steps should have an indicator', async ({ runUITest }) => { await expect(skippedMarker).toBeVisible(); await expect(skippedMarker).toHaveAccessibleName('skipped'); }); + +test('should show copy prompt button in errors tab', async ({ runUITest }) => { + const { page } = await runUITest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('fails', async () => { + expect(1).toBe(2); + }); + `, + }); + + await page.getByText('fails').dblclick(); + + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await page.getByText('Errors', { exact: true }).click(); + await page.locator('.tab-errors').getByRole('button', { name: 'Fix with AI' }).click(); + const prompt = await page.evaluate(() => navigator.clipboard.readText()); + expect(prompt, 'contains error').toContain('expect(received).toBe(expected)'); +});