背景

在 VisionAutoTest 的 MVP 阶段,我们已经支持了以下几类浏览器交互步骤:

  • click

  • input

  • navigate

  • wait

  • scroll

  • long_press

同时,元素定位能力已经从单纯的 CSS Selector 扩展到了 OCR 文本定位。这让平台可以覆盖一部分非结构化页面场景,但当我们开始联调真实业务流程时,一个经典难题很快暴露出来:验证码输入。

这次复盘记录的是一条非常具体、也非常具有代表性的链路:

  • MVP Demo Workspace 中执行“飞书登录”套件

  • 进入验证码页

  • 自动向 6 位验证码输入框写入固定验证码 005714

最终我们不仅打通了这条链路,还把过程中抽象出的能力沉淀成了平台级的通用能力,而不是飞书特化逻辑。

第一阶段:先把 OCR 定位能力补齐

最开始,前端编排层对交互步骤的定位方式只有一个默认认知:Selector。

为支持 OCR 定位,我们先完成了第一轮基础扩展:

前端能力补齐

在以下步骤中增加“定位方式”切换:

  • click

  • input

  • scroll(target=element)

  • long_press

支持两种定位:

  • selector

  • ocr

新增的 OCR 相关字段包括:

  • ocr_text

  • ocr_match_mode

  • ocr_case_sensitive

  • ocr_occurrence

前端完成了:

  • 表单显隐切换

  • 字段校验

  • 序列化 / 反序列化

  • 步骤摘要显示

后端联调确认

在真实页面中,我们验证了 click / input / scroll / long_press 的 OCR 定位链路都已打通,能够完成:

  • 编辑

  • 保存

  • 回显

  • 真实接口落库

这一步让平台从“只能选元素定位”迈到了“可以基于界面文字做交互”的阶段。

第二阶段:真实业务场景把 OCR 的边界打出来了

当我们开始执行“飞书登录”套件时,新的问题暴露出来了。

问题 1:OCR 并不总能稳定定位到输入区域

一开始,我们尝试用 OCR 文本 你的手机号 来定位手机号输入框。

真实执行后发现:

  • 报告页截图里可以识别到 请输入你的手机号

  • 但执行时 OCR 仍然可能报:

  • OCR locate failed: text 你的手机号 not found on screen.

进一步排查后发现:

  • 运行时 OCR 使用的是即时视口截图

  • 报告页展示的是 case-run 结束后的整页截图

  • 两者不是同一张图

遇到这种情况我们可以给浏览器一点时间也给算法一点时间。加一点等待即可。

这让我们得出一个很重要的结论:

结果页截图能识别到,不代表执行时那一帧也能识别到。

问题 2:流程推进并不等于页面状态推进

我们随后又碰到了一个更容易误判的问题。

在“点击下一步”后,页面会弹出“请先同意服务协议和隐私政策”的提示框。我们为此补了等待,并加入“点击同意按钮”步骤。

但真实浏览器回放时发现:

  • click 同意 虽然步骤被判 passed

  • 页面却没有真正进入验证码页

直到我们加入显式的页面断言:

输入手机号验证码

才确认流程推进的真实状态。

下图就是当时失败时的真实执行证据。虽然步骤已经走到了“等待进入验证码页”之后,但 OCR 断言仍然读到了登录页文本,而不是验证码

对应的运行态截图也能看出,当时页面仍停留在登录流程,没有真正进入验证

这个阶段让我们意识到:

对真实 E2E 场景而言,步骤通过不等于业务状态推进成功。

第三阶段:OTP 输入框把现有 input 能力的边界彻底暴露出来

进入验证码页后,我们发现飞书验证码输入并不是普通输入框,而是 6 个独立的验证码格:

  • input.base-code-box-input x 6

这时候现有的 input 执行模型不够用了。

原始模型只有:

  1. 定位目标

  2. 点击目标

  3. keyboard.type(text)

这个模型对普通输入框可用,但对 OTP / PIN / 多格验证码输入组件并不稳。

于是我们把问题抽象成平台能力缺口,而不是飞书特化问题:

平台需要一层“通用输入策略”,而不是把所有输入都视为普通输入框。

第四阶段:给平台补齐通用 input_mode

这次实现里,我们没有新增一个“飞书验证码步骤”,而是扩展了现有 input 步骤的能力边界。

