Path: blob/main/extensions/copilot/test/simulation/workbench/components/testRun.tsx
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { MessageBar, MessageBarBody, MessageBarTitle, Text } from '@fluentui/react-components';6import { ChevronUp20Regular } from '@fluentui/react-icons';7import * as mobxlite from 'mobx-react-lite';8import * as React from 'react';9import { getTextPart } from '../../../../src/platform/chat/common/globalStringUtils';10import { IDiagnostic, IDiagnosticComparison, IRange, ISerializedFileEdit, ISerializedNesUserEditsHistory, InterceptedRequest } from '../../shared/sharedTypes';11import { EvaluationError } from '../stores/amlResults';12import { ISimulationTest } from '../stores/simulationTestsProvider';13import { IResolvedFile, InitialWorkspaceState, InteractionWorkspaceState, WorkspaceState } from '../stores/simulationWorkspaceState';14import { TestRun } from '../stores/testRun';15import { isToolCall } from '../utils/utils';16import { DisplayOptions } from './app';17import { DiffEditor } from './diffEditor';18import { Editor } from './editor';19import { ErrorComparison } from './errorComparison';20import { OutputView } from './output';21import { RequestView } from './request';22import { TestCaseSummary } from './testCaseSummary';2324type TestRunViewProps = {25readonly test: ISimulationTest;26readonly run: TestRun;27readonly baseline: TestRun | undefined;28readonly displayOptions: DisplayOptions;29readonly closeTestRunView: () => void;30};3132export const TestRunView = mobxlite.observer(33({ test, run, baseline, displayOptions, closeTestRunView }: TestRunViewProps) => {34return (35<div className='testRun'>36<div className='foldingBar' onClick={closeTestRunView}><ChevronUp20Regular /></div>37<div className='content'>38<TestRunVisualization test={test} run={run} baseline={baseline} displayOptions={displayOptions} />39{run.error !== undefined && <ErrorMessageBar error={run.error} />}40</div>41</div>42);43}44);4546class WorkspaceFileState {4748constructor(49public readonly files = new Map<string, string>(),50public readonly selections = new Map<string, IRange>(),51) { }5253public update(state: WorkspaceState): WorkspaceFileState {54const files = new Map<string, string>(this.files);55const selections = new Map<string, IRange>(this.selections);5657if (state.kind === 'initial') {58if (state.file.value) {59files.set(state.file.value.workspacePath, state.file.value.contents);60if (state.selection) {61selections.set(state.file.value.workspacePath, state.selection);62}63}64if (state.otherFiles.value) {65for (const file of state.otherFiles.value) {66files.set(file.workspacePath, file.contents);67}68}69} else {70for (const changedFile of state.changedFiles.value) {71if (files.has(changedFile.workspacePath) && state.selection) {72selections.set(changedFile.workspacePath, state.selection);73}74files.set(changedFile.workspacePath, changedFile.contents);75}76}77return new WorkspaceFileState(files, selections);78}79}8081class RequestRenderData {82constructor(83public readonly idx: number,84public readonly request: InterceptedRequest,85public readonly title: string | undefined,86public readonly baselineRequest: InterceptedRequest | undefined,87public readonly expand: boolean88) { }89}9091class RequestRenderer {92private _nextRequestIndex: number = 0;9394constructor(95private readonly _expand: boolean,96private readonly _run: TestRun,97private readonly _baseline: TestRun | undefined98) { }99100public take(stopIndex: number = this._run.requests.value.length): RequestRenderData[] {101stopIndex = Math.min(stopIndex, this._run.requests.value.length);102103const result: RequestRenderData[] = [];104while (this._nextRequestIndex < stopIndex) {105const request = this._run.requests.value[this._nextRequestIndex];106const baselineRequest = this._baseline?.requests.value[this._nextRequestIndex];107const isIntentDetection = Array.isArray(request.requestMessages) && request.requestMessages.some(m => getTextPart(m.content).includes('Function Id: doc'));108result.push(109new RequestRenderData(110this._nextRequestIndex,111request,112isIntentDetection ? 'Intent Detection' : undefined,113baselineRequest,114this._expand && !isIntentDetection115)116);117this._nextRequestIndex++;118}119return result;120}121}122123class OutputRenderData {124constructor(125public readonly run: TestRun,126public readonly baseline: TestRun | undefined,127public readonly expand: boolean128) { }129}130131const TestRunVisualization = mobxlite.observer(132({ test, run, baseline, displayOptions }: Omit<TestRunViewProps, 'closeTestRunView'>) => {133if (!run.requests.resolved || !run.inlineChatWorkspaceStates.resolved || !run.nextEditSuggestion.resolved) {134return <>PENDING</>;135}136137let files = new WorkspaceFileState();138const requestRenderer = new RequestRenderer(displayOptions.expandPrompts.value, run, baseline);139140const result: React.ReactElement[] = [];141if (142run.inlineChatWorkspaceStates.value.length === 2 &&143run.inlineChatWorkspaceStates.value[0].kind === 'initial' &&144run.inlineChatWorkspaceStates.value[1].kind === 'interaction' &&145run.inlineChatWorkspaceStates.value[1].changedFiles.value.length === 1 &&146run.inlineChatWorkspaceStates.value[1].changedFiles.value[0].workspacePath === run.inlineChatWorkspaceStates.value[0].file.value?.workspacePath147) {148// Optimize rendering for simple case149files = files.update(run.inlineChatWorkspaceStates.value[0]);150result.push(151<InteractionState key={`single-state-step`} test={test} files={files} state={run.inlineChatWorkspaceStates.value[1]} requests={[]} expectedDiff={run.expectedDiff} />152);153} else {154let stepNumber = 1;155for (const state of run.inlineChatWorkspaceStates.value) {156if (state.kind === 'initial') {157result.push(158<InitialState key='initial-state' state={state} />159);160} else {161const requests = requestRenderer.take(state.requestCount);162result.push(163<InteractionState key={`state-step-${stepNumber++}`} test={test} files={files} state={state} requests={requests} expectedDiff={run.expectedDiff} />164);165}166files = files.update(state);167}168}169170if (run.generatedTestCaseCount !== undefined && run.generatedTestCaseCount !== null) {171result.push(172<TestCaseSummary currentRun={run} baselineRun={baseline} />173);174}175176if (run.nesUserEditsHistory.value) {177result.push(178<NesUserEditHistory key='nes-user-edit-history' userEditsHistory={run.nesUserEditsHistory.value} />179);180}181if (baseline?.nextEditSuggestion.value) {182result.push(183<details><summary>Next edit (reference run)</summary>184<NextEditSuggestion key='reference-run-next-edit-suggestion'185runKind={'reference'}186proposedNextEdit={baseline.nextEditSuggestion.value} />187</details>188);189}190if (run.nextEditSuggestion.value) {191result.push(192<NextEditSuggestion key='next-edit-suggestion'193runKind={'current'}194proposedNextEdit={run.nextEditSuggestion.value} />195);196}197if (run.nesLogContext.value) {198result.push(<NesLogContext logContext={run.nesLogContext.value} />);199}200201result.push(<Requests key='leftover-requests' requests={requestRenderer.take()} />);202203if (run.stdout || run.stderr || (baseline && (baseline.stdout || baseline.stderr))) {204// We'll show the OutputView only when there is stdout OR stderr in either run or baseline.205206const outputRenderData = new OutputRenderData(run, baseline, displayOptions.expandPrompts.value);207result.push(<OutputView208run={outputRenderData.run}209baseline={outputRenderData.baseline}210expand={outputRenderData.expand}211/>212);213}214215return <div>{result}</div>;216}217);218219const InteractionState = mobxlite.observer(220({ test, files, state, requests, expectedDiff }: { test: ISimulationTest; files: WorkspaceFileState; state: InteractionWorkspaceState; requests: RequestRenderData[]; expectedDiff?: string }) => {221return (222<div>223<UserQuery key={`step-query`} text={state.interaction.query} />224{!state.interaction.query.startsWith('/') && <ChosenIntent key={`step-intent`} detectedIntent={state.interaction.detectedIntent} actualIntent={state.interaction.actualIntent} />}225<Requests key={'step-requests'} requests={requests} />226<ChangedFiles key={`step-changed`} files={files} state={state} test={test} />227{expectedDiff && <ExpectedDiff expectedDiff={expectedDiff} />}228<ErrorComparison key={`step-error-comparison`} test={test} />229</div>230);231}232);233234const Requests = mobxlite.observer(235({ requests }: { requests: RequestRenderData[] }) => {236let chatRequestIndex = 0;237let toolCallIndex = 0;238const requestViews = [];239for (let i = 0; i < requests.length; i++) {240const request = requests[i];241let index;242if (isToolCall(request.request)) {243index = toolCallIndex;244toolCallIndex += 1;245} else {246index = chatRequestIndex;247chatRequestIndex += 1;248}249requestViews.push(250<RequestView251key={`request-${i}`}252idx={index}253request={request.request}254title={request.title}255baselineRequest={request.baselineRequest}256expand={request.expand}257/>258);259}260return (<div>{requestViews}</div>);261}262);263264type ChangedFilesProps = {265readonly files: WorkspaceFileState;266readonly state: InteractionWorkspaceState;267readonly test: ISimulationTest;268};269270const ChangedFiles = mobxlite.observer(271({ files, state, test }: ChangedFilesProps) => {272const result: React.ReactElement[] = [];273for (let changedFileIndex = 0; changedFileIndex < state.changedFiles.value.length; changedFileIndex++) {274const changedFile = state.changedFiles.value[changedFileIndex];275let prevFile = files.files.get(changedFile.workspacePath);276// HACK here [mahuang]277// simulator creates a new test file if that doesn't exist and has one line in the file. That ends up showing a diff of the modified file with this line on the UI. We would like to replace prevFile with undefined if there is only one line in the content. So that on the UI we show that the file is created. Any only do this when the intent of the state is tests.278if (prevFile && prevFile.split('\n').length === 1 && state.interaction.detectedIntent === 'tests') {279prevFile = undefined;280}281282const fileDiagnostics = state.diagnostics?.[changedFile.workspacePath] ?? getDiagnosticsFromTest(test, changedFileIndex);283result.push(284<ChangedFile285key={`changedFile-${changedFileIndex}`}286changedFile={changedFile}287prevFile={prevFile}288fileDiagnostics={fileDiagnostics}289languageId={changedFile.languageId ?? 'plaintext'}290range={state.range}291selections={{ before: files.selections.get(changedFile.workspacePath), after: state.selection }}292/>293);294}295return <div>{result}</div>;296}297);298function getDiagnosticsFromTest(testRun: ISimulationTest, index: number): IDiagnosticComparison | undefined {299if (index === 0 && testRun.errorsOnlyInBefore && testRun.errorsOnlyInAfter) {300const toDiagnostics = (errors: EvaluationError[]): IDiagnostic[] => {301return errors.map(error => {302return {303message: error.message,304range: { start: { line: error.startLine, character: error.startColumn }, end: { line: error.endLine, character: error.endColumn } }305};306});307};308return { before: toDiagnostics(testRun.errorsOnlyInBefore), after: toDiagnostics(testRun.errorsOnlyInAfter) };309}310return undefined;311}312313type ChangedFileProps = {314readonly changedFile: IResolvedFile;315readonly prevFile: string | undefined;316readonly fileDiagnostics: IDiagnosticComparison | undefined;317readonly languageId: string;318readonly selections: {319readonly before: IRange | undefined;320readonly after: IRange | undefined;321};322readonly range: IRange | undefined;323};324325const ChangedFile = mobxlite.observer(326({ changedFile, prevFile, fileDiagnostics, languageId, selections, range }: ChangedFileProps) => {327return (328<div>329{330typeof prevFile !== 'undefined'331? (332<div>333<div className='step-title'>334Modified of Current run [{changedFile.workspacePath}]335</div>336<Text size={300}>Left editor - the existing code before applying the change, Right editor - the new code after applying the change</Text>337<DiffEditor338languageId={languageId}339original={prevFile}340modified={changedFile.contents}341diagnostics={fileDiagnostics}342selections={selections}343/>344</div>345)346: (347<div>348<div className='step-title'>349Created of Current run [{changedFile.workspacePath}]350</div>351<Editor352contents={changedFile.contents}353languageId={languageId}354range={range}355selection={selections.after}356diagnostics={fileDiagnostics?.after ?? []}357/>358</div>359)360}361{fileDiagnostics &&362<div className='diagnostics-comparison'>363Diagnostics before: {fileDiagnostics.before.length},364after: {fileDiagnostics.after.length}365</div>366}367</div>368);369}370);371372const InitialState = mobxlite.observer(373({ state }: { state: InitialWorkspaceState }) => {374if (!state.file.value) {375return <></>;376}377378return (379<div>380<div className='step-title'>381Initial State of Current run [{state.file.value.workspacePath}]382</div>383<Editor384contents={state.file.value.contents}385languageId={state.languageId ?? 'plaintext'}386selection={state.selection}387diagnostics={state.diagnostics}388/>389</div>390);391}392);393394const NesUserEditHistory = mobxlite.observer(395({ userEditsHistory }: { userEditsHistory: ISerializedNesUserEditsHistory }) => {396const { edits, currentDocumentIndex } = userEditsHistory;397return (398<div>399<div>User edits</div>400{(edits).map((edit, idx) => {401return <div key={idx}>402<div className='step-title'>403{currentDocumentIndex === idx ? 'Active' : ''} File {edit.id ?? 'No file name'}404</div>405<DiffEditor406languageId={edit.languageId}407original={edit.original}408modified={edit.modified}409/>410</div>;411})}412</div>413);414}415);416417const NesLogContext = mobxlite.observer(418({ logContext }: { logContext: string }) => {419return (420<details>421<summary>Logs</summary>422<Editor contents={logContext} languageId='markdown' />423</details>424);425}426);427428const NextEditSuggestion = mobxlite.observer(429({ runKind, proposedNextEdit }: {430runKind: 'current' | 'reference';431proposedNextEdit: ISerializedFileEdit;432}) => {433return (434<div>435<div className='step-title'>436Next Edit ({runKind === 'current' ? 'current run' : 'reference run'})437</div>438<DiffEditor439languageId={proposedNextEdit.languageId}440original={proposedNextEdit.original}441modified={proposedNextEdit.modified}442/>443</div>444);445}446);447448const UserIcon = () => {449return (450<svg stroke='currentColor' fill='currentColor' strokeWidth='0' viewBox='0 0 256 256' height='100%' width='100%' xmlns='http://www.w3.org/2000/svg'><path d='M224,128a95.76,95.76,0,0,1-31.8,71.37A72,72,0,0,0,128,160a40,40,0,1,0-40-40,40,40,0,0,0,40,40,72,72,0,0,0-64.2,39.37h0A96,96,0,1,1,224,128Z' opacity='0.2'></path><path d='M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM74.08,197.5a64,64,0,0,1,107.84,0,87.83,87.83,0,0,1-107.84,0ZM96,120a32,32,0,1,1,32,32A32,32,0,0,1,96,120Zm97.76,66.41a79.66,79.66,0,0,0-36.06-28.75,48,48,0,1,0-59.4,0,79.66,79.66,0,0,0-36.06,28.75,88,88,0,1,1,131.52,0Z'></path></svg>451);452};453454const IntentIcon = () => {455return (456<svg fill='#000000' width='100%' height='100%' viewBox='0 0 32 32' id='icon' xmlns='http://www.w3.org/2000/svg'>457<polygon points='22 4 22 6 24.586 6 19.586 11 21 12.414 26 7.414 26 10 28 10 28 4 22 4' />458<polygon points='10 4 10 6 7.414 6 12.414 11 11 12.414 6 7.414 6 10 4 10 4 4 10 4' />459<polygon points='20 5 16 1 12 5 13.414 6.414 15 4.829 15 11 17 11 17 4.829 18.586 6.414 20 5' />460<polygon points='22 28 22 26 24.586 26 19.586 21 21 19.586 26 24.586 26 22 28 22 28 28 22 28' />461<polygon points='10 28 10 26 7.414 26 12.414 21 11 19.586 6 24.586 6 22 4 22 4 28 10 28' />462<polygon points='20 27 16 31 12 27 13.414 25.586 15 27.171 15 21 17 21 17 27.171 18.586 25.586 20 27' />463<polygon points='5 12 1 16 5 20 6.414 18.586 4.829 17 11 17 11 15 4.829 15 6.414 13.414 5 12' />464<polygon points='27 12 31 16 27 20 25.586 18.586 27.171 17 21 17 21 15 27.171 15 25.586 13.414 27 12' />465<rect id='_Transparent_Rectangle_' data-name='<Transparent Rectangle>' style={{ fill: 'none' }} width='32' height='32' />466</svg>467);468};469470const UserQuery = ({ text }: { text: string }) => {471return (472<div style={{ marginTop: '15px' }}>473<span style={{ width: '1.75em', height: '1.75em', display: 'inline-block', float: 'left' }}>474<UserIcon />475</span>476<span style={{477lineHeight: '1.75em',478display: 'inline-block',479float: 'left',480marginLeft: '5px',481maxWidth: '1000px',482}} className='step-query'>{text}</span>483<div style={{ clear: 'left' }}></div>484</div>485);486};487488const ChosenIntent = ({ detectedIntent, actualIntent }: { detectedIntent: string | undefined; actualIntent: string | undefined }) => {489return (490<div>491<span style={{ width: '1.50em', height: '1.50em', paddingLeft: '0.12em', paddingRight: '0.12em', display: 'inline-block', float: 'left' }}>492<IntentIcon />493</span>494<span style={{ lineHeight: '1.50em', display: 'inline-block', float: 'left', marginLeft: '5px' }} className='step-query'>/{detectedIntent} (EXPECTED: /{actualIntent})</span>495<div style={{ clear: 'left' }}></div>496</div>497);498};499500const ErrorMessageBar = mobxlite.observer(({ error }: { error: string }) => {501502let errorTitle: string = 'See below';503{ // try extracting first line of error504const firstNewlineIdx = error.indexOf('\n');505if (firstNewlineIdx !== -1) {506errorTitle = error.substring(0, firstNewlineIdx);507}508}509510return (511<MessageBar intent='error' layout='singleline'>512<MessageBarBody>513<MessageBarTitle>Test failed with error: {errorTitle}</MessageBarTitle>514<pre>{stripAnsiiColors(error)} </pre>515</MessageBarBody>516</MessageBar>517);518});519520function stripAnsiiColors(str: string) {521return str.replace(/\x1b\[[0-9;]*m/g, '');522}523524type ExpectedDiffProps = {525readonly expectedDiff: string;526};527528const ExpectedDiff = mobxlite.observer(529({ expectedDiff }: ExpectedDiffProps) => {530return <div className='expected-diffs'>531{dumbDiffParser(expectedDiff).map(details => {532return <div className='expected-diffs'>533<div className='step-title'>534Expected diff [{details.path}]535</div>536<DiffEditor537languageId={details.path.match(/\.\w+/)?.[0].substring(1) ?? 'plaintext'}538modified={details.modified}539original={details.original} />540</div>;541})}542</div>;543}544);545546function dumbDiffParser(diff: string): { original: string; modified: string; path: string }[] {547const lines = diff.split('\n');548const result: { original: string; modified: string; path: string }[] = [];549let current: { original: string; modified: string; path: string } | undefined;550for (const line of lines) {551if (line.startsWith('---')) {552const path = line.substring(6);553current = { original: '', modified: '', path };554result.push(current);555} else if (line.startsWith('+++')) {556// ignore557} else if (line.startsWith('-')) {558if (current) {559current.original += line.slice(1) + '\n';560}561} else if (line.startsWith('+')) {562if (current) {563current.modified += line.slice(1) + '\n';564}565} else {566if (current) {567current.original += line.slice(1) + '\n';568current.modified += line.slice(1) + '\n';569}570}571}572return result;573}574575