前端四处直接伤人的地方:输入法回车、覆盖别的图、停止不停、整站白屏

1. 中文输入法选词的回车被当成发送。问数据页输入框不判组字状态(Composer 判
   了,这里没有):用户每选一个词,半句问题就被送去建图、跑图。同样的漏洞还在
   会话重命名、知识库搜索、{{ }} 补全、⌘Enter 运行上。收成 ui.isComposing,六处
   统一——Safari 结束组字那一下 isComposing 已经是 false、keyCode 还是 229,两个
   都看。

2. 「在画布里打开」后 ⌘S 覆盖上一次打开的工作流。它只调了 setGraph:换掉画布
   上的节点,store 里的 workflow 还是上一张图,保存就把那张图整张覆盖了(旧版本
   还在版本表里,界面上没有入口找回);没打开过图时 ⌘S 则什么都不做。现在建成
   一张新工作流(以这一轮的问题命名)、走它自己的地址,原来的图不受影响。

3. 问数据页的「停止」不停。它只关了自己这头的事件流,从不调 runs.cancel,而后端
   断开订阅不会取消运行——模型和 SQL 其实还在跑、还在计费,界面上却已经显示
   "已取消"。等人工审批的那一轮照旧不动。

4. Copilot 编出一个不存在的节点类型,整站白屏、没保存的编辑一起丢。三层都补:
   - 后端 _apply_op 拒收未知类型,不进图、不转发,最终校验里说明跳过了哪一步;
   - 前端流式加节点时再拦一道;sourceHandles、属性面板遇到未知类型不再崩;
   - 全站加页面级 ErrorBoundary:导航栏留着,换一页自动恢复;编辑中的图在 store
     里,点「重试」时它还在。

