From 55a1412819773aa2d38b255083a3db0e2fff324e Mon Sep 17 00:00:00 2001 From: bheemreddy-samsara Date: Wed, 21 Jan 2026 19:07:55 -0600 Subject: [PATCH] feat(runtime,jest,bridge): add console forwarding and batched state updates Console Forwarding: - Forward console.log/warn/error/info/debug from device to Jest output - Add ConsoleEvent/ConsoleLevel types to BridgeEvents union - Preserve Error details (stack/message) in forwarded output - Fix %d printf placeholder to use Number() for decimals Type Improvements: - Remove type assertions in factory.ts with proper ConsoleEvent types - Add proper generic constraints for Harness.on/off methods - Import shared ConsoleEvent type in jest package Shared Utilities: - Extract batchedUpdate utility to avoid code duplication - Add resetRenderState helper for common state reset pattern - Simplify render/cleanup.ts using shared utilities --- packages/bridge/src/shared.ts | 5 +- packages/bridge/src/shared/console.ts | 8 +++ packages/jest/src/harness.ts | 11 +++ packages/jest/src/index.ts | 79 +++++++++++++++++++++ packages/runtime/src/client/factory.ts | 65 +++++++++++++++++ packages/runtime/src/render/cleanup.ts | 6 +- packages/runtime/src/render/index.ts | 35 +++++---- packages/runtime/src/render/utils.ts | 14 ++++ packages/runtime/src/utils/batchedUpdate.ts | 13 ++++ 9 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 packages/bridge/src/shared/console.ts create mode 100644 packages/runtime/src/render/utils.ts create mode 100644 packages/runtime/src/utils/batchedUpdate.ts diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index d5695c8..3d103f0 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -4,6 +4,7 @@ import type { } from './shared/test-runner.js'; import type { TestCollectorEvents } from './shared/test-collector.js'; import type { BundlerEvents } from './shared/bundler.js'; +import type { ConsoleEvent } from './shared/console.js'; import type { HarnessPlatform } from '@react-native-harness/platforms'; export type FileReference = { @@ -103,6 +104,7 @@ export type { SetupFileBundlingFailedEvent, BundlerEvents, } from './shared/bundler.js'; +export type { ConsoleEvent, ConsoleLevel } from './shared/console.js'; export type DeviceDescriptor = { platform: 'ios' | 'android' | 'vega'; @@ -114,7 +116,8 @@ export type DeviceDescriptor = { export type BridgeEvents = | TestCollectorEvents | TestRunnerEvents - | BundlerEvents; + | BundlerEvents + | ConsoleEvent; export type BridgeEventsMap = { [K in BridgeEvents['type']]: ( diff --git a/packages/bridge/src/shared/console.ts b/packages/bridge/src/shared/console.ts new file mode 100644 index 0000000..d7ad339 --- /dev/null +++ b/packages/bridge/src/shared/console.ts @@ -0,0 +1,8 @@ +export type ConsoleLevel = 'log' | 'warn' | 'error' | 'info' | 'debug'; + +export type ConsoleEvent = { + type: 'console'; + level: ConsoleLevel; + args: string[]; + timestamp: number; +}; diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index e1fd62b..b2b3c06 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -1,6 +1,7 @@ import { getBridgeServer, BridgeServer, + BridgeServerEvents, } from '@react-native-harness/bridge/server'; import { HarnessContext, @@ -31,6 +32,14 @@ export type Harness = { restart: () => Promise; dispose: () => Promise; crashMonitor: CrashMonitor; + on: ( + event: T, + handler: BridgeServerEvents[T] + ) => void; + off: ( + event: T, + handler: BridgeServerEvents[T] + ) => void; }; export const waitForAppReady = async (options: { @@ -205,6 +214,8 @@ const getHarnessInternal = async ( restart, dispose, crashMonitor, + on: (event, handler) => serverBridge.on(event, handler), + off: (event, handler) => serverBridge.off(event, handler), }; }; diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts index b087d39..c32c101 100644 --- a/packages/jest/src/index.ts +++ b/packages/jest/src/index.ts @@ -9,6 +9,7 @@ import type { TestWatcher, } from 'jest-runner'; import pLimit from 'p-limit'; +import chalk from 'chalk'; import { runHarnessTestFile } from './run.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; import { type Harness } from './harness.js'; @@ -18,6 +19,72 @@ import { HarnessError } from '@react-native-harness/tools'; import { getErrorMessage } from './logs.js'; import { DeviceNotRespondingError } from '@react-native-harness/bridge/server'; import { NativeCrashError } from './errors.js'; +import { ConsoleEvent } from '@react-native-harness/bridge'; + +// Printf-style string interpolation for console messages +const formatConsoleMessage = (args: string[]): string => { + if (!args || args.length === 0) return ''; + if (args.length === 1) return args[0]; + + let template = String(args[0]); + let argIndex = 1; + + // Replace %s, %d, %i, %o, %O, %j with corresponding arguments + template = template.replace(/%[sdioOj]/g, (match) => { + if (argIndex >= args.length) return match; + const arg = args[argIndex++]; + switch (match) { + case '%s': + return String(arg); + case '%d': + return String(Number(arg)); + case '%i': + return String(parseInt(String(arg), 10)); + case '%o': + case '%O': + case '%j': + return typeof arg === 'string' ? arg : JSON.stringify(arg); + default: + return String(arg); + } + }); + + // Append remaining arguments + const remaining = args.slice(argIndex); + if (remaining.length > 0) { + template += ' ' + remaining.join(' '); + } + + return template; +}; + +// Console event handler - prints console messages from device +const createConsoleEventHandler = (): ((event: ConsoleEvent) => void) => { + return (event: ConsoleEvent) => { + if (event.type === 'console') { + const message = formatConsoleMessage(event.args); + const tags: Record = { + log: chalk.supportsColor + ? chalk.reset.inverse.bold.cyan(' LOG ') + : 'LOG', + warn: chalk.supportsColor + ? chalk.reset.inverse.bold.yellow(' WARN ') + : 'WARN', + error: chalk.supportsColor + ? chalk.reset.inverse.bold.red(' ERROR ') + : 'ERROR', + info: chalk.supportsColor + ? chalk.reset.inverse.bold.blue(' INFO ') + : 'INFO', + debug: chalk.supportsColor + ? chalk.reset.inverse.bold.gray(' DEBUG ') + : 'DEBUG', + }; + const tag = tags[event.level] || tags.log; + process.stderr.write(`${tag} ${message}\n`); + } + }; +}; class CancelRun extends Error { constructor(message?: string) { @@ -47,6 +114,8 @@ export default class JestHarness implements CallbackTestRunnerInterface { throw new Error('Parallel test running is not supported'); } + let consoleHandler: ((event: ConsoleEvent) => void) | null = null; + try { // This is necessary as Harness may throw and we want to catch it and display a helpful error message. await setup(this.#globalConfig); @@ -54,6 +123,12 @@ export default class JestHarness implements CallbackTestRunnerInterface { const harness = global.HARNESS; const harnessConfig = global.HARNESS_CONFIG; + // Setup console forwarding if not in silent mode + if (!this.#globalConfig.silent) { + consoleHandler = createConsoleEventHandler(); + harness.on('event', consoleHandler); + } + return await this._createInBandTestRun( tests, watcher, @@ -71,6 +146,10 @@ export default class JestHarness implements CallbackTestRunnerInterface { throw error; } finally { + // Cleanup console handler + if (consoleHandler && global.HARNESS) { + global.HARNESS.off('event', consoleHandler); + } // This is necessary as Harness may throw and we want to catch it and display a helpful error message. await teardown(this.#globalConfig); } diff --git a/packages/runtime/src/client/factory.ts b/packages/runtime/src/client/factory.ts index 20726ac..7861928 100644 --- a/packages/runtime/src/client/factory.ts +++ b/packages/runtime/src/client/factory.ts @@ -3,6 +3,8 @@ import type { TestCollectorEvents, BundlerEvents, TestExecutionOptions, + ConsoleLevel, + ConsoleEvent, } from '@react-native-harness/bridge'; import { getBridgeClient } from '@react-native-harness/bridge/client'; import { store } from '../ui/state.js'; @@ -16,6 +18,61 @@ import { setup } from '../render/setup.js'; import { runSetupFiles } from './setup-files.js'; import { setClient } from './store.js'; +type EmitEventFn = (type: ConsoleEvent['type'], data: ConsoleEvent) => void; + +// Console forwarding setup - intercepts console calls and emits events to host +const setupConsoleForwarding = (emitEvent: EmitEventFn): (() => void) => { + const originalConsole: Record = { + log: console.log, + warn: console.warn, + error: console.error, + info: console.info, + debug: console.debug, + }; + + const createForwarder = + (level: ConsoleLevel) => + (...args: unknown[]) => { + // Call original console method + originalConsole[level](...args); + + // Forward to host via bridge + try { + emitEvent('console', { + type: 'console', + level, + args: args.map((arg) => { + try { + if (arg instanceof Error) { + return arg.stack ?? `${arg.name}: ${arg.message}`; + } + if (typeof arg === 'object' && arg !== null) { + return JSON.stringify(arg); + } + return String(arg); + } catch { + return String(arg); + } + }), + timestamp: Date.now(), + }); + } catch { + // Ignore errors during forwarding to avoid infinite loops + } + }; + + console.log = createForwarder('log'); + console.warn = createForwarder('warn'); + console.error = createForwarder('error'); + console.info = createForwarder('info'); + console.debug = createForwarder('debug'); + + // Return cleanup function to restore original console + return () => { + Object.assign(console, originalConsole); + }; +}; + export const getClient = async () => { const client = await getBridgeClient(getWSServer(), { runTests: async () => { @@ -41,6 +98,7 @@ export const getClient = async () => { TestRunnerEvents | TestCollectorEvents | BundlerEvents > | null = null; let bundler: Bundler | null = null; + let cleanupConsole: (() => void) | null = null; try { collector = getTestCollector(); @@ -56,6 +114,11 @@ export const getClient = async () => { client.rpc.emitEvent(event.type, event); }); + // Setup console forwarding to emit console events to host + cleanupConsole = setupConsoleForwarding((type, data) => { + client.rpc.emitEvent(type, data); + }); + await runSetupFiles({ setupFiles: options.setupFiles ?? [], setupFilesAfterEnv: [], @@ -94,6 +157,8 @@ export const getClient = async () => { }); return result; } finally { + // Restore original console + cleanupConsole?.(); collector?.dispose(); runner?.dispose(); events?.clearAllListeners(); diff --git a/packages/runtime/src/render/cleanup.ts b/packages/runtime/src/render/cleanup.ts index f864d8c..e4638d8 100644 --- a/packages/runtime/src/render/cleanup.ts +++ b/packages/runtime/src/render/cleanup.ts @@ -1,7 +1,5 @@ -import { store } from '../ui/state.js'; +import { resetRenderState } from './utils.js'; export const cleanup = (): void => { - store.getState().setRenderedElement(null); - store.getState().setOnLayoutCallback(null); - store.getState().setOnRenderCallback(null); + resetRenderState(); }; diff --git a/packages/runtime/src/render/index.ts b/packages/runtime/src/render/index.ts index 30eb84a..5405836 100644 --- a/packages/runtime/src/render/index.ts +++ b/packages/runtime/src/render/index.ts @@ -1,5 +1,7 @@ import React from 'react'; import { store } from '../ui/state.js'; +import { batchedUpdate } from '../utils/batchedUpdate.js'; +import { resetRenderState } from './utils.js'; import type { RenderResult, RenderOptions } from './types.js'; const wrapElement = ( @@ -20,9 +22,7 @@ export const render = async ( // If an element is already rendered, unmount it first if (store.getState().renderedElement !== null) { - store.getState().setRenderedElement(null); - store.getState().setOnLayoutCallback(null); - store.getState().setOnRenderCallback(null); + resetRenderState(); } // Create a promise that resolves when the element is rendered. @@ -36,15 +36,19 @@ export const render = async ( ); }, timeout); - store.getState().setOnRenderCallback(() => { - clearTimeout(timeoutId); - resolve(); + batchedUpdate(() => { + store.getState().setOnRenderCallback(() => { + clearTimeout(timeoutId); + resolve(); + }); }); }); // Wrap and set the element in state (key is generated automatically) const wrappedElement = wrapElement(element, wrapper); - store.getState().setRenderedElement(wrappedElement); + batchedUpdate(() => { + store.getState().setRenderedElement(wrappedElement); + }); // Wait for useEffect to fire, ensuring all children are committed await renderPromise; @@ -65,14 +69,18 @@ export const render = async ( ); }, timeout); - store.getState().setOnRenderCallback(() => { - clearTimeout(timeoutId); - resolve(); + batchedUpdate(() => { + store.getState().setOnRenderCallback(() => { + clearTimeout(timeoutId); + resolve(); + }); }); }); const wrappedNewElement = wrapElement(newElement, wrapper); - store.getState().updateRenderedElement(wrappedNewElement); + batchedUpdate(() => { + store.getState().updateRenderedElement(wrappedNewElement); + }); // Wait for render await renderPromise; @@ -82,10 +90,7 @@ export const render = async ( if (store.getState().renderedElement === null) { return; } - - store.getState().setRenderedElement(null); - store.getState().setOnLayoutCallback(null); - store.getState().setOnRenderCallback(null); + resetRenderState(); }; return { diff --git a/packages/runtime/src/render/utils.ts b/packages/runtime/src/render/utils.ts new file mode 100644 index 0000000..659252d --- /dev/null +++ b/packages/runtime/src/render/utils.ts @@ -0,0 +1,14 @@ +import { store } from '../ui/state.js'; +import { batchedUpdate } from '../utils/batchedUpdate.js'; + +/** + * Resets render-related state in a batched update. + * Clears the rendered element and associated callbacks. + */ +export const resetRenderState = (): void => { + batchedUpdate(() => { + store.getState().setRenderedElement(null); + store.getState().setOnLayoutCallback(null); + store.getState().setOnRenderCallback(null); + }); +}; diff --git a/packages/runtime/src/utils/batchedUpdate.ts b/packages/runtime/src/utils/batchedUpdate.ts new file mode 100644 index 0000000..48a23c8 --- /dev/null +++ b/packages/runtime/src/utils/batchedUpdate.ts @@ -0,0 +1,13 @@ +import { unstable_batchedUpdates } from 'react-native'; + +/** + * Batches state updates to avoid act() warnings in React Native. + * Falls back to direct execution if unstable_batchedUpdates is unavailable. + */ +export const batchedUpdate = (fn: () => void): void => { + if (unstable_batchedUpdates) { + unstable_batchedUpdates(fn); + } else { + fn(); + } +};