Home
Perfecto的头像

Perfecto

React Native + Detox:基于 CDP 的信号同步机制

AI 速览

这篇文章深入分析了一个令人困惑的问题:为什么 CDP 协议的 awaitPromise 参数在 React Native 中完全不起作用?通过翻阅 Hermes 源码,作者发现这个参数虽然被正确解析,但在实际处理逻辑中从未被读取——这是一个”只解析不处理”的半成品实现。文章的核心贡献是提出了基于 Console 事件的信号同步机制作为替代方案:App 端通过特定格式的 console.log 发送完成信号,Detox 端监听 Runtime.consoleAPICalled 事件。这个方案的巧妙之处在于利用了 CDP 协议中最稳定可靠的 Console API,而非依赖不完整的 Promise 等待机制。文章还详细讨论了 Race Condition 的避免策略:先注册监听,再触发操作


系列文章导航


术语说明

术语
全称
说明
CDP
Chrome DevTools Protocol
Chrome 开发者工具使用的调试协议,Hermes 引擎支持此协议
Hermes
-
Facebook 为 React Native 优化的轻量级 JavaScript 引擎
Metro
Metro Bundler
React Native 的开发服务器,在 8081 端口暴露 CDP 代理接口

背景

在 React Native + Detox E2E 测试中,Detox 的原生 API(element().tap()typeText())只能模拟用户行为。但在灰盒测试 (Gray Box Testing) 场景下,为了提高效率和覆盖率,我需要绕过 UI 层直接操作 App 内部逻辑:

场景
Detox 原生 API
需要的能力
自动登录
❌ 逐个填写表单
✅ 直接调用 Redux Action
页面导航
❌ 逐级点击菜单
✅ 直接调用 Navigation API
读取 Toast
❌ 无法获取 Toast 文本
✅ 读取 window 变量
清理 UI 状态
❌ 无法隐藏键盘/清除焦点
✅ 调用 React Native API

为此,我引入了 Chrome DevTools Protocol (CDP)。React Native 使用 Hermes 作为 JavaScript 引擎,Hermes 支持 CDP 协议进行调试。Metro Bundler 在开发模式下会在 8081 端口 暴露 CDP 代理接口。

通过 Runtime.evaluate 方法,理论上我可以在 App 的 JS 运行时中执行任何代码,从而实现:

  • 调用 window.login() 执行自动登录
  • 调用 window.navigate() 执行页面跳转
  • 读取 window._currentToastMessage 获取 Toast 文本

环境

  • React Native: 0.74.5
  • Detox: 20.42.0
  • Hermes Engine: (React Native 内置)
  • Metro Bundler: (React Native 内置)
  • CDP Client: chrome-remote-interface 0.33.2

问题 1: Hermes 的 awaitPromise 不可靠

期望 vs 现实

CDP 协议的 Runtime.evaluate 方法 有一个参数叫 awaitPromise,根据官方文档:

awaitPromise (boolean): Whether execution should await for resulting value and return once awaited promise is resolved.

理论上,这应该是完美的异步等待方案:

// 期望:等待 Promise resolve 后再返回
await client.Runtime.evaluate({
  expression: `window.login('user@test.com', 'password')`, // 这个函数返回一个 Promise
  awaitPromise: true, // 告诉引擎等待 Promise resolve 再返回
  returnByValue: true
});
console.log('登录完成,继续测试...');

然而实际测试发现:

现象
描述
提前返回
App 里的登录请求还在转圈,Detox 这边已经收到了"执行成功"的响应
永久挂起
有时代码执行完了,CDP socket 却一直没有响应

源码排查:awaitPromise 在 Hermes 中未被实现

在 React Native 的 Github Issues #46966 中有人明确回复:

“Unfortunately awaitPromises isn’t supported yet. We do want to close the gap but don’t have timeline for that.” — @dannysu 2024, 10

我出于好奇去翻了 Hermes 的 CDP 实现源码,沿着 Runtime.evaluate 的完整调用链路排查:

当我在 Detox 测试代码中调用 await client.Runtime.evaluate({ expression, awaitPromise: true }) 时,背后发生了这些事情:

  • 发起:chrome-remote-interface 将请求序列化为 JSON,通过 WebSocket 发送到 Metro Bundler 的 8081 端口
  • 转发:Metro 作为 CDP 代理,将请求原封不动地转发给 Hermes 引擎
  • 解析:Hermes 的 MessageTypes.cpp 将 JSON 解析为 C++ 结构体 EvaluateRequest,此时 awaitPromise 字段被正确提取
  • 路由:CDPAgent.cpp 根据 method 字段将请求分发给 RuntimeDomainAgent
  • 执行:RuntimeDomainAgent::evaluate() 调用 runtime.evaluateJavaScript() 执行代码,但完全没有读取 awaitPromise 参数
  • 返回:执行结果被立即序列化并返回给客户端,无论表达式是否返回 Promise