新增通用输入模式 input_mode

input 步骤支持:

  • fill

  • type

  • otp

其中:

  • fill

    • 面向普通输入框

  • type

    • 面向需要键盘事件驱动的输入组件

  • otp

    • 面向验证码、多格 PIN、OTP 输入组件

配套新增字段:

  • otp_length

  • per_char_delay_ms

前端支持

前端步骤编排里,input 步骤新增了“输入方式”配置:

  • 普通输入

  • 键盘输入

  • 验证码输入

同时完成:

  • UI 表单

  • 校验

  • 摘要展示

  • 回显

后端支持

后端执行器中,input_mode=otp 时会:

  • 按字符逐位输入

  • 支持字符间延迟

这一层实现的是“如何输入”。

大致代码如下:

if step.step_type == "input":
    text = self._payload_str(payload, "text")
    input_mode = payload.get("input_mode", "fill")

    if self._uses_visual_locator(payload):
        loc = self._resolve_interaction_target(
            page, payload, template_contexts=template_contexts
        )
        page.mouse.click(loc.center_x, loc.center_y)
        self._prepare_input_focus(page, input_mode=input_mode)
        self._input_via_keyboard(
            page,
            text=text,
            input_mode=input_mode,
            otp_length=self._optional_positive_int(payload, "otp_length"),
            per_char_delay_ms=per_char_delay_ms,
        )
        self._verify_input_applied(
            page,
            text=text,
            input_mode=input_mode,
            otp_length=self._optional_positive_int(payload, "otp_length"),
        )

这里有两个非常关键的设计点:

  • 输入前先做焦点修正

  • 输入后必须做真实生效校验

但很快我们发现,仅有 OTP 输入策略还不够。

第五阶段:OCR 对验证码区域不稳定,转向视觉模板定位

验证码页还有一个问题:

  • OCR 文本 验证码 太短

  • 页面文案、倒计时、手机号掩码都在变化

  • OCR 对验证码区域的定位并不稳定

这时我们没有另起一套截图比对系统,而是复用了现有模板能力。

第六阶段:复用模板与 Mask 能力,新增通用 visual locator

项目里本来就已经有:

  • 模板上传

  • 基准版本

  • Mask 忽略区域

  • 模板匹配

因此我们没有新造一个“截图点击系统”,而是选择:

在现有模板能力之上,新增一个通用的 visual locator

新增第三种定位方式

交互步骤的 locator 现在支持:

  • selector

  • ocr

  • visual

visual 定位使用:

  • template_id

  • 可选 threshold

这一能力可用于:

  • click

  • input

  • scroll(target=element)

  • long_press

为什么不是做“像素级完全比对”

我们没有做逐像素严格相等比较,因为这对平台来说太脆弱:

  • 分辨率变化

  • DPR 变化

  • 字体抗锯齿

  • 浏览器渲染波动

  • 动态文案

正确方向是模板匹配 / 视觉锚点定位,而不是死板像素相等。

交互步骤里最终使用的视觉定位载荷形态如下:

{
  "locator": "visual",
  "template_id": 9,
  "threshold": 0.85,
  "text": "005714",
  "input_mode": "otp",
  "otp_length": 6,
  "per_char_delay_ms": 80
}

这也是我们最终在飞书验证码场景中跑通的组合方式:

  • visual 负责找验证码输入区

  • otp 负责把验证码逐位打进多格组件

第七阶段:模板匹配算法踩坑与修正

在第一版 visual locator 实现中,我们直接复用了模板断言里的逻辑。

这带来了一个隐蔽但严重的问题:

  • 模板断言是“整图归一化后算相似度”

  • 视觉定位需要的是“在当前截图中搜索局部模板区域”

第一版错误地把实际截图缩放到模板大小,再做 matchTemplate,结果模板会错误命中左上角,或者分数异常低。

后面我们修正为真正的局部模板搜索:

  • 不再先把整页缩放到模板尺寸

  • 直接在实际截图中搜索模板区域

这是一个非常关键的修正。

模板断言和模板定位看似相近,但本质是两种不同的视觉任务。

# 错误思路:先把整页缩放到模板尺寸,再做匹配
expected_image, actual_image = self._normalize_sizes(cv2, baseline_image, actual_image)
result = cv2.matchTemplate(actual_gray, expected_gray, cv2.TM_CCOEFF_NORMED)

