diff --git a/packages/playground/src/common.ts b/packages/playground/src/common.ts index d5e8d0510f..a2e451f04f 100644 --- a/packages/playground/src/common.ts +++ b/packages/playground/src/common.ts @@ -8,7 +8,7 @@ import type { ValidationResult, } from './types'; -// APIs that should not generate replay scripts +// APIs that return extracted data from the current interface. export const dataExtractionAPIs = [ 'aiQuery', 'aiBoolean', @@ -19,6 +19,7 @@ export const dataExtractionAPIs = [ export const validationAPIs = ['aiAssert', 'aiWaitFor']; +// APIs whose executions should not be rendered as replays by StandardPlayground. export const noReplayAPIs = [...dataExtractionAPIs, ...validationAPIs]; const agentPromptAPIs = [ diff --git a/packages/playground/tests/unit/common.test.ts b/packages/playground/tests/unit/common.test.ts index 69832812d2..348557142b 100644 --- a/packages/playground/tests/unit/common.test.ts +++ b/packages/playground/tests/unit/common.test.ts @@ -34,7 +34,7 @@ describe('common utilities', () => { expect(validationAPIs).toEqual(['aiAssert', 'aiWaitFor']); }); - it('should combine data extraction and validation APIs in noReplayAPIs', () => { + it('should identify APIs that do not generate replay scripts', () => { expect(noReplayAPIs).toEqual([...dataExtractionAPIs, ...validationAPIs]); }); }); diff --git a/packages/visualizer/src/component/playground-result/index.tsx b/packages/visualizer/src/component/playground-result/index.tsx index 15a6251544..0530c7e7f2 100644 --- a/packages/visualizer/src/component/playground-result/index.tsx +++ b/packages/visualizer/src/component/playground-result/index.tsx @@ -1,5 +1,4 @@ import { LoadingOutlined } from '@ant-design/icons'; -import { noReplayAPIs } from '@midscene/playground'; import { Spin } from 'antd'; import type React from 'react'; import type { @@ -25,7 +24,8 @@ interface PlaygroundResultProps { notReadyMessage?: React.ReactNode | string; fitMode?: 'width' | 'height'; autoZoom?: boolean; - actionType?: string; // The action type that was executed + // When a report is available, also show the return value above it. + showOutputAlongsideReport?: boolean; canDownloadReport?: boolean; onDownloadReport?: ReportDownloadHandler; playerPresentation?: PlayerPresentation; @@ -44,7 +44,7 @@ export const PlaygroundResultView: React.FC = ({ notReadyMessage, fitMode, autoZoom, - actionType, + showOutputAlongsideReport = false, canDownloadReport, onDownloadReport, playerPresentation, @@ -60,10 +60,6 @@ export const PlaygroundResultView: React.FC = ({ let resultDataToShow: React.ReactNode = emptyResultTip; - // Determine if this is a data extraction API that should prioritize result output - const shouldPrioritizeResult = - actionType && noReplayAPIs.includes(actionType); - if (!serverValid && serviceMode === 'Server') { resultDataToShow = serverLaunchTip(notReadyMessage); } else if (loading) { @@ -122,11 +118,11 @@ export const PlaygroundResultView: React.FC = ({ resultDataToShow = errorNode; } } else if ( - shouldPrioritizeResult && + showOutputAlongsideReport && result?.result !== undefined && replayScriptsInfo ) { - // For data extraction APIs: show both result output and replay/report + // Show both the API return value and the replay/report. const resultOutput = typeof result?.result === 'string' ? (
{result?.result}
@@ -188,11 +184,11 @@ export const PlaygroundResultView: React.FC = ({ /> ); } else if ( - shouldPrioritizeResult && + showOutputAlongsideReport && result?.result !== undefined && (result?.reportHTML || result?.report) ) { - // For data extraction APIs: show both result output and reportHTML + // Show both the API return value and the report. const resultOutput = typeof result?.result === 'string' ? (
{result?.result}
@@ -227,8 +223,8 @@ export const PlaygroundResultView: React.FC = ({ ); - } else if (shouldPrioritizeResult && result?.result !== undefined) { - // For data extraction APIs without reportHTML: show result output only + } else if (showOutputAlongsideReport && result?.result !== undefined) { + // Without a report, show the API return value on its own. resultDataToShow = typeof result?.result === 'string' ? (
{result?.result}
diff --git a/packages/visualizer/src/component/universal-playground/index.tsx b/packages/visualizer/src/component/universal-playground/index.tsx index 3eca272f97..37f39dbea6 100644 --- a/packages/visualizer/src/component/universal-playground/index.tsx +++ b/packages/visualizer/src/component/universal-playground/index.tsx @@ -40,6 +40,7 @@ import { createStorageProvider, detectBestStorageType, } from './providers/storage-provider'; +import { shouldShowOutputAlongsideReport } from './result-display'; const handledExternalRunRequestIds = new Set(); const MAX_HANDLED_EXTERNAL_RUN_REQUEST_IDS = 100; @@ -780,7 +781,9 @@ export function UniversalPlayground({ } verticalMode={item.verticalMode || false} fitMode="width" - actionType={item.actionType} + showOutputAlongsideReport={shouldShowOutputAlongsideReport( + item.actionType, + )} onDownloadReport={ componentConfig.onDownloadReport } diff --git a/packages/visualizer/src/component/universal-playground/result-display.ts b/packages/visualizer/src/component/universal-playground/result-display.ts new file mode 100644 index 0000000000..a97f4211f8 --- /dev/null +++ b/packages/visualizer/src/component/universal-playground/result-display.ts @@ -0,0 +1,7 @@ +import { dataExtractionAPIs, validationAPIs } from '@midscene/playground'; + +const outputAndReportAPIs = [...dataExtractionAPIs, ...validationAPIs, 'aiAct']; + +export function shouldShowOutputAlongsideReport(actionType?: string): boolean { + return actionType !== undefined && outputAndReportAPIs.includes(actionType); +} diff --git a/packages/visualizer/src/hooks/usePlaygroundExecution.ts b/packages/visualizer/src/hooks/usePlaygroundExecution.ts index 7c283ce717..d1b10b70d8 100644 --- a/packages/visualizer/src/hooks/usePlaygroundExecution.ts +++ b/packages/visualizer/src/hooks/usePlaygroundExecution.ts @@ -413,8 +413,8 @@ export function usePlaygroundExecution(options: UsePlaygroundExecutionOptions) { let replayInfo = null; let counter = replayCounter; - // Generate replay info for all APIs (including noReplayAPIs) - // This allows noReplayAPIs to display both output and report + // Generate replay info for all APIs so eligible APIs can display their + // return value alongside the report. const info = replayInfoFromExecutionResult(result, deviceType); if (info) { setReplayCounter((c) => c + 1); diff --git a/packages/visualizer/tests/playground-result.test.ts b/packages/visualizer/tests/playground-result.test.ts new file mode 100644 index 0000000000..9671b5361b --- /dev/null +++ b/packages/visualizer/tests/playground-result.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, rs } from '@rstest/core'; +import React, { type ComponentProps, createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +rs.stubGlobal('React', React); + +rs.mock('../src/component/player', () => ({ + Player: () => 'REPORT_PLAYER', +})); + +rs.mock('../src/component/misc', () => ({ + emptyResultTip: 'EMPTY_RESULT', + serverLaunchTip: () => 'SERVER_NOT_READY', +})); + +import { PlaygroundResultView } from '../src/component/playground-result'; + +type ResultProps = ComponentProps; + +const output = 'This list page contains 16 articles'; +const reportCases: Array<{ + name: string; + props: Pick; +}> = [ + { + name: 'replay', + props: { + result: { result: output, error: null }, + replayScriptsInfo: { scripts: [], modelBriefs: [] }, + }, + }, + { + name: 'inline report', + props: { + result: { result: output, error: null, reportHTML: '' }, + replayScriptsInfo: null, + }, + }, + { + name: 'report reference', + props: { + result: { + result: output, + error: null, + report: { id: 'report-1', url: '/report.html', bytes: 100 }, + }, + replayScriptsInfo: null, + }, + }, +]; + +function renderResult(overrides: Partial) { + return renderToStaticMarkup( + createElement(PlaygroundResultView, { + result: { result: output, error: null }, + loading: false, + serverValid: true, + serviceMode: 'In-Browser', + replayScriptsInfo: null, + replayCounter: 0, + loadingProgressText: '', + ...overrides, + }), + ); +} + +describe('PlaygroundResultView', () => { + it.each(reportCases)( + 'shows output before $name when enabled', + ({ props }) => { + const html = renderResult({ ...props, showOutputAlongsideReport: true }); + + expect(html).toContain('Output:'); + expect(html).toContain(output); + expect(html).toContain('Report:'); + expect(html).toContain('REPORT_PLAYER'); + expect(html.indexOf(output)).toBeLessThan(html.indexOf('Report:')); + expect(html.indexOf('Report:')).toBeLessThan( + html.indexOf('REPORT_PLAYER'), + ); + }, + ); + + it.each(reportCases)('shows only $name by default', ({ props }) => { + const html = renderResult(props); + + expect(html).not.toContain('Output:'); + expect(html).not.toContain(output); + expect(html).toContain('REPORT_PLAYER'); + }); + + it.each([undefined, false, true])( + 'shows output without a report when the display option is %s', + (showOutputAlongsideReport) => { + const html = renderResult({ showOutputAlongsideReport }); + + expect(html).toContain(output); + expect(html).not.toContain('REPORT_PLAYER'); + }, + ); + + it('keeps errors ahead of reports even when output is enabled', () => { + const html = renderResult({ + ...reportCases[0].props, + result: { result: output, error: 'Execution failed' }, + showOutputAlongsideReport: true, + }); + + expect(html).toContain('Execution failed'); + expect(html).not.toContain(output); + expect(html).toContain('REPORT_PLAYER'); + expect(html.indexOf('Execution failed')).toBeLessThan( + html.indexOf('REPORT_PLAYER'), + ); + }); +}); diff --git a/packages/visualizer/tests/universal-playground-result.test.ts b/packages/visualizer/tests/universal-playground-result.test.ts new file mode 100644 index 0000000000..784925a1eb --- /dev/null +++ b/packages/visualizer/tests/universal-playground-result.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, rs } from '@rstest/core'; +import React, { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { InfoListItem } from '../src/types'; + +rs.stubGlobal('React', React); + +const { getInfoList } = rs.hoisted(() => ({ + getInfoList: rs.fn<() => InfoListItem[]>(), +})); + +// Keep the real Universal -> display policy -> result view path. Only stub +// state/execution hooks and unrelated UI; API classifications are not mocked. +rs.mock('../src/hooks/usePlaygroundState', () => ({ + usePlaygroundState: () => ({ + infoList: getInfoList(), + loading: false, + actionSpace: [], + actionSpaceLoading: false, + infoListRef: { current: null }, + }), +})); + +rs.mock('../src/hooks/usePlaygroundExecution', () => ({ + usePlaygroundExecution: () => ({ canStop: false }), +})); + +rs.mock('../src/store/store', () => ({ + useEnvConfig: () => ({ config: {} }), +})); + +rs.mock('../src/utils', () => ({ notifyError: rs.fn() })); +rs.mock('../src/icons/avatar.svg', () => ({ default: () => null })); +rs.mock('../src/component/prompt-input', () => ({ PromptInput: () => null })); +rs.mock('../src/component/context-preview', () => ({ + ContextPreview: () => null, +})); +rs.mock('../src/component/env-config-reminder', () => ({ + EnvConfigReminder: () => null, +})); +rs.mock('../src/component/player', () => ({ Player: () => 'REPORT_PLAYER' })); +rs.mock('../src/component/misc', () => ({ + emptyResultTip: 'EMPTY_RESULT', + serverLaunchTip: () => 'SERVER_NOT_READY', +})); + +import { UniversalPlayground } from '../src/component/universal-playground'; + +describe('UniversalPlayground result display policy', () => { + it.each([ + { actionType: 'aiAct', showOutput: true }, + { actionType: 'aiQuery', showOutput: true }, + { actionType: 'aiBoolean', showOutput: true }, + { actionType: 'aiNumber', showOutput: true }, + { actionType: 'aiString', showOutput: true }, + { actionType: 'aiAsk', showOutput: true }, + { actionType: 'aiAssert', showOutput: true }, + { actionType: 'aiWaitFor', showOutput: true }, + { actionType: 'aiTap', showOutput: false }, + { actionType: 'aiHover', showOutput: false }, + { actionType: undefined, showOutput: false }, + ])( + 'selects output visibility for $actionType', + ({ actionType, showOutput }) => { + getInfoList.mockReturnValue([ + { + id: 'result-1', + type: 'result', + content: '', + timestamp: new Date('2026-09-03T00:00:00Z'), + actionType, + result: { + result: 'API_RETURN_VALUE', + reportHTML: '', + error: null, + }, + }, + ]); + + const html = renderToStaticMarkup( + createElement(UniversalPlayground, { + playgroundSDK: null, + showContextPreview: false, + config: { + persistMessages: false, + hidePromptInput: true, + showSystemMessageHeader: false, + showClearButton: false, + }, + }), + ); + + expect(html).toContain('REPORT_PLAYER'); + expect(html.includes('API_RETURN_VALUE')).toBe(showOutput); + expect(html.includes('Output:')).toBe(showOutput); + if (showOutput) { + expect(html.indexOf('API_RETURN_VALUE')).toBeLessThan( + html.indexOf('REPORT_PLAYER'), + ); + } + }, + ); +});