Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/simulation/workbench/components/testRun.tsx
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { MessageBar, MessageBarBody, MessageBarTitle, Text } from '@fluentui/react-components';
7
import { ChevronUp20Regular } from '@fluentui/react-icons';
8
import * as mobxlite from 'mobx-react-lite';
9
import * as React from 'react';
10
import { getTextPart } from '../../../../src/platform/chat/common/globalStringUtils';
11
import { IDiagnostic, IDiagnosticComparison, IRange, ISerializedFileEdit, ISerializedNesUserEditsHistory, InterceptedRequest } from '../../shared/sharedTypes';
12
import { EvaluationError } from '../stores/amlResults';
13
import { ISimulationTest } from '../stores/simulationTestsProvider';
14
import { IResolvedFile, InitialWorkspaceState, InteractionWorkspaceState, WorkspaceState } from '../stores/simulationWorkspaceState';
15
import { TestRun } from '../stores/testRun';
16
import { isToolCall } from '../utils/utils';
17
import { DisplayOptions } from './app';
18
import { DiffEditor } from './diffEditor';
19
import { Editor } from './editor';
20
import { ErrorComparison } from './errorComparison';
21
import { OutputView } from './output';
22
import { RequestView } from './request';
23
import { TestCaseSummary } from './testCaseSummary';
24
25
type TestRunViewProps = {
26
readonly test: ISimulationTest;
27
readonly run: TestRun;
28
readonly baseline: TestRun | undefined;
29
readonly displayOptions: DisplayOptions;
30
readonly closeTestRunView: () => void;
31
};
32
33
export const TestRunView = mobxlite.observer(
34
({ test, run, baseline, displayOptions, closeTestRunView }: TestRunViewProps) => {
35
return (
36
<div className='testRun'>
37
<div className='foldingBar' onClick={closeTestRunView}><ChevronUp20Regular /></div>
38
<div className='content'>
39
<TestRunVisualization test={test} run={run} baseline={baseline} displayOptions={displayOptions} />
40
{run.error !== undefined && <ErrorMessageBar error={run.error} />}
41
</div>
42
</div>
43
);
44
}
45
);
46
47
class WorkspaceFileState {
48
49
constructor(
50
public readonly files = new Map<string, string>(),
51
public readonly selections = new Map<string, IRange>(),
52
) { }
53
54
public update(state: WorkspaceState): WorkspaceFileState {
55
const files = new Map<string, string>(this.files);
56
const selections = new Map<string, IRange>(this.selections);
57
58
if (state.kind === 'initial') {
59
if (state.file.value) {
60
files.set(state.file.value.workspacePath, state.file.value.contents);
61
if (state.selection) {
62
selections.set(state.file.value.workspacePath, state.selection);
63
}
64
}
65
if (state.otherFiles.value) {
66
for (const file of state.otherFiles.value) {
67
files.set(file.workspacePath, file.contents);
68
}
69
}
70
} else {
71
for (const changedFile of state.changedFiles.value) {
72
if (files.has(changedFile.workspacePath) && state.selection) {
73
selections.set(changedFile.workspacePath, state.selection);
74
}
75
files.set(changedFile.workspacePath, changedFile.contents);
76
}
77
}
78
return new WorkspaceFileState(files, selections);
79
}
80
}
81
82
class RequestRenderData {
83
constructor(
84
public readonly idx: number,
85
public readonly request: InterceptedRequest,
86
public readonly title: string | undefined,
87
public readonly baselineRequest: InterceptedRequest | undefined,
88
public readonly expand: boolean
89
) { }
90
}
91
92
class RequestRenderer {
93
private _nextRequestIndex: number = 0;
94
95
constructor(
96
private readonly _expand: boolean,
97
private readonly _run: TestRun,
98
private readonly _baseline: TestRun | undefined
99
) { }
100
101
public take(stopIndex: number = this._run.requests.value.length): RequestRenderData[] {
102
stopIndex = Math.min(stopIndex, this._run.requests.value.length);
103
104
const result: RequestRenderData[] = [];
105
while (this._nextRequestIndex < stopIndex) {
106
const request = this._run.requests.value[this._nextRequestIndex];
107
const baselineRequest = this._baseline?.requests.value[this._nextRequestIndex];
108
const isIntentDetection = Array.isArray(request.requestMessages) && request.requestMessages.some(m => getTextPart(m.content).includes('Function Id: doc'));
109
result.push(
110
new RequestRenderData(
111
this._nextRequestIndex,
112
request,
113
isIntentDetection ? 'Intent Detection' : undefined,
114
baselineRequest,
115
this._expand && !isIntentDetection
116
)
117
);
118
this._nextRequestIndex++;
119
}
120
return result;
121
}
122
}
123
124
class OutputRenderData {
125
constructor(
126
public readonly run: TestRun,
127
public readonly baseline: TestRun | undefined,
128
public readonly expand: boolean
129
) { }
130
}
131
132
const TestRunVisualization = mobxlite.observer(
133
({ test, run, baseline, displayOptions }: Omit<TestRunViewProps, 'closeTestRunView'>) => {
134
if (!run.requests.resolved || !run.inlineChatWorkspaceStates.resolved || !run.nextEditSuggestion.resolved) {
135
return <>PENDING</>;
136
}
137
138
let files = new WorkspaceFileState();
139
const requestRenderer = new RequestRenderer(displayOptions.expandPrompts.value, run, baseline);
140
141
const result: React.ReactElement[] = [];
142
if (
143
run.inlineChatWorkspaceStates.value.length === 2 &&
144
run.inlineChatWorkspaceStates.value[0].kind === 'initial' &&
145
run.inlineChatWorkspaceStates.value[1].kind === 'interaction' &&
146
run.inlineChatWorkspaceStates.value[1].changedFiles.value.length === 1 &&
147
run.inlineChatWorkspaceStates.value[1].changedFiles.value[0].workspacePath === run.inlineChatWorkspaceStates.value[0].file.value?.workspacePath
148
) {
149
// Optimize rendering for simple case
150
files = files.update(run.inlineChatWorkspaceStates.value[0]);
151
result.push(
152
<InteractionState key={`single-state-step`} test={test} files={files} state={run.inlineChatWorkspaceStates.value[1]} requests={[]} expectedDiff={run.expectedDiff} />
153
);
154
} else {
155
let stepNumber = 1;
156
for (const state of run.inlineChatWorkspaceStates.value) {
157
if (state.kind === 'initial') {
158
result.push(
159
<InitialState key='initial-state' state={state} />
160
);
161
} else {
162
const requests = requestRenderer.take(state.requestCount);
163
result.push(
164
<InteractionState key={`state-step-${stepNumber++}`} test={test} files={files} state={state} requests={requests} expectedDiff={run.expectedDiff} />
165
);
166
}
167
files = files.update(state);
168
}
169
}
170
171
if (run.generatedTestCaseCount !== undefined && run.generatedTestCaseCount !== null) {
172
result.push(
173
<TestCaseSummary currentRun={run} baselineRun={baseline} />
174
);
175
}
176
177
if (run.nesUserEditsHistory.value) {
178
result.push(
179
<NesUserEditHistory key='nes-user-edit-history' userEditsHistory={run.nesUserEditsHistory.value} />
180
);
181
}
182
if (baseline?.nextEditSuggestion.value) {
183
result.push(
184
<details><summary>Next edit (reference run)</summary>
185
<NextEditSuggestion key='reference-run-next-edit-suggestion'
186
runKind={'reference'}
187
proposedNextEdit={baseline.nextEditSuggestion.value} />
188
</details>
189
);
190
}
191
if (run.nextEditSuggestion.value) {
192
result.push(
193
<NextEditSuggestion key='next-edit-suggestion'
194
runKind={'current'}
195
proposedNextEdit={run.nextEditSuggestion.value} />
196
);
197
}
198
if (run.nesLogContext.value) {
199
result.push(<NesLogContext logContext={run.nesLogContext.value} />);
200
}
201
202
result.push(<Requests key='leftover-requests' requests={requestRenderer.take()} />);
203
204
if (run.stdout || run.stderr || (baseline && (baseline.stdout || baseline.stderr))) {
205
// We'll show the OutputView only when there is stdout OR stderr in either run or baseline.
206
207
const outputRenderData = new OutputRenderData(run, baseline, displayOptions.expandPrompts.value);
208
result.push(<OutputView
209
run={outputRenderData.run}
210
baseline={outputRenderData.baseline}
211
expand={outputRenderData.expand}
212
/>
213
);
214
}
215
216
return <div>{result}</div>;
217
}
218
);
219
220
const InteractionState = mobxlite.observer(
221
({ test, files, state, requests, expectedDiff }: { test: ISimulationTest; files: WorkspaceFileState; state: InteractionWorkspaceState; requests: RequestRenderData[]; expectedDiff?: string }) => {
222
return (
223
<div>
224
<UserQuery key={`step-query`} text={state.interaction.query} />
225
{!state.interaction.query.startsWith('/') && <ChosenIntent key={`step-intent`} detectedIntent={state.interaction.detectedIntent} actualIntent={state.interaction.actualIntent} />}
226
<Requests key={'step-requests'} requests={requests} />
227
<ChangedFiles key={`step-changed`} files={files} state={state} test={test} />
228
{expectedDiff && <ExpectedDiff expectedDiff={expectedDiff} />}
229
<ErrorComparison key={`step-error-comparison`} test={test} />
230
</div>
231
);
232
}
233
);
234
235
const Requests = mobxlite.observer(
236
({ requests }: { requests: RequestRenderData[] }) => {
237
let chatRequestIndex = 0;
238
let toolCallIndex = 0;
239
const requestViews = [];
240
for (let i = 0; i < requests.length; i++) {
241
const request = requests[i];
242
let index;
243
if (isToolCall(request.request)) {
244
index = toolCallIndex;
245
toolCallIndex += 1;
246
} else {
247
index = chatRequestIndex;
248
chatRequestIndex += 1;
249
}
250
requestViews.push(
251
<RequestView
252
key={`request-${i}`}
253
idx={index}
254
request={request.request}
255
title={request.title}
256
baselineRequest={request.baselineRequest}
257
expand={request.expand}
258
/>
259
);
260
}
261
return (<div>{requestViews}</div>);
262
}
263
);
264
265
type ChangedFilesProps = {
266
readonly files: WorkspaceFileState;
267
readonly state: InteractionWorkspaceState;
268
readonly test: ISimulationTest;
269
};
270
271
const ChangedFiles = mobxlite.observer(
272
({ files, state, test }: ChangedFilesProps) => {
273
const result: React.ReactElement[] = [];
274
for (let changedFileIndex = 0; changedFileIndex < state.changedFiles.value.length; changedFileIndex++) {
275
const changedFile = state.changedFiles.value[changedFileIndex];
276
let prevFile = files.files.get(changedFile.workspacePath);
277
// HACK here [mahuang]
278
// 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.
279
if (prevFile && prevFile.split('\n').length === 1 && state.interaction.detectedIntent === 'tests') {
280
prevFile = undefined;
281
}
282
283
const fileDiagnostics = state.diagnostics?.[changedFile.workspacePath] ?? getDiagnosticsFromTest(test, changedFileIndex);
284
result.push(
285
<ChangedFile
286
key={`changedFile-${changedFileIndex}`}
287
changedFile={changedFile}
288
prevFile={prevFile}
289
fileDiagnostics={fileDiagnostics}
290
languageId={changedFile.languageId ?? 'plaintext'}
291
range={state.range}
292
selections={{ before: files.selections.get(changedFile.workspacePath), after: state.selection }}
293
/>
294
);
295
}
296
return <div>{result}</div>;
297
}
298
);
299
function getDiagnosticsFromTest(testRun: ISimulationTest, index: number): IDiagnosticComparison | undefined {
300
if (index === 0 && testRun.errorsOnlyInBefore && testRun.errorsOnlyInAfter) {
301
const toDiagnostics = (errors: EvaluationError[]): IDiagnostic[] => {
302
return errors.map(error => {
303
return {
304
message: error.message,
305
range: { start: { line: error.startLine, character: error.startColumn }, end: { line: error.endLine, character: error.endColumn } }
306
};
307
});
308
};
309
return { before: toDiagnostics(testRun.errorsOnlyInBefore), after: toDiagnostics(testRun.errorsOnlyInAfter) };
310
}
311
return undefined;
312
}
313
314
type ChangedFileProps = {
315
readonly changedFile: IResolvedFile;
316
readonly prevFile: string | undefined;
317
readonly fileDiagnostics: IDiagnosticComparison | undefined;
318
readonly languageId: string;
319
readonly selections: {
320
readonly before: IRange | undefined;
321
readonly after: IRange | undefined;
322
};
323
readonly range: IRange | undefined;
324
};
325
326
const ChangedFile = mobxlite.observer(
327
({ changedFile, prevFile, fileDiagnostics, languageId, selections, range }: ChangedFileProps) => {
328
return (
329
<div>
330
{
331
typeof prevFile !== 'undefined'
332
? (
333
<div>
334
<div className='step-title'>
335
Modified of Current run [{changedFile.workspacePath}]
336
</div>
337
<Text size={300}>Left editor - the existing code before applying the change, Right editor - the new code after applying the change</Text>
338
<DiffEditor
339
languageId={languageId}
340
original={prevFile}
341
modified={changedFile.contents}
342
diagnostics={fileDiagnostics}
343
selections={selections}
344
/>
345
</div>
346
)
347
: (
348
<div>
349
<div className='step-title'>
350
Created of Current run [{changedFile.workspacePath}]
351
</div>
352
<Editor
353
contents={changedFile.contents}
354
languageId={languageId}
355
range={range}
356
selection={selections.after}
357
diagnostics={fileDiagnostics?.after ?? []}
358
/>
359
</div>
360
)
361
}
362
{fileDiagnostics &&
363
<div className='diagnostics-comparison'>
364
Diagnostics before: {fileDiagnostics.before.length},
365
after: {fileDiagnostics.after.length}
366
</div>
367
}
368
</div>
369
);
370
}
371
);
372
373
const InitialState = mobxlite.observer(
374
({ state }: { state: InitialWorkspaceState }) => {
375
if (!state.file.value) {
376
return <></>;
377
}
378
379
return (
380
<div>
381
<div className='step-title'>
382
Initial State of Current run [{state.file.value.workspacePath}]
383
</div>
384
<Editor
385
contents={state.file.value.contents}
386
languageId={state.languageId ?? 'plaintext'}
387
selection={state.selection}
388
diagnostics={state.diagnostics}
389
/>
390
</div>
391
);
392
}
393
);
394
395
const NesUserEditHistory = mobxlite.observer(
396
({ userEditsHistory }: { userEditsHistory: ISerializedNesUserEditsHistory }) => {
397
const { edits, currentDocumentIndex } = userEditsHistory;
398
return (
399
<div>
400
<div>User edits</div>
401
{(edits).map((edit, idx) => {
402
return <div key={idx}>
403
<div className='step-title'>
404
{currentDocumentIndex === idx ? 'Active' : ''} File {edit.id ?? 'No file name'}
405
</div>
406
<DiffEditor
407
languageId={edit.languageId}
408
original={edit.original}
409
modified={edit.modified}
410
/>
411
</div>;
412
})}
413
</div>
414
);
415
}
416
);
417
418
const NesLogContext = mobxlite.observer(
419
({ logContext }: { logContext: string }) => {
420
return (
421
<details>
422
<summary>Logs</summary>
423
<Editor contents={logContext} languageId='markdown' />
424
</details>
425
);
426
}
427
);
428
429
const NextEditSuggestion = mobxlite.observer(
430
({ runKind, proposedNextEdit }: {
431
runKind: 'current' | 'reference';
432
proposedNextEdit: ISerializedFileEdit;
433
}) => {
434
return (
435
<div>
436
<div className='step-title'>
437
Next Edit ({runKind === 'current' ? 'current run' : 'reference run'})
438
</div>
439
<DiffEditor
440
languageId={proposedNextEdit.languageId}
441
original={proposedNextEdit.original}
442
modified={proposedNextEdit.modified}
443
/>
444
</div>
445
);
446
}
447
);
448
449
const UserIcon = () => {
450
return (
451
<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>
452
);
453
};
454
455
const IntentIcon = () => {
456
return (
457
<svg fill='#000000' width='100%' height='100%' viewBox='0 0 32 32' id='icon' xmlns='http://www.w3.org/2000/svg'>
458
<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' />
459
<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' />
460
<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' />
461
<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' />
462
<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' />
463
<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' />
464
<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' />
465
<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' />
466
<rect id='_Transparent_Rectangle_' data-name='&lt;Transparent Rectangle&gt;' style={{ fill: 'none' }} width='32' height='32' />
467
</svg>
468
);
469
};
470
471
const UserQuery = ({ text }: { text: string }) => {
472
return (
473
<div style={{ marginTop: '15px' }}>
474
<span style={{ width: '1.75em', height: '1.75em', display: 'inline-block', float: 'left' }}>
475
<UserIcon />
476
</span>
477
<span style={{
478
lineHeight: '1.75em',
479
display: 'inline-block',
480
float: 'left',
481
marginLeft: '5px',
482
maxWidth: '1000px',
483
}} className='step-query'>{text}</span>
484
<div style={{ clear: 'left' }}></div>
485
</div>
486
);
487
};
488
489
const ChosenIntent = ({ detectedIntent, actualIntent }: { detectedIntent: string | undefined; actualIntent: string | undefined }) => {
490
return (
491
<div>
492
<span style={{ width: '1.50em', height: '1.50em', paddingLeft: '0.12em', paddingRight: '0.12em', display: 'inline-block', float: 'left' }}>
493
<IntentIcon />
494
</span>
495
<span style={{ lineHeight: '1.50em', display: 'inline-block', float: 'left', marginLeft: '5px' }} className='step-query'>/{detectedIntent} (EXPECTED: /{actualIntent})</span>
496
<div style={{ clear: 'left' }}></div>
497
</div>
498
);
499
};
500
501
const ErrorMessageBar = mobxlite.observer(({ error }: { error: string }) => {
502
503
let errorTitle: string = 'See below';
504
{ // try extracting first line of error
505
const firstNewlineIdx = error.indexOf('\n');
506
if (firstNewlineIdx !== -1) {
507
errorTitle = error.substring(0, firstNewlineIdx);
508
}
509
}
510
511
return (
512
<MessageBar intent='error' layout='singleline'>
513
<MessageBarBody>
514
<MessageBarTitle>Test failed with error: {errorTitle}</MessageBarTitle>
515
<pre>{stripAnsiiColors(error)} </pre>
516
</MessageBarBody>
517
</MessageBar>
518
);
519
});
520
521
function stripAnsiiColors(str: string) {
522
return str.replace(/\x1b\[[0-9;]*m/g, '');
523
}
524
525
type ExpectedDiffProps = {
526
readonly expectedDiff: string;
527
};
528
529
const ExpectedDiff = mobxlite.observer(
530
({ expectedDiff }: ExpectedDiffProps) => {
531
return <div className='expected-diffs'>
532
{dumbDiffParser(expectedDiff).map(details => {
533
return <div className='expected-diffs'>
534
<div className='step-title'>
535
Expected diff [{details.path}]
536
</div>
537
<DiffEditor
538
languageId={details.path.match(/\.\w+/)?.[0].substring(1) ?? 'plaintext'}
539
modified={details.modified}
540
original={details.original} />
541
</div>;
542
})}
543
</div>;
544
}
545
);
546
547
function dumbDiffParser(diff: string): { original: string; modified: string; path: string }[] {
548
const lines = diff.split('\n');
549
const result: { original: string; modified: string; path: string }[] = [];
550
let current: { original: string; modified: string; path: string } | undefined;
551
for (const line of lines) {
552
if (line.startsWith('---')) {
553
const path = line.substring(6);
554
current = { original: '', modified: '', path };
555
result.push(current);
556
} else if (line.startsWith('+++')) {
557
// ignore
558
} else if (line.startsWith('-')) {
559
if (current) {
560
current.original += line.slice(1) + '\n';
561
}
562
} else if (line.startsWith('+')) {
563
if (current) {
564
current.modified += line.slice(1) + '\n';
565
}
566
} else {
567
if (current) {
568
current.original += line.slice(1) + '\n';
569
current.modified += line.slice(1) + '\n';
570
}
571
}
572
}
573
return result;
574
}
575