diff --git a/docs/design/README.md b/docs/design/README.md index 7dd8d249..a2f4913d 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -8,5 +8,6 @@ - 报告详情采用执行轨迹与证据工作区,可按执行、节点、请求尝试次数定位历史画布,并从画布返回完整报告。流程设置、来源定位与检查器配置/输入输出/校验均连接已有定义和处理器。 - 1440×900、1280×800、1600×1000 是桌面核对视口;390px 报告采用单列证据和表格内部滚动。 - 当前实现、验证范围及必要视觉差异见 [实施记录](ui-v5/implementation.md)、[视觉差异](ui-v5/visual-deviations.md) 和 [80 项验收矩阵](ui-v5/acceptance.csv)。 +- 最新审计的运行隔离、页外保存/发布、证据定位和核心场景验证见 [审计修复记录](ui-v5/audit-followup.md)。 历史 V3 设计源保留在 [`FlowTest_V3_UI_CN_HD`](../../FlowTest_V3_UI_CN_HD/README.md)。其中紫色主色、深色导航和旧面板尺寸已由 v5 替代。 diff --git a/docs/design/ui-v5/acceptance.csv b/docs/design/ui-v5/acceptance.csv index f8b7247b..6a9d6a9b 100644 --- a/docs/design/ui-v5/acceptance.csv +++ b/docs/design/ui-v5/acceptance.csv @@ -1,6 +1,6 @@ id,category,test,steps,expected,status,evidence,notes V5-001,视觉与主题,主工作区,1440/1280打开编辑器,系统分别设浅色和深色,始终冰蓝白,无黑色画布/面板,已验证,05-workflow-dark-os-1600.png; 04-workflow-1280.png,本地前端验证;不代表远程 CI 或完整发布验收。 -V5-002,视觉与主题,主图一致性,对照主图01检查节点/工具条/检查器,保留质感与比例,不退成灰线框,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-002,视觉与主题,主图一致性,对照主图01检查节点/工具条/检查器,保留质感与比例,不退成灰线框,部分定向验证通过;待综合验收,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log;audit-followup.md,新增1280/1440实际核心场景及6张macOS像素基线;Linux原27基线保留。见audit-followup.md;新Linux核心像素及整站主图比对待独立验收。 V5-003,视觉与主题,全局导航,打开常用模块和全部模块,浅色导航、文字可见、原入口和权限仍在,已验证,20-module-directory.png,本地前端验证;不代表远程 CI 或完整发布验收。 V5-004,视觉与主题,弹出层,逐一打开Tooltip/Popover/Modal/Toast,白/浅蓝底,无默认黑底,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-005,视觉与主题,代码区,请求/响应/历史JSON/diff,浅色语法主题、选中文本可读,已验证,06-api-workbench-1440.png; 07-workflow-history-evidence-1440.png,本地前端验证;不代表远程 CI 或完整发布验收。 @@ -15,22 +15,22 @@ V5-013,流程编辑与草稿,真实端口,对照schema检查handle,无按装饰 V5-014,流程编辑与草稿,拖放添加,鼠标与键盘按已有方式添加,真实图新增正确类型,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-015,流程编辑与草稿,复制粘贴,跨选择集复制节点和引用,ID与引用规则按原逻辑,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-016,流程编辑与草稿,撤销重做,添加/移动/删节点/改连线,状态和历史栈一致,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 -V5-017,流程编辑与草稿,连线操作,插入节点、重连、非法连接,原校验与行为保持,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-017,流程编辑与草稿,连线操作,插入节点、重连、非法连接,原校验与行为保持,部分定向验证通过;待综合验收,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log;audit-followup.md,新增画布稳定后实际命中检查;测量完成后释放初始高度估计。连线真实创建/重连/映射/删除/撤销按无重试重复验证。见audit-followup.md。 V5-018,流程编辑与草稿,自动布局,手动触发并切换主题,只在显式触发时改坐标,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-019,流程编辑与草稿,节点apply,修改未应用,再apply,图只在apply后dirty,已验证,frontend/src/flow/WorkflowNodeEditSession.test.tsx,本地前端验证;不代表远程 CI 或完整发布验收。 V5-020,流程编辑与草稿,未应用切节点,修改后选其他节点,应用/丢弃/取消可用,不丢输入,已验证,22-node-switch-confirmation.png; frontend/src/flow/WorkflowDesigner.test.tsx; qa-association-settings-result.log,本轮应用/取消浏览器验证;丢弃有组件回归;保留节点输入与图草稿边界。 -V5-021,流程编辑与草稿,保存成功,apply后发起保存,只确认提交版本,显示真实状态,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-021,流程编辑与草稿,保存成功,apply后发起保存,只确认提交版本,显示真实状态,部分定向验证通过;待综合验收,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log;audit-followup.md,页外流程保存同步精确详情及列表;连续保存使用最新修订号。见workflow-save-concurrency.test.tsx及audit-followup.md。 V5-022,流程编辑与草稿,保存失败,模拟超时/错误,保留dirty与数据,可重试,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 -V5-023,流程编辑与草稿,保存竞态,保存r12期间编辑到r13,r12成功不能清掉r13 dirty,已验证,frontend/src/features/workflows/workflow-save-concurrency.test.tsx,前端并发保存回归通过;未执行后端/真实网络保存。 +V5-023,流程编辑与草稿,保存竞态,保存r12期间编辑到r13,r12成功不能清掉r13 dirty,已验证,frontend/src/features/workflows/workflow-save-concurrency.test.tsx;audit-followup.md,保存期间编辑与缓存同步期间丢弃后的新稿均保留;不清除新的草稿代次。见workflow-save-concurrency.test.tsx及audit-followup.md。 V5-024,流程编辑与草稿,关tab/切项目,两层dirty均存在时离开,未应用和未保存均受保护,定向验证通过;待综合验收,frontend/src/pages/WorkflowsPage.test.tsx;frontend/src/features/workflows/workflow-save-concurrency.test.tsx;frontend/src/features/navigation/WorkspaceObjectTabs.test.tsx,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 -V5-025,流程编辑与草稿,session隔离,不同用户/项目与旧tab恢复,不串草稿,不因主题清空存储,定向验证通过;待综合验收,frontend/src/features/navigation/workspace-tab-store.test.ts;frontend/src/lib/use-route-scoped-state.test.tsx,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 +V5-025,流程编辑与草稿,session隔离,不同用户/项目与旧tab恢复,不串草稿,不因主题清空存储,定向验证通过;待综合验收,frontend/src/features/navigation/workspace-tab-store.test.ts;frontend/src/lib/use-route-scoped-state.test.tsx;audit-followup.md,新增用户/项目/流程/运行代次隔离;迟到开始/终态/事件/重连/重跑不污染当前对象。深链接恢复与手动切换清理分别验证。见audit-followup.md。 V5-026,流程编辑与草稿,快捷键上下文,输入框/代码/弹窗中按快捷键,不误删图、不拦截文本操作,已验证,frontend/src/flow/editor/use-canvas-hotkeys.test.tsx,本地前端验证;不代表远程 CI 或完整发布验收。 V5-027,控制流与数据关系,数据来源,选择可解析变量节点,来源名/路径/作用域正确,定向验证通过;待综合验收,frontend/src/flow/WorkflowNodeRelations.test.tsx;frontend/src/flow/editor/node-presentation.test.ts,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 V5-028,控制流与数据关系,无法解析引用,复杂表达式,显示未解析,不猜上游,定向验证通过;待综合验收,frontend/src/flow/editor/node-presentation.test.ts,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 V5-029,控制流与数据关系,依赖线,切换依赖可视化后保存,执行edge集合不变,已验证,qa-reference-preservation-final-result.log; frontend/src/flow/editor/node-presentation.test.ts,本地前端验证;不代表远程 CI 或完整发布验收。 -V5-030,控制流与数据关系,if/switch,真假/default/表达式错误,分支语义与原模型一致,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-030,控制流与数据关系,if/switch,真假/default/表达式错误,分支语义与原模型一致,部分定向验证通过;待综合验收,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log;audit-followup.md,实际If命中真分支并执行内联请求;switch/default/所有表达式错误仍需独立综合验收。见核心浏览器场景及audit-followup.md。 V5-031,控制流与数据关系,foreach/repeat,不同项/次数的嵌套执行,作用域正确,不变并行,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 -V5-032,控制流与数据关系,while/do-while/until,首轮条件与退出条件不同,先后判断保持准确,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-032,控制流与数据关系,while/do-while/until,首轮条件与退出条件不同,先后判断保持准确,部分定向验证通过;待综合验收,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log;audit-followup.md,实际Until计数到2退出且持久化两轮记录;循环体入口/出口/实例证据分别截图。while/do-while全部场景不据此判定通过。见audit-followup.md。 V5-033,控制流与数据关系,循环上限,非法输入与耗尽运行,单位/上限/失败策略不丢失,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-034,控制流与数据关系,parallel,一分支完成、一分支失败,原join策略决定整体状态,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-035,控制流与数据关系,try/return/fail,正常/异常/返回路径,不吞异常、不改终止范围,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 @@ -38,11 +38,11 @@ V5-036,控制流与数据关系,SQL/Redis,UI配置与请求尝试写入,现有 V5-037,运行与证据,运行对象,执行草稿或发布版本,显示真实执行对象与环境,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-038,运行与证据,历史冻结,修改当前草稿后看旧run,旧预期/版本不变,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;backend/tests/test_workflows_api.py;frontend/src/pages/ReportsPage.test.tsx;frontend/src/pages/WorkflowsPage.test.tsx;frontend/e2e/workflow-editor-visual.spec.ts,旧快照不读取当前草稿;未带focus的目录打开快照后保留记录所属流程及历史列表。公开回归与隔离API/Worker合成流程验证,不代表全部真实环境验收。 V5-039,运行与证据,节点证据,选择不同node/attempt,请求与结果属于正确节点,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;frontend/src/flow/WorkflowRunInspector.test.tsx;frontend/src/pages/ReportsPage.test.tsx,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 -V5-040,运行与证据,循环证据,切item/index/嵌套路径,不串循环项,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;frontend/src/flow/WorkflowRunInspector.test.tsx,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 +V5-040,运行与证据,循环证据,切item/index/嵌套路径,不串循环项,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;frontend/src/flow/WorkflowRunInspector.test.tsx;audit-followup.md,实际Until两轮记录及指定轮次的嵌套请求实例恢复通过;手动历史/草稿切换清除旧实例定位。见audit-followup.md。 V5-041,运行与证据,并行证据,切不同分支/子流程,不串branch/subflow,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;frontend/src/flow/WorkflowRunInspector.test.tsx,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 -V5-042,运行与证据,选择竞态,慢请求A后快速选择B,A回包不覆盖B,定向验证通过;待综合验收,frontend/src/features/api-console/use-api-executions.test.tsx;frontend/src/features/test-engineering/use-test-engineering.test.tsx;frontend/src/lib/use-route-scoped-state.test.tsx,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 -V5-043,运行与证据,HTTP与断言,HTTP200且断言失败,分别显示,运行不能假通过,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;frontend/src/pages/ApiConsolePage.test.tsx,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 -V5-044,运行与证据,缺字段,检查点/响应未提供,显示未提供而非0或通过,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;frontend/src/features/evidence/AssertionEvidence.test.tsx;frontend/src/pages/PerformanceLabPage.test.tsx,本轮修复见 completeness-remediation.md;测试仅证明覆盖的公开行为。真实角色/网络与整项发布验收未据此判定通过。 +V5-042,运行与证据,选择竞态,慢请求A后快速选择B,A回包不覆盖B,定向验证通过;待综合验收,frontend/src/features/api-console/use-api-executions.test.tsx;frontend/src/features/test-engineering/use-test-engineering.test.tsx;frontend/src/lib/use-route-scoped-state.test.tsx;audit-followup.md,新增流程A/B、同流程乱序运行及用户/项目/卸载边界;迟到轮询/事件/重连/重跑不覆盖新运行。见workflow-execution-concurrency.test.tsx及audit-followup.md。 +V5-043,运行与证据,HTTP与断言,HTTP200且断言失败,分别显示,运行不能假通过,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;frontend/src/pages/ApiConsolePage.test.tsx;audit-followup.md,实际API/Worker返回HTTP200且独立断言期望201失败;检查器展示期望/实际/差值-1/单位未提供/证据来源。见核心浏览器场景及audit-followup.md。 +V5-044,运行与证据,缺字段,检查点/响应未提供,显示未提供而非0或通过,定向验证通过;待综合验收,backend/tests/test_reporting_api.py;frontend/src/features/evidence/AssertionEvidence.test.tsx;frontend/src/pages/PerformanceLabPage.test.tsx;audit-followup.md,兼容明确assert节点的冻结输出;标准证据优先;无证据仍显示未提供且不根据状态猜测数值。见AssertionEvidence.test.tsx及audit-followup.md。 V5-045,运行与证据,重跑资格,不支持或权限不足的重跑,给原因,不发请求,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-046,运行与证据,只读回看,打开历史/证据tabs,不触发有副作用请求,已验证,qa-association-frozen-complete-result.log; qa-association-attempt-result.log,历史/设置只读、来源导航及双向证据跳转实测零后端资产写入或目标请求;浏览器使用隔离契约夹具。 V5-047,运行与证据,网络中断,运行中断网/恢复,状态待确认,不宣称完成,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 @@ -77,5 +77,5 @@ V5-075,质量与交付,减弱动效,系统减少动效,非必要动画取消, V5-076,质量与交付,图规模,同环境小/中/大图前后对照,不凭空宣称性能提升,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 V5-077,质量与交付,工作区保护,对比修改前后status/diff,不覆盖原有修改/编码,已验证,git-status-delivery.log; git-diff-delivery.patch,原工作区基线干净,改动仅在独立工作树;同步最新主干并保留其修改,无 reset/clean。浏览器服务仅使用本任务独立数据库。 V5-078,质量与交付,当前版本,核对lockfile与baseline差异,不为适配设计升级全部依赖,已验证,backend/uv.lock; frontend/package.json; frontend/pnpm-lock.yaml; backend/Dockerfile; frontend/Dockerfile; mock-target/Dockerfile; implementation.md,未为设计升级全部依赖;安全依赖与运行库及固定夹具修复独立提交于 PR #126,执行受控 Bootstrap 后同步主干。工具链与扫描规则保持,UI 普通门禁按最终新 Head 确认。 -V5-079,质量与交付,真实测试,检查测试日志与截图来源,未测不写通过,不用原型替代,本地完整检查通过;待综合验收,completeness-remediation.md;audit/completeness-final-full-check.log;audit/completeness-followup-frontend-coverage.log,本轮 make check 退出0:后端1723通过/11跳过,90.73%;后续关联及测试定位修复后前端126文件/768项通过,四项覆盖率均达80%,格式/Lint/类型/构建通过。远程门禁绑定本轮最新提交;真实环境验收未据此判定通过。 +V5-079,质量与交付,真实测试,检查测试日志与截图来源,未测不写通过,不用原型替代,本地完整检查通过;待综合验收,completeness-remediation.md;audit/completeness-final-full-check.log;audit/completeness-followup-frontend-coverage.log;audit-followup.md,新增审计修复的前端全量787项通过及实际API/Worker核心场景;源提交/源码哈希/平台分别记录。Linux新核心像素与全角色/大图综合验收未执行。见audit-followup.md。 V5-080,质量与交付,回滚,回退本次主题/组件挂接,不清数据库/草稿/session,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 diff --git a/docs/design/ui-v5/audit-followup.md b/docs/design/ui-v5/audit-followup.md new file mode 100644 index 00000000..386fadeb --- /dev/null +++ b/docs/design/ui-v5/audit-followup.md @@ -0,0 +1,27 @@ +# v5 最新审计修复 + +依据[最新审计](https://chatgpt.com/share/6ac1011f-d588-83ea-bfd7-6b30b3a9b6f5),以 `main@b06af115de9151add04060d09bfc963ec2ac0700` 为基线,在 `codex/ui-v5-audit-followup` 独立工作树实施。保留已确认的冰蓝白主题、权限、固定发布版本和草稿修订检查。 + +## 修复范围 + +| 缺口 | 现在的行为 | 回归证据 | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| 旧运行迟到覆盖当前流程 | 运行开始、轮询、事件、重连和失败项重跑均绑定用户、项目、流程与运行代次;切换或卸载失效旧回调,后台任务继续执行;终态证据不被迟到节点事件覆盖 | `workflow-execution-concurrency.test.tsx`、`use-execution-events.test.ts` | +| 页外流程保存后回退、发布版本不更新 | 保存完整对象同步精确详情和已加载列表,取消旧读取;发布后读取精确详情。连续保存使用新修订号,首次发布和再次发布按新固定版本运行;保存期间的新编辑及丢弃后的新稿保留 | `workflow-save-concurrency.test.tsx`、`WorkflowsPage.test.tsx` | +| 手动切换残留旧控制实例定位 | 切换草稿或历史清除节点、请求尝试、控制记录和实例参数;直接深链接继续恢复原定位 | `WorkflowsPage.test.tsx`、`WorkflowRunInspector.test.tsx` | +| 独立断言结果有值但检查器显示未提供 | 实际浏览器验证发现此缺口。优先显示标准断言证据;明确的 `assert` 节点兼容读取冻结输出中的期望、实际和结果,保留差值与来源,不根据 HTTP 成功推断断言通过 | `AssertionEvidence.test.tsx`、`WorkflowRunInspector.test.tsx`、核心浏览器场景 | +| 连线点击受画布动画影响 | 点击前等待节点测量及视口连续稳定,保留可命中区检查与实际检查器断言;没有增加重试或放宽像素阈值 | `workflow-editor-interactions.spec.ts`、`workflow-editor-visual.spec.ts` | + +## 核心场景与视觉证据 + +新增 `frontend/e2e/workflow-editor-core-scenarios.spec.ts`,在 **1280×800、1440×900** 两档分别验证并截图:完整编辑器、If 与 Until、循环体入口和出口、两轮真实控制记录、精确嵌套实例、HTTP 200 后独立断言失败。失败证据截图确保期望 201、实际 200 和差值 −1 在视口内同时可见;保存、报告链接和只读边界分别断言。 + +这些场景使用实际 API/Worker 与合成资产。截图附带源码提交、前端差异和完整源码 SHA-256、浏览器版本、平台与几何数据。新建的 **6 张 macOS Chromium 基线**覆盖完整编辑器、循环体和失败证据,普通验证保持 `maxDiffPixelRatio=0.003`。只屏蔽生成的对象标识、耗时等易变区域,断言数值、失败状态与循环边界保留。既有 **27 张 Linux 基线未重建**。 + +本地 Docker 新容器无法启动,改用独立数据库 `flowtest_ui_v5_audit_20261003`、Redis 10/11/12、独立存储桶和当前源码的 API/Worker/Vite 进程;基础设施沿用已有服务。测试目标为同机 Mock,项目策略只允许目标主机及 `/32` 私网地址,没有放宽生产出站策略。此验证不称为完整本地 Compose 验收。 + +## 验收边界 + +前端定向检查、全量覆盖率与构建、实际浏览器行为、平台对应的像素检查和最新提交远程 Required Gate 分别计入证据。历史 PR #127 的门禁与 62 通过/1 重试记录不能证明本轮通过。PR 评审补充了再次运行及失败项重跑被拒绝的路径:保留上一轮终态证据,直到新执行成功返回才替换;旧回调仍失效,失败对话框能够展示错误和重试。 + +新核心场景的 Linux 像素基线、整站文字与控件对比度、真实角色的全部特殊状态、完整键盘/屏幕阅读器审计以及同环境小/中/大图前后对比仍需独立验收。新场景在 Linux 执行真实接口与行为断言并取景,但不把 macOS 像素通过计为 Linux 通过。未把这些项目改写为全量完成。 diff --git a/frontend/e2e/support/workflow-editor.ts b/frontend/e2e/support/workflow-editor.ts index 35c4aa7a..03e8c982 100644 --- a/frontend/e2e/support/workflow-editor.ts +++ b/frontend/e2e/support/workflow-editor.ts @@ -34,9 +34,13 @@ export async function seedEditor( const project = await create('/projects', { name: `流程编辑验收 ${Date.now()}` }) const root = `/projects/${project.id}` const mockTargetUrl = process.env.FLOWTEST_E2E_MOCK_TARGET_URL ?? 'http://mock-target:8080' + const mockTargetHost = new URL(mockTargetUrl).hostname await create( `${root}/security-policy`, - { allowed_hosts: ['mock-target'], allowed_private_cidrs: ['172.16.0.0/12'] }, + { + allowed_hosts: [mockTargetHost], + allowed_private_cidrs: [process.env.FLOWTEST_E2E_MOCK_TARGET_CIDR ?? '172.16.0.0/12'], + }, 'put', ) const environment = await create(`${root}/environments`, { @@ -91,6 +95,7 @@ export async function seedEditor( export async function clickEdge(page: Page, id = 'start-api') { const path = page.locator(`.react-flow__edge[data-id="${id}"] .react-flow__edge-interaction`) await path.waitFor({ state: 'attached' }) + await settleCanvas(page) const hit = await path.evaluate((element) => { const svgPath = element as SVGPathElement const transform = svgPath.getScreenCTM() @@ -111,6 +116,42 @@ export async function clickEdge(page: Page, id = 'start-api') { await page.mouse.click(hit!.x, hit!.y) await expect(page.getByRole('heading', { name: '连接关系', exact: true })).toBeVisible() } + +export async function settleCanvas(page: Page): Promise { + await expect + .poll(() => + page.locator('.react-flow__node').evaluateAll((nodes) => + nodes.flatMap((node) => { + const body = node.querySelector('.flow-node') + if (!body) return [] + const wrapperHeight = node.getBoundingClientRect().height + const bodyHeight = body.getBoundingClientRect().height + return Math.abs(wrapperHeight - bodyHeight) < 1 + ? [] + : [{ node: node.getAttribute('data-id'), wrapperHeight, bodyHeight }] + }), + ), + ) + .toEqual([]) + await page.evaluate(async () => { + let previous = '', + stable = 0 + const deadline = performance.now() + 5000 + while (stable < 10) { + if (performance.now() > deadline) throw new Error('流程画布测量或视口动画尚未稳定') + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const current = JSON.stringify({ + viewport: document.querySelector('.react-flow__viewport')?.getAttribute('style'), + nodes: Array.from(document.querySelectorAll('.react-flow__node'), (node) => { + const { x, y, width, height } = node.getBoundingClientRect() + return { x, y, width, height } + }), + }) + stable = current === previous ? stable + 1 : 0 + previous = current + } + }) +} export async function dragBetween(page: Page, source: Locator, target: Locator) { await source.waitFor({ state: 'visible' }) await target.waitFor({ state: 'visible' }) diff --git a/frontend/e2e/workflow-editor-core-scenarios.spec.ts b/frontend/e2e/workflow-editor-core-scenarios.spec.ts new file mode 100644 index 00000000..b7870e73 --- /dev/null +++ b/frontend/e2e/workflow-editor-core-scenarios.spec.ts @@ -0,0 +1,363 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test' +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { createHash } from 'node:crypto' +import type { + WorkflowDefinition, + WorkflowNode, + WorkflowRegion, + WorkflowExecutionDetail, +} from '../src/lib/api' +import { seedEditor, settleCanvas, type EditorFixture } from './support/workflow-editor' + +const viewports = [ + { width: 1280, height: 800 }, + { width: 1440, height: 900 }, +] +for (const viewport of viewports) { + test(`核心场景:完整编辑器、条件循环和失败证据 ${viewport.width}×${viewport.height}`, async ({ + page, + }, info) => { + test.setTimeout(90_000) + await page.setViewportSize(viewport) + const ordinary = await seedEditor(page) + await page.locator('.react-flow__node[data-id=api]').click() + await expect(page.getByRole('button', { name: '应用节点配置', exact: true })).toBeVisible() + await capture(page, info, 'editor') + + const control = await seedEditor(page, conditionLoop) + await page.locator('.react-flow__node[data-id=loop]').click() + await expect(page.getByRole('heading', { name: 'body 区域', exact: true })).toBeVisible() + await capture(page, info, 'condition-loop') + await page.getByRole('button', { name: '打开区域画布', exact: true }).click() + const body = page.getByRole('dialog', { name: 'body 区域画布', exact: true }) + await expect(body).toContainText('入口 · 循环内 HTTP 请求') + await expect(body).toContainText('本轮出口 · 出口') + await capture(page, info, 'condition-loop-body') + await body.getByRole('button', { name: 'Close', exact: true }).click() + const controlRun = await executeFrozen(page, control) + expect(controlRun.execution.status).toBe('passed') + const records = await page.request.get( + `/api/v1${control.root}/workflow-executions/${controlRun.execution.id}/control-records?node_id=loop&kind=iteration`, + { headers: control.headers }, + ) + expect(records.ok(), await records.text()).toBeTruthy() + expect((await records.json()).items).toHaveLength(2) + await page.goto( + `${control.root}/workflows?focus=${control.workflow.id}&execution=${controlRun.execution.id}&node=loop&control_kind=iteration&control_ordinal=1`, + ) + await expect(page.getByText('历史快照 · 不可修改')).toBeVisible() + await expect(page.locator('.workflow-run-inspector')).toBeVisible() + const instance = page + .getByText('loop-request · passed', { exact: true }) + .locator('..') + .getByRole('button', { name: '查看实例详情', exact: true }) + await expect(instance).toBeVisible() + await capture(page, info, 'control-evidence') + await instance.click() + await expect(page.getByRole('region', { name: '实例执行证据', exact: true })).toContainText( + '"status_code": 200', + ) + await expect(page).toHaveURL(/instance=__nested_request__/) + await page.goto(`${page.url()}&instance_attempt=1`) + await expect(page.getByRole('region', { name: '实例执行证据', exact: true })).toContainText( + '检查点尝试:1', + ) + await page.getByRole('button', { name: '折叠运行面板', exact: true }).click() + await page.getByRole('region', { name: '实例执行证据', exact: true }).scrollIntoViewIfNeeded() + await capture(page, info, 'nested-evidence') + + const failed = await seedEditor(page, failureDefinition) + const failure = await executeFrozen(page, failed) + expect(failure.execution.status).toBe('failed') + expect(failure.nodes.find((node) => node.node_id === 'api')?.status).toBe('passed') + const assertion = failure.nodes.find((node) => node.node_id === 'assert') + expect(assertion?.status).toBe('failed') + expect(assertion?.output).toMatchObject({ actual: 200, expected: 201 }) + await page.goto( + `${failed.root}/workflows?focus=${failed.workflow.id}&execution=${failure.execution.id}&node=assert&attempt=1`, + ) + const inspector = page.locator('.workflow-run-inspector') + await expect(inspector).toContainText('201') + await expect(inspector).toContainText('200') + await expect(inspector).toContainText('差值') + await expect(inspector).toContainText('单位:未提供') + await page.getByRole('button', { name: '折叠运行面板', exact: true }).click() + await inspector.getByLabel('断言证据').scrollIntoViewIfNeeded() + await expect(inspector.getByText('201', { exact: true })).toBeInViewport() + await expect(inspector.getByText('200', { exact: true })).toBeInViewport() + await expect(page.getByRole('button', { name: '保存草稿', exact: true })).toHaveCount(0) + await capture(page, info, 'failed-assertion') + const report = page.getByRole('link', { name: '查看完整报告', exact: true }) + await expect(report).toHaveAttribute( + 'href', + new RegExp(`execution=${failure.execution.id}.*node=assert.*attempt=1`), + ) + expect(ordinary.projectId).not.toBe(failed.projectId) + }) +} + +function edge(source: string, target: string) { + return { id: `${source}-${target}`, source, target, condition: null, mappings: [] } +} +function controlNode( + id: string, + capability: string, + configuration: WorkflowNode['configuration'], + x: number, +): WorkflowNode { + return { + id, + type: 'capability', + name: id === 'loop' ? '最多三轮,达到计数退出' : '只执行命中的分支', + position: { x, y: 80 }, + config: {}, + capability_id: capability, + capability_version: '1.0.0', + configuration, + bindings: [], + } +} +function region( + id: string, + owner: string, + role: string, + node: WorkflowNode, + exit?: WorkflowNode, +): WorkflowRegion { + return { + id, + owner_node_id: owner, + role, + nodes: exit ? [node, exit] : [node], + edges: exit ? [edge(node.id, exit.id)] : [], + entry_node_id: node.id, + exit_node_ids: [exit?.id ?? node.id], + inputs: {}, + outputs: {}, + } +} +function conditionLoop(base: WorkflowDefinition): WorkflowDefinition { + const branch = controlNode( + 'branch', + 'flow.control.if', + { + condition: { + kind: 'compare', + left: { kind: 'literal', value: true }, + operator: 'equals', + right: { kind: 'literal', value: true }, + }, + true_body: { kind: 'inline', region_id: 'true-body' }, + false_body: { kind: 'inline', region_id: 'false-body' }, + }, + 260, + ) + const loop = controlNode( + 'loop', + 'flow.control.until', + { + condition: { + kind: 'compare', + left: { kind: 'variable', scope: 'state', path: ['counter'] }, + operator: 'gte', + right: { kind: 'literal', value: 2 }, + }, + state: { counter: { kind: 'literal', value: 0 } }, + update: { counter: { kind: 'add', value: { kind: 'literal', value: 1 } } }, + body: { kind: 'inline', region_id: 'loop-body' }, + policy: { max_iterations: 3, timeout_seconds: 30 }, + }, + 520, + ) + const delay: WorkflowNode = { + id: 'skip', + type: 'delay', + name: '备用分支', + position: { x: 0, y: 0 }, + config: { seconds: 0 }, + } + return { + ...base, + schema_version: '4.0', + run_policy: { + request_budget: 20, + max_runtime_seconds: 60, + cleanup_request_budget: null, + force_cancel_skips_cleanup: true, + }, + nodes: [base.nodes[0], branch, loop, { ...base.nodes[2], position: { x: 780, y: 80 } }], + edges: [edge('start', 'branch'), edge('branch', 'loop'), edge('loop', 'end')], + regions: [ + region('true-body', 'branch', 'true', { + ...base.nodes[1], + id: 'branch-request', + name: '命中分支的请求', + position: { x: 0, y: 0 }, + }), + region('false-body', 'branch', 'false', delay), + region( + 'loop-body', + 'loop', + 'body', + { + ...base.nodes[1], + id: 'loop-request', + name: '循环内 HTTP 请求', + position: { x: 0, y: 0 }, + }, + { ...delay, id: 'loop-boundary', name: '本轮出口', position: { x: 260, y: 0 } }, + ), + ], + } +} +function failureDefinition(base: WorkflowDefinition): WorkflowDefinition { + return { + ...base, + nodes: [ + base.nodes[0], + base.nodes[1], + { + id: 'assert', + type: 'assert', + name: 'HTTP 成功后独立断言失败', + position: { x: 520, y: 80 }, + config: { + source_node_id: 'api', + expression: 'status_code', + operator: 'equals', + expected: 201, + }, + }, + { ...base.nodes[2], position: { x: 780, y: 80 } }, + ], + edges: [edge('start', 'api'), edge('api', 'assert'), edge('assert', 'end')], + } +} +async function executeFrozen(page: Page, fixture: EditorFixture): Promise { + const published = await page.request.post( + `/api/v1${fixture.root}/workflows/${fixture.workflow.id}/versions`, + { headers: fixture.headers }, + ) + expect(published.ok(), await published.text()).toBeTruthy() + const version = (await published.json()).version + const environments = await page.request.get(`/api/v1${fixture.root}/environments`, { + headers: fixture.headers, + }) + const environment = (await environments.json())[0] + const started = await page.request.post( + `/api/v1${fixture.root}/workflows/${fixture.workflow.id}/executions`, + { headers: fixture.headers, data: { environment_id: environment.id, version } }, + ) + expect(started.ok(), await started.text()).toBeTruthy() + const execution = await started.json() + let result!: WorkflowExecutionDetail + await expect + .poll( + async () => { + const response = await page.request.get( + `/api/v1${fixture.root}/workflow-executions/${execution.id}`, + { headers: fixture.headers }, + ) + expect(response.ok()).toBeTruthy() + result = await response.json() + return ['queued', 'running'].includes(result.execution.status) + }, + { timeout: 30_000 }, + ) + .toBe(false) + return result +} +async function capture(page: Page, info: TestInfo, scenario: string): Promise { + await page.evaluate(() => document.fonts.ready) + await settleCanvas(page) + await page.mouse.move(0, 0) + const size = page.viewportSize()! + const name = `${size.width}x${size.height}-${scenario}` + const screenshot = info.outputPath(`${name}.png`) + await page.screenshot({ path: screenshot, animations: 'disabled' }) + await info.attach(name, { path: screenshot, contentType: 'image/png' }) + const git = (args: string[]) => execFileSync('git', ['-C', '..', ...args]) + const frontendSource = createHash('sha256') + const files = [ + ...new Set( + git(['ls-files', '-co', '--exclude-standard', '-z', '--', 'frontend']).toString().split('\0'), + ), + ] + .filter((file) => /\.(?:tsx?|css|json|ya?ml|conf)$/.test(file)) + .sort() + for (const file of files) + frontendSource.update(file + '\0').update(readFileSync(resolve('..', file))) + const pixelGate = + process.platform === 'darwin' && + ['editor', 'condition-loop-body', 'failed-assertion'].includes(scenario) + const metadata = info.outputPath(`${name}.json`) + writeFileSync( + metadata, + JSON.stringify( + { + sourceSha: git(['rev-parse', 'HEAD']).toString().trim(), + frontendDiffSha256: createHash('sha256') + .update(git(['diff', 'HEAD', '--', 'frontend'])) + .digest('hex'), + frontendSourceSha256: frontendSource.digest('hex'), + scenario, + viewport: size, + platform: process.platform, + browserVersion: page.context().browser()?.version(), + runtime: '实际 API/Worker,合成测试资产', + geometry: await page.evaluate(() => { + const selectors = [ + '.workflow-canvas-stage', + '.workflow-canvas-toolbar', + '.workflow-inspector', + '.workflow-runtime-dock', + '.workflow-region-canvas', + ] + return { + surfaces: Object.fromEntries( + selectors.map((selector) => { + const element = document.querySelector(selector) + const box = element?.getBoundingClientRect() + return [ + selector, + box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null, + ] + }), + ), + page: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + theme: document.documentElement.dataset.flowtestTheme, + } + }), + pixelGate: pixelGate ? 'ASSERTED (macOS)' : 'NOT RUN (capture and behavior only)', + }, + null, + 2, + ) + '\n', + ) + await info.attach(`${name}-source`, { path: metadata, contentType: 'application/json' }) + if (pixelGate) { + await expect(page).toHaveScreenshot(`${name}.png`, { + animations: 'disabled', + maxDiffPixelRatio: 0.003, + mask: [ + page.locator('.global-project-select'), + page.locator('.page-breadcrumb'), + page.locator('.workflow-volatile-value'), + page + .locator('.workspace-navigation-tabs .ant-tabs-tab-btn') + .filter({ hasText: '执行报告' }), + page.locator('.workflow-runtime-panel-heading .ant-typography-secondary'), + page.locator('.flow-node-status small'), + page.locator('.workflow-runtime-dock'), + ], + }) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + size.width, + ) +} diff --git a/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-condition-loop-body-chromium-darwin.png b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-condition-loop-body-chromium-darwin.png new file mode 100644 index 00000000..36fa6b5a Binary files /dev/null and b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-condition-loop-body-chromium-darwin.png differ diff --git a/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-editor-chromium-darwin.png b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-editor-chromium-darwin.png new file mode 100644 index 00000000..4e904053 Binary files /dev/null and b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-editor-chromium-darwin.png differ diff --git a/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-failed-assertion-chromium-darwin.png b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-failed-assertion-chromium-darwin.png new file mode 100644 index 00000000..95cfb068 Binary files /dev/null and b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1280x800-failed-assertion-chromium-darwin.png differ diff --git a/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-condition-loop-body-chromium-darwin.png b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-condition-loop-body-chromium-darwin.png new file mode 100644 index 00000000..c7a84bbe Binary files /dev/null and b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-condition-loop-body-chromium-darwin.png differ diff --git a/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-editor-chromium-darwin.png b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-editor-chromium-darwin.png new file mode 100644 index 00000000..1dee811a Binary files /dev/null and b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-editor-chromium-darwin.png differ diff --git a/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-failed-assertion-chromium-darwin.png b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-failed-assertion-chromium-darwin.png new file mode 100644 index 00000000..bd48a5aa Binary files /dev/null and b/frontend/e2e/workflow-editor-core-scenarios.spec.ts-snapshots/1440x900-failed-assertion-chromium-darwin.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts b/frontend/e2e/workflow-editor-visual.spec.ts index 753d5a47..82d59e9d 100644 --- a/frontend/e2e/workflow-editor-visual.spec.ts +++ b/frontend/e2e/workflow-editor-visual.spec.ts @@ -4,7 +4,13 @@ import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import { resolve } from 'node:path' -import { clickEdge, closeInspector, publishAndRun, seedEditor } from './support/workflow-editor' +import { + clickEdge, + closeInspector, + publishAndRun, + seedEditor, + settleCanvas as settleViewport, +} from './support/workflow-editor' const matrixRoot = resolve( process.cwd(), @@ -175,37 +181,6 @@ function visualFixtureMasks(page: Page, name: string): Locator[] { ) return masks } -async function settleViewport(page: Page): Promise { - await expect - .poll(() => - page.evaluate(() => - Array.from(document.querySelectorAll('.react-flow__node')).every((node) => { - const body = node.querySelector('.flow-node') - if (!body) return true - return ( - Math.abs(node.getBoundingClientRect().height - body.getBoundingClientRect().height) < 1 - ) - }), - ), - ) - .toBe(true) - await page.evaluate(async () => { - let previous = '' - let stableFrames = 0 - while (stableFrames < 10) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - const current = JSON.stringify({ - viewport: document.querySelector('.react-flow__viewport')?.getAttribute('style'), - nodes: Array.from(document.querySelectorAll('.react-flow__node'), (element) => { - const { x, y, width, height } = element.getBoundingClientRect() - return { x, y, width, height } - }), - }) - stableFrames = current === previous ? stableFrames + 1 : 0 - previous = current - } - }) -} async function fitCanvas(page: Page): Promise { const fit = page.getByRole('button', { name: /适应画布/ }) await fit.click() diff --git a/frontend/src/features/evidence/AssertionEvidence.test.tsx b/frontend/src/features/evidence/AssertionEvidence.test.tsx index 0cbcd6a7..818e5f02 100644 --- a/frontend/src/features/evidence/AssertionEvidence.test.tsx +++ b/frontend/src/features/evidence/AssertionEvidence.test.tsx @@ -1,7 +1,7 @@ import { render, screen, within } from '@testing-library/react' import { expect, it } from 'vitest' import AssertionEvidence from './AssertionEvidence' -import { reportAssertionEvidence } from './assertion-model' +import { reportAssertionEvidence, workflowAssertionEvidence } from './assertion-model' it('shows failed assertions first and preserves false, zero, null and missing values', () => { render( @@ -44,3 +44,17 @@ it('derives numeric differences from frozen values and identifies the actual evi expect(screen.getByText('差值(实际 − 期望):0 · 单位:未提供')).toBeVisible() expect(screen.getAllByText('证据来源:订单断言 (assert-order)')).toHaveLength(3) }) + +it('reads frozen standalone assertion output and keeps standard assertions authoritative', () => { + const output = { passed: false, actual: 200, expected: 201, operator: 'equals' } + const node = { node_type: 'assert', name: '状态码断言', output } + expect(workflowAssertionEvidence(node)).toEqual([ + { name: node.name, passed: false, actual: 200, expected: 201, message: undefined }, + ]) + const standard = { name: '标准证据', passed: false, actual: false, expected: 0 } + expect(workflowAssertionEvidence({ ...node, result: { assertions: [standard] } })).toEqual([ + { ...standard, message: undefined }, + ]) + expect(workflowAssertionEvidence({ ...node, node_type: 'api' })).toEqual([]) + expect(workflowAssertionEvidence({ ...node, output: { passed: false } })).toEqual([]) +}) diff --git a/frontend/src/features/evidence/assertion-model.ts b/frontend/src/features/evidence/assertion-model.ts index 86d80c90..70ab9064 100644 --- a/frontend/src/features/evidence/assertion-model.ts +++ b/frontend/src/features/evidence/assertion-model.ts @@ -1,5 +1,21 @@ import type { AssertionEvidenceItem } from './AssertionEvidence' +type AssertionNodeEvidence = { + node_type: string + name: string + output: unknown + result?: { assertions?: unknown } | null +} + +export function workflowAssertionEvidence( + node: AssertionNodeEvidence | undefined, +): AssertionEvidenceItem[] { + if (!node) return [] + const standard = reportAssertionEvidence(node.result?.assertions, node.name) + if (standard.length) return standard + return node.node_type === 'assert' ? reportAssertionEvidence(node.output, node.name) : [] +} + export function reportAssertionEvidence(value: unknown, name: string): AssertionEvidenceItem[] { if (Array.isArray(value)) return value.flatMap((item) => reportAssertionEvidence(item, name)) if (!value || typeof value !== 'object') return [] diff --git a/frontend/src/features/workflows/use-execution-events.test.ts b/frontend/src/features/workflows/use-execution-events.test.ts index 8b2c2150..d3645236 100644 --- a/frontend/src/features/workflows/use-execution-events.test.ts +++ b/frontend/src/features/workflows/use-execution-events.test.ts @@ -115,6 +115,36 @@ describe('workflow execution events', () => { expect(constructor).not.toHaveBeenCalled() }) + it('ignores socket messages after cleanup or after a reconnect replaced the socket', async () => { + vi.useFakeTimers() + const sockets: FakeWebSocket[] = [] + vi.stubGlobal( + 'WebSocket', + class extends FakeWebSocket { + constructor(url: string, protocols: string[]) { + super(url, protocols) + sockets.push(this) + } + }, + ) + const handler = vi.fn() + const gap = vi.fn() + const { unmount } = renderHook(() => + useExecutionEvents('execution-id', 'token', handler, async () => {}, gap), + ) + await act(async () => {}) + act(() => sockets[0].disconnect(1006)) + await act(async () => vi.advanceTimersByTime(500)) + act(() => sockets[0].emit(eventMessage(900))) + expect(handler).not.toHaveBeenCalled() + expect(gap).not.toHaveBeenCalled() + act(() => sockets[1].emit(eventMessage(1))) + expect(handler).toHaveBeenCalledOnce() + unmount() + act(() => sockets[1].emit(eventMessage(2))) + expect(handler).toHaveBeenCalledOnce() + }) + it('reconciles a sequence gap before accepting a replay beyond retained history', async () => { vi.useFakeTimers() const sockets: FakeWebSocket[] = [] diff --git a/frontend/src/features/workflows/use-execution-events.ts b/frontend/src/features/workflows/use-execution-events.ts index 73643f15..a79be4c7 100644 --- a/frontend/src/features/workflows/use-execution-events.ts +++ b/frontend/src/features/workflows/use-execution-events.ts @@ -38,6 +38,7 @@ export function useExecutionEvents( } function handleMessage(message: MessageEvent, current: WebSocket) { + if (!active || current !== socket) return const event = parseExecutionEvent(message.data) if (!event || event.execution_id !== executionId || event.sequence <= latestSequence) return if (event.sequence > latestSequence + 1) { @@ -69,11 +70,12 @@ export function useExecutionEvents( ]) socket = current current.onopen = () => { + if (!active || current !== socket) return retryAttempt = 0 } current.onmessage = (message) => handleMessage(message, current) current.onclose = (event) => { - if (!active) return + if (!active || current !== socket) return if (!shouldRetryClose(event.code)) { void reconcile.current?.(executionId!)?.catch(() => { if (active && event.code === 1000) scheduleReconnect() diff --git a/frontend/src/features/workflows/use-workflow-execution.ts b/frontend/src/features/workflows/use-workflow-execution.ts new file mode 100644 index 00000000..61b40684 --- /dev/null +++ b/frontend/src/features/workflows/use-workflow-execution.ts @@ -0,0 +1,247 @@ +import { useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { App } from 'antd' +import { + apiErrorMessage, + type ExecutionEvent, + type WorkflowDefinition, + type WorkflowExecution, + type WorkflowExecutionDetail, + type WorkflowNodeExecution, +} from '../../lib/api' +import { getWorkflowExecution } from './workflow-service' +import { useExecutionEvents } from './use-execution-events' +import { + initialNodeExecutions, + mergeExecutionEvent, + snapshotDefinition, + type NodeExecutionEvent, +} from './workflow-runtime' + +class ExecutionScope { + private generation = 0 + private mounted = true + constructor( + readonly userId: string | undefined, + readonly projectId: string | null, + readonly workflowId: string | null, + ) {} + mount(): void { + this.mounted = true + } + unmount(): void { + this.mounted = false + this.invalidate() + } + invalidate(): void { + this.generation += 1 + } + capture(): ExecutionTicket { + return { scope: this, generation: this.generation } + } + isCurrent(generation: number): boolean { + return this.mounted && generation === this.generation + } +} +export type ExecutionTicket = { scope: ExecutionScope; generation: number } +type ExecutionSubscription = ExecutionTicket & { + executionId: string + completing: boolean + completed: boolean + lastError: string | null +} +type ExecutionState = { + subscription: ExecutionSubscription | null + execution: WorkflowExecution | null + definition: WorkflowDefinition | null + nodes: Record + result: WorkflowExecutionDetail | null + historyIncompleteId: string | null +} + +function emptyState(): ExecutionState { + return { + subscription: null, + execution: null, + definition: null, + nodes: {}, + result: null, + historyIncompleteId: null, + } +} +function isCurrent(ticket: ExecutionTicket): boolean { + return ticket.scope.isCurrent(ticket.generation) +} +function isActive(tracking: ExecutionSubscription | null): tracking is ExecutionSubscription { + return tracking !== null && !tracking.completed && isCurrent(tracking) +} +function belongsToScope(execution: WorkflowExecution, scope: ExecutionScope): boolean { + return execution.project_id === scope.projectId && execution.workflow_id === scope.workflowId +} + +export function useWorkflowExecution( + userId: string | undefined, + projectId: string | null, + workflowId: string | null, + token: string | null, +) { + const { message } = App.useApp() + const queryClient = useQueryClient() + const scope = useMemo( + () => new ExecutionScope(userId, projectId, workflowId), + [userId, projectId, workflowId], + ) + const [state, setState] = useState(emptyState) + useEffect(() => { + scope.mount() + return () => { + scope.unmount() + } + }, [scope]) + const view = state.subscription?.scope === scope ? state : emptyState() + const subscription = view.subscription + const activeExecutionId = isActive(subscription) ? subscription.executionId : null + + function reserve(): ExecutionTicket { + scope.invalidate() + return scope.capture() + } + function reset() { + scope.invalidate() + setState(emptyState()) + } + + function begin(execution: WorkflowExecution, ticket?: ExecutionTicket): boolean { + if (!belongsToScope(execution, scope) || !isCurrent(scope.capture())) return false + const owner = ticket ?? reserve() + if (owner.scope !== scope || !isCurrent(owner)) return false + const tracking: ExecutionSubscription = { + ...owner, + executionId: execution.id, + completing: false, + completed: false, + lastError: null, + } + const definition = snapshotDefinition(execution.snapshot) + setState({ + subscription: tracking, + execution, + definition, + nodes: definition ? initialNodeExecutions(execution.id, definition) : {}, + result: null, + historyIncompleteId: null, + }) + void watch(tracking) + return true + } + + async function finish( + tracking: ExecutionSubscription, + result: WorkflowExecutionDetail, + ): Promise { + if (!isActive(tracking)) return true + if ( + result.execution.id !== tracking.executionId || + !belongsToScope(result.execution, tracking.scope) + ) + return false + if (['queued', 'running'].includes(result.execution.status)) return false + tracking.completed = true + setState((current) => + isCurrent(tracking) && current.subscription === tracking + ? { + ...current, + result, + execution: result.execution, + nodes: Object.fromEntries(result.nodes.map((node) => [node.node_id, node])), + } + : current, + ) + await queryClient.invalidateQueries({ + queryKey: ['workflow-executions', tracking.scope.projectId], + }) + if (isCurrent(tracking)) + void message.success( + result.execution.status === 'passed' ? '工作流执行通过' : '工作流执行完成', + ) + return true + } + + async function complete(tracking: ExecutionSubscription): Promise { + if (!isActive(tracking)) return true + if (tracking.completing || !tracking.scope.projectId) return false + tracking.completing = true + try { + const result = await getWorkflowExecution( + tracking.scope.projectId, + tracking.executionId, + true, + ) + return await finish(tracking, result) + } catch (error) { + tracking.lastError = apiErrorMessage(error) + return !isCurrent(tracking) + } finally { + tracking.completing = false + } + } + async function watch(tracking: ExecutionSubscription) { + for (let attempt = 0; attempt < 600; attempt += 1) { + await new Promise((resolve) => window.setTimeout(resolve, 500)) + if (await complete(tracking)) return + } + if (isCurrent(tracking)) void message.error(tracking.lastError ?? '等待工作流执行结果超时') + } + + function onEvent(event: ExecutionEvent) { + if (!isActive(subscription) || event.execution_id !== subscription.executionId) return + if ( + (event.type === 'node.status' || event.type === 'node.result') && + event.node_id && + event.node_status + ) { + const nodeId = event.node_id + setState((current) => + current.subscription === subscription && isCurrent(subscription) + ? { + ...current, + nodes: { + ...current.nodes, + [nodeId]: mergeExecutionEvent(current.nodes[nodeId], event as NodeExecutionEvent), + }, + } + : current, + ) + } + if (event.type === 'execution.completed') void complete(subscription) + } + async function reconcile(executionId: string): Promise { + if (!isActive(subscription) || executionId !== subscription.executionId || !projectId) return + const result = await getWorkflowExecution(projectId, executionId, true) + if (!isCurrent(subscription)) return + if (await finish(subscription, result)) return + if (result.execution.id !== executionId || !belongsToScope(result.execution, scope)) return + setState((current) => + current.subscription === subscription && isCurrent(subscription) + ? { + ...current, + execution: result.execution, + nodes: { + ...current.nodes, + ...Object.fromEntries(result.nodes.map((node) => [node.node_id, node])), + }, + } + : current, + ) + } + function onHistoryGap(executionId: string) { + if (!isActive(subscription) || executionId !== subscription.executionId) return + setState((current) => + current.subscription === subscription && isCurrent(subscription) + ? { ...current, historyIncompleteId: executionId } + : current, + ) + } + useExecutionEvents(activeExecutionId, token, onEvent, reconcile, onHistoryGap) + return { ...view, activeExecutionId, reserve, reset, begin, isCurrent } +} diff --git a/frontend/src/features/workflows/use-workflows.ts b/frontend/src/features/workflows/use-workflows.ts index b583cb1d..9cc0adcb 100644 --- a/frontend/src/features/workflows/use-workflows.ts +++ b/frontend/src/features/workflows/use-workflows.ts @@ -9,7 +9,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { apiErrorMessage, - type ExecutionEvent, type Page, type WorkflowDebugResult, type WorkflowDefinition, @@ -27,7 +26,8 @@ import { listGraphQLSchemas, listGrpcDescriptors, } from '../protocols/protocol-service' -import { useExecutionEvents } from './use-execution-events' +import { useWorkflowExecution, type ExecutionTicket } from './use-workflow-execution' +import { snapshotDefinition } from './workflow-runtime' import type { WorkflowTemplateKind } from './workflow-templates' import { clearWorkflowDrafts, @@ -111,19 +111,11 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st const memoryDraftsRef = useMemo(() => ({ current: scopedMemory.drafts }), [scopedMemory]) const draftGenerationRef = useMemo(() => ({ current: scopedMemory.generations }), [scopedMemory]) const previousDraftScopeRef = useRef(draftScope) - const [lastResult, setLastResult] = useState(null) - const [activeExecution, setActiveExecution] = useState(null) - const [activeExecutionId, setActiveExecutionId] = useState(null) - const [eventHistoryIncompleteId, setEventHistoryIncompleteId] = useState(null) - const [liveNodes, setLiveNodes] = useState>({}) - const [executionDefinition, setExecutionDefinition] = useState(null) const { workspaceMode, setWorkspaceMode, historyExecutionId, setHistoryExecutionId } = useExecutionSelection(projectId, initialExecutionId) const [breakpointSelection, setBreakpointSelection] = useState(null) const [debugResult, setDebugResult] = useState(null) const [versionDiff, setVersionDiff] = useState(null) - const completedExecutionId = useRef(null) - const completingExecutionId = useRef(null) const environments = useQuery({ queryKey: ['environments', projectId], @@ -185,6 +177,15 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st Boolean(initialExecutionId), isCompleteWorkflowCatalog(workflowSearch, workflows.data), ) + const runtime = useWorkflowExecution(userId, projectId, workflowId, token) + const { + result: lastResult, + execution: activeExecution, + definition: executionDefinition, + nodes: liveNodes, + activeExecutionId, + historyIncompleteId: eventHistoryIncompleteId, + } = runtime const setDraftStorageError = useCallback( (error: string | null, targetId = workflowId) => { setDraftStorageErrorState(error) @@ -305,10 +306,15 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st deleteWorkflow(requiredId(projectId), targetWorkflowId), }) const saveMutation = useMutation({ - mutationFn: (input: { definition: WorkflowDefinition; expectedRevision: number }) => + mutationFn: (input: { + projectId: string + workflow: Workflow + definition: WorkflowDefinition + expectedRevision: number + }) => updateWorkflowDraft( - requiredId(projectId), - requiredWorkflow(selectedWorkflow), + input.projectId, + input.workflow, input.definition, input.expectedRevision, ), @@ -318,13 +324,13 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st publishWorkflow(target.projectId, target.workflowId), }) const executeMutation = useMutation({ - mutationFn: () => - executeWorkflow( - requiredId(projectId), - requiredId(workflowId), - requiredId(environmentId), - requiredVersion(selectedWorkflow?.current_version), - ), + mutationFn: (target: { + projectId: string + workflowId: string + environmentId: string + version: number + }) => + executeWorkflow(target.projectId, target.workflowId, target.environmentId, target.version), }) const debugMutation = useMutation({ mutationFn: () => @@ -361,26 +367,13 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st rerunFailedWorkflowItems(requiredId(projectId), input.executionId, input.payload), }) - useExecutionEvents( - activeExecutionId, - token, - handleExecutionEvent, - reconcileExecution, - setEventHistoryIncompleteId, - ) - function selectProject(value: string) { selectContextProject(value) setWorkflowSelection(null) setWorkflowSelectionCleared(false) setDraftEdit(null) draftVersionRef.current = 0 - setLastResult(null) - setActiveExecution(null) - setActiveExecutionId(null) - setEventHistoryIncompleteId(null) - setLiveNodes({}) - setExecutionDefinition(null) + runtime.reset() setWorkspaceMode('draft') setHistoryExecutionId(null) setBreakpointSelection(null) @@ -438,11 +431,15 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st const generationAtStart = draftGenerationRef.current.get(workflowIdAtStart ?? '') ?? 0 await runMutation(message.error, async () => { const saved = await saveMutation.mutateAsync({ + projectId: requiredId(projectId), + workflow: requiredWorkflow(selectedWorkflow), definition: draftDefinition, expectedRevision, }) if (!workflowIdAtStart || !draftKeyAtStart) return - if ((draftGenerationRef.current.get(workflowIdAtStart) ?? 0) !== generationAtStart) return + if (!isDraftGenerationCurrent(workflowIdAtStart, generationAtStart)) return + await synchronizeWorkflow(draftKeyAtStart.projectId, saved) + if (!isDraftGenerationCurrent(workflowIdAtStart, generationAtStart)) return const latest = memoryDraftsRef.current.get(workflowIdAtStart) const hasNewerEdit = Boolean(latest && latest.editVersion !== editVersionAtStart) if (latest && hasNewerEdit) rebaseWorkflowDraft(latest, saved.draft_revision, draftKeyAtStart) @@ -499,6 +496,12 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st if (hasPendingNodeEditor(target.workflowId)) return await runMutation(message.error, async () => { const published = await publishMutation.mutateAsync(target) + await queryClient.cancelQueries({ + queryKey: ['workflow', target.projectId, target.workflowId], + exact: true, + }) + const detail = await getWorkflow(target.projectId, target.workflowId) + await synchronizeWorkflow(target.projectId, detail) await refreshWorkflows() void message.success(`工作流 v${published.version} 已发布`) }) @@ -506,25 +509,24 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st async function execute() { if (!canEdit || hasPendingNodeEditor(workflowId)) return + const target = { + projectId: requiredId(projectId), + workflowId: requiredId(workflowId), + environmentId: requiredId(environmentId), + version: requiredVersion(selectedWorkflow?.current_version), + } + const ticket = runtime.reserve() await runMutation(message.error, async () => { - const execution = await executeMutation.mutateAsync() - beginExecution(execution) - void message.info('工作流已开始运行') + const execution = await executeMutation.mutateAsync(target) + if (beginExecution(execution, ticket)) void message.info('工作流已开始运行') }) } - function beginExecution(execution: WorkflowExecution) { - const runningDefinition = snapshotDefinition(execution.snapshot) ?? emptyDefinition() - setLastResult(null) - setActiveExecution(execution) - setExecutionDefinition(runningDefinition) - setLiveNodes(initialNodeExecutions(execution.id, runningDefinition)) - completedExecutionId.current = null - setActiveExecutionId(execution.id) - setEventHistoryIncompleteId(null) + function beginExecution(execution: WorkflowExecution, ticket?: ExecutionTicket): boolean { + if (!runtime.begin(execution, ticket)) return false setWorkspaceMode('run') setHistoryExecutionId(null) - void watchExecution(execution.id) + return true } async function debugToBreakpoint() { @@ -548,83 +550,40 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st } async function rerunFailedItems(executionId: string, payload: FailedItemRerunRequest) { + const ticket = runtime.reserve() const execution = await rerunFailedItemsMutation.mutateAsync({ executionId, payload }) - beginExecution(execution) - void message.info('失败项派生运行已开始,原运行报告保留') - } - - function handleExecutionEvent(event: ExecutionEvent) { - if ( - (event.type === 'node.status' || event.type === 'node.result') && - event.node_id && - event.node_status - ) { - const nodeId = event.node_id - setLiveNodes((current) => ({ - ...current, - [nodeId]: mergeExecutionEvent(current[nodeId], event as NodeExecutionEvent), - })) - } - if (event.type === 'execution.completed') void completeExecution(event.execution_id) - } - - async function reconcileExecution(executionId: string): Promise { - if (!projectId) return - const result = await getWorkflowExecution(projectId, executionId, true) - if (['queued', 'running'].includes(result.execution.status)) { - setActiveExecution((current) => (current?.id === executionId ? result.execution : current)) - return - } - await completeExecution(executionId) - } - - async function watchExecution(executionId: string) { - for (let attempt = 0; attempt < 600; attempt += 1) { - await delay(500) - if (await completeExecution(executionId)) return - } - void message.error('等待工作流执行结果超时') - } - - async function completeExecution(executionId: string): Promise { - if (completedExecutionId.current === executionId) return true - if (completingExecutionId.current === executionId) return false - completingExecutionId.current = executionId - try { - const result = await getWorkflowExecution(requiredId(projectId), executionId, true) - if (['queued', 'running'].includes(result.execution.status)) return false - completedExecutionId.current = executionId - setLastResult(result) - setActiveExecution(result.execution) - setLiveNodes(Object.fromEntries(result.nodes.map((node) => [node.node_id, node]))) - setActiveExecutionId(null) - await queryClient.invalidateQueries({ queryKey: ['workflow-executions', projectId] }) - void message.success( - result.execution.status === 'passed' ? '工作流执行通过' : '工作流执行完成', - ) - return true - } catch { - return false - } finally { - if (completingExecutionId.current === executionId) completingExecutionId.current = null - } + if (beginExecution(execution, ticket)) void message.info('失败项派生运行已开始,原运行报告保留') } async function refreshWorkflows() { await queryClient.invalidateQueries({ queryKey: ['workflows', projectId] }) } + async function synchronizeWorkflow(targetProjectId: string, saved: Workflow) { + const queryKey = ['workflow', targetProjectId, saved.id] + await Promise.all([ + queryClient.cancelQueries({ queryKey, exact: true }), + queryClient.cancelQueries({ queryKey: ['workflows', targetProjectId] }), + ]) + queryClient.setQueryData(queryKey, saved) + queryClient.setQueriesData>( + { queryKey: ['workflows', targetProjectId] }, + (page) => + page + ? { + ...page, + items: page.items.map((item) => (item.id === saved.id ? saved : item)), + } + : page, + ) + } + function selectWorkflow(value: string | null) { setWorkflowSelection(value) setWorkflowSelectionCleared(value === null) setDraftEdit(null) draftVersionRef.current = 0 - setLastResult(null) - setActiveExecution(null) - setActiveExecutionId(null) - setEventHistoryIncompleteId(null) - setLiveNodes({}) - setExecutionDefinition(null) + runtime.reset() setWorkspaceMode('draft') setHistoryExecutionId(null) } @@ -654,7 +613,9 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st targetDraft.definition, targetDraft.baseRevision, ) - if ((draftGenerationRef.current.get(targetWorkflowId) ?? 0) !== generationAtStart) return + if (!isDraftGenerationCurrent(targetWorkflowId, generationAtStart)) return + await synchronizeWorkflow(projectId, saved) + if (!isDraftGenerationCurrent(targetWorkflowId, generationAtStart)) return const latestMemoryDraft = memoryDraftsRef.current.get(targetWorkflowId) if (isNewerDraft(latestMemoryDraft, targetDraft.editVersion)) { const rebased = { ...latestMemoryDraft, baseRevision: saved.draft_revision } @@ -679,6 +640,10 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st }) } + function isDraftGenerationCurrent(targetWorkflowId: string, generation: number): boolean { + return (draftGenerationRef.current.get(targetWorkflowId) ?? 0) === generation + } + function discardWorkflowDraft(targetWorkflowId: string) { invalidateWorkflowDraft(targetWorkflowId) if (userId && projectId) { @@ -787,7 +752,6 @@ export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: st setMemoryDraftIds([...memoryDraftsRef.current.keys()]) setDraftEdit(edit) setDraftStorageError(persisted.ok ? null : persisted.error) - setExecutionDefinition(null) } }, draftRestored: Boolean(draftEdit), @@ -938,35 +902,6 @@ function canLoadWorkflowDetail( return Boolean(projectId && workflowId && !listedWorkflow) } -function initialNodeExecutions( - executionId: string, - definition: WorkflowDefinition, -): Record { - return Object.fromEntries( - definition.nodes.map((node) => [ - node.id, - { - id: `${executionId}:${node.id}`, - node_id: node.id, - node_type: node.type, - name: node.name, - status: 'pending', - attempts: 0, - output: null, - result: null, - error_code: null, - error_message: null, - started_at: null, - }, - ]), - ) -} - -type NodeExecutionEvent = ExecutionEvent & { - node_id: string - node_status: WorkflowNodeExecution['status'] -} - type WorkspaceViewInput = { mode: WorkflowWorkspaceMode draftDefinition: WorkflowDefinition @@ -1029,56 +964,6 @@ function nodeStatusMap(nodes: WorkflowNodeExecution[]): Record { return Object.fromEntries(nodes.map((node) => [node.node_id, node.status])) } -function mergeExecutionEvent( - current: WorkflowNodeExecution | undefined, - event: NodeExecutionEvent, -): WorkflowNodeExecution { - const base = current ?? emptyEventNode(event) - const resultFields = event.result ? { output: event.result.output, result: event.result } : {} - return { - ...base, - ...resultFields, - status: event.node_status, - attempts: event.attempts, - error_code: event.error_code, - error_message: event.error_message, - started_at: eventStartedAt(base, event), - completed_at: eventCompletedAt(base, event), - } -} - -function emptyEventNode(event: NodeExecutionEvent): WorkflowNodeExecution { - return { - id: `${event.execution_id}:${event.node_id}`, - node_id: event.node_id, - node_type: event.node_type ?? 'unknown', - name: event.node_name ?? '未命名节点', - status: 'pending', - attempts: 0, - output: null, - result: null, - error_code: null, - error_message: null, - started_at: null, - } -} - -function eventStartedAt( - current: WorkflowNodeExecution, - event: NodeExecutionEvent, -): string | null | undefined { - if (current.started_at) return current.started_at - return event.node_status === 'running' ? event.emitted_at : null -} - -function eventCompletedAt( - current: WorkflowNodeExecution, - event: NodeExecutionEvent, -): string | undefined { - if (isTerminalNodeStatus(event.node_status)) return event.emitted_at - return current.completed_at -} - function orderedLiveNodes( definition: WorkflowDefinition | null, nodes: Record, @@ -1087,10 +972,6 @@ function orderedLiveNodes( return definition.nodes.flatMap((node) => (nodes[node.id] ? [nodes[node.id]] : [])) } -function isTerminalNodeStatus(status: WorkflowNodeExecution['status']): boolean { - return !['pending', 'running'].includes(status) -} - type Identified = { id: string } function draftSource( @@ -1132,24 +1013,6 @@ function emptyDefinition(): WorkflowDefinition { } } -function snapshotDefinition(snapshot: Record): WorkflowDefinition | null { - const workflow = snapshot.workflow - if (!isRecord(workflow)) return null - const definition = workflow.definition - if ( - !isRecord(definition) || - !Array.isArray(definition.nodes) || - !Array.isArray(definition.edges) - ) { - return null - } - return definition as WorkflowDefinition -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - async function runMutation( showError: (content: string) => unknown, operation: () => Promise, @@ -1162,10 +1025,6 @@ async function runMutation( } } -function delay(milliseconds: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)) -} - function isNewerDraft( draft: WorkflowDraftEdit | undefined, version: number, diff --git a/frontend/src/features/workflows/workflow-execution-concurrency.test.tsx b/frontend/src/features/workflows/workflow-execution-concurrency.test.tsx new file mode 100644 index 00000000..aac4ad79 --- /dev/null +++ b/frontend/src/features/workflows/workflow-execution-concurrency.test.tsx @@ -0,0 +1,311 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { App, ConfigProvider } from 'antd' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { DraftSessionProvider } from '../drafts/DraftSessionProvider' +import { authenticateTestUser } from '../../test/auth' +import { environment, project, user, workflow, workflowRunningExecution } from '../../test/fixtures' +import type { ExecutionEvent, WorkflowExecution, WorkflowExecutionDetail } from '../../lib/api' +import { useWorkflows } from './use-workflows' +import { useAuthStore } from '../auth/auth-store' + +const context = vi.hoisted(() => ({ projectId: '' })) +vi.mock('../projects/use-project-context', () => ({ + useProjectContext: () => ({ + projects: { data: { items: [] } }, + projectId: context.projectId, + selectProject: vi.fn(), + }), +})) +vi.mock('./workflow-service', () => ({ + listWorkflows: async () => ({ + items: [workflow, { ...workflow, id: 'workflow-b' }], + total: 2, + page: 1, + page_size: 100, + }), + listEnvironments: async () => [environment], + listApis: async () => ({ items: [] }), + listArtifacts: async () => ({ items: [] }), + listWorkflowExecutions: async () => ({ items: [] }), + executeWorkflow: vi.fn(), + getWorkflowExecution: vi.fn(), + rerunFailedWorkflowItems: vi.fn(), +})) +vi.mock('../data-sources/data-source-service', () => ({ listCredentials: async () => [] })) +vi.mock('../protocols/protocol-service', () => ({ + listGraphQLSchemas: async () => ({ items: [] }), + listGrpcDescriptors: async () => ({ items: [] }), + listEventSources: async () => ({ items: [] }), +})) +vi.mock('./use-execution-events', () => ({ useExecutionEvents: vi.fn() })) +import { executeWorkflow, getWorkflowExecution, rerunFailedWorkflowItems } from './workflow-service' +import { useExecutionEvents } from './use-execution-events' + +function pending() { + let resolve!: (value: T) => void + let reject!: (reason: Error) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} +function execution(id: string, workflowId = workflow.id): WorkflowExecution { + return { ...workflowRunningExecution, id, workflow_id: workflowId } +} +function detail( + value: WorkflowExecution, + status: WorkflowExecution['status'] = 'passed', +): WorkflowExecutionDetail { + return { execution: { ...value, status }, nodes: [], children: [] } +} +async function setup() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + function Wrapper({ children }: { children: ReactNode }) { + return ( + + + + + {children} + + + + + ) + } + const hook = renderHook(() => useWorkflows(workflow.id), { wrapper: Wrapper }) + await waitFor(() => expect(hook.result.current.canEdit).toBe(true)) + await waitFor(() => expect(hook.result.current.selectedWorkflow?.id).toBe(workflow.id)) + act(() => hook.result.current.setEnvironmentSelection(environment.id)) + return hook +} +function callbacks(id: string) { + const call = vi.mocked(useExecutionEvents).mock.calls.findLast(([active]) => active === id) + if (!call) throw new Error(`No subscription for ${id}`) + return { event: call[2], reconcile: call[3]! } +} +beforeEach(() => { + context.projectId = project.id + authenticateTestUser(user) + vi.clearAllMocks() + vi.mocked(getWorkflowExecution).mockImplementation(async (_project, id) => + detail(execution(id), 'running'), + ) +}) +afterEach(() => { + vi.useRealTimers() + localStorage.clear() + useAuthStore.setState({ user: null }) +}) + +it('does not let a completed request from workflow A replace the active run of workflow B', async () => { + const oldResult = pending() + const runA = execution('run-a') + const runB = execution('run-b', 'workflow-b') + vi.mocked(executeWorkflow).mockResolvedValueOnce(runA).mockResolvedValueOnce(runB) + const hook = await setup() + vi.useFakeTimers() + await act(async () => hook.result.current.execute()) + vi.mocked(getWorkflowExecution).mockReturnValueOnce(oldResult.promise) + await act(async () => vi.advanceTimersByTime(500)) + act(() => hook.result.current.setWorkflowSelection('workflow-b')) + await act(async () => hook.result.current.execute()) + await act(async () => oldResult.resolve(detail(runA))) + expect(hook.result.current.workflowId).toBe('workflow-b') + expect(hook.result.current.activeExecutionId).toBe('run-b') + expect(hook.result.current.runtimeExecution?.id).toBe('run-b') + expect(hook.result.current.lastResult).toBeNull() +}) + +it('ignores a delayed start response after selecting another workflow', async () => { + const start = pending() + vi.mocked(executeWorkflow).mockReturnValueOnce(start.promise) + const hook = await setup() + let operation!: Promise + act(() => { + operation = hook.result.current.execute() + }) + act(() => hook.result.current.setWorkflowSelection('workflow-b')) + await act(async () => { + start.resolve(execution('run-a')) + await operation + }) + expect(hook.result.current.workflowId).toBe('workflow-b') + expect(hook.result.current.workspaceMode).toBe('draft') + expect(hook.result.current.activeExecutionId).toBeNull() +}) + +it('keeps the newest start when two runs of the same workflow return out of order', async () => { + const first = pending() + const second = pending() + vi.mocked(executeWorkflow).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const hook = await setup() + let older!: Promise, newer!: Promise + act(() => { + older = hook.result.current.execute() + }) + act(() => { + newer = hook.result.current.execute() + }) + await act(async () => { + second.resolve(execution('run-new')) + await newer + }) + await act(async () => { + first.resolve(execution('run-old')) + await older + }) + expect(hook.result.current.activeExecutionId).toBe('run-new') + expect(hook.result.current.runtimeExecution?.id).toBe('run-new') +}) + +it('ignores delayed events and reconciliation from a superseded run', async () => { + vi.mocked(executeWorkflow) + .mockResolvedValueOnce(execution('run-old')) + .mockResolvedValueOnce(execution('run-new')) + const hook = await setup() + await act(async () => hook.result.current.execute()) + const previous = callbacks('run-old') + const reconciliation = pending() + vi.mocked(getWorkflowExecution).mockReturnValueOnce(reconciliation.promise) + let operation!: Promise + act(() => { + operation = previous.reconcile('run-old') + }) + await act(async () => hook.result.current.execute()) + act(() => + previous.event({ + type: 'node.status', + execution_id: 'run-old', + node_id: 'api', + node_status: 'failed', + attempts: 1, + } as ExecutionEvent), + ) + await act(async () => { + reconciliation.resolve(detail(execution('run-old'))) + await operation + }) + expect(hook.result.current.activeExecutionId).toBe('run-new') + expect(hook.result.current.nodeStatuses).toMatchObject({ api: 'pending' }) + expect(hook.result.current.lastResult).toBeNull() +}) + +it('keeps terminal server evidence when a node event arrives after completion', async () => { + const run = execution('run-complete') + vi.mocked(executeWorkflow).mockResolvedValueOnce(run) + const hook = await setup() + await act(async () => hook.result.current.execute()) + const previous = callbacks(run.id) + const finished = detail(run) + vi.mocked(getWorkflowExecution).mockResolvedValueOnce(finished) + await act(async () => previous.reconcile(run.id)) + act(() => + previous.event({ + type: 'node.status', + execution_id: run.id, + node_id: 'api', + node_status: 'running', + attempts: 1, + } as ExecutionEvent), + ) + expect(hook.result.current.lastResult).toEqual(finished) + expect(hook.result.current.nodeStatuses).toEqual({}) + expect(hook.result.current.activeExecutionId).toBeNull() +}) + +it.each(['execute', 'rerun'] as const)( + 'preserves the terminal evidence while a replacement %s request is pending or rejected', + async (kind) => { + const run = execution('run-finished') + vi.mocked(executeWorkflow).mockResolvedValueOnce(run) + const hook = await setup() + await act(async () => hook.result.current.execute()) + const finished = detail(run, 'failed') + vi.mocked(getWorkflowExecution).mockResolvedValueOnce(finished) + await act(async () => callbacks(run.id).reconcile(run.id)) + const request = pending() + if (kind === 'execute') vi.mocked(executeWorkflow).mockReturnValueOnce(request.promise) + else vi.mocked(rerunFailedWorkflowItems).mockReturnValueOnce(request.promise) + let operation!: Promise + act(() => { + operation = ( + kind === 'execute' + ? hook.result.current.execute() + : hook.result.current.rerunFailedItems(run.id, { + loop_node_id: 'loop', + input_indices: [0], + upstream_resource_status: 'unverified', + write_retry_strategy: 'reject', + }) + ).catch(() => undefined) + }) + expect(hook.result.current.lastResult).toEqual(finished) + expect(hook.result.current.runtimeExecution?.id).toBe(run.id) + await act(async () => { + request.reject(new Error('replacement request rejected')) + await operation + }) + expect(hook.result.current.lastResult).toEqual(finished) + expect(hook.result.current.runtimeExecution?.id).toBe(run.id) + }, +) + +it.each(['project', 'user', 'unmount'] as const)( + 'invalidates an in-flight completion on %s changes', + async (change) => { + vi.mocked(executeWorkflow).mockResolvedValueOnce(execution('run-a')) + const hook = await setup() + await act(async () => hook.result.current.execute()) + const old = callbacks('run-a') + const completion = pending() + vi.mocked(getWorkflowExecution).mockReturnValueOnce(completion.promise) + let operation!: Promise + act(() => { + operation = old.reconcile('run-a') + }) + if (change === 'unmount') hook.unmount() + else + act(() => { + if (change === 'project') context.projectId = 'project-b' + else authenticateTestUser({ ...user, id: 'user-b' }) + hook.rerender() + }) + const requests = vi.mocked(getWorkflowExecution).mock.calls.length + await act(async () => { + completion.resolve(detail(execution('run-a'))) + await operation + }) + expect(getWorkflowExecution).toHaveBeenCalledTimes(requests) + if (change !== 'unmount') { + expect(hook.result.current.lastResult).toBeNull() + expect(hook.result.current.activeExecutionId).toBeNull() + } + }, +) + +it('does not adopt a delayed failed-item rerun after switching workflows', async () => { + const response = pending() + vi.mocked(rerunFailedWorkflowItems).mockReturnValueOnce(response.promise) + const hook = await setup() + let operation!: Promise + act(() => { + operation = hook.result.current.rerunFailedItems('run-a', { + loop_node_id: 'loop', + input_indices: [0], + upstream_resource_status: 'unverified', + write_retry_strategy: 'reject', + }) + }) + act(() => hook.result.current.setWorkflowSelection('workflow-b')) + await act(async () => { + response.resolve(execution('derived-a')) + await operation + }) + expect(hook.result.current.workspaceMode).toBe('draft') + expect(hook.result.current.activeExecutionId).toBeNull() +}) diff --git a/frontend/src/features/workflows/workflow-runtime.ts b/frontend/src/features/workflows/workflow-runtime.ts new file mode 100644 index 00000000..037fce89 --- /dev/null +++ b/frontend/src/features/workflows/workflow-runtime.ts @@ -0,0 +1,102 @@ +import type { ExecutionEvent, WorkflowDefinition, WorkflowNodeExecution } from '../../lib/api' + +export function initialNodeExecutions( + executionId: string, + definition: WorkflowDefinition, +): Record { + return Object.fromEntries( + definition.nodes.map((node) => [ + node.id, + { + id: `${executionId}:${node.id}`, + node_id: node.id, + node_type: node.type, + name: node.name, + status: 'pending', + attempts: 0, + output: null, + result: null, + error_code: null, + error_message: null, + started_at: null, + }, + ]), + ) +} + +export type NodeExecutionEvent = ExecutionEvent & { + node_id: string + node_status: WorkflowNodeExecution['status'] +} + +export function mergeExecutionEvent( + current: WorkflowNodeExecution | undefined, + event: NodeExecutionEvent, +): WorkflowNodeExecution { + const base = current ?? emptyEventNode(event) + const resultFields = event.result ? { output: event.result.output, result: event.result } : {} + return { + ...base, + ...resultFields, + status: event.node_status, + attempts: event.attempts, + error_code: event.error_code, + error_message: event.error_message, + started_at: eventStartedAt(base, event), + completed_at: eventCompletedAt(base, event), + } +} + +function emptyEventNode(event: NodeExecutionEvent): WorkflowNodeExecution { + return { + id: `${event.execution_id}:${event.node_id}`, + node_id: event.node_id, + node_type: event.node_type ?? 'unknown', + name: event.node_name ?? '未命名节点', + status: 'pending', + attempts: 0, + output: null, + result: null, + error_code: null, + error_message: null, + started_at: null, + } +} + +function eventStartedAt( + current: WorkflowNodeExecution, + event: NodeExecutionEvent, +): string | null | undefined { + if (current.started_at) return current.started_at + return event.node_status === 'running' ? event.emitted_at : null +} + +function eventCompletedAt( + current: WorkflowNodeExecution, + event: NodeExecutionEvent, +): string | undefined { + if (isTerminalNodeStatus(event.node_status)) return event.emitted_at + return current.completed_at +} + +export function snapshotDefinition(snapshot: Record): WorkflowDefinition | null { + const workflow = snapshot.workflow + if (!isRecord(workflow)) return null + const definition = workflow.definition + if ( + !isRecord(definition) || + !Array.isArray(definition.nodes) || + !Array.isArray(definition.edges) + ) { + return null + } + return definition as WorkflowDefinition +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isTerminalNodeStatus(status: WorkflowNodeExecution['status']): boolean { + return !['pending', 'running'].includes(status) +} diff --git a/frontend/src/features/workflows/workflow-save-concurrency.test.tsx b/frontend/src/features/workflows/workflow-save-concurrency.test.tsx index c397c508..12dab1ed 100644 --- a/frontend/src/features/workflows/workflow-save-concurrency.test.tsx +++ b/frontend/src/features/workflows/workflow-save-concurrency.test.tsx @@ -10,7 +10,13 @@ import { DraftSessionProvider } from '../drafts/DraftSessionProvider' import { useWorkflows } from './use-workflows' import { useAuthStore } from '../auth/auth-store' import { authenticateTestUser } from '../../test/auth' -import { workflow, user as actor } from '../../test/fixtures' +import { + environment, + workflow, + workflowVersion, + workflowRunningExecution, + user as actor, +} from '../../test/fixtures' vi.mock('../projects/use-project-context', () => ({ useProjectContext: () => ({ @@ -29,7 +35,7 @@ vi.mock('./workflow-service', () => ({ getWorkflow: vi.fn(async (_project: string, id: string) => id === workflow.id ? workflow : secondWorkflow, ), - listEnvironments: async () => [], + listEnvironments: async () => [environment], listApis: async () => ({ items: [] }), listArtifacts: async () => ({ items: [] }), listWorkflowExecutions: async () => ({ items: [] }), @@ -68,6 +74,9 @@ function Editor() { const [closeResult, setCloseResult] = useState('等待关闭') return ( <> + + {state.selectedWorkflow?.current_version ?? '未发布'} + {closeResult}