# 正确思路:保留模板原始尺寸,直接在实际截图中搜索局部区域
expected_image = self._apply_masks(baseline_image, context.mask_regions)
expected_gray = cv2.cvtColor(expected_image, cv2.COLOR_BGR2GRAY)
actual_gray = cv2.cvtColor(actual_image, cv2.COLOR_BGR2GRAY)
result = cv2.matchTemplate(actual_gray, expected_gray, cv2.TM_CCOEFF_NORMED)

这段修正直接决定了 visual locator 是“整图相似度比较”,还是“局部目标搜索”。

也正是因为这个修正,我们后面才能让验证码区域模板真正命中页面中的局部输入框,而不是错误命中左上角或整页无关区域。

第八阶段:模板资产本身也需要精修

能力打通后,飞书验证码场景仍然一度失败。

原因不是代码,而是模板资产质量不够:

  • 第一版模板裁剪错用了移动端坐标思路

  • 实际运行截图是桌面版 1440x900

  • 模板区域不够纯,包含了过多动态信息

后来我们做了几轮裁剪和交叉打分,最终定位到更稳定的验证码格区域,并切换了模板当前基准版本。

这一阶段说明:

视觉自动化不仅是执行器能力问题,模板资产本身也是成功率的重要组成部分。

第九阶段:严格 E2E 把“假通过”打回原形

这是整轮工作里最有价值的部分之一。

一度平台执行记录里显示验证码步骤通过,但真正用浏览器 E2E 去走时,我们发现:

  • 页面甚至没进入验证码页

  • 或者验证码格没有真正写入

因此我们进一步做了两件事。

1. 增加页面状态断言

在验证码输入前,先断言

输入手机号验证码

这样可以确保:

  • 不是“动作执行了”

  • 而是“页面真的进入了验证码页”

2. 增加 OTP 输入后生效校验

input_mode=otp 不再只发键盘事件就算成功。

现在会检查:

  • 页面上的验证码格是否真的被填入

  • 目标值是否与预期验证码一致

这样才能避免“步骤通过但值没写进去”的假通过。

生效校验的核心代码大致如下:

def _verify_input_applied(self, page, *, text: str, input_mode: str, otp_length: int | None) -> None:
    if input_mode == "otp":
        expected_length = otp_length if otp_length is not None else len(text)
        otp_state = page.evaluate(
            """() => {
                const inputs = Array.from(document.querySelectorAll('input.base-code-box-input'));
                return inputs.map((item) => ({ value: item.value || '' }));
            }"""
        )
        joined = "".join(str(item.get("value", "")) for item in otp_state)
        if len(joined) == expected_length and joined == text:
            return
        raise RuntimeError("OTP input did not populate verification boxes with the expected value.")

这个校验的价值不在于“多写了一层代码”,而在于它把执行语义从“尝试输入”提升成了“确认输入真的生效”。

在严格校验链路加上之后,我们得到了一张很关键的证据图:这次 OCR 断言已经能识别出“输入手机号验证码”,说明流程终于真实进入验证码输入页

第十阶段:真正的根因是 OTP 焦点没有落对

在我们严格做 E2E 后,终于定位到了最后一个真实阻塞点。

实际验证发现:

  • 进入验证码页后

  • 6 个 input.base-code-box-input 确实存在

  • 只要焦点落到第一个验证码格,insertText('005714') 就能成功写进去

也就是说,OTP 输入方式本身没有问题。

真正的问题是:

  • visual locator 点击到的是验证码区域中心

  • 这个点击点不一定会让第一个验证码格获得焦点

于是执行器继续发送 OTP 输入时,值没有进到输入格里。

最终修复

我们为 input_mode=otp 增加了通用焦点修正策略:

  • OTP 输入前

  • 优先查找第一个可见、可用的 input.base-code-box-input

  • 主动聚焦 / 点击它

  • 再开始逐位输入

这一步是平台级修复,不依赖飞书业务逻辑,只依赖 OTP 输入组件的通用结构特征。

焦点修正的核心思路如下:

def _prepare_input_focus(self, page, *, input_mode: str) -> None:
    if input_mode != "otp":
        return

    focused = page.evaluate(
        """() => {
            const inputs = Array.from(document.querySelectorAll('input.base-code-box-input'));
            const firstVisible = inputs.find((item) => {
                if (!(item instanceof HTMLInputElement)) return false;
                const rect = item.getBoundingClientRect();
                return rect.width > 0 && rect.height > 0 && !item.disabled;
            });
            if (!(firstVisible instanceof HTMLInputElement)) {
                return false;
            }
            firstVisible.focus();
            firstVisible.click();
            return document.activeElement === firstVisible;
        }"""
    )

这段逻辑让 OTP 输入不再依赖“模板点击点恰好落在第一个验证码格上”,而是主动把焦点修正到可输入的第一个格子。

在真实执行成功后,运行截图也能看到流程已经稳定推进到验证码页,并完成并完成了后续输入动作。

最终结果

在完成以上修正后,我们得到了两个层面的成功验证。

平台执行记录验证

最新真实执行中:

  • 已进入验证码页

  • 验证码输入步骤通过

  • 用例整体通过

严格 E2E 浏览器验证

我们又用真实浏览器手动走了一遍同样流程,并直接读取了 6 个验证码格的值。

最终得到

["1", "2", "3", "4", "5", "6"]

这说明:

  • 验证码 123456 已经真实写进了 6 个验证码格

  • 不是只看步骤状态

  • 是 DOM 级别的真实写入

这轮工作最终沉淀出的平台能力

这次工作没有停留在“修一个飞书问题”,而是沉淀出了三类平台通用能力。

1. 通用定位方式

  • selector

  • ocr

  • visual

2. 通用输入策略

  • fill

  • type

  • otp

3. 更严格的执行可信度保障

  • 关键页面状态断言

  • OTP 输入后真实值校验

  • 模板定位与模板断言分离

这意味着平台已经具备了对以下场景的基础支撑能力:

  • 普通输入框

  • 文字定位点击/输入

  • 多格验证码输入框

  • PIN 输入组件

  • 视觉模板定位交互区

这次最值得记录的几个教训

教训 1:步骤通过不等于业务成功

自动化平台最怕“假通过”。

如果只看动作有没有抛异常,而不看页面状态是否真的改变,就很容易误判。

教训 2:真实 E2E 验证不可省

这次如果只看执行记录,我们会很容易以为验证码输入已经打通。

真正用浏览器去看、去读 DOM,才发现问题还没真正解决。

教训 3:平台能力建设要抽象,不要特化

这次所有问题都可以做成飞书特化补丁,但那样只会积累技术债。

我们最终坚持抽象成:

  • input_mode=otp

  • locator=visual

  • OTP 焦点修正

这才是自动化测试平台应该做的事。

教训 4:模板资产质量和算法一样重要

视觉自动化不是“代码好了就行”。

模板怎么裁、是否需要 mask、是否包含动态区域,都会直接影响执行稳定性。

结语

这次工作看上去是在解决一个“验证码输入框”问题,但真正完成的是一轮平台能力升级。

我们从一个具体问题出发:

  • OCR 不稳定

  • OTP 输不进去

  • 页面状态误判

最后沉淀出来的是一套更完整的执行能力模型:

  • 视觉定位

  • 多策略输入

  • 页面状态断言

  • 输入结果校验

这才是视觉自动化测试平台演进时最有价值的部分。

如果后面继续扩展,我认为最自然的方向包括:

  • 视觉模板定位的偏移点击配置

  • 模板预览和模板调试工具

  • 更通用的 OTP 焦点探测规则

  • 步骤级后置断言能力标准化

  • 输入/点击结果的证据截图增强

但无论怎么扩展,这轮工作的核心结论已经很明确:

验证码这类复杂交互,不应该通过为了某一个业务特化去解决,而应该通过平台通用能力建设去解决。

VisionAutoTest已在 GitHub 开源

VisionAutoTest:一款面向研发、测试及 DevOps 团队的高级(企业级)视觉自动化测试平台。基于现代 Python 栈(异步、易运维、易 AI 生成)及渐进式前端技术开发。聚焦降低维护成本、强化断言鲁棒性、实现真正的“一次编排、随处断言”。

如果你感兴趣请给我们 star,谢谢,也欢迎提交你的 PR

项目地址:https://github.com/xiaolin0429/VisionAutoTest