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…
Reference in New Issue