Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/simulation/workbench/components/testView.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 { Badge, BadgeProps, CounterBadge, Text, Tooltip, Tree, TreeItem, TreeItemLayout } from '@fluentui/react-components';
7
import { ArrowRight16Regular, Checkmark20Filled, DatabaseWarning20Regular, Dismiss20Filled, FluentIconsProps } from '@fluentui/react-icons';
8
import * as mobx from 'mobx';
9
import * as mobxlite from 'mobx-react-lite';
10
import * as React from 'react';
11
import { OutputAnnotation } from '../../shared/sharedTypes';
12
import { NesExternalOptions } from '../stores/nesExternalOptions';
13
import { RunnerOptions } from '../stores/runnerOptions';
14
import { RunnerTestStatus } from '../stores/runnerTestStatus';
15
import { SimulationRunner, StateKind } from '../stores/simulationRunner';
16
import { ISimulationTest } from '../stores/simulationTestsProvider';
17
import { TestRun } from '../stores/testRun';
18
import { TestSource, TestSourceValue } from '../stores/testSource';
19
import { DisplayOptions } from './app';
20
import { useContextMenu } from './contextMenu';
21
import { OpenInVSCodeButton } from './openInVSCode';
22
import { TestRunView } from './testRun';
23
24
type Props = {
25
readonly test: ISimulationTest;
26
readonly runner: SimulationRunner;
27
readonly runnerOptions: RunnerOptions;
28
readonly nesExternalOptions: NesExternalOptions;
29
readonly testSource: TestSourceValue;
30
readonly displayOptions: DisplayOptions;
31
};
32
33
export const TestView = mobxlite.observer(({ test, runner, runnerOptions, nesExternalOptions, testSource, displayOptions }: Props) => {
34
35
// Set the default open status for test runs. If there is is only one test run, the open status is `true`.
36
// Otherwise, they are `false`.
37
const [isTestRunOpen, setIsTestRunOpen] = React.useState(new Array(test.runnerStatus?.runs.length).fill(test.runnerStatus?.runs.length === 1 ? true : false));
38
const [highlightedIndices, setHighlightedIndices] = React.useState<boolean[]>(new Array(test.runnerStatus?.runs.length).fill(false));
39
40
const { showMenu } = useContextMenu();
41
42
// Add a ref to store the test item elements
43
const testItemRefs = React.useRef<HTMLDivElement[]>([]);
44
45
const updateNth = (n: number, value: boolean) => {
46
const copy = Array.from(isTestRunOpen);
47
copy[n] = value;
48
setIsTestRunOpen(copy);
49
};
50
51
const closeTestRunView = (idx: number) => {
52
updateNth(idx, false);
53
const copy = Array.from(highlightedIndices);
54
copy[idx] = true;
55
setHighlightedIndices(copy);
56
};
57
58
React.useEffect(() => {
59
const timeoutId = highlightedIndices.includes(true)
60
? setTimeout(() => {
61
setHighlightedIndices(new Array(test.runnerStatus?.runs.length).fill(false));
62
}, 1000)
63
: undefined;
64
65
return () => timeoutId && clearTimeout(timeoutId);
66
}, [highlightedIndices, test.runnerStatus?.runs.length]);
67
68
const testNameContextMenuEntries = (testName: string) => [
69
{
70
label: `Run test`,
71
onClick: () => runner.startRunning({
72
grep: `${testName}`,
73
cacheMode: runnerOptions.cacheMode.value,
74
n: parseInt(runnerOptions.n.value),
75
noFetch: runnerOptions.noFetch.value,
76
additionalArgs: runnerOptions.additionalArgs.value,
77
nesExternalScenariosPath: testSource.value === TestSource.NesExternal ? nesExternalOptions.externalScenariosPath.value || undefined : undefined,
78
}),
79
},
80
{
81
label: `Run test (grep update)`,
82
onClick: () => {
83
mobx.runInAction(() => runnerOptions.grep.value = testName);
84
runner.startRunning({
85
grep: testName,
86
cacheMode: runnerOptions.cacheMode.value,
87
n: parseInt(runnerOptions.n.value),
88
noFetch: runnerOptions.noFetch.value,
89
additionalArgs: runnerOptions.additionalArgs.value,
90
nesExternalScenariosPath: testSource.value === TestSource.NesExternal ? nesExternalOptions.externalScenariosPath.value || undefined : undefined,
91
});
92
},
93
},
94
{
95
label: `Run test once`,
96
onClick: () => runner.startRunning({
97
grep: `${testName}`,
98
cacheMode: runnerOptions.cacheMode.value,
99
n: 1,
100
noFetch: runnerOptions.noFetch.value,
101
additionalArgs: runnerOptions.additionalArgs.value,
102
nesExternalScenariosPath: testSource.value === TestSource.NesExternal ? nesExternalOptions.externalScenariosPath.value || undefined : undefined,
103
}),
104
},
105
{
106
label: 'Copy full test name',
107
onClick: () => navigator.clipboard.writeText(testName),
108
}
109
];
110
111
return (
112
<TreeItem itemType={'branch'} className='test-runs-container'>
113
<TreeItemLayout className='test-renderer'
114
iconBefore={<StatusIcon runner={runner} runnerOptions={runnerOptions} nesExternalOptions={nesExternalOptions} testSource={testSource} test={test} />}
115
iconAfter={test.runnerStatus && <RunsSummaryBadge runs={test.runnerStatus.runs} />}
116
onAuxClick={(e) => showMenu(e, testNameContextMenuEntries(test.name))}
117
>
118
<Score test={test} />
119
{displayOptions.testsKind.value === 'suiteList' ? null : <Text weight='semibold'>{test.suiteName}</Text>}
120
<Text>{test.suiteName ? test.name.replace(test.suiteName, '') : test.name}</Text>
121
<InlineTestError runnerStatus={test.runnerStatus} />
122
</TreeItemLayout>
123
<Tree>
124
{
125
test.runnerStatus === undefined
126
? (
127
<TreeItem itemType='leaf'>
128
<TreeItemLayout> Test doesn't have run info. Have you run the test? </TreeItemLayout>
129
</TreeItem>
130
)
131
: <>
132
{test.activeEditorLangId &&
133
<TreeItem itemType='leaf'>
134
<TreeItemLayout>
135
Language: {test.activeEditorLangId}
136
</TreeItemLayout>
137
</TreeItem>
138
}
139
{test.runnerStatus.runs.map(
140
(run, idx) => {
141
const key = `${test.name}-${idx}`;
142
const baseline = test.baseline?.runs[idx];
143
return (
144
// Wrap each TreeItem in a div and assign a ref
145
<div key={key} ref={el => testItemRefs.current[idx] = el!}>
146
<TreeItem
147
itemType='branch'
148
open={isTestRunOpen[idx]}
149
onOpenChange={() => updateNth(idx, !isTestRunOpen[idx])}
150
>
151
<TreeItemLayout
152
className={highlightedIndices[idx] ? 'fade-out-background' : undefined}
153
iconBefore={run.explicitScore === undefined ? undefined : <Badge title='Test Run Score (range [0, 1])' color='informative' size='small'>{run.explicitScore}</Badge>}
154
iconAfter={<RunSummaryBadge run={run} baseline={baseline} />}
155
>
156
Test Run # {idx + 1}
157
</TreeItemLayout>
158
<Tree>
159
<TreeItem itemType='leaf'>
160
<OpenInVSCodeButton test={test} />
161
<TestRunView
162
key={key}
163
test={test}
164
run={run}
165
baseline={baseline}
166
displayOptions={displayOptions}
167
closeTestRunView={() => closeTestRunView(idx)}
168
/>
169
</TreeItem>
170
</Tree>
171
</TreeItem>
172
</div>
173
);
174
}
175
)}
176
</>
177
}
178
</Tree>
179
</TreeItem >
180
);
181
});
182
183
const redIconStyleProps: FluentIconsProps = {
184
primaryFill: 'red',
185
};
186
187
const greenIconStyleProps: FluentIconsProps = {
188
primaryFill: 'green',
189
};
190
191
const RunSummaryBadge = ({ run, baseline }: { run: TestRun; baseline: TestRun | undefined }) => (
192
<>
193
<RunAndBaselineOutcomeBadge run={run} baseline={baseline} /> {/* show a "X" icon if run validation function failed */}
194
<CacheMisses cacheMissCount={run.hasCacheMiss ? 1 : 0} /> {/* shows a "cache miss" icon if a cache miss happens */}
195
<TotalDuration title='Total request run duration' timeInMs={run.averageRequestDuration && run.requestCount ? run.averageRequestDuration * run.requestCount : undefined} />
196
<AnnotationBadges annotations={run.annotations} />
197
</>
198
);
199
200
const RunOutcomeBadge = ({ testRun }: { testRun: TestRun }) => {
201
202
let tooltipContent: string | undefined;
203
204
if (testRun.pass) {
205
tooltipContent = 'Test passed';
206
} else {
207
const errorFirstLine = testRun.error?.split('\n')[0];
208
tooltipContent = (errorFirstLine ?? testRun.error) ?? 'Error info missing';
209
}
210
211
return (
212
<Tooltip content={tooltipContent} relationship={'description'}>
213
{testRun.pass ? <Checkmark20Filled {...greenIconStyleProps} /> : <Dismiss20Filled {...redIconStyleProps} />}
214
</Tooltip>
215
);
216
};
217
218
const RunAndBaselineOutcomeBadge = ({ run, baseline }: { run: TestRun; baseline: TestRun | undefined }) => {
219
if (baseline === undefined) {
220
if (run.pass) {
221
return null; // if test is passing, we don't need to show anything
222
} else {
223
return <RunOutcomeBadge testRun={run} />; // if there is no baseline, show just failure
224
}
225
} else {
226
if (baseline.pass === run.pass) {
227
if (run.pass) {
228
return null;
229
} else {
230
return <RunOutcomeBadge testRun={run} />;
231
}
232
} else {
233
return (
234
<>
235
<RunOutcomeBadge testRun={baseline} />
236
<Tooltip content={'Left - outcome of "Compare against" run | Right - outcome of "Current run"'} relationship={'description'}>
237
<ArrowRight16Regular />
238
</Tooltip>
239
<RunOutcomeBadge testRun={run} />
240
</>
241
);
242
}
243
}
244
};
245
246
const RunsSummaryBadge = ({ runs }: { runs: TestRun[] }) => {
247
let failingRunsCount = 0, cacheMissCount = 0, totalDurations = 0;
248
const infos: OutputAnnotation[] = [];
249
for (const run of runs) {
250
if (!run.pass) {
251
failingRunsCount++;
252
}
253
if (run.hasCacheMiss) {
254
cacheMissCount++;
255
}
256
if (run.averageRequestDuration !== undefined && run.requestCount !== undefined) {
257
const totalDuration = run.averageRequestDuration * run.requestCount;
258
totalDurations += totalDuration;
259
}
260
if (run.annotations) {
261
infos.push(...run.annotations);
262
}
263
}
264
265
return <>
266
{failingRunsCount ? <Dismiss20Filled {...redIconStyleProps} /> : null}
267
{failingRunsCount ? <CounterBadge count={failingRunsCount} color='danger' size='small' /> : null}
268
<CacheMisses cacheMissCount={cacheMissCount} />
269
{runs.length ? <TotalDuration title='Average request duration' timeInMs={totalDurations / runs.length} /> : null}
270
<AnnotationBadges annotations={infos} />
271
</>;
272
};
273
274
const AnnotationBadges = ({ annotations }: { annotations: OutputAnnotation[] }) => {
275
if (annotations.length) {
276
const colors: Record<string, BadgeProps['color']> = {
277
'error': 'severe',
278
'warning': 'warning',
279
'info': 'informative',
280
};
281
const annotationCounts = new Set<string>();
282
const badges: JSX.Element[] = [];
283
for (const info of annotations) {
284
if (!annotationCounts.has(info.label)) {
285
annotationCounts.add(info.label);
286
badges.push(<Badge key={info.label} title={info.message} color={colors[info.severity] ?? 'informative'} shape='square' appearance='outline' size='small'>{info.label}</Badge>);
287
}
288
}
289
return <>{badges}</>;
290
}
291
return null;
292
};
293
294
const CacheMisses = ({ cacheMissCount }: { cacheMissCount: number }) => {
295
if (cacheMissCount > 0) {
296
return <DatabaseWarning20Regular primaryFill={'orange'} title={`${cacheMissCount} cache misses`} />;
297
}
298
return null;
299
};
300
301
const TotalDuration = ({ timeInMs: timeInMillis, title }: { timeInMs: number | undefined; title: string }) => {
302
if (timeInMillis !== undefined) {
303
return <Badge title={title} color='informative' size='small'>{+((timeInMillis / 1000).toFixed(2))}s</Badge>;
304
}
305
return null;
306
};
307
308
const Score = mobxlite.observer(({ test }: { test: ISimulationTest }) => {
309
// Shows the score number and its comparison against the baseline. The baseline is defined as followed
310
// if test.baselineJSON is defined, it's used as the baseline.
311
// If test.baselineJSON is not defined and test.baseline is defined, test.baseline is used as the baseline.
312
// If neight test.baselineJSON nor test.baseline is defined, then there is no comaprison.
313
314
if (test.runnerStatus === undefined || test.runnerStatus.isSkipped) {
315
return null;
316
}
317
318
if (test.runnerStatus.runs.length < test.runnerStatus.expectedRuns) {
319
return (
320
<div className='test-score' title='# of runs completed (regardless of result) / # of total runs'>
321
{test.runnerStatus.runs.length} / {test.runnerStatus.expectedRuns}
322
</div>
323
);
324
}
325
326
const runs = test.runnerStatus.runs;
327
328
let explicitScoreRendering = '';
329
let explicitScoreColor: string | undefined = undefined;
330
let explicitScoreTitle = 'Score set by test itself on some rubric';
331
332
if (runs.length > 0 && runs[0].explicitScore !== undefined) {
333
334
const testRunsScore = runs.reduce((acc, run) => (acc + (run.explicitScore ?? 0)), 0) / runs.length;
335
const scoreToString = (passes: number) => `${String(passes.toFixed(2)).padStart(2, ' ')}`;
336
337
const testRunsScoreAsString = scoreToString(testRunsScore);
338
339
if (test.baselineJSON === undefined) {
340
explicitScoreRendering = testRunsScoreAsString;
341
} else {
342
343
const baselineJsonScoreAsString = scoreToString(test.baselineJSON.score);
344
345
explicitScoreColor =
346
testRunsScoreAsString === baselineJsonScoreAsString
347
? 'gray'
348
: (parseFloat(testRunsScoreAsString) > parseFloat(baselineJsonScoreAsString) ? 'green' : 'red');
349
350
if (testRunsScoreAsString === baselineJsonScoreAsString) {
351
explicitScoreRendering = `= ${scoreToString(testRunsScore)}`;
352
} else {
353
explicitScoreTitle += '(left - baseline | right - current)';
354
explicitScoreRendering = `${baselineJsonScoreAsString} -> ${testRunsScoreAsString}`;
355
}
356
}
357
}
358
359
const passCount = runs.filter(r => r.pass).length;
360
const hasBaseline = test.baselineJSON || test.baseline;
361
362
if (!hasBaseline) {
363
const title = `${passCount} runs passing`;
364
const scoreToString = (passes: number) => `${String(passes.toFixed(0)).padStart(3, ' ')}`;
365
return (
366
<div className='test-score' title={title}>
367
{scoreToString(passCount)} ({explicitScoreRendering})
368
</div>
369
);
370
}
371
372
const passPercentage = passCount / runs.length;
373
374
// When it runs to this line, either test.baselineJSON or test.baseline is defined. We'll use test.baselineJSON as the baseline first.
375
// If they both are not defined, we've already returned without comparison at line 328.
376
377
const baselinePassCount = test.baselineJSON
378
? test.baselineJSON.passCount
379
: (test.baseline ? test.baseline.runs.filter(r => r.pass).length : 0);
380
381
const baselineTotal = test.baselineJSON
382
? test.baselineJSON.passCount + test.baselineJSON.failCount
383
: (test.baseline ? test.baseline.runs.length : 0);
384
385
const baselinePercentage = baselinePassCount / baselineTotal;
386
387
const color =
388
passPercentage === baselinePercentage
389
? 'gray'
390
: (passPercentage > baselinePercentage ? 'green' : 'red');
391
392
const scoreToString = (passes: number) => `${String(passes).padStart(2, ' ')}`;
393
394
const title = [
395
`Runs: ${runs.length}`,
396
`Passing: ${passCount}`,
397
`Expected: ${runs.length * baselinePercentage} (Baseline: ${baselinePassCount} / ${baselineTotal})`,
398
`'=' sign means current score equals baseline score.`,
399
].join('\n');
400
401
const renderedScore =
402
passPercentage === baselinePercentage
403
? `= ${scoreToString(passCount)}`
404
: `${scoreToString(runs.length * baselinePercentage)} -> ${scoreToString(passCount)}`;
405
406
return (
407
<>
408
<span className='test-score' style={{ color }} title={title}>
409
{`${renderedScore} | `}
410
</span>
411
{explicitScoreRendering === ''
412
? null
413
: <span className='test-score' style={{ color: explicitScoreColor }} title={explicitScoreTitle}>
414
{`${explicitScoreRendering} | `}
415
</span>}
416
</>
417
);
418
});
419
420
const StatusIcon = mobxlite.observer(({ runner, runnerOptions, nesExternalOptions, testSource, test }: { runner: SimulationRunner; runnerOptions: RunnerOptions; nesExternalOptions: NesExternalOptions; testSource: TestSourceValue; test: ISimulationTest }) => {
421
const runTest = (e: React.MouseEvent) => {
422
e.stopPropagation();
423
if (runner.state.kind !== StateKind.Running) {
424
runner.startRunning({
425
grep: test.name,
426
cacheMode: runnerOptions.cacheMode.value,
427
n: parseInt(runnerOptions.n.value),
428
noFetch: runnerOptions.noFetch.value,
429
additionalArgs: runnerOptions.additionalArgs.value,
430
nesExternalScenariosPath: testSource.value === TestSource.NesExternal ? nesExternalOptions.externalScenariosPath.value || undefined : undefined,
431
});
432
}
433
};
434
435
const runnerStatus = test.runnerStatus;
436
437
if (runnerStatus) {
438
if (runnerStatus.isSkipped) {
439
return <span title='Test is skipped'>⏭️</span>;
440
} else if (runnerStatus.isCancelled && runner.terminationReason) {
441
return <span title='Simulation terminated early due to an error, click to run again.' onClick={runTest}></span>;
442
} else if (runnerStatus.isCancelled) {
443
return <span title='Test is cancelled, click to run again.' onClick={runTest}>⭕️</span>;
444
} else if (runnerStatus.isNowRunning > 0) {
445
return <span title='Test is currently running'>🏃</span>;
446
} else if (runnerStatus.runs.length < runnerStatus.expectedRuns) {
447
return <span title='Test is queued to be run'></span>;
448
} else {
449
const failCount = runnerStatus.runs.filter(r => !r.pass).length;
450
if (failCount === runnerStatus.runs.length) {
451
return <span title='All runs failed, click to run again.' onClick={runTest}></span>;
452
} else if (failCount > 0) {
453
return <span title={`${failCount} of ${runnerStatus.runs.length} runs failed, click to run again.`} onClick={runTest}>⚠️</span>;
454
}
455
return <span title='Test is complete, click to run again.' onClick={runTest}>🏁</span>;
456
}
457
}
458
return <span title='Test has not been run, click to run.' onClick={runTest}>🔘</span>;
459
});
460
461
const InlineTestError = mobxlite.observer(({ runnerStatus }: { runnerStatus: RunnerTestStatus | undefined }) => {
462
if (!runnerStatus) {
463
return null;
464
}
465
const failedRuns = runnerStatus.runs.filter(r => !r.pass && r.error);
466
if (failedRuns.length === 0) {
467
return null;
468
}
469
const firstError = failedRuns[0].error!;
470
const firstLine = firstError.split(/\r?\n/)[0];
471
const label = failedRuns.length > 1
472
? `${firstLine} (+${failedRuns.length - 1} more)`
473
: firstLine;
474
return (
475
<Tooltip content={firstError} relationship='description'>
476
<Text style={{ color: 'var(--colorPaletteRedForeground1)', marginLeft: '8px', fontSize: '12px' }}>{label}</Text>
477
</Tooltip>
478
);
479
});
480
481
482