新增 scripts/check-guards.mjs(真浏览器,拦下所有非 GET 请求,不写库不花钱):
组字回车与 keyCode 229 不发送、普通回车照常发送、未知类型不白屏、渲染真出错时
只坏这一页且换页恢复,连跑三次全绿。等待一律等事件,只有"不该发生"的断言用
窗口。后端补了未知类型不进图的用例,413 → 414 passed;tsc 通过,decode / stream
/ canvas-layout 三个检查不回归。
This commit is contained in:
ThearchyHelios
2026-09-25 15:41:30 +08:00
parent 4559c96fdd
commit 429398a156
17 changed files with 236 additions and 28 deletions
+1
View File
@@ -433,6 +433,7 @@ node scripts/check-stream.mjs # 助手流组件渲染是否正确
node scripts/check-ui.mjs # 真浏览器端到端:URL 寻址、会话、运行详情
node scripts/check-canvas-layout.mjs # 排版与连线几何:线有没有叠、有没有穿过卡片
node scripts/check-canvas-fx.mjs # 运行态动效:协作矩阵、并行度、命中的出口、关掉动效
node scripts/check-guards.mjs # 护栏:输入法回车、未知节点类型、页面级错误边界(不写库)
node scripts/e2e-check.mjs # 冒烟:打开画布 → 选模板 → 运行 → 观察高亮
```
+17 -1
View File
@@ -541,12 +541,17 @@ def _parse_op_line(line: str) -> dict[str, Any] | None:
return obj
_NODE_TYPES = frozenset(t.value for t in NodeType)
def _apply_op(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], op: dict[str, Any]) -> bool:
"""把一个操作应用到服务端维护的图状态。返回是否真的改了图。"""
kind = op.get("op")
if kind == "add_node":
node = op.get("node") or {}
if not node.get("id") or not node.get("type"):
# 类型不认识的节点不进图、也不转给前端:前端按类型查节点定义,查不到
# 以前是整站白屏,连带没保存的编辑一起丢
if not node.get("id") or node.get("type") not in _NODE_TYPES:
return False
nodes[node["id"]] = {
"id": node["id"],
@@ -725,6 +730,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
async def event_stream():
explanation = ""
seen_ops: list[dict[str, Any]] = []
skipped_types: list[str] = []
# 建完要不要跑,由模型在 done.run 里表态
autorun = True
yield f"data: {json.dumps({'op': 'model', 'model': model_id}, ensure_ascii=False)}\n\n"
@@ -747,6 +753,10 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
autorun = op.get("run") is not False
seen_ops.append(op)
changed = _apply_op(nodes, edges, op)
if kind == "add_node" and not changed:
bad_type = (op.get("node") or {}).get("type")
if bad_type and bad_type not in _NODE_TYPES:
skipped_types.append(str(bad_type))
if changed or kind in ("plan", "done"):
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
except Exception as e: # noqa: BLE001
@@ -780,6 +790,12 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges})
)
issues = [i.model_dump() for i in validate_graph(spec).issues]
# 跳过的节点要说出来,不能安静地少一步
issues += [
{"level": "warning", "node_id": None, "edge_id": None,
"message": f"模型写了一个不存在的节点类型「{t}」,这一步已跳过"}
for t in dict.fromkeys(skipped_types)
]
final = {
"op": "final",
"graph": spec.model_dump(mode="json"),
+14
View File
@@ -113,3 +113,17 @@ async def test_heartbeat_phase_follows_progress(monkeypatch):
assert beats, "add_node 之后的停顿也该有心跳"
assert beats[0]["phase"] == "building", "放过节点了,阶段不该还停在 planning"
def test_unknown_node_types_never_reach_the_canvas():
"""模型编一个不存在的节点类型:不进图、不转给前端。
前端按类型查节点定义,查不到以前是整站白屏、连带没保存的编辑一起丢。
"""
nodes: dict = {}
edges: list = []
ghost = {"op": "add_node", "node": {"id": "x", "type": "no_such_type", "config": {}}}
real = {"op": "add_node", "node": {"id": "a", "type": "llm", "config": {}}}
assert cp._apply_op(nodes, edges, ghost) is False # 生成流只转发 changed 的操作
assert cp._apply_op(nodes, edges, real) is True
assert set(nodes) == {"a"}
+5 -2
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { BookOpen, FlaskConical, History, MessageSquare, Settings, Wrench } from 'lucide-react'
import clsx from 'clsx'
import { api } from './api/client'
@@ -10,7 +10,7 @@ import { RunsPage } from './pages/RunsPage'
import { ToolsPage } from './pages/ToolsPage'
import { KnowledgePage } from './pages/KnowledgePage'
import { SettingsPage, applyTheme } from './pages/SettingsPage'
import { Spinner } from './components/ui'
import { ErrorBoundary, Spinner } from './components/ui'
const NAV = [
{ to: '/chat', label: '问数据', icon: MessageSquare },
@@ -23,6 +23,7 @@ const NAV = [
export default function App() {
const { loaded, refresh, refreshApprovals, approvals } = useCatalog()
const location = useLocation()
useEffect(() => {
void refresh()
@@ -79,6 +80,7 @@ export default function App() {
「没带 id」是真要单独处理的一种情况(落到上次那个、或列表第一个),
藏在一个问号里下次就没人记得它存在了。
导航高亮不用动——NavLink 默认前缀匹配,/chat/abc 照样点亮「问数据」。*/}
<ErrorBoundary resetKey={location.pathname}>
<Routes>
{/* 默认落到对话页:多数人要的是答案,画布留给要自己编排的人 */}
<Route path="/" element={<Navigate to="/chat" replace />} />
@@ -97,6 +99,7 @@ export default function App() {
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/:tab" element={<SettingsPage />} />
</Routes>
</ErrorBoundary>
</main>
</div>
)
+10
View File
@@ -30,6 +30,16 @@ export function Inspector() {
}
const def = NODE_DEFS[node.data.nodeType]
if (!def) {
// 这个界面版本不认识的类型:给出实情和唯一能做的事,而不是读 def.fields 崩掉整页
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
<div className="text-xs">不认识的节点类型「{node.data.nodeType}」</div>
<div className="text-[11px] text-faint">这个版本的界面编辑不了它,运行时后端也会拒绝这张图。</div>
<button className="btn btn-sm" onClick={() => removeNode(node.id)}>删除这个节点</button>
</div>
)
}
const config = node.data.config ?? {}
const setConfig = (key: string, value: any) =>
updateNode(node.id, { config: { ...config, [key]: value } })
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { isComposing } from '../components/ui'
import { useStudio } from '../store/studio'
import type { Variable } from '../types'
@@ -74,7 +75,7 @@ export function TemplateText({
// 弹层开着的时候这几个键有原生行为(换行、移光标、跳焦点),得拦下来
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => (i + 1) % matches.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => (i - 1 + matches.length) % matches.length) }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insert(matches[active].path) }
else if ((e.key === 'Enter' || e.key === 'Tab') && !isComposing(e)) { e.preventDefault(); insert(matches[active].path) }
else if (e.key === 'Escape') {
// 只关弹层,别让它冒泡上去——外面 InspectorSheet 也在听 Escape
e.preventDefault()
+3
View File
@@ -438,6 +438,9 @@ export const NODE_CATEGORIES = ['起止', '模型', '执行', '控制', '上下
/** branch / loop / human 的出口由配置决定,这里统一算出来。 */
export function sourceHandles(type: NodeType, config: Record<string, any>): HandleDef[] {
const def = NODE_DEFS[type]
// 不认识的类型给一个普通出口,连线还能接上。以前这里直接读 def.sources,
// 一个未知类型就让画布连带整站白屏
if (!def) return [{ id: 'out', label: '' }]
if (def.sources) return def.sources
if (type === 'branch') {
const cases = (config.cases ?? []) as { key?: string; label?: string }[]
+54 -2
View File
@@ -1,9 +1,61 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Component, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import type { ReactNode } from 'react'
import type { ErrorInfo, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
import { AlertCircle, CheckCircle2, Info, Loader2, X } from 'lucide-react'
import clsx from 'clsx'
/**
* 输入法正在组字吗。组字期间的回车是"选词",不是"提交"——不判这一条,中文
* 用户每选一个词就把半句话发出去一次(问数据页就这么把半句问题送去建图、跑图)。
* Safari 在结束组字的那一下 keydown 上 isComposing 已经是 false,keyCode 还是
* 229,两个都得看。所有"回车即提交"的输入框都要过这一关。
*/
export function isComposing(e: ReactKeyboardEvent): boolean {
return e.nativeEvent.isComposing || e.keyCode === 229
}
/**
* 页面级错误边界。没有它,任何一个组件渲染时抛错,React 会卸掉整棵树——整站
* 白屏,导航栏也没了,用户只能刷新,没保存的编辑跟着丢。
*
* 包在路由外面:导航栏还在,换一页(resetKey 变了)就自动恢复。编辑中的图在
* store 里、不在组件树上,点「重试」重新渲染时它还在。
*/
export class ErrorBoundary extends Component<
{ resetKey?: string; children: ReactNode }, { error: Error | null }
> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('页面渲染出错', error, info.componentStack)
}
componentDidUpdate(prev: { resetKey?: string }) {
if (prev.resetKey !== this.props.resetKey && this.state.error) this.setState({ error: null })
}
render() {
const { error } = this.state
if (!error) return this.props.children
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
<AlertCircle size={22} className="text-[var(--err)]" />
<div className="text-sm font-medium">这一页出错了</div>
<div className="max-w-md break-all text-[11.5px] text-faint">{error.message}</div>
<div className="text-[11px] text-faint">没保存的编辑还在内存里:先点「重试」,恢复了就尽快保存。</div>
<div className="flex gap-2">
<button className="btn btn-sm" onClick={() => this.setState({ error: null })}>重试</button>
<button className="btn btn-sm btn-ghost" onClick={() => location.reload()}>刷新页面</button>
</div>
</div>
)
}
}
// -------------------------------------------------------------------------
// Toast
// -------------------------------------------------------------------------
+17 -10
View File
@@ -8,9 +8,8 @@ import { decodeCopilot, decodeRun } from '../run/decode'
import { useCatalog } from '../store/catalog'
import { useChat, type ChatTurn } from '../store/chat'
import { lastVisited, useConversations } from '../store/conversations'
import { useStudio } from '../store/studio'
import { ConversationList } from './ConversationList'
import { useToast } from '../components/ui'
import { isComposing, useToast } from '../components/ui'
/**
* 对话式入口。
@@ -37,7 +36,7 @@ export function ChatPage() {
const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined))
const [draft, setDraft] = useState('')
const sources = useDataSources()
const setGraph = useStudio((s) => s.setGraph)
const refreshCatalog = useCatalog((s) => s.refresh)
const toast = useToast()
// URL → store。这是 currentId 唯一的写入口,别处一律靠导航
@@ -124,12 +123,20 @@ export function ChatPage() {
}
/>
)}
onOpenGraph={(graph) => {
// 以前只把图放过去、让用户自己去点导航。有了路由就直接送过去——
// "已放到画布,切到「编排」继续改"是在让人替系统完成一次跳转
setGraph(graph)
navigate('/studio')
toast('已放到画布', 'ok')
onOpenGraph={async (graph, question) => {
// 建成一张新工作流,走它自己的地址。以前是 setGraph(graph):只换掉了画布
// 上的节点,store 里的 workflow 还是上一次打开的那张图——于是 ⌘S 把那张图
// 整张覆盖了(旧版本还在版本表里,界面上却没有入口找回);没打开过图时
// ⌘S 则什么都不做,也不说一声
try {
const name = `问数据:${(question ?? '').trim().slice(0, 24) || '未命名'}`
const created = await api.workflows.create({ name, graph })
void refreshCatalog()
navigate(`/studio/${created.id}`)
toast('已放到画布:新建了一个工作流,原来的图不受影响', 'ok')
} catch (e: any) {
toast(e?.message ?? '放到画布失败', 'error')
}
}}
footer={
<div className="shrink-0 border-t p-3">
@@ -146,7 +153,7 @@ export function ChatPage() {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter 发送,Shift+Enter 换行——对话框的通用约定
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send() }
if (e.key === 'Enter' && !e.shiftKey && !isComposing(e)) { e.preventDefault(); void send() }
}}
/>
{busy ? (
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import { isComposing } from '../components/ui'
import { useChat } from '../store/chat'
import { useConversations } from '../store/conversations'
@@ -142,7 +143,7 @@ function RenameField({ initial, onSubmit, onCancel }: {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSubmit(text)
if (e.key === 'Enter' && !isComposing(e)) onSubmit(text)
if (e.key === 'Escape') onCancel()
}}
autoFocus
+2 -2
View File
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import { BookOpen, Brain, Plus, Search, Sparkles, Trash2, Upload } from 'lucide-react'
import { api } from '../api/client'
import { useCatalog } from '../store/catalog'
import { Empty, Modal, Spinner, Tabs, useTabRoute, useToast } from '../components/ui'
import { Empty, isComposing, Modal, Spinner, Tabs, useTabRoute, useToast } from '../components/ui'
import type { KbDocument, MemoryItem, Skill } from '../types'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上
@@ -375,7 +375,7 @@ function KbList() {
<div className="mb-2 flex items-center gap-2">
<input className="field" placeholder="测试检索效果…" value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && search()} />
onKeyDown={(e) => e.key === 'Enter' && !isComposing(e) && search()} />
<button className="btn" onClick={search}><Search size={12} /> 检索</button>
</div>
<label className="flex items-center gap-2 text-[11px] text-faint">
+4 -4
View File
@@ -66,7 +66,7 @@ export function AssistantStream({
empty?: ReactNode
/** 这一轮要不要插审批卡——插槽而不是内建,因为不同页面接流的方式不同 */
approvalsFor?: (turn: StreamTurn) => ReactNode
onOpenGraph?: (graph: any) => void
onOpenGraph?: (graph: any, question?: string) => void
footer?: ReactNode
}) {
const bottomRef = useRef<HTMLDivElement>(null)
@@ -99,7 +99,7 @@ export function AssistantStream({
function TurnCard({ turn, dense, approvals, onOpenGraph }: {
turn: StreamTurn; dense: boolean; approvals?: ReactNode
onOpenGraph?: (graph: any) => void
onOpenGraph?: (graph: any, question?: string) => void
}) {
const running = turn.phase === 'running'
@@ -182,8 +182,8 @@ function TurnCard({ turn, dense, approvals, onOpenGraph }: {
)}
{turn.graph && (
<GraphPeek graph={turn.graph} note={turn.graphNote}
dense={dense} onOpen={onOpenGraph} />
<GraphPeek graph={turn.graph} note={turn.graphNote} dense={dense}
onOpen={onOpenGraph && ((g) => onOpenGraph(g, turn.question))} />
)}
</div>
</div>
+2 -2
View File
@@ -4,7 +4,7 @@ import clsx from 'clsx'
import { api } from '../api/client'
import { useCatalog, modelOptions } from '../store/catalog'
import { useStudio } from '../store/studio'
import { Spinner } from '../components/ui'
import { isComposing, Spinner } from '../components/ui'
/**
* Copilot 的输入。
@@ -114,7 +114,7 @@ export function Composer({ hero, onFocusChange }: {
onKeyDown={(e) => {
// 输入法组字期间的回车是"选词",不是"发送"。不判 isComposing 的话
// 中文用户每打一个词就发出去一次
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
if (e.key === 'Enter' && !e.shiftKey && !isComposing(e)) {
e.preventDefault()
send()
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Play, RotateCw, ShieldCheck, Square } from 'lucide-react'
import { useStudio } from '../store/studio'
import { Spinner, useToast } from '../components/ui'
import { isComposing, Spinner, useToast } from '../components/ui'
/**
* 工具栏上的运行控件。
@@ -151,7 +151,7 @@ export function RunControl() {
placeholder={field.required ? '必填' : '可留空'}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void launch(false)
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !isComposing(e)) void launch(false)
}}
/>
</div>
+5
View File
@@ -272,6 +272,11 @@ export const useChat = create<ChatState>((set, get) => ({
const last = turns[turns.length - 1]
last?.cancel?.()
get().cancel?.()
// 上面两句只是把自己这头的事件流关了。运行在后端,断开订阅不会让它停:
// 以前点了「停止」,模型和 SQL 其实还在跑、还在计费,界面上却已经显示"已取消"
if (last?.run?.id && !['done', 'error', 'waiting'].includes(last.phase)) {
void api.runs.cancel(last.run.id).catch(() => undefined) // 已经跑完会回 409,无妨
}
if (last?.serverId && !['done', 'error', 'waiting'].includes(last.phase)) {
void api.conversations
.patchTurn(conversationId, last.serverId, { status: 'error', error: '已取消' })
+3 -1
View File
@@ -562,7 +562,9 @@ export const useStudio = create<StudioState>((set, get) => ({
break
case 'add_node': {
const n = op.node
if (!n?.id || !n?.type) break
// 类型不认识就不放上画布:节点卡片、属性面板都按类型查定义,查不到
// 以前是整站白屏、连带没保存的编辑一起丢。后端也会拦,这里是第二道
if (!n?.id || !n?.type || !NODE_DEFS[n.type as NodeType]) break
const node: FlowNode = {
id: n.id, type: 'card', position: { x: 0, y: 0 },
data: { nodeType: n.type, label: n.label ?? '', config: n.config ?? {} },
+93
View File
@@ -0,0 +1,93 @@
// 几道"坏了就是整页事故"的护栏:输入法回车、未知节点类型、页面级错误边界。
//
// 这三样出事时的样子都很重:中文用户每选一个词就把半句话发去建图、跑图;
// Copilot 编出一个不存在的节点类型,整站白屏、没保存的编辑一起丢;任何组件渲染
// 抛错,React 卸掉整棵树,连导航栏都没了。而 decode / stream / ui 三层检查对它们
// 一个字都不会说——它们喂的都是合法数据。
//
// 探针拦掉所有非 GET 的 /api 请求:不写库、不调模型、不花钱,随时可以跑。
// 往画布 store 里注入节点走的是 dev 构建挂在 window.__studio 上的那一份。
// 跑之前前端得起着:./scripts/dev.sh
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
// 不用 playwright install:它既下不动也会动到已有缓存。系统 Chrome 就够了。
const CHROME = process.env.CHROME_PATH
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const browser = await chromium.launch({ executablePath: CHROME })
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } })
const writes = []
await page.route('**/api/**', (route) => {
const r = route.request()
if (r.method() === 'GET') return route.continue()
writes.push(`${r.method()} ${new URL(r.url()).pathname}`)
return route.abort()
})
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
// 等到页面上出现某段文字为止。只在"应该发生"的断言上用;"不应该发生"的
// 只能等一个窗口——那是它的本性,不是偷懒
const shows = (text) => page.getByText(text).first().waitFor({ timeout: 8000 }).then(() => true, () => false)
console.log('=== 问数据页:输入法组字时的回车 ===')
await page.goto(`${WEB}/chat`, { waitUntil: 'networkidle' })
const box = page.locator('textarea').first()
await box.fill('帮我查一下本周')
const fire = (init) => box.evaluate((el, init) => el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })), init)
writes.length = 0
await fire({ key: 'Enter', isComposing: true })
await page.waitForTimeout(400)
check('isComposing 的回车不发送', writes.length === 0, writes.join(', '))
await fire({ key: 'Enter', keyCode: 229 })
await page.waitForTimeout(400)
check('Safari 式 keyCode=229 的回车不发送', writes.length === 0, writes.join(', '))
check('草稿还在输入框里', (await box.inputValue()) === '帮我查一下本周')
const sent = page.waitForRequest((r) => r.method() !== 'GET' && r.url().includes('/api/'), { timeout: 8000 })
.then(() => true, () => false)
await fire({ key: 'Enter' })
await sent
check('普通回车照常发送(请求被探针拦下)', writes.length > 0, writes.join(', '))
console.log('\n=== 画布:未知节点类型不再白屏 ===')
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
// 画布会自己跳到第一张图,等它真的把节点装进 store 再注入
await page.waitForFunction(() => window.__studio?.getState().nodes.length > 0, null, { timeout: 8000 })
await page.evaluate(() => {
const s = window.__studio.getState()
window.__studio.setState({ nodes: [...s.nodes, { id: 'ghost', type: 'card', position: { x: 60, y: 60 },
data: { nodeType: 'no_such_type', label: '幽灵节点', config: {} } }] })
window.__studio.getState().select('ghost')
})
check('属性面板说清楚是未知类型', await shows('不认识的节点类型'))
check('画布还在(没有白屏)', (await page.locator('body').innerText()).includes('幽灵节点'))
check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '')
console.log('\n=== 错误边界:渲染真出错时只坏这一页 ===')
errors.length = 0
await page.evaluate(() => {
const s = window.__studio.getState()
window.__studio.setState({ nodes: [...s.nodes, { id: 'broken', type: 'card', position: { x: 0, y: 0 }, data: null }] })
})
check('出错时给出错误页而不是白屏', await shows('这一页出错了'))
const after = await page.locator('body').innerText()
check('导航栏还在', after.includes('问数据') && after.includes('编排'))
await page.evaluate(() => {
const s = window.__studio.getState()
window.__studio.setState({ nodes: s.nodes.filter((n) => n.id !== 'broken' && n.id !== 'ghost') })
})
await page.getByText('问数据', { exact: true }).first().click()
await page.waitForURL(/\/chat/)
check('换一页就恢复', await page.getByText('这一页出错了').waitFor({ state: 'detached', timeout: 8000 })
.then(() => true, () => false))
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 护栏全部通过')
process.exit(failed ? 1 : 0)