From 5115b19e4d2e04e3f6fac38afcef2d080985b916 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:18:53 -0400 Subject: [PATCH 1/2] fix(bridge): the transport is the bridge index.html installs, in tests too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every transport path branched on `window.DsmBridge.__callBin`, a function only the jest stub installed: `sendBridgeRequestBytes`, `callBoundaryMethod` and `callHostMethod` each tried it first and fell back to `sendMessageBin`, the MessagePort transport `index.html` installs — so the transport the tests exercised was not the one the app runs. The production branch pre-waited for `dsm-bridge-ready` under a 2.5 s timer and then proceeded, which was redundant (`sendMessageBin` waits for the port itself), and ran the answer through `maybeUnframe`, a length-prefix guess with no producer: Kotlin never length-prefixes, and no BridgeRpcResponse can begin with its own length, so the guess never fit a real answer. The bridge type declared the phantom, `isNativeHostUnavailableError` matched a test stub's error text, and `globals.d.ts` declared a `dsmBridge` global the bridge gate bans. Now the transport is the bridge index.html installs and nothing else: `sendMessageBin` for RPCs, its BridgeRpcResponse unwrapped as posted; `startup` / `ingress` / `hostRequest` for the boundaries, a wrapper failure reaching `bridge.error` as its message and propagating; `__binary` for readiness. The pre-wait, the unframing, the fallbacks, the stub-text match and the phantom declarations are deleted, and `AndroidBridgeV3` is exactly the seven members index.html installs. The jest setup completes any test bridge that supplies `sendMessageBin` with the same wrapper composition index.html uses, reading `sendMessageBin` at call time, so the production transport runs unchanged in tests; the 20 test bridges now supply `sendMessageBin`, and two that also carried a `sendMessageBin` delegating to the phantom lost it. `ci/bridge_rpc_names.py` also holds the type's members equal to the installed keys and refuses a production source naming `__callBin`. Tests: transportCore.bridge.test.ts (a bridge without the port transport is refused; a router query goes through the bridge's ingress wrapper), bridgeDecoding.integration.test.ts (undecodable bytes answered as index.html answers them; a boundary failure reaches bridge.error as its message), headerService.test.ts (available only through the bytes-only bridge). Mutation controls, each red on its named test: a fallback re-encoding the request in place of the bridge's ingress; a bridge without the port transport accepted. Gate controls, each failing naming the offender: a phantom member on the bridge type; a production source naming __callBin. Verified: tsc, lint, jest (every suite on the production transport), npm run build, and the purity, flow, scan, bridge, codegen, proto, safety and bridge-RPC-name gates. --- ci/bridge_rpc_names.py | 80 ++++++++++++++- ...ntactsTabScreen.pairingIdEncoding.test.tsx | 2 +- dsm_client/frontend/src/dsm/BridgeGate.ts | 5 +- .../frontend/src/dsm/NativeBoundaryBridge.ts | 80 +++------------ .../frontend/src/dsm/NativeHostBridge.ts | 75 +++----------- .../src/dsm/WebViewBridge/transportCore.ts | 57 ++--------- .../__tests__/NativeBoundaryBridge.test.ts | 2 +- .../__tests__/WebViewBridge.events.test.ts | 4 +- .../__tests__/WebViewBridge.framing.test.ts | 7 +- .../__tests__/bilateralAcceptEvent.test.ts | 6 +- .../dsm/__tests__/bleIdentityPrune.test.ts | 2 +- .../dsm/__tests__/bleIdentityResolver.test.ts | 10 +- .../blePairingRequestNormalization.test.ts | 4 +- .../bridgeDecoding.integration.test.ts | 53 ++++------ .../src/dsm/__tests__/diagnostics.test.ts | 2 +- .../getAllBalancesStrictBridge.test.ts | 4 +- .../src/dsm/__tests__/offlineSend.test.ts | 6 +- ...offlineTransfer_consistency_bridge.test.ts | 4 +- .../dsm/__tests__/protobufPayloads.test.ts | 4 +- .../__tests__/transportCore.bridge.test.ts | 33 +++++++ dsm_client/frontend/src/dsm/bridgeTypes.ts | 24 +++-- dsm_client/frontend/src/globals.d.ts | 4 - .../__tests__/bitcoinTap.withdrawal.test.ts | 4 +- .../services/__tests__/headerService.test.ts | 7 +- .../frontend/src/services/headerService.ts | 12 +-- dsm_client/frontend/src/setupTests.ts | 61 +++++++++--- .../tests/E2E.bilateral.acceptFlow.test.tsx | 4 +- .../src/tests/E2E.offlineBleExchange.test.ts | 31 +----- .../src/tests/E2E.sendOnlineTransfer.test.ts | 2 +- .../src/tests/E2E.transferProof.test.ts | 19 ++-- .../src/tests/E2E.uiCoordination.test.tsx | 98 +++++++++---------- specs/requirements/CONFORMANCE_GAPS.md | 5 +- 32 files changed, 332 insertions(+), 379 deletions(-) create mode 100644 dsm_client/frontend/src/dsm/__tests__/transportCore.bridge.test.ts diff --git a/ci/bridge_rpc_names.py b/ci/bridge_rpc_names.py index 1a0d2834d..89644b5cf 100644 --- a/ci/bridge_rpc_names.py +++ b/ci/bridge_rpc_names.py @@ -18,8 +18,15 @@ # Handled names are the string arms of the `when (method)` inside # SinglePathWebViewBridge.handleBinaryRpcInternal. # -# Exit 0 only when both sets are non-empty and equal. There is no allowlist: a -# name one side must stop using is removed from that side. +# The bridge object itself is held to the same rule: the members of the +# frontend's `AndroidBridgeV3` must be exactly the keys `index.html` installs on +# `window.DsmBridge` (every production call on the bridge object is typed +# against that interface), and no production source may name `__callBin`, the +# transport function only the jest stub installed and every transport path +# once branched on. +# +# Exit 0 only when every set is non-empty and each pair is equal. There is no +# allowlist: a name one side must stop using is removed from that side. import os import re @@ -28,6 +35,7 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) FRONTEND_SRC = os.path.join(ROOT, "dsm_client", "frontend", "src") INDEX_HTML = os.path.join(ROOT, "dsm_client", "frontend", "public", "index.html") +BRIDGE_TYPES = os.path.join(ROOT, "dsm_client", "frontend", "src", "dsm", "bridgeTypes.ts") KOTLIN_BRIDGE = os.path.join( ROOT, "dsm_client", "android", "app", "src", "main", "java", "com", "dsm", "wallet", "bridge", "SinglePathWebViewBridge.kt", @@ -39,6 +47,9 @@ CALL_IDENT_RE = re.compile(r"\bcallBin\(\s*([A-Z][A-Z0-9_]+)\b") HTML_RE = re.compile(r"\b(?:callBridgeMethod|encodeBridgeRequest)\(\s*[\"']([A-Za-z0-9_]+)[\"']") ARM_RE = re.compile(r"^\s*\"([A-Za-z0-9_]+)\"\s*->", re.M) +# A member of the object literal: `key: value` or the shorthand `key,`. +INSTALLED_KEY_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::|,\s*$)", re.M) +TYPE_MEMBER_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\??\s*:", re.M) def fail(msg): @@ -117,9 +128,54 @@ def handled_names(): return handled +def installed_bridge_keys(): + """The keys of the object literal `index.html` assigns to `window.DsmBridge`.""" + html = open(INDEX_HTML, encoding="utf-8").read() + start = html.find("window.DsmBridge = {") + if start < 0: + fail("index.html does not install `window.DsmBridge = {`") + sys.exit(2) + i = html.index("{", start) + depth = 0 + j = i + while j < len(html): + if html[j] == "{": + depth += 1 + elif html[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + return set(INSTALLED_KEY_RE.findall(html[i + 1:j])) + + +def bridge_type_members(): + text = open(BRIDGE_TYPES, encoding="utf-8").read() + start = text.find("export interface AndroidBridgeV3 {") + if start < 0: + fail("bridgeTypes.ts does not declare AndroidBridgeV3") + sys.exit(2) + body = text[text.index("{", start) + 1:text.index("\n}", start)] + body = re.sub(r"/\*.*?\*/", "", body, flags=re.S) + body = re.sub(r"//[^\n]*", "", body) + return set(TYPE_MEMBER_RE.findall(body)) + + +def production_names_callbin(): + hits = [] + for path in frontend_sources(): + text = open(path, encoding="utf-8").read() + for m in re.finditer(r"__callBin", text): + hits.append(f"{os.path.relpath(path, ROOT)}:{text.count(chr(10), 0, m.start()) + 1}") + return hits + + def main(): sent = sent_names() handled = handled_names() + installed = installed_bridge_keys() + typed = bridge_type_members() + callbin = production_names_callbin() if not sent or not handled: fail(f"a scan that finds nothing is not a scan (sent={len(sent)}, handled={len(handled)})") return 2 @@ -136,8 +192,26 @@ def main(): for name in unsent: print(f" {name}: " + ", ".join(handled[name])) status = 1 + if not installed or not typed: + fail(f"a scan that finds nothing is not a scan (installed={len(installed)}, typed={len(typed)})") + return 2 + if installed != typed: + fail("the frontend's AndroidBridgeV3 and the bridge object index.html installs differ:") + for name in sorted(typed - installed): + print(f" typed but not installed: {name}") + for name in sorted(installed - typed): + print(f" installed but not typed: {name}") + status = 1 + if callbin: + fail("production sources name `__callBin`, a transport only the jest stub installs:") + for where in callbin: + print(f" {where}") + status = 1 if status == 0: - print(f"[bridge-rpc-names] OK: {len(sent)} names sent, {len(handled)} handled, the same set") + print( + f"[bridge-rpc-names] OK: {len(sent)} names sent, {len(handled)} handled, the same set; " + f"the bridge object's {len(installed)} members typed as installed" + ) return status diff --git a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx index 344c01083..f9e49721e 100644 --- a/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx +++ b/dsm_client/frontend/src/components/screens/__tests__/ContactsTabScreen.pairingIdEncoding.test.tsx @@ -61,7 +61,7 @@ describe('ContactsTabScreen BLE pairing', () => { (globalThis as any).window = (globalThis as any).window || {}; (globalThis as any).window.DsmBridge = { - __callBin: async () => new Uint8Array(0), + sendMessageBin: async () => new Uint8Array(0), }; (globalThis as any).requestAnimationFrame = () => 0; diff --git a/dsm_client/frontend/src/dsm/BridgeGate.ts b/dsm_client/frontend/src/dsm/BridgeGate.ts index 59fe079b0..9e04edeee 100644 --- a/dsm_client/frontend/src/dsm/BridgeGate.ts +++ b/dsm_client/frontend/src/dsm/BridgeGate.ts @@ -31,9 +31,8 @@ export class BridgeGate { * (`window.DsmBridge`, bytes-only). Safe to call repeatedly. */ refreshPrereqsOnce(): BridgePrereqState { - const b = (globalThis as { window?: { DsmBridge?: { __binary?: boolean; __callBin?: unknown } } }) - .window?.DsmBridge; - const installed = !!(b && (b.__binary === true || typeof b.__callBin === 'function')); + const b = (globalThis as { window?: { DsmBridge?: { __binary?: boolean } } }).window?.DsmBridge; + const installed = b?.__binary === true; if (installed && !this.prereq.bridgeReady) { this.onEvent({ type: 'bridge.ready' }); } diff --git a/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts b/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts index 3ccc5d0ed..2073b0fc5 100644 --- a/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts +++ b/dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts @@ -4,20 +4,7 @@ import { getBridgeInstance } from '../bridge/BridgeRegistry'; import { bridgeEvents } from '../bridge/bridgeEvents'; import type { AndroidBridgeV3 } from './bridgeTypes'; -import { encodeBase32Crockford } from '../utils/textId'; -import { - BridgeRpcRequest, - BridgeRpcResponse, - BytesPayload, - EmptyPayload, - EnvelopeOp, - IngressRequest, - IngressResponse, - RouterInvokeOp, - RouterQueryOp, - StartupRequest, - StartupResponse, -} from '../proto/dsm_app_pb'; +import { EnvelopeOp, IngressRequest, IngressResponse, RouterInvokeOp, RouterQueryOp, StartupRequest, StartupResponse } from '../proto/dsm_app_pb'; function mustBridge(): AndroidBridgeV3 { const bridge = getBridgeInstance(); @@ -34,62 +21,23 @@ function normalizeToBytes(data: unknown): Uint8Array { throw new Error('expected Uint8Array response from native boundary'); } -function buildBridgeRequest(method: string, payload: Uint8Array): Uint8Array { - const req = new BridgeRpcRequest({ - method, - payload: - payload.length > 0 - ? { case: 'bytes', value: new BytesPayload({ data: new Uint8Array(payload) }) } - : { case: 'empty', value: new EmptyPayload({}) }, - }); - return req.toBinary(); -} - -function unwrapBridgeRpcResponse(method: string, responseBytes: Uint8Array): Uint8Array { - let response: BridgeRpcResponse; - try { - response = BridgeRpcResponse.fromBinary(responseBytes); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - throw new Error(`Bridge error: failed to decode response for ${method}: ${msg}`); - } - if (response.result.case === 'success') { - const data = response.result.value?.data; - return data instanceof Uint8Array ? data : new Uint8Array(0); - } - if (response.result.case === 'error') { - const errVal = response.result.value; - const message = errVal?.message || `bridge error while calling ${method}`; - const debugBytes = errVal ? errVal.toBinary() : new Uint8Array(0); - bridgeEvents.emit('bridge.error', { - code: errVal?.errorCode, - message, - debugB32: encodeBase32Crockford(debugBytes), - }); - throw new Error(message); - } - throw new Error(`empty bridge response for ${method}`); -} - async function callBoundaryMethod(method: 'nativeBoundaryStartup' | 'nativeBoundaryIngress', payload: Uint8Array): Promise { + // `startup` and `ingress` are the bridge object's own wrappers over the + // MessagePort (`index.html`); they answer the boundary's bytes or throw. const bridge = mustBridge(); - if (method === 'nativeBoundaryStartup' && typeof bridge.startup === 'function') { - return normalizeToBytes(await bridge.startup(payload)); - } - if (method === 'nativeBoundaryIngress' && typeof bridge.ingress === 'function') { - return normalizeToBytes(await bridge.ingress(payload)); + const call = method === 'nativeBoundaryStartup' ? bridge.startup : bridge.ingress; + if (typeof call !== 'function') { + throw new Error(`DSM bridge does not expose ${method}`); } - - const requestBytes = buildBridgeRequest(method, payload); - if (typeof bridge.__callBin === 'function') { - const responseBytes = await bridge.__callBin(requestBytes); - return unwrapBridgeRpcResponse(method, normalizeToBytes(responseBytes)); - } - if (bridge.__binary === true && typeof bridge.sendMessageBin === 'function') { - const responseBytes = await bridge.sendMessageBin(requestBytes); - return unwrapBridgeRpcResponse(method, normalizeToBytes(responseBytes)); + try { + return normalizeToBytes(await call(payload)); + } catch (e) { + // The wrapper reduces Kotlin's ErrorResponse to its message; that message + // reaches the diagnostics bus as the RPC path's failures do. + const message = e instanceof Error ? e.message : String(e); + bridgeEvents.emit('bridge.error', { code: 0, message, debugB32: '' }); + throw e; } - throw new Error('DSM bridge does not expose the native boundary transport'); } function encodeStartupRequest(request: StartupRequest | Uint8Array): Uint8Array { diff --git a/dsm_client/frontend/src/dsm/NativeHostBridge.ts b/dsm_client/frontend/src/dsm/NativeHostBridge.ts index 4130ef5e1..a34dc8dac 100644 --- a/dsm_client/frontend/src/dsm/NativeHostBridge.ts +++ b/dsm_client/frontend/src/dsm/NativeHostBridge.ts @@ -2,30 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { getBridgeInstance } from '../bridge/BridgeRegistry'; +import { bridgeEvents } from '../bridge/bridgeEvents'; import logger from '../utils/logger'; import type { AndroidBridgeV3 } from './bridgeTypes'; import { bridgeGate } from './BridgeGate'; -import { - BiometricAuthorizePayload, - BiometricAuthorizeResult, - BridgeRpcRequest, - BridgeRpcResponse, - BytesPayload, - EmptyPayload, - HostPermissionsRequestPayload, - NativeHostAck, - NativeHostCapabilities, - NativeHostEvent, - NativeHostEventKind, - NativeHostRequest, - NativeHostRequestKind, - NativeHostResponse, - NfcTagReadPayload, - NfcTagReadResult, - NfcTagWritePayload, - NfcTagWriteResult, - QrScanResultPayload, -} from '../proto/dsm_app_pb'; +import { BiometricAuthorizePayload, BiometricAuthorizeResult, HostPermissionsRequestPayload, NativeHostAck, NativeHostCapabilities, NativeHostEvent, NativeHostEventKind, NativeHostRequest, NativeHostRequestKind, NativeHostResponse, NfcTagReadPayload, NfcTagReadResult, NfcTagWritePayload, NfcTagWriteResult, QrScanResultPayload } from '../proto/dsm_app_pb'; function mustBridge(): AndroidBridgeV3 { const bridge = getBridgeInstance(); @@ -42,46 +23,20 @@ function normalizeToBytes(data: unknown): Uint8Array { throw new Error('expected Uint8Array response from native host boundary'); } -function buildBridgeRequest(method: string, payload: Uint8Array): Uint8Array { - const req = new BridgeRpcRequest({ - method, - payload: - payload.length > 0 - ? { case: 'bytes', value: new BytesPayload({ data: new Uint8Array(payload) }) } - : { case: 'empty', value: new EmptyPayload({}) }, - }); - return req.toBinary(); -} - -function unwrapBridgeRpcResponse(method: string, responseBytes: Uint8Array): Uint8Array { - const response = BridgeRpcResponse.fromBinary(responseBytes); - if (response.result.case === 'success') { - const data = response.result.value?.data; - return data instanceof Uint8Array ? data : new Uint8Array(0); - } - if (response.result.case === 'error') { - const message = response.result.value?.message || `bridge error while calling ${method}`; - throw new Error(message); - } - throw new Error(`empty bridge response for ${method}`); -} - async function callHostMethod(payload: Uint8Array): Promise { + // `hostRequest` is the bridge object's own wrapper over the MessagePort + // (`index.html`); it answers the host's bytes or throws. const bridge = mustBridge(); - if (typeof bridge.hostRequest === 'function') { - return normalizeToBytes(await bridge.hostRequest(payload)); - } - - const requestBytes = buildBridgeRequest('nativeHostRequest', payload); - if (typeof bridge.__callBin === 'function') { - const responseBytes = await bridge.__callBin(requestBytes); - return unwrapBridgeRpcResponse('nativeHostRequest', normalizeToBytes(responseBytes)); + if (typeof bridge.hostRequest !== 'function') { + throw new Error('DSM bridge does not expose nativeHostRequest'); } - if (bridge.__binary === true && typeof bridge.sendMessageBin === 'function') { - const responseBytes = await bridge.sendMessageBin(requestBytes); - return unwrapBridgeRpcResponse('nativeHostRequest', normalizeToBytes(responseBytes)); + try { + return normalizeToBytes(await bridge.hostRequest(payload)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + bridgeEvents.emit('bridge.error', { code: 0, message, debugB32: '' }); + throw e; } - throw new Error('DSM bridge does not expose the native host boundary transport'); } function encodeRequest(request: NativeHostRequest | Uint8Array): Uint8Array { @@ -104,11 +59,7 @@ function unwrapHostResponse(responseBytes: Uint8Array): Uint8Array { export function isNativeHostUnavailableError(error: unknown): boolean { if (!(error instanceof Error)) return false; - return ( - error.message.includes('Unknown binary RPC method: nativeHostRequest') || - error.message.includes('unhandled __callBin method') || - error.message.includes('does not expose the native host boundary transport') - ); + return error.message.includes('Unknown binary RPC method: nativeHostRequest'); } export async function hostRequest(request: NativeHostRequest | Uint8Array): Promise { diff --git a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts index 533d07813..8d8fb77a8 100644 --- a/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts +++ b/dsm_client/frontend/src/dsm/WebViewBridge/transportCore.ts @@ -50,14 +50,6 @@ export const toBytes = (bytes: Uint8Array): Uint8Array => { return out; }; -function maybeUnframe(buf: Uint8Array): Uint8Array { - if (buf.length < 4) return buf; - const nBE = ((buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]) >>> 0; - if (4 + nBE === buf.length) return buf.slice(4, 4 + nBE); - const nLE = (buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24)) >>> 0; - if (4 + nLE === buf.length) return buf.slice(4, 4 + nLE); - return buf; -} export class BridgeError extends Error { errorCode?: number; @@ -149,52 +141,21 @@ const buildBridgeRequest = (method: string, payload?: Uint8Array): Uint8Array => return req.toBinary(); }; +/** + * One request over the bytes-only MessagePort bridge `index.html` installs: + * `sendMessageBin` waits for the port itself and answers the BridgeRpcResponse + * Kotlin posted, which is unwrapped here. There is no other transport. + */ export const sendBridgeRequestBytes = async ( method: string, requestBytes: Uint8Array ): Promise => { const b = mustBridge(); - - const waitForBinaryBridgeReady = async (): Promise => { - const maybeBridge = b as unknown as { isAvailable?: () => boolean }; - if (typeof maybeBridge.isAvailable !== "function" || maybeBridge.isAvailable()) { - return; - } - - await new Promise((resolve) => { - let done = false; - const finish = () => { - if (!done) { - done = true; - resolve(); - } - }; - const onReady = () => finish(); - if (typeof window !== "undefined") { - window.addEventListener("dsm-bridge-ready", onReady, { once: true }); - } - setTimeout(() => { - if (typeof window !== "undefined") { - window.removeEventListener("dsm-bridge-ready", onReady); - } - finish(); - }, 2500); - }); - }; - - if (typeof b.__callBin === "function") { - const respBytes = await b.__callBin(requestBytes); - return await unwrapProtobufResponse(method, normalizeToBytes(respBytes)); - } - - if (b.__binary === true && typeof b.sendMessageBin === "function") { - await waitForBinaryBridgeReady(); - const respBytes = await b.sendMessageBin(requestBytes); - const respFramed = normalizeToBytes(respBytes); - return await unwrapProtobufResponse(method, maybeUnframe(respFramed)); + if (b.__binary !== true || typeof b.sendMessageBin !== "function") { + throw new Error("DSM bridge not available (bytes-only MessagePort required)"); } - - throw new Error("DSM bridge not available (bytes-only MessagePort required)"); + const respBytes = normalizeToBytes(await b.sendMessageBin(requestBytes)); + return await unwrapProtobufResponse(method, respBytes); }; export async function callBin(method: string, payload?: Uint8Array): Promise { diff --git a/dsm_client/frontend/src/dsm/__tests__/NativeBoundaryBridge.test.ts b/dsm_client/frontend/src/dsm/__tests__/NativeBoundaryBridge.test.ts index 6640d5acc..793c5500f 100644 --- a/dsm_client/frontend/src/dsm/__tests__/NativeBoundaryBridge.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/NativeBoundaryBridge.test.ts @@ -25,8 +25,8 @@ describe('NativeBoundaryBridge', () => { }).toBinary(); }, }; + // The setter registers the bridge with the DI registry. (globalThis as any).window.DsmBridge = bridge; - setBridgeInstance(bridge); const result = await routerQueryBin('wallet.balance', new Uint8Array([1, 2, 3])); diff --git a/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.events.test.ts b/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.events.test.ts index 0f10de7ac..40082f6d8 100644 --- a/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.events.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.events.test.ts @@ -54,7 +54,7 @@ describe("WebViewBridge preference gating", () => { const enqueueSpy = jest.spyOn(bridgeGate, "enqueue"); const bridge = { __binary: true, - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const pb = require("../../proto/dsm_app_pb"); const req = pb.BridgeRpcRequest.fromBinary(reqBytes); expect(req.method).toBe("getPreference"); @@ -76,7 +76,7 @@ describe("WebViewBridge preference gating", () => { const enqueueSpy = jest.spyOn(bridgeGate, "enqueue"); const bridge = { __binary: true, - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const pb = require("../../proto/dsm_app_pb"); const req = pb.BridgeRpcRequest.fromBinary(reqBytes); expect(req.method).toBe("setPreference"); diff --git a/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.framing.test.ts b/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.framing.test.ts index cc5edfdf7..28b28b11a 100644 --- a/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.framing.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/WebViewBridge.framing.test.ts @@ -20,7 +20,7 @@ describe('WebViewBridge framing invariants', () => { }).toBinary(); (global as any).window.DsmBridge = { - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const req = BridgeRpcRequest.fromBinary(reqBytes); seen.method = req.method; seen.payload = req.payload?.case === 'bytes' ? req.payload.value.data : new Uint8Array(0); @@ -37,9 +37,12 @@ describe('WebViewBridge framing invariants', () => { expect((ingressRequest.operation.value as EnvelopeOp).envelopeBytes).toEqual(envelope); }); + // The guard is on the bridge's own `ingress` wrapper's answer: index.html + // answers bytes or throws, and anything else is refused here. test('a native answer that is not bytes is refused', async () => { (global as any).window.DsmBridge = { - __callBin: async () => ({ nope: true } as any), + sendMessageBin: async () => new Uint8Array(0), + ingress: async () => ({ nope: true } as any), }; await expect(processEnvelopeV3Bin(new Uint8Array([1]))).rejects.toThrow( diff --git a/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts b/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts index e1d8e8d13..ef813f388 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bilateralAcceptEvent.test.ts @@ -34,7 +34,7 @@ describe('bilateral accept event dispatch', () => { } as any); const framed = frameEnvelope(env); (window as any).DsmBridge = { - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const req = pb.BridgeRpcRequest.fromBinary(reqBytes); const method = req.method || ''; const payload = req.payload?.case === 'bytes' ? req.payload.value.data : new Uint8Array(0); @@ -43,7 +43,7 @@ describe('bilateral accept event dispatch', () => { expect(payload.length).toBe(32); return wrapSuccessEnvelope(framed); } - throw new Error(`unhandled __callBin method: ${method}`); + throw new Error(`unhandled bridge method: ${method}`); }, }; @@ -64,7 +64,7 @@ describe('bilateral accept event dispatch', () => { test('acceptBilateralByCommitmentBridge rejects invalid payload size', async () => { (window as any).DsmBridge = { - __callBin: async (_reqBytes: Uint8Array) => new Uint8Array([1]), + sendMessageBin: async (_reqBytes: Uint8Array) => new Uint8Array([1]), }; await expect(acceptBilateralByCommitmentBridge(new Uint8Array([1, 2, 3]))).rejects.toThrow(/must be 32 bytes/i); }); diff --git a/dsm_client/frontend/src/dsm/__tests__/bleIdentityPrune.test.ts b/dsm_client/frontend/src/dsm/__tests__/bleIdentityPrune.test.ts index 0ec4b346f..a7ec38fc5 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bleIdentityPrune.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bleIdentityPrune.test.ts @@ -33,7 +33,7 @@ describe('pruneBleIdentityMappings', () => { ]); (globalThis as any).window.DsmBridge = { __binary: true, - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const pb = require('../../proto/dsm_app_pb'); const req = pb.BridgeRpcRequest.fromBinary(reqBytes); const method = req.method || ''; diff --git a/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts b/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts index 32eaaa3a0..f682ac7b1 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bleIdentityResolver.test.ts @@ -23,7 +23,7 @@ describe('resolveBleAddressForContact', () => { it('returns undefined when no mapping or stored address', async () => { (globalThis as any).window.DsmBridge = { __binary: true, - __callBin: async () => createDsmBridgeSuccessResponse(new Uint8Array(0)), + sendMessageBin: async () => createDsmBridgeSuccessResponse(new Uint8Array(0)), }; const contact = { alias: 'A', deviceId: mkBytes(1), genesisHash: mkBytes(2) }; await expect(dsmClient.resolveBleAddressForContact?.(contact as any)).resolves.toBeUndefined(); @@ -32,7 +32,7 @@ describe('resolveBleAddressForContact', () => { it('uses stored ble_address directly', async () => { (globalThis as any).window.DsmBridge = { __binary: true, - __callBin: async () => new Uint8Array(0), + sendMessageBin: async () => new Uint8Array(0), }; const contact = { alias: 'B', deviceId: mkBytes(3), genesisHash: mkBytes(4), bleAddress: '11:22:33:44:55:66' }; await expect(dsmClient.resolveBleAddressForContact?.(contact as any)).resolves.toBe('11:22:33:44:55:66'); @@ -44,7 +44,7 @@ describe('resolveBleAddressForContact', () => { const address = 'AA:BB:CC:DD:EE:FF'; (globalThis as any).window.DsmBridge = { __binary: true, - __callBin: async (_reqBytes: Uint8Array) => { + sendMessageBin: async (_reqBytes: Uint8Array) => { return createDsmBridgeSuccessResponse(new Uint8Array(Array.from(enc.encode(address)))); }, }; @@ -70,7 +70,7 @@ describe('resolveBleAddressForContact', () => { (globalThis as any).window.DsmBridge = { __binary: true, - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const pb = require('../../proto/dsm_app_pb'); const req = pb.BridgeRpcRequest.fromBinary(reqBytes); const method = req.method || ''; @@ -100,7 +100,7 @@ describe('resolveBleAddressForContact', () => { const address = 'AA:11:22:33:44:55'; (globalThis as any).window.DsmBridge = { __binary: true, - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const pb = require('../../proto/dsm_app_pb'); const req = pb.BridgeRpcRequest.fromBinary(reqBytes); const method = req.method || ''; diff --git a/dsm_client/frontend/src/dsm/__tests__/blePairingRequestNormalization.test.ts b/dsm_client/frontend/src/dsm/__tests__/blePairingRequestNormalization.test.ts index 9b6d3c06e..4608b7654 100644 --- a/dsm_client/frontend/src/dsm/__tests__/blePairingRequestNormalization.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/blePairingRequestNormalization.test.ts @@ -22,7 +22,7 @@ describe('BlePairingRequest normalization & mapping', () => { const genesis = mkBytes(61); const rawAddress = 'aa:bb:cc:dd:ee:ff'; // lower-case; should normalize to upper-case (globalThis as any).window = (globalThis as any).window || {}; - (globalThis as any).window.DsmBridge = { __binary: true, __callBin: async () => new Uint8Array(0) }; + (globalThis as any).window.DsmBridge = { __binary: true, sendMessageBin: async () => new Uint8Array(0) }; const contact = { alias: 'PeerLC', deviceId: devId, genesisHash: genesis, bleAddress: rawAddress }; const resolved = await dsmClient.resolveBleAddressForContact?.(contact as any); @@ -33,7 +33,7 @@ describe('BlePairingRequest normalization & mapping', () => { const devId = mkBytes(70); const genesis = mkBytes(71); const rawAddress = '112233445566'; // contiguous hex - (globalThis as any).window.DsmBridge = { __binary: true, __callBin: async () => new Uint8Array(0) }; + (globalThis as any).window.DsmBridge = { __binary: true, sendMessageBin: async () => new Uint8Array(0) }; const contact = { alias: 'PeerHex', deviceId: devId, genesisHash: genesis, bleAddress: rawAddress }; const resolved = await dsmClient.resolveBleAddressForContact?.(contact as any); diff --git a/dsm_client/frontend/src/dsm/__tests__/bridgeDecoding.integration.test.ts b/dsm_client/frontend/src/dsm/__tests__/bridgeDecoding.integration.test.ts index ab3b62e54..15fe1ebea 100644 --- a/dsm_client/frontend/src/dsm/__tests__/bridgeDecoding.integration.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/bridgeDecoding.integration.test.ts @@ -3,7 +3,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { decodeBalancesListResponseStrict, decodeFramedEnvelopeV3 } from '../decoding'; import { processEnvelopeV3Bin } from '../WebViewBridge'; -import { decodeBase32Crockford } from '../../utils/textId'; function makeInvalidResponse(): Uint8Array { return new Uint8Array([0x01, 0x02, 0x03, 0x04]); @@ -22,47 +21,33 @@ describe('bridge decoding boundary (integration)', () => { (global as any).window.DsmBridge = (global as any).window.DsmBridge || {}; }); + // Bytes the port answered that are not a BridgeRpcResponse: index.html's + // wrapper answers `invalid bridge response for `. it('rejects invalid BridgeRpcResponse bytes', async () => { - (global as any).window.DsmBridge.__callBin = async () => makeInvalidResponse(); - await expect(processEnvelopeV3Bin(new Uint8Array([1, 2, 3]))).rejects.toThrow(/Bridge error/i); + (global as any).window.DsmBridge.sendMessageBin = async () => makeInvalidResponse(); + await expect(processEnvelopeV3Bin(new Uint8Array([1, 2, 3]))).rejects.toThrow(/invalid bridge response for nativeBoundaryIngress/); }); it('propagates bridge error payloads', async () => { - (global as any).window.DsmBridge.__callBin = async () => makeErrorResponse('native exploded'); + (global as any).window.DsmBridge.sendMessageBin = async () => makeErrorResponse('native exploded'); await expect(processEnvelopeV3Bin(new Uint8Array([1]))).rejects.toThrow(/native exploded/i); }); - it('emits bridge.error event with debug_b32 that decodes to original ErrorResponse', async () => { - (global as any).window.DsmBridge.__callBin = async () => makeErrorResponse('native exploded'); - - // Listen for bridge.error event + // A boundary failure reaches the diagnostics bus as its message. The port + // wrappers in index.html reduce Kotlin's ErrorResponse to that message, so + // no code or debug bytes travel with it (recorded as Open in §6.29). + it('a boundary failure reaches bridge.error as its message', async () => { + (global as any).window.DsmBridge.sendMessageBin = async () => makeErrorResponse('native exploded'); const { bridgeEvents } = require('../../bridge/bridgeEvents'); - - const evPromise = new Promise((resolve, reject) => { - const off = bridgeEvents.on('bridge.error', (detail: any) => { - try { - expect(detail).toHaveProperty('code'); - expect(detail).toHaveProperty('message'); - expect(typeof detail.debugB32).toBe('string'); - const dbgStr = detail.debugB32; - console.log('DEBUG_B32:', dbgStr?.slice(0, 120)); - const decoded = decodeBase32Crockford(detail.debugB32); - // Basic check: decoded bytes exist and are non-empty (debug payload present) - console.log('DEBUG_DECODED_LEN:', decoded.length); - expect((decoded as Uint8Array).length).toBeGreaterThan(0); - off(); - resolve(); - } catch (e) { - off(); - reject(e); - } - }); - // Timeout fail-safe - setTimeout(() => { off(); reject(new Error('bridge.error not emitted')); }, 3000); - }); - - await expect(processEnvelopeV3Bin(new Uint8Array([1]))).rejects.toThrow(/native exploded/i); - await evPromise; + const seen: any[] = []; + const off = bridgeEvents.on('bridge.error', (detail: any) => { seen.push(detail); }); + try { + await expect(processEnvelopeV3Bin(new Uint8Array([1]))).rejects.toThrow(/native exploded/); + } finally { + off(); + } + expect(seen).toHaveLength(1); + expect(seen[0].message).toMatch(/native exploded/); }); it('decodeFramedEnvelopeV3 rejects non-framed garbage bytes', () => { diff --git a/dsm_client/frontend/src/dsm/__tests__/diagnostics.test.ts b/dsm_client/frontend/src/dsm/__tests__/diagnostics.test.ts index 58e9206b0..22ba7c5b6 100644 --- a/dsm_client/frontend/src/dsm/__tests__/diagnostics.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/diagnostics.test.ts @@ -17,7 +17,7 @@ function failure(message: string): Uint8Array { /** The native bridge answering `answers[method]` (bytes) over the real transport. */ function installBridge(answers: Record Uint8Array>): void { (global as any).window.DsmBridge = { - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const method = BridgeRpcRequest.fromBinary(reqBytes).method; const answer = answers[method]; if (!answer) throw new Error(`unexpected bridge method ${method}`); diff --git a/dsm_client/frontend/src/dsm/__tests__/getAllBalancesStrictBridge.test.ts b/dsm_client/frontend/src/dsm/__tests__/getAllBalancesStrictBridge.test.ts index c712fe823..c66acdb0a 100644 --- a/dsm_client/frontend/src/dsm/__tests__/getAllBalancesStrictBridge.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/getAllBalancesStrictBridge.test.ts @@ -38,8 +38,8 @@ describe('getAllBalancesStrictBridge', () => { // Mock the native bridge to return the framed data wrapped in BridgeRpcResponse (globalThis as any).window = (globalThis as any).window || {}; (globalThis as any).window.DsmBridge = { - // presence of __callBin signals BridgeGate to not block in tests - __callBin: async (reqBytes: Uint8Array) => { + // The test bridge speaks the production interface; setupTests completes it. + sendMessageBin: async (reqBytes: Uint8Array) => { const pb = require('../../proto/dsm_app_pb'); const req = pb.BridgeRpcRequest.fromBinary(reqBytes); // Expect the dedicated strict balance RPC, not the shared ingress router path diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts index 88e1179ed..924c6981d 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineSend.test.ts @@ -68,7 +68,7 @@ describe('offlineSend', () => { const to = new Uint8Array(32).fill(0x22); const commitmentHash = new Uint8Array(32).fill(0x99); - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { route, args } = decodeRouterInvoke(reqBytes); expect(route).toBe('wallet.sendOffline'); const argPack = pb.ArgPack.fromBinary(args); @@ -98,7 +98,7 @@ describe('offlineSend', () => { const bleAddress = 'AA:BB:CC:DD:EE:FF'; const commitmentHash = new Uint8Array(32).fill(0x55); - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { route, args } = decodeRouterInvoke(reqBytes); expect(route).toBe('wallet.sendOffline'); const request = pb.BilateralPrepareRequest.fromBinary(pb.ArgPack.fromBinary(args).body); @@ -122,7 +122,7 @@ describe('offlineSend', () => { test('surfaces bilateral prepare rejects from wallet.sendOffline', async () => { const to = new Uint8Array(32).fill(0x44); - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { route } = decodeRouterInvoke(reqBytes); expect(route).toBe('wallet.sendOffline'); const env = new pb.Envelope({ diff --git a/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts b/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts index d33a400dc..440c218db 100644 --- a/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/offlineTransfer_consistency_bridge.test.ts @@ -52,7 +52,7 @@ describe('offline transfer sender/recipient consistency through WebView bridge', const bleAddress = 'AA:BB:CC:DD:EE:FF'; const commitmentHash = new Uint8Array(32).fill(0x77); - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { route, args } = decodeRouterInvoke(reqBytes); expect(route).toBe('wallet.sendOffline'); @@ -93,7 +93,7 @@ describe('offline transfer sender/recipient consistency through WebView bridge', const bleAddress = 'AA:BB:CC:DD:EE:11'; const commitmentHash = new Uint8Array(32).fill(0x33); - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { args } = decodeRouterInvoke(reqBytes); const argPack = pb.ArgPack.fromBinary(args); const prepare = pb.BilateralPrepareRequest.fromBinary(argPack.body); diff --git a/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts b/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts index ad547d295..b1b2315ba 100644 --- a/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts +++ b/dsm_client/frontend/src/dsm/__tests__/protobufPayloads.test.ts @@ -24,7 +24,7 @@ function wrapSuccessEnvelope(data: Uint8Array): Uint8Array { function setupBridge(onRequest: (req: BridgeRpcRequest) => void): void { (global as any).window = (global as any).window ?? {}; (global as any).window.DsmBridge = { - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const req = BridgeRpcRequest.fromBinary(reqBytes); onRequest(req); return wrapSuccessEnvelope(new Uint8Array([1])); @@ -54,7 +54,7 @@ describe("protobuf-only bridge payloads", () => { const framedGenesisEnvelope = new Uint8Array([0x03, ...genesisEnvelope.toBinary()]); (global as any).window = (global as any).window ?? {}; (global as any).window.DsmBridge = { - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const req = BridgeRpcRequest.fromBinary(reqBytes); seenRequests.push(req); if (req.method === "createGenesisV2") { diff --git a/dsm_client/frontend/src/dsm/__tests__/transportCore.bridge.test.ts b/dsm_client/frontend/src/dsm/__tests__/transportCore.bridge.test.ts new file mode 100644 index 000000000..d184dedf6 --- /dev/null +++ b/dsm_client/frontend/src/dsm/__tests__/transportCore.bridge.test.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { BridgeRpcRequest, BridgeRpcResponse, IngressRequest, IngressResponse } from '../../proto/dsm_app_pb'; +import { callBin, routerQueryBin } from '../WebViewBridge/transportCore'; + +function success(data: Uint8Array): Uint8Array { + return new BridgeRpcResponse({ result: { case: 'success', value: { data: new Uint8Array(data) } } }).toBinary(); +} + +describe('the transport is the bridge index.html installs', () => { + // Every transport path used to branch on `__callBin`, a function only the + // jest stub installed, and fall back to `sendMessageBin`. + test('a bridge object without the port transport is refused', async () => { + (global as any).window.DsmBridge = { isAvailable: () => true }; + await expect(callBin('getPreference')).rejects.toThrow('DSM bridge not available'); + }); + + // Router calls go through the bridge object's own `ingress` wrapper — the + // one index.html installs over the port — never through a fallback that + // re-encodes the request here. + test('a router query goes through the bridge’s ingress wrapper', async () => { + const seen: Uint8Array[] = []; + const answer = new IngressResponse({ result: { case: 'okBytes', value: new Uint8Array([9, 9]) } }).toBinary(); + (global as any).window.DsmBridge = { + sendMessageBin: async () => { throw new Error('the port must not be called directly for a router query'); }, + ingress: async (payload: Uint8Array) => { seen.push(payload); return answer; }, + }; + await expect(routerQueryBin('balance.list', new Uint8Array(0))).resolves.toEqual(new Uint8Array([9, 9])); + expect(seen).toHaveLength(1); + expect(IngressRequest.fromBinary(seen[0]).operation.case).toBe('routerQuery'); + }); +}); diff --git a/dsm_client/frontend/src/dsm/bridgeTypes.ts b/dsm_client/frontend/src/dsm/bridgeTypes.ts index 54ee0ae99..4a2e50647 100644 --- a/dsm_client/frontend/src/dsm/bridgeTypes.ts +++ b/dsm_client/frontend/src/dsm/bridgeTypes.ts @@ -1,15 +1,19 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 // The bridge object `public/index.html` installs as `window.DsmBridge`: a -// bytes-only MessagePort bridge. Separated from WebViewBridge to avoid -// circular imports when providing/injecting the bridge instance. +// bytes-only MessagePort bridge. `ci/bridge_rpc_names.py` holds these members +// to exactly the keys index.html installs. Separated from WebViewBridge to +// avoid circular imports when providing/injecting the bridge instance. export interface AndroidBridgeV3 { - __binary?: boolean; - sendMessageBin?: (payload: Uint8Array) => Promise; - __callBin?: (payload: Uint8Array) => Promise; - startup?: (payload: Uint8Array) => Promise; - ingress?: (payload: Uint8Array) => Promise; - hostRequest?: (payload: Uint8Array) => Promise; - isAvailable?: () => boolean; - getBridgeStatus?: () => number; + __binary: boolean; + isAvailable: () => boolean; + /** One BridgeRpcRequest over the port; answers the BridgeRpcResponse bytes Kotlin posted. */ + sendMessageBin: (payload: Uint8Array) => Promise; + /** `nativeBoundaryStartup`, unwrapped to the boundary's bytes. */ + startup: (payload: Uint8Array) => Promise; + /** `nativeBoundaryIngress`, unwrapped to the boundary's bytes. */ + ingress: (payload: Uint8Array) => Promise; + /** `nativeHostRequest`, unwrapped to the host's bytes. */ + hostRequest: (payload: Uint8Array) => Promise; + getBridgeStatus: () => number; } diff --git a/dsm_client/frontend/src/globals.d.ts b/dsm_client/frontend/src/globals.d.ts index ec2a86b13..723862561 100644 --- a/dsm_client/frontend/src/globals.d.ts +++ b/dsm_client/frontend/src/globals.d.ts @@ -4,12 +4,8 @@ // Jest types are provided by @types/jest (do not redeclare here). declare interface Window { - dsmBridge: { - callNative(method: string, payload: Uint8Array): Promise; - }; DsmBridge?: { __binary?: boolean; - __callBin?: (payload: Uint8Array) => Promise; sendMessageBin?: (payload: Uint8Array) => Promise; }; } diff --git a/dsm_client/frontend/src/services/__tests__/bitcoinTap.withdrawal.test.ts b/dsm_client/frontend/src/services/__tests__/bitcoinTap.withdrawal.test.ts index 253e04486..7825abf6a 100644 --- a/dsm_client/frontend/src/services/__tests__/bitcoinTap.withdrawal.test.ts +++ b/dsm_client/frontend/src/services/__tests__/bitcoinTap.withdrawal.test.ts @@ -39,7 +39,7 @@ describe('bitcoinTap withdrawal planner service', () => { framedEnv.set(envBytes, 1); (global as any).window.DsmBridge = { - __callBin: async (reqBytes: Uint8Array): Promise => { + sendMessageBin: async (reqBytes: Uint8Array): Promise => { capturedReqBytes = new Uint8Array(reqBytes); return (global as any).createDsmBridgeSuccessResponse( new IngressResponse({ @@ -97,7 +97,7 @@ describe('bitcoinTap withdrawal planner service', () => { framedEnv.set(envBytes, 1); (global as any).window.DsmBridge = { - __callBin: async (reqBytes: Uint8Array): Promise => { + sendMessageBin: async (reqBytes: Uint8Array): Promise => { capturedReqBytes = new Uint8Array(reqBytes); return (global as any).createDsmBridgeSuccessResponse( new IngressResponse({ diff --git a/dsm_client/frontend/src/services/__tests__/headerService.test.ts b/dsm_client/frontend/src/services/__tests__/headerService.test.ts index fa55e924b..d1a9a88ec 100644 --- a/dsm_client/frontend/src/services/__tests__/headerService.test.ts +++ b/dsm_client/frontend/src/services/__tests__/headerService.test.ts @@ -40,9 +40,12 @@ describe('HeaderService', () => { expect(headerService.isBridgeAvailable()).toBe(true); }); - it('returns true when DsmBridge.__callBin is a function', () => { - (globalThis as Record).DsmBridge = { __callBin: jest.fn() }; + it('is available only through the bytes-only bridge index.html installs', () => { + (globalThis as Record).DsmBridge = { __binary: true, sendMessageBin: jest.fn() }; expect(headerService.isBridgeAvailable()).toBe(true); + // A transport function alone is not the bridge: production installs `__binary`. + (globalThis as Record).DsmBridge = { sendMessageBin: jest.fn() }; + expect(headerService.isBridgeAvailable()).toBe(false); }); it('returns false when DsmBridge is an empty object', () => { diff --git a/dsm_client/frontend/src/services/headerService.ts b/dsm_client/frontend/src/services/headerService.ts index 5e7c68603..77db02da6 100644 --- a/dsm_client/frontend/src/services/headerService.ts +++ b/dsm_client/frontend/src/services/headerService.ts @@ -34,20 +34,12 @@ class HeaderService { isBridgeAvailable(): boolean { const b = (globalThis as any)?.DsmBridge; - return !!( - b && - (b.__binary === true || typeof b.__callBin === 'function') - ); + return b?.__binary === true; } ensureBridge(): void { const b: any = (globalThis as any)?.DsmBridge; - const ok = !!( - b && ( - b.__binary === true || typeof b.__callBin === 'function' - ) - ); - if (!ok) throw new Error('DSM bridge not available'); + if (b?.__binary !== true) throw new Error('DSM bridge not available'); } invalidateCache(): void { diff --git a/dsm_client/frontend/src/setupTests.ts b/dsm_client/frontend/src/setupTests.ts index 61c2aa2dd..2636281ee 100644 --- a/dsm_client/frontend/src/setupTests.ts +++ b/dsm_client/frontend/src/setupTests.ts @@ -4,6 +4,7 @@ // Jest setup for React Testing Library and bridge shims import '@testing-library/jest-dom'; import { setBridgeInstance } from './bridge/BridgeRegistry'; +import * as pb from './proto/dsm_app_pb'; // Silence noisy console logs in test output. Warnings and errors remain visible. const silenceLogs = process.env.JEST_SILENCE_LOGS !== '0'; @@ -50,12 +51,49 @@ if (typeof window !== 'undefined' && typeof window.HTMLMediaElement !== 'undefin // Provide a minimal WebView MCP bridge mock for tests if (typeof (global as any).window !== 'undefined') { const g = (global as any); - // Ensure DsmBridge exists for tests, but do NOT install shims here. - // Tests should explicitly mock the exact methods they need, and production code - // should rely on the single bytes-only bridge contract. - // Install a proxy setter so any reassignment of window.DsmBridge also updates the DI registry. - // This keeps tests deterministic even when they replace the bridge object. - let __bridge = g.window.DsmBridge || {}; + // The test bridge speaks the production interface: the object `index.html` + // installs — `__binary`, `isAvailable`, `sendMessageBin`, and `startup` / + // `ingress` / `hostRequest`, which are index.html's own wrappers over + // `sendMessageBin`. A test supplies `sendMessageBin` (one BridgeRpcRequest in, + // BridgeRpcResponse bytes out) and the setter completes the rest, so the + // production transport runs unchanged; nothing in production branches on a + // test-only method. + const completeTestBridge = (bridge: any) => { + if (typeof bridge.sendMessageBin !== 'function') return bridge; + const callBridgeMethod = async (method: string, payload: Uint8Array): Promise => { + const req = new pb.BridgeRpcRequest({ + method, + payload: payload.length > 0 + ? { case: 'bytes', value: new pb.BytesPayload({ data: new Uint8Array(payload) as Uint8Array }) } + : { case: 'empty', value: new pb.EmptyPayload({}) }, + }); + // Read at call time: a test may replace `sendMessageBin` on the same object. + const raw = await bridge.sendMessageBin(req.toBinary()); + let response: pb.BridgeRpcResponse; + try { + response = pb.BridgeRpcResponse.fromBinary(raw); + } catch { + // As index.html's unwrapBridgeRpcSuccess answers bytes it cannot parse. + throw new Error(`invalid bridge response for ${method}`); + } + if (response.result.case === 'success') return response.result.value.data; + if (response.result.case === 'error') { + throw new Error(response.result.value.message || `bridge error while calling ${method}`); + } + throw new Error(`invalid bridge response for ${method}`); + }; + bridge.__binary = true; + if (typeof bridge.isAvailable !== 'function') bridge.isAvailable = () => true; + if (typeof bridge.getBridgeStatus !== 'function') bridge.getBridgeStatus = () => 3; + if (typeof bridge.startup !== 'function') bridge.startup = (p: Uint8Array) => callBridgeMethod('nativeBoundaryStartup', p); + if (typeof bridge.ingress !== 'function') bridge.ingress = (p: Uint8Array) => callBridgeMethod('nativeBoundaryIngress', p); + if (typeof bridge.hostRequest !== 'function') bridge.hostRequest = (p: Uint8Array) => callBridgeMethod('nativeHostRequest', p); + return bridge; + }; + // A proxy setter: any reassignment of window.DsmBridge is completed and + // registered with the DI registry, so a test that replaces the bridge object + // still runs the production transport. + let __bridge = completeTestBridge(g.window.DsmBridge || {}); Object.defineProperty(g.window, 'DsmBridge', { configurable: true, enumerable: true, @@ -63,16 +101,17 @@ if (typeof (global as any).window !== 'undefined') { return __bridge; }, set(v: any) { - __bridge = v || {}; + __bridge = completeTestBridge(v || {}); setBridgeInstance(__bridge); }, }); // Initialize registry with current bridge value. setBridgeInstance(g.window.DsmBridge); - // Provide a default __callBin implementation that returns BridgeRpcResponse bytes - if (!g.window.DsmBridge.__callBin) { - g.window.DsmBridge.__callBin = async (reqBytes: Uint8Array): Promise => { + // The default transport answers the methods most tests need; a test that + // needs another answer installs its own `sendMessageBin`. + if (!g.window.DsmBridge.sendMessageBin) { + g.window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array): Promise => { const req = pb.BridgeRpcRequest.fromBinary(reqBytes); const method = req.method || ''; // Default implementation returns mock responses for common methods @@ -123,10 +162,10 @@ if (typeof (global as any).window !== 'undefined') { const errorMessage = `Method '${method}' not mocked in test environment`; return createDsmBridgeErrorResponse(errorMessage); }; + completeTestBridge(g.window.DsmBridge); } } -import * as pb from './proto/dsm_app_pb'; import { encodeBase32Crockford } from './utils/textId'; // Helper function to create properly formatted BridgeRpcResponse error responses diff --git a/dsm_client/frontend/src/tests/E2E.bilateral.acceptFlow.test.tsx b/dsm_client/frontend/src/tests/E2E.bilateral.acceptFlow.test.tsx index ee318c5a8..dbe90a6de 100644 --- a/dsm_client/frontend/src/tests/E2E.bilateral.acceptFlow.test.tsx +++ b/dsm_client/frontend/src/tests/E2E.bilateral.acceptFlow.test.tsx @@ -48,7 +48,7 @@ describe('E2E bilateral accept: BLE accept flow triggers refresh and toast', () }; (window as any).DsmBridge = { - __callBin: async (reqBytes: Uint8Array) => { + sendMessageBin: async (reqBytes: Uint8Array) => { const req = pb.BridgeRpcRequest.fromBinary(reqBytes); const method = req.method || ''; if (method === 'acceptBilateralByCommitment') { @@ -83,7 +83,7 @@ describe('E2E bilateral accept: BLE accept flow triggers refresh and toast', () const headers = new pb.Headers({ deviceId: new Uint8Array(32).fill(1), genesisHash: new Uint8Array(32).fill(1) as any, chainTip: new Uint8Array(32), seq: 1n as any } as any); return (global as any).createDsmBridgeSuccessResponse(frame(headers.toBinary())); } - throw new Error(`unhandled __callBin method:${method}`); + throw new Error(`unhandled bridge method:${method}`); }, getDeviceIdBin: () => new Uint8Array(32).fill(1), getGenesisHashBin: () => new Uint8Array(32).fill(1), diff --git a/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts b/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts index 16731f3cd..0025efc59 100644 --- a/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts +++ b/dsm_client/frontend/src/tests/E2E.offlineBleExchange.test.ts @@ -67,35 +67,6 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { (global as any).window.DsmBridge = { __binary: true, sendMessageBin: async (reqBytes: Uint8Array) => { - const req = pb.BridgeRpcRequest.fromBinary(reqBytes); - const method = req.method || ''; - const data = req.payload?.case === 'bytes' ? req.payload.value.data : new Uint8Array(0); - if (method === 'nativeBoundaryIngress') { - const ingress = pb.IngressRequest.fromBinary(data); - if (ingress.operation.case === 'routerQuery' && ingress.operation.value.method === 'contacts.list') { - const contactsListResponse = new pb.ContactsListResponse({ - contacts: [ - { - alias: 'Bob', - deviceId: BOB_DEVICE_ID, - genesisHash: new pb.Hash32({ v: BOB_GENESIS } as any), - chainTip: new pb.Hash32({ v: BOB_TIP } as any), - bleAddress: 'AA:BB:CC:DD:EE:FF', - }, - ], - } as any); - // Return Envelope-wrapped response with framing byte and router prefix - const env = new pb.Envelope({ - version: 3, - payload: { case: 'contactsListResponse', value: contactsListResponse }, - } as any); - return wrapIngressOk(frameEnvelope(env)); - } - return wrapIngressOk(new Uint8Array(0)); - } - throw new Error(`unhandled sendMessageBin: ${reqBytes.length} bytes`); - }, - __callBin: async (reqBytes: Uint8Array) => { const req = pb.BridgeRpcRequest.fromBinary(reqBytes); const method = req.method || ''; const payload = req.payload?.case === 'bytes' ? req.payload.value.data : new Uint8Array(0); @@ -160,7 +131,7 @@ describe('E2E: Offline BLE exchange -> wallet refresh', () => { const hostRequest = pb.NativeHostRequest.fromBinary(payload); throw new Error(`unhandled nativeHostRequest kind: ${hostRequest.kind}`); } - throw new Error(`unhandled __callBin method: ${method} (payloadLen=${payload.length})`); + throw new Error(`unhandled bridge method: ${method} (payloadLen=${payload.length})`); }, // Some call sites read base32 Crockford strings from these getters. getDeviceIdBin: () => base32CrockfordEncode(ALICE_DEVICE_ID), diff --git a/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts b/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts index 8972ae548..4d2330e6a 100644 --- a/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts +++ b/dsm_client/frontend/src/tests/E2E.sendOnlineTransfer.test.ts @@ -98,7 +98,7 @@ describe('E2E: sendOnlineTransfer (unit-level, mocked storage)', () => { (global as any).window.DsmBridge.sendMessageBin = (global as any).window.DsmBridge.sendMessageBin || (async () => new Uint8Array(0)); // Mock the bytes-only router calls - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const req = pb.BridgeRpcRequest.fromBinary(reqBytes); const method = req.method || ''; diff --git a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts index 4d092110d..6096b9d34 100644 --- a/dsm_client/frontend/src/tests/E2E.transferProof.test.ts +++ b/dsm_client/frontend/src/tests/E2E.transferProof.test.ts @@ -167,7 +167,7 @@ function installBridge(opts?: { contactBleAddress?: string }) { getDeviceIdBin: () => encodeBase32Crockford(DEVICE_A), getGenesisHashBin: () => encodeBase32Crockford(GENESIS_A), - __callBin: async (reqBytes: Uint8Array): Promise => { + sendMessageBin: async (reqBytes: Uint8Array): Promise => { const { method, payload } = decodeBridgeReq(reqBytes); capturedMethods.push(method); @@ -238,11 +238,6 @@ function installBridge(opts?: { contactBleAddress?: string }) { return wrapError(`unhandled method: ${method}`); }, - - sendMessageBin: async (reqBytes: Uint8Array): Promise => { - // MessagePort path — delegates to __callBin for simplicity in tests - return g.window.DsmBridge.__callBin(reqBytes); - }, }; // DOM event APIs are provided by jsdom — no mocking needed. @@ -481,8 +476,8 @@ describe('Offline Transfer — Full Cycle', () => { let capturedPrepReq: pb.BilateralPrepareRequest | null = null; // Intercept nativeBoundaryIngress to capture the ArgPack → BilateralPrepareRequest - const origCallBin = (global as any).window.DsmBridge.__callBin; - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + const origCallBin = (global as any).window.DsmBridge.sendMessageBin; + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { method, payload } = decodeBridgeReq(reqBytes); if (method === 'nativeBoundaryIngress') { const ingress = decodeIngressReq(payload); @@ -588,8 +583,8 @@ describe('Offline Transfer — Full Cycle', () => { test('missing BLE address with no resolution → error', async () => { // Override bridge: when wallet.sendOffline is called with an empty bleAddress, // the Rust layer rejects with a bilateralPrepareReject error. - const origCallBin = (global as any).window.DsmBridge.__callBin; - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + const origCallBin = (global as any).window.DsmBridge.sendMessageBin; + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { method, payload } = decodeBridgeReq(reqBytes); if (method === 'nativeBoundaryIngress') { const ingress = decodeIngressReq(payload); @@ -766,8 +761,8 @@ describe('Offline Transfer — Timeout & Event Matching', () => { version: 3, payload: { case: 'error', value: new pb.Error({ code: 1, message: 'wallet.sendOffline: the request names no token' }) }, } as any)); - const origCallBin = (global as any).window.DsmBridge.__callBin; - (global as any).window.DsmBridge.__callBin = async (reqBytes: Uint8Array) => { + const origCallBin = (global as any).window.DsmBridge.sendMessageBin; + (global as any).window.DsmBridge.sendMessageBin = async (reqBytes: Uint8Array) => { const { method, payload } = decodeBridgeReq(reqBytes); if (method === 'nativeBoundaryIngress') { const ingress = decodeIngressReq(payload); diff --git a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx index f85b02af0..5ff411e2f 100644 --- a/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx +++ b/dsm_client/frontend/src/tests/E2E.uiCoordination.test.tsx @@ -2,29 +2,29 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /** - * E2E UI Coordination Tests — REAL EVERYTHING, __callBin-only mock + * E2E UI Coordination Tests — REAL EVERYTHING, sendMessageBin-only mock * * WHAT THIS PROVES: * The ENTIRE TypeScript stack works end-to-end — from React components down to - * the JNI bridge boundary. The ONLY mock is window.DsmBridge.__callBin, which + * the JNI bridge boundary. The ONLY mock is window.DsmBridge.sendMessageBin, which * is the actual Android/Kotlin JNI entry point. * * WHAT'S REAL (NOT MOCKED): * - BilateralTransferDialog (REAL React component) * - WalletProvider / useWallet (REAL React context with real refreshAll) * - UXProvider (REAL) - * - dsmClient.getAllBalances() → dsm/wallet.ts::getAllBalances() → WebViewBridge::getAllBalancesStrictBridge() → callBin() → __callBin (mock) - * - dsmClient.getWalletHistory() → dsm/wallet.ts::getWalletHistory() → WebViewBridge::getWalletHistoryStrictBridge() → routerQueryBin() → __callBin (mock) - * - dsmClient.getIdentity() → dsm/identity.ts::getIdentity() → getHeaders() → getTransportHeadersV3Bin() → __callBin (mock) - * - dsmClient.isReady() → hasIdentity() → checkIdentityState() → __callBin (mock) - * - acceptIncomingTransfer() → acceptOfflineTransfer() → acceptBilateralByCommitmentBridge() → callBin() → __callBin (mock) - * - rejectIncomingTransfer() → rejectOfflineTransfer() → rejectBilateralByCommitmentBridge() → sendBridgeRequestBytes() → __callBin (mock) + * - dsmClient.getAllBalances() → dsm/wallet.ts::getAllBalances() → WebViewBridge::getAllBalancesStrictBridge() → callBin() → sendMessageBin (mock) + * - dsmClient.getWalletHistory() → dsm/wallet.ts::getWalletHistory() → WebViewBridge::getWalletHistoryStrictBridge() → routerQueryBin() → sendMessageBin (mock) + * - dsmClient.getIdentity() → dsm/identity.ts::getIdentity() → getHeaders() → getTransportHeadersV3Bin() → sendMessageBin (mock) + * - dsmClient.isReady() → hasIdentity() → checkIdentityState() → sendMessageBin (mock) + * - acceptIncomingTransfer() → acceptOfflineTransfer() → acceptBilateralByCommitmentBridge() → callBin() → sendMessageBin (mock) + * - rejectIncomingTransfer() → rejectOfflineTransfer() → rejectBilateralByCommitmentBridge() → sendBridgeRequestBytes() → sendMessageBin (mock) * - EventBridge (REAL — initializeEventBridge) * - nativeBridgeAdapter (REAL — initializeNativeBridgeAdapter) * - bridgeEvents (REAL pub/sub) * - useEventSignal (REAL useSyncExternalStore) * - useWalletSync (REAL event→dispatch routing) - * - BridgeGate (REAL — auto-opens for __callBin paths) + * - BridgeGate (REAL — auto-opens for sendMessageBin paths) * - decodeFramedEnvelopeV3, decodeBalancesListResponseStrict (REAL decoders) * * COVERAGE: @@ -33,7 +33,7 @@ * 3. Bilateral event encode/decode roundtrip (protobuf) * 4. DOM event → nativeBridgeAdapter → bridgeEvents (REAL adapter) * 5. DOM event → EventBridge → bilateral.event (REAL EventBridge) - * 6. INTEGRATED: Dialog + WalletContext — PREPARE → Accept → COMPLETE → refreshAll → REAL getAllBalances → __callBin → proto decode → balance in DOM + * 6. INTEGRATED: Dialog + WalletContext — PREPARE → Accept → COMPLETE → refreshAll → REAL getAllBalances → sendMessageBin → proto decode → balance in DOM * 7. INTEGRATED: wallet.sendCommitted → WalletContext refresh trigger only * 8. INTEGRATED: Full bilateral sequence through REAL components — EXACT device sequence */ @@ -198,9 +198,9 @@ function makeRejectFramedEnvelope(): Uint8Array { return frameEnvelope(env); } -// ─── __callBin Mock State ──────────────────────────────────────────────────── +// ─── sendMessageBin Mock State ──────────────────────────────────────────────────── -/** Mutable state that tests can modify to change what __callBin returns */ +/** Mutable state that tests can modify to change what sendMessageBin returns */ let balancesState: Array<{ tokenId: string; available: bigint }> = [ { tokenId: 'ERA', available: 10000n }, ]; @@ -209,14 +209,14 @@ let historyState: Array<{ amount: bigint; amountSigned: bigint }> = [ ]; let capturedMethods: string[] = []; -/** Install a __callBin mock that handles the full protocol */ +/** Install a sendMessageBin mock that handles the full protocol */ function installCallBinMock() { const g = global as any; g.window = g.window || {}; const bridge = { __binary: true, - __callBin: async (reqBytes: Uint8Array): Promise => { + sendMessageBin: async (reqBytes: Uint8Array): Promise => { const { method, payload } = decodeBridgeReq(reqBytes); capturedMethods.push(method); @@ -281,14 +281,10 @@ function installCallBinMock() { // Default: error for unknown methods return wrapError(`Method '${method}' not handled in UI coordination test mock`); }, - sendMessageBin: async (reqBytes: Uint8Array): Promise => { - return bridge.__callBin(reqBytes); - }, - getAppRouterStatus: () => 1, }; + // The setter registers the bridge with the DI registry and completes it. g.window.DsmBridge = bridge; - setBridgeInstance(bridge); } // ─── Initialization ────────────────────────────────────────────────────────── @@ -632,14 +628,14 @@ describe('EventBridge — REAL DOM event-bin propagation', () => { }); // ═══════════════════════════════════════════════════════════════════════════════ -// 6. INTEGRATED: BilateralTransferDialog + WalletProvider — __callBin-only Mock -// The ENTIRE TypeScript chain is REAL. Only __callBin is mocked. +// 6. INTEGRATED: BilateralTransferDialog + WalletProvider — sendMessageBin-only Mock +// The ENTIRE TypeScript chain is REAL. Only sendMessageBin is mocked. // ═══════════════════════════════════════════════════════════════════════════════ -describe('INTEGRATED: Full chain with __callBin-only mock', () => { +describe('INTEGRATED: Full chain with sendMessageBin-only mock', () => { // NO jest.mock() for bilateralEventService — it's REAL! - // acceptIncomingTransfer → acceptOfflineTransfer → acceptBilateralByCommitmentBridge → callBin → __callBin (mocked) - // rejectIncomingTransfer → rejectOfflineTransfer → rejectBilateralByCommitmentBridge → sendBridgeRequestBytes → __callBin (mocked) + // acceptIncomingTransfer → acceptOfflineTransfer → acceptBilateralByCommitmentBridge → callBin → sendMessageBin (mocked) + // rejectIncomingTransfer → rejectOfflineTransfer → rejectBilateralByCommitmentBridge → sendBridgeRequestBytes → sendMessageBin (mocked) // Dynamic imports to avoid module initialization order issues let BilateralTransferDialog: any; @@ -706,7 +702,7 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { jest.useRealTimers(); }); - test('WalletProvider initializes by calling REAL getAllBalances → __callBin', async () => { + test('WalletProvider initializes by calling REAL getAllBalances → sendMessageBin', async () => { render(); await settleWalletInit(); @@ -715,19 +711,19 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { expect(screen.getByTestId('i-balance-era').textContent).not.toBe('none'); }); - // The balance came from __callBin via: dsmClient.getAllBalances() → dsm.getAllBalances() - // → getAllBalancesStrictBridge() → callBin('getAllBalancesStrict') → __callBin → FramedEnvelopeV3 + // The balance came from sendMessageBin via: dsmClient.getAllBalances() → dsm.getAllBalances() + // → getAllBalancesStrictBridge() → callBin('getAllBalancesStrict') → sendMessageBin → FramedEnvelopeV3 // → decodeBalancesListResponseStrict() → TokenBalanceView[] // PROVES the entire decode chain works. const balText = screen.getByTestId('i-balance-era').textContent; expect(balText).toBe('10000'); - // Verify __callBin was actually called with the expected methods + // Verify sendMessageBin was actually called with the expected methods expect(capturedMethods).toContain('getAllBalancesStrict'); expect(capturedMethods).toContain('getTransportHeadersV3Bin'); }); - test('PREPARE_RECEIVED → Dialog shows → Accept → REAL acceptIncomingTransfer → __callBin', async () => { + test('PREPARE_RECEIVED → Dialog shows → Accept → REAL acceptIncomingTransfer → sendMessageBin', async () => { const { container } = render(); await settleWalletInit(); await waitFor(() => expect(screen.getByTestId('i-balance-era').textContent).not.toBe('none')); @@ -762,12 +758,12 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { capturedMethods = []; // Click Accept — calls REAL handleAccept → REAL acceptIncomingTransfer - // → REAL acceptOfflineTransfer → REAL acceptBilateralByCommitmentBridge → callBin → __callBin + // → REAL acceptOfflineTransfer → REAL acceptBilateralByCommitmentBridge → callBin → sendMessageBin await act(async () => { fireEvent.click(screen.getByText('Accept')); }); - // Verify __callBin received acceptBilateralByCommitment + // Verify sendMessageBin received acceptBilateralByCommitment expect(capturedMethods).toContain('acceptBilateralByCommitment'); // Dialog clears after successful accept @@ -776,7 +772,7 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { }); }); - test('PREPARE → Reject → REAL rejectIncomingTransfer → __callBin', async () => { + test('PREPARE → Reject → REAL rejectIncomingTransfer → sendMessageBin', async () => { const { container } = render(); await settleWalletInit(); await waitFor(() => expect(screen.getByTestId('i-balance-era').textContent).not.toBe('none')); @@ -800,7 +796,7 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { fireEvent.click(screen.getByText('Reject')); }); - // Verify __callBin received rejectBilateralByCommitment + // Verify sendMessageBin received rejectBilateralByCommitment expect(capturedMethods).toContain('rejectBilateralByCommitment'); await waitFor(() => { @@ -808,7 +804,7 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { }); }); - test('TRANSFER_COMPLETE → Dialog clears + REAL refreshAll → __callBin returns new balance → DOM updates', async () => { + test('TRANSFER_COMPLETE → Dialog clears + REAL refreshAll → sendMessageBin returns new balance → DOM updates', async () => { const { container } = render(); await settleWalletInit(); await waitFor(() => expect(screen.getByTestId('i-balance-era').textContent).toBe('10000')); @@ -826,7 +822,7 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { }); await waitFor(() => expect(container.querySelector('.bilateral-transfer-dialog')).not.toBeNull()); - // CHANGE what __callBin returns for the NEXT getAllBalancesStrict call + // CHANGE what sendMessageBin returns for the NEXT getAllBalancesStrict call // This simulates the balance updating in the native layer after transfer balancesState = [{ tokenId: 'ERA', available: 10300n }]; historyState = [ @@ -839,7 +835,7 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { // Emit TRANSFER_COMPLETE — this is the critical moment: // BilateralTransferDialog.handleComplete → refreshAll() → WalletProvider's REAL refreshAll() // → dsmClient.getAllBalances() → dsm.getAllBalances() → getAllBalancesStrictBridge() - // → callBin('getAllBalancesStrict') → __callBin → FramedEnvelopeV3(10300) + // → callBin('getAllBalancesStrict') → sendMessageBin → FramedEnvelopeV3(10300) // → getAllBalances() → TokenBalanceView[] → walletStore → DOM updates act(() => { eventBridgeEmit('bilateral.event', encodeBilateralEventNotification({ @@ -853,16 +849,16 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { // Dialog should be cleared await waitFor(() => expect(container.querySelector('.bilateral-transfer-dialog')).toBeNull()); - // KEY ASSERTION: __callBin was called for the refresh + // KEY ASSERTION: sendMessageBin was called for the refresh await waitFor(() => { expect(capturedMethods).toContain('getAllBalancesStrict'); }); - // KEY ASSERTION: The new balance (10300) from __callBin should appear in the DOM + // KEY ASSERTION: The new balance (10300) from sendMessageBin should appear in the DOM // This proves the ENTIRE chain works: // TRANSFER_COMPLETE event → Dialog.handleComplete → refreshAll() → dsmClient.getAllBalances() // → dsm/wallet.ts::getAllBalances() → getAllBalancesStrictBridge() → callBin('getAllBalancesStrict') - // → __callBin → BridgeRpcResponse → unwrapProtobufResponse → FramedEnvelopeV3 + // → sendMessageBin → BridgeRpcResponse → unwrapProtobufResponse → FramedEnvelopeV3 // → getAllBalances → TokenBalanceView[] → walletStore → DOM await waitFor(() => { expect(screen.getByTestId('i-balance-era').textContent).toBe('10300'); @@ -982,12 +978,12 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { await waitFor(() => expect(container.querySelector('.bilateral-transfer-overlay')).not.toBeNull()); }); - test('wallet.bilateralCommitted → WalletProvider refreshes (REAL __callBin round trip)', async () => { + test('wallet.bilateralCommitted → WalletProvider refreshes (REAL sendMessageBin round trip)', async () => { render(); await settleWalletInit(); await waitFor(() => expect(screen.getByTestId('i-balance-era').textContent).toBe('10000')); - // Change what __callBin will return on next balance fetch + // Change what sendMessageBin will return on next balance fetch balancesState = [{ tokenId: 'ERA', available: 10500n }]; capturedMethods = []; @@ -996,12 +992,12 @@ describe('INTEGRATED: Full chain with __callBin-only mock', () => { bridgeEvents.emit('wallet.bilateralCommitted', { accepted: true, committed: true } as any); }); - // Wait for REAL getAllBalances → __callBin round trip + // Wait for REAL getAllBalances → sendMessageBin round trip await waitFor(() => { expect(capturedMethods).toContain('getAllBalancesStrict'); }); - // Balance should update in UI from __callBin's response + // Balance should update in UI from sendMessageBin's response await waitFor(() => { expect(screen.getByTestId('i-balance-era').textContent).toBe('10500'); }); @@ -1094,10 +1090,10 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { jest.useRealTimers(); }); - test('EXACT device sequence: PREPARE → show → Accept → __callBin(acceptBilateral) → ACCEPT_SENT → COMMIT → COMPLETE → __callBin(getAllBalancesStrict) → new balance in DOM', async () => { + test('EXACT device sequence: PREPARE → show → Accept → sendMessageBin(acceptBilateral) → ACCEPT_SENT → COMMIT → COMPLETE → sendMessageBin(getAllBalancesStrict) → new balance in DOM', async () => { /** * This test reproduces the EXACT sequence of events from a real BLE transfer. - * The ONLY mock is __callBin. Everything else — React components, event bridges, + * The ONLY mock is sendMessageBin. Everything else — React components, event bridges, * proto encoding/decoding, identity resolution, BridgeGate, WebViewBridge, * bilateralEventService, transactions — is ALL REAL. */ @@ -1126,12 +1122,12 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { expect(container.textContent).toContain('1000'); }); - // ──── Step 3: User clicks Accept → REAL acceptIncomingTransfer chain → __callBin ──── + // ──── Step 3: User clicks Accept → REAL acceptIncomingTransfer chain → sendMessageBin ──── capturedMethods = []; await act(async () => { fireEvent.click(screen.getByText('Accept')); }); - // Proves the REAL chain: acceptIncomingTransfer → acceptOfflineTransfer → acceptBilateralByCommitmentBridge → __callBin + // Proves the REAL chain: acceptIncomingTransfer → acceptOfflineTransfer → acceptBilateralByCommitmentBridge → sendMessageBin expect(capturedMethods).toContain('acceptBilateralByCommitment'); // ──── Step 4: ACCEPT_SENT ──── @@ -1156,7 +1152,7 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { })); }); - // ──── Step 6: TRANSFER_COMPLETE → refreshAll → __callBin(getAllBalancesStrict with new balance) → DOM ──── + // ──── Step 6: TRANSFER_COMPLETE → refreshAll → sendMessageBin(getAllBalancesStrict with new balance) → DOM ──── balancesState = [{ tokenId: 'ERA', available: 6000n }]; historyState = [{ amount: 1000n, amountSigned: 1000n }]; capturedMethods = []; @@ -1173,7 +1169,7 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { })); }); - // Balance should reflect the 1000 ERA received (5000 → 6000) via __callBin + // Balance should reflect the 1000 ERA received (5000 → 6000) via sendMessageBin await waitFor(() => { expect(screen.getByTestId('seq-bal').textContent).toBe('6000'); }); @@ -1183,10 +1179,10 @@ describe('INTEGRATED: Full bilateral transfer back-and-forth', () => { expect(parseInt(screen.getByTestId('seq-txs').textContent || '0')).toBe(1); }); - // Verify __callBin was called for the balance refresh + // Verify sendMessageBin was called for the balance refresh expect(capturedMethods).toContain('getAllBalancesStrict'); - // ──── Step 7: wallet.bilateralCommitted → refresh again via __callBin ──── + // ──── Step 7: wallet.bilateralCommitted → refresh again via sendMessageBin ──── capturedMethods = []; act(() => { bridgeEvents.emit('wallet.bilateralCommitted', { diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index e7e4d6367..f872c7d27 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -382,6 +382,8 @@ Tests for token requests and adoption: `dsm_sdk::handlers::token_adoption_tests: Tests for settings and genesis: frontend `components/screens/__tests__/SettingsMainScreen.test.tsx` · `shows a failed NFC status read as its failure, not as NOT SET`; `hooks/__tests__/useGenesisFlow.test.ts` · `successful genesis flow decodes envelope and completes` (the request names the mnemonic alone); `dsm/__tests__/protobufPayloads.test.ts` · `createGenesisViaRouter sends one mnemonic-rooted Genesis v2 request`. Mutation controls, each red on its named test: a failed status read shown as NOT SET again (`shows a failed NFC status read as its failure, not as NOT SET`); an invented locale sent again (`successful genesis flow decodes envelope and completes`). +Tests for the transport: frontend `dsm/__tests__/transportCore.bridge.test.ts` · `a bridge object without the port transport is refused`, `a router query goes through the bridge’s ingress wrapper`; `dsm/__tests__/bridgeDecoding.integration.test.ts` · `rejects invalid BridgeRpcResponse bytes`, `a boundary failure reaches bridge.error as its message`; `services/__tests__/headerService.test.ts` · `is available only through the bytes-only bridge index.html installs`; the 20 migrated suites (E2E and bridge tests) run the production transport. Mutation controls, each red on its named test: a fallback re-encoding the request here in place of the bridge's `ingress` (`a router query goes through the bridge’s ingress wrapper`); a bridge without the port transport accepted (`a bridge object without the port transport is refused`). Gate controls, each failing the gate naming the offender: a phantom member on the bridge type; a production source naming `__callBin`. A control that re-adds the length-prefix guess stays green, as it must: no valid answer can satisfy the guess, which is why the code is deleted rather than tested. + **Open** | Location | Finding | @@ -1014,6 +1016,7 @@ Owner request: integrate the frontend with the storage nodes properly, working f | frontend · dsm/policies.ts · `createToken`, `addTokenByAnchor`, `burnToken`, `forgetToken`, `getTokenCreationFeeEra`; components/TokenCreationDialog.tsx; components/screens/AccountsScreen.tsx; `proto` · `TokenCreateResponse`; `dsm_sdk` · handlers/token_routes.rs | `createToken` filled what the dialog did not give — an empty ticker and alias, 0 decimals, empty description and icon — and sent it, where Rust refuses an empty ticker or alias but takes 0 decimals as a choice; `burnToken` and `forgetToken` sent `""` for a missing token id. `addTokenByAnchor` reported the adopted token's ticker by stripping "Added " off Rust's message — the wire carried the ticker only as prose — and the screen showed its anchor from whichever balance row it found, or `""`. `getTokenCreationFeeEra` answered `undefined` for a refused or malformed answer, which the dialog shows as "…" for ever, indistinguishable from a query still running. | The requests carry what the user entered, as entered; Rust trims, uppercases and refuses. `TokenCreateResponse` carries `ticker` (field 5); Rust fills it at creation, at a repeated creation and at adoption, and the frontend reads the ticker, the token id and the 32-byte anchor from the answer's fields, refusing a success answer that lacks them. The fee query's failure is the failure, and the dialog shows `not available: `. The added-token panel shows the anchor Rust re-derived and answered. | | frontend · components/screens/SettingsMainScreen.tsx; hooks/useGenesisFlow.ts; dsm/WebViewBridge/genesis.ts; `proto` · `WalletCreateGenesisV2Request.locale`, `GenesisCreated.locale`; `dsm_sdk` · handlers/system_routes.rs; Kotlin · SinglePathWebViewBridge.kt, BridgeIdentityHandler.kt | The settings screen showed a failed `recovery.status` read as `NOT SET` — "Not configured. Add a mnemonic before this phone can arm a recovery capsule.", the status of a device with no backup at all — by rendering an all-false status in the failure's place, and logged nothing for a failed developer-mode preference read. `_onSetupRing`, reachable from nothing, probed two client methods that exist nowhere (`nfcReadRingId`, `nfcRegisterRingId`) and fell back to `window.prompt` for a ring id "provided by NFC". Genesis creation sent a `locale` — `navigator.language`, or `en-US` when the browser has none — that Kotlin threaded into the request and Rust only echoed back in `GenesisCreated.locale`; nothing consumed it. | A status that could not be read is shown as `NOT READ` / `Status not read: `, with the auto-backup toggle withheld until a status is held; the preference failure is logged; the ring setup and its phantom methods are deleted. `locale` is retired on both messages and removed from Rust, Kotlin (including the androidTest `AndroidLayerProofTest`, which passed the device locale) and the frontend: the genesis request carries the mnemonic alone. | | `ci` · bridge_rpc_names.py (new); `.github/workflows/ci.yml`; Kotlin · SinglePathWebViewBridge.kt, Unified.kt, UnifiedNativeApi.kt; `dsm_sdk` · jni/unified_protobuf_bridge.rs | Nothing checked that the bridge's two sides agreed on the RPC method names a `BridgeRpcRequest` carries: the frontend sent names Kotlin never handled (`hasIdentityDirect`, for months, answered by the unknown-method arm) and Kotlin kept eight arms nothing sent — `processEnvelopeV3`, `getWalletHistoryStrict`, `getSigningPublicKeyBin`, `getPersistedGenesisEnvelope`, `getGenesisHashBin`, `getDeviceIdBin`, `startNativeQrScanner`, `hasNativeQrScanner` — two of them the only callers of their JNI exports. | `ci/bridge_rpc_names.py`: the names the frontend's production sources and `index.html` send (string literals, and a constant resolved in its file; unresolved fails) must equal the string arms of Kotlin's `handleBinaryRpcInternal`, both ways, no allowlist; it runs in the purity step, the frontend job and the Android unit-test job, so any side's change selects it. The eight dead arms are deleted, with the `getWalletHistoryStrict` and `getSigningPublicKeyBin` Kotlin functions, externals and Rust JNI exports that only they reached, and the prefs reader only one of them used. Negative controls: a phantom frontend name, a dead Kotlin arm and an unresolvable constant each fail the gate naming the offender. | +| frontend · dsm/WebViewBridge/transportCore.ts, dsm/NativeBoundaryBridge.ts, dsm/NativeHostBridge.ts, dsm/BridgeGate.ts, services/headerService.ts, dsm/bridgeTypes.ts, globals.d.ts; setupTests.ts and 20 test files; `ci` · bridge_rpc_names.py | Every transport path branched on `window.DsmBridge.__callBin`, a function only the jest stub installed: `sendBridgeRequestBytes`, `callBoundaryMethod` and `callHostMethod` each tried it first and fell back to `sendMessageBin`, the port transport `index.html` installs, so the transport the tests exercised was not the one the app runs. The production branch pre-waited for `dsm-bridge-ready` under a 2.5 s timer and then proceeded — redundant, since `sendMessageBin` waits for the port itself — and ran the answer through `maybeUnframe`, which guessed a 4-byte length prefix in either byte order and stripped it when the guess fit — a decoder with no producer: Kotlin never length-prefixes, and no BridgeRpcResponse (its first byte is a field tag) can begin with its own length, so the guess never fit a real answer and the code was dead. The bridge type declared the phantom, `isNativeHostUnavailableError` matched a test stub's error text, and `globals.d.ts` declared a `dsmBridge` global the bridge gate bans. | The transport is the bridge `index.html` installs and nothing else: `sendMessageBin` for RPCs (its BridgeRpcResponse unwrapped as posted), `startup` / `ingress` / `hostRequest` for the boundaries, `__binary` for readiness; the pre-wait, the unframing, the fallbacks, the stub-text match and the phantom declarations are deleted. `AndroidBridgeV3` is exactly the seven members index.html installs. The jest setup completes any test bridge that supplies `sendMessageBin` with the same wrapper composition index.html uses (reading `sendMessageBin` at call time), so the production transport runs unchanged in tests; the 20 test bridges now supply `sendMessageBin`. `ci/bridge_rpc_names.py` also holds the type's members equal to the installed keys and refuses a production source that names `__callBin`. | Tests: `dsm_sdk::handlers::storage_routes::tests::storage_status_reports_the_pinned_set_and_each_members_own_answer` (the router's answer over real nodes on Postgres; then one member stops serving), `dsm_sdk::sdk::storage_node_sdk::tests::a_members_latest_bytecommit_is_its_own_or_there_is_none`, `dsm_sdk::storage::client_db::tests::a_database_that_does_not_exist_has_no_size`; frontend `dsm/__tests__/storage.test.ts` and `components/storage/__tests__/StorageNodePanels.test.tsx`. Mutation controls, each red on its named test: another member's ByteCommit accepted as this member's (`a_members_latest_bytecommit_is_its_own_or_there_is_none`); a missing database file reported as 0 bytes (`a_database_that_does_not_exist_has_no_size`); a member that did not answer reported as "no cycle" (`storage_status_reports_the_pinned_set_and_each_members_own_answer`); the frontend inventing an answer for a member that carries none (`a member that carries no answer is refused, never given one`); every member counted as answering (`shows the set and counts only the members that gave an answer`). @@ -1040,7 +1043,7 @@ Tests for policy publication: `dsm_sdk::handlers::token_routes::tests::bytes_tha - frontend · AccountsScreen `CPTA_INFO`: the ERA entry's type ("DJTE emission token"), anchor formula and "PROTOCOL-DEFINED" anchor id are copy in the screen, not facts Rust reports (the anchor Rust reports is shown beside them), and whether a token is protocol-defined — which withholds burn — is decided by its ticker. The dBTC entry is not touched: Bitcoin is parked. - `dsm_sdk` · `faucet.claim`: writes no history row, so a claim never appears in the wallet's history (the reserved `TX_TYPE_FAUCET` had no writer). - frontend · services/recovery/nfcRecoveryService.ts `getNfcBackupStatus`: reads `recovery.status` as `key=value` text inside an `AppStateResponse` and fills a missing `capsule_count`/`last_capsule_index` with 0 — a text protocol on a DSM path. Recovery is a dependency boundary this round; the route and its reader change together when the recovery specification is in scope. -- frontend · dsm/WebViewBridge/transportCore.ts, NativeBoundaryBridge.ts, NativeHostBridge.ts, BridgeGate.ts, services/headerService.ts: every transport path branches on `window.DsmBridge.__callBin`, a method only the jest stub (`setupTests.ts`, 20 test files) installs; production has `sendMessageBin`, whose path frames responses and waits for `dsm-bridge-ready` under a 2.5 s timer. The test bridge should speak the production interface, and the readiness wait should be event-driven; `ci/bridge_rpc_names.py` will then also check the bridge object's methods against what `index.html` installs. +- frontend · `public/index.html` `sendMessageBin`: rejects a request after 30 minutes on a wall-clock timer (a bound sized for the M=3 K=21 enrollment); `installPortHandler` tells a response from an async event by whether the first eight bytes match a pending id. Both are transport-side, fail-closed and unchanged by the sweep; they are where the port protocol still guesses. - Kotlin · androidTest `AndroidLayerProofTest.claimFaucet`: hand-encodes the ArgPack's `schema_hash` as 32 zero bytes, where the frontend sends none, and swallows the claim's failure. ### 6.30 The SoFi verdict is Core's: facts built from reads, the ladder inside the advance (`fix/sofi-verdict-core-resolver`, 2026-09-26) From f0032ce882c3f38157728f536a5243967516c592 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:57:44 -0400 Subject: [PATCH 2/2] fix(ci): the bridge RPC name gate closes every file it reads Six `open(...).read()` calls (two flagged by code quality on #1014) left their handles to the garbage collector. A `read_text` helper opens each file in a `with` block. Gate output unchanged: 22 names sent, 22 handled, the bridge object's 7 members typed as installed; its five negative controls still fail naming the offender. --- ci/bridge_rpc_names.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ci/bridge_rpc_names.py b/ci/bridge_rpc_names.py index 89644b5cf..c75f160b7 100644 --- a/ci/bridge_rpc_names.py +++ b/ci/bridge_rpc_names.py @@ -56,6 +56,11 @@ def fail(msg): print(f"[bridge-rpc-names] FAIL: {msg}") +def read_text(path): + with open(path, encoding="utf-8") as f: + return f.read() + + def frontend_sources(): for dirpath, dirnames, filenames in os.walk(FRONTEND_SRC): if "__tests__" in dirpath or os.sep + "proto" in dirpath[len(FRONTEND_SRC):]: @@ -73,7 +78,7 @@ def sent_names(): sent = {} unresolved = [] for path in frontend_sources(): - text = open(path, encoding="utf-8").read() + text = read_text(path) rel = os.path.relpath(path, ROOT) for m in CALL_RE.finditer(text): sent.setdefault(m.group(1), []).append(f"{rel}:{text.count(chr(10), 0, m.start()) + 1}") @@ -84,7 +89,7 @@ def sent_names(): sent.setdefault(const.group(1), []).append(where) else: unresolved.append(f"{where} ({m.group(1)})") - html = open(INDEX_HTML, encoding="utf-8").read() + html = read_text(INDEX_HTML) for m in HTML_RE.finditer(html): sent.setdefault(m.group(1), []).append( f"{os.path.relpath(INDEX_HTML, ROOT)}:{html.count(chr(10), 0, m.start()) + 1}" @@ -98,7 +103,7 @@ def sent_names(): def handled_names(): - text = open(KOTLIN_BRIDGE, encoding="utf-8").read() + text = read_text(KOTLIN_BRIDGE) fn = text.find("fun handleBinaryRpcInternal(") if fn < 0: fail(f"{os.path.relpath(KOTLIN_BRIDGE, ROOT)}: handleBinaryRpcInternal not found") @@ -130,7 +135,7 @@ def handled_names(): def installed_bridge_keys(): """The keys of the object literal `index.html` assigns to `window.DsmBridge`.""" - html = open(INDEX_HTML, encoding="utf-8").read() + html = read_text(INDEX_HTML) start = html.find("window.DsmBridge = {") if start < 0: fail("index.html does not install `window.DsmBridge = {`") @@ -150,7 +155,7 @@ def installed_bridge_keys(): def bridge_type_members(): - text = open(BRIDGE_TYPES, encoding="utf-8").read() + text = read_text(BRIDGE_TYPES) start = text.find("export interface AndroidBridgeV3 {") if start < 0: fail("bridgeTypes.ts does not declare AndroidBridgeV3") @@ -164,7 +169,7 @@ def bridge_type_members(): def production_names_callbin(): hits = [] for path in frontend_sources(): - text = open(path, encoding="utf-8").read() + text = read_text(path) for m in re.finditer(r"__callBin", text): hits.append(f"{os.path.relpath(path, ROOT)}:{text.count(chr(10), 0, m.start()) + 1}") return hits