From d312a33cf3271c2a79c5a053143856dc15d43373 Mon Sep 17 00:00:00 2001 From: tanzui <305375675@.com> Date: Fri, 9 Oct 2026 10:11:49 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(agent):=20=E7=94=BB=E5=B8=83=E6=9D=83?= =?UTF-8?q?=E9=99=90=20-=20=E4=BD=BF=E7=94=A8=E7=9C=9F=E5=AE=9E=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E5=BD=92=E5=B1=9E=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/cloud_agent_permissions_test.go | 111 ++++++++++++++++++ .../internal/app/cloud_agent_pi_request.go | 55 ++------- 2 files changed, 124 insertions(+), 42 deletions(-) create mode 100644 backend/internal/app/cloud_agent_permissions_test.go diff --git a/backend/internal/app/cloud_agent_permissions_test.go b/backend/internal/app/cloud_agent_permissions_test.go new file mode 100644 index 000000000..9dd12b750 --- /dev/null +++ b/backend/internal/app/cloud_agent_permissions_test.go @@ -0,0 +1,111 @@ +package app + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" +) + +// agentPermissionsDB 为权限测试创建数据库,只迁移真实画布表。 +// CI 使用临时 SQLite;Windows 可提供 PostgreSQL DSN,数据只写入独立临时 schema。 +func agentPermissionsDB(t *testing.T) *gorm.DB { + t.Helper() + options := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)} + var db *gorm.DB + var err error + if dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN"); dsn != "" { + admin, openErr := gorm.Open(postgres.Open(dsn), options) + if openErr != nil { + t.Fatal(openErr) + } + adminSQL, sqlErr := admin.DB() + if sqlErr != nil { + t.Fatal(sqlErr) + } + t.Cleanup(func() { _ = adminSQL.Close() }) + schema := fmt.Sprintf("agent_permissions_%d", time.Now().UnixNano()) + if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil { + t.Error(err) + } + }) + parsed, parseErr := url.Parse(dsn) + if parseErr != nil { + t.Fatal(parseErr) + } + query := parsed.Query() + query.Set("search_path", schema) + parsed.RawQuery = query.Encode() + db, err = gorm.Open(postgres.Open(parsed.String()), options) + } else { + db, err = gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "permissions.db")), options) + } + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.CanvasProject{}); err != nil { + t.Fatal(err) + } + return db +} + +func TestAgentPermissionsUseCanvasProjects(t *testing.T) { + db := agentPermissionsDB(t) + s := &Service{repo: repository.New(db)} + for _, canvas := range []model.CanvasProject{ + {ID: "independent", UserID: "owner", PayloadJSON: `{}`}, + {ID: "associated", UserID: "owner", ProjectID: "domain-project", PayloadJSON: `{}`}, + } { + if err := db.Create(&canvas).Error; err != nil { + t.Fatal(err) + } + } + if db.Migrator().HasTable("canvas") { + t.Fatal("权限测试不能创建旧 canvas 表") + } + for _, tc := range []struct { + user, canvas string + allowed bool + }{ + {"owner", "independent", true}, {"owner", "associated", true}, + {"other", "independent", false}, {"other", "associated", false}, + {"owner", "missing", false}, {"owner", "domain-project", false}, + } { + t.Run(tc.user+"/"+tc.canvas, func(t *testing.T) { + permissions := s.buildPermissionsConfig(tc.user, tc.canvas) + for _, key := range []string{"canReadCanvas", "canWriteCanvas", "canDeleteNodes", "canCreateNodes", "canMoveNodes", "canDuplicateNodes", "canManageRelations", "canInviteUsers", "canExportCanvas"} { + if permissions[key] != tc.allowed { + t.Errorf("%s=%v, want %t", key, permissions[key], tc.allowed) + } + } + }) + } + // 缺表模拟数据库读取故障,不能把读取失败当成只读或导出许可。 + if err := db.Migrator().DropTable(&model.CanvasProject{}); err != nil { + t.Fatal(err) + } + permissions := s.buildPermissionsConfig("owner", "independent") + for _, key := range []string{"canReadCanvas", "canWriteCanvas", "canExportCanvas"} { + if permissions[key] != false { + t.Errorf("数据库故障时 %s=%v", key, permissions[key]) + } + } +} diff --git a/backend/internal/app/cloud_agent_pi_request.go b/backend/internal/app/cloud_agent_pi_request.go index 2bc4f37db..ed6e5a022 100644 --- a/backend/internal/app/cloud_agent_pi_request.go +++ b/backend/internal/app/cloud_agent_pi_request.go @@ -170,15 +170,16 @@ func (s *Service) buildFeaturesConfig(state *cloudAgentRuntime) map[string]any { } } -// buildPermissionsConfig 构建权限配置 +// buildPermissionsConfig 根据当前账号的画布归属生成运行时权限。 +// userID 是登录账号,canvasID 是画布 ID(不是所属项目 ID);查询失败时拒绝访问。 func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any { - // 从数据库读取实际权限 - canvas, err := s.repo.GetCanvas(userID, canvasID) + // 与画布读写工具共用账号归属查询,不访问旧的 Canvas 扩展模型。 + _, err := s.repo.CanvasProjectForUser(userID, canvasID) if err != nil { log.Printf("[Agent] failed to get canvas for permissions: %v", err) // 返回最小权限集 return map[string]any{ - "canReadCanvas": true, + "canReadCanvas": false, "canWriteCanvas": false, "canDeleteNodes": false, "canCreateNodes": false, @@ -186,51 +187,21 @@ func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any "canDuplicateNodes": false, "canManageRelations": false, "canInviteUsers": false, - "canExportCanvas": true, + "canExportCanvas": false, "maxTokenBudget": 200000, "maxSteps": 50, } } - // 检查用户是否是画布所有者 - isOwner := canvas.UserID == userID - - // 检查协作权限 - canWrite := isOwner - canDelete := isOwner - canInvite := isOwner - - if canvas.Metadata != nil { - if collaborators, ok := canvas.Metadata["collaborators"].([]any); ok { - for _, collab := range collaborators { - if collabMap, ok := collab.(map[string]any); ok { - if collabUserID, _ := collabMap["userId"].(string); collabUserID == userID { - role, _ := collabMap["role"].(string) - switch role { - case "admin": - canWrite = true - canDelete = true - canInvite = true - case "editor": - canWrite = true - case "viewer": - // 只读权限 - } - } - } - } - } - } - return map[string]any{ "canReadCanvas": true, - "canWriteCanvas": canWrite, - "canDeleteNodes": canDelete, - "canCreateNodes": canWrite, - "canMoveNodes": canWrite, - "canDuplicateNodes": canWrite, - "canManageRelations": canWrite, - "canInviteUsers": canInvite, + "canWriteCanvas": true, + "canDeleteNodes": true, + "canCreateNodes": true, + "canMoveNodes": true, + "canDuplicateNodes": true, + "canManageRelations": true, + "canInviteUsers": true, "canExportCanvas": true, "maxTokenBudget": 200000, "maxSteps": 50, From a3de731bf1c2d1014ef17bedc275a659a12550fd Mon Sep 17 00:00:00 2001 From: tanzui <305375675@.com> Date: Fri, 9 Oct 2026 10:11:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(agent):=20=E7=94=BB=E5=B8=83=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=20-=20=E5=A4=A7=E4=BA=8B=E4=BB=B6=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E5=88=B7=E6=96=B0=E9=81=BF=E5=85=8D=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=9B=9E=E6=BB=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/cloud_agent_canvas_event_size_test.go | 155 ++++++++++++++++++ .../internal/app/cloud_agent_canvas_events.go | 21 +++ .../internal/app/cloud_agent_runtime_state.go | 8 +- docs/content/docs/backend/http-api.mdx | 6 + docs/content/docs/progress/pending-test.mdx | 8 + 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 backend/internal/app/cloud_agent_canvas_event_size_test.go diff --git a/backend/internal/app/cloud_agent_canvas_event_size_test.go b/backend/internal/app/cloud_agent_canvas_event_size_test.go new file mode 100644 index 000000000..e6c92033f --- /dev/null +++ b/backend/internal/app/cloud_agent_canvas_event_size_test.go @@ -0,0 +1,155 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "yingce/backend/internal/database" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" +) + +// TestAgentApprovalLargeCanvasEvent 验证完整审批路径,不只检查事件生成 helper。 +// 所有数据保存在临时数据库中,禁止启动 Pi 或调用收费模型。 +func TestAgentApprovalLargeCanvasEvent(t *testing.T) { + for _, large := range []bool{false, true} { + name := "small" + if large { + name = "large" + } + t.Run(name, func(t *testing.T) { + db := agentPermissionsDB(t) + if err := db.AutoMigrate(database.Models()...); err != nil { + t.Fatal(err) + } + s := &Service{repo: repository.New(db), dataDir: t.TempDir(), disablePiRuntime: true} + if err := os.WriteFile(filepath.Join(s.dataDir, ".settings-key"), make([]byte, 32), 0o600); err != nil { + t.Fatal(err) + } + content := "保留正文" + if large { + content = strings.Repeat("分镜正文", 18000) + } + doc := map[string]any{"nodes": []any{map[string]any{"id": "story", "type": "text", "title": "原标题", "content": content}}, "connections": []any{}} + raw, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + profile := mustEncodeModelCapabilityConfig(t, DefaultModelCapabilityConfigForModel("chat-completion", "text-test")) + for _, row := range []any{ + &model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: string(raw)}, + &model.ModelChannel{ID: "channel", Scope: model.ChannelScopeSystem, Name: "测试", Enabled: true}, + &model.ChannelModel{ID: "cm", ChannelID: "channel", ModelKey: "text-test", Capability: "text", Protocol: model.ChannelInterfaceChatCompletion, CapabilityConfigJSON: profile, BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}, + &model.ChannelModelPriceTier{ID: "tier", ChannelModelID: "cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 100, PriceConfigured: true, Enabled: true}, + &model.CreditAccount{UserID: "user", AvailableMicrocredits: 10000}, + } { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + req := agentTestRequest() + req.PermissionMode = "request_approval" + root, err := s.CreateCloudAgentRun("user", req, "") + if err != nil { + t.Fatal(err) + } + run, state := agentInterjectionState(t, s, root.ID) + call := cloudAgentCall{ID: "rename-story"} + call.Function.Name = "canvas_apply_ops" + args, err := json.Marshal(map[string]any{"snapshotHash": cloudAgentCanvasHash(doc), "ops": []any{map[string]any{"type": "update_node", "id": "story", "patch": map[string]any{"title": "新标题"}}}}) + if err != nil { + t.Fatal(err) + } + call.Function.Arguments = string(args) + state.Calls = []cloudAgentCall{call} + state.CallIndex = 0 + run.Status = "running" + approvalPauseSaveState(t, s, db, run, &state) + run, state = agentInterjectionState(t, s, root.ID) + if err := s.advanceCloudAgentTool(run, &state); err != nil { + t.Fatal(err) + } + _, state = agentInterjectionState(t, s, root.ID) + if state.Approval == nil { + t.Fatal("missing approval") + } + approvalID := state.Approval.ID + if err := s.DecideCloudAgentApproval("user", root.ID, approvalID, "approve", ""); err != nil { + t.Fatal(err) + } + _, state = agentInterjectionState(t, s, root.ID) + if state.Approval != nil || state.CallIndex != 1 { + t.Fatal("approval did not complete") + } + payload := agentCanvasPatchForOperation(t, state, "canvas_apply_ops") + if large { + if payload["requiresRefresh"] != true || payload["canvasPatch"] != nil { + t.Fatal("large event must request a snapshot refresh") + } + if len(creationMaps(payload["actions"])) != 1 || payload["preview"] == nil { + t.Fatal("refresh notification lost its bounded operation summary") + } + } else if payload["canvasPatch"] == nil || payload["requiresRefresh"] == true { + t.Fatal("small event must retain its delta") + } + for _, event := range state.Events { + body, err := json.Marshal(event.Payload) + if err != nil || len(body) > cloudAgentEventPayloadLimit { + t.Fatal("event exceeded checkpoint limit") + } + } + canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas") + if err != nil { + t.Fatal(err) + } + after, err := creationDocument(canvas.PayloadJSON) + if err != nil { + t.Fatal(err) + } + node := creationMaps(after["nodes"])[0] + if node["title"] != "新标题" || node["content"] != content { + t.Fatal("saved canvas lost its title or full content") + } + if canvas.Revision != 2 { + t.Fatalf("unexpected canvas revision: %d", canvas.Revision) + } + // 审批重送必须复用已经完成的结果,不再写一次画布。 + if err := s.DecideCloudAgentApproval("user", root.ID, approvalID, "approve", ""); err != nil { + t.Fatal(err) + } + canvas, err = s.repo.CanvasProjectForUser("user", "agent-canvas") + if err != nil || canvas.Revision != 2 { + t.Fatal("duplicate approval changed the canvas") + } + var mutations int64 + if err := db.Model(&model.CloudAgentCanvasMutation{}).Where("run_id = ?", root.ID).Count(&mutations).Error; err != nil || mutations != 1 { + t.Fatalf("unexpected mutation count: %d, error: %v", mutations, err) + } + }) + } +} + +func TestAgentOversizedCanvasPreviewRequestsRefresh(t *testing.T) { + db := agentPermissionsDB(t) + repo := repository.New(db) + canvas := &model.CanvasProject{ID: "canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`} + if err := db.Create(canvas).Error; err != nil { + t.Fatal(err) + } + state := cloudAgentRuntime{} + input := cloudAgentMutationInput{UserID: "user", CanvasID: canvas.ID, BeforeJSON: canvas.PayloadJSON, Operation: "canvas_apply_ops", Preview: &cloudAgentApprovalPreview{Description: strings.Repeat("摘要", cloudAgentEventPayloadLimit)}} + if err := emitCloudAgentCanvasChange(repo, "run", &state, input); err != nil { + t.Fatal(err) + } + payload := agentCanvasPatchForOperation(t, state, input.Operation) + if payload["requiresRefresh"] != true || payload["canvasPatch"] != nil || payload["preview"] != nil { + t.Fatal("oversized summary must use a bounded refresh notification") + } + raw, err := json.Marshal(payload) + if err != nil || len(raw) > cloudAgentEventPayloadLimit { + t.Fatal("refresh notification exceeded the event limit") + } +} diff --git a/backend/internal/app/cloud_agent_canvas_events.go b/backend/internal/app/cloud_agent_canvas_events.go index 4b6aca368..dd40aac02 100644 --- a/backend/internal/app/cloud_agent_canvas_events.go +++ b/backend/internal/app/cloud_agent_canvas_events.go @@ -1,6 +1,7 @@ package app import ( + "encoding/json" "reflect" "yingce/backend/internal/repository" @@ -85,6 +86,8 @@ func cloudAgentShrinkChange(before, after map[string]any) (map[string]any, map[s return shrunkBefore, shrunkAfter } +// emitCloudAgentCanvasChange 生成与画布写入同事务保存的事件。 +// 增量无法完整表达变更或超过事件上限时通知客户端重新读取,不截断真实画布内容。 func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state *cloudAgentRuntime, input cloudAgentMutationInput) error { before, err := creationDocument(input.BeforeJSON) if err != nil { @@ -190,6 +193,24 @@ func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) { payload["callId"] = state.Calls[state.CallIndex].ID } + raw, err := json.Marshal(payload) + if err != nil { + return err + } + if len(raw) > cloudAgentEventPayloadLimit { + // 修改标题或关联素材也可能携带整份分镜正文的 before/after,导致审批事务回滚。 + // 大增量沿用已有的快照刷新路径;画布、撤销记录和工具回执仍由原事务完整保存。 + delete(payload, "canvasPatch") + payload["requiresRefresh"] = true + raw, err = json.Marshal(payload) + if err != nil { + return err + } + if len(raw) > cloudAgentEventPayloadLimit { + // 摘要本身也超限时不截断字段,退回既有的完整刷新通知。 + payload = map[string]any{"canvasId": input.CanvasID, "operation": input.Operation, "requiresRefresh": true} + } + } state.event(runID, "canvas_updated", payload) return nil } diff --git a/backend/internal/app/cloud_agent_runtime_state.go b/backend/internal/app/cloud_agent_runtime_state.go index 820b9ab6d..641c86ce4 100644 --- a/backend/internal/app/cloud_agent_runtime_state.go +++ b/backend/internal/app/cloud_agent_runtime_state.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "sort" "strings" "unicode/utf8" @@ -19,6 +20,8 @@ import ( "yingce/backend/internal/prompts" ) +const cloudAgentEventPayloadLimit = 128 << 10 + func cloudAgentDecode(run *model.CloudAgentExecution) (cloudAgentRuntime, error) { var state cloudAgentRuntime if run == nil || strings.TrimSpace(run.StateJSON) == "" { @@ -262,7 +265,7 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent return err } raw, err := json.Marshal(event.Payload) - if err != nil || len(raw) > 128<<10 { + if err != nil || len(raw) > cloudAgentEventPayloadLimit { return errors.New("Agent runtime event payload is too large") } } @@ -471,6 +474,9 @@ func cloudAgentSave(run *model.CloudAgentExecution, state *cloudAgentRuntime) er } if run.ID != "" { if err := validateCloudAgentRuntime(run, state); err != nil { + // HTTP 日志只记录错误类型;在首次校验失败处保留原因,避免被后续模型错误掩盖。 + // 不记录完整检查点、提示词或工具参数,日志仅用于定位失败步骤。 + log.Printf("[Agent] checkpoint validation failed run=%s step=%d call_index=%d: %v", run.ID, state.Step, state.CallIndex, err) return cloudAgentCheckpointFailure("runtime validation", err) } for index, event := range state.Events { diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 7b29608e2..fb276d5e4 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -5,6 +5,12 @@ description: 信封、错误码与 OpenAPI # HTTP API 合同 +## Agent 画布权限与大事件 + +自由画布和项目关联画布均使用 `canvas_projects`。Agent 运行时按登录账号和画布 ID 查询归属,不能把所属项目 ID 当作画布 ID;画布不存在、属于其他账号或数据库读取失败时,不声明读写及导出权限。 + +`canvas_updated` 的画布增量超过单事件 128 KiB 上限时,复用 `requiresRefresh: true` 通知客户端重新读取完整画布,优先保留操作摘要;摘要仍超限则仅发送刷新通知。画布正文、历史和操作回执仍完整保存,普通大小事件继续携带 `canvasPatch`。不提高事件上限、不截断正文、不修改审批暂停或模型调度判断。 + ## 注册邮箱等价检查 公开注册与邮箱绑定的唯一性检查将 `gmail.com` 和 `googlemail.com` 视为同一域,移除本地部分的点及 `+` 后缀。例如 `a.b+x@googlemail.com` 与 `ab@gmail.com` 不允许创建两个账号。其他域保留点和 `+`;现有域名白名单规则保持不变。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index d2e08f1b6..a54333e64 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## Agent 画布权限与大事件审批 + +- 已修复:权限查询使用 `canvas_projects`;大画布增量超过事件上限时改为完整快照刷新通知,避免审批执行事务回滚。 +- [x] PostgreSQL 独立临时 schema 验证独立和项目关联画布、跨账号、缺失画布、项目 ID 误用以及数据库读取失败。 +- [x] 专项测试复现修改前大节点审批失败;修改后覆盖完整批准、执行、回执保存、正文保留、重复批准不再写入、小事件保留增量及超大摘要刷新。 +- [x] 本地用户确认原审批失败场景可以继续执行;前端画布事件与版本同步专项测试通过。 +- [ ] 验证跨浏览器、多实例及刷新失败时的恢复;本次不改既有审批恢复和模型调度流程。 + ## 复制生图节点后节点消失 - 已修复:副本不再继承原节点的 `generationEffectKeys`,避免普通保存把它误判为未确认的生成结果。图片内容、提示词和参考字段仍保留,原节点不受影响。 From 6b82fe86fd2f0673d8608cce7adb0792c210bf61 Mon Sep 17 00:00:00 2001 From: tanzui <305375675@.com> Date: Thu, 8 Oct 2026 18:24:27 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(web):=20=E9=A2=84=E6=BC=94=E5=85=B3?= =?UTF-8?q?=E9=97=AD=20-=20=E6=A3=80=E6=B5=8B=E7=9C=9F=E5=AE=9E=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E7=82=B9=E5=87=BB=E5=B9=B6=E4=BF=9D=E7=95=99=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/scripts/previs-p0-chrome-e2e.mjs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/web/scripts/previs-p0-chrome-e2e.mjs b/web/scripts/previs-p0-chrome-e2e.mjs index 8703f54b8..8f230e655 100644 --- a/web/scripts/previs-p0-chrome-e2e.mjs +++ b/web/scripts/previs-p0-chrome-e2e.mjs @@ -727,8 +727,31 @@ async function saveFailureCloseGuard(cdp, baseUrl) { const modalShown = await cdp.poll(`!!document.querySelector('.ant-modal-confirm') && (document.body.innerText || "").includes('留在预演台')`, "close confirm modal", 40000); assert(modalShown, "F5 close is guarded by a confirm dialog, not silent exit"); - const stayClicked = await cdp.clickText("留在预演台"); - if (!stayClicked) throw new Error("F: 留在预演台 button not clickable"); + // 坐标点击成功派发不代表按钮收到了点击:CI 的动画/渲染调度可能让坐标落到遮罩。 + // 只对未送达的点击有限重试;一旦按钮收到真实点击,就保留原关闭断言,不重试业务操作。 + await cdp.evaluate(`(() => { + const probe = { count: 0 }; + probe.listener = (event) => { + const button = event.target instanceof Element ? event.target.closest('button') : null; + if (event.isTrusted && button?.closest('.ant-modal-confirm') && (button.textContent || '').trim() === '留在预演台') probe.count++; + }; + window.__previsStayClickProbe = probe; + document.addEventListener('click', probe.listener, true); + })()`); + let stayClicked = false; + try { + for (let attempt = 0; attempt < 3 && !stayClicked; attempt++) { + const dispatched = await cdp.clickText("留在预演台", ".ant-modal-confirm button"); + if (!dispatched) throw new Error("F: 留在预演台 button not clickable"); + stayClicked = await cdp.poll(`window.__previsStayClickProbe.count > 0`, "stay button received trusted click", 1500); + } + if (!stayClicked) throw new Error("F: 留在预演台 button did not receive a trusted click"); + } finally { + await cdp.evaluate(`(() => { + document.removeEventListener('click', window.__previsStayClickProbe.listener, true); + delete window.__previsStayClickProbe; + })()`); + } const modalGone = await cdp.poll( `![...document.querySelectorAll('.ant-modal-confirm')].some((modal) => { const rect = modal.getBoundingClientRect();