{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/2d84bc7aa346a4c9e31d6bfd641abf9974e948ff",
        "commitHash": "2d84bc7aa346a4c9e31d6bfd641abf9974e948ff",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/288",
        "prTitle": "promote(dev→test): Bridge Lab callback observability",
        "prBaseHash": "5b2b55f6cf5f92784085d90b2ec4dbfa12a6767a",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/42586315077"
      },
      "gitCommit": {
        "shortHash": "2d84bc7",
        "hash": "2d84bc7aa346a4c9e31d6bfd641abf9974e948ff",
        "subject": "Merge 39e3906b82dafb8b9552f80f6c7520bd6cb0a5c5 into 6d07918b5d011880bc674c401c72829daa6a85a3",
        "body": "Merge 39e3906b82dafb8b9552f80f6c7520bd6cb0a5c5 into 6d07918b5d011880bc674c401c72829daa6a85a3\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1794212965000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1794212965000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/workbench/evidence/h027-bridge-lab.md b/docs/workbench/evidence/h027-bridge-lab.md\nindex 61d9eeb..ba7fecc 100644\n--- a/docs/workbench/evidence/h027-bridge-lab.md\n+++ b/docs/workbench/evidence/h027-bridge-lab.md\n@@ -179,3 +179,30 @@ H027 Bridge Lab 因此保持两层：\n - iOS Raw Probe：仍保留旧 `getAuthorizationInfo + onToken` 预设，用于旧 App build / 历史协议诊断。\n \n 该更新不删除 H026 messageHandler transport；它只改变当前 production auth protocol 的事实来源。\n+\n+## 11. Async callback observability\n+\n+Bridge Lab subscribes to a passive diagnostic stream emitted by the existing injected-object callback transport. The stream does not create a second callback channel and does not alter request settlement.\n+\n+Each registered async invocation can be inspected through:\n+\n+- Native object and method actually invoked;\n+- generated callbackId and callback endpoints configured for the request;\n+- safe argument shape summary (JSON keys plus callbackId / scanType only; arbitrary string contents are not copied into logs);\n+- actual callback endpoint used by Native;\n+- callbackId pending match / unmatched status;\n+- callback count and capability-specific intermediate / terminal classification;\n+- callback payload type and timeout.\n+\n+Privacy boundary:\n+\n+- scan callback envelope metadata such as numeric Native code, message and scanType remain visible for diagnosis;\n+- scanned QR text (including a legacy top-level string `code`) is masked in the default log view;\n+- explicit reveal remains a user action and exposes only the in-memory current log item;\n+- unknown / unmatched callbacks are fully masked because their originating capability cannot be established;\n+- the observer is subscribed only while Bridge Lab is mounted; with no subscribers, diagnostic events are not delivered or retained;\n+- listener exceptions are isolated and cannot interrupt Native callback dispatch or Promise settlement.\n+\n+The added automated tests cover request correlation, both Android callback aliases, callback counts and stage classification, unmatched callbacks, timeout events, component log rendering and Playwright browser smoke.\n+\n+**Validation boundary:** these tests use stub Native hosts and prove the H5 diagnostic mechanism only. They do not prove that the current App build emits a callback, that the real Native callbackId matches, or that Android/iOS scan business flows work. A real App WebView scan is still required after the change reaches the test environment.\ndiff --git a/docs/workbench/tasks/H027-bridge-lab.md b/docs/workbench/tasks/H027-bridge-lab.md\nindex 36eae8e..32bbb5d 100644\n--- a/docs/workbench/tasks/H027-bridge-lab.md\n+++ b/docs/workbench/tasks/H027-bridge-lab.md\n@@ -12,6 +12,7 @@\n - Android / iOS 宿主协议可通过隔离的 Raw Probe 临时探测；\n - 不为每个新增 Native 方法继续手写一套固定按钮；\n - 调试信息可观察，但敏感返回默认脱敏；\n+- 异步 Native callback 可按 callbackId 追踪实际入口、回调次数、中间态/终态与超时；扫码原文默认脱敏；\n - Lab 只服务 dev / preview / test 联调，不进入 production 正式业务导航或 production bundle。\n \n ## 当前事实\n@@ -29,6 +30,14 @@\n - 已确认的 capability 通过同一 production runtime/facade 链调用；\n - 浏览器或宿主缺失时明确显示 unsupported，不 fake success。\n \n+### Async callback trace\n+\n+- 记录 Native 方法实际调用、object/method、callbackId、候选 callback endpoints 与安全的参数摘要；\n+- 记录每次 Native callback 实际抵达的 endpoint、callbackId 是否命中 pending、回调次数、payload 类型及 capability 阶段判定；\n+- 记录 callback timeout；未匹配 callbackId 也要可见，并继续保持既有 fallback 行为；\n+- callback observer 仅在 Bridge Lab 挂载时订阅；没有 observer 时 transport 不向订阅者分发或保留诊断事件；\n+- 扫码 payload 保留 envelope 状态字段用于诊断，但默认遮蔽二维码正文；显式 reveal 仍受现有调试动作控制��\n+\n ### Android Raw Probe\n \n 支持动态输入：\n@@ -74,6 +83,7 @@\n - iOS Raw Probe 复用 H026 transport，并按 callback name 持久化 single-flight channel 状态；\n - timeout 后 channel 保持 fail-closed，显式 reset 才恢复；\n - Lab 日志记录开始、耗时、结果与错误，并默认脱敏；\n+- Registered async callback 增加被动诊断事件：Native 调用、callbackId、实际回调入口、回调计数、阶段判定、未匹配回调与超时；扫码正文默认遮蔽；\n - production 使用编译期 route gate + lazy import，CI 额外扫描 dist，避免 Bridge Lab / Raw Probe 落入 production bundle；\n - 新增 Vitest / React component / Playwright browser smoke 覆盖。\n \n@@ -87,6 +97,7 @@\n - [x] 敏感返回默认脱敏，raw 查看需要显式动作；\n - [x] 普通浏览器无宿主对象时明确 unsupported；\n - [x] Lab 不进入 production 正式业务导航，并由 bundle gate 验证；\n+- [ ] Registered async callback trace 可关联 request / callbackId / 实际 endpoint / callback 次数 / intermediate-terminal / timeout；扫码正文默认脱敏；\n - [ ] 当前 App WebView 对本轮 Lab 再跑一次真人 smoke：registered `getLoginToken` + 至少一次 Android Raw Probe；\n - [ ] 用户验收后才标记 Accepted。\n \ndiff --git a/scripts/verify-h015-jsbridge.mjs b/scripts/verify-h015-jsbridge.mjs\nindex 3a51702..8fb0d5f 100644\n--- a/scripts/verify-h015-jsbridge.mjs\n+++ b/scripts/verify-h015-jsbridge.mjs\n@@ -42,6 +42,8 @@ function collectFormalH5PageFiles(directory, pageRoot = path.normalize('src/page\n     }\n \n     if (!/\\.[jt]sx?$/.test(entry.name)) return []\n+    // Test fixtures exercise fake Native globals; only shipped page modules are subject to H015.\n+    if (/\\.test\\.[jt]sx?$/.test(entry.name)) return []\n     if (path.normalize(directory) === pageRoot && NATIVE_REFERENCE_TOP_LEVEL_PAGES.has(entry.name)) {\n       return []\n     }\ndiff --git a/src/app/router/index.tsx b/src/app/router/index.tsx\nindex 8fa14ea..31dec60 100644\n--- a/src/app/router/index.tsx\n+++ b/src/app/router/index.tsx\n@@ -251,6 +251,14 @@ const bridgeLabRoutes =\n             return { Component }\n           },\n         },\n+        {\n+          // Non-production test-only H5 probe; not a Native protocol or public binding URL.\n+          path: '/__debug/buddy-qr',\n+          lazy: async () => {\n+            const { default: Component } = await import('../../pages/BuddyQrProbe')\n+            return { Component }\n+          },\n+        },\n       ]\n \n // OSS artifacts are mounted at /h5/releases/<sha>/<run>/, whereas normal H5\ndiff --git a/src/components/mobile/BottomNav.test.tsx b/src/components/mobile/BottomNav.test.tsx\nindex c6a3c1a..1cbed4d 100644\n--- a/src/components/mobile/BottomNav.test.tsx\n+++ b/src/components/mobile/BottomNav.test.tsx\n@@ -53,7 +53,7 @@ describe('BottomNav middle scan item', () => {\n     fireEvent.click(button)\n \n     await waitFor(() => {\n-      expect(bridgeMocks.scanCode).toHaveBeenCalledWith({ scanType: 'all' })\n+      expect(bridgeMocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n     })\n   })\n \ndiff --git a/src/components/mobile/BottomNav.tsx b/src/components/mobile/BottomNav.tsx\nindex d0eaa41..25750c0 100644\n--- a/src/components/mobile/BottomNav.tsx\n+++ b/src/components/mobile/BottomNav.tsx\n@@ -93,7 +93,7 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n     if (!getNativeBridgeDiagnostics().capabilities.scanCode) return\n \n     try {\n-      await scanCode({ scanType: 'all' })\n+      await scanCode({ scanType: 'qr' })\n     } catch {\n       // 扫码取消 / 失败 / 超时均静默回落到当前页，不新增反馈链路。\n     }\ndiff --git a/src/pages/BridgeLab.test.tsx b/src/pages/BridgeLab.test.tsx\nindex 717e845..f7b8213 100644\n--- a/src/pages/BridgeLab.test.tsx\n+++ b/src/pages/BridgeLab.test.tsx\n@@ -164,6 +164,56 @@ describe('Bridge Lab page', () => {\n     expect(screen.queryByText(/bridge-lab-scan/)).toBeNull()\n   })\n \n+  it('shows scan callback correlation, actual endpoint, both stages, and redacts the QR text', async () => {\n+    usePlatform('android')\n+    let request: { scanType: string; callbackId: string } | undefined\n+    labWindow.androidBridge = {\n+      scanCode(payload: unknown) {\n+        request = JSON.parse(payload as string) as { scanType: string; callbackId: string }\n+      },\n+    }\n+\n+    render(<BridgeLab />)\n+    fireEvent.click(document.querySelector('[data-capability-name=\"scanCode\"]') as HTMLButtonElement)\n+    fireEvent.click(screen.getByRole('button', { name: '调用 scanCode' }))\n+\n+    await waitFor(() => {\n+      expect(request).toBeDefined()\n+      const invocationLog = document.querySelector('[data-bridge-lab-logs]')?.textContent ?? ''\n+      expect(invocationLog).toContain('Native invoke')\n+      expect(invocationLog).toContain('androidBridge')\n+      expect(invocationLog).toContain('scanCode')\n+    })\n+\n+    const logs = document.querySelector('[data-bridge-lab-logs]') as HTMLElement\n+    expect(logs.textContent).toContain(request!.callbackId)\n+    expect(logs.textContent).toContain('\"scanType\": \"all\"')\n+    expect(logs.textContent).toContain('window.nativeBridgeCallback')\n+    expect(logs.textContent).toContain('window.androidBridgeCallback')\n+\n+    labWindow.androidBridgeCallback?.(request!.callbackId, {\n+      code: 0,\n+      message: 'ok',\n+      data: { text: 'buddy-qr-private-value', scanType: 'qr' },\n+    })\n+\n+    await waitFor(() => expect(logs.textContent).toContain('intermediate'))\n+    expect(logs.textContent).toContain('\"text\": \"[REDACTED]\"')\n+    expect(logs.textContent).not.toContain('buddy-qr-private-value')\n+\n+    labWindow.androidBridgeCallback?.(request!.callbackId, {\n+      code: 0,\n+      message: 'ok',\n+      data: { text: 'buddy-qr-private-value', scanType: 'qr' },\n+    })\n+\n+    await waitFor(() => {\n+      expect(logs.textContent).toContain('terminal')\n+      expect(logs.textContent).toContain('callbackCount')\n+    })\n+    expect(logs.textContent).not.toContain('buddy-qr-private-value')\n+  })\n+\n   it('can invoke a registered H032 capability with editable JSON input', async () => {\n     usePlatform('android')\n     labWindow.androidBridge = {\ndiff --git a/src/pages/BridgeLab.tsx b/src/pages/BridgeLab.tsx\nindex 3684419..da12714 100644\n--- a/src/pages/BridgeLab.tsx\n+++ b/src/pages/BridgeLab.tsx\n@@ -1,4 +1,4 @@\n-import { useEffect, useMemo, useRef, useState } from 'react'\n+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'\n import {\n   Bug,\n   Eye,\n@@ -15,6 +15,7 @@ import {\n   getNativeBridgeCapabilityCatalog,\n   getNativeBridgeDiagnostics,\n   invokeRegisteredNativeCapabilityForDebug,\n+  subscribeNativeBridgeTransportDebugEvents,\n   type NativeCapabilityName,\n } from '../services/nativeBridge'\n import {\n@@ -34,7 +35,7 @@ type BridgeLabCallbackWindow = Window & {\n   androidBridgeCallback?: (callbackId: string, payload: unknown) => void\n   testFunc?: (params: unknown) => string\n }\n-type LogLevel = 'call' | 'result' | 'callback' | 'error'\n+type LogLevel = 'call' | 'result' | 'callback' | 'error' | 'timeout'\n \n type LabLog = {\n   id: string\n@@ -126,6 +127,79 @@ function errorValue(error: unknown): unknown {\n   return error\n }\n \n+function summarizeNativeArguments(args: readonly unknown[]) {\n+  return args.map((argument) => {\n+    if (typeof argument !== 'string') {\n+      return { type: argument === null ? 'null' : Array.isArray(argument) ? 'array' : typeof argument }\n+    }\n+    try {\n+      const parsed: unknown = JSON.parse(argument)\n+      if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {\n+        const record = parsed as Record<string, unknown>\n+        return {\n+          type: 'JSON string',\n+          keys: Object.keys(record),\n+          ...(typeof record.callbackId === 'string' ? { callbackId: record.callbackId } : {}),\n+          ...(typeof record.scanType === 'string' ? { scanType: record.scanType } : {}),\n+        }\n+      }\n+    } catch {\n+      // Plain strings are represented by length only; their contents may be sensitive.\n+    }\n+    return { type: 'string', length: argument.length }\n+  })\n+}\n+\n+function getNativePayloadType(payload: unknown): string {\n+  if (typeof payload === 'string') {\n+    try {\n+      JSON.parse(payload)\n+      return 'JSON string'\n+    } catch {\n+      return 'string'\n+    }\n+  }\n+  if (payload === null) return 'null'\n+  if (Array.isArray(payload)) return 'array'\n+  return typeof payload\n+}\n+\n+function redactNativeCallbackPayload(\n+  payload: unknown,\n+  methodName: string | undefined,\n+  forceSensitive: boolean,\n+) {\n+  if (methodName !== 'scanCode') return redactBridgeValue(payload, forceSensitive)\n+\n+  let normalized = payload\n+  if (typeof normalized === 'string') {\n+    try {\n+      normalized = JSON.parse(normalized)\n+    } catch {\n+      return { value: '[REDACTED]', redacted: true }\n+    }\n+  }\n+  const redaction = redactBridgeValue(normalized)\n+  if (redaction.value === null || typeof redaction.value !== 'object' || Array.isArray(redaction.value)) {\n+    return { value: '[REDACTED]', redacted: true }\n+  }\n+\n+  const record = redaction.value as Record<string, unknown>\n+  if (typeof record.code === 'string' && record.code.length > 0) {\n+    record.code = '[REDACTED]'\n+    redaction.redacted = true\n+  }\n+  const data = record.data\n+  if (data !== null && typeof data === 'object' && !Array.isArray(data)) {\n+    const dataRecord = data as Record<string, unknown>\n+    if (typeof dataRecord.text === 'string') {\n+      dataRecord.text = '[REDACTED]'\n+      redaction.redacted = true\n+    }\n+  }\n+  return redaction\n+}\n+\n function inputClassName() {\n   return 'h-10 w-full rounded-control border border-border bg-surface px-3 text-sm text-text-primary outline-none transition focus:border-reward-strong'\n }\n@@ -222,21 +296,23 @@ export default function BridgeLab() {\n     }\n   }\n \n-  const appendLog = (\n+  const appendLog = useCallback((\n     level: LogLevel,\n     title: string,\n     options: {\n       value?: unknown\n       forceSensitive?: boolean\n+      redaction?: { value: unknown; redacted: boolean }\n       durationMs?: number\n     } = {},\n   ) => {\n     sequence.current += 1\n     const id = `${Date.now()}-${sequence.current}`\n     const redaction =\n-      options.value === undefined\n+      options.redaction ??\n+      (options.value === undefined\n         ? { value: undefined, redacted: false }\n-        : redactBridgeValue(options.value, options.forceSensitive)\n+        : redactBridgeValue(options.value, options.forceSensitive))\n \n     setLogs((current) =>\n       [\n@@ -253,7 +329,70 @@ export default function BridgeLab() {\n         ...current,\n       ].slice(0, 50),\n     )\n-  }\n+  }, [])\n+\n+  useEffect(() => {\n+    if (!runtimePolicy.bridgeLabEnabled) return\n+\n+    return subscribeNativeBridgeTransportDebugEvents((event) => {\n+      if (event.type === 'request') {\n+        appendLog('call', `Native invoke · ${event.objectName}.${event.methodName}`, {\n+          value: {\n+            callbackId: event.callbackId,\n+            callbackEndpoints: event.callbackNames.map((name) => `window.${name}`),\n+            timeoutMs: event.timeoutMs,\n+            args: summarizeNativeArguments(event.args),\n+          },\n+        })\n+        return\n+      }\n+\n+      if (event.type === 'callback') {\n+        const capability = event.methodName\n+          ? platformCatalog.find((item) => item.name === event.methodName)\n+          : undefined\n+        const endpoint = event.callbackName ? `window.${event.callbackName}` : 'method return'\n+        const stage = !event.pendingMatched\n+          ? 'unmatched callbackId'\n+          : event.terminal === false\n+            ? 'intermediate'\n+            : event.terminal === true\n+              ? 'terminal'\n+              : 'stage unknown'\n+        const payloadRedaction = redactNativeCallbackPayload(\n+          event.payload,\n+          event.methodName,\n+          !event.pendingMatched || capability?.sensitiveResult === true,\n+        )\n+        const details = {\n+          callbackId: event.callbackId,\n+          endpoint,\n+          source: event.source,\n+          pendingMatched: event.pendingMatched,\n+          ...(event.callbackCount === undefined ? {} : { callbackCount: event.callbackCount }),\n+          ...(event.terminal === undefined ? {} : { terminal: event.terminal }),\n+          ...(event.classificationError ? { classificationError: event.classificationError } : {}),\n+          payloadType: getNativePayloadType(event.payload),\n+        }\n+        appendLog(\n+          'callback',\n+          `Native callback · ${endpoint} · ${stage}${event.callbackCount === undefined ? '' : ` · #${event.callbackCount}`}`,\n+          {\n+            value: { ...details, payload: event.payload },\n+            redaction: {\n+              value: { ...details, payload: payloadRedaction.value },\n+              redacted: payloadRedaction.redacted,\n+            },\n+          },\n+        )\n+        return\n+      }\n+\n+      appendLog('timeout', `Native callback timeout · ${event.objectName}.${event.methodName}`, {\n+        value: { callbackId: event.callbackId, timeoutMs: event.timeoutMs },\n+      })\n+    })\n+  }, [appendLog, platformCatalog])\n \n   const runRegisteredCapability = async () => {\n     if (!currentCapability) return\n@@ -270,7 +409,7 @@ export default function BridgeLab() {\n       }\n     }\n \n-    appendLog('call', `capability · ${currentCapability.name}`)\n+    appendLog('call', `H5 requested · capability · ${currentCapability.name}`)\n     const startedAt = performance.now()\n     try {\n       const result = await invokeRegisteredNativeCapabilityForDebug(\n@@ -484,7 +623,7 @@ export default function BridgeLab() {\n                   input JSON\n                 </label>\n                 <p className=\"mb-2 mt-1 text-[11px] leading-4 text-text-tertiary\">\n-                  异步 capability 的 callbackId 由 H5 Runtime 自动生成并注入，无需手填。\n+                  异步 capability 的 callbackId 由 H5 Runtime 自动生成并注入；日志会记录实际调用、回调入口、次数与阶段。扫码正文默认脱敏。\n                 </p>\n                 <textarea\n                   id=\"capability-input\"\n@@ -739,7 +878,7 @@ export default function BridgeLab() {\n               <h2 className=\"text-sm font-semibold\">Native → H5 callback endpoints</h2>\n               <p className=\"mt-1 text-xs leading-5 text-text-tertiary\">\n                 异步能力统一目标为 nativeBridgeCallback；Android 当前已验证的 androidBridgeCallback\n-                作为兼容入口接入同一个 callbackId pending channel。\n+                作为兼容入口接入同一个 callbackId pending channel。实际到达的入口与 callbackId 会自动写入 Lab logs。\n               </p>\n             </div>\n             <div className=\"mt-3 space-y-2 rounded-control bg-surface-subtle p-3 text-xs leading-5 text-text-secondary\">\ndiff --git a/src/pages/BuddyAccept.test.tsx b/src/pages/BuddyAccept.test.tsx\nindex 59ba684..58f7055 100644\n--- a/src/pages/BuddyAccept.test.tsx\n+++ b/src/pages/BuddyAccept.test.tsx\n@@ -81,6 +81,21 @@ describe('H5 scan invite confirmation', () => {\n     expect(mocks.accept).not.toHaveBeenCalled()\n   })\n \n+  it('shows the real inviter but disables unsupported QR relationship creation', async () => {\n+    mocks.mode = 'api'\n+    mocks.preview.mockResolvedValue({\n+      inviter: { id: '9', nickname: '真实邀请人', avatarUrl: null },\n+      relationship: 'available',\n+      demo: false,\n+      confirmationAvailable: false,\n+    })\n+    mount({ source: 'native-buddy-recognition', raw: 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000' })\n+    await waitFor(() => expect(screen.getByText(/真实邀请人邀请你成为洗头搭子/)).toBeTruthy())\n+    expect(screen.getByText(/后台尚未开放二维码确认/)).toBeTruthy()\n+    expect((screen.getByRole('button', { name: '确认成为搭子' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(mocks.accept).not.toHaveBeenCalled()\n+  })\n+\n   it('does not expose a fake acceptance in API mode without a native recognition result', async () => {\n     mocks.mode = 'api'\n     mount()\ndiff --git a/src/pages/BuddyAccept.tsx b/src/pages/BuddyAccept.tsx\nindex 1cb9a3a..6fbd1cb 100644\n--- a/src/pages/BuddyAccept.tsx\n+++ b/src/pages/BuddyAccept.tsx\n@@ -24,7 +24,7 @@ type ViewState =\n /**\n  * #107: consumes ONLY an in-App, route-state QR recognition result; never the public\n  * /buddy/invite/scan URL. #110 Native must preserve existing device/card redemption.\n- * API/test/prod have no signed #105 adapter yet and fail closed.\n+ * API/test/prod may preview the actual UUID owner, but QR relation writes remain blocked until #105.\n  */\n export default function BuddyAccept() {\n   const location = useLocation()\n@@ -106,7 +106,9 @@ export default function BuddyAccept() {\n           : relationship === 'self' ? '自己的二维码无法用于成为搭子'\n             : relationship === 'unavailable' ? '请确认二维码仍可使用'\n               : preview?.demo ? '演示用户，仅验证确认交互，不会绑定真实账号'\n-                : '确认后你们将成为洗头搭子'\n+                : preview?.confirmationAvailable === false\n+                  ? '已识别真实邀请人；后台尚未开放二维码确认，暂不能建立搭子关系'\n+                  : '确认后你们将成为洗头搭子'\n \n   return (\n     <>\n@@ -138,7 +140,7 @@ export default function BuddyAccept() {\n         {actionError && <p role=\"alert\" className=\"mt-2 text-sm text-danger-text\">{actionError}</p>}\n         {view.kind === 'ready' && relationship === 'available' && (\n           <Button size=\"large\" leadingIcon={UserRoundCheck} loading={sending}\n-            disabled={sending} className=\"mt-5 w-full rounded-full\" onClick={() => void confirm()}>\n+            disabled={sending || preview?.confirmationAvailable === false} className=\"mt-5 w-full rounded-full\" onClick={() => void confirm()}>\n             {preview?.demo ? '演示确认（不真实绑定）' : '确认成为搭子'}\n           </Button>\n         )}\ndiff --git a/src/pages/BuddyQrProbe.test.tsx b/src/pages/BuddyQrProbe.test.tsx\nnew file mode 100644\nindex 0000000..2d771bb\n--- /dev/null\n+++ b/src/pages/BuddyQrProbe.test.tsx\n@@ -0,0 +1,49 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'\n+import BuddyQrProbe from './BuddyQrProbe'\n+\n+const qr = 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000'\n+\n+function ReceiveState() {\n+  const location = useLocation()\n+  const scan = (location.state as { buddyScan?: { source: string; raw: string } } | null)?.buddyScan\n+  return <div data-testid=\"route-scan\">{scan?.source} / {scan?.raw}</div>\n+}\n+\n+function mount() {\n+  return render(\n+    <MemoryRouter initialEntries={['/__debug/buddy-qr']}>\n+      <Routes>\n+        <Route path=\"/__debug/buddy-qr\" element={<BuddyQrProbe />} />\n+        <Route path=\"/buddy/accept\" element={<ReceiveState />} />\n+      </Routes>\n+    </MemoryRouter>,\n+  )\n+}\n+\n+afterEach(() => vi.unstubAllEnvs())\n+\n+describe('#107 H5-only QR acceptance rehearsal entry', () => {\n+  it('transfers a validated QR only via internal route state, never public query parameters', async () => {\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', 'https://kbs.3cgroup.cn')\n+    mount()\n+    fireEvent.change(screen.getByLabelText('扫码结果 URL'), { target: { value: qr } })\n+    fireEvent.click(screen.getByRole('button', { name: '查询并打开邀请确认页' }))\n+    await waitFor(() => {\n+      expect(screen.getByTestId('route-scan').textContent).toContain('native-buddy-recognition')\n+      expect(screen.getByTestId('route-scan').textContent).toContain(qr)\n+    })\n+  })\n+\n+  it('rejects external hosts and cannot navigate without a trusted origin', () => {\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', 'https://kbs.3cgroup.cn')\n+    mount()\n+    fireEvent.change(screen.getByLabelText('扫码结果 URL'), {\n+      target: { value: qr.replace('kbs.3cgroup.cn', 'untrusted.example') },\n+    })\n+    fireEvent.click(screen.getByRole('button', { name: '查询并打开邀请确认页' }))\n+    expect(screen.getByRole('alert').textContent).toContain('不���有效的洗头搭子二维码')\n+    expect(screen.queryByTestId('route-scan')).toBeNull()\n+  })\n+})\ndiff --git a/src/pages/BuddyQrProbe.tsx b/src/pages/BuddyQrProbe.tsx\nnew file mode 100644\nindex 0000000..38a9488\n--- /dev/null\n+++ b/src/pages/BuddyQrProbe.tsx\n@@ -0,0 +1,65 @@\n+import { useState, type FormEvent } from 'react'\n+import { useNavigate } from 'react-router-dom'\n+import { parseBuddyInviteCode } from '../services/buddyRelations'\n+\n+/**\n+ * #107 H5-only test entry. It can create the same internal navigation state that a future\n+ * dedicated Native QR recognition handoff will supply, without changing the actual Bridge.\n+ * This module is only imported from the non-production debug route.\n+ */\n+export default function BuddyQrProbe() {\n+  const navigate = useNavigate()\n+  const [raw, setRaw] = useState('')\n+  const [error, setError] = useState<string | null>(null)\n+  const origin = import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN\n+\n+  const preview = (event: FormEvent<HTMLFormElement>) => {\n+    event.preventDefault()\n+    setError(null)\n+    if (!origin?.trim()) {\n+      setError('当前构建未配置官方二维码域名 VITE_BUDDY_PUBLIC_ORIGIN')\n+      return\n+    }\n+    try {\n+      parseBuddyInviteCode(raw, origin)\n+      navigate('/buddy/accept', {\n+        state: { buddyScan: { source: 'native-buddy-recognition', raw } },\n+      })\n+    } catch (cause) {\n+      setError(cause instanceof Error ? cause.message : '二维码内容无效')\n+    }\n+  }\n+\n+  return (\n+    <main className=\"min-h-dvh bg-background px-4 py-8 text-text-primary\">\n+      <div className=\"mx-auto max-w-xl rounded-container border border-border bg-surface p-5\">\n+        <h1 className=\"text-xl font-semibold\">搭子二维码 H5 验收</h1>\n+        <p className=\"mt-2 text-sm leading-6 text-text-secondary\">\n+          手动粘贴真实扫码返回的完整 HTTPS URL，验证 UUID 查询、邀请人资料与确认页。\n+          本入口不调用 Native，也不会代替真实扫码联调或创建搭子关系。\n+        </p>\n+        <p className=\"mt-2 break-all text-xs text-text-tertiary\">\n+          官方域名：{origin || '未配置'}\n+        </p>\n+        <form onSubmit={preview} className=\"mt-5\">\n+          <label className=\"block text-sm font-medium\" htmlFor=\"buddy-qr-input\">\n+            扫码结果 URL\n+          </label>\n+          <textarea\n+            id=\"buddy-qr-input\"\n+            value={raw}\n+            onChange={event => setRaw(event.target.value)}\n+            rows={4}\n+            className=\"mt-2 w-full rounded-control border border-border bg-surface px-3 py-2 font-mono text-xs text-text-primary\"\n+            placeholder=\"https://.../buddy/invite/scan?code=...\"\n+          />\n+          {error && <p role=\"alert\" className=\"mt-2 text-sm text-danger-text\">{error}</p>}\n+          <button type=\"submit\"\n+            className=\"mt-4 rounded-button bg-reward-strong px-4 py-2.5 text-sm font-semibold text-white\">\n+            查询并打开邀请确认页\n+          </button>\n+        </form>\n+      </div>\n+    </main>\n+  )\n+}\ndiff --git a/src/pages/ScanVerify.test.tsx b/src/pages/ScanVerify.test.tsx\nindex 3208786..299e9fa 100644\n--- a/src/pages/ScanVerify.test.tsx\n+++ b/src/pages/ScanVerify.test.tsx\n@@ -111,7 +111,7 @@ describe('ScanVerify', () => {\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n-      expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'all' })\n+      expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n     })\n     expect(mocks.navigate).toHaveBeenCalledWith('/card/verify/confirm', {\n       replace: true,\n@@ -140,6 +140,7 @@ describe('ScanVerify', () => {\n         state: { nativeScanCode: 'IOS-SCAN-CODE' },\n       })\n     })\n+    expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n     expect(mocks.navigate).not.toHaveBeenCalledWith(\n       '/card/verify/confirm',\n       expect.objectContaining({\ndiff --git a/src/pages/ScanVerify.tsx b/src/pages/ScanVerify.tsx\nindex d2f527d..0eac3a8 100644\n--- a/src/pages/ScanVerify.tsx\n+++ b/src/pages/ScanVerify.tsx\n@@ -48,7 +48,7 @@ export default function ScanVerify() {\n \n     try {\n       const host = getNativeHost()\n-      const result = await scanCode({ scanType: 'all' })\n+      const result = await scanCode({ scanType: 'qr' })\n \n       if (host === 'android') {\n         const state: ScanVerifyNavigationState = {\n@@ -86,7 +86,7 @@ export default function ScanVerify() {\n     ? '当前 App 版本暂不支持扫码'\n     : scanning\n       ? '正在调用系统扫码…'\n-      : error ?? '将二维码 / 条形码对准扫描框，点击开始扫码'\n+      : error ?? '将二维码对准扫描框，点击开始扫码'\n \n   return (\n     <div className=\"relative min-h-full bg-black text-white\">\ndiff --git a/src/services/buddyRelations.test.ts b/src/services/buddyRelations.test.ts\nindex e160cf1..7c48d97 100644\n--- a/src/services/buddyRelations.test.ts\n+++ b/src/services/buddyRelations.test.ts\n@@ -1,8 +1,24 @@\n-import { describe, expect, it, vi } from 'vitest'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n \n-vi.mock('../app/config/runtime', () => ({\n-  runtimePolicy: { dataMode: 'mock' },\n+const live = vi.hoisted(() => ({\n+  mode: { dataMode: 'mock' },\n+  lookup: vi.fn(),\n+  profile: vi.fn(),\n+  relations: vi.fn(),\n }))\n+vi.mock('../app/config/runtime', () => ({ runtimePolicy: live.mode }))\n+vi.mock('./buddyBackend', () => ({\n+  fetchUserByIdentifyCode: live.lookup,\n+  fetchBackendBuddyList: live.relations,\n+}))\n+vi.mock('./userProfile', () => ({ fetchUserProfile: live.profile }))\n+afterEach(() => {\n+  live.mode.dataMode = 'mock'\n+  live.lookup.mockReset()\n+  live.profile.mockReset()\n+  live.relations.mockReset()\n+  vi.unstubAllEnvs()\n+})\n \n import {\n   acceptBuddyQr,\n@@ -10,6 +26,7 @@ import {\n   MOCK_BUDDY_SCAN_URL,\n   previewBuddyQr,\n   readBuddyScanNavigation,\n+  parseBuddyInviteCode,\n   type BuddyRelationsBackend,\n } from './buddyRelations'\n \n@@ -83,3 +100,61 @@ describe('QR recognition and relationship backend boundary', () => {\n     await expect(acceptBuddyQr(valid)).rejects.toMatchObject({ reason: 'not-configured' })\n   })\n })\n+\n+describe('real H5 UUID invitation preview without the unsigned binding API', () => {\n+  const origin = 'https://kbs.3cgroup.cn'\n+  const uuid = '550e8400-e29b-41d4-a716-446655440000'\n+  const qr = `${origin}/buddy/invite/scan?code=${uuid}`\n+\n+  function prepare() {\n+    live.mode.dataMode = 'api'\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', origin)\n+    live.lookup.mockResolvedValue({ id: '9', nickname: '真实邀请人', avatarUrl: null })\n+    live.profile.mockResolvedValue({ identifyCode: '550e8400-e29b-41d4-a716-446655440111' })\n+    live.relations.mockResolvedValue({ items: [] })\n+  }\n+\n+  it('parses exactly the trusted UUID and reads real account data without any write', async () => {\n+    prepare()\n+    expect(parseBuddyInviteCode(qr, origin)).toBe(uuid)\n+    const preview = await previewBuddyQr(qr)\n+    expect(preview).toEqual({\n+      demo: false,\n+      inviter: { id: '9', nickname: '真实邀请人', avatarUrl: null },\n+      relationship: 'available',\n+      confirmationAvailable: false,\n+    })\n+    expect(live.lookup).toHaveBeenCalledWith(uuid)\n+    expect(live.profile).toHaveBeenCalledOnce()\n+    expect(live.relations).toHaveBeenCalledOnce()\n+    await expect(acceptBuddyQr(qr)).rejects.toMatchObject({ reason: 'not-configured' })\n+  })\n+\n+  it('uses real logged-in identity and existing list for self / already-related states', async () => {\n+    prepare()\n+    live.profile.mockResolvedValue({ identifyCode: uuid })\n+    expect((await previewBuddyQr(qr)).relationship).toBe('self')\n+    live.profile.mockResolvedValue({ identifyCode: '550e8400-e29b-41d4-a716-446655440111' })\n+    live.relations.mockResolvedValue({ items: [{ id: '9', nickname: '真实邀请人', avatarUrl: null }] })\n+    expect((await previewBuddyQr(qr)).relationship).toBe('already-buddies')\n+  })\n+\n+  it.each([\n+    'http://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000',\n+    'https://evil.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000',\n+    'https://kbs.3cgroup.cn/buddy/invite/scan?code=invalid',\n+    'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000&code=550e8400-e29b-41d4-a716-446655440111',\n+    'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000&redirect=/buddy/accept',\n+    '550e8400-e29b-41d4-a716-446655440000',\n+  ])('rejects untrusted or malformed recognition, never queries the backend: %s', async raw => {\n+    prepare()\n+    await expect(previewBuddyQr(raw)).rejects.toMatchObject({ reason: 'invalid-code' })\n+    expect(live.lookup).not.toHaveBeenCalled()\n+  })\n+\n+  it('fails closed on a read endpoint outage and never reports relation creation', async () => {\n+    prepare()\n+    live.relations.mockRejectedValue(new Error('down'))\n+    await expect(previewBuddyQr(qr)).rejects.toMatchObject({ reason: 'request-failed' })\n+  })\n+})\ndiff --git a/src/services/buddyRelations.ts b/src/services/buddyRelations.ts\nindex 207f4e4..cd6ea68 100644\n--- a/src/services/buddyRelations.ts\n+++ b/src/services/buddyRelations.ts\n@@ -1,7 +1,8 @@\n import { z } from 'zod'\n import { runtimePolicy } from '../app/config/runtime'\n import { validateBuddyQrUrl } from './buddyQr'\n-import { fetchBackendBuddyList } from './buddyBackend'\n+import { fetchBackendBuddyList, fetchUserByIdentifyCode } from './buddyBackend'\n+import { fetchUserProfile } from './userProfile'\n \n /**\n  * #107 H5-only contract. #105 Backend and #110 Native have not signed their APIs:\n@@ -19,7 +20,7 @@ const previewSchema = z.object({\n   inviter: memberSchema,\n   relationship: z.enum(['available', 'self', 'already-buddies', 'unavailable']),\n })\n-export type BuddyPreview = z.infer<typeof previewSchema> & { demo: boolean }\n+export type BuddyPreview = z.infer<typeof previewSchema> & { demo: boolean; confirmationAvailable?: boolean }\n export type BuddyPreviewStatus = BuddyPreview['relationship']\n \n const acceptSchema = z.object({\n@@ -63,6 +64,22 @@ export function readBuddyScanNavigation(value: unknown): string | null {\n     raw.length > 0 && raw.length <= 2048 ? raw : null\n }\n \n+/** QR recognition is always a complete, trusted HTTPS URL, never a naked UUID or an external query-driven action. */\n+export function parseBuddyInviteCode(raw: string, trustedOrigin: string): string {\n+  try {\n+    validateBuddyQrUrl(raw, trustedOrigin)\n+    const url = new URL(raw)\n+    const values = url.searchParams.getAll('code')\n+    if ([...url.searchParams.keys()].length !== 1 || values.length !== 1 ||\n+        !z.string().uuid().safeParse(values[0]).success) {\n+      throw new Error('unsupported QR parameters')\n+    }\n+    return values[0]!\n+  } catch {\n+    throw new BuddyRelationsError('invalid-code')\n+  }\n+}\n+\n function checkQr(raw: string, adapter?: BuddyRelationsBackend): boolean {\n   // Reject obviously malformed QR data before the backend is wired. A well-formed\n   // QR still requires #105's authenticated server verification, not a client-side guess.\n@@ -91,6 +108,29 @@ export async function previewBuddyQr(\n   raw: string,\n   adapter: BuddyRelationsBackend | null = signedBackend,\n ): Promise<BuddyPreview> {\n+  // #107 independent H5 slice: existing authenticated read-only backend endpoints.\n+  // Relation creation stays blocked until #105 provides its dedicated authorized QR acceptance.\n+  if (!adapter && runtimePolicy.dataMode === 'api') {\n+    const origin = import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN\n+    if (!origin?.trim()) throw new BuddyRelationsError('not-configured')\n+    const code = parseBuddyInviteCode(raw, origin)\n+    try {\n+      const [inviter, profile, relations] = await Promise.all([\n+        fetchUserByIdentifyCode(code),\n+        fetchUserProfile(),\n+        fetchBackendBuddyList(),\n+      ])\n+      const relationship: BuddyPreviewStatus = profile.identifyCode === code\n+        ? 'self'\n+        : relations.items.some(member => member.id === inviter.id)\n+          ? 'already-buddies'\n+          : 'available'\n+      return { inviter, relationship, demo: false, confirmationAvailable: false }\n+    } catch (error) {\n+      if (error instanceof BuddyRelationsError) throw error\n+      throw new BuddyRelationsError('request-failed')\n+    }\n+  }\n   const demo = checkQr(raw, adapter ?? undefined)\n   if (demo) return {\n     inviter: { id: 'demo-buddy-inviter', nickname: '演示搭子', avatarUrl: null },\ndiff --git a/src/services/chatHuman.test.ts b/src/services/chatHuman.test.ts\nindex 4100475..711a2fa 100644\n--- a/src/services/chatHuman.test.ts\n+++ b/src/services/chatHuman.test.ts\n@@ -48,7 +48,8 @@ describe('#138 Socket.IO client contract', () => {\n     const status = vi.fn(), onMessage = vi.fn(), onConnected = vi.fn()\n     const handle = connectChatSocket('secret-token', { onStatus: status, onMessage, onConnected })\n     expect(mocks.io).toHaveBeenCalledWith('https://api.example.test/socket.io', expect.objectContaining({\n-      auth: { token: 'Bearer secret-token' }, path: '/socket.io/', autoConnect: false, reconnection: true,\n+      auth: { token: 'Bearer secret-token' }, path: '/socket.io/', transports: ['websocket'],\n+      autoConnect: false, reconnection: true,\n     }))\n     expect(mocks.socket.connect).toHaveBeenCalledOnce()\n     mocks.socket.connected = true\ndiff --git a/src/services/chatHuman.ts b/src/services/chatHuman.ts\nindex bca529c..7dc2adc 100644\n--- a/src/services/chatHuman.ts\n+++ b/src/services/chatHuman.ts\n@@ -27,6 +27,7 @@ export function connectChatSocket(token: string, callbacks: ChatSocketEvents): C\n   // Keep it separate from path, which configures the Engine.IO transport endpoint.\n   const socket: Socket = io(`${runtimePolicy.socketBaseUrl ?? new URL(base).origin}${CHAT_SOCKET_NAMESPACE}`, {\n     path: runtimePolicy.socketPath ?? '/socket.io',\n+    transports: ['websocket'],\n     auth: { token: `Bearer ${token}` },\n     autoConnect: false,\n     reconnection: true,\ndiff --git a/src/services/nativeBridge.ts b/src/services/nativeBridge.ts\nindex 18ba72c..b6dda21 100644\n--- a/src/services/nativeBridge.ts\n+++ b/src/services/nativeBridge.ts\n@@ -23,6 +23,8 @@ import type {\n } from './nativeBridge/types'\n \n export { NativeBridgeError } from './nativeBridge/errors'\n+export { subscribeNativeBridgeTransportDebugEvents } from './nativeBridgeTransport'\n+export type { NativeBridgeTransportDebugEvent } from './nativeBridgeTransport'\n export {\n   getNativeBridgeCapabilityCatalog,\n   getNativeBridgeDiagnostics,\ndiff --git a/src/services/nativeBridgeTransport.callback.test.ts b/src/services/nativeBridgeTransport.callback.test.ts\nindex 916b01a..1c0c059 100644\n--- a/src/services/nativeBridgeTransport.callback.test.ts\n+++ b/src/services/nativeBridgeTransport.callback.test.ts\n@@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'\n import {\n   createCallbackInjectedObjectTransport,\n   INJECTED_CALLBACK_TIMEOUT_MS,\n+  subscribeNativeBridgeTransportDebugEvents,\n+  type NativeBridgeTransportDebugEvent,\n   type NativeTransportResolution,\n   type NativeTransportWindow,\n } from './nativeBridgeTransport'\n@@ -126,6 +128,102 @@ describe('Android injected callback dispatcher', () => {\n     await expect(promise).resolves.toBe('device-finished')\n   })\n \n+  it('emits request correlation and callback-stage diagnostics without changing settlement', async () => {\n+    const events: NativeBridgeTransportDebugEvent[] = []\n+    const unsubscribe = subscribeNativeBridgeTransportDebugEvents((event) => events.push(event))\n+    const requests: Array<{ scanType: string; callbackId: string }> = []\n+    hostWindow.androidBridge = {\n+      scanCode(payload: unknown) {\n+        requests.push(JSON.parse(payload as string) as { scanType: string; callbackId: string })\n+      },\n+    }\n+    const transport = createCallbackInjectedObjectTransport<'all', string>({\n+      objectName: 'androidBridge',\n+      methodName: 'scanCode',\n+      callbackName: 'nativeBridgeCallback',\n+      callbackAliases: ['androidBridgeCallback'],\n+      timeoutMs: 1_000,\n+      serializeArgs: (scanType, callbackId) => [JSON.stringify({ scanType, callbackId })],\n+      isTerminalPayload: (_payload, callbackCount) => callbackCount > 1,\n+      parseResult: (payload) => (payload as { result: string }).result,\n+    })\n+\n+    try {\n+      const promise = invoke(transport.resolve(hostWindow, 'all'))\n+      const request = requests[0]\n+      expect(request).toBeDefined()\n+      expect(events[0]).toMatchObject({\n+        type: 'request',\n+        objectName: 'androidBridge',\n+        methodName: 'scanCode',\n+        callbackId: request.callbackId,\n+        callbackNames: ['nativeBridgeCallback', 'androidBridgeCallback'],\n+        timeoutMs: 1_000,\n+        args: [expect.stringContaining(request.callbackId)],\n+      })\n+\n+      hostWindow.androidBridgeCallback?.(request.callbackId, { result: 'scan-recognized' })\n+      expect(events[1]).toMatchObject({\n+        type: 'callback',\n+        objectName: 'androidBridge',\n+        methodName: 'scanCode',\n+        callbackId: request.callbackId,\n+        callbackName: 'androidBridgeCallback',\n+        source: 'callback',\n+        callbackCount: 1,\n+        pendingMatched: true,\n+        terminal: false,\n+      })\n+\n+      hostWindow.nativeBridgeCallback?.(request.callbackId, { result: 'device-finished' })\n+      expect(events[2]).toMatchObject({\n+        type: 'callback',\n+        callbackId: request.callbackId,\n+        callbackName: 'nativeBridgeCallback',\n+        source: 'callback',\n+        callbackCount: 2,\n+        pendingMatched: true,\n+        terminal: true,\n+      })\n+      await expect(promise).resolves.toBe('device-finished')\n+\n+      hostWindow.androidBridgeCallback?.('unmatched-callback-id', { error: 'unknown' })\n+      expect(events[3]).toMatchObject({\n+        type: 'callback',\n+        callbackId: 'unmatched-callback-id',\n+        callbackName: 'androidBridgeCallback',\n+        pendingMatched: false,\n+      })\n+      expect(events).toHaveLength(4)\n+    } finally {\n+      unsubscribe()\n+    }\n+  })\n+\n+  it('emits a correlated timeout event and still rejects the pending invocation', async () => {\n+    vi.useFakeTimers()\n+    const events: NativeBridgeTransportDebugEvent[] = []\n+    const unsubscribe = subscribeNativeBridgeTransportDebugEvents((event) => events.push(event))\n+    hostWindow.androidBridge = { scanCode() {} }\n+    const transport = createProbeTransport('scanCode', 25)\n+\n+    try {\n+      const promise = invoke(transport.resolve(hostWindow, 'waiting'))\n+      const rejection = expect(promise).rejects.toMatchObject({ code: 'callback-timeout' })\n+      await vi.advanceTimersByTimeAsync(25)\n+      await rejection\n+      expect(events).toContainEqual(expect.objectContaining({\n+        type: 'timeout',\n+        objectName: 'androidBridge',\n+        methodName: 'scanCode',\n+        timeoutMs: 25,\n+        callbackId: expect.any(String),\n+      }))\n+    } finally {\n+      unsubscribe()\n+    }\n+  })\n+\n   it('resolves out-of-order callbacks by callbackId instead of invocation order', async () => {\n     const requests: CallbackRequest[] = []\n     hostWindow.androidBridge = {\ndiff --git a/src/services/nativeBridgeTransport.ts b/src/services/nativeBridgeTransport.ts\nindex b80362d..5370299 100644\n--- a/src/services/nativeBridgeTransport.ts\n+++ b/src/services/nativeBridgeTransport.ts\n@@ -211,6 +211,59 @@ export type InjectedCallbackName =\n   | 'nativeBridgeCallback'\n   | 'androidBridgeCallback'\n \n+export type NativeBridgeTransportDebugEvent =\n+  | {\n+      type: 'request'\n+      objectName: string\n+      methodName: string\n+      callbackId: string\n+      callbackNames: readonly InjectedCallbackName[]\n+      timeoutMs: number\n+      args: readonly unknown[]\n+    }\n+  | {\n+      type: 'callback'\n+      objectName?: string\n+      methodName?: string\n+      callbackId: string\n+      callbackName: InjectedCallbackName | null\n+      source: 'callback' | 'method-return'\n+      callbackCount?: number\n+      pendingMatched: boolean\n+      terminal?: boolean\n+      classificationError?: string\n+      payload: unknown\n+    }\n+  | {\n+      type: 'timeout'\n+      objectName: string\n+      methodName: string\n+      callbackId: string\n+      timeoutMs: number\n+    }\n+\n+type NativeBridgeTransportDebugListener = (event: NativeBridgeTransportDebugEvent) => void\n+const nativeBridgeTransportDebugListeners = new Set<NativeBridgeTransportDebugListener>()\n+\n+/** Passive Bridge Lab observer. Events are delivered only to active debug listeners. */\n+export function subscribeNativeBridgeTransportDebugEvents(\n+  listener: NativeBridgeTransportDebugListener,\n+): () => void {\n+  nativeBridgeTransportDebugListeners.add(listener)\n+  return () => nativeBridgeTransportDebugListeners.delete(listener)\n+}\n+\n+function emitNativeBridgeTransportDebugEvent(event: NativeBridgeTransportDebugEvent): void {\n+  if (nativeBridgeTransportDebugListeners.size === 0) return\n+  for (const listener of nativeBridgeTransportDebugListeners) {\n+    try {\n+      listener(event)\n+    } catch {\n+      // Diagnostics must never interfere with Native callback delivery or Promise settlement.\n+    }\n+  }\n+}\n+\n export interface CallbackInjectedObjectTransportConfig<TInput, TResult>\n   extends Omit<InjectedObjectTransportConfig<TInput, TResult>, 'serializeArgs'> {\n   serializeArgs: (input: TInput, callbackId: string) => readonly unknown[]\n@@ -232,7 +285,7 @@ export interface CallbackInjectedObjectTransportConfig<TInput, TResult>\n }\n \n type InjectedCallbackPending = {\n-  handle: (payload: unknown) => void\n+  handle: (payload: unknown, callbackName: InjectedCallbackName) => void\n }\n \n type InjectedCallbackDispatcher = (callbackId: string, payload: unknown) => void\n@@ -265,9 +318,17 @@ function installInjectedCallbackEndpoint(\n     dispatcher = (callbackId, payload) => {\n       const request = channel.pending.get(callbackId)\n       if (request) {\n-        request.handle(payload)\n+        request.handle(payload, callbackName)\n         return\n       }\n+      emitNativeBridgeTransportDebugEvent({\n+        type: 'callback',\n+        callbackId,\n+        callbackName,\n+        source: 'callback',\n+        pendingMatched: false,\n+        payload,\n+      })\n       channel.fallbacks.get(callbackName)?.call(hostWindow, callbackId, payload)\n     }\n     channel.dispatchers.set(callbackName, dispatcher)\n@@ -348,6 +409,7 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n           }\n \n           const callbackName = config.callbackName ?? 'nativeBridgeCallback'\n+          const callbackNames = [...new Set([callbackName, ...(config.callbackAliases ?? [])])]\n           const channel = getInjectedCallbackChannel(\n             hostWindow,\n             callbackName,\n@@ -377,13 +439,29 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n               cleanup()\n               work()\n             }\n-            const handlePayload = (payload: unknown) => {\n+            const handlePayload = (\n+              payload: unknown,\n+              source: 'callback' | 'method-return',\n+              endpoint: InjectedCallbackName | null,\n+            ) => {\n               callbackCount += 1\n \n               let isTerminal = true\n               try {\n                 isTerminal = config.isTerminalPayload?.(payload, callbackCount) ?? true\n               } catch (error) {\n+                emitNativeBridgeTransportDebugEvent({\n+                  type: 'callback',\n+                  objectName: config.objectName,\n+                  methodName: config.methodName,\n+                  callbackId,\n+                  callbackName: endpoint,\n+                  source,\n+                  callbackCount,\n+                  pendingMatched: true,\n+                  classificationError: error instanceof Error ? error.message : String(error),\n+                  payload,\n+                })\n                 finish(() =>\n                   reject(\n                     error instanceof NativeTransportError\n@@ -398,6 +476,19 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n                 return\n               }\n \n+              emitNativeBridgeTransportDebugEvent({\n+                type: 'callback',\n+                objectName: config.objectName,\n+                methodName: config.methodName,\n+                callbackId,\n+                callbackName: endpoint,\n+                source,\n+                callbackCount,\n+                pendingMatched: true,\n+                terminal: isTerminal,\n+                payload,\n+              })\n+\n               if (!isTerminal) return\n \n               finish(() => {\n@@ -409,8 +500,17 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n               })\n             }\n \n-            channel.pending.set(callbackId, { handle: handlePayload })\n+            channel.pending.set(callbackId, {\n+              handle: (payload, endpoint) => handlePayload(payload, 'callback', endpoint),\n+            })\n             const timer = window.setTimeout(() => {\n+              emitNativeBridgeTransportDebugEvent({\n+                type: 'timeout',\n+                objectName: config.objectName,\n+                methodName: config.methodName,\n+                callbackId,\n+                timeoutMs,\n+              })\n               finish(() =>\n                 reject(\n                   new NativeTransportError(\n@@ -423,8 +523,17 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n \n             try {\n               const args = config.serializeArgs(input, callbackId)\n+              emitNativeBridgeTransportDebugEvent({\n+                type: 'request',\n+                objectName: config.objectName,\n+                methodName: config.methodName,\n+                callbackId,\n+                callbackNames,\n+                timeoutMs,\n+                args,\n+              })\n               const rawResult = method.call(bridge, ...args)\n-              if (rawResult !== undefined) handlePayload(rawResult)\n+              if (rawResult !== undefined) handlePayload(rawResult, 'method-return', null)\n             } catch (error) {\n               finish(() =>\n                 reject(\ndiff --git a/tests/e2e/bridge-lab.spec.ts b/tests/e2e/bridge-lab.spec.ts\nindex 6f416f7..7bcc433 100644\n--- a/tests/e2e/bridge-lab.spec.ts\n+++ b/tests/e2e/bridge-lab.spec.ts\n@@ -59,6 +59,46 @@ test.describe('H027 Bridge Lab', () => {\n     await expect(logs).toContainText('\"success\": true')\n   })\n \n+  test('shows correlated Android scan callback stages without exposing QR text by default', async ({ page }) => {\n+    await page.addInitScript(() => {\n+      const host = window as unknown as {\n+        androidBridge?: { scanCode(payload: unknown): void }\n+        androidBridgeCallback?: (callbackId: string, payload: unknown) => void\n+      }\n+      host.androidBridge = {\n+        scanCode(payload) {\n+          const request = JSON.parse(payload as string) as { callbackId: string }\n+          setTimeout(() => {\n+            host.androidBridgeCallback?.(request.callbackId, {\n+              code: 0,\n+              message: 'ok',\n+              data: { text: 'e2e-buddy-qr-private', scanType: 'qr' },\n+            })\n+          }, 0)\n+          setTimeout(() => {\n+            host.androidBridgeCallback?.(request.callbackId, {\n+              code: 0,\n+              message: 'ok',\n+              data: { text: 'e2e-buddy-qr-private', scanType: 'qr' },\n+            })\n+          }, 20)\n+        },\n+      }\n+    })\n+\n+    await page.goto('/__debug/bridge-lab?osType=android', { waitUntil: 'domcontentloaded' })\n+    await page.locator('[data-capability-name=\"scanCode\"]').click()\n+    await page.getByRole('button', { name: '调用 scanCode' }).click()\n+\n+    const logs = page.locator('[data-bridge-lab-logs]')\n+    await expect(logs).toContainText('Native invoke · androidBridge.scanCode')\n+    await expect(logs).toContainText('androidBridgeCallback')\n+    await expect(logs).toContainText('intermediate')\n+    await expect(logs).toContainText('terminal')\n+    await expect(logs).toContainText('[REDACTED]')\n+    await expect(logs).not.toContainText('e2e-buddy-qr-private')\n+  })\n+\n   test('Native can still call the legacy window.testFunc endpoint', async ({ page }) => {\n     await page.goto('/__debug/bridge-lab?osType=android', { waitUntil: 'domcontentloaded' })",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/2d84bc7aa346a4c9e31d6bfd641abf9974e948ff",
            "commitHash": "2d84bc7aa346a4c9e31d6bfd641abf9974e948ff",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/288",
            "prTitle": "promote(dev→test): Bridge Lab callback observability",
            "prBaseHash": "5b2b55f6cf5f92784085d90b2ec4dbfa12a6767a",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/42586315077"
          },
          "gitCommit": {
            "shortHash": "2d84bc7",
            "hash": "2d84bc7aa346a4c9e31d6bfd641abf9974e948ff",
            "subject": "Merge 39e3906b82dafb8b9552f80f6c7520bd6cb0a5c5 into 6d07918b5d011880bc674c401c72829daa6a85a3",
            "body": "Merge 39e3906b82dafb8b9552f80f6c7520bd6cb0a5c5 into 6d07918b5d011880bc674c401c72829daa6a85a3\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1794212965000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1794212965000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/workbench/evidence/h027-bridge-lab.md b/docs/workbench/evidence/h027-bridge-lab.md\nindex 61d9eeb..ba7fecc 100644\n--- a/docs/workbench/evidence/h027-bridge-lab.md\n+++ b/docs/workbench/evidence/h027-bridge-lab.md\n@@ -179,3 +179,30 @@ H027 Bridge Lab 因此保持两层：\n - iOS Raw Probe：仍保留旧 `getAuthorizationInfo + onToken` 预设，用于旧 App build / 历史协议诊断。\n \n 该更新不删除 H026 messageHandler transport；它只改变当前 production auth protocol 的事实来源。\n+\n+## 11. Async callback observability\n+\n+Bridge Lab subscribes to a passive diagnostic stream emitted by the existing injected-object callback transport. The stream does not create a second callback channel and does not alter request settlement.\n+\n+Each registered async invocation can be inspected through:\n+\n+- Native object and method actually invoked;\n+- generated callbackId and callback endpoints configured for the request;\n+- safe argument shape summary (JSON keys plus callbackId / scanType only; arbitrary string contents are not copied into logs);\n+- actual callback endpoint used by Native;\n+- callbackId pending match / unmatched status;\n+- callback count and capability-specific intermediate / terminal classification;\n+- callback payload type and timeout.\n+\n+Privacy boundary:\n+\n+- scan callback envelope metadata such as numeric Native code, message and scanType remain visible for diagnosis;\n+- scanned QR text (including a legacy top-level string `code`) is masked in the default log view;\n+- explicit reveal remains a user action and exposes only the in-memory current log item;\n+- unknown / unmatched callbacks are fully masked because their originating capability cannot be established;\n+- the observer is subscribed only while Bridge Lab is mounted; with no subscribers, diagnostic events are not delivered or retained;\n+- listener exceptions are isolated and cannot interrupt Native callback dispatch or Promise settlement.\n+\n+The added automated tests cover request correlation, both Android callback aliases, callback counts and stage classification, unmatched callbacks, timeout events, component log rendering and Playwright browser smoke.\n+\n+**Validation boundary:** these tests use stub Native hosts and prove the H5 diagnostic mechanism only. They do not prove that the current App build emits a callback, that the real Native callbackId matches, or that Android/iOS scan business flows work. A real App WebView scan is still required after the change reaches the test environment.\ndiff --git a/docs/workbench/tasks/H027-bridge-lab.md b/docs/workbench/tasks/H027-bridge-lab.md\nindex 36eae8e..32bbb5d 100644\n--- a/docs/workbench/tasks/H027-bridge-lab.md\n+++ b/docs/workbench/tasks/H027-bridge-lab.md\n@@ -12,6 +12,7 @@\n - Android / iOS 宿主协议可通过隔离的 Raw Probe 临时探测；\n - 不为每个新增 Native 方法继续手写一套固定按钮；\n - 调试信息可观察，但敏感返回默认脱敏；\n+- 异步 Native callback 可按 callbackId 追踪实际入口、回调次数、中间态/终态与超时；扫码原文默认脱敏；\n - Lab 只服务 dev / preview / test 联调，不进入 production 正式业务导航或 production bundle。\n \n ## 当前事实\n@@ -29,6 +30,14 @@\n - 已确认的 capability 通过同一 production runtime/facade 链调用；\n - 浏览器或宿主缺失时明确显示 unsupported，不 fake success。\n \n+### Async callback trace\n+\n+- 记录 Native 方法实际调用、object/method、callbackId、候选 callback endpoints 与安全的参数摘要；\n+- 记录每次 Native callback 实际抵达的 endpoint、callbackId 是否命中 pending、回调次数、payload 类型及 capability 阶段判定；\n+- 记录 callback timeout；未匹配 callbackId 也要可见，并继续保持既有 fallback 行为；\n+- callback observer 仅在 Bridge Lab 挂载时订阅；没有 observer 时 transport 不向订阅者分发或保留诊断事件；\n+- 扫码 payload 保留 envelope 状态字段用于诊断，但默认遮蔽二维码正文；显式 reveal 仍受现有调试动作控制��\n+\n ### Android Raw Probe\n \n 支持动态输入：\n@@ -74,6 +83,7 @@\n - iOS Raw Probe 复用 H026 transport，并按 callback name 持久化 single-flight channel 状态；\n - timeout 后 channel 保持 fail-closed，显式 reset 才恢复；\n - Lab 日志记录开始、耗时、结果与错误，并默认脱敏；\n+- Registered async callback 增加被动诊断事件：Native 调用、callbackId、实际回调入口、回调计数、阶段判定、未匹配回调与超时；扫码正文默认遮蔽；\n - production 使用编译期 route gate + lazy import，CI 额外扫描 dist，避免 Bridge Lab / Raw Probe 落入 production bundle；\n - 新增 Vitest / React component / Playwright browser smoke 覆盖。\n \n@@ -87,6 +97,7 @@\n - [x] 敏感返回默认脱敏，raw 查看需要显式动作；\n - [x] 普通浏览器无宿主对象时明确 unsupported；\n - [x] Lab 不进入 production 正式业务导航，并由 bundle gate 验证；\n+- [ ] Registered async callback trace 可关联 request / callbackId / 实际 endpoint / callback 次数 / intermediate-terminal / timeout；扫码正文默认脱敏；\n - [ ] 当前 App WebView 对本轮 Lab 再跑一次真人 smoke：registered `getLoginToken` + 至少一次 Android Raw Probe；\n - [ ] 用户验收后才标记 Accepted。\n \ndiff --git a/scripts/verify-h015-jsbridge.mjs b/scripts/verify-h015-jsbridge.mjs\nindex 3a51702..8fb0d5f 100644\n--- a/scripts/verify-h015-jsbridge.mjs\n+++ b/scripts/verify-h015-jsbridge.mjs\n@@ -42,6 +42,8 @@ function collectFormalH5PageFiles(directory, pageRoot = path.normalize('src/page\n     }\n \n     if (!/\\.[jt]sx?$/.test(entry.name)) return []\n+    // Test fixtures exercise fake Native globals; only shipped page modules are subject to H015.\n+    if (/\\.test\\.[jt]sx?$/.test(entry.name)) return []\n     if (path.normalize(directory) === pageRoot && NATIVE_REFERENCE_TOP_LEVEL_PAGES.has(entry.name)) {\n       return []\n     }\ndiff --git a/src/app/router/index.tsx b/src/app/router/index.tsx\nindex 8fa14ea..31dec60 100644\n--- a/src/app/router/index.tsx\n+++ b/src/app/router/index.tsx\n@@ -251,6 +251,14 @@ const bridgeLabRoutes =\n             return { Component }\n           },\n         },\n+        {\n+          // Non-production test-only H5 probe; not a Native protocol or public binding URL.\n+          path: '/__debug/buddy-qr',\n+          lazy: async () => {\n+            const { default: Component } = await import('../../pages/BuddyQrProbe')\n+            return { Component }\n+          },\n+        },\n       ]\n \n // OSS artifacts are mounted at /h5/releases/<sha>/<run>/, whereas normal H5\ndiff --git a/src/components/mobile/BottomNav.test.tsx b/src/components/mobile/BottomNav.test.tsx\nindex c6a3c1a..1cbed4d 100644\n--- a/src/components/mobile/BottomNav.test.tsx\n+++ b/src/components/mobile/BottomNav.test.tsx\n@@ -53,7 +53,7 @@ describe('BottomNav middle scan item', () => {\n     fireEvent.click(button)\n \n     await waitFor(() => {\n-      expect(bridgeMocks.scanCode).toHaveBeenCalledWith({ scanType: 'all' })\n+      expect(bridgeMocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n     })\n   })\n \ndiff --git a/src/components/mobile/BottomNav.tsx b/src/components/mobile/BottomNav.tsx\nindex d0eaa41..25750c0 100644\n--- a/src/components/mobile/BottomNav.tsx\n+++ b/src/components/mobile/BottomNav.tsx\n@@ -93,7 +93,7 @@ export default function BottomNav({ variant = 'main' }: { variant?: 'main' | 'le\n     if (!getNativeBridgeDiagnostics().capabilities.scanCode) return\n \n     try {\n-      await scanCode({ scanType: 'all' })\n+      await scanCode({ scanType: 'qr' })\n     } catch {\n       // 扫码取消 / 失败 / 超时均静默回落到当前页，不新增反馈链路。\n     }\ndiff --git a/src/pages/BridgeLab.test.tsx b/src/pages/BridgeLab.test.tsx\nindex 717e845..f7b8213 100644\n--- a/src/pages/BridgeLab.test.tsx\n+++ b/src/pages/BridgeLab.test.tsx\n@@ -164,6 +164,56 @@ describe('Bridge Lab page', () => {\n     expect(screen.queryByText(/bridge-lab-scan/)).toBeNull()\n   })\n \n+  it('shows scan callback correlation, actual endpoint, both stages, and redacts the QR text', async () => {\n+    usePlatform('android')\n+    let request: { scanType: string; callbackId: string } | undefined\n+    labWindow.androidBridge = {\n+      scanCode(payload: unknown) {\n+        request = JSON.parse(payload as string) as { scanType: string; callbackId: string }\n+      },\n+    }\n+\n+    render(<BridgeLab />)\n+    fireEvent.click(document.querySelector('[data-capability-name=\"scanCode\"]') as HTMLButtonElement)\n+    fireEvent.click(screen.getByRole('button', { name: '调用 scanCode' }))\n+\n+    await waitFor(() => {\n+      expect(request).toBeDefined()\n+      const invocationLog = document.querySelector('[data-bridge-lab-logs]')?.textContent ?? ''\n+      expect(invocationLog).toContain('Native invoke')\n+      expect(invocationLog).toContain('androidBridge')\n+      expect(invocationLog).toContain('scanCode')\n+    })\n+\n+    const logs = document.querySelector('[data-bridge-lab-logs]') as HTMLElement\n+    expect(logs.textContent).toContain(request!.callbackId)\n+    expect(logs.textContent).toContain('\"scanType\": \"all\"')\n+    expect(logs.textContent).toContain('window.nativeBridgeCallback')\n+    expect(logs.textContent).toContain('window.androidBridgeCallback')\n+\n+    labWindow.androidBridgeCallback?.(request!.callbackId, {\n+      code: 0,\n+      message: 'ok',\n+      data: { text: 'buddy-qr-private-value', scanType: 'qr' },\n+    })\n+\n+    await waitFor(() => expect(logs.textContent).toContain('intermediate'))\n+    expect(logs.textContent).toContain('\"text\": \"[REDACTED]\"')\n+    expect(logs.textContent).not.toContain('buddy-qr-private-value')\n+\n+    labWindow.androidBridgeCallback?.(request!.callbackId, {\n+      code: 0,\n+      message: 'ok',\n+      data: { text: 'buddy-qr-private-value', scanType: 'qr' },\n+    })\n+\n+    await waitFor(() => {\n+      expect(logs.textContent).toContain('terminal')\n+      expect(logs.textContent).toContain('callbackCount')\n+    })\n+    expect(logs.textContent).not.toContain('buddy-qr-private-value')\n+  })\n+\n   it('can invoke a registered H032 capability with editable JSON input', async () => {\n     usePlatform('android')\n     labWindow.androidBridge = {\ndiff --git a/src/pages/BridgeLab.tsx b/src/pages/BridgeLab.tsx\nindex 3684419..da12714 100644\n--- a/src/pages/BridgeLab.tsx\n+++ b/src/pages/BridgeLab.tsx\n@@ -1,4 +1,4 @@\n-import { useEffect, useMemo, useRef, useState } from 'react'\n+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'\n import {\n   Bug,\n   Eye,\n@@ -15,6 +15,7 @@ import {\n   getNativeBridgeCapabilityCatalog,\n   getNativeBridgeDiagnostics,\n   invokeRegisteredNativeCapabilityForDebug,\n+  subscribeNativeBridgeTransportDebugEvents,\n   type NativeCapabilityName,\n } from '../services/nativeBridge'\n import {\n@@ -34,7 +35,7 @@ type BridgeLabCallbackWindow = Window & {\n   androidBridgeCallback?: (callbackId: string, payload: unknown) => void\n   testFunc?: (params: unknown) => string\n }\n-type LogLevel = 'call' | 'result' | 'callback' | 'error'\n+type LogLevel = 'call' | 'result' | 'callback' | 'error' | 'timeout'\n \n type LabLog = {\n   id: string\n@@ -126,6 +127,79 @@ function errorValue(error: unknown): unknown {\n   return error\n }\n \n+function summarizeNativeArguments(args: readonly unknown[]) {\n+  return args.map((argument) => {\n+    if (typeof argument !== 'string') {\n+      return { type: argument === null ? 'null' : Array.isArray(argument) ? 'array' : typeof argument }\n+    }\n+    try {\n+      const parsed: unknown = JSON.parse(argument)\n+      if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {\n+        const record = parsed as Record<string, unknown>\n+        return {\n+          type: 'JSON string',\n+          keys: Object.keys(record),\n+          ...(typeof record.callbackId === 'string' ? { callbackId: record.callbackId } : {}),\n+          ...(typeof record.scanType === 'string' ? { scanType: record.scanType } : {}),\n+        }\n+      }\n+    } catch {\n+      // Plain strings are represented by length only; their contents may be sensitive.\n+    }\n+    return { type: 'string', length: argument.length }\n+  })\n+}\n+\n+function getNativePayloadType(payload: unknown): string {\n+  if (typeof payload === 'string') {\n+    try {\n+      JSON.parse(payload)\n+      return 'JSON string'\n+    } catch {\n+      return 'string'\n+    }\n+  }\n+  if (payload === null) return 'null'\n+  if (Array.isArray(payload)) return 'array'\n+  return typeof payload\n+}\n+\n+function redactNativeCallbackPayload(\n+  payload: unknown,\n+  methodName: string | undefined,\n+  forceSensitive: boolean,\n+) {\n+  if (methodName !== 'scanCode') return redactBridgeValue(payload, forceSensitive)\n+\n+  let normalized = payload\n+  if (typeof normalized === 'string') {\n+    try {\n+      normalized = JSON.parse(normalized)\n+    } catch {\n+      return { value: '[REDACTED]', redacted: true }\n+    }\n+  }\n+  const redaction = redactBridgeValue(normalized)\n+  if (redaction.value === null || typeof redaction.value !== 'object' || Array.isArray(redaction.value)) {\n+    return { value: '[REDACTED]', redacted: true }\n+  }\n+\n+  const record = redaction.value as Record<string, unknown>\n+  if (typeof record.code === 'string' && record.code.length > 0) {\n+    record.code = '[REDACTED]'\n+    redaction.redacted = true\n+  }\n+  const data = record.data\n+  if (data !== null && typeof data === 'object' && !Array.isArray(data)) {\n+    const dataRecord = data as Record<string, unknown>\n+    if (typeof dataRecord.text === 'string') {\n+      dataRecord.text = '[REDACTED]'\n+      redaction.redacted = true\n+    }\n+  }\n+  return redaction\n+}\n+\n function inputClassName() {\n   return 'h-10 w-full rounded-control border border-border bg-surface px-3 text-sm text-text-primary outline-none transition focus:border-reward-strong'\n }\n@@ -222,21 +296,23 @@ export default function BridgeLab() {\n     }\n   }\n \n-  const appendLog = (\n+  const appendLog = useCallback((\n     level: LogLevel,\n     title: string,\n     options: {\n       value?: unknown\n       forceSensitive?: boolean\n+      redaction?: { value: unknown; redacted: boolean }\n       durationMs?: number\n     } = {},\n   ) => {\n     sequence.current += 1\n     const id = `${Date.now()}-${sequence.current}`\n     const redaction =\n-      options.value === undefined\n+      options.redaction ??\n+      (options.value === undefined\n         ? { value: undefined, redacted: false }\n-        : redactBridgeValue(options.value, options.forceSensitive)\n+        : redactBridgeValue(options.value, options.forceSensitive))\n \n     setLogs((current) =>\n       [\n@@ -253,7 +329,70 @@ export default function BridgeLab() {\n         ...current,\n       ].slice(0, 50),\n     )\n-  }\n+  }, [])\n+\n+  useEffect(() => {\n+    if (!runtimePolicy.bridgeLabEnabled) return\n+\n+    return subscribeNativeBridgeTransportDebugEvents((event) => {\n+      if (event.type === 'request') {\n+        appendLog('call', `Native invoke · ${event.objectName}.${event.methodName}`, {\n+          value: {\n+            callbackId: event.callbackId,\n+            callbackEndpoints: event.callbackNames.map((name) => `window.${name}`),\n+            timeoutMs: event.timeoutMs,\n+            args: summarizeNativeArguments(event.args),\n+          },\n+        })\n+        return\n+      }\n+\n+      if (event.type === 'callback') {\n+        const capability = event.methodName\n+          ? platformCatalog.find((item) => item.name === event.methodName)\n+          : undefined\n+        const endpoint = event.callbackName ? `window.${event.callbackName}` : 'method return'\n+        const stage = !event.pendingMatched\n+          ? 'unmatched callbackId'\n+          : event.terminal === false\n+            ? 'intermediate'\n+            : event.terminal === true\n+              ? 'terminal'\n+              : 'stage unknown'\n+        const payloadRedaction = redactNativeCallbackPayload(\n+          event.payload,\n+          event.methodName,\n+          !event.pendingMatched || capability?.sensitiveResult === true,\n+        )\n+        const details = {\n+          callbackId: event.callbackId,\n+          endpoint,\n+          source: event.source,\n+          pendingMatched: event.pendingMatched,\n+          ...(event.callbackCount === undefined ? {} : { callbackCount: event.callbackCount }),\n+          ...(event.terminal === undefined ? {} : { terminal: event.terminal }),\n+          ...(event.classificationError ? { classificationError: event.classificationError } : {}),\n+          payloadType: getNativePayloadType(event.payload),\n+        }\n+        appendLog(\n+          'callback',\n+          `Native callback · ${endpoint} · ${stage}${event.callbackCount === undefined ? '' : ` · #${event.callbackCount}`}`,\n+          {\n+            value: { ...details, payload: event.payload },\n+            redaction: {\n+              value: { ...details, payload: payloadRedaction.value },\n+              redacted: payloadRedaction.redacted,\n+            },\n+          },\n+        )\n+        return\n+      }\n+\n+      appendLog('timeout', `Native callback timeout · ${event.objectName}.${event.methodName}`, {\n+        value: { callbackId: event.callbackId, timeoutMs: event.timeoutMs },\n+      })\n+    })\n+  }, [appendLog, platformCatalog])\n \n   const runRegisteredCapability = async () => {\n     if (!currentCapability) return\n@@ -270,7 +409,7 @@ export default function BridgeLab() {\n       }\n     }\n \n-    appendLog('call', `capability · ${currentCapability.name}`)\n+    appendLog('call', `H5 requested · capability · ${currentCapability.name}`)\n     const startedAt = performance.now()\n     try {\n       const result = await invokeRegisteredNativeCapabilityForDebug(\n@@ -484,7 +623,7 @@ export default function BridgeLab() {\n                   input JSON\n                 </label>\n                 <p className=\"mb-2 mt-1 text-[11px] leading-4 text-text-tertiary\">\n-                  异步 capability 的 callbackId 由 H5 Runtime 自动生成并注入，无需手填。\n+                  异步 capability 的 callbackId 由 H5 Runtime 自动生成并注入；日志会记录实际调用、回调入口、次数与阶段。扫码正文默认脱敏。\n                 </p>\n                 <textarea\n                   id=\"capability-input\"\n@@ -739,7 +878,7 @@ export default function BridgeLab() {\n               <h2 className=\"text-sm font-semibold\">Native → H5 callback endpoints</h2>\n               <p className=\"mt-1 text-xs leading-5 text-text-tertiary\">\n                 异步能力统一目标为 nativeBridgeCallback；Android 当前已验证的 androidBridgeCallback\n-                作为兼容入口接入同一个 callbackId pending channel。\n+                作为兼容入口接入同一个 callbackId pending channel。实际到达的入口与 callbackId 会自动写入 Lab logs。\n               </p>\n             </div>\n             <div className=\"mt-3 space-y-2 rounded-control bg-surface-subtle p-3 text-xs leading-5 text-text-secondary\">\ndiff --git a/src/pages/BuddyAccept.test.tsx b/src/pages/BuddyAccept.test.tsx\nindex 59ba684..58f7055 100644\n--- a/src/pages/BuddyAccept.test.tsx\n+++ b/src/pages/BuddyAccept.test.tsx\n@@ -81,6 +81,21 @@ describe('H5 scan invite confirmation', () => {\n     expect(mocks.accept).not.toHaveBeenCalled()\n   })\n \n+  it('shows the real inviter but disables unsupported QR relationship creation', async () => {\n+    mocks.mode = 'api'\n+    mocks.preview.mockResolvedValue({\n+      inviter: { id: '9', nickname: '真实邀请人', avatarUrl: null },\n+      relationship: 'available',\n+      demo: false,\n+      confirmationAvailable: false,\n+    })\n+    mount({ source: 'native-buddy-recognition', raw: 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000' })\n+    await waitFor(() => expect(screen.getByText(/真实邀请人邀请你成为洗头搭子/)).toBeTruthy())\n+    expect(screen.getByText(/后台尚未开放二维码确认/)).toBeTruthy()\n+    expect((screen.getByRole('button', { name: '确认成为搭子' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(mocks.accept).not.toHaveBeenCalled()\n+  })\n+\n   it('does not expose a fake acceptance in API mode without a native recognition result', async () => {\n     mocks.mode = 'api'\n     mount()\ndiff --git a/src/pages/BuddyAccept.tsx b/src/pages/BuddyAccept.tsx\nindex 1cb9a3a..6fbd1cb 100644\n--- a/src/pages/BuddyAccept.tsx\n+++ b/src/pages/BuddyAccept.tsx\n@@ -24,7 +24,7 @@ type ViewState =\n /**\n  * #107: consumes ONLY an in-App, route-state QR recognition result; never the public\n  * /buddy/invite/scan URL. #110 Native must preserve existing device/card redemption.\n- * API/test/prod have no signed #105 adapter yet and fail closed.\n+ * API/test/prod may preview the actual UUID owner, but QR relation writes remain blocked until #105.\n  */\n export default function BuddyAccept() {\n   const location = useLocation()\n@@ -106,7 +106,9 @@ export default function BuddyAccept() {\n           : relationship === 'self' ? '自己的二维码无法用于成为搭子'\n             : relationship === 'unavailable' ? '请确认二维码仍可使用'\n               : preview?.demo ? '演示用户，仅验证确认交互，不会绑定真实账号'\n-                : '确认后你们将成为洗头搭子'\n+                : preview?.confirmationAvailable === false\n+                  ? '已识别真实邀请人；后台尚未开放二维码确认，暂不能建立搭子关系'\n+                  : '确认后你们将成为洗头搭子'\n \n   return (\n     <>\n@@ -138,7 +140,7 @@ export default function BuddyAccept() {\n         {actionError && <p role=\"alert\" className=\"mt-2 text-sm text-danger-text\">{actionError}</p>}\n         {view.kind === 'ready' && relationship === 'available' && (\n           <Button size=\"large\" leadingIcon={UserRoundCheck} loading={sending}\n-            disabled={sending} className=\"mt-5 w-full rounded-full\" onClick={() => void confirm()}>\n+            disabled={sending || preview?.confirmationAvailable === false} className=\"mt-5 w-full rounded-full\" onClick={() => void confirm()}>\n             {preview?.demo ? '演示确认（不真实绑定）' : '确认成为搭子'}\n           </Button>\n         )}\ndiff --git a/src/pages/BuddyQrProbe.test.tsx b/src/pages/BuddyQrProbe.test.tsx\nnew file mode 100644\nindex 0000000..2d771bb\n--- /dev/null\n+++ b/src/pages/BuddyQrProbe.test.tsx\n@@ -0,0 +1,49 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'\n+import BuddyQrProbe from './BuddyQrProbe'\n+\n+const qr = 'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000'\n+\n+function ReceiveState() {\n+  const location = useLocation()\n+  const scan = (location.state as { buddyScan?: { source: string; raw: string } } | null)?.buddyScan\n+  return <div data-testid=\"route-scan\">{scan?.source} / {scan?.raw}</div>\n+}\n+\n+function mount() {\n+  return render(\n+    <MemoryRouter initialEntries={['/__debug/buddy-qr']}>\n+      <Routes>\n+        <Route path=\"/__debug/buddy-qr\" element={<BuddyQrProbe />} />\n+        <Route path=\"/buddy/accept\" element={<ReceiveState />} />\n+      </Routes>\n+    </MemoryRouter>,\n+  )\n+}\n+\n+afterEach(() => vi.unstubAllEnvs())\n+\n+describe('#107 H5-only QR acceptance rehearsal entry', () => {\n+  it('transfers a validated QR only via internal route state, never public query parameters', async () => {\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', 'https://kbs.3cgroup.cn')\n+    mount()\n+    fireEvent.change(screen.getByLabelText('扫码结果 URL'), { target: { value: qr } })\n+    fireEvent.click(screen.getByRole('button', { name: '查询并打开邀请确认页' }))\n+    await waitFor(() => {\n+      expect(screen.getByTestId('route-scan').textContent).toContain('native-buddy-recognition')\n+      expect(screen.getByTestId('route-scan').textContent).toContain(qr)\n+    })\n+  })\n+\n+  it('rejects external hosts and cannot navigate without a trusted origin', () => {\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', 'https://kbs.3cgroup.cn')\n+    mount()\n+    fireEvent.change(screen.getByLabelText('扫码结果 URL'), {\n+      target: { value: qr.replace('kbs.3cgroup.cn', 'untrusted.example') },\n+    })\n+    fireEvent.click(screen.getByRole('button', { name: '查询并打开邀请确认页' }))\n+    expect(screen.getByRole('alert').textContent).toContain('不���有效的洗头搭子二维码')\n+    expect(screen.queryByTestId('route-scan')).toBeNull()\n+  })\n+})\ndiff --git a/src/pages/BuddyQrProbe.tsx b/src/pages/BuddyQrProbe.tsx\nnew file mode 100644\nindex 0000000..38a9488\n--- /dev/null\n+++ b/src/pages/BuddyQrProbe.tsx\n@@ -0,0 +1,65 @@\n+import { useState, type FormEvent } from 'react'\n+import { useNavigate } from 'react-router-dom'\n+import { parseBuddyInviteCode } from '../services/buddyRelations'\n+\n+/**\n+ * #107 H5-only test entry. It can create the same internal navigation state that a future\n+ * dedicated Native QR recognition handoff will supply, without changing the actual Bridge.\n+ * This module is only imported from the non-production debug route.\n+ */\n+export default function BuddyQrProbe() {\n+  const navigate = useNavigate()\n+  const [raw, setRaw] = useState('')\n+  const [error, setError] = useState<string | null>(null)\n+  const origin = import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN\n+\n+  const preview = (event: FormEvent<HTMLFormElement>) => {\n+    event.preventDefault()\n+    setError(null)\n+    if (!origin?.trim()) {\n+      setError('当前构建未配置官方二维码域名 VITE_BUDDY_PUBLIC_ORIGIN')\n+      return\n+    }\n+    try {\n+      parseBuddyInviteCode(raw, origin)\n+      navigate('/buddy/accept', {\n+        state: { buddyScan: { source: 'native-buddy-recognition', raw } },\n+      })\n+    } catch (cause) {\n+      setError(cause instanceof Error ? cause.message : '二维码内容无效')\n+    }\n+  }\n+\n+  return (\n+    <main className=\"min-h-dvh bg-background px-4 py-8 text-text-primary\">\n+      <div className=\"mx-auto max-w-xl rounded-container border border-border bg-surface p-5\">\n+        <h1 className=\"text-xl font-semibold\">搭子二维码 H5 验收</h1>\n+        <p className=\"mt-2 text-sm leading-6 text-text-secondary\">\n+          手动粘贴真实扫码返回的完整 HTTPS URL，验证 UUID 查询、邀请人资料与确认页。\n+          本入口不调用 Native，也不会代替真实扫码联调或创建搭子关系。\n+        </p>\n+        <p className=\"mt-2 break-all text-xs text-text-tertiary\">\n+          官方域名：{origin || '未配置'}\n+        </p>\n+        <form onSubmit={preview} className=\"mt-5\">\n+          <label className=\"block text-sm font-medium\" htmlFor=\"buddy-qr-input\">\n+            扫码结果 URL\n+          </label>\n+          <textarea\n+            id=\"buddy-qr-input\"\n+            value={raw}\n+            onChange={event => setRaw(event.target.value)}\n+            rows={4}\n+            className=\"mt-2 w-full rounded-control border border-border bg-surface px-3 py-2 font-mono text-xs text-text-primary\"\n+            placeholder=\"https://.../buddy/invite/scan?code=...\"\n+          />\n+          {error && <p role=\"alert\" className=\"mt-2 text-sm text-danger-text\">{error}</p>}\n+          <button type=\"submit\"\n+            className=\"mt-4 rounded-button bg-reward-strong px-4 py-2.5 text-sm font-semibold text-white\">\n+            查询并打开邀请确认页\n+          </button>\n+        </form>\n+      </div>\n+    </main>\n+  )\n+}\ndiff --git a/src/pages/ScanVerify.test.tsx b/src/pages/ScanVerify.test.tsx\nindex 3208786..299e9fa 100644\n--- a/src/pages/ScanVerify.test.tsx\n+++ b/src/pages/ScanVerify.test.tsx\n@@ -111,7 +111,7 @@ describe('ScanVerify', () => {\n     fireEvent.click(screen.getByRole('button', { name: '开始扫码核销' }))\n \n     await waitFor(() => {\n-      expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'all' })\n+      expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n     })\n     expect(mocks.navigate).toHaveBeenCalledWith('/card/verify/confirm', {\n       replace: true,\n@@ -140,6 +140,7 @@ describe('ScanVerify', () => {\n         state: { nativeScanCode: 'IOS-SCAN-CODE' },\n       })\n     })\n+    expect(mocks.scanCode).toHaveBeenCalledWith({ scanType: 'qr' })\n     expect(mocks.navigate).not.toHaveBeenCalledWith(\n       '/card/verify/confirm',\n       expect.objectContaining({\ndiff --git a/src/pages/ScanVerify.tsx b/src/pages/ScanVerify.tsx\nindex d2f527d..0eac3a8 100644\n--- a/src/pages/ScanVerify.tsx\n+++ b/src/pages/ScanVerify.tsx\n@@ -48,7 +48,7 @@ export default function ScanVerify() {\n \n     try {\n       const host = getNativeHost()\n-      const result = await scanCode({ scanType: 'all' })\n+      const result = await scanCode({ scanType: 'qr' })\n \n       if (host === 'android') {\n         const state: ScanVerifyNavigationState = {\n@@ -86,7 +86,7 @@ export default function ScanVerify() {\n     ? '当前 App 版本暂不支持扫码'\n     : scanning\n       ? '正在调用系统扫码…'\n-      : error ?? '将二维码 / 条形码对准扫描框，点击开始扫码'\n+      : error ?? '将二维码对准扫描框，点击开始扫码'\n \n   return (\n     <div className=\"relative min-h-full bg-black text-white\">\ndiff --git a/src/services/buddyRelations.test.ts b/src/services/buddyRelations.test.ts\nindex e160cf1..7c48d97 100644\n--- a/src/services/buddyRelations.test.ts\n+++ b/src/services/buddyRelations.test.ts\n@@ -1,8 +1,24 @@\n-import { describe, expect, it, vi } from 'vitest'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n \n-vi.mock('../app/config/runtime', () => ({\n-  runtimePolicy: { dataMode: 'mock' },\n+const live = vi.hoisted(() => ({\n+  mode: { dataMode: 'mock' },\n+  lookup: vi.fn(),\n+  profile: vi.fn(),\n+  relations: vi.fn(),\n }))\n+vi.mock('../app/config/runtime', () => ({ runtimePolicy: live.mode }))\n+vi.mock('./buddyBackend', () => ({\n+  fetchUserByIdentifyCode: live.lookup,\n+  fetchBackendBuddyList: live.relations,\n+}))\n+vi.mock('./userProfile', () => ({ fetchUserProfile: live.profile }))\n+afterEach(() => {\n+  live.mode.dataMode = 'mock'\n+  live.lookup.mockReset()\n+  live.profile.mockReset()\n+  live.relations.mockReset()\n+  vi.unstubAllEnvs()\n+})\n \n import {\n   acceptBuddyQr,\n@@ -10,6 +26,7 @@ import {\n   MOCK_BUDDY_SCAN_URL,\n   previewBuddyQr,\n   readBuddyScanNavigation,\n+  parseBuddyInviteCode,\n   type BuddyRelationsBackend,\n } from './buddyRelations'\n \n@@ -83,3 +100,61 @@ describe('QR recognition and relationship backend boundary', () => {\n     await expect(acceptBuddyQr(valid)).rejects.toMatchObject({ reason: 'not-configured' })\n   })\n })\n+\n+describe('real H5 UUID invitation preview without the unsigned binding API', () => {\n+  const origin = 'https://kbs.3cgroup.cn'\n+  const uuid = '550e8400-e29b-41d4-a716-446655440000'\n+  const qr = `${origin}/buddy/invite/scan?code=${uuid}`\n+\n+  function prepare() {\n+    live.mode.dataMode = 'api'\n+    vi.stubEnv('VITE_BUDDY_PUBLIC_ORIGIN', origin)\n+    live.lookup.mockResolvedValue({ id: '9', nickname: '真实邀请人', avatarUrl: null })\n+    live.profile.mockResolvedValue({ identifyCode: '550e8400-e29b-41d4-a716-446655440111' })\n+    live.relations.mockResolvedValue({ items: [] })\n+  }\n+\n+  it('parses exactly the trusted UUID and reads real account data without any write', async () => {\n+    prepare()\n+    expect(parseBuddyInviteCode(qr, origin)).toBe(uuid)\n+    const preview = await previewBuddyQr(qr)\n+    expect(preview).toEqual({\n+      demo: false,\n+      inviter: { id: '9', nickname: '真实邀请人', avatarUrl: null },\n+      relationship: 'available',\n+      confirmationAvailable: false,\n+    })\n+    expect(live.lookup).toHaveBeenCalledWith(uuid)\n+    expect(live.profile).toHaveBeenCalledOnce()\n+    expect(live.relations).toHaveBeenCalledOnce()\n+    await expect(acceptBuddyQr(qr)).rejects.toMatchObject({ reason: 'not-configured' })\n+  })\n+\n+  it('uses real logged-in identity and existing list for self / already-related states', async () => {\n+    prepare()\n+    live.profile.mockResolvedValue({ identifyCode: uuid })\n+    expect((await previewBuddyQr(qr)).relationship).toBe('self')\n+    live.profile.mockResolvedValue({ identifyCode: '550e8400-e29b-41d4-a716-446655440111' })\n+    live.relations.mockResolvedValue({ items: [{ id: '9', nickname: '真实邀请人', avatarUrl: null }] })\n+    expect((await previewBuddyQr(qr)).relationship).toBe('already-buddies')\n+  })\n+\n+  it.each([\n+    'http://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000',\n+    'https://evil.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000',\n+    'https://kbs.3cgroup.cn/buddy/invite/scan?code=invalid',\n+    'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000&code=550e8400-e29b-41d4-a716-446655440111',\n+    'https://kbs.3cgroup.cn/buddy/invite/scan?code=550e8400-e29b-41d4-a716-446655440000&redirect=/buddy/accept',\n+    '550e8400-e29b-41d4-a716-446655440000',\n+  ])('rejects untrusted or malformed recognition, never queries the backend: %s', async raw => {\n+    prepare()\n+    await expect(previewBuddyQr(raw)).rejects.toMatchObject({ reason: 'invalid-code' })\n+    expect(live.lookup).not.toHaveBeenCalled()\n+  })\n+\n+  it('fails closed on a read endpoint outage and never reports relation creation', async () => {\n+    prepare()\n+    live.relations.mockRejectedValue(new Error('down'))\n+    await expect(previewBuddyQr(qr)).rejects.toMatchObject({ reason: 'request-failed' })\n+  })\n+})\ndiff --git a/src/services/buddyRelations.ts b/src/services/buddyRelations.ts\nindex 207f4e4..cd6ea68 100644\n--- a/src/services/buddyRelations.ts\n+++ b/src/services/buddyRelations.ts\n@@ -1,7 +1,8 @@\n import { z } from 'zod'\n import { runtimePolicy } from '../app/config/runtime'\n import { validateBuddyQrUrl } from './buddyQr'\n-import { fetchBackendBuddyList } from './buddyBackend'\n+import { fetchBackendBuddyList, fetchUserByIdentifyCode } from './buddyBackend'\n+import { fetchUserProfile } from './userProfile'\n \n /**\n  * #107 H5-only contract. #105 Backend and #110 Native have not signed their APIs:\n@@ -19,7 +20,7 @@ const previewSchema = z.object({\n   inviter: memberSchema,\n   relationship: z.enum(['available', 'self', 'already-buddies', 'unavailable']),\n })\n-export type BuddyPreview = z.infer<typeof previewSchema> & { demo: boolean }\n+export type BuddyPreview = z.infer<typeof previewSchema> & { demo: boolean; confirmationAvailable?: boolean }\n export type BuddyPreviewStatus = BuddyPreview['relationship']\n \n const acceptSchema = z.object({\n@@ -63,6 +64,22 @@ export function readBuddyScanNavigation(value: unknown): string | null {\n     raw.length > 0 && raw.length <= 2048 ? raw : null\n }\n \n+/** QR recognition is always a complete, trusted HTTPS URL, never a naked UUID or an external query-driven action. */\n+export function parseBuddyInviteCode(raw: string, trustedOrigin: string): string {\n+  try {\n+    validateBuddyQrUrl(raw, trustedOrigin)\n+    const url = new URL(raw)\n+    const values = url.searchParams.getAll('code')\n+    if ([...url.searchParams.keys()].length !== 1 || values.length !== 1 ||\n+        !z.string().uuid().safeParse(values[0]).success) {\n+      throw new Error('unsupported QR parameters')\n+    }\n+    return values[0]!\n+  } catch {\n+    throw new BuddyRelationsError('invalid-code')\n+  }\n+}\n+\n function checkQr(raw: string, adapter?: BuddyRelationsBackend): boolean {\n   // Reject obviously malformed QR data before the backend is wired. A well-formed\n   // QR still requires #105's authenticated server verification, not a client-side guess.\n@@ -91,6 +108,29 @@ export async function previewBuddyQr(\n   raw: string,\n   adapter: BuddyRelationsBackend | null = signedBackend,\n ): Promise<BuddyPreview> {\n+  // #107 independent H5 slice: existing authenticated read-only backend endpoints.\n+  // Relation creation stays blocked until #105 provides its dedicated authorized QR acceptance.\n+  if (!adapter && runtimePolicy.dataMode === 'api') {\n+    const origin = import.meta.env.VITE_BUDDY_PUBLIC_ORIGIN\n+    if (!origin?.trim()) throw new BuddyRelationsError('not-configured')\n+    const code = parseBuddyInviteCode(raw, origin)\n+    try {\n+      const [inviter, profile, relations] = await Promise.all([\n+        fetchUserByIdentifyCode(code),\n+        fetchUserProfile(),\n+        fetchBackendBuddyList(),\n+      ])\n+      const relationship: BuddyPreviewStatus = profile.identifyCode === code\n+        ? 'self'\n+        : relations.items.some(member => member.id === inviter.id)\n+          ? 'already-buddies'\n+          : 'available'\n+      return { inviter, relationship, demo: false, confirmationAvailable: false }\n+    } catch (error) {\n+      if (error instanceof BuddyRelationsError) throw error\n+      throw new BuddyRelationsError('request-failed')\n+    }\n+  }\n   const demo = checkQr(raw, adapter ?? undefined)\n   if (demo) return {\n     inviter: { id: 'demo-buddy-inviter', nickname: '演示搭子', avatarUrl: null },\ndiff --git a/src/services/chatHuman.test.ts b/src/services/chatHuman.test.ts\nindex 4100475..711a2fa 100644\n--- a/src/services/chatHuman.test.ts\n+++ b/src/services/chatHuman.test.ts\n@@ -48,7 +48,8 @@ describe('#138 Socket.IO client contract', () => {\n     const status = vi.fn(), onMessage = vi.fn(), onConnected = vi.fn()\n     const handle = connectChatSocket('secret-token', { onStatus: status, onMessage, onConnected })\n     expect(mocks.io).toHaveBeenCalledWith('https://api.example.test/socket.io', expect.objectContaining({\n-      auth: { token: 'Bearer secret-token' }, path: '/socket.io/', autoConnect: false, reconnection: true,\n+      auth: { token: 'Bearer secret-token' }, path: '/socket.io/', transports: ['websocket'],\n+      autoConnect: false, reconnection: true,\n     }))\n     expect(mocks.socket.connect).toHaveBeenCalledOnce()\n     mocks.socket.connected = true\ndiff --git a/src/services/chatHuman.ts b/src/services/chatHuman.ts\nindex bca529c..7dc2adc 100644\n--- a/src/services/chatHuman.ts\n+++ b/src/services/chatHuman.ts\n@@ -27,6 +27,7 @@ export function connectChatSocket(token: string, callbacks: ChatSocketEvents): C\n   // Keep it separate from path, which configures the Engine.IO transport endpoint.\n   const socket: Socket = io(`${runtimePolicy.socketBaseUrl ?? new URL(base).origin}${CHAT_SOCKET_NAMESPACE}`, {\n     path: runtimePolicy.socketPath ?? '/socket.io',\n+    transports: ['websocket'],\n     auth: { token: `Bearer ${token}` },\n     autoConnect: false,\n     reconnection: true,\ndiff --git a/src/services/nativeBridge.ts b/src/services/nativeBridge.ts\nindex 18ba72c..b6dda21 100644\n--- a/src/services/nativeBridge.ts\n+++ b/src/services/nativeBridge.ts\n@@ -23,6 +23,8 @@ import type {\n } from './nativeBridge/types'\n \n export { NativeBridgeError } from './nativeBridge/errors'\n+export { subscribeNativeBridgeTransportDebugEvents } from './nativeBridgeTransport'\n+export type { NativeBridgeTransportDebugEvent } from './nativeBridgeTransport'\n export {\n   getNativeBridgeCapabilityCatalog,\n   getNativeBridgeDiagnostics,\ndiff --git a/src/services/nativeBridgeTransport.callback.test.ts b/src/services/nativeBridgeTransport.callback.test.ts\nindex 916b01a..1c0c059 100644\n--- a/src/services/nativeBridgeTransport.callback.test.ts\n+++ b/src/services/nativeBridgeTransport.callback.test.ts\n@@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'\n import {\n   createCallbackInjectedObjectTransport,\n   INJECTED_CALLBACK_TIMEOUT_MS,\n+  subscribeNativeBridgeTransportDebugEvents,\n+  type NativeBridgeTransportDebugEvent,\n   type NativeTransportResolution,\n   type NativeTransportWindow,\n } from './nativeBridgeTransport'\n@@ -126,6 +128,102 @@ describe('Android injected callback dispatcher', () => {\n     await expect(promise).resolves.toBe('device-finished')\n   })\n \n+  it('emits request correlation and callback-stage diagnostics without changing settlement', async () => {\n+    const events: NativeBridgeTransportDebugEvent[] = []\n+    const unsubscribe = subscribeNativeBridgeTransportDebugEvents((event) => events.push(event))\n+    const requests: Array<{ scanType: string; callbackId: string }> = []\n+    hostWindow.androidBridge = {\n+      scanCode(payload: unknown) {\n+        requests.push(JSON.parse(payload as string) as { scanType: string; callbackId: string })\n+      },\n+    }\n+    const transport = createCallbackInjectedObjectTransport<'all', string>({\n+      objectName: 'androidBridge',\n+      methodName: 'scanCode',\n+      callbackName: 'nativeBridgeCallback',\n+      callbackAliases: ['androidBridgeCallback'],\n+      timeoutMs: 1_000,\n+      serializeArgs: (scanType, callbackId) => [JSON.stringify({ scanType, callbackId })],\n+      isTerminalPayload: (_payload, callbackCount) => callbackCount > 1,\n+      parseResult: (payload) => (payload as { result: string }).result,\n+    })\n+\n+    try {\n+      const promise = invoke(transport.resolve(hostWindow, 'all'))\n+      const request = requests[0]\n+      expect(request).toBeDefined()\n+      expect(events[0]).toMatchObject({\n+        type: 'request',\n+        objectName: 'androidBridge',\n+        methodName: 'scanCode',\n+        callbackId: request.callbackId,\n+        callbackNames: ['nativeBridgeCallback', 'androidBridgeCallback'],\n+        timeoutMs: 1_000,\n+        args: [expect.stringContaining(request.callbackId)],\n+      })\n+\n+      hostWindow.androidBridgeCallback?.(request.callbackId, { result: 'scan-recognized' })\n+      expect(events[1]).toMatchObject({\n+        type: 'callback',\n+        objectName: 'androidBridge',\n+        methodName: 'scanCode',\n+        callbackId: request.callbackId,\n+        callbackName: 'androidBridgeCallback',\n+        source: 'callback',\n+        callbackCount: 1,\n+        pendingMatched: true,\n+        terminal: false,\n+      })\n+\n+      hostWindow.nativeBridgeCallback?.(request.callbackId, { result: 'device-finished' })\n+      expect(events[2]).toMatchObject({\n+        type: 'callback',\n+        callbackId: request.callbackId,\n+        callbackName: 'nativeBridgeCallback',\n+        source: 'callback',\n+        callbackCount: 2,\n+        pendingMatched: true,\n+        terminal: true,\n+      })\n+      await expect(promise).resolves.toBe('device-finished')\n+\n+      hostWindow.androidBridgeCallback?.('unmatched-callback-id', { error: 'unknown' })\n+      expect(events[3]).toMatchObject({\n+        type: 'callback',\n+        callbackId: 'unmatched-callback-id',\n+        callbackName: 'androidBridgeCallback',\n+        pendingMatched: false,\n+      })\n+      expect(events).toHaveLength(4)\n+    } finally {\n+      unsubscribe()\n+    }\n+  })\n+\n+  it('emits a correlated timeout event and still rejects the pending invocation', async () => {\n+    vi.useFakeTimers()\n+    const events: NativeBridgeTransportDebugEvent[] = []\n+    const unsubscribe = subscribeNativeBridgeTransportDebugEvents((event) => events.push(event))\n+    hostWindow.androidBridge = { scanCode() {} }\n+    const transport = createProbeTransport('scanCode', 25)\n+\n+    try {\n+      const promise = invoke(transport.resolve(hostWindow, 'waiting'))\n+      const rejection = expect(promise).rejects.toMatchObject({ code: 'callback-timeout' })\n+      await vi.advanceTimersByTimeAsync(25)\n+      await rejection\n+      expect(events).toContainEqual(expect.objectContaining({\n+        type: 'timeout',\n+        objectName: 'androidBridge',\n+        methodName: 'scanCode',\n+        timeoutMs: 25,\n+        callbackId: expect.any(String),\n+      }))\n+    } finally {\n+      unsubscribe()\n+    }\n+  })\n+\n   it('resolves out-of-order callbacks by callbackId instead of invocation order', async () => {\n     const requests: CallbackRequest[] = []\n     hostWindow.androidBridge = {\ndiff --git a/src/services/nativeBridgeTransport.ts b/src/services/nativeBridgeTransport.ts\nindex b80362d..5370299 100644\n--- a/src/services/nativeBridgeTransport.ts\n+++ b/src/services/nativeBridgeTransport.ts\n@@ -211,6 +211,59 @@ export type InjectedCallbackName =\n   | 'nativeBridgeCallback'\n   | 'androidBridgeCallback'\n \n+export type NativeBridgeTransportDebugEvent =\n+  | {\n+      type: 'request'\n+      objectName: string\n+      methodName: string\n+      callbackId: string\n+      callbackNames: readonly InjectedCallbackName[]\n+      timeoutMs: number\n+      args: readonly unknown[]\n+    }\n+  | {\n+      type: 'callback'\n+      objectName?: string\n+      methodName?: string\n+      callbackId: string\n+      callbackName: InjectedCallbackName | null\n+      source: 'callback' | 'method-return'\n+      callbackCount?: number\n+      pendingMatched: boolean\n+      terminal?: boolean\n+      classificationError?: string\n+      payload: unknown\n+    }\n+  | {\n+      type: 'timeout'\n+      objectName: string\n+      methodName: string\n+      callbackId: string\n+      timeoutMs: number\n+    }\n+\n+type NativeBridgeTransportDebugListener = (event: NativeBridgeTransportDebugEvent) => void\n+const nativeBridgeTransportDebugListeners = new Set<NativeBridgeTransportDebugListener>()\n+\n+/** Passive Bridge Lab observer. Events are delivered only to active debug listeners. */\n+export function subscribeNativeBridgeTransportDebugEvents(\n+  listener: NativeBridgeTransportDebugListener,\n+): () => void {\n+  nativeBridgeTransportDebugListeners.add(listener)\n+  return () => nativeBridgeTransportDebugListeners.delete(listener)\n+}\n+\n+function emitNativeBridgeTransportDebugEvent(event: NativeBridgeTransportDebugEvent): void {\n+  if (nativeBridgeTransportDebugListeners.size === 0) return\n+  for (const listener of nativeBridgeTransportDebugListeners) {\n+    try {\n+      listener(event)\n+    } catch {\n+      // Diagnostics must never interfere with Native callback delivery or Promise settlement.\n+    }\n+  }\n+}\n+\n export interface CallbackInjectedObjectTransportConfig<TInput, TResult>\n   extends Omit<InjectedObjectTransportConfig<TInput, TResult>, 'serializeArgs'> {\n   serializeArgs: (input: TInput, callbackId: string) => readonly unknown[]\n@@ -232,7 +285,7 @@ export interface CallbackInjectedObjectTransportConfig<TInput, TResult>\n }\n \n type InjectedCallbackPending = {\n-  handle: (payload: unknown) => void\n+  handle: (payload: unknown, callbackName: InjectedCallbackName) => void\n }\n \n type InjectedCallbackDispatcher = (callbackId: string, payload: unknown) => void\n@@ -265,9 +318,17 @@ function installInjectedCallbackEndpoint(\n     dispatcher = (callbackId, payload) => {\n       const request = channel.pending.get(callbackId)\n       if (request) {\n-        request.handle(payload)\n+        request.handle(payload, callbackName)\n         return\n       }\n+      emitNativeBridgeTransportDebugEvent({\n+        type: 'callback',\n+        callbackId,\n+        callbackName,\n+        source: 'callback',\n+        pendingMatched: false,\n+        payload,\n+      })\n       channel.fallbacks.get(callbackName)?.call(hostWindow, callbackId, payload)\n     }\n     channel.dispatchers.set(callbackName, dispatcher)\n@@ -348,6 +409,7 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n           }\n \n           const callbackName = config.callbackName ?? 'nativeBridgeCallback'\n+          const callbackNames = [...new Set([callbackName, ...(config.callbackAliases ?? [])])]\n           const channel = getInjectedCallbackChannel(\n             hostWindow,\n             callbackName,\n@@ -377,13 +439,29 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n               cleanup()\n               work()\n             }\n-            const handlePayload = (payload: unknown) => {\n+            const handlePayload = (\n+              payload: unknown,\n+              source: 'callback' | 'method-return',\n+              endpoint: InjectedCallbackName | null,\n+            ) => {\n               callbackCount += 1\n \n               let isTerminal = true\n               try {\n                 isTerminal = config.isTerminalPayload?.(payload, callbackCount) ?? true\n               } catch (error) {\n+                emitNativeBridgeTransportDebugEvent({\n+                  type: 'callback',\n+                  objectName: config.objectName,\n+                  methodName: config.methodName,\n+                  callbackId,\n+                  callbackName: endpoint,\n+                  source,\n+                  callbackCount,\n+                  pendingMatched: true,\n+                  classificationError: error instanceof Error ? error.message : String(error),\n+                  payload,\n+                })\n                 finish(() =>\n                   reject(\n                     error instanceof NativeTransportError\n@@ -398,6 +476,19 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n                 return\n               }\n \n+              emitNativeBridgeTransportDebugEvent({\n+                type: 'callback',\n+                objectName: config.objectName,\n+                methodName: config.methodName,\n+                callbackId,\n+                callbackName: endpoint,\n+                source,\n+                callbackCount,\n+                pendingMatched: true,\n+                terminal: isTerminal,\n+                payload,\n+              })\n+\n               if (!isTerminal) return\n \n               finish(() => {\n@@ -409,8 +500,17 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n               })\n             }\n \n-            channel.pending.set(callbackId, { handle: handlePayload })\n+            channel.pending.set(callbackId, {\n+              handle: (payload, endpoint) => handlePayload(payload, 'callback', endpoint),\n+            })\n             const timer = window.setTimeout(() => {\n+              emitNativeBridgeTransportDebugEvent({\n+                type: 'timeout',\n+                objectName: config.objectName,\n+                methodName: config.methodName,\n+                callbackId,\n+                timeoutMs,\n+              })\n               finish(() =>\n                 reject(\n                   new NativeTransportError(\n@@ -423,8 +523,17 @@ export function createCallbackInjectedObjectTransport<TInput, TResult>(\n \n             try {\n               const args = config.serializeArgs(input, callbackId)\n+              emitNativeBridgeTransportDebugEvent({\n+                type: 'request',\n+                objectName: config.objectName,\n+                methodName: config.methodName,\n+                callbackId,\n+                callbackNames,\n+                timeoutMs,\n+                args,\n+              })\n               const rawResult = method.call(bridge, ...args)\n-              if (rawResult !== undefined) handlePayload(rawResult)\n+              if (rawResult !== undefined) handlePayload(rawResult, 'method-return', null)\n             } catch (error) {\n               finish(() =>\n                 reject(\ndiff --git a/tests/e2e/bridge-lab.spec.ts b/tests/e2e/bridge-lab.spec.ts\nindex 6f416f7..7bcc433 100644\n--- a/tests/e2e/bridge-lab.spec.ts\n+++ b/tests/e2e/bridge-lab.spec.ts\n@@ -59,6 +59,46 @@ test.describe('H027 Bridge Lab', () => {\n     await expect(logs).toContainText('\"success\": true')\n   })\n \n+  test('shows correlated Android scan callback stages without exposing QR text by default', async ({ page }) => {\n+    await page.addInitScript(() => {\n+      const host = window as unknown as {\n+        androidBridge?: { scanCode(payload: unknown): void }\n+        androidBridgeCallback?: (callbackId: string, payload: unknown) => void\n+      }\n+      host.androidBridge = {\n+        scanCode(payload) {\n+          const request = JSON.parse(payload as string) as { callbackId: string }\n+          setTimeout(() => {\n+            host.androidBridgeCallback?.(request.callbackId, {\n+              code: 0,\n+              message: 'ok',\n+              data: { text: 'e2e-buddy-qr-private', scanType: 'qr' },\n+            })\n+          }, 0)\n+          setTimeout(() => {\n+            host.androidBridgeCallback?.(request.callbackId, {\n+              code: 0,\n+              message: 'ok',\n+              data: { text: 'e2e-buddy-qr-private', scanType: 'qr' },\n+            })\n+          }, 20)\n+        },\n+      }\n+    })\n+\n+    await page.goto('/__debug/bridge-lab?osType=android', { waitUntil: 'domcontentloaded' })\n+    await page.locator('[data-capability-name=\"scanCode\"]').click()\n+    await page.getByRole('button', { name: '调用 scanCode' }).click()\n+\n+    const logs = page.locator('[data-bridge-lab-logs]')\n+    await expect(logs).toContainText('Native invoke · androidBridge.scanCode')\n+    await expect(logs).toContainText('androidBridgeCallback')\n+    await expect(logs).toContainText('intermediate')\n+    await expect(logs).toContainText('terminal')\n+    await expect(logs).toContainText('[REDACTED]')\n+    await expect(logs).not.toContainText('e2e-buddy-qr-private')\n+  })\n+\n   test('Native can still call the legacy window.testFunc endpoint', async ({ page }) => {\n     await page.goto('/__debug/bridge-lab?osType=android', { waitUntil: 'domcontentloaded' })",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 50,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T08:30:27.530Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1030,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T08:30:27.553Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1049,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T08:30:27.632Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 931,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T08:30:28.891Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 901,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T08:30:28.913Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 891,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T08:30:29.827Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 940,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-11-09T08:30:29.840Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-11-09T08:30:26.617Z",
    "duration": 4214.089,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}