Path: blob/main/extensions/copilot/test/simulation/workbench/components/testView.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 { Badge, BadgeProps, CounterBadge, Text, Tooltip, Tree, TreeItem, TreeItemLayout } from '@fluentui/react-components';6import { ArrowRight16Regular, Checkmark20Filled, DatabaseWarning20Regular, Dismiss20Filled, FluentIconsProps } from '@fluentui/react-icons';7import * as mobx from 'mobx';8import * as mobxlite from 'mobx-react-lite';9import * as React from 'react';10import { OutputAnnotation } from '../../shared/sharedTypes';11import { NesExternalOptions } from '../stores/nesExternalOptions';12import { RunnerOptions } from '../stores/runnerOptions';13import { RunnerTestStatus } from '../stores/runnerTestStatus';14import { SimulationRunner, StateKind } from '../stores/simulationRunner';15import { ISimulationTest } from '../stores/simulationTestsProvider';16import { TestRun } from '../stores/testRun';17import { TestSource, TestSourceValue } from '../stores/testSource';18import { DisplayOptions } from './app';19import { useContextMenu } from './contextMenu';20import { OpenInVSCodeButton } from './openInVSCode';21import { TestRunView } from './testRun';2223type Props = {24readonly test: ISimulationTest;25readonly runner: SimulationRunner;26readonly runnerOptions: RunnerOptions;27readonly nesExternalOptions: NesExternalOptions;28readonly testSource: TestSourceValue;29readonly displayOptions: DisplayOptions;30};3132export const TestView = mobxlite.observer(({ test, runner, runnerOptions, nesExternalOptions, testSource, displayOptions }: Props) => {3334// Set the default open status for test runs. If there is is only one test run, the open status is `true`.35// Otherwise, they are `false`.36const [isTestRunOpen, setIsTestRunOpen] = React.useState(new Array(test.runnerStatus?.runs.length).fill(test.runnerStatus?.runs.length === 1 ? true : false));37const [highlightedIndices, setHighlightedIndices] = React.useState<boolean[]>(new Array(test.runnerStatus?.runs.length).fill(false));3839const { showMenu } = useContextMenu();4041// Add a ref to store the test item elements42const testItemRefs = React.useRef<HTMLDivElement[]>([]);4344const updateNth = (n: number, value: boolean) => {45const copy = Array.from(isTestRunOpen);46copy[n] = value;47setIsTestRunOpen(copy);48};4950const closeTestRunView = (idx: number) => {51updateNth(idx, false);52const copy = Array.from(highlightedIndices);53copy[idx] = true;54setHighlightedIndices(copy);55};5657React.useEffect(() => {58const timeoutId = highlightedIndices.includes(true)59? setTimeout(() => {60setHighlightedIndices(new Array(test.runnerStatus?.runs.length).fill(false));61}, 1000)62: undefined;6364return () => timeoutId && clearTimeout(timeoutId);65}, [highlightedIndices, test.runnerStatus?.runs.length]);6667const testNameContextMenuEntries = (testName: string) => [68{69label: `Run test`,70onClick: () => runner.startRunning({71grep: `${testName}`,72cacheMode: runnerOptions.cacheMode.value,73n: parseInt(runnerOptions.n.value),74noFetch: runnerOptions.noFetch.value,75additionalArgs: runnerOptions.additionalArgs.value,76nesExternalScenariosPath: testSource.value === TestSource.NesExternal ? nesExternalOptions.externalScenariosPath.value || undefined : undefined,77}),78},79{80label: `Run test (grep update)`,81onClick: () => {82mobx.runInAction(() => runnerOptions.grep.value = testName);83runner.startRunning({84grep: testName,85cacheMode: runnerOptions.cacheMode.value,86n: parseInt(runnerOptions.n.value),87noFetch: runnerOptions.noFetch.value,88additionalArgs: runnerOptions.additionalArgs.value,89nesExternalScenariosPath: testSource.value === TestSource.NesExternal ? nesExternalOptions.externalScenariosPath.value || undefined : undefined,90});91},92},93{94label: `Run test once`,95onClick: () => runner.startRunning({96grep: `${testName}`,97cacheMode: runnerOptions.cacheMode.value,98n: 1,99noFetch: runnerOptions.noFetch.value,100additionalArgs: runnerOptions.additionalArgs.value,101nesExternalScenariosPath: testSource.value === TestSource.NesExternal ? nesExternalOptions.externalScenariosPath.value || undefined : undefined,102}),103},104{105label: 'Copy full test name',106onClick: () => navigator.clipboard.writeText(testName),107}108];109110return (111<TreeItem itemType={'branch'} className='test-runs-container'>112<TreeItemLayout className='test-renderer'113iconBefore={<StatusIcon runner={runner} runnerOptions={runnerOptions} nesExternalOptions={nesExternalOptions} testSource={testSource} test={test} />}114iconAfter={test.runnerStatus && <RunsSummaryBadge runs={test.runnerStatus.runs} />}115onAuxClick={(e) => showMenu(e, testNameContextMenuEntries(test.name))}116>117<Score test={test} />118{displayOptions.testsKind.value === 'suiteList' ? null : <Text weight='semibold'>{test.suiteName}</Text>}119<Text>{test.suiteName ? test.name.replace(test.suiteName, '') : test.name}</Text>120<InlineTestError runnerStatus={test.runnerStatus} />121</TreeItemLayout>122<Tree>123{124test.runnerStatus === undefined125? (126<TreeItem itemType='leaf'>127<TreeItemLayout> Test doesn't have run info. Have you run the test? </TreeItemLayout>128</TreeItem>129)130: <>131{test.activeEditorLangId &&132<TreeItem itemType='leaf'>133<TreeItemLayout>134Language: {test.activeEditorLangId}135</TreeItemLayout>136</TreeItem>137}138{test.runnerStatus.runs.map(139(run, idx) => {140const key = `${test.name}-${idx}`;141const baseline = test.baseline?.runs[idx];142return (143// Wrap each TreeItem in a div and assign a ref144<div key={key} ref={el => testItemRefs.current[idx] = el!}>145<TreeItem146itemType='branch'147open={isTestRunOpen[idx]}148onOpenChange={() => updateNth(idx, !isTestRunOpen[idx])}149>150<TreeItemLayout151className={highlightedIndices[idx] ? 'fade-out-background' : undefined}152iconBefore={run.explicitScore === undefined ? undefined : <Badge title='Test Run Score (range [0, 1])' color='informative' size='small'>{run.explicitScore}</Badge>}153iconAfter={<RunSummaryBadge run={run} baseline={baseline} />}154>155Test Run # {idx + 1}156</TreeItemLayout>157<Tree>158<TreeItem itemType='leaf'>159<OpenInVSCodeButton test={test} />160<TestRunView161key={key}162test={test}163run={run}164baseline={baseline}165displayOptions={displayOptions}166closeTestRunView={() => closeTestRunView(idx)}167/>168</TreeItem>169</Tree>170</TreeItem>171</div>172);173}174)}175</>176}177</Tree>178</TreeItem >179);180});181182const redIconStyleProps: FluentIconsProps = {183primaryFill: 'red',184};185186const greenIconStyleProps: FluentIconsProps = {187primaryFill: 'green',188};189190const RunSummaryBadge = ({ run, baseline }: { run: TestRun; baseline: TestRun | undefined }) => (191<>192<RunAndBaselineOutcomeBadge run={run} baseline={baseline} /> {/* show a "X" icon if run validation function failed */}193<CacheMisses cacheMissCount={run.hasCacheMiss ? 1 : 0} /> {/* shows a "cache miss" icon if a cache miss happens */}194<TotalDuration title='Total request run duration' timeInMs={run.averageRequestDuration && run.requestCount ? run.averageRequestDuration * run.requestCount : undefined} />195<AnnotationBadges annotations={run.annotations} />196</>197);198199const RunOutcomeBadge = ({ testRun }: { testRun: TestRun }) => {200201let tooltipContent: string | undefined;202203if (testRun.pass) {204tooltipContent = 'Test passed';205} else {206const errorFirstLine = testRun.error?.split('\n')[0];207tooltipContent = (errorFirstLine ?? testRun.error) ?? 'Error info missing';208}209210return (211<Tooltip content={tooltipContent} relationship={'description'}>212{testRun.pass ? <Checkmark20Filled {...greenIconStyleProps} /> : <Dismiss20Filled {...redIconStyleProps} />}213</Tooltip>214);215};216217const RunAndBaselineOutcomeBadge = ({ run, baseline }: { run: TestRun; baseline: TestRun | undefined }) => {218if (baseline === undefined) {219if (run.pass) {220return null; // if test is passing, we don't need to show anything221} else {222return <RunOutcomeBadge testRun={run} />; // if there is no baseline, show just failure223}224} else {225if (baseline.pass === run.pass) {226if (run.pass) {227return null;228} else {229return <RunOutcomeBadge testRun={run} />;230}231} else {232return (233<>234<RunOutcomeBadge testRun={baseline} />235<Tooltip content={'Left - outcome of "Compare against" run | Right - outcome of "Current run"'} relationship={'description'}>236<ArrowRight16Regular />237</Tooltip>238<RunOutcomeBadge testRun={run} />239</>240);241}242}243};244245const RunsSummaryBadge = ({ runs }: { runs: TestRun[] }) => {246let failingRunsCount = 0, cacheMissCount = 0, totalDurations = 0;247const infos: OutputAnnotation[] = [];248for (const run of runs) {249if (!run.pass) {250failingRunsCount++;251}252if (run.hasCacheMiss) {253cacheMissCount++;254}255if (run.averageRequestDuration !== undefined && run.requestCount !== undefined) {256const totalDuration = run.averageRequestDuration * run.requestCount;257totalDurations += totalDuration;258}259if (run.annotations) {260infos.push(...run.annotations);261}262}263264return <>265{failingRunsCount ? <Dismiss20Filled {...redIconStyleProps} /> : null}266{failingRunsCount ? <CounterBadge count={failingRunsCount} color='danger' size='small' /> : null}267<CacheMisses cacheMissCount={cacheMissCount} />268{runs.length ? <TotalDuration title='Average request duration' timeInMs={totalDurations / runs.length} /> : null}269<AnnotationBadges annotations={infos} />270</>;271};272273const AnnotationBadges = ({ annotations }: { annotations: OutputAnnotation[] }) => {274if (annotations.length) {275const colors: Record<string, BadgeProps['color']> = {276'error': 'severe',277'warning': 'warning',278'info': 'informative',279};280const annotationCounts = new Set<string>();281const badges: JSX.Element[] = [];282for (const info of annotations) {283if (!annotationCounts.has(info.label)) {284annotationCounts.add(info.label);285badges.push(<Badge key={info.label} title={info.message} color={colors[info.severity] ?? 'informative'} shape='square' appearance='outline' size='small'>{info.label}</Badge>);286}287}288return <>{badges}</>;289}290return null;291};292293const CacheMisses = ({ cacheMissCount }: { cacheMissCount: number }) => {294if (cacheMissCount > 0) {295return <DatabaseWarning20Regular primaryFill={'orange'} title={`${cacheMissCount} cache misses`} />;296}297return null;298};299300const TotalDuration = ({ timeInMs: timeInMillis, title }: { timeInMs: number | undefined; title: string }) => {301if (timeInMillis !== undefined) {302return <Badge title={title} color='informative' size='small'>{+((timeInMillis / 1000).toFixed(2))}s</Badge>;303}304return null;305};306307const Score = mobxlite.observer(({ test }: { test: ISimulationTest }) => {308// Shows the score number and its comparison against the baseline. The baseline is defined as followed309// if test.baselineJSON is defined, it's used as the baseline.310// If test.baselineJSON is not defined and test.baseline is defined, test.baseline is used as the baseline.311// If neight test.baselineJSON nor test.baseline is defined, then there is no comaprison.312313if (test.runnerStatus === undefined || test.runnerStatus.isSkipped) {314return null;315}316317if (test.runnerStatus.runs.length < test.runnerStatus.expectedRuns) {318return (319<div className='test-score' title='# of runs completed (regardless of result) / # of total runs'>320{test.runnerStatus.runs.length} / {test.runnerStatus.expectedRuns}321</div>322);323}324325const runs = test.runnerStatus.runs;326327let explicitScoreRendering = '';328let explicitScoreColor: string | undefined = undefined;329let explicitScoreTitle = 'Score set by test itself on some rubric';330331if (runs.length > 0 && runs[0].explicitScore !== undefined) {332333const testRunsScore = runs.reduce((acc, run) => (acc + (run.explicitScore ?? 0)), 0) / runs.length;334const scoreToString = (passes: number) => `${String(passes.toFixed(2)).padStart(2, ' ')}`;335336const testRunsScoreAsString = scoreToString(testRunsScore);337338if (test.baselineJSON === undefined) {339explicitScoreRendering = testRunsScoreAsString;340} else {341342const baselineJsonScoreAsString = scoreToString(test.baselineJSON.score);343344explicitScoreColor =345testRunsScoreAsString === baselineJsonScoreAsString346? 'gray'347: (parseFloat(testRunsScoreAsString) > parseFloat(baselineJsonScoreAsString) ? 'green' : 'red');348349if (testRunsScoreAsString === baselineJsonScoreAsString) {350explicitScoreRendering = `= ${scoreToString(testRunsScore)}`;351} else {352explicitScoreTitle += '(left - baseline | right - current)';353explicitScoreRendering = `${baselineJsonScoreAsString} -> ${testRunsScoreAsString}`;354}355}356}357358const passCount = runs.filter(r => r.pass).length;359const hasBaseline = test.baselineJSON || test.baseline;360361if (!hasBaseline) {362const title = `${passCount} runs passing`;363const scoreToString = (passes: number) => `${String(passes.toFixed(0)).padStart(3, ' ')}`;364return (365<div className='test-score' title={title}>366{scoreToString(passCount)} ({explicitScoreRendering})367</div>368);369}370371const passPercentage = passCount / runs.length;372373// When it runs to this line, either test.baselineJSON or test.baseline is defined. We'll use test.baselineJSON as the baseline first.374// If they both are not defined, we've already returned without comparison at line 328.375376const baselinePassCount = test.baselineJSON377? test.baselineJSON.passCount378: (test.baseline ? test.baseline.runs.filter(r => r.pass).length : 0);379380const baselineTotal = test.baselineJSON381? test.baselineJSON.passCount + test.baselineJSON.failCount382: (test.baseline ? test.baseline.runs.length : 0);383384const baselinePercentage = baselinePassCount / baselineTotal;385386const color =387passPercentage === baselinePercentage388? 'gray'389: (passPercentage > baselinePercentage ? 'green' : 'red');390391const scoreToString = (passes: number) => `${String(passes).padStart(2, ' ')}`;392393const title = [394`Runs: ${runs.length}`,395`Passing: ${passCount}`,396`Expected: ${runs.length * baselinePercentage} (Baseline: ${baselinePassCount} / ${baselineTotal})`,397`'=' sign means current score equals baseline score.`,398].join('\n');399400const renderedScore =401passPercentage === baselinePercentage402? `= ${scoreToString(passCount)}`403: `${scoreToString(runs.length * baselinePercentage)} -> ${scoreToString(passCount)}`;404405return (406<>407<span className='test-score' style={{ color }} title={title}>408{`${renderedScore} | `}409</span>410{explicitScoreRendering === ''411? null412: <span className='test-score' style={{ color: explicitScoreColor }} title={explicitScoreTitle}>413{`${explicitScoreRendering} | `}414</span>}415</>416);417});418419const StatusIcon = mobxlite.observer(({ runner, runnerOptions, nesExternalOptions, testSource, test }: { runner: SimulationRunner; runnerOptions: RunnerOptions; nesExternalOptions: NesExternalOptions; testSource: TestSourceValue; test: ISimulationTest }) => {420const runTest = (e: React.MouseEvent) => {421e.stopPropagation();422if (runner.state.kind !== StateKind.Running) {423runner.startRunning({424grep: test.name,425cacheMode: runnerOptions.cacheMode.value,426n: parseInt(runnerOptions.n.value),427noFetch: runnerOptions.noFetch.value,428additionalArgs: runnerOptions.additionalArgs.value,429nesExternalScenariosPath: testSource.value === TestSource.NesExternal ? nesExternalOptions.externalScenariosPath.value || undefined : undefined,430});431}432};433434const runnerStatus = test.runnerStatus;435436if (runnerStatus) {437if (runnerStatus.isSkipped) {438return <span title='Test is skipped'>⏭️</span>;439} else if (runnerStatus.isCancelled && runner.terminationReason) {440return <span title='Simulation terminated early due to an error, click to run again.' onClick={runTest}>❌</span>;441} else if (runnerStatus.isCancelled) {442return <span title='Test is cancelled, click to run again.' onClick={runTest}>⭕️</span>;443} else if (runnerStatus.isNowRunning > 0) {444return <span title='Test is currently running'>🏃</span>;445} else if (runnerStatus.runs.length < runnerStatus.expectedRuns) {446return <span title='Test is queued to be run'>⏳</span>;447} else {448const failCount = runnerStatus.runs.filter(r => !r.pass).length;449if (failCount === runnerStatus.runs.length) {450return <span title='All runs failed, click to run again.' onClick={runTest}>❌</span>;451} else if (failCount > 0) {452return <span title={`${failCount} of ${runnerStatus.runs.length} runs failed, click to run again.`} onClick={runTest}>⚠️</span>;453}454return <span title='Test is complete, click to run again.' onClick={runTest}>🏁</span>;455}456}457return <span title='Test has not been run, click to run.' onClick={runTest}>🔘</span>;458});459460const InlineTestError = mobxlite.observer(({ runnerStatus }: { runnerStatus: RunnerTestStatus | undefined }) => {461if (!runnerStatus) {462return null;463}464const failedRuns = runnerStatus.runs.filter(r => !r.pass && r.error);465if (failedRuns.length === 0) {466return null;467}468const firstError = failedRuns[0].error!;469const firstLine = firstError.split(/\r?\n/)[0];470const label = failedRuns.length > 1471? `${firstLine} (+${failedRuns.length - 1} more)`472: firstLine;473return (474<Tooltip content={firstError} relationship='description'>475<Text style={{ color: 'var(--colorPaletteRedForeground1)', marginLeft: '8px', fontSize: '12px' }}>{label}</Text>476</Tooltip>477);478});479480481482