Files
udmin/frontend/src/flows/components/testrun/testrun-panel/index.tsx
ayou cb0d829884 fix(flow): 修复Rhai脚本执行错误处理并优化变量解析逻辑
refactor(engine): 重构Rhai表达式错误处理为枚举类型
fix(script_rhai): 修正脚本文件读取和执行失败的错误返回
perf(testrun): 优化前端测试面板日志去重和显示逻辑
2025-09-22 20:25:05 +08:00

373 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<TestRunSidePanelProps> = ({ visible, onCancel }) => {
const customService = useService(CustomService);
const { nodeId: sidebarNodeId, setNodeId } = useContext(SidebarContext);
const [isRunning, setRunning] = useState(false);
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<string[]>();
const [result, setResult] = useState<
| {
ok?: boolean;
ctx: any;
logs: string[];
}
| undefined
>();
// 模式切换SSE 流式 or 普通 HTTP
const [streamMode, _setStreamMode] = useState<boolean>(() => {
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<boolean>(() => {
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<any | undefined>();
const [streamLogs, setStreamLogs] = useState<string[]>([]);
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<string>();
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 = (
<div className={styles['testrun-panel-running']}>
<IconSpin spin size="large" />
<div className={styles.text}>{I18n.t('Running...')}</div>
{/* 实时输出(仅流式模式显示) */}
{streamMode && (
<>
{errors?.length ? (
<div className={styles.error}>
{errors.map((e) => (
<div key={e}>{e}</div>
))}
</div>
) : null}
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
</>
)}
</div>
);
const renderStatus = (
<div style={{ marginBottom: 8 }}>
{result?.ok === true && <Tag color="green">{I18n.t('Success')}</Tag>}
{(errors?.length || result?.ok === false) && (
<Tag color="red">{I18n.t('Failed')}</Tag>
)}
</div>
);
const renderForm = (
<div className={styles['testrun-panel-form']}>
<div className={styles['testrun-panel-input']}>
<div className={styles.title}>{I18n.t('Input Form')}</div>
<div className={styles.toggle}>
<div>{I18n.t('JSON Mode')}</div>
<Switch
checked={inputJSONMode}
onChange={(checked: boolean) => setInputJSONMode(checked)}
size="small"
/>
</div>
<div className={styles.toggle}>
<div>{I18n.t('Streaming Mode')}</div>
<Switch
checked={streamMode}
onChange={(checked: boolean) => setStreamMode(checked)}
size="small"
/>
</div>
{streamMode && (
<div className={styles.toggle}>
<div>WS</div>
<Switch
checked={useWS}
onChange={(checked: boolean) => setUseWS(checked)}
size="small"
/>
</div>
)}
</div>
{renderStatus}
{errors?.map((e) => (
<div className={styles.error} key={e}>
{e}
</div>
))}
{inputJSONMode ? (
<TestRunJsonInput values={values} setValues={setValues} />
) : (
<TestRunForm values={values} setValues={setValues} />
)}
{/* 运行中(流式)时,直接在表单区域下方展示实时输出,而不是覆盖整块内容 */}
{streamMode && isRunning && (
<>
<NodeStatusGroup title={I18n.t('Logs') + ' (Live)'} data={streamLogs} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Context') + ' (Live)'} data={streamCtx} optional disableCollapse />
</>
)}
{/* 展示后端返回的执行信息:仅在非流式或流式已结束时显示,避免与实时输出重复 */}
{(!streamMode || !isRunning) && (
<>
<NodeStatusGroup title={I18n.t('Logs')} data={result?.logs} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Context')} data={result?.ctx} optional disableCollapse />
</>
)}
</div>
);
const renderButton = (
<Button
onClick={isRunning ? (streamMode ? onCancelRun : undefined) : onTestRun}
disabled={isRunning && !streamMode}
icon={
// 仅用按钮转圈提示运行中
isRunning ? <IconSpin spin size="small" /> : <IconPlay size="small" />
}
className={classnames(styles.button, {
[styles.running]: isRunning,
[styles.default]: !isRunning,
})}
>
{isRunning ? I18n.t('Running...') : I18n.t('Test Run')}
</Button>
);
return (
<SideSheet
title={I18n.t('Test Run')}
visible={visible}
mask={false}
motion={false}
onCancel={onClose}
width={500}
headerStyle={{
display: 'none',
}}
bodyStyle={{
padding: 0,
}}
style={{
background: 'none',
boxShadow: 'none',
}}
>
<div className={styles['testrun-panel-container']}>
<div className={styles['testrun-panel-header']}>
<div className={styles['testrun-panel-title']}>{I18n.t('Test Run')}</div>
<Button
className={styles['testrun-panel-title']}
type="tertiary"
icon={<IconClose />}
size="small"
theme="borderless"
onClick={onClose}
/>
</div>
<div className={styles['testrun-panel-content']}>
{/* 始终展示表单与结果区域;运行中不再使用覆盖层 */}
{renderForm}
</div>
<div className={styles['testrun-panel-footer']}>{renderButton}</div>
</div>
</SideSheet>
);
};