/** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FC, useContext, useEffect, useRef, useState } from 'react'; import classnames from 'classnames'; import { useService, I18n } from '@flowgram.ai/free-layout-editor'; import { Button, SideSheet, Switch, Tag } from '@douyinfe/semi-ui'; import { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons'; import { TestRunJsonInput } from '../testrun-json-input'; import { TestRunForm } from '../testrun-form'; import { NodeStatusGroup } from '../node-status-bar/group'; // 改为使用后端运行服务 import { CustomService } from '../../../services'; import { SidebarContext } from '../../../context'; import { IconCancel } from '../../../assets/icon-cancel'; import styles from './index.module.less'; interface TestRunSidePanelProps { visible: boolean; onCancel: () => void; } export const TestRunSidePanel: FC = ({ visible, onCancel }) => { const customService = useService(CustomService); const { nodeId: sidebarNodeId, setNodeId } = useContext(SidebarContext); const [isRunning, setRunning] = useState(false); const [values, setValues] = useState>({}); const [errors, setErrors] = useState(); const [result, setResult] = useState< | { ok?: boolean; ctx: any; logs: string[]; } | undefined >(); // 模式切换:SSE 流式 or 普通 HTTP const [streamMode, _setStreamMode] = useState(() => { const saved = localStorage.getItem('testrun-stream-mode'); return saved ? JSON.parse(saved) : true; }); const setStreamMode = (checked: boolean) => { _setStreamMode(checked); localStorage.setItem('testrun-stream-mode', JSON.stringify(checked)); }; // 当启用流式时,选择 WS 或 SSE(默认 SSE) const [useWS, _setUseWS] = useState(() => { const saved = localStorage.getItem('testrun-ws-mode'); return saved ? JSON.parse(saved) : false; }); const setUseWS = (checked: boolean) => { _setUseWS(checked); localStorage.setItem('testrun-ws-mode', JSON.stringify(checked)); }; // 流式渲染:实时上下文与日志 const [streamCtx, setStreamCtx] = useState(); const [streamLogs, setStreamLogs] = useState([]); const cancelRef = useRef<(() => void) | null>(null); // en - Use localStorage to persist the JSON mode state const [inputJSONMode, _setInputJSONMode] = useState(() => { const savedMode = localStorage.getItem('testrun-input-json-mode'); return savedMode ? JSON.parse(savedMode) : false; }); const setInputJSONMode = (checked: boolean) => { _setInputJSONMode(checked); localStorage.setItem('testrun-input-json-mode', JSON.stringify(checked)); }; const extractErrorMsg = (logs: string[] | undefined): string | undefined => { if (!logs || logs.length === 0) return undefined; const patterns = [/failed/i, /error/i, /panic/i]; for (const line of logs) { if (patterns.some((p) => p.test(line))) return line; } return logs[logs.length - 1]; }; const onTestRun = async () => { if (isRunning) { // 运行中,忽略重复点击 return; } setResult(undefined); setErrors(undefined); setStreamCtx(undefined); setStreamLogs([]); setRunning(true); try { // 运行前保存(静默),确保后端 YAML 与编辑器一致;若保存失败则不继续运行 const saved = await customService.save({ silent: true }); if (!saved) { setErrors([I18n.t('Save failed, cannot run')]); return; } if (streamMode) { const startStream = () => useWS ? customService.runStreamWS(values, { onNode: (evt) => { if (evt.ctx) setStreamCtx((prev: any) => ({ ...(prev || {}), ...(evt.ctx || {}) })); if (evt.logs && evt.logs.length) { const normalizeLog = (s: string) => s.replace(/\r/g, '').trim(); const dedupLogs = (arr: string[]) => { const seen = new Set(); const res: string[] = []; for (const s of arr) { const key = normalizeLog(s); if (!seen.has(key)) { seen.add(key); res.push(s); } } return res; }; const incoming = evt.logs!; setStreamLogs((prev) => dedupLogs([...(prev || []), ...incoming])); } }, onError: (evt) => { const msg = evt.message || I18n.t('Run failed'); setErrors((prev) => [...(prev || []), msg]); }, onDone: (evt) => { setResult({ ok: evt.ok, ctx: evt.ctx, logs: evt.logs }); }, onFatal: (err) => { setErrors((prev) => [...(prev || []), err.message || String(err)]); setRunning(false); }, }) : customService.runStream(values, { onNode: (evt) => { if (evt.ctx) setStreamCtx((prev: any) => ({ ...(prev || {}), ...(evt.ctx || {}) })); if (evt.logs && evt.logs.length) setStreamLogs((prev: string[]) => [...prev, ...evt.logs!]); }, onError: (evt) => { const msg = evt.message || I18n.t('Run failed'); setErrors((prev) => [...(prev || []), msg]); }, onDone: (evt) => { setResult({ ok: evt.ok, ctx: evt.ctx, logs: evt.logs }); }, onFatal: (err) => { setErrors((prev) => [...(prev || []), err.message || String(err)]); setRunning(false); }, }); const { cancel, done } = startStream(); cancelRef.current = cancel; const finished = await done; if (finished) { setResult(finished as any); } else { // 流结束但未收到 done 事件,给出提示 setErrors((prev) => [...(prev || []), I18n.t('Stream terminated without completion')]); } } else { // 普通 HTTP 一次性运行 try { const runRes = await customService.run(values); if (runRes) { if ((runRes as any).ok === false) { setResult(runRes as any); const err = extractErrorMsg((runRes as any).logs) || I18n.t('Run failed'); setErrors([err]); } else { setResult(runRes as any); } } else { setErrors([I18n.t('Run failed')]); } } catch (e: any) { setErrors([e?.message || I18n.t('Run failed')]); } } } catch (e: any) { setErrors([e?.message || I18n.t('Run failed')]); } finally { setRunning(false); cancelRef.current = null; } }; const onCancelRun = () => { try { cancelRef.current?.(); } catch {} setRunning(false); }; const onClose = async () => { setValues({}); if (isRunning) { if (streamMode) onCancelRun(); } setRunning(false); setStreamCtx(undefined); setStreamLogs([]); onCancel(); }; // 当测试运行面板打开时,自动关闭右侧节点编辑侧栏,避免两个 SideSheet 重叠 useEffect(() => { if (visible) { setNodeId(undefined); } }, [visible]); useEffect(() => { if (sidebarNodeId) { onCancel(); } }, [sidebarNodeId]); const renderRunning = (
{I18n.t('Running...')}
{/* 实时输出(仅流式模式显示) */} {streamMode && ( <> {errors?.length ? (
{errors.map((e) => (
{e}
))}
) : null} )}
); const renderStatus = (
{result?.ok === true && {I18n.t('Success')}} {(errors?.length || result?.ok === false) && ( {I18n.t('Failed')} )}
); const renderForm = (
{I18n.t('Input Form')}
{I18n.t('JSON Mode')}
setInputJSONMode(checked)} size="small" />
{I18n.t('Streaming Mode')}
setStreamMode(checked)} size="small" />
{streamMode && (
WS
setUseWS(checked)} size="small" />
)}
{renderStatus} {errors?.map((e) => (
{e}
))} {inputJSONMode ? ( ) : ( )} {/* 运行中(流式)时,直接在表单区域下方展示实时输出,而不是覆盖整块内容 */} {streamMode && isRunning && ( <> )} {/* 展示后端返回的执行信息:仅在非流式或流式已结束时显示,避免与实时输出重复 */} {(!streamMode || !isRunning) && ( <> )}
); const renderButton = ( ); return (
{I18n.t('Test Run')}
{/* 始终展示表单与结果区域;运行中不再使用覆盖层 */} {renderForm}
{renderButton}
); };