mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	feat(ui): "fix with ai" button (#34708)
This commit is contained in:
		
							parent
							
								
									2f8d448dbb
								
							
						
					
					
						commit
						0672f1ce67
					
				| @ -165,7 +165,7 @@ function isEqualAttachment(a: Attachment, b: AfterActionTraceEventAttachment): b | |||||||
|   return a.name === b.name && a.path === b.path && a.sha1 === b.sha1; |   return a.name === b.name && a.path === b.path && a.sha1 === b.sha1; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function attachmentURL(attachment: Attachment, queryParams: Record<string, string> = {}) { | export function attachmentURL(attachment: Attachment, queryParams: Record<string, string> = {}) { | ||||||
|   const params = new URLSearchParams(queryParams); |   const params = new URLSearchParams(queryParams); | ||||||
|   if (attachment.sha1) { |   if (attachment.sha1) { | ||||||
|     params.set('trace', attachment.traceUrl); |     params.set('trace', attachment.traceUrl); | ||||||
|  | |||||||
| @ -46,11 +46,16 @@ export const CopyToClipboard: React.FunctionComponent<{ | |||||||
| export const CopyToClipboardTextButton: React.FunctionComponent<{ | export const CopyToClipboardTextButton: React.FunctionComponent<{ | ||||||
|   value: string | (() => Promise<string>), |   value: string | (() => Promise<string>), | ||||||
|   description: string, |   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 handleCopy = React.useCallback(async () => { | ||||||
|     const valueToCopy = typeof value === 'function' ? await value() : value; |     const valueToCopy = typeof value === 'function' ? await value() : value; | ||||||
|     await navigator.clipboard.writeText(valueToCopy); |     await navigator.clipboard.writeText(valueToCopy); | ||||||
|  |     setCopied(true); | ||||||
|  |     setTimeout(() => setCopied(false), 3000); | ||||||
|   }, [value]); |   }, [value]); | ||||||
| 
 | 
 | ||||||
|   return <ToolbarButton title={description} onClick={handleCopy} className='copy-to-clipboard-text-button'>{description}</ToolbarButton>; |   return <ToolbarButton style={style} title={description} onClick={handleCopy} className='copy-to-clipboard-text-button'>{copied ? copiedDescription : description}</ToolbarButton>; | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -21,6 +21,59 @@ import { PlaceholderPanel } from './placeholderPanel'; | |||||||
| import { renderAction } from './actionList'; | import { renderAction } from './actionList'; | ||||||
| import type { Language } from '@isomorphic/locatorGenerators'; | import type { Language } from '@isomorphic/locatorGenerators'; | ||||||
| import type { StackFrame } from '@protocol/channels'; | 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<GitCommitInfo | undefined>(undefined); | ||||||
|  | 
 | ||||||
|  | export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) { | ||||||
|  |   return <GitCommitInfoContext.Provider value={gitCommitInfo}>{children}</GitCommitInfoContext.Provider>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function useGitCommitInfo() { | ||||||
|  |   return React.useContext(GitCommitInfoContext); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const PromptButton: React.FC<{ | ||||||
|  |   error: string; | ||||||
|  |   actions: modelUtil.ActionTraceEventInContext[]; | ||||||
|  | }> = ({ error, actions }) => { | ||||||
|  |   const [pageSnapshot, setPageSnapshot] = React.useState<string>(); | ||||||
|  | 
 | ||||||
|  |   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 ( | ||||||
|  |     <CopyToClipboardTextButton | ||||||
|  |       value={prompt} | ||||||
|  |       description='Fix with AI' | ||||||
|  |       copiedDescription={<>Copied <span className='codicon codicon-copy' style={{ marginLeft: '5px' }}/></>} | ||||||
|  |       style={{ width: '90px', justifyContent: 'center' }} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| export type ErrorDescription = { | export type ErrorDescription = { | ||||||
|   action?: modelUtil.ActionTraceEventInContext; |   action?: modelUtil.ActionTraceEventInContext; | ||||||
| @ -44,9 +97,10 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined): | |||||||
| 
 | 
 | ||||||
| export const ErrorsTab: React.FunctionComponent<{ | export const ErrorsTab: React.FunctionComponent<{ | ||||||
|   errorsModel: ErrorsTabModel, |   errorsModel: ErrorsTabModel, | ||||||
|  |   actions: modelUtil.ActionTraceEventInContext[], | ||||||
|   sdkLanguage: Language, |   sdkLanguage: Language, | ||||||
|   revealInSource: (error: ErrorDescription) => void, |   revealInSource: (error: ErrorDescription) => void, | ||||||
| }> = ({ errorsModel, sdkLanguage, revealInSource }) => { | }> = ({ errorsModel, sdkLanguage, revealInSource, actions }) => { | ||||||
|   if (!errorsModel.errors.size) |   if (!errorsModel.errors.size) | ||||||
|     return <PlaceholderPanel text='No errors' />; |     return <PlaceholderPanel text='No errors' />; | ||||||
| 
 | 
 | ||||||
| @ -72,6 +126,9 @@ export const ErrorsTab: React.FunctionComponent<{ | |||||||
|           {location && <div className='action-location'> |           {location && <div className='action-location'> | ||||||
|             @ <span title={longLocation} onClick={() => revealInSource(error)}>{location}</span> |             @ <span title={longLocation} onClick={() => revealInSource(error)}>{location}</span> | ||||||
|           </div>} |           </div>} | ||||||
|  |           <span style={{ position: 'absolute', right: '5px' }}> | ||||||
|  |             <PromptButton error={message} actions={actions} /> | ||||||
|  |           </span> | ||||||
|         </div> |         </div> | ||||||
|         <ErrorMessage error={message} /> |         <ErrorMessage error={message} /> | ||||||
|       </div>; |       </div>; | ||||||
|  | |||||||
| @ -37,6 +37,7 @@ import { TestListView } from './uiModeTestListView'; | |||||||
| import { TraceView } from './uiModeTraceView'; | import { TraceView } from './uiModeTraceView'; | ||||||
| import { SettingsView } from './settingsView'; | import { SettingsView } from './settingsView'; | ||||||
| import { DefaultSettingsView } from './defaultSettingsView'; | import { DefaultSettingsView } from './defaultSettingsView'; | ||||||
|  | import { GitCommitInfoProvider } from './errorsTab'; | ||||||
| 
 | 
 | ||||||
| let xtermSize = { cols: 80, rows: 24 }; | let xtermSize = { cols: 80, rows: 24 }; | ||||||
| const xtermDataSource: XtermDataSource = { | const xtermDataSource: XtermDataSource = { | ||||||
| @ -430,13 +431,15 @@ export const UIModeView: React.FC<{}> = ({ | |||||||
|           <XtermWrapper source={xtermDataSource}></XtermWrapper> |           <XtermWrapper source={xtermDataSource}></XtermWrapper> | ||||||
|         </div> |         </div> | ||||||
|         <div className={clsx('vbox', isShowingOutput && 'hidden')}> |         <div className={clsx('vbox', isShowingOutput && 'hidden')}> | ||||||
|           <TraceView |           <GitCommitInfoProvider gitCommitInfo={testModel?.config.metadata['git.commit.info']}> | ||||||
|             pathSeparator={queryParams.pathSeparator} |             <TraceView | ||||||
|             item={selectedItem} |               pathSeparator={queryParams.pathSeparator} | ||||||
|             rootDir={testModel?.config?.rootDir} |               item={selectedItem} | ||||||
|             revealSource={revealSource} |               rootDir={testModel?.config?.rootDir} | ||||||
|             onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} |               revealSource={revealSource} | ||||||
|           /> |               onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} | ||||||
|  |             /> | ||||||
|  |           </GitCommitInfoProvider> | ||||||
|         </div> |         </div> | ||||||
|       </div>} |       </div>} | ||||||
|       sidebar={<div className='vbox ui-mode-sidebar'> |       sidebar={<div className='vbox ui-mode-sidebar'> | ||||||
|  | |||||||
| @ -199,7 +199,7 @@ export const Workbench: React.FunctionComponent<{ | |||||||
|       else |       else | ||||||
|         setRevealedError(error); |         setRevealedError(error); | ||||||
|       selectPropertiesTab('source'); |       selectPropertiesTab('source'); | ||||||
|     }} /> |     }} actions={model?.actions ?? []} /> | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   // Fallback location w/o action stands for file / test.
 |   // Fallback location w/o action stands for file / test.
 | ||||||
|  | |||||||
| @ -499,3 +499,22 @@ test('skipped steps should have an indicator', async ({ runUITest }) => { | |||||||
|   await expect(skippedMarker).toBeVisible(); |   await expect(skippedMarker).toBeVisible(); | ||||||
|   await expect(skippedMarker).toHaveAccessibleName('skipped'); |   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)'); | ||||||
|  | }); | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Simon Knott
						Simon Knott