关键发现:awaitPromise 只解析不处理

在整个 Hermes 代码库中搜索 awaitPromise,只出现在消息解析层:

文件
作用
awaitPromise
MessageTypes.h
定义请求结构体
✅ 字段存在
MessageTypes.cpp
解析 JSON 请求
✅ 被解析
RuntimeDomainAgent.cpp
实际处理逻辑
❌ 从未读取
CDPAgentTest.cpp
单元测试
❌ 无测试用例

RuntimeDomainAgent::evaluate() 核心源码:

// 文件: API/hermes/cdp/RuntimeDomainAgent.cpp (第 618-648 行)
void RuntimeDomainAgent::evaluate(const m::runtime::EvaluateRequest &req) {
  // 使用的参数
  serializationOptions.returnByValue = req.returnByValue.value_or(false);
  serializationOptions.generatePreview = req.generatePreview.value_or(false);
  std::string objectGroup = req.objectGroup.value_or("");

  // req.awaitPromise 未被读取

  // 直接同步执行,立即返回
  std::tie(resp.result, resp.exceptionDetails) = evaluateAndWrapResult(
      runtime_, *objTable_, objectGroup, serializationOptions,
      [&req](jsi::Runtime &runtime) {
        return runtime.evaluateJavaScript(...);
      });

  sendResponseToClient(resp);
}

结论

参数
CDP 协议规范
Hermes 实现
expression
必需
支持
returnByValue
可选
支持
generatePreview
可选
支持
objectGroup
可选
支持
awaitPromise
可选
未实现

Hermes 的 awaitPromise 只是个摆设:参数被正确解析到结构体中,但从未被读取和处理。如果表达式返回 Promise,Hermes 会直接返回 Promise 对象本身,而不是等待其 resolve。

这意味着:不能依赖 Hermes 告诉我”什么时候结束”,必须让 App 自己来告诉我。


问题 2: 信号机制的核心难题 —— Race Condition

设计思路

既然底层协议不可靠,我需要在应用层构建一套握手协议,需要一个绝对可靠的”通信载体”。

在 CDP 中,Runtime.consoleAPICalled 事件 (即 console.log) 是非常健壮的 —— 只要 App 打印了日志,调试器一定能收到。这是因为 Console API 是 CDP 协议的核心功能,用于调试器的 Console 面板。

初次尝试 (错误示范)

// ❌ 错误示范:先触发操作,再监听信号
async function loginWrong(email: string, password: string) {
  const opId = generateOperationId();

  // 1. 先触发操作
  await client.Runtime.evaluate({
    expression: `login('${email}', '${password}', '${opId}')`,
    awaitPromise: false,
  });

  // 2. 再监听信号
  await waitForSignal(opId); // 可能错过信号
}

问题: 如果 App 端的 login() 函数执行速度极快 (如使用了缓存的 Token),可能在 waitForSignal() 注册监听器之前就已经发出了完成信号,导致永久等待。

关键洞察: 先监听,后触发

时间轴 (错误做法):
T1: evaluate() 发送命令
T2: App 执行 login()
T3: App 打印 console.log('[E2E:DONE:xxx]')  ← 信号已发出
T4: waitForSignal() 开始监听               ← 监听器才注册,错过信号

时间轴 (正确做法):
T1: waitForSignal() 开始监听               ← 监听器已就绪
T2: evaluate() 发送命令
T3: App 执行 login()
T4: App 打印 console.log('[E2E:DONE:xxx]')  ← 信号被捕获 ✅

解决方案: Console 事件驱动的信号同步

协议格式设计

定义两种特殊的日志格式作为 IPC (进程间通信) 信号:

信号类型
格式
说明
成功
[E2E:DONE:${opId}]
操作完成
失败
[E2E:ERROR:${opId}:${message}]
操作失败

其中 opId 是每次操作生成的唯一 UUID,防止不同操作的信号混淆:

export function generateOperationId(): string {
  return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  // 示例: "1733812345678-k3j2h1"
}

实现流程

App 端注入 (Inject Layer)

在 App 启动时注入 Helper 函数,用于包装异步操作:

// e2e/lib/inject/helpers.ts
export const signalDone = (opId: string) => {
  console.log(`[E2E:DONE:${opId}]`);
};

