fix(agent): 画布审批 - 修复归属校验与大事件刷新

This commit is contained in:
ddcat666
2026-10-10 04:45:12 +08:00
8 changed files with 351 additions and 57 deletions

View File

@@ -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")
}
}

View File

@@ -1,6 +1,7 @@
package app package app
import ( import (
"encoding/json"
"reflect" "reflect"
"yingce/backend/internal/repository" "yingce/backend/internal/repository"
@@ -85,6 +86,8 @@ func cloudAgentShrinkChange(before, after map[string]any) (map[string]any, map[s
return shrunkBefore, shrunkAfter return shrunkBefore, shrunkAfter
} }
// emitCloudAgentCanvasChange 生成与画布写入同事务保存的事件。
// 增量无法完整表达变更或超过事件上限时通知客户端重新读取,不截断真实画布内容。
func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state *cloudAgentRuntime, input cloudAgentMutationInput) error { func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state *cloudAgentRuntime, input cloudAgentMutationInput) error {
before, err := creationDocument(input.BeforeJSON) before, err := creationDocument(input.BeforeJSON)
if err != nil { if err != nil {
@@ -190,6 +193,24 @@ func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state
if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) { if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) {
payload["callId"] = state.Calls[state.CallIndex].ID 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) state.event(runID, "canvas_updated", payload)
return nil return nil
} }

View File

@@ -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])
}
}
}

View File

@@ -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 { func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any {
// 从数据库读取实际权限 // 与画布读写工具共用账号归属查询,不访问旧的 Canvas 扩展模型。
canvas, err := s.repo.GetCanvas(userID, canvasID) _, err := s.repo.CanvasProjectForUser(userID, canvasID)
if err != nil { if err != nil {
log.Printf("[Agent] failed to get canvas for permissions: %v", err) log.Printf("[Agent] failed to get canvas for permissions: %v", err)
// 返回最小权限集 // 返回最小权限集
return map[string]any{ return map[string]any{
"canReadCanvas": true, "canReadCanvas": false,
"canWriteCanvas": false, "canWriteCanvas": false,
"canDeleteNodes": false, "canDeleteNodes": false,
"canCreateNodes": false, "canCreateNodes": false,
@@ -186,51 +187,21 @@ func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any
"canDuplicateNodes": false, "canDuplicateNodes": false,
"canManageRelations": false, "canManageRelations": false,
"canInviteUsers": false, "canInviteUsers": false,
"canExportCanvas": true, "canExportCanvas": false,
"maxTokenBudget": 200000, "maxTokenBudget": 200000,
"maxSteps": 50, "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{ return map[string]any{
"canReadCanvas": true, "canReadCanvas": true,
"canWriteCanvas": canWrite, "canWriteCanvas": true,
"canDeleteNodes": canDelete, "canDeleteNodes": true,
"canCreateNodes": canWrite, "canCreateNodes": true,
"canMoveNodes": canWrite, "canMoveNodes": true,
"canDuplicateNodes": canWrite, "canDuplicateNodes": true,
"canManageRelations": canWrite, "canManageRelations": true,
"canInviteUsers": canInvite, "canInviteUsers": true,
"canExportCanvas": true, "canExportCanvas": true,
"maxTokenBudget": 200000, "maxTokenBudget": 200000,
"maxSteps": 50, "maxSteps": 50,

View File

@@ -11,6 +11,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"sort" "sort"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -19,6 +20,8 @@ import (
"yingce/backend/internal/prompts" "yingce/backend/internal/prompts"
) )
const cloudAgentEventPayloadLimit = 128 << 10
func cloudAgentDecode(run *model.CloudAgentExecution) (cloudAgentRuntime, error) { func cloudAgentDecode(run *model.CloudAgentExecution) (cloudAgentRuntime, error) {
var state cloudAgentRuntime var state cloudAgentRuntime
if run == nil || strings.TrimSpace(run.StateJSON) == "" { if run == nil || strings.TrimSpace(run.StateJSON) == "" {
@@ -262,7 +265,7 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent
return err return err
} }
raw, err := json.Marshal(event.Payload) 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") 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 run.ID != "" {
if err := validateCloudAgentRuntime(run, state); err != nil { 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) return cloudAgentCheckpointFailure("runtime validation", err)
} }
for index, event := range state.Events { for index, event := range state.Events {

View File

@@ -5,6 +5,12 @@ description: 信封、错误码与 OpenAPI
# HTTP API 合同 # 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` 不允许创建两个账号。其他域保留点和 `+`;现有域名白名单规则保持不变。 公开注册与邮箱绑定的唯一性检查将 `gmail.com` 和 `googlemail.com` 视为同一域,移除本地部分的点及 `+` 后缀。例如 `a.b+x@googlemail.com` 与 `ab@gmail.com` 不允许创建两个账号。其他域保留点和 `+`;现有域名白名单规则保持不变。

View File

@@ -5,11 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试 # 待测试
## Agent 资源型文本读取 ## Agent 画布权限与大事件审批
- 已修复:`canvas_read_text` 在单工具执行路径中使用资源服务,并在检查点写事务外读取,避免误报“文本资源读取服务不可用”及 SQLite 单连接嵌套读取阻塞。 - 已修复:权限查询使用 `canvas_projects`;大画布增量超过事件上限时改为完整快照刷新通知,避免审批执行事务回滚。
- [x] 专项测试通过:实际运行时工具入口读取资源正文、中文分页、跨用户资源拒绝、读取不修改画布,以及相邻工具结果回填测试。 - [x] PostgreSQL 独立临时 schema 验证独立和项目关联画布、跨账号、缺失画布、项目 ID 误用以及数据库读取失败。
- [ ] 后端加载修复后,在真实画布中让 Agent 读取上传的 TXT/Markdown 节点,确认返回正文;真实对象存储读取和原失败对话的后续回合仍待验收。 - [x] 专项测试复现修改前大节点审批失败;修改后覆盖完整批准、执行、回执保存、正文保留、重复批准不再写入、小事件保留增量及超大摘要刷新。
- [x] 本地用户确认原审批失败场景可以继续执行;前端画布事件与版本同步专项测试通过。
- [ ] 验证跨浏览器、多实例及刷新失败时的恢复;本次不改既有审批恢复和模型调度流程。
## 复制生图节点后节点消失 ## 复制生图节点后节点消失

View File

@@ -309,10 +309,9 @@ async function connectCdp(cdpPort) {
console.log(` (click target not interactable: ${label})`); console.log(` (click target not interactable: ${label})`);
return false; return false;
} }
const point = { x: box.x, y: box.y, button: "left", pointerType: "mouse" }; const point = { x: box.x, y: box.y, button: "left" };
await send("Input.dispatchMouseEvent", { type: "mouseMoved", ...point, buttons: 0 }); await send("Input.dispatchMouseEvent", { type: "mouseMoved", ...point, buttons: 0 });
await send("Input.dispatchMouseEvent", { type: "mousePressed", ...point, buttons: 1, clickCount: 1 }); await send("Input.dispatchMouseEvent", { type: "mousePressed", ...point, buttons: 1, clickCount: 1 });
await sleep(50);
await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, buttons: 0, clickCount: 1 }); await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, buttons: 0, clickCount: 1 });
return true; return true;
}; };
@@ -728,17 +727,40 @@ async function saveFailureCloseGuard(cdp, baseUrl) {
const modalShown = await cdp.poll(`!!document.querySelector('.ant-modal-confirm') && (document.body.innerText || "").includes('留在预演台')`, "close confirm modal", 40000); 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"); assert(modalShown, "F5 close is guarded by a confirm dialog, not silent exit");
const modalDismissedExpression = `![...document.querySelectorAll('.ant-modal-confirm')].some((modal) => { // 坐标点击成功派发不代表按钮收到了点击: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(); const rect = modal.getBoundingClientRect();
const style = getComputedStyle(modal); const style = getComputedStyle(modal);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0; return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0;
})`; })`,
let modalGone = false; "modal dismissed",
for (let attempt = 0; attempt < 3 && !modalGone; attempt += 1) { 20000,
const stayClicked = await cdp.clickText("留在预演台"); );
if (!stayClicked) throw new Error("F: 留在预演台 button not clickable");
modalGone = await cdp.poll(modalDismissedExpression, "modal dismissed", attempt === 2 ? 8000 : 2500);
}
assert(modalGone, "F6 confirm dialog dismissed after choosing 留在预演台"); assert(modalGone, "F6 confirm dialog dismissed after choosing 留在预演台");
await sleep(1000); await sleep(1000);