export const signalError = (opId: string, error: unknown) => {
  const message = error instanceof Error ? error.message : String(error);
  console.error(`[E2E:ERROR:${opId}:${message}]`);
};
// e2e/lib/inject/auth.ts
window.login = async (email: string, password: string, opId?: string) => {
  try {
    // 真实的业务逻辑
    await store.dispatch(loginLegacy({ byEmailPassword: { email, password } }));
    await store.dispatch(getAccountInfo());
    globalNavigation.current?.dispatch(PATH_HOME.reset({}));

    // 关键点:操作完成后打印特殊格式日志
    if (opId) signalDone(opId);
  } catch (error) {
    if (opId) signalError(opId, error);
  }
};

Test 端实现 (Detox Bridge)

在 Node.js 进程中,实现一个基于事件的等待机制。核心原则是

,先建立好监听器,防止 Race Condition 导致信号丢失。

// e2e/lib/cdp/client.ts
export async function waitForSignal(opId: string, timeout = 60000): Promise<void> {
  const cdpClient = await getCdpClient();

  return new Promise((resolve, reject) => {
    // 1. 创建监听器
    const listener = (params: CDP.Runtime.ConsoleAPICalledEvent) => {
      const message = params.args?.[0]?.value;

      if (message === `[E2E:DONE:${opId}]`) {
        cleanup();
        resolve();
      } else if (typeof message === 'string' && message.startsWith(`[E2E:ERROR:${opId}:`)) {
        cleanup();
        const errorMsg = message.replace(`[E2E:ERROR:${opId}:`, '').replace(']', '');
        reject(new Error(errorMsg));
      }
    };

    // 2. 注册监听器
    const unsubscribe = cdpClient['Runtime.consoleAPICalled'](listener);

    // 3. 设置超时兜底
    const timeoutId = setTimeout(() => {
      cleanup();
      reject(new Error(`waitForSignal timeout after ${timeout}ms for opId: ${opId}`));
    }, timeout);

    // 清理函数
    const cleanup = () => {
      clearTimeout(timeoutId);
      unsubscribe();
    };
  });
}

调用示例

// e2e/lib/actions/login.ts
export async function login(email: string, password: string): Promise<void> {
  const opId = generateOperationId();
  const client = await getCdpClient();

  // 关键顺序:先监听,后触发
  const signalPromise = waitForSignal(opId);  // 1️⃣ 先监听

  await client.Runtime.evaluate({
    expression: `window.login('${email}', '${password}', '${opId}')`,
    awaitPromise: false,  // 不依赖 Hermes 的 awaitPromise
  });

  await signalPromise;  // 3️⃣ 等待信号
}

为什么同步信号用 CDP 事件,Console 日志用轮询?

这两种场景看似都用 consoleAPICalled,但本质需求不同:

同步信号 (login/navigate):

T1: waitForSignal() 注册监听 ← 监听器已就绪
T2: Runtime.evaluate() 触发操作
T3: App 执行异步逻辑...
T4: signalDone(opId) 发出信号
T5: 监听器收到,resolve()

信号在 T4 才产生,监听器在 T1 已就绪。时序可控,不需要历史数据。

Console 日志 (App 启动日志):

T0: App 开始启动
T1: setupConsoleInterceptor() 注入
T2-T4: 日志产生 (启动日志等)
T5: Detox 测试开始
T6: CDP 连接建立 ← 这时才能用 consoleAPICalled

T2-T4 的日志在 CDP 连接之前就产生,用事件监听会永久丢失。因此 Console 日志监听采用”App 端数组缓存 + Detox 端轮询”的策略。


问题 3: CDP 连接断开的陷阱

现象

测试运行到中途,突然报错:

WebSocket is not open: readyState 3 (CLOSED)

通常发生在 device.reloadReactNative() 之后,或者 Metro Bundler 偶尔的抖动时。旧的 CDP Client 单例持有的是已经断开的连接,导致后续操作全部失败。

解决方案: 健康检查 + 自动重连

我在 lib/cdp/client.ts 中引入了三层防护:

Promise 共享单例模式

防止高并发调用时 (例如多个 test case 同时初始化) 创建多个 WebSocket 连接,造成资源竞争。

let client: CDP.Client | null = null;
let initPromise: Promise<CDP.Client> | null = null;

export const getCdpClient = async (): Promise<CDP.Client> => {
  // 1. 检查现有连接 + 健康检查
  if (client && (await isClientConnected(client))) {
    return client;
  }

  // 2. 并发锁:复用正在进行的连接尝试
  if (initPromise) return initPromise;

  // 3. 发起新连接
  initPromise = (async () => {
    try {
      const newClient = await createCdpConnectionWithRetry(3);
      client = newClient;
      return newClient;
    } finally {
      initPromise = null;  // 无论成功失败都重置,允许下次重试
    }
  })();

  return initPromise;
};

健康检查 (Health Check)

每次获取 Client 时,先执行一个极轻量的 1+1 运算。如果 2 秒内无响应,判定连接已死,强制重连。

async function isClientConnected(cdpClient: CDP.Client): Promise<boolean> {
  try {
    const healthCheck = cdpClient.Runtime.evaluate({
      expression: '1+1',
      returnByValue: true,
    });

    const timeout = new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error('Health Check Timeout')), 2000)
    );

    await Promise.race([healthCheck, timeout]);
    return true;
  } catch {
    return false;
  }
}

主动监听断开

监听 WebSocket 的 disconnect 事件,一旦触发,立即清空单例,下次调用时自动重新建立连接。

async function createCdpConnection(): Promise<CDP.Client> {
  const newClient = await CDP({ port: 8081 });
  await newClient.Runtime.enable();

  // 监听断开事件
  newClient.on('disconnect', () => {
    console.log('[CDP] Connection disconnected, resetting client');
    // 防误杀:只有当前 client 是这个 newClient 时才重置
    if (client === newClient) {
      resetCdpClient();
    }
  });

  return newClient;
}

function resetCdpClient() {
  if (client) {
    // 清理所有监听器,防止内存泄漏
    (client as unknown as { removeAllListeners: () => void }).removeAllListeners();
    client = null;
  }
}

避坑指南

returnByValue 必须传

在使用 Runtime.evaluate 获取返回值时,如果不加 returnByValue: true,Hermes 只会返回一个 RemoteObject 的 ID,而不是实际的数据。

// ❌ 错误:不加 returnByValue,复杂对象返回引用
const resp = await client.Runtime.evaluate({
  expression: `window._consoleLogs.splice(0)`,
});
// resp.result.value 可能是 undefined

// ✅ 正确:加 returnByValue + JSON.stringify
const resp = await client.Runtime.evaluate({
  expression: `JSON.stringify(window._consoleLogs.splice(0))`,
  returnByValue: true,
});
const logs = JSON.parse(resp.result.value as string);

同步信号 vs 日志收集

  • 同步信号 (login/navigate): 使用 Runtime.consoleAPICalled 事件监听,因为时序可控 (我触发操作,我等待信号)
  • 普通 App 日志: 不能只靠事件监听,因为在 CDP 连接建立之前的启动日志会丢失。对于普通日志,采用”App 端数组缓存 + Detox 端轮询”的策略,以确保历史日志不丢失

JSON.stringify 的必要性

在注入代码中,传递给 console 的参数如果是对象,必须先 JSON.stringify。否则通过 CDP 传过来的可能是 [object Object] 字符串,导致信号解析失败。

// App 端注入代码
window.getNavigationState = () => {
  const state = globalNavigation.current?.getRootState();
  // ✅ 必须序列化
  console.log(`[E2E:NAV_STATE:${JSON.stringify(state)}]`);
};

awaitPromise 使用场景

// ✅ 简单同步操作可以用 awaitPromise: true
await client.Runtime.evaluate({
  expression: `window.clearToast()`,
  awaitPromise: true,
});

// ❌ 复杂异步操作不可靠,使用信号同步
const opId = generateOperationId();
const signalPromise = waitForSignal(opId);
await client.Runtime.evaluate({
  expression: `window.navigate('/customer/detail', {id: 123}, '${opId}')`,
  awaitPromise: false,  // 不依赖 Hermes
});
await signalPromise;

总结

通过引入 CDP 信号同步机制,我彻底解耦了 Detox 测试进程与 React Native 内部异步状态的依赖:

对比维度
以前 (awaitPromise)
现在 (信号同步)
可靠性
Hermes 实现不完整
基于可靠的 Console 事件
同步精度
提前返回或永久挂起
App 说"好了",测试立刻继续
错误传递
异常被吞掉,测试超时才发现
通过 ERROR 信号立即传递
调试体验
只能靠 `setTimeout(5000)` 猜
日志清晰标注 opId
Race 风险
-
先监听后触发,严格时序控制
连接稳定性
断开后无感知
健康检查 + 自动重连 + 事件监听

这套机制构成了我们 E2E 框架的通信基石。在此之上,我们得以构建出强大的”健康检查”和”视觉监控”体系,这将是下一篇的主题。


参考

技术实践 APP开发 React Native E